Admin IA overhaul, cap-aware autoscaler, optional ml-worker, hardened tag reset #187

Merged
bvandeusen merged 6 commits from dev into main 2026-07-02 18:01:57 -04:00
5 changed files with 224 additions and 71 deletions
Showing only changes of commit dc7fa6eae2 - Show all commits
@@ -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'
+40
View File
@@ -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,
}
})