Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6463,6 +6463,11 @@ components:
additionalProperties: true
description: Free-form host_intelligence_state.snapshot — mirrors collector.Snapshot
collected_at: {type: string, format: date-time}
# Per-category collection freshness (system-os-intelligence v1.3.0 C-09).
# nullable: rows written before migration 0052 have no freshness map.
category_freshness:
nullable: true
allOf: [{$ref: '#/components/schemas/CategoryFreshness'}]

Alert:
type: object
Expand Down Expand Up @@ -6534,6 +6539,29 @@ components:
reason: {type: string, maxLength: 256}
until: {type: string, format: date-time, nullable: true}

FreshnessEntry:
type: object
required: [status, observed_at, attempt_at]
description: |
Per-category collection freshness. status=ok when the category was
observed on the most recent run; status=stale when the run did not
observe it and the last-known-good value was carried forward
(observed_at then points at the last successful observation).
Spec system-host-discovery / system-os-intelligence.
properties:
status: {type: string, enum: [ok, stale]}
observed_at: {type: string, format: date-time}
attempt_at: {type: string, format: date-time}
CategoryFreshness:
type: object
additionalProperties: {$ref: '#/components/schemas/FreshnessEntry'}
description: |
Map of fact category -> freshness. Absent categories were never
observed. Discovery keys: os_release, uname, memory, disk, hostname,
fqdn, selinux, apparmor, firewall. Intelligence keys: users, groups,
ports, interfaces, routes, firewall, packages, services, kernel,
uptime, mounts, config.

HostSystemInfo:
type: object
required: [host_id, collected_at]
Expand Down Expand Up @@ -6574,6 +6602,11 @@ components:
firewall_service: {type: string, nullable: true, description: 'firewalld | ufw | nftables | iptables | empty'}
firewall_status: {type: string, nullable: true}
collected_at: {type: string, format: date-time}
# Per-category collection freshness (system-host-discovery v1.6.0 C-13).
# nullable: rows written before migration 0052 have no freshness map.
category_freshness:
nullable: true
allOf: [{$ref: '#/components/schemas/CategoryFreshness'}]

