Admin IA overhaul, cap-aware autoscaler, optional ml-worker, hardened tag reset #187
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<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">
|
||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||
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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user