mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
775fab7d64
* feat: open security basics, manual fleet ops, and basic fleet management to Community
Realign tier guards to the user-stated philosophy: Community covers
deploy/monitor at scale plus security basics, Skipper adds automation
and advanced fleet management, Admiral keeps enterprise control.
Community now includes:
- Trivy install / uninstall / update from the Settings Hub (admin role)
- CVE suppressions CRUD (admin role; replicates fleet-wide)
- Manual image scan with vuln, secret, and misconfig results
- Stack-config scan, scan comparison
- Manual fleet snapshots: create, list, view, restore, delete
- Per-node Sencho self-update (Check Updates + per-node Update)
- Fleet Overview search, sort, filters, node-card expand, auto-refresh
Stays paid:
- Scan policies with block_on_deploy enforcement (Skipper+)
- SBOM (SPDX, CycloneDX), SARIF export (Skipper+)
- Bulk Update All across the fleet (Skipper+)
- Scheduled snapshot create (now Skipper, was Admiral)
- Trivy auto-update toggle, fleet-wide policy push (Admiral)
The Settings -> Security tab is unhidden by setting the registry tier to
null. The SecuritySection no longer early-returns a PaidGate; the policy
list, Add Policy button, and policy dialogs are wrapped in {isPaid && }.
The Fleet view drops isPaid gates on the Snapshots tab, Check Updates
button, per-node update handlers, OverviewToolbar grid controls, the
NodeCard expand affordance, and the auto-refresh notice. The
NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All
button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid
guards so polling runs for Community; useFleetOverview drops the isPaid
wrap on the filter and sort path.
Backend route guards are flipped per the matrix above. The scheduler
tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch.
Backend test assertions are inverted for the now-Community endpoints
and a positive Skipper-snapshot-task test is added.
Documentation across features/, api-reference/, and operations/ is
updated to reflect the new tier mapping.
* feat: add node last-contact tracking, fleet latency, and stack-restart summary
- DatabaseService: add last_successful_contact column to nodes table via
idempotent migration; expose updateNodeLastContact() and getStackRestartSummary()
methods; include the column in NODE_COLUMNS so getNodes/getNode return it
- fleet.ts: record latency_ms and last_successful_contact on each remote
node overview fetch; pilot-agent nodes surface pilot_last_seen instead;
pass db singleton into fetchRemoteNodeOverview to avoid redundant getInstance calls
- dashboard.ts: replace /recent-activity with /stack-restarts endpoint that
groups notification_history events by stack and category (crash/autoheal/manual)
over a configurable window (default 7 days, max 30)
* refactor(dashboard): remove redundant per-route authMiddleware
All routes under /api/ are covered by the global auth gate in app.ts.
The inline authMiddleware arguments on /configuration and /stack-restarts
were redundant with that gate and inconsistent with every other route in
the file. Remove them and drop the now-unused import.
* refactor(backend): consolidate Date.now(), move SQL aggregation, normalize node row mapping
- Capture a single completedAt timestamp in fetchRemoteNodeOverview to
eliminate two separate Date.now() calls and ensure latency_ms and
last_successful_contact are derived from the same instant
- Inline the redundant contactedAt variable; use completedAt directly
- Move stack-restart aggregation from JS into SQL (GROUP BY stack_name
with CASE/SUM counts), replacing the Map loop in the route handler
- Export StackRestartSummary interface from DatabaseService and remove
the duplicate local definition in dashboard.ts; handler now returns
the query result directly
- Add last_successful_contact normalization in decryptNodeRow, mirroring
the existing pilot_last_seen pattern
- Add authGate reliance comment above dashboardRouter route handlers
* feat(dashboard): replace Recent Activity card with context-aware Fleet Heartbeat / Stack Restart Map
- Multi-node installs (≥1 remote node): shows Fleet Heartbeat — real-time
reachability, latency, and container count per registered node
- Local-only installs: shows Stack Restart Map — 7-day restart frequency
per stack grouped by crash / auto-heal / manual category
- Conditional wrapper (DashboardActivityCard) switches states automatically
when the node list changes, with no page reload required
- Deletes RecentActivity card and hook (duplicated data already in Recent Alerts)
- Extracts formatRelativeTime to frontend/src/lib/utils.ts for reuse
* fix(dashboard): add pilot_last_seen to FleetNodeOverview and use it in getLastSeenLabel
* fix(fleet): expose mode and pilot_last_seen in overview, consolidate formatRelativeTime, drop em dash
- Add `mode` and `pilot_last_seen` (in seconds) to the FleetNodeOverview
interface and to both the pilot-agent and HTTP-proxy return paths in
fetchRemoteNodeOverview so the frontend getLastSeenLabel pilot branch
can fire correctly
- Remove the private formatRelativeTime from RecentAlerts.tsx and use
the shared implementation from lib/utils, converting the millisecond
timestamp at the call site
- Replace the em dash in getLatencyLabel with 'n/a' per project rules
171 lines
6.3 KiB
TypeScript
171 lines
6.3 KiB
TypeScript
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Radio, CheckCircle2 } from 'lucide-react';
|
|
import { formatRelativeTime } from '@/lib/utils';
|
|
import { useFleetHeartbeat } from './useFleetHeartbeat';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import type { FleetNodeOverview } from './useFleetHeartbeat';
|
|
import type { Node } from '@/context/NodeContext';
|
|
|
|
function StatusDot({ status }: { status: 'online' | 'offline' | 'unknown' }) {
|
|
const colorClass =
|
|
status === 'online'
|
|
? 'bg-success'
|
|
: status === 'unknown'
|
|
? 'bg-warning'
|
|
: 'bg-destructive';
|
|
return (
|
|
<span
|
|
className={`inline-block h-2 w-2 rounded-full shrink-0 ${colorClass}`}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
function getLatencyLabel(node: FleetNodeOverview, isPilot: boolean): string | null {
|
|
if (node.type === 'local') return null;
|
|
if (isPilot) return 'n/a';
|
|
if (node.status === 'online' && node.latency_ms !== undefined) return `${node.latency_ms} ms`;
|
|
return null;
|
|
}
|
|
|
|
function getLastSeenLabel(node: FleetNodeOverview): string | null {
|
|
if (node.status === 'online') return null;
|
|
// Pilot-agent nodes use the tunnel heartbeat timestamp
|
|
if (node.mode === 'pilot_agent') {
|
|
if (node.pilot_last_seen) return formatRelativeTime(node.pilot_last_seen);
|
|
return 'never reached';
|
|
}
|
|
// Proxy nodes use the contact timestamp updated by the fleet overview handler
|
|
const contact = node.last_successful_contact;
|
|
if (!contact) return 'never reached';
|
|
return formatRelativeTime(contact);
|
|
}
|
|
|
|
function SkeletonRow() {
|
|
return (
|
|
<div className="flex items-center gap-2.5 py-1.5 px-1">
|
|
<div className="h-2 w-2 rounded-full bg-accent/10 animate-pulse shrink-0" />
|
|
<div className="h-3 w-24 rounded-sm bg-accent/10 animate-pulse" />
|
|
<div className="h-3 flex-1 rounded-sm bg-accent/10 animate-pulse" />
|
|
<div className="h-3 w-12 rounded-sm bg-accent/10 animate-pulse shrink-0" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FleetHeartbeat() {
|
|
const { nodes: overviewNodes, loading, error } = useFleetHeartbeat();
|
|
const { nodes: contextNodes } = useNodes();
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card className="bg-card shadow-card-bevel">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-stat-title">Fleet Heartbeat</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 space-y-0.5">
|
|
{Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} />)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Card className="bg-card shadow-card-bevel">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium text-stat-title">Fleet Heartbeat</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<p className="text-xs text-stat-subtitle py-4 text-center">Unable to load fleet status.</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const unreachableCount = overviewNodes.filter(n => n.status !== 'online').length;
|
|
|
|
const sorted = overviewNodes.slice().sort((a, b) => {
|
|
if (a.type === 'local' && b.type !== 'local') return -1;
|
|
if (b.type === 'local' && a.type !== 'local') return 1;
|
|
return 0;
|
|
});
|
|
|
|
return (
|
|
<Card className="bg-card shadow-card-bevel">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-medium text-stat-title">Fleet Heartbeat</CardTitle>
|
|
<div className="flex items-center gap-1.5">
|
|
<Radio className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
|
|
<span className="text-[10px] font-mono tracking-[0.18em] uppercase text-stat-subtitle">
|
|
{overviewNodes.length} node{overviewNodes.length !== 1 ? 's' : ''}
|
|
{unreachableCount > 0 && (
|
|
<span className="text-destructive"> · {unreachableCount} unreachable</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
{sorted.length === 0 ? (
|
|
<div className="flex items-center justify-center gap-2 py-6 text-stat-subtitle">
|
|
<CheckCircle2 className="h-4 w-4 text-success" strokeWidth={1.5} />
|
|
<span className="text-sm">No nodes registered.</span>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-0.5">
|
|
{sorted.map(node => {
|
|
const ctxNode = contextNodes.find((n: Node) => n.id === node.id);
|
|
const isPilot = ctxNode?.mode === 'pilot_agent';
|
|
const containerText = node.stats
|
|
? `${node.stats.active} container${node.stats.active !== 1 ? 's' : ''}`
|
|
: null;
|
|
const latencyCell = getLatencyLabel(node, isPilot ?? false);
|
|
const lastSeenCell = getLastSeenLabel(node);
|
|
|
|
return (
|
|
<div
|
|
key={node.id}
|
|
className="flex items-center gap-2.5 py-1.5 px-1 rounded-sm hover:bg-accent/5"
|
|
>
|
|
<StatusDot status={node.status} />
|
|
|
|
<span className="text-xs font-mono text-stat-value truncate">
|
|
{node.name}
|
|
</span>
|
|
|
|
{node.type === 'local' && (
|
|
<span className="inline-flex items-center rounded-sm border border-brand/30 bg-brand/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-brand shrink-0">
|
|
local
|
|
</span>
|
|
)}
|
|
|
|
<span className="flex-1" />
|
|
|
|
{containerText && (
|
|
<span className="text-xs font-mono tabular-nums text-stat-subtitle shrink-0">
|
|
{containerText}
|
|
</span>
|
|
)}
|
|
|
|
{latencyCell !== null && (
|
|
<span className="text-xs font-mono tabular-nums text-stat-icon shrink-0 min-w-[3rem] text-right">
|
|
{latencyCell}
|
|
</span>
|
|
)}
|
|
|
|
{lastSeenCell !== null && (
|
|
<span className="text-xs font-mono tabular-nums text-stat-icon shrink-0">
|
|
{lastSeenCell}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|