FleetRuleFailure:
type: object
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4294,6 +4294,7 @@ export interface components {
};
/** Format: date-time */
collected_at: string;
category_freshness?: components["schemas"]["CategoryFreshness"] | null;
};
Alert: {
/** Format: uuid */
Expand Down Expand Up @@ -4364,6 +4365,31 @@ export interface components {
/** Format: date-time */
until?: string | null;
};
/**
* @description Per-category collection freshness. status=ok when the category was
* observed on the most recent run; status=stale when the run did not
* observe it and the last-known-good value was carried forward
* (observed_at then points at the last successful observation).
* Spec system-host-discovery / system-os-intelligence.
*/
FreshnessEntry: {
/** @enum {string} */
status: "ok" | "stale";
/** Format: date-time */
observed_at: string;
/** Format: date-time */
attempt_at: string;
};
/**
* @description Map of fact category -> freshness. Absent categories were never
* observed. Discovery keys: os_release, uname, memory, disk, hostname,
* fqdn, selinux, apparmor, firewall. Intelligence keys: users, groups,
* ports, interfaces, routes, firewall, packages, services, kernel,
* uptime, mounts, config.
*/
CategoryFreshness: {
[key: string]: components["schemas"]["FreshnessEntry"];
};
/**
* @description Result of a Discovery run. Mirrors the host_system_info table
* column-for-column. Spec system-host-discovery.
Expand Down Expand Up @@ -4400,6 +4426,7 @@ export interface components {
firewall_status?: string | null;
/** Format: date-time */
collected_at: string;
category_freshness?: components["schemas"]["CategoryFreshness"] | null;
};
FleetRuleFailure: {
rule_id: string;
Expand Down
62 changes: 59 additions & 3 deletions frontend/src/pages/host-detail/CardSystem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export interface CardSystemHost {
os_version?: string | null;
}

// Per-category collection freshness. Mirrors the API CategoryFreshness map
// (system-host-discovery v1.6.0). status=stale means the value shown was
// carried forward from an earlier successful run — the most recent Discovery
// did not re-observe this category (SSH degraded, sudo denied, probe failed).
export interface CategoryFreshness {
status: 'ok' | 'stale';
observed_at: string;
attempt_at: string;
}

// Subset of HostSystemInfo we render. Mirrors the API schema column
// names; null fields tolerate partial-collection rows (sudo unavailable,
// older snapshots).
Expand All @@ -50,6 +60,7 @@ export interface CardSystemInfo {
firewall_service?: string | null;
firewall_status?: string | null;
collected_at?: string;
category_freshness?: Record<string, CategoryFreshness> | null;
}

interface CardSystemProps {
Expand Down Expand Up @@ -135,6 +146,7 @@ export function CardSystem({ host, intelligenceSnapshot, systemInfo }: CardSyste
{systemInfo?.architecture && (
<div style={subValueStyle}>{systemInfo.architecture}</div>
)}
<StaleNote freshness={systemInfo?.category_freshness} category="os_release" />
</div>,
],
[
Expand All @@ -145,9 +157,10 @@ export function CardSystem({ host, intelligenceSnapshot, systemInfo }: CardSyste
],
[
'FQDN',
<span key="f" style={{ fontFamily: 'var(--ow-font-mono)' }}>
{fqdnDisplay}
</span>,
<div key="f">
<span style={{ fontFamily: 'var(--ow-font-mono)' }}>{fqdnDisplay}</span>
<StaleNote freshness={systemInfo?.category_freshness} category="fqdn" />
</div>,
],
[
'Uptime',
Expand Down Expand Up @@ -205,6 +218,7 @@ export function CardSystem({ host, intelligenceSnapshot, systemInfo }: CardSyste
status={systemInfo?.firewall_status ?? null}
service={systemInfo?.firewall_service ?? null}
/>
<StaleNote freshness={systemInfo?.category_freshness} category="firewall" />
</div>,
],
]}
Expand Down Expand Up @@ -280,6 +294,7 @@ function HardwareSection({ systemInfo }: { systemInfo: CardSystemInfo }) {
) : (
<span style={{ color: 'var(--ow-fg-3)' }}>—</span>
)}
<StaleNote freshness={systemInfo.category_freshness} category="disk" />
</div>,
],
[
Expand All @@ -289,6 +304,7 @@ function HardwareSection({ systemInfo }: { systemInfo: CardSystemInfo }) {
{memGb !== null ? `${memGb} GB` : '—'}
</div>
<div style={subValueStyle}>Live utilization not collected</div>
<StaleNote freshness={systemInfo.category_freshness} category="memory" />
</div>,
],
]}
Expand Down Expand Up @@ -350,6 +366,46 @@ const subValueStyle: React.CSSProperties = {
marginTop: 2,
};

// StaleNote — the honesty marker for "The Eye": when a rendered value was
// carried forward (the last Discovery did not re-observe this category), we
// tell the operator what they're looking at is last-known-good, not a fresh
// reading, and when it was last actually verified. An ok/absent category
// renders nothing (a fresh reading needs no caveat).
//
// `category` is a host_system_info freshness key: os_release, uname, memory,
// disk, hostname, fqdn, selinux, apparmor, firewall.
export function StaleNote({
freshness,
category,
}: {
freshness: Record<string, CategoryFreshness> | null | undefined;
category: string;
}) {
const entry = freshness?.[category];
if (!entry || entry.status !== 'stale') return null;
return (
<div
style={{ ...subValueStyle, color: 'var(--ow-warn)' }}
title={`Last verified ${entry.observed_at}`}
>
Last verified {formatTimeAgo(entry.observed_at)}
</div>
);
}

// formatTimeAgo — compact "Xm/Xh/Xd ago" from an ISO timestamp, for the
// stale-value caveat. Self-contained so CardSystem stays testable.
export function formatTimeAgo(iso: string): string {
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) return 'earlier';
const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}

