feat(dashboards): 1600px max-width, richer Downloads filters, needs-attention + sticky headers

Operator-flagged 2026-05-28: the Subscriptions/Downloads dashboards were
full-bleed and thin on filtering. Chosen via AskUserQuestion.

**Layout**
- SubscriptionsView capped at a centered max-width 1600px (covers all
  three subtabs) so rows aren't a mile wide on ultrawide monitors.
- Sticky control headers on both tabs (top: 48px, below the top nav,
  opaque bg) so filters/stat-chips stay reachable while scrolling a
  long list.

**Downloads filters (all four requested)**
- Stat chips are now clickable filters: click Queued/Running/Completed/
  Failed/Skipped to filter the list to that status; the active chip is
  outlined + elevated; re-click clears.
- Free-text search box over the loaded events (artist / platform /
  error substring).
- Artist filter: the filter popover's numeric "Source ID" field is
  replaced with an artist autocomplete (sources.autocompleteArtist →
  artist_id, which /api/downloads already supports). Pill shows the
  artist name.
- "Show no-change scans" toggle (default OFF): hides status=ok/skipped
  rows with 0 files (the scheduled scans that found nothing) so real
  downloads + failures stand out.

**Subscriptions**
- "Needs attention" quick-filter chip: one click to show only artists
  with sources that have errors OR have never been checked; chip shows
  the count and disables the status dropdown while active.

Frontend-only — backend filter params (status/artist_id/date) and the
/api/downloads endpoint already supported everything.
This commit is contained in:
2026-05-28 08:10:04 -04:00
parent 73520b7cc3
commit a459d21a65
5 changed files with 184 additions and 32 deletions
@@ -1,20 +1,41 @@
<template>
<div>
<div class="fc-dl__top">
<DownloadStatChips :stats="store.stats" />
<span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
<span class="fc-dl__live-dot" />
live
</span>
<v-spacer />
<v-btn variant="text" icon @click="refresh">
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Refresh</v-tooltip>
</v-btn>
<MaintenanceMenu @refresh="refresh" />
</div>
<div class="fc-dl__sticky">
<div class="fc-dl__top">
<DownloadStatChips
:stats="store.stats"
:active-status="filterModel.status"
@select="onStatusChipSelect"
/>
<span v-if="liveActive" class="fc-dl__live" title="Auto-refreshing while downloads are active">
<span class="fc-dl__live-dot" />
live
</span>
<v-spacer />
<v-btn variant="text" icon @click="refresh">
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Refresh</v-tooltip>
</v-btn>
<MaintenanceMenu @refresh="refresh" />
</div>
<DownloadsFilterPopover v-model="filterModel" class="fc-dl__filter" />
<div class="fc-dl__controls">
<v-text-field
v-model="search"
density="compact" variant="outlined" hide-details clearable
prepend-inner-icon="mdi-magnify"
placeholder="Search artist / platform / error"
style="max-width: 340px"
/>
<DownloadsFilterPopover v-model="filterModel" />
<v-switch
v-model="showNoChange"
label="Show no-change scans"
density="compact" hide-details color="accent"
class="fc-dl__nochange"
/>
</div>
</div>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
{{ String(store.error) }}
@@ -92,6 +113,17 @@ const route = useRoute()
const store = useDownloadsStore()
const filterModel = ref({ ...store.filter })
// Free-text search (client-side over the loaded events) + a toggle to
// hide "no-change" scheduled scans (status=ok/skipped with 0 files) so
// real downloads + failures stand out. Operator-flagged 2026-05-28.
const search = ref('')
const showNoChange = ref(false)
// Clicking a stat chip toggles a status filter (re-click clears it).
function onStatusChipSelect(statusKey) {
filterModel.value = { ...filterModel.value, status: statusKey }
}
// Each group's collapsed state persists across refreshes for the
// lifetime of the SubscriptionsView (operator-friendly default: all
// expanded; collapse what you don't care about right now).
@@ -158,6 +190,21 @@ const filteredEvents = computed(() => {
const toTs = new Date(to).getTime() + 24 * 3600 * 1000 - 1
arr = arr.filter((e) => new Date(e.started_at).getTime() <= toTs)
}
// Hide no-change scheduled scans (succeeded/skipped, downloaded
// nothing) unless the operator opts to show them.
if (!showNoChange.value) {
arr = arr.filter(
(e) => !((e.status === 'ok' || e.status === 'skipped') && !(e.files_count > 0)),
)
}
const q = search.value?.trim().toLowerCase()
if (q) {
arr = arr.filter((e) =>
(e.artist_name || '').toLowerCase().includes(q)
|| (e.platform || '').toLowerCase().includes(q)
|| (e.error || '').toLowerCase().includes(q),
)
}
return arr
})
@@ -207,6 +254,7 @@ watch(filterModel, async (m) => {
await store.applyFilter({
status: m.status,
source_id: m.source_id || null,
artist_id: m.artist_id || null,
from_date: m.from_date,
to_date: m.to_date,
})
@@ -219,12 +267,27 @@ async function openDetail(id) {
</script>
<style scoped>
/* Sticky control header: stat chips + search + filter + toggle stay
pinned below the top nav while the event feed scrolls. Opaque
background so rows don't bleed through. */
.fc-dl__sticky {
position: sticky;
top: 48px;
z-index: 3;
background: rgb(var(--v-theme-background));
padding: 8px 0 10px;
margin-bottom: 4px;
border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.12);
}
.fc-dl__top {
display: flex; gap: 8px; align-items: center;
margin-bottom: 12px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.fc-dl__filter { margin-bottom: 12px; }
.fc-dl__controls {
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.fc-dl__nochange { flex: 0 0 auto; }
.fc-dl__live {
display: inline-flex; align-items: center; gap: 5px;
font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em;