feat: no-access is visible per source, and findable (milestone 387 step A3)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m40s
Build images / build-ml (push) Successful in 2m47s
Build images / build-web (push) Successful in 1m35s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
2026-09-09 21:26:48 -04:00
co-authored by Claude Opus 5
parent 7751715b83
commit 6bb18050a4
8 changed files with 282 additions and 4 deletions
+46 -1
View File
@@ -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(
@@ -10,6 +10,7 @@
<div class="fc-health-tip">
<div>Last checked: {{ lastCheckedText }}</div>
<div v-if="nextCheckText">Next check: {{ nextCheckText }}</div>
<div v-if="noAccess" class="fc-health-tip__gated">{{ noAccessText }}</div>
<div v-if="(source.consecutive_failures || 0) > 0">
Failures: {{ source.consecutive_failures }}
</div>
@@ -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));
@@ -48,6 +48,20 @@
<span class="fc-source-row__err-text">{{ source.last_error }}</span>
</v-tooltip>
</v-chip>
<!-- No access (#387 A3). Sits directly after the failure chip and before
the backfill states: a source we can't see is the more useful thing
to say about it than which walk phase it's in, and unlike those it
doesn't resolve on its own. Info-coloured, never error — the walk
worked, the content just isn't ours. -->
<v-chip
v-else-if="source.error_type === 'tier_limited'"
size="x-small" color="info" variant="tonal" label
prepend-icon="mdi-lock-outline"
>{{ source.tier_gated_count ? `${source.tier_gated_count} gated` : 'No access' }}
<v-tooltip activator="parent" location="top" max-width="480">
<span>{{ noAccessTip }}</span>
</v-tooltip>
</v-chip>
<v-chip
v-else-if="source.backfill_state === 'running'"
size="x-small" color="info" variant="tonal" label
@@ -79,6 +93,8 @@
</template>
<script setup>
import { computed } from 'vue'
import SourceActions from './SourceActions.vue'
import SourceHealthDot from './SourceHealthDot.vue'
import { formatRelative } from '../../utils/date.js'
@@ -88,6 +104,19 @@ const props = defineProps({
checking: { type: Boolean, default: false },
warningThreshold: { type: Number, default: 5 },
})
// Says what to DO about it, not just what it is — the action here is the
// operator's subscription, not anything FC can retry. The count is only joined
// in by the list endpoint, so phrase it without one when it's absent rather
// than rendering a fabricated zero.
const noAccessTip = computed(() => {
const n = props.source.tier_gated_count
const what = n
? `The last check skipped ${n} post${n === 1 ? '' : 's'}`
: 'The last check skipped posts'
return `${what} this account can't view. Nothing is broken — your `
+ 'subscription tier does not grant access to them.'
})
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill', 'recover', 'recapture'])
function onToggleEnabled(value) {
@@ -348,6 +348,12 @@ const STATUS_OPTIONS = [
{ title: 'Enabled', value: 'enabled' },
{ title: 'Disabled', value: 'disabled' },
{ title: 'Has errors', value: 'errors' },
// #387 A3: no-access is deliberately its own filter and NOT folded into
// "Has errors" — nothing failed, and it is the only status here whose fix is
// the operator's subscription rather than anything FC can retry. Without a
// filter a gated source is invisible in a long list, since it correctly
// stays out of the failing rollup.
{ title: 'No access', value: 'no_access' },
{ title: 'Stale', value: 'stale' },
]
@@ -506,6 +512,7 @@ function groupMatchesStatus(g, status) {
if (status === 'enabled') return g.sources.some((s) => s.enabled)
if (status === 'disabled') return g.sources.every((s) => !s.enabled)
if (status === 'errors') return g.sources.some((s) => (s.consecutive_failures || 0) > 0)
if (status === 'no_access') return g.sources.some((s) => s.error_type === 'tier_limited')
if (status === 'stale') return g.sources.some((s) => !s.last_checked_at)
return true
}
@@ -20,6 +20,35 @@ describe('ActiveDownloadsPanel', () => {
w.unmount() // clear the 1s elapsed-timer interval
})
// #387 A1: the live payload gained a `gated` count. Shown only when non-zero
// so a healthy run stays uncluttered — a "🔒 0" on every download would be
// noise, and noise is what stops the number being noticed when it matters.
it('ticks the tier-gated count mid-walk when there is one', () => {
const pinia = freshPinia()
useDownloadsStore().activeEvents = [{
id: 1, status: 'running',
started_at: new Date(Date.now() - 65000).toISOString(),
platform: 'patreon', artist_name: 'Alice',
live: { downloaded: 2, skipped: 0, errors: 0, posts: 12, gated: 9 },
}]
const w = mountComponent(ActiveDownloadsPanel, { pinia })
expect(w.text()).toContain('9')
w.unmount()
})
it('omits the gated count when nothing was gated', () => {
const pinia = freshPinia()
useDownloadsStore().activeEvents = [{
id: 1, status: 'running',
started_at: new Date(Date.now() - 65000).toISOString(),
platform: 'patreon', artist_name: 'Alice',
live: { downloaded: 2, skipped: 0, errors: 0, posts: 12, gated: 0 },
}]
const w = mountComponent(ActiveDownloadsPanel, { pinia })
expect(w.find('.fc-active__count--gated').exists()).toBe(false)
w.unmount()
})
it('renders nothing when there is no active work', () => {
const pinia = freshPinia()
useDownloadsStore().activeEvents = []
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import SourceHealthDot from '../../src/components/subscriptions/SourceHealthDot.vue'
import { VTooltipStub, mountComponent } from '../support/mountComponent.js'
// Milestone #387 A3. The dot is the only always-visible signal per source, so
// the grade it picks IS the claim FC makes about that subscription. These pin
// that no-access is graded as its own thing — not as healthy (which hides it)
// and not as a failure (which would send the operator hunting for a break that
// isn't there).
const checked = { last_checked_at: '2026-09-09T12:00:00+00:00' }
function dotClass (w) {
return w.find('.fc-health-dot').classes().join(' ')
}
describe('SourceHealthDot', () => {
it('grades a tier-gated source as no-access, not healthy', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: {
source: { ...checked, consecutive_failures: 0, error_type: 'tier_limited' },
},
})
expect(dotClass(w)).toContain('fc-health-dot--no-access')
expect(dotClass(w)).not.toContain('fc-health-dot--healthy')
})
it('shows the gated count when the list endpoint supplied one', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: {
source: {
...checked, consecutive_failures: 0,
error_type: 'tier_limited', tier_gated_count: 47,
},
},
})
expect(w.text()).toContain('47 posts')
})
it('states the condition without a number when no count was joined in', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: {
source: {
...checked, consecutive_failures: 0,
error_type: 'tier_limited', tier_gated_count: null,
},
},
})
// Absent is not zero: never render "0 posts you don't have access to".
expect(w.text()).not.toContain('0 post')
expect(w.text()).toContain("tier you don't hold")
})
it('a genuinely failing source still grades as a failure, gated or not', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: {
source: { ...checked, consecutive_failures: 9, error_type: 'tier_limited' },
warningThreshold: 5,
},
})
expect(dotClass(w)).toContain('fc-health-dot--critical')
expect(dotClass(w)).not.toContain('fc-health-dot--no-access')
})
it('an unchecked source is still unchecked', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: { source: { last_checked_at: null, consecutive_failures: 0 } },
})
expect(dotClass(w)).toContain('fc-health-dot--unchecked')
})
})
+15 -2
View File
@@ -13,12 +13,25 @@ export function freshPinia () {
return pinia
}
export function mountComponent (Component, { props = {}, pinia } = {}) {
// `stubs` is merged over the defaults. Needed whenever the component under
// test puts content in a NAMED slot of a Vuetify component: leaving those
// unresolved renders default-slot children only, so a named slot (v-tooltip's
// `#activator`, say) silently renders nothing and assertions find an empty
// wrapper rather than failing loudly.
export function mountComponent (Component, { props = {}, pinia, stubs = {} } = {}) {
return mount(Component, {
props,
global: {
plugins: pinia ? [pinia] : [],
stubs: { RouterLink: { template: '<a><slot /></a>' } },
stubs: { RouterLink: { template: '<a><slot /></a>' }, ...stubs },
},
})
}
// Renders both halves of a v-tooltip: the activator (the thing the operator
// actually sees) and the tip body. `props` is passed as an empty object so the
// activator's `v-bind="tipProps"` binds cleanly.
export const VTooltipStub = {
name: 'VTooltip',
template: '<div><slot name="activator" :props="{}" /><slot /></div>',
}
+55
View File
@@ -125,6 +125,61 @@ async def test_list_filters_by_artist(db):
assert len(all_rows) == 2
@pytest.mark.asyncio
async def test_list_joins_tier_gated_count_from_the_latest_event(db):
"""A no-access source carries the count from its most recent walk.
The number lives on the DownloadEvent's run_stats, not on the source, so
`list()` joins it in. Two events are seeded deliberately: the newest must
win, or the row would show a stale figure from a walk where the operator
still held the tier.
"""
from datetime import UTC, datetime, timedelta
from backend.app.models import DownloadEvent
artist = await _artist(db)
svc = SourceService(db)
rec = await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/gated",
)
src = (await db.execute(
select(Source).where(Source.id == rec.id)
)).scalar_one()
src.error_type = "tier_limited"
src.last_checked_at = datetime.now(UTC)
now = datetime.now(UTC)
db.add(DownloadEvent(
source_id=rec.id, status="ok", started_at=now - timedelta(hours=2),
metadata_={"run_stats": {"tier_gated_count": 3}},
))
db.add(DownloadEvent(
source_id=rec.id, status="ok", started_at=now,
metadata_={"run_stats": {"tier_gated_count": 47}},
))
await db.commit()
rows = await svc.list(artist_id=artist.id)
assert rows[0].tier_gated_count == 47
assert rows[0].to_dict()["tier_gated_count"] == 47
@pytest.mark.asyncio
async def test_list_leaves_tier_gated_count_none_for_ordinary_sources(db):
"""The join is scoped to tier-gated sources, so a healthy row reports None
rather than 0 — the UI distinguishes 'no count available' from 'zero
gated', and must not print a fabricated number."""
artist = await _artist(db)
svc = SourceService(db)
await svc.create(
artist_id=artist.id, platform="patreon", url="https://patreon.com/fine",
)
rows = await svc.list(artist_id=artist.id)
assert rows[0].tier_gated_count is None
@pytest.mark.asyncio
async def test_update_changes_fields(db):
artist = await _artist(db)