Better error handling and visual clue on errors

This commit is contained in:
Marcel Hellkamp 2024-06-06 11:13:41 +02:00
parent 70a5034874
commit 4d77c6cf8a
3 changed files with 109 additions and 41 deletions

View File

@ -10,6 +10,7 @@ import { fetchPosts } from '@/sources'
import Card from './components/Card.vue';
import ConfigModal from './components/ConfigModal.vue';
import InfoBar from './components/InfoBar.vue';
import { arrayUnique } from './utils';
const config = ref<Config>();
@ -18,6 +19,9 @@ const pinned = ref<Array<string>>([])
const hidden = ref<Array<string>>([])
const updateInProgress = ref(false)
const statusText = ref<string | undefined>("Initializing ...")
const statusIsError = ref(false)
var updateIntervalHandle: number;
var lastUpdate = 0;
@ -127,10 +131,19 @@ async function updateWall() {
updateInProgress.value = true
try {
allPosts.value = await fetchPosts(cfg)
allPosts.value = await fetchPosts(cfg, progress => {
if (progress.errors.length) {
setStatus(progress.errors.slice(-1)[0].message, true)
} else if (progress.finished < progress.total) {
setStatus(`Updating [${progress.finished}/${progress.total}] ...`)
} else {
setStatus(false)
}
})
console.debug("Update completed")
} catch (e) {
console.warn("Update failed", e)
setStatus(`Update failed: ${e}`)
} finally {
lastUpdate = Date.now()
updateInProgress.value = false;
@ -138,18 +151,29 @@ async function updateWall() {
}
function setStatus(text: string | false, isError?: boolean) {
if (text === false) {
statusText.value = undefined
statusIsError.value = false
} else {
statusText.value = text
statusIsError.value = isError === true
}
}
/**
* Filter and order posts based on real-time criteria (e.g. pinned or hidden posts).
* Most of filtering already happened earlier.
*/
const filteredPosts = computed(() => {
// Copy to make sure those are detected as a reactive dependencies
var posts: Array<Post> = [... allPosts.value]
var posts: Array<Post> = [...allPosts.value]
const pinnedLocal = [...pinned.value]
const hiddenLocal = [...hidden.value]
// Filter hidden posts, authors or domains
posts = posts.filter((p) => !hiddenLocal.some(hide =>
posts = posts.filter((p) => !hiddenLocal.some(hide =>
p.id == hide || p.author?.profile.endsWith(hide)
))
@ -186,7 +210,8 @@ const hideAuthor = (profile: string) => {
const hideDomain = (profile: string) => {
var domain = profile.split("@").pop()
toggle(hidden.value, "@" + domain)
if (domain)
toggle(hidden.value, "@" + domain)
}
const toggleTheme = () => {
@ -204,8 +229,7 @@ const privacyLink = computed(() => {
<template>
<div id="page">
<icon v-show="updateInProgress" icon="spinner" spin
class="position-fixed bottom-0 start-0 m-1 opacity-25 text-muted" />
<header v-if="config?.showInfobar" class="secret-hover" style="cursor: context-menu" data-bs-toggle="modal"
data-bs-target="#configModal" title="Click to edit wall settings">
<span class="text-muted float-end secret">
@ -214,11 +238,16 @@ const privacyLink = computed(() => {
<InfoBar :config="config" />
</header>
<aside id="status-row" class="position-absolute opacity-25">
<Transition>
<icon v-if="statusIsError" icon="triangle-exclamation" class="mx-1" :title="statusText" />
<icon v-else-if="updateInProgress" icon="spinner" spin class="mx-1" />
</Transition>
</aside>
<main>
<div v-if="config === undefined">Initializing ...</div>
<div v-else-if="filteredPosts.length === 0 && updateInProgress">Loading first posts ...</div>
<div v-else-if="filteredPosts.length === 0">Nothing there yet ...</div>
<div v-else v-masonry transition-duration="1s" item-selector=".wall-item" percent-position="true" id="wall">
<div v-if="config && filteredPosts.length > 0" v-masonry transition-duration="1s" item-selector=".wall-item"
percent-position="true" id="wall">
<Card v-masonry-tile class="wall-item secret-hover" v-for="post in filteredPosts" :key="post.id" :post="post"
:config="config">
@ -228,7 +257,7 @@ const privacyLink = computed(() => {
aria-expanded="false">...</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" @click.prevent="pin(post.id)">{{ post.pinned ? "Unpin" : "Pin"
}}</a></li>
}}</a></li>
<li><a class="dropdown-item" href="#" @click.prevent="hide(post.id)">Hide Post</a></li>
<li v-if="post.author?.profile"><a class="dropdown-item" href="#"
@click.prevent="hideAuthor(post.author?.profile)">Hide
@ -247,8 +276,11 @@ const privacyLink = computed(() => {
<ConfigModal v-if="config" v-model="config" id="configModal" />
<footer>
<aside class="opacity-50 text-center">
Status: {{ statusText || "OK" }}
</aside>
<button class="btn btn-link text-muted" @click="toggleTheme(); false">[{{ actualTheme == "dark" ? "Light" : "Dark"
}} mode]</button>
}} mode]</button>
<button class="btn btn-link text-muted" data-bs-toggle="modal" data-bs-target="#configModal">[Customize]</button>
<div>
<a href="https://github.com/defnull/fediwall" target="_blank" class="mx-1 text-muted">Fediwall <span
@ -363,4 +395,14 @@ body {
width: 100%;
}
}
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>

View File

@ -27,8 +27,8 @@ import VueDOMPurifyHTML from 'vue-dompurify-html';
// Register fontawesome icons
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faGear, faSpinner } from '@fortawesome/free-solid-svg-icons'
library.add(faGear, faSpinner)
import { faGear, faSpinner, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
library.add(faGear, faSpinner, faTriangleExclamation)
import App from '@/App.vue'
const app = createApp(App)

View File

@ -5,22 +5,32 @@ import type { faTags } from "@fortawesome/free-solid-svg-icons";
import DOMPurify from 'dompurify'
/**
* Fetch unique posts from all sources (curently only Mastodon is implemented)
* Fetch unique posts from all sources (currently only Mastodon is implemented)
*/
export async function fetchPosts(cfg: Config): Promise<Post[]> {
export type Progress = {
total: number
started: number
finished: number
errors: Error[]
}
export async function fetchPosts(cfg: Config, onProgress: (progress: Progress) => void): Promise<Post[]> {
type Task = () => Promise<MastodonStatus[]>;
let progress: Progress = {total: 0, started: 0, finished: 0, errors: []}
// Group tasks by domain (see below)
const domainTasks: Record<string, Array<Task>> = {}
const addTask = (domain: string, task: Task) => {
progress.total += 1;
(domainTasks[domain] ??= []).push(task)
}
// Load tags from all servers
for (const domain of cfg.servers) {
const query: Record<string, any> = { limit: cfg.limit }
if(cfg.badWords.length) query.none = cfg.badWords.join(",")
if(!cfg.showText) query.only_media = "True"
if (cfg.badWords.length) query.none = cfg.badWords.join(",")
if (!cfg.showText) query.only_media = "True"
for (const tag of cfg.tags) {
addTask(domain, async () => {
return await fetchJson(domain, `api/v1/timelines/tag/${encodeURIComponent(tag)}`, query)
@ -93,14 +103,19 @@ export async function fetchPosts(cfg: Config): Promise<Post[]> {
.map(([domain, tasks]) => {
return async () => {
for (const task of tasks) {
progress.started += 1;
try {
(await task())
.map(status => fixLocalAcct(domain, status))
.filter(status => filterStatus(cfg, status))
.map(status => statusToWallPost(cfg, status))
.forEach(addOrRepacePost)
} catch (err) {
console.warn(`Update task failed for domain ${domain}`, err)
} catch (err: any) {
let error = err instanceof Error ? err : new Error(err?.toString())
progress.errors.push(error)
} finally {
progress.finished += 1
onProgress(progress)
}
}
}
@ -145,34 +160,45 @@ async function fetchJson(domain: string, path: string, query?: Record<string, an
const pairs = Object.entries(query).map(([key, value]) => [key, value.toString()])
url += "?" + new URLSearchParams(pairs).toString()
}
let rs = await fetch(url)
// Auto-retry rate limit errors
let errCount = 0
while (!rs.ok) {
if (errCount++ > 3)
break // Do not retry anymore
let rs: Response;
let json: any;
if (rs.headers.get("X-RateLimit-Remaining") === "0") {
const resetTime = new Date(rs.headers.get("X-RateLimit-Reset") || (new Date().getTime() + 10000)).getTime();
const referenceTime = new Date(rs.headers.get("Date") || new Date()).getTime();
const sleep = Math.max(0, resetTime - referenceTime) + 1000 // 1 second leeway
await new Promise(resolve => setTimeout(resolve, sleep));
} else {
break // Do not retry
}
await new Promise(resolve => setTimeout(resolve, 1000));
// Retry
try {
rs = await fetch(url)
// Auto-retry rate limit errors
let errCount = 0
while (!rs.ok) {
if (errCount++ > 3)
break // Do not retry anymore
if (rs.headers.get("X-RateLimit-Remaining") === "0") {
const resetTime = new Date(rs.headers.get("X-RateLimit-Reset") || (new Date().getTime() + 10000)).getTime();
const referenceTime = new Date(rs.headers.get("Date") || new Date()).getTime();
const sleep = Math.max(0, resetTime - referenceTime) + 1000 // 1 second leeway
await new Promise(resolve => setTimeout(resolve, sleep));
} else {
break // Do not retry
}
// Retry
rs = await fetch(url)
}
json = await rs.json()
} catch (e: any) {
throw new Error(`Failed to fetch ${url} (${e.message || e})`);
}
const json = await rs.json()
if (json.error) {
console.warn(`Fetch error: ${rs.status} ${JSON.stringify(json)}`)
const err = new Error(json.error);
if (!rs.ok || json.error) {
const err = new Error(`Failed to fetch ${url} (${rs.status}: ${json.error || "Unknown reason"})`);
(err as any).status = rs.status;
(err as any).body = json;
throw err;
}
return json
}
@ -218,7 +244,7 @@ const filterStatus = (cfg: Config, status: MastodonStatus) => {
}
/**
* Convert a mastdon status object to a Post.
* Convert a mastodon status object to a Post.
*/
const statusToWallPost = (cfg: Config, status: MastodonStatus): Post => {
const date = new Date(status.created_at)