Files
FabledCurator/frontend/src/components/subscriptions/SourceHealthDot.vue
T
bvandeusenandClaude Opus 5 6bb18050a4
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
feat: no-access is visible per source, and findable (milestone 387 step A3)
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
2026-09-09 21:26:48 -04:00

102 lines
3.3 KiB
Vue

<template>
<v-tooltip location="top" open-delay="200">
<template #activator="{ props: tipProps }">
<span
v-bind="tipProps"
:class="['fc-health-dot', `fc-health-dot--${level}`]"
:aria-label="ariaLabel"
/>
</template>
<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>
<div v-if="source.last_error" class="fc-health-tip__err">
{{ truncatedError }}
</div>
</div>
</v-tooltip>
</template>
<script setup>
import { computed } from 'vue'
import { formatRelative } from '../../utils/date.js'
const props = defineProps({
source: { type: Object, required: true },
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
// 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))
const nextCheckText = computed(() =>
props.source.next_check_at
? formatRelative(props.source.next_check_at, { future: true })
: null,
)
const truncatedError = computed(() => {
const e = props.source.last_error || ''
return e.length > 120 ? e.slice(0, 117) + '…' : e
})
</script>
<style scoped>
.fc-health-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.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)); }
.fc-health-tip {
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));
white-space: pre-wrap;
max-width: 32rem;
}
</style>