mirror of
https://github.com/defnull/fediwall.git
synced 2024-11-21 23:23:14 +01:00
Better error handling and visual clue on errors
This commit is contained in:
parent
70a5034874
commit
4d77c6cf8a
68
src/App.vue
68
src/App.vue
@ -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>
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user