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

View File

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

View File

@ -5,22 +5,32 @@ import type { faTags } from "@fortawesome/free-solid-svg-icons";
import DOMPurify from 'dompurify' 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[]>; type Task = () => Promise<MastodonStatus[]>;
let progress: Progress = {total: 0, started: 0, finished: 0, errors: []}
// Group tasks by domain (see below) // Group tasks by domain (see below)
const domainTasks: Record<string, Array<Task>> = {} const domainTasks: Record<string, Array<Task>> = {}
const addTask = (domain: string, task: Task) => { const addTask = (domain: string, task: Task) => {
progress.total += 1;
(domainTasks[domain] ??= []).push(task) (domainTasks[domain] ??= []).push(task)
} }
// Load tags from all servers // Load tags from all servers
for (const domain of cfg.servers) { for (const domain of cfg.servers) {
const query: Record<string, any> = { limit: cfg.limit } const query: Record<string, any> = { limit: cfg.limit }
if(cfg.badWords.length) query.none = cfg.badWords.join(",") if (cfg.badWords.length) query.none = cfg.badWords.join(",")
if(!cfg.showText) query.only_media = "True" if (!cfg.showText) query.only_media = "True"
for (const tag of cfg.tags) { for (const tag of cfg.tags) {
addTask(domain, async () => { addTask(domain, async () => {
return await fetchJson(domain, `api/v1/timelines/tag/${encodeURIComponent(tag)}`, query) 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]) => { .map(([domain, tasks]) => {
return async () => { return async () => {
for (const task of tasks) { for (const task of tasks) {
progress.started += 1;
try { try {
(await task()) (await task())
.map(status => fixLocalAcct(domain, status)) .map(status => fixLocalAcct(domain, status))
.filter(status => filterStatus(cfg, status)) .filter(status => filterStatus(cfg, status))
.map(status => statusToWallPost(cfg, status)) .map(status => statusToWallPost(cfg, status))
.forEach(addOrRepacePost) .forEach(addOrRepacePost)
} catch (err) { } catch (err: any) {
console.warn(`Update task failed for domain ${domain}`, err) 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()]) const pairs = Object.entries(query).map(([key, value]) => [key, value.toString()])
url += "?" + new URLSearchParams(pairs).toString() url += "?" + new URLSearchParams(pairs).toString()
} }
let rs = await fetch(url)
// Auto-retry rate limit errors let rs: Response;
let errCount = 0 let json: any;
while (!rs.ok) {
if (errCount++ > 3)
break // Do not retry anymore
if (rs.headers.get("X-RateLimit-Remaining") === "0") { await new Promise(resolve => setTimeout(resolve, 1000));
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 try {
rs = await fetch(url) 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 (!rs.ok || json.error) {
if (json.error) { const err = new Error(`Failed to fetch ${url} (${rs.status}: ${json.error || "Unknown reason"})`);
console.warn(`Fetch error: ${rs.status} ${JSON.stringify(json)}`)
const err = new Error(json.error);
(err as any).status = rs.status; (err as any).status = rs.status;
(err as any).body = json;
throw err; throw err;
} }
return json 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 statusToWallPost = (cfg: Config, status: MastodonStatus): Post => {
const date = new Date(status.created_at) const date = new Date(status.created_at)