mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService';
|
||||
import { CloudBackupService } from '../services/CloudBackupService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { effectiveTier, effectiveVariant } from '../middleware/tierGates';
|
||||
import type { LicenseTier, LicenseVariant } from '../services/license-types';
|
||||
|
||||
@@ -155,9 +154,8 @@ export function buildLocalConfigurationStatus(
|
||||
};
|
||||
}
|
||||
|
||||
// Sits after authGate and before the remote proxy in index.ts so remote-node
|
||||
// requests are transparently forwarded to the target Sencho instance.
|
||||
dashboardRouter.get('/configuration', authMiddleware, (req: Request, res: Response): void => {
|
||||
// All routes below are protected by the global authGate mounted at app.use('/api', authGate)
|
||||
dashboardRouter.get('/configuration', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const userId = req.user?.userId ?? 0;
|
||||
@@ -171,17 +169,17 @@ dashboardRouter.get('/configuration', authMiddleware, (req: Request, res: Respon
|
||||
}
|
||||
});
|
||||
|
||||
dashboardRouter.get('/recent-activity', authMiddleware, (req: Request, res: Response): void => {
|
||||
dashboardRouter.get('/stack-restarts', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const rawLimit = parseInt(String(req.query['limit'] ?? '10'), 10);
|
||||
const limit = isNaN(rawLimit) || rawLimit < 1 ? 10 : Math.min(rawLimit, 50);
|
||||
const rawDays = parseInt(String(req.query['days'] ?? '7'), 10);
|
||||
const days = isNaN(rawDays) || rawDays < 1 ? 7 : Math.min(rawDays, 30);
|
||||
|
||||
const items = db.getNotificationHistory(nodeId, limit);
|
||||
res.json(items);
|
||||
const result: StackRestartSummary[] = db.getStackRestartSummary(nodeId, days);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Failed to fetch recent activity:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch recent activity' });
|
||||
console.error('[Dashboard] Failed to fetch stack restarts:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch stack restarts' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ interface FleetNodeOverview {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
mode?: string;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
stats: {
|
||||
active: number;
|
||||
@@ -87,6 +88,9 @@ interface FleetNodeOverview {
|
||||
disk: { total: number; used: number; free: number; usagePercent: string } | null;
|
||||
} | null;
|
||||
stacks: string[] | null;
|
||||
latency_ms?: number;
|
||||
last_successful_contact?: number | null;
|
||||
pilot_last_seen?: number | null;
|
||||
}
|
||||
|
||||
/** Resolve the version to compare nodes against (latest from GitHub, or gateway fallback). */
|
||||
@@ -154,26 +158,46 @@ async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
} : null,
|
||||
},
|
||||
stacks,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Fleet] Local node ${node.name} error:`, error);
|
||||
return {
|
||||
id: node.id, name: node.name, type: node.type, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise<FleetNodeOverview> {
|
||||
// Pilot-agent nodes: use pilot_last_seen as the contact signal; no HTTP fetch.
|
||||
if (node.mode === 'pilot_agent') {
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
mode: node.mode,
|
||||
status: node.pilot_last_seen ? 'online' : 'offline',
|
||||
stats: null,
|
||||
systemStats: null,
|
||||
stacks: null,
|
||||
last_successful_contact: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null,
|
||||
pilot_last_seen: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!node.api_url || !node.api_token) {
|
||||
return {
|
||||
id: node.id, name: node.name, type: node.type, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const baseUrl = node.api_url.replace(/\/$/, '');
|
||||
const headers = { Authorization: `Bearer ${node.api_token}` };
|
||||
const t0 = Date.now();
|
||||
|
||||
try {
|
||||
const [statsRes, systemStatsRes, stacksRes] = await Promise.allSettled([
|
||||
@@ -206,20 +230,34 @@ async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
} : null,
|
||||
} : null;
|
||||
|
||||
const completedAt = Date.now();
|
||||
const latency_ms = completedAt - t0;
|
||||
const isOnline = !!(stats || systemStats);
|
||||
|
||||
if (isOnline) {
|
||||
db.updateNodeLastContact(node.id);
|
||||
}
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
status: stats || systemStats ? 'online' : 'offline',
|
||||
mode: node.mode,
|
||||
status: isOnline ? 'online' : 'offline',
|
||||
stats,
|
||||
systemStats,
|
||||
stacks,
|
||||
latency_ms,
|
||||
last_successful_contact: isOnline
|
||||
? Math.floor(completedAt / 1000)
|
||||
: node.last_successful_contact ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Fleet] Remote node ${node.name} error:`, error);
|
||||
return {
|
||||
id: node.id, name: node.name, type: node.type, status: 'offline',
|
||||
id: node.id, name: node.name, type: node.type, mode: node.mode, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -287,7 +325,7 @@ fleetRouter.get('/overview', authMiddleware, async (_req: Request, res: Response
|
||||
const results = await Promise.allSettled(
|
||||
nodes.map(async (node): Promise<FleetNodeOverview> => {
|
||||
if (node.type === 'remote') {
|
||||
return fetchRemoteNodeOverview(node);
|
||||
return fetchRemoteNodeOverview(node, db);
|
||||
}
|
||||
return fetchLocalNodeOverview(node);
|
||||
}),
|
||||
|
||||
@@ -71,6 +71,15 @@ export interface Node {
|
||||
api_token?: string;
|
||||
pilot_last_seen?: number | null;
|
||||
pilot_agent_version?: string | null;
|
||||
last_successful_contact?: number | null;
|
||||
}
|
||||
|
||||
export interface StackRestartSummary {
|
||||
stackName: string;
|
||||
crash: number;
|
||||
autoheal: number;
|
||||
manual: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PilotEnrollment {
|
||||
@@ -577,6 +586,7 @@ export class DatabaseService {
|
||||
this.migrateMeshTables();
|
||||
this.migrateNodeLabels();
|
||||
this.migrateBlueprints();
|
||||
this.migrateAddNodeLastContact();
|
||||
|
||||
// Reset the cache once at end of constructor in case any migration
|
||||
// populated it via getGlobalSettings() and a subsequent migration
|
||||
@@ -1397,6 +1407,10 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private migrateAddNodeLastContact(): void {
|
||||
this.tryAddColumn('nodes', 'last_successful_contact', 'INTEGER');
|
||||
}
|
||||
|
||||
// --- Sencho Mesh ---
|
||||
|
||||
public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> {
|
||||
@@ -1789,6 +1803,25 @@ export class DatabaseService {
|
||||
this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
|
||||
}
|
||||
|
||||
public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
|
||||
const since = Date.now() - days * 86400 * 1000;
|
||||
return this.db.prepare(`
|
||||
SELECT
|
||||
stack_name AS stackName,
|
||||
SUM(CASE WHEN category = 'deploy_failure' THEN 1 ELSE 0 END) AS crash,
|
||||
SUM(CASE WHEN category = 'autoheal_triggered' THEN 1 ELSE 0 END) AS autoheal,
|
||||
SUM(CASE WHEN category = 'stack_restarted' THEN 1 ELSE 0 END) AS manual,
|
||||
COUNT(*) AS total
|
||||
FROM notification_history
|
||||
WHERE node_id = ?
|
||||
AND timestamp >= ?
|
||||
AND category IN ('deploy_failure', 'autoheal_triggered', 'stack_restarted')
|
||||
AND stack_name IS NOT NULL
|
||||
GROUP BY stack_name
|
||||
ORDER BY total DESC
|
||||
`).all(nodeId, since) as StackRestartSummary[];
|
||||
}
|
||||
|
||||
// --- Container Metrics ---
|
||||
|
||||
public addContainerMetric(metric: Omit<any, 'id'>): void {
|
||||
@@ -1854,11 +1887,12 @@ export class DatabaseService {
|
||||
api_token: row.api_token ? crypto.decrypt(row.api_token) : '',
|
||||
pilot_last_seen: row.pilot_last_seen ?? null,
|
||||
pilot_agent_version: row.pilot_agent_version ?? null,
|
||||
last_successful_contact: row.last_successful_contact ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly NODE_COLUMNS =
|
||||
'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version';
|
||||
'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact';
|
||||
|
||||
public getNodes(): Node[] {
|
||||
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes ORDER BY is_default DESC, name ASC`);
|
||||
@@ -1952,6 +1986,11 @@ export class DatabaseService {
|
||||
this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
|
||||
}
|
||||
|
||||
public updateNodeLastContact(nodeId: number): void {
|
||||
this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?')
|
||||
.run(Math.floor(Date.now() / 1000), nodeId);
|
||||
}
|
||||
|
||||
// --- Pilot enrollments ---
|
||||
|
||||
public getPilotEnrollment(nodeId: number): PilotEnrollment | undefined {
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
ResourceGauges,
|
||||
StackHealthTable,
|
||||
ConfigurationStatus,
|
||||
RecentActivity,
|
||||
RecentAlerts,
|
||||
useDashboardData,
|
||||
} from './dashboard';
|
||||
import { DashboardActivityCard } from './dashboard/DashboardActivityCard';
|
||||
|
||||
interface HomeDashboardProps {
|
||||
onNavigateToStack?: (stackFile: string) => void;
|
||||
@@ -53,7 +53,7 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ConfigurationStatus onOpenSection={onOpenSettingsSection} />
|
||||
<RecentActivity />
|
||||
<DashboardActivityCard />
|
||||
</div>
|
||||
|
||||
<RecentAlerts
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { FleetHeartbeat } from './FleetHeartbeat';
|
||||
import { StackRestartMap } from './StackRestartMap';
|
||||
|
||||
export function DashboardActivityCard() {
|
||||
const { nodes } = useNodes();
|
||||
const hasRemoteNodes = nodes.some(n => n.type === 'remote');
|
||||
|
||||
if (hasRemoteNodes) {
|
||||
return <FleetHeartbeat />;
|
||||
}
|
||||
|
||||
return <StackRestartMap />;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Activity, AlertOctagon, AlertTriangle, Cloud, RefreshCw,
|
||||
RotateCcw, Search, ServerCrash, CheckCircle2, Info,
|
||||
} from 'lucide-react';
|
||||
import { useRecentActivity } from './useRecentActivity';
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
type IconType = typeof Activity;
|
||||
|
||||
const CATEGORY_ICONS: Record<string, { icon: IconType; className: string }> = {
|
||||
deploy_failure: { icon: ServerCrash, className: 'text-destructive' },
|
||||
deploy_success: { icon: CheckCircle2, className: 'text-success' },
|
||||
image_update_applied: { icon: RefreshCw, className: 'text-brand' },
|
||||
image_update_available: { icon: RefreshCw, className: 'text-warning' },
|
||||
auto_heal_restarted: { icon: RotateCcw, className: 'text-brand' },
|
||||
auto_heal_failed: { icon: AlertOctagon, className: 'text-destructive' },
|
||||
auto_heal_policy_disabled: { icon: AlertTriangle, className: 'text-warning' },
|
||||
scan_finding: { icon: Search, className: 'text-warning' },
|
||||
cloud_backup_success: { icon: Cloud, className: 'text-success' },
|
||||
cloud_backup_failed: { icon: Cloud, className: 'text-destructive' },
|
||||
};
|
||||
|
||||
const LEVEL_ICONS: Record<string, { icon: IconType; className: string }> = {
|
||||
info: { icon: Info, className: 'text-stat-subtitle' },
|
||||
warning: { icon: AlertTriangle, className: 'text-warning' },
|
||||
error: { icon: AlertOctagon, className: 'text-destructive' },
|
||||
};
|
||||
|
||||
export function RecentActivity() {
|
||||
const { items, loading } = useRecentActivity(10);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 py-1.5 px-1">
|
||||
<div className="h-3.5 w-3.5 rounded-full bg-accent/10 animate-pulse shrink-0" />
|
||||
<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>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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">Recent Activity</CardTitle>
|
||||
<Activity className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{items.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 recent activity.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{items.map(item => {
|
||||
const categoryConfig = item.category ? CATEGORY_ICONS[item.category] : null;
|
||||
const levelConfig = LEVEL_ICONS[item.level] ?? LEVEL_ICONS.info;
|
||||
const { icon: Icon, className } = categoryConfig ?? levelConfig;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-2.5 py-1.5 px-1 rounded-sm hover:bg-accent/5"
|
||||
>
|
||||
<Icon
|
||||
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${className}`}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<span className={`text-xs flex-1 leading-relaxed ${item.is_read ? 'text-stat-subtitle' : 'text-stat-value'}`}>
|
||||
{item.message}
|
||||
</span>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-icon shrink-0 mt-0.5">
|
||||
{formatRelativeTime(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Info, AlertTriangle, AlertOctagon, CheckCircle2, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
import type { NotificationItem } from './types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
|
||||
@@ -21,16 +22,6 @@ const levelConfig: Record<string, { icon: typeof Info; className: string }> = {
|
||||
error: { icon: AlertOctagon, className: 'text-destructive' },
|
||||
};
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
export function RecentAlerts({ notifications, nodes, onCleared }: RecentAlertsProps) {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -115,7 +106,7 @@ export function RecentAlerts({ notifications, nodes, onCleared }: RecentAlertsPr
|
||||
{n.message}
|
||||
</span>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-icon shrink-0">
|
||||
{formatRelativeTime(n.timestamp)}
|
||||
{formatRelativeTime(Math.floor(n.timestamp / 1000))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,14 @@ export { ResourceGauges } from './ResourceGauges';
|
||||
export { StackHealthTable } from './StackHealthTable';
|
||||
export { RecentAlerts } from './RecentAlerts';
|
||||
export { ConfigurationStatus } from './ConfigurationStatus';
|
||||
export { RecentActivity } from './RecentActivity';
|
||||
export { DashboardActivityCard } from './DashboardActivityCard';
|
||||
export { FleetHeartbeat } from './FleetHeartbeat';
|
||||
export { StackRestartMap } from './StackRestartMap';
|
||||
export { useDashboardData } from './useDashboardData';
|
||||
export { useConfigurationStatus } from './useConfigurationStatus';
|
||||
export { useRecentActivity } from './useRecentActivity';
|
||||
export { useFleetHeartbeat } from './useFleetHeartbeat';
|
||||
export { useStackRestartMap } from './useStackRestartMap';
|
||||
export type * from './types';
|
||||
export type { ConfigurationStatus as ConfigurationStatusPayload } from './useConfigurationStatus';
|
||||
export type { ActivityItem } from './useRecentActivity';
|
||||
export type { FleetNodeOverview } from './useFleetHeartbeat';
|
||||
export type { StackRestartSummary } from './useStackRestartMap';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
|
||||
export interface FleetNodeOverview {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
mode?: string;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
stats: {
|
||||
active: number;
|
||||
managed: number;
|
||||
unmanaged: number;
|
||||
exited: number;
|
||||
total: number;
|
||||
} | null;
|
||||
latency_ms?: number;
|
||||
last_successful_contact?: number | null;
|
||||
pilot_last_seen?: number | null;
|
||||
}
|
||||
|
||||
export interface FleetHeartbeatResult {
|
||||
nodes: FleetNodeOverview[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useFleetHeartbeat(): FleetHeartbeatResult {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const nodeIdRef = useRef(nodeId);
|
||||
useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]);
|
||||
|
||||
const [nodes, setNodes] = useState<FleetNodeOverview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/overview', { localOnly: true });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
setError(body.error ?? 'Failed to load fleet overview');
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as FleetNodeOverview[];
|
||||
setNodes(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || 'Failed to load fleet overview');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes([]); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const currentNodeId = nodeId;
|
||||
const guard = () => {
|
||||
if (nodeIdRef.current === currentNodeId) {
|
||||
void fetchOverview();
|
||||
}
|
||||
};
|
||||
guard();
|
||||
return visibilityInterval(guard, 30_000);
|
||||
}, [nodeId, fetchOverview]);
|
||||
|
||||
return { nodes, loading, error };
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
|
||||
export interface ActivityItem {
|
||||
id: number;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
category?: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
is_read: boolean;
|
||||
stack_name?: string;
|
||||
container_name?: string;
|
||||
}
|
||||
|
||||
export function useRecentActivity(limit = 10) {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const nodeIdRef = useRef(nodeId);
|
||||
useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]);
|
||||
|
||||
const [items, setItems] = useState<ActivityItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchActivity = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/dashboard/recent-activity?limit=${limit}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as ActivityItem[];
|
||||
setItems(data);
|
||||
} catch {
|
||||
// Silent; stale data stays
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit]);
|
||||
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setLoading(true);
|
||||
const currentNodeId = nodeId;
|
||||
const guard = () => { if (nodeIdRef.current === currentNodeId) void fetchActivity(); };
|
||||
guard();
|
||||
return visibilityInterval(guard, 30_000);
|
||||
}, [nodeId, fetchActivity]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => void fetchActivity();
|
||||
window.addEventListener('sencho:state-invalidate', handler);
|
||||
return () => window.removeEventListener('sencho:state-invalidate', handler);
|
||||
}, [fetchActivity]);
|
||||
|
||||
return { items, loading };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
|
||||
export interface StackRestartSummary {
|
||||
stackName: string;
|
||||
crash: number;
|
||||
autoheal: number;
|
||||
manual: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface StackRestartMapResult {
|
||||
restarts: StackRestartSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useStackRestartMap(): StackRestartMapResult {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const nodeIdRef = useRef(nodeId);
|
||||
useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]);
|
||||
|
||||
const [restarts, setRestarts] = useState<StackRestartSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRestarts = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/dashboard/stack-restarts?days=7');
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
setError(body.error ?? 'Failed to load restart data');
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as StackRestartSummary[];
|
||||
setRestarts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || 'Failed to load restart data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setRestarts([]); // eslint-disable-line react-hooks/set-state-in-effect
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const currentNodeId = nodeId;
|
||||
const guard = () => {
|
||||
if (nodeIdRef.current === currentNodeId) {
|
||||
void fetchRestarts();
|
||||
}
|
||||
};
|
||||
guard();
|
||||
return visibilityInterval(guard, 300_000);
|
||||
}, [nodeId, fetchRestarts]);
|
||||
|
||||
return { restarts, loading, error };
|
||||
}
|
||||
@@ -19,6 +19,17 @@ export function formatCount(n: number, unit: string): string {
|
||||
return `${n} ${unit}${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/** Format a Unix timestamp (seconds) as a human-readable relative string, e.g. "42s ago", "5m ago". */
|
||||
export function formatRelativeTime(timestampSeconds: number): string {
|
||||
const seconds = Math.floor(Date.now() / 1000 - timestampSeconds);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function visibilityInterval(fn: () => void, ms: number): () => void {
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => { if (interval) return; interval = setInterval(fn, ms); };
|
||||
|
||||
Reference in New Issue
Block a user