function NotDiscoveredState({
canWrite,
pending,
Expand Down
59 changes: 58 additions & 1 deletion frontend/tests/pages/host-detail-system-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { CardSystem, type CardSystemHost } from '@/pages/host-detail/CardSystem';
import {
CardSystem,
formatTimeAgo,
type CardSystemHost,
type CardSystemInfo,
} from '@/pages/host-detail/CardSystem';
import { useAuthStore } from '@/store/useAuthStore';

const PAGE_SRC = readFileSync(
Expand Down Expand Up @@ -119,6 +124,58 @@ describe('frontend-host-detail-system-card — behavior', () => {
});
});

describe('frontend-host-detail-system-card — freshness', () => {
// @ac AC-08
test('frontend-host-detail-system-card/AC-08 — stale category shows Last verified caveat; ok/absent shows none', () => {
withWriter();
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString();
const now = new Date().toISOString();

// Stale firewall → caveat present.
const staleInfo: CardSystemInfo = {
firewall_status: 'active',
firewall_service: 'firewalld',
category_freshness: {
firewall: { status: 'stale', observed_at: twoDaysAgo, attempt_at: now },
},
};
const { unmount } = renderWith(
<CardSystem
host={makeHost({ os_family: 'rhel', os_version: '9.2' })}
intelligenceSnapshot={null}
systemInfo={staleInfo}
/>,
);
expect(screen.getByText(/Last verified/i)).toBeInTheDocument();
unmount();

// ok firewall → no caveat.
const freshInfo: CardSystemInfo = {
firewall_status: 'active',
firewall_service: 'firewalld',
category_freshness: {
firewall: { status: 'ok', observed_at: now, attempt_at: now },
},
};
renderWith(
<CardSystem
host={makeHost({ os_family: 'rhel', os_version: '9.2' })}
intelligenceSnapshot={null}
systemInfo={freshInfo}
/>,
);
expect(screen.queryByText(/Last verified/i)).toBeNull();
});

// @ac AC-08
test('frontend-host-detail-system-card/AC-08 — formatTimeAgo buckets to coarse m/h/d ago', () => {
expect(formatTimeAgo(new Date(Date.now() - 2 * 24 * 3600 * 1000).toISOString())).toBe('2d ago');
expect(formatTimeAgo(new Date(Date.now() - 5 * 3600 * 1000).toISOString())).toBe('5h ago');
expect(formatTimeAgo(new Date(Date.now() - 10 * 60 * 1000).toISOString())).toBe('10m ago');
expect(formatTimeAgo(new Date().toISOString())).toBe('just now');
});
});

describe('frontend-host-detail-system-card — structural', () => {
// @ac AC-05
test('frontend-host-detail-system-card/AC-05 — Re-run click mints fresh idempotency key via crypto.randomUUID', () => {
Expand Down
31 changes: 31 additions & 0 deletions internal/db/migrations/0052_intelligence_freshness.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
-- 0052_intelligence_freshness.sql
--
-- Per-category collection freshness for discovery + OS intelligence, Phase 1b of
-- the last-known-good model. The no-clobber merge (0051-era code, spec
-- system-host-discovery v1.5.0 / system-os-intelligence v1.2.0) already keeps a
-- failed probe from blanking good data. This adds the metadata a consumer needs
-- to tell FRESH data from CARRIED-FORWARD data: for each fact category, when it
-- was last observed, when it was last attempted, and the last attempt's status.
--
-- Shape (JSONB, one key per fact category):
-- { "<category>": { "observed_at": <ts>, "attempt_at": <ts>, "status": "ok" | "stale" } }
-- * status "ok" — observed this run; observed_at == attempt_at
-- * status "stale" — not observed this run; observed_at is the last good
-- time, attempt_at is this run (the failed attempt)
-- A category never observed has no key. NULL column = pre-feature row.
--
-- Discovery categories: os_release, uname, memory, disk, hostname, fqdn,
-- selinux, apparmor, firewall.
-- Intelligence categories: users, groups, listening_ports, network_interfaces,
-- routes, firewall_rule_count, packages, services, kernel_release, uptime,
-- mountpoints, config_hashes.
--
-- Spec: system-host-discovery, system-os-intelligence.

-- +goose Up
ALTER TABLE host_system_info ADD COLUMN category_freshness JSONB;
ALTER TABLE host_intelligence_state ADD COLUMN category_freshness JSONB;

-- +goose Down
ALTER TABLE host_intelligence_state DROP COLUMN IF EXISTS category_freshness;
ALTER TABLE host_system_info DROP COLUMN IF EXISTS category_freshness;
Loading
Loading