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) }}
|
{{ String(store.error) }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<FailingSourcesCard
|
<!-- The failing-sources rollup moved to the Subscriptions landing tab
|
||||||
:sources="store.failing"
|
(needs-attention strip, 2026-07-02); the maintenance menu above keeps
|
||||||
:retrying-ids="sourcesStore.checkingIds"
|
its bulk-retry via the same shared store action. -->
|
||||||
:retrying-all="retryingAll"
|
|
||||||
@retry="onRetrySource"
|
|
||||||
@retry-all="onRetryAll"
|
|
||||||
@view-logs="onViewFailingLogs"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
||||||
<v-progress-circular indeterminate color="accent" size="36" />
|
<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 { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useDownloadsStore } from '../../stores/downloads.js'
|
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||||
import { useSourcesStore } from '../../stores/sources.js'
|
|
||||||
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
|
import DownloadEventRow from '../downloads/DownloadEventRow.vue'
|
||||||
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
|
import DownloadDetailModal from '../downloads/DownloadDetailModal.vue'
|
||||||
import DownloadStatChips from './DownloadStatChips.vue'
|
import DownloadStatChips from './DownloadStatChips.vue'
|
||||||
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
|
import DownloadActivitySparkline from './DownloadActivitySparkline.vue'
|
||||||
import FailingSourcesCard from './FailingSourcesCard.vue'
|
|
||||||
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
|
import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue'
|
||||||
import MaintenanceMenu from './MaintenanceMenu.vue'
|
import MaintenanceMenu from './MaintenanceMenu.vue'
|
||||||
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
import DownloadsFilterPopover from './DownloadsFilterPopover.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useDownloadsStore()
|
const store = useDownloadsStore()
|
||||||
const sourcesStore = useSourcesStore()
|
|
||||||
const filterModel = ref({ ...store.filter })
|
const filterModel = ref({ ...store.filter })
|
||||||
const retryingAll = ref(false)
|
|
||||||
|
|
||||||
// Free-text search (client-side over the loaded events) + a toggle to
|
// Free-text search (client-side over the loaded events) + a toggle to
|
||||||
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
|
// 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
|
// rollup + stats so the operator sees it move. Passes force=true so the
|
||||||
// platform cooldown is bypassed — single-source click is an explicit
|
// platform cooldown is bypassed — single-source click is an explicit
|
||||||
// operator override, useful for rapid auth-fix or fixture testing.
|
// operator override, useful for rapid auth-fix or fixture testing.
|
||||||
async function onRetrySource(source) {
|
// Bulk retry for the maintenance menu — the shared store action keeps the
|
||||||
try {
|
// cooldown semantics + tally shape identical to the needs-attention card on
|
||||||
await sourcesStore.checkNow(source.id, { force: true })
|
// the Subscriptions tab. Toast tallies the three outcomes so the operator
|
||||||
toast({ text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' })
|
// can read whether cooldown is the dominant failure mode ("12 deferred
|
||||||
} catch (e) {
|
// (cooldown)" → yes, rate limit is the issue).
|
||||||
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).
|
|
||||||
async function onRetryAll(sources) {
|
async function onRetryAll(sources) {
|
||||||
retryingAll.value = true
|
const t = await store.retryAllFailing(sources)
|
||||||
let ok = 0
|
await store.loadFirst()
|
||||||
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 parts = []
|
const parts = []
|
||||||
if (ok) parts.push(`${ok} queued`)
|
if (t.ok) parts.push(`${t.ok} queued`)
|
||||||
if (deferred) parts.push(`${deferred} deferred (cooldown)`)
|
if (t.deferred) parts.push(`${t.deferred} deferred (cooldown)`)
|
||||||
if (conflict) parts.push(`${conflict} already running`)
|
if (t.conflict) parts.push(`${t.conflict} already running`)
|
||||||
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
|
toast({ text: parts.join(', ') || 'Nothing to retry', type: 'info' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +234,16 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
onUnmounted(stopPolling)
|
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
|
// Client-side date filter on the loaded page (avoids a backend round-trip
|
||||||
// for the date pickers; the existing /api/downloads endpoint can grow
|
// for the date pickers; the existing /api/downloads endpoint can grow
|
||||||
// these as proper query params later if a UX need shows up).
|
// these as proper query params later if a UX need shows up).
|
||||||
@@ -366,22 +334,6 @@ async function openDetail(id) {
|
|||||||
await store.loadOne(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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Renders nothing when everything is healthy — the daily answer to
|
||||||
|
"does anything need me?" should be silence, not an empty card. -->
|
||||||
|
<FailingSourcesCard
|
||||||
|
v-if="failing.length"
|
||||||
|
class="mb-4"
|
||||||
|
:sources="failing"
|
||||||
|
:retrying-ids="sourcesStore.checkingIds"
|
||||||
|
:retrying-all="retryingAll"
|
||||||
|
@retry="onRetry"
|
||||||
|
@retry-all="onRetryAll"
|
||||||
|
@view-logs="onViewLogs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { toast } from '../../utils/toast.js'
|
||||||
|
import FailingSourcesCard from './FailingSourcesCard.vue'
|
||||||
|
import { useDownloadsStore } from '../../stores/downloads.js'
|
||||||
|
import { useSourcesStore } from '../../stores/sources.js'
|
||||||
|
|
||||||
|
// The needs-attention strip on the Subscriptions LANDING tab (2026-07-02):
|
||||||
|
// failing sources used to live below the fold of the Downloads tab, so a
|
||||||
|
// broken subscription was invisible unless you went looking. Retry logic is
|
||||||
|
// shared with the Downloads maintenance menu via the downloads store.
|
||||||
|
const store = useDownloadsStore()
|
||||||
|
const sourcesStore = useSourcesStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const { failing } = storeToRefs(store)
|
||||||
|
const retryingAll = ref(false)
|
||||||
|
let pollId = null
|
||||||
|
|
||||||
|
async function onRetry(source) {
|
||||||
|
try {
|
||||||
|
const res = await store.retrySource(source)
|
||||||
|
toast(res === 'queued'
|
||||||
|
? { text: `Retry queued for ${source.artist_name || source.platform}`, type: 'success' }
|
||||||
|
: { text: 'Already running — check Downloads', type: 'info' })
|
||||||
|
} catch (e) {
|
||||||
|
toast({ text: `Retry failed: ${e?.detail || e?.message || e}`, type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRetryAll(sources) {
|
||||||
|
retryingAll.value = true
|
||||||
|
try {
|
||||||
|
const t = await store.retryAllFailing(sources)
|
||||||
|
const parts = []
|
||||||
|
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' })
|
||||||
|
} finally {
|
||||||
|
retryingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewLogs(source) {
|
||||||
|
// The Downloads tab owns the log/detail surface — deep-link into it
|
||||||
|
// pre-filtered to this source (it watches ?source_id).
|
||||||
|
router.push({ path: '/subscriptions', query: { tab: 'downloads', source_id: source.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadFailing()
|
||||||
|
pollId = setInterval(() => { if (!document.hidden) store.loadFailing() }, 60000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<v-card v-if="arrivals.length" class="mb-4">
|
||||||
|
<CardHeading icon="mdi-new-box" title="Recent arrivals">
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text" size="small" rounded="pill"
|
||||||
|
to="/subscriptions?tab=downloads"
|
||||||
|
>
|
||||||
|
All downloads
|
||||||
|
<v-icon end size="small">mdi-arrow-right</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</CardHeading>
|
||||||
|
<v-card-text class="pt-0">
|
||||||
|
<div
|
||||||
|
v-for="ev in arrivals" :key="ev.id"
|
||||||
|
class="fc-arrival"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
v-if="ev.artist_slug"
|
||||||
|
:to="`/artist/${ev.artist_slug}`"
|
||||||
|
class="fc-arrival__artist"
|
||||||
|
>{{ ev.artist_name || ev.artist_slug }}</router-link>
|
||||||
|
<span v-else class="fc-arrival__artist">{{ ev.artist_name || '—' }}</span>
|
||||||
|
<span class="fc-arrival__meta">
|
||||||
|
{{ ev.platform }} ·
|
||||||
|
{{ ev.files_count ? `${ev.files_count} file(s)` : 'no new files' }}
|
||||||
|
· {{ formatRelative(ev.started_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import CardHeading from '../common/CardHeading.vue'
|
||||||
|
import { formatRelative } from '../../utils/date.js'
|
||||||
|
import { useApi } from '../../composables/useApi.js'
|
||||||
|
|
||||||
|
// "What came in?" — the other half of the landing tab's daily answer
|
||||||
|
// (2026-07-02). Own fetch, own state: the downloads store's event list is
|
||||||
|
// the Downloads tab's FILTERED feed; borrowing it would couple this card to
|
||||||
|
// whatever filter the operator left that tab on.
|
||||||
|
const api = useApi()
|
||||||
|
const arrivals = ref([])
|
||||||
|
let pollId = null
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const events = await api.get('/api/downloads', {
|
||||||
|
params: { status: 'ok', limit: 25 },
|
||||||
|
})
|
||||||
|
// Real arrivals only — scheduled scans that found nothing are noise here.
|
||||||
|
arrivals.value = events.filter((e) => (e.files_count || 0) > 0).slice(0, 6)
|
||||||
|
} catch { /* non-fatal — the card just hides */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
pollId = setInterval(() => { if (!document.hidden) load() }, 60000)
|
||||||
|
})
|
||||||
|
onUnmounted(() => { if (pollId) clearInterval(pollId) })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-arrival {
|
||||||
|
display: flex; align-items: baseline; gap: 10px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.fc-arrival__artist {
|
||||||
|
font-weight: 600; text-decoration: none;
|
||||||
|
color: rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
.fc-arrival__artist:hover { text-decoration: underline; }
|
||||||
|
.fc-arrival__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
|
<SchedulerStatusBar :status="store.scheduleStatus" class="fc-subs__sched" />
|
||||||
|
|
||||||
|
<!-- Daily-use ordering (2026-07-02): what needs me → what came in →
|
||||||
|
the full source list. Both cards render nothing when there's
|
||||||
|
nothing to say. -->
|
||||||
|
<NeedsAttentionCard />
|
||||||
|
<RecentArrivalsCard />
|
||||||
|
|
||||||
<div class="fc-subs__bar">
|
<div class="fc-subs__bar">
|
||||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||||
Add subscription
|
Add subscription
|
||||||
@@ -319,6 +325,8 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useSourcesStore } from '../../stores/sources.js'
|
import { useSourcesStore } from '../../stores/sources.js'
|
||||||
import { usePlatformsStore } from '../../stores/platforms.js'
|
import { usePlatformsStore } from '../../stores/platforms.js'
|
||||||
import { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
|
import NeedsAttentionCard from './NeedsAttentionCard.vue'
|
||||||
|
import RecentArrivalsCard from './RecentArrivalsCard.vue'
|
||||||
import SourceRow from './SourceRow.vue'
|
import SourceRow from './SourceRow.vue'
|
||||||
import SourceCard from './SourceCard.vue'
|
import SourceCard from './SourceCard.vue'
|
||||||
import SourceHealthDot from './SourceHealthDot.vue'
|
import SourceHealthDot from './SourceHealthDot.vue'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
|||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||||
import { useInflightToken } from '../composables/useInflightToken.js'
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
||||||
|
import { useSourcesStore } from './sources.js'
|
||||||
|
|
||||||
export const useDownloadsStore = defineStore('downloads', () => {
|
export const useDownloadsStore = defineStore('downloads', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -106,6 +107,44 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
return failing.value
|
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() {
|
async function loadActive() {
|
||||||
const [running, pending] = await Promise.all([
|
const [running, pending] = await Promise.all([
|
||||||
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
|
api.get('/api/downloads', { params: { status: 'running', limit: 50 } }),
|
||||||
@@ -128,5 +167,6 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
||||||
closeDetail, loadStats,
|
closeDetail, loadStats,
|
||||||
loadActivity, loadFailing, loadActive, recoverStalled,
|
loadActivity, loadFailing, loadActive, recoverStalled,
|
||||||
|
retrySource, retryAllFailing,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user