mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
fix(drift): reconcile the drift ledger on deploy and timestamp its history (#1405)
* fix(drift): reconcile the drift ledger on deploy and timestamp its history
The drift ledger (persisted history + activity timeline) only advanced
when someone clicked re-check on a stack's Drift tab, so the history could
sit indefinitely out of sync with the live status: a stack reading
"drifted" live while its history still said "resolved". Two corrections:
- Deploy and update reconcile the ledger against the just-deployed runtime
(the rollback route re-deploys through deployStack, so it is covered),
resolving what the change fixed and recording what it left.
- Every authoritative reconcile stamps the dossier last-checked time, and
the Drift tab labels its history "checked {time}" so a stale finding
reads as history, not a claim about the live status above it.
Adds the last_drift_check_at column and tests across the ledger reconcile
stamp, reconcileStack, the deploy hook, and the panel.
* fix(drift): stamp last-checked inside the ledger transaction
Move the dossier last-checked stamp into the same transaction as the
finding insert/resolve, so the "checked {time}" the Drift tab shows can
never persist without the ledger update it describes. The stamp still runs
on a no-op authoritative check (a transaction that only stamps), keeping
the history "as of" honest. Adds a test that a failed deploy does not
reconcile the ledger.
This commit is contained in:
@@ -23,6 +23,7 @@ interface DriftReport {
|
||||
parseError?: string;
|
||||
temporal?: { hasBaseline: boolean; sourceChanged: boolean; renderedChanged: boolean };
|
||||
ledger?: Array<{ service: string; kind: string; message: string; detectedAt: number; resolvedAt: number | null }>;
|
||||
lastCheckedAt?: number | null;
|
||||
}
|
||||
|
||||
function report(partial: Partial<DriftReport>): DriftReport {
|
||||
@@ -195,10 +196,11 @@ describe('DriftPanel', () => {
|
||||
expect(temporal).toHaveAttribute('data-temporal', 'matches');
|
||||
});
|
||||
|
||||
it('renders the persisted drift history with open and resolved entries', async () => {
|
||||
it('renders the persisted drift history with open and resolved entries, labelled with when it was checked', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'drifted',
|
||||
findings: [{ kind: 'image-mismatch', service: 'web', detail: 'image differs' }],
|
||||
lastCheckedAt: Date.now(),
|
||||
ledger: [
|
||||
{ service: 'web', kind: 'image-mismatch', message: 'image differs', detectedAt: Date.now(), resolvedAt: null },
|
||||
{ service: 'db', kind: 'service-missing', message: 'db not running', detectedAt: Date.now() - 1000, resolvedAt: Date.now() },
|
||||
@@ -209,5 +211,21 @@ describe('DriftPanel', () => {
|
||||
expect(screen.getByText(/drift history/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('open')).toBeInTheDocument();
|
||||
expect(screen.getByText('resolved')).toBeInTheDocument();
|
||||
// The history is timestamped so a stale row reads as history, not the live status.
|
||||
expect(screen.getByText(/checked/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the last-checked label when the stack has never been reconciled', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
|
||||
status: 'drifted',
|
||||
findings: [{ kind: 'image-mismatch', service: 'web', detail: 'image differs' }],
|
||||
lastCheckedAt: null,
|
||||
ledger: [
|
||||
{ service: 'web', kind: 'image-mismatch', message: 'image differs', detectedAt: Date.now(), resolvedAt: null },
|
||||
],
|
||||
})));
|
||||
render(<DriftPanel stackName="web" />);
|
||||
await screen.findByTestId('drift-status');
|
||||
expect(screen.queryByText(/checked/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,9 @@ interface StackDriftReport {
|
||||
// Optional so a report from an older remote node (no ledger layer) still renders.
|
||||
temporal?: DriftTemporal;
|
||||
ledger?: DriftLedgerEntry[];
|
||||
// When the ledger was last reconciled (re-check, deploy, or background scan); null
|
||||
// if never. The history is "as of" this time, not the live status above it.
|
||||
lastCheckedAt?: number | null;
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
@@ -229,6 +232,10 @@ export default function DriftPanel({ stackName }: { stackName: string }) {
|
||||
const temporal = report?.temporal ? temporalMeta(report.temporal) : null;
|
||||
const TemporalIcon = temporal?.icon;
|
||||
const ledger = report?.ledger ?? [];
|
||||
// The ledger only moves on a reconcile (re-check, deploy, or background scan), so
|
||||
// label the history with when that last happened: a "resolved"/"open" row then
|
||||
// reads as the state at that check, not a claim about the live status above it.
|
||||
const lastChecked = report?.lastCheckedAt != null ? formatTimeAgo(report.lastCheckedAt) : null;
|
||||
const busy = loading || rechecking;
|
||||
|
||||
return (
|
||||
@@ -306,7 +313,12 @@ export default function DriftPanel({ stackName }: { stackName: string }) {
|
||||
|
||||
{ledger.length > 0 && (
|
||||
<section>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5')}>drift history</div>
|
||||
<div className={cn(LABEL_CLASS, 'mb-1.5 flex items-center gap-1.5')}>
|
||||
<span>drift history</span>
|
||||
{lastChecked && (
|
||||
<span className="tracking-normal normal-case text-stat-subtitle/70">· checked {lastChecked}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{ledger.map((e, i) => (
|
||||
<LedgerRow key={`${e.service}-${e.kind}-${e.detectedAt}-${i}`} entry={e} />
|
||||
|
||||
Reference in New Issue
Block a user