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
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user