diff --git a/frontend/src/components/subscriptions/SourceActions.vue b/frontend/src/components/subscriptions/SourceActions.vue index 94b2a02..ae4751c 100644 --- a/frontend/src/components/subscriptions/SourceActions.vue +++ b/frontend/src/components/subscriptions/SourceActions.vue @@ -34,16 +34,6 @@ - - Preview backfill - - Count what a backfill would download — nothing is downloaded - - props.source.backfill_state === 'running') const recovering = computed(() => !!props.source.backfill_bypass_seen) diff --git a/frontend/src/components/subscriptions/SourceCard.vue b/frontend/src/components/subscriptions/SourceCard.vue index c8f5fb2..0ba7f86 100644 --- a/frontend/src/components/subscriptions/SourceCard.vue +++ b/frontend/src/components/subscriptions/SourceCard.vue @@ -32,7 +32,11 @@ {{ source.consecutive_failures }} err + >{{ source.consecutive_failures }} err + + {{ source.last_error }} + + @@ -76,7 +79,7 @@ const props = defineProps({ checking: { type: Boolean, default: false }, warningThreshold: { type: Number, default: 5 }, }) -const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover', 'preview']) +const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover']) function onToggleEnabled(value) { emit('toggle', { source: props.source, enabled: value }) diff --git a/frontend/src/components/subscriptions/SourceRow.vue b/frontend/src/components/subscriptions/SourceRow.vue index 554e7d9..8f737d5 100644 --- a/frontend/src/components/subscriptions/SourceRow.vue +++ b/frontend/src/components/subscriptions/SourceRow.vue @@ -42,7 +42,12 @@ {{ source.consecutive_failures }} + >{{ source.consecutive_failures }} + + + {{ source.last_error }} + + @@ -83,7 +87,7 @@ const props = defineProps({ checking: { type: Boolean, default: false }, warningThreshold: { type: Number, default: 5 }, }) -const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover', 'preview']) +const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover']) function onToggleEnabled(value) { emit('toggle', { source: props.source, enabled: value }) @@ -127,6 +131,10 @@ function onToggleEnabled(value) { color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6; } +.fc-source-row__err-text { + white-space: pre-wrap; + word-break: break-word; +} .fc-source-row__actions { white-space: nowrap; text-align: right; diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue index fc2892e..83bbb8c 100644 --- a/frontend/src/components/subscriptions/SubscriptionsTab.vue +++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue @@ -54,6 +54,9 @@ Disable all + + Backfill + Delete @@ -82,6 +85,7 @@ item-value="key" v-model="selected" v-model:expanded="expanded" + v-model:sort-by="sortBy" :items-per-page="50" :items-per-page-options="ITEMS_PER_PAGE_OPTIONS" density="comfortable" @@ -89,7 +93,13 @@ @click:row="onRowClick" > @@ -291,7 +322,7 @@ import SourceCard from './SourceCard.vue' import SourceHealthDot from './SourceHealthDot.vue' import SourceFormDialog from './SourceFormDialog.vue' import ArtistCreateDialog from './ArtistCreateDialog.vue' -import PreviewDialog from './PreviewDialog.vue' +import SourceActions from './SourceActions.vue' import PlatformChip from './PlatformChip.vue' import SchedulerStatusBar from './SchedulerStatusBar.vue' import { formatRelative } from '../../utils/date.js' @@ -325,6 +356,12 @@ const statusFilter = ref('all') const needsAttention = ref(false) const expanded = ref([]) const selected = ref([]) +// #3: default to worst-health first, name as the tiebreak (the table header +// stays clickable to re-sort by any column). +const sortBy = ref([ + { key: 'health', order: 'desc' }, + { key: 'name', order: 'asc' }, +]) // Mobile card list drives the same `selected`/`expanded` key arrays the // desktop v-data-table binds, so selection + bulk actions work identically. @@ -342,8 +379,6 @@ const showSourceDialog = ref(false) const editingSource = ref(null) const editingArtist = ref(null) const showArtistDialog = ref(false) -const showPreviewDialog = ref(false) -const previewTarget = ref(null) const artistFilter = computed(() => { const raw = route.query.artist_id @@ -382,7 +417,7 @@ const headers = [ { title: 'Subscription', key: 'name', sortable: true, align: 'start' }, { title: 'Platforms', key: 'platforms', sortable: false, align: 'start', width: 240 }, { title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 90 }, - { title: 'Health', key: 'health', sortable: false, align: 'start', width: 80 }, + { title: 'Health', key: 'health', sortable: true, align: 'start', width: 80 }, { title: 'Last activity',key: 'last_activity', sortable: true, align: 'start', width: 140 }, { title: 'Actions', key: 'actions', sortable: false, align: 'end', width: 200 }, ] @@ -403,10 +438,26 @@ const groups = computed(() => { lastActivity, name: g.artist.name, last_activity: lastActivity ?? '', + // #3: numeric health rank so the Health column is sortable worst-first. + health: healthRank(worstSource, failureThreshold.value), + // #2: when a subscription has exactly one source, surface it on the + // artist row directly (URL + actions) so the common case needs no expand. + singleSource: g.sources.length === 1 ? g.sources[0] : null, } }) }) +// 0 never-checked · 1 ok · 2 warning · 3 failing — higher = worse, so a +// descending sort floats the broken subscriptions to the top. +function healthRank(source, threshold) { + if (!source) return 0 + if (!source.last_checked_at) return 0 + const f = source.consecutive_failures || 0 + if (f === 0) return 1 + if (f < threshold) return 2 + return 3 +} + // A group "needs attention" if any of its sources has accumulated // failures OR has never been checked — the ones worth acting on. function groupNeedsAttention(g) { @@ -595,18 +646,6 @@ async function onRecover(source) { } } -// Plan #708 B4: open the dry-run preview dialog (it self-fetches on open). -function onPreview(source) { - previewTarget.value = source - showPreviewDialog.value = true -} - -// "Start backfill" from inside the preview dialog → arm it + close. -async function onPreviewBackfill(source) { - showPreviewDialog.value = false - await onBackfill(source) -} - async function checkAll(group) { let ok = 0 let conflict = 0 @@ -662,6 +701,28 @@ async function bulkSetEnabled(enabled) { selected.value = [] } +// #5: arm a run-until-done backfill on every enabled source in the selected +// subscriptions (skips ones already backfilling). Patches each row in place. +async function bulkBackfill() { + const sel = resolveSelectedGroups() + let armed = 0 + for (const g of sel) { + for (const s of g.sources) { + if (!s.enabled || s.backfill_state === 'running') continue + try { + await store.startBackfill(s.id, s.artist_id) + armed += 1 + } catch { /* keep going */ } + } + } + toast({ + text: armed ? `Backfill started on ${armed} source${armed === 1 ? '' : 's'}` + : 'Nothing to backfill (already running or disabled)', + type: armed ? 'success' : 'info', + }) + selected.value = [] +} + async function bulkDelete() { const groups = resolveSelectedGroups() const total = groups.reduce((n, g) => n + g.sources.length, 0) @@ -746,6 +807,17 @@ async function bulkDelete() { } .fc-subs__mactions { display: flex; gap: 2px; } .fc-subs__name { font-weight: 600; } +.fc-subs__sub-url { + display: block; + max-width: 460px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.76rem; + color: rgb(var(--v-theme-on-surface-variant)); + text-decoration: none; +} +.fc-subs__sub-url:hover { color: rgb(var(--v-theme-accent)); } .fc-subs__chips { display: flex; flex-wrap: wrap; gap: 4px; }