feat(ia): wave 3 — Subscriptions landing answers 'what needs me, what came in?'
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -52,14 +52,9 @@
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
|
||||
<FailingSourcesCard
|
||||
:sources="store.failing"
|
||||
:retrying-ids="sourcesStore.checkingIds"
|
||||
:retrying-all="retryingAll"
|
||||
@retry="onRetrySource"
|
||||
@retry-all="onRetryAll"
|
||||
@view-logs="onViewFailingLogs"
|
||||
/>
|
||||
<!-- The failing-sources rollup moved to the Subscriptions landing tab
|
||||
(needs-attention strip, 2026-07-02); the maintenance menu above keeps
|
||||
its bulk-retry via the same shared store action. -->
|
||||
|
||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user