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,
}
})