diff --git a/backend/app/services/db_helpers.py b/backend/app/services/db_helpers.py index 992a76d..0a76379 100644 --- a/backend/app/services/db_helpers.py +++ b/backend/app/services/db_helpers.py @@ -20,6 +20,9 @@ from sqlalchemy import Select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from ..models import Source +from .gallery_dl import ErrorType + async def get_or_create[T]( session: AsyncSession, @@ -50,3 +53,31 @@ async def get_or_create[T]( except IntegrityError: await sp.rollback() return (await session.execute(select_stmt)).scalar_one(), False + + +# --- shared Source health predicates ---------------------------------------- +# +# The subscriptions rollup, the front-door status ribbon and the list endpoint +# all have to agree on what "failing" and "no access" MEAN, or the ribbon says +# 3 and the card it links to shows 4. Same reasoning as get_or_create above: +# divergent copies of one predicate are how the drift creeps in. Defined here +# rather than in source_service because scheduler_service needs them too, and +# source_service already imports scheduler_service (the other direction would +# be a cycle). + + +def failing_sources_clause(): + """A source is FAILING when its runs are actually erroring. + + Deliberately not `last_error IS NOT NULL` — a tier-limited source clears + last_error and keeps a chip, and must never be counted as broken. + """ + return Source.consecutive_failures > 0 + + +def no_access_sources_clause(): + """A source we can't see the content of: the walk works, the tier doesn't + grant it (#874 / milestone #387 phase A). Not a failure — kept separate + from failing_sources_clause on purpose, and the two are disjoint because + an informational class only ever rides an otherwise-OK run.""" + return Source.error_type == ErrorType.TIER_LIMITED diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py index adda050..76c0b27 100644 --- a/backend/app/services/scheduler_service.py +++ b/backend/app/services/scheduler_service.py @@ -8,12 +8,13 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..models import AppSetting, Artist, ImportSettings, Source +from .db_helpers import failing_sources_clause, no_access_sources_clause MIN_INTERVAL_SECONDS = 60 MAX_INTERVAL_SECONDS = 86400 @@ -219,10 +220,28 @@ async def scheduler_status(session: AsyncSession) -> dict: cooldowns = await active_platform_cooldowns(session) + # Ingestion health for the front-door ribbon (#387 B3). Counted over ENABLED + # sources rather than the auto_check subset walked above: a source that is + # erroring or paywalled is worth surfacing whether or not a schedule happens + # to poll it. Two scalar COUNTs, not a second pass over `rows`. + # + # Both predicates are the shared ones, so the ribbon and the surfaces it + # links to cannot disagree about what they are counting. + failing_sources = (await session.execute( + select(func.count()).select_from(Source) + .where(Source.enabled.is_(True), failing_sources_clause()) + )).scalar_one() + no_access_sources = (await session.execute( + select(func.count()).select_from(Source) + .where(Source.enabled.is_(True), no_access_sources_clause()) + )).scalar_one() + return { "last_tick_at": last_tick_at, "next_due_at": next_due_at.isoformat() if next_due_at else None, "due_now": due_now, "auto_sources": len(rows), + "failing_sources": failing_sources, + "no_access_sources": no_access_sources, "platform_cooldowns": {p: dt.isoformat() for p, dt in cooldowns.items()}, } diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index e6b22c0..51a2a6b 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -17,6 +17,7 @@ from ..models import ( Post, Source, ) +from .db_helpers import failing_sources_clause from .gallery_dl import ErrorType from .platforms import known_platform_keys from .scheduler_service import compute_next_check_at @@ -250,7 +251,9 @@ class SourceService: stmt = stmt.where(~Source.url.like("sidecar:%")) if failing: # Worst-first so the rollup card surfaces the loudest failures. - stmt = stmt.where(Source.consecutive_failures > 0).order_by( + # Shared clause: the front-door ribbon counts with the same one, so + # it can never report a number this list then contradicts. + stmt = stmt.where(failing_sources_clause()).order_by( Source.consecutive_failures.desc(), Artist.name.asc(), ) else: diff --git a/frontend/src/components/posts/FeedStatusRibbon.vue b/frontend/src/components/posts/FeedStatusRibbon.vue new file mode 100644 index 0000000..9addca0 --- /dev/null +++ b/frontend/src/components/posts/FeedStatusRibbon.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue index 1894e77..b5ef2b2 100644 --- a/frontend/src/components/subscriptions/SubscriptionsTab.vue +++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue @@ -368,7 +368,19 @@ const platformsStore = usePlatformsStore() const importStore = useImportStore() const search = ref('') -const statusFilter = ref('all') +// URL-addressable (#387 B3) so the front-door status ribbon can link straight +// to "the sources this number is about" — a count that lands you on an +// unfiltered list makes the reader do the filtering the ribbon just did. +// Mirrors how artistFilter already reads from route.query below. +const statusFilter = computed({ + get: () => route.query.status || 'all', + set: (v) => { + const q = { ...route.query } + if (!v || v === 'all') delete q.status + else q.status = v + router.replace({ query: q }) + }, +}) const needsAttention = ref(false) const expanded = ref([]) const selected = ref([]) diff --git a/frontend/src/router.js b/frontend/src/router.js index 3de94dd..00bd714 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -35,7 +35,10 @@ const routes = [ // // No stickyChrome: unlike Browse/Gallery/Settings this view has no sticky // sub-header for the nav to butt against, so the nav keeps its normal fade. - { path: '/latest', name: 'latest', component: PostsView, meta: { title: 'Latest', navOrder: 5 } }, + // `props` turns on the ingestion-status ribbon (#387 B3). Only here: inside + // Browse's Posts tab the same view renders without it. + { path: '/latest', name: 'latest', component: PostsView, props: { statusRibbon: true }, + meta: { title: 'Latest', navOrder: 5 } }, // FC-2: image backbone { path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase', navOrder: 10 } }, diff --git a/frontend/src/views/PostsView.vue b/frontend/src/views/PostsView.vue index 30bbb96..ee5b760 100644 --- a/frontend/src/views/PostsView.vue +++ b/frontend/src/views/PostsView.vue @@ -49,6 +49,8 @@