From dc7fa6eae29f26368170c00a15e05726c7515dfa Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 2 Jul 2026 17:52:00 -0400 Subject: [PATCH] =?UTF-8?q?feat(ia):=20wave=203=20=E2=80=94=20Subscription?= =?UTF-8?q?s=20landing=20answers=20'what=20needs=20me,=20what=20came=20in?= =?UTF-8?q?=3F'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily-use reorder of the Subscriptions tab: needs-attention strip first (FailingSourcesCard moves up from below the Downloads fold — a broken subscription was invisible unless you went looking), then a new Recent arrivals card (real downloads only, no-change scans filtered out, artist links), then the source list. Both cards render nothing when there's nothing to say. Retry logic moves into the downloads store (retrySource / retryAllFailing) so the needs-attention card and the Downloads maintenance menu share one implementation — single-retry forces past cooldown, bulk keeps cooldown enforcement, same tally shape. The card's Logs button deep-links into the Downloads tab pre-filtered (?source_id now watched, not just read on mount). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- .../components/subscriptions/DownloadsTab.vue | 94 +++++-------------- .../subscriptions/NeedsAttentionCard.vue | 73 ++++++++++++++ .../subscriptions/RecentArrivalsCard.vue | 80 ++++++++++++++++ .../subscriptions/SubscriptionsTab.vue | 8 ++ frontend/src/stores/downloads.js | 40 ++++++++ 5 files changed, 224 insertions(+), 71 deletions(-) create mode 100644 frontend/src/components/subscriptions/NeedsAttentionCard.vue create mode 100644 frontend/src/components/subscriptions/RecentArrivalsCard.vue diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue index 5c26b07..b73ce10 100644 --- a/frontend/src/components/subscriptions/DownloadsTab.vue +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -52,14 +52,9 @@ {{ String(store.error) }} - +
@@ -124,21 +119,17 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { useDownloadsStore } from '../../stores/downloads.js' -import { useSourcesStore } from '../../stores/sources.js' import DownloadEventRow from '../downloads/DownloadEventRow.vue' import DownloadDetailModal from '../downloads/DownloadDetailModal.vue' import DownloadStatChips from './DownloadStatChips.vue' import DownloadActivitySparkline from './DownloadActivitySparkline.vue' -import FailingSourcesCard from './FailingSourcesCard.vue' import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue' import MaintenanceMenu from './MaintenanceMenu.vue' import DownloadsFilterPopover from './DownloadsFilterPopover.vue' const route = useRoute() const store = useDownloadsStore() -const sourcesStore = useSourcesStore() const filterModel = ref({ ...store.filter }) -const retryingAll = ref(false) // Free-text search (client-side over the loaded events) + a toggle to // hide "no-change" scheduled scans (status=ok/skipped with 0 files) so @@ -175,51 +166,18 @@ async function refresh() { // rollup + stats so the operator sees it move. Passes force=true so the // platform cooldown is bypassed — single-source click is an explicit // operator override, useful for rapid auth-fix or fixture testing. -async function onRetrySource(source) { - try { - await sourcesStore.checkNow(source.id, { force: true }) - toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' }) - } catch (e) { - if (e?.body?.download_event_id) { - toast({ text: 'Already running — see below', type: 'info' }) - } else { - toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' }) - } - } finally { - await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()]) - } -} - -// Bulk retry — leaves cooldown enforcement ON so N failing sources on -// the same platform don't all retry into the rate limit the cooldown is -// preventing. Sources deferred by cooldown will be picked up by the -// next scan tick after the AppSetting expires. Toast tallies the three -// outcomes so the operator can quickly read whether cooldown is the -// dominant failure mode ("12 deferred (cooldown)" → yes, rate limit is -// the issue). +// Bulk retry for the maintenance menu — the shared store action keeps the +// cooldown semantics + tally shape identical to the needs-attention card on +// the Subscriptions tab. Toast tallies the three outcomes so the operator +// can read whether cooldown is the dominant failure mode ("12 deferred +// (cooldown)" → yes, rate limit is the issue). async function onRetryAll(sources) { - retryingAll.value = true - let ok = 0 - let conflict = 0 - let deferred = 0 - try { - for (const s of sources) { - try { - const body = await sourcesStore.checkNow(s.id) - if (body?.status === 'deferred') deferred += 1 - else ok += 1 - } catch (e) { - if (e?.body?.download_event_id) conflict += 1 - } - } - } finally { - retryingAll.value = false - await Promise.all([store.loadFailing(), store.loadStats(24), store.loadFirst()]) - } + const t = await store.retryAllFailing(sources) + await store.loadFirst() const parts = [] - if (ok) parts.push(`${ok} queued`) - if (deferred) parts.push(`${deferred} deferred (cooldown)`) - if (conflict) parts.push(`${conflict} already running`) + if (t.ok) parts.push(`${t.ok} queued`) + if (t.deferred) parts.push(`${t.deferred} deferred (cooldown)`) + if (t.conflict) parts.push(`${t.conflict} already running`) toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' }) } @@ -276,6 +234,16 @@ onMounted(() => { }) onUnmounted(stopPolling) +// The needs-attention card (Subscriptions tab) deep-links here with +// ?source_id= while this tab may ALREADY be mounted (v-window keeps tabs +// alive) — react to the query, not just the mount. +watch(() => route.query.source_id, (v) => { + if (v) { + filterModel.value = { ...filterModel.value, source_id: Number(v) } + refresh() + } +}) + // Client-side date filter on the loaded page (avoids a backend round-trip // for the date pickers; the existing /api/downloads endpoint can grow // these as proper query params later if a UX need shows up). @@ -366,22 +334,6 @@ async function openDetail(id) { await store.loadOne(id) } -async function onViewFailingLogs(source) { - // Find and open the most recent DownloadEvent for this source. - // Reuses the existing DownloadDetailModal — same stdout/stderr/error - // surface the row-click in the events feed shows. - try { - const ev = await store.loadLastForSource(source.id) - if (!ev) { - toast({ - text: `No download events recorded for ${source.artist_name || source.platform} yet.`, - type: 'warning', - }) - } - } catch (e) { - toast({ text: `Failed to load logs: ${e.message}`, type: 'error' }) - } -} diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue index bcadbfb..25ab67e 100644 --- a/frontend/src/components/subscriptions/SubscriptionsTab.vue +++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue @@ -2,6 +2,12 @@
+ + + +
Add subscription @@ -319,6 +325,8 @@ import { useRoute, useRouter } from 'vue-router' import { useSourcesStore } from '../../stores/sources.js' import { usePlatformsStore } from '../../stores/platforms.js' import { useImportStore } from '../../stores/import.js' +import NeedsAttentionCard from './NeedsAttentionCard.vue' +import RecentArrivalsCard from './RecentArrivalsCard.vue' import SourceRow from './SourceRow.vue' import SourceCard from './SourceCard.vue' import SourceHealthDot from './SourceHealthDot.vue' diff --git a/frontend/src/stores/downloads.js b/frontend/src/stores/downloads.js index c78613f..3c83e11 100644 --- a/frontend/src/stores/downloads.js +++ b/frontend/src/stores/downloads.js @@ -3,6 +3,7 @@ import { ref } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' import { useInflightToken } from '../composables/useInflightToken.js' +import { useSourcesStore } from './sources.js' export const useDownloadsStore = defineStore('downloads', () => { const api = useApi() @@ -106,6 +107,44 @@ export const useDownloadsStore = defineStore('downloads', () => { return failing.value } + // --- Failing-source retries (shared by the Subscriptions needs-attention + // card and the Downloads maintenance menu — one implementation for both + // surfaces). Data-only: callers own the toasts, per this store's style. + // A single deliberate retry forces past cooldown; BULK retries keep + // cooldown enforcement ON so N failing sources on one platform don't all + // retry into the very rate limit the cooldown is preventing. + async function retrySource(source) { + const sourcesStore = useSourcesStore() + try { + await sourcesStore.checkNow(source.id, { force: true }) + return 'queued' + } catch (e) { + if (e?.body?.download_event_id) return 'already_running' + throw e + } finally { + await Promise.all([loadFailing(), loadStats(24)]) + } + } + + async function retryAllFailing(sources) { + const sourcesStore = useSourcesStore() + const tally = { ok: 0, deferred: 0, conflict: 0 } + try { + for (const s of sources) { + try { + const body = await sourcesStore.checkNow(s.id) + if (body?.status === 'deferred') tally.deferred += 1 + else tally.ok += 1 + } catch (e) { + if (e?.body?.download_event_id) tally.conflict += 1 + } + } + } finally { + await Promise.all([loadFailing(), loadStats(24)]) + } + return tally + } + async function loadActive() { const [running, pending] = await Promise.all([ api.get('/api/downloads', { params: { status: 'running', limit: 50 } }), @@ -128,5 +167,6 @@ export const useDownloadsStore = defineStore('downloads', () => { loadFirst, loadMore, loadOne, loadLastForSource, applyFilter, closeDetail, loadStats, loadActivity, loadFailing, loadActive, recoverStalled, + retrySource, retryAllFailing, } })