From 6bb18050a475a2d8b08ccdda063ecd520998e855 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 9 Sep 2026 21:26:48 -0400 Subject: [PATCH] feat: no-access is visible per source, and findable (milestone 387 step A3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A3 of milestone 387, completing phase A. A1 made the count true, A2 made it a durable state; this makes it something the operator can see without going looking. Turned out smaller than filed, because A2 revealed why the existing `tier_limited` palette entry in FailingSourcesCard had never rendered: the chip was being cleared by the same successful run that produced it. The colour was already chosen. Where it surfaces: - SourceHealthDot gains a `no-access` grade. Deliberately its own grade rather than folded into healthy (which hides it) or warning (which sends the operator hunting for a break that isn't there). A source with real failures still grades as failing whether or not it is also gated. - SourceRow gets an info-coloured lock chip in the status cell, which was empty for these sources — they have zero failures. Placed ahead of the backfill states: "we can't see this creator" is the more useful thing to say than which walk phase it is in, and unlike those it does not resolve on its own. - A "No access" status filter, deliberately separate from "Has errors". Without it a gated source is invisible in a long list, because it correctly stays out of the failing rollup. Left OUT of NeedsAttentionCard on purpose. That card's only affordance is Retry, and you cannot retry your way into a subscription tier — issue 1285 already gives the real escape hatch, since disabling a source clears its state. Nothing structural needed changing: the card is fed by consecutive_failures > 0, which a tier-limited source never has. The count lives on the download event, not the source, so `list()` joins it in with one DISTINCT ON query — selecting the run_stats sub-object rather than whole metadata blobs, which carry up to 500KB of truncated stdout each. Scoped to tier-gated rows only, so a healthy library issues no extra query at all. Absent stays None rather than 0, and both UI surfaces phrase the state without a number when it is missing instead of printing a fabricated zero. Also covers A1's live gated count, which shipped untested, and extends the mount helper with slot stubs: SourceHealthDot puts the dot in a NAMED slot, and unresolved Vuetify components render default slots only — so those assertions would have found an empty wrapper and passed vacuously. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9 --- backend/app/services/source_service.py | 47 ++++++++++- .../subscriptions/SourceHealthDot.vue | 24 +++++- .../components/subscriptions/SourceRow.vue | 29 +++++++ .../subscriptions/SubscriptionsTab.vue | 7 ++ .../components/activeDownloadsPanel.spec.js | 29 +++++++ .../test/components/sourceHealthDot.spec.js | 78 +++++++++++++++++++ frontend/test/support/mountComponent.js | 17 +++- tests/test_source_service.py | 55 +++++++++++++ 8 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 frontend/test/components/sourceHealthDot.spec.js diff --git a/backend/app/services/source_service.py b/backend/app/services/source_service.py index dcc66d5..e6b22c0 100644 --- a/backend/app/services/source_service.py +++ b/backend/app/services/source_service.py @@ -10,12 +10,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( Artist, + DownloadEvent, ImageProvenance, ImageRecord, ImportSettings, Post, Source, ) +from .gallery_dl import ErrorType from .platforms import known_platform_keys from .scheduler_service import compute_next_check_at @@ -84,6 +86,11 @@ class SourceRecord: # plan #704: cumulative posts processed across the walk's chunks — live # progress for the badge. backfill_posts: int + # Milestone #387 A3: posts the last walk skipped because the account can't + # view them. Lives on the EVENT (run_stats.tier_gated_count), not the + # source, so it is joined in by `list()` only — None everywhere else, which + # the UI renders as the bare no-access state with no fabricated number. + tier_gated_count: int | None = None def to_dict(self) -> dict: return { @@ -107,6 +114,7 @@ class SourceRecord: "backfill_bypass_seen": self.backfill_bypass_seen, "backfill_recapture": self.backfill_recapture, "backfill_posts": self.backfill_posts, + "tier_gated_count": self.tier_gated_count, } @@ -159,8 +167,39 @@ class SourceService: async def _load_settings(self) -> ImportSettings: return await ImportSettings.load(self.session) + async def _tier_gated_counts(self, source_ids: list[int]) -> dict[int, int]: + """Latest walk's tier-gated post count, per source, in ONE query. + + Selects the `run_stats` sub-object rather than whole `metadata` blobs: + those carry truncated stdout/stderr up to 500KB each, and pulling one + per source to read a single integer would make the subscriptions list + pay for the Logs view. DISTINCT ON + ORDER BY takes the newest event per + source (Postgres-only, like the rest of this codebase). + + Callers pass only the sources that actually need it — the count is + meaningless for a source that isn't tier-gated. + """ + if not source_ids: + return {} + rows = (await self.session.execute( + select( + DownloadEvent.source_id, + DownloadEvent.metadata_["run_stats"], + ) + .where(DownloadEvent.source_id.in_(source_ids)) + .distinct(DownloadEvent.source_id) + .order_by(DownloadEvent.source_id, DownloadEvent.started_at.desc()) + )).all() + counts: dict[int, int] = {} + for source_id, run_stats in rows: + n = (run_stats or {}).get("tier_gated_count") or 0 + if n: + counts[source_id] = int(n) + return counts + def _build_record( self, source: Source, artist: Artist, settings: ImportSettings, + gated_counts: dict[int, int] | None = None, ) -> SourceRecord: nxt = compute_next_check_at(source, artist, settings) co = source.config_overrides or {} @@ -185,6 +224,7 @@ class SourceService: backfill_bypass_seen=bool(co.get("_backfill_bypass_seen")), backfill_recapture=bool(co.get("_backfill_recapture")), backfill_posts=int(co.get("_backfill_posts", 0)), + tier_gated_count=(gated_counts or {}).get(source.id), ) async def _row_to_record(self, source: Source) -> SourceRecord: @@ -217,7 +257,12 @@ class SourceService: stmt = stmt.order_by(Artist.name.asc(), Source.id.asc()) rows = (await self.session.execute(stmt)).all() settings = await self._load_settings() - return [self._build_record(s, a, settings) for s, a in rows] + # Only tier-gated rows need the join — on a healthy library that is an + # empty list and _tier_gated_counts short-circuits without a query. + gated_counts = await self._tier_gated_counts( + [s.id for s, _a in rows if s.error_type == ErrorType.TIER_LIMITED] + ) + return [self._build_record(s, a, settings, gated_counts) for s, a in rows] async def get(self, source_id: int) -> SourceRecord | None: source = (await self.session.execute( diff --git a/frontend/src/components/subscriptions/SourceHealthDot.vue b/frontend/src/components/subscriptions/SourceHealthDot.vue index 145044d..b84b7db 100644 --- a/frontend/src/components/subscriptions/SourceHealthDot.vue +++ b/frontend/src/components/subscriptions/SourceHealthDot.vue @@ -10,6 +10,7 @@
Last checked: {{ lastCheckedText }}
Next check: {{ nextCheckText }}
+
{{ noAccessText }}
Failures: {{ source.consecutive_failures }}
@@ -29,14 +30,29 @@ const props = defineProps({ warningThreshold: { type: Number, default: 5 }, }) +const noAccess = computed(() => props.source.error_type === 'tier_limited') + const level = computed(() => { if (!props.source.last_checked_at) return 'unchecked' const f = props.source.consecutive_failures || 0 - if (f === 0) return 'healthy' + // No-access outranks 'healthy' but is NOT a failure grade: the walk worked, + // the content simply isn't ours. Checked after failures so a source that is + // genuinely erroring still reads as erroring. + if (f === 0) return noAccess.value ? 'no-access' : 'healthy' if (f < props.warningThreshold) return 'warning' return 'critical' }) +// The count comes from the last walk's run_stats and is only joined in by the +// list endpoint, so it can legitimately be absent — say the state without it +// rather than printing a fabricated zero. +const noAccessText = computed(() => { + const n = props.source.tier_gated_count + return n + ? `${n} post${n === 1 ? '' : 's'} you don't have access to` + : "Some posts are behind a tier you don't hold" +}) + const ariaLabel = computed(() => `source health: ${level.value}`) const lastCheckedText = computed(() => formatRelative(props.source.last_checked_at)) @@ -63,6 +79,9 @@ const truncatedError = computed(() => { } .fc-health-dot--unchecked { background-color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.5; } .fc-health-dot--healthy { background-color: rgb(var(--v-theme-success, 76 175 80)); } +/* Matches the 'info' severity FailingSourcesCard already assigns tier_limited — + deliberately not a warning/error hue: nothing is broken. */ +.fc-health-dot--no-access { background-color: rgb(var(--v-theme-info, 33 150 243)); } .fc-health-dot--warning { background-color: rgb(var(--v-theme-warning, 255 167 38)); } .fc-health-dot--critical { background-color: rgb(var(--v-theme-error, 244 67 54)); } @@ -70,6 +89,9 @@ const truncatedError = computed(() => { font-size: 0.85rem; line-height: 1.4; } +.fc-health-tip__gated { + color: rgb(var(--v-theme-info, 33 150 243)); +} .fc-health-tip__err { margin-top: 0.25rem; color: rgb(var(--v-theme-error, 244 67 54)); diff --git a/frontend/src/components/subscriptions/SourceRow.vue b/frontend/src/components/subscriptions/SourceRow.vue index 10de1b0..0c287a0 100644 --- a/frontend/src/components/subscriptions/SourceRow.vue +++ b/frontend/src/components/subscriptions/SourceRow.vue @@ -48,6 +48,20 @@ {{ source.last_error }} + + {{ source.tier_gated_count ? `${source.tier_gated_count} gated` : 'No access' }} + + {{ noAccessTip }} + +