Files
sencho/frontend/src/components/dashboard/StackRestartMap.tsx
T
Anso 775fab7d64 feat(dashboard): replace duplicate Recent Activity card with Fleet Heartbeat / Stack Restart Map (#932)
* 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
2026-05-05 15:23:05 -04:00

139 lines
5.2 KiB
TypeScript

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CheckCircle2, RotateCcw } from 'lucide-react';
import { useStackRestartMap } from './useStackRestartMap';
import type { StackRestartSummary } from './useStackRestartMap';
type DominantCategory = 'crash' | 'autoheal' | 'manual';
function dominantCategory(entry: StackRestartSummary): DominantCategory {
if (entry.crash >= entry.autoheal && entry.crash >= entry.manual) return 'crash';
if (entry.autoheal >= entry.manual) return 'autoheal';
return 'manual';
}
const CATEGORY_LABELS: Record<DominantCategory, string> = {
crash: 'crash',
autoheal: 'auto-heal',
manual: 'manual',
};
const CATEGORY_CLASSES: Record<DominantCategory, string> = {
crash: 'border-destructive/30 bg-destructive/10 text-destructive',
autoheal: 'border-success/30 bg-success/10 text-success',
manual: 'border-brand/30 bg-brand/10 text-brand',
};
const CATEGORY_BAR_CLASSES: Record<DominantCategory, string> = {
crash: 'bg-destructive/50',
autoheal: 'bg-success/50',
manual: 'bg-brand/50',
};
function CategoryBadge({ category }: { category: DominantCategory }) {
return (
<span
className={`inline-flex items-center rounded-sm border px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase shrink-0 ${CATEGORY_CLASSES[category]}`}
>
{CATEGORY_LABELS[category]}
</span>
);
}
function SkeletonRow() {
return (
<div className="flex items-center gap-2.5 py-1.5 px-1">
<div className="h-3 w-20 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-8 rounded-sm bg-accent/10 animate-pulse shrink-0" />
</div>
);
}
export function StackRestartMap() {
const { restarts, loading, error } = useStackRestartMap();
if (loading) {
return (
<Card className="bg-card shadow-card-bevel">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-stat-title">Stack Restarts (7d)</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-0.5">
{Array.from({ length: 4 }).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">Stack Restarts (7d)</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-stat-subtitle py-4 text-center">Unable to load restart data.</p>
</CardContent>
</Card>
);
}
const withRestarts = restarts.filter(s => s.total > 0);
const stableCount = restarts.length - withRestarts.length;
const maxTotal = withRestarts.reduce((m, s) => Math.max(m, s.total), 1);
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">Stack Restarts (7d)</CardTitle>
<RotateCcw className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
</div>
</CardHeader>
<CardContent className="pt-0">
{withRestarts.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 restarts in the last 7 days.</span>
</div>
) : (
<div className="space-y-1.5">
{withRestarts.map(entry => {
const category = dominantCategory(entry);
const barWidth = Math.round((entry.total / maxTotal) * 100);
return (
<div key={entry.stackName} className="space-y-0.5 py-0.5 px-1 rounded-sm hover:bg-accent/5">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-stat-value truncate flex-1">
{entry.stackName}
</span>
<CategoryBadge category={category} />
<span className="text-xs font-mono tabular-nums text-stat-subtitle shrink-0 min-w-[2rem] text-right">
{entry.total}
</span>
</div>
<div className="h-1 rounded-full bg-accent/10 overflow-hidden">
<div
className={`h-full rounded-full ${CATEGORY_BAR_CLASSES[category]}`}
style={{ width: `${barWidth}%` }}
/>
</div>
</div>
);
})}
{stableCount > 0 && (
<div className="flex items-center gap-2 py-1.5 px-1 mt-1 border-t border-card-border">
<CheckCircle2 className="h-3.5 w-3.5 text-success shrink-0" strokeWidth={1.5} />
<span className="text-xs text-stat-subtitle">
{stableCount} stack{stableCount !== 1 ? 's' : ''} stable
</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}