mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
feat(dashboard): replace 24h charts with Configuration Status and Recent Activity (#785)
* feat(dashboard): replace 24h charts with Configuration Status and Recent Activity The 24-hour CPU/Memory area charts summed per-container metrics normalized to each container's CPU quota, producing numbers that bore no honest relationship to host load. The live ResourceGauges strip already shows accurate host-level stats, making the historical charts both inaccurate and redundant. This commit replaces that row with two side-by-side cards: - **Configuration Status**: aggregates every toggleable feature on the active node (notification agents, alert rules, routing rules, auto-heal, auto-update, webhooks, scheduled tasks, MFA, SSO, vulnerability scanning, cloud backup, and alert thresholds) into a single at-a-glance card. Tier-locked rows display an upgrade indicator instead of a value. Each row is clickable and navigates to the relevant settings section. Data refreshes every 60 s and immediately on state-invalidate events. - **Recent Activity**: lists the ten most recent notification-history events for the active node (deployments, image updates, auto-heal actions, scan findings, cloud backup events, system notices) with category icons and relative timestamps. Refreshes every 30 s. New backend endpoints: - GET /api/dashboard/configuration - per-node feature status with locked/ requiredTier markers so the frontend renders upgrade chips without extra calls. The endpoint sits after authGate and before the remote proxy so remote-node requests are transparently forwarded. - GET /api/dashboard/recent-activity?limit=N - thin wrapper over DatabaseService.getNotificationHistory. - GET /api/fleet/configuration - fleet-wide fan-out using the same Promise.allSettled dead-node-tolerant pattern as /fleet/overview. Exposed as the new "Status" tab on the Fleet page (after Snapshots). Shared utilities: - visibilityInterval and formatCount extracted to frontend/src/lib/utils.ts so the three polling hooks and two components share a single copy. * docs(dashboard): fix stale alt text referencing removed historical charts
This commit is contained in:
@@ -6,6 +6,7 @@ import TerminalComponent from './Terminal';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import HomeDashboard from './HomeDashboard';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
import type { SectionId } from './settings/types';
|
||||
import BashExecModal from './BashExecModal';
|
||||
import HostConsole from './HostConsole';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
@@ -360,7 +361,7 @@ export default function EditorLayout() {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [tickerConnected, setTickerConnected] = useState(false);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels' | 'nodes'>('account');
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId>('account');
|
||||
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
|
||||
const [alertSheetStack, setAlertSheetStack] = useState('');
|
||||
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(null);
|
||||
@@ -2922,6 +2923,7 @@ export default function EditorLayout() {
|
||||
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
|
||||
notifications={notifications}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
onOpenSettings={(section) => { setSettingsInitialSection(section); setSettingsModalOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { LabelDot } from './LabelPill';
|
||||
import { type Label as StackLabel, type LabelColor } from './label-types';
|
||||
@@ -1041,6 +1042,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="configuration">
|
||||
<TabsTrigger value="configuration">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1337,6 +1343,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="configuration">
|
||||
<FleetConfiguration />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Reconnecting overlay shown when local node is updating */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { NotificationItem } from './dashboard/types';
|
||||
import type { SectionId } from './settings/types';
|
||||
import {
|
||||
HealthStatusBar,
|
||||
ResourceGauges,
|
||||
StackHealthTable,
|
||||
HistoricalCharts,
|
||||
ConfigurationStatus,
|
||||
RecentActivity,
|
||||
RecentAlerts,
|
||||
useDashboardData,
|
||||
} from './dashboard';
|
||||
@@ -13,11 +15,12 @@ interface HomeDashboardProps {
|
||||
onNavigateToStack?: (stackFile: string) => void;
|
||||
notifications: NotificationItem[];
|
||||
onClearNotifications: () => void | Promise<void>;
|
||||
onOpenSettings?: (section: SectionId) => void;
|
||||
}
|
||||
|
||||
const NOOP = () => {};
|
||||
|
||||
export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) {
|
||||
export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications, onOpenSettings }: HomeDashboardProps) {
|
||||
const { activeNode, nodes } = useNodes();
|
||||
const data = useDashboardData();
|
||||
const activeNodeName = activeNode?.name || 'Local';
|
||||
@@ -48,10 +51,10 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea
|
||||
onNavigateToStack={onNavigateToStack ?? NOOP}
|
||||
/>
|
||||
|
||||
<HistoricalCharts
|
||||
metrics={data.metrics}
|
||||
systemStats={data.systemStats}
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ConfigurationStatus onOpenSettings={onOpenSettings} />
|
||||
<RecentActivity />
|
||||
</div>
|
||||
|
||||
<RecentAlerts
|
||||
notifications={notifications}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
|
||||
import { formatCount } from '@/lib/utils';
|
||||
import { useConfigurationStatus } from './useConfigurationStatus';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
|
||||
interface ConfigurationStatusProps {
|
||||
onOpenSettings?: (section: SectionId) => void;
|
||||
}
|
||||
|
||||
function StatusBadge({ value, locked, requiredTier }: {
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
requiredTier?: string;
|
||||
}) {
|
||||
if (locked && requiredTier) {
|
||||
const label = requiredTier === 'admiral' ? 'Admiral' : 'Skipper';
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-warning/80">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === 'on' || lower === 'enabled') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-success/30 bg-success/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-success">
|
||||
ON
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (lower === 'off' || lower === 'disabled') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-card-border bg-card px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-stat-subtitle">
|
||||
OFF
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, locked, requiredTier, onClick }: {
|
||||
label: string;
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
requiredTier?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const labelClass = `text-xs ${locked ? 'text-stat-subtitle/60' : 'text-stat-subtitle'}`;
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left hover:bg-accent/5 rounded-sm transition-colors cursor-pointer group"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between py-1 px-1">
|
||||
<span className={`${labelClass} group-hover:text-stat-value transition-colors`}>{label}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusBadge value={value} locked={locked} requiredTier={requiredTier} />
|
||||
<ChevronRight className="h-3 w-3 text-stat-icon opacity-0 group-hover:opacity-60 transition-opacity shrink-0" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 px-1 rounded-sm">
|
||||
<span className={labelClass}>{label}</span>
|
||||
<StatusBadge value={value} locked={locked} requiredTier={requiredTier} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ icon: Icon, label }: { icon: typeof Bell; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 pt-2 pb-0.5 first:pt-0">
|
||||
<Icon className="h-3 w-3 text-stat-icon shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-[10px] font-mono tracking-widest uppercase text-stat-icon">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 px-1">
|
||||
<div className="h-3 w-24 rounded-sm bg-accent/10 animate-pulse" />
|
||||
<div className="h-4 w-12 rounded-sm bg-accent/10 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigurationStatus({ onOpenSettings }: ConfigurationStatusProps) {
|
||||
const { status, loading } = useConfigurationStatus();
|
||||
|
||||
const open = (section: SectionId) => () => onOpenSettings?.(section);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Configuration Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-0.5">
|
||||
{Array.from({ length: 8 }).map((_, i) => <SkeletonRow key={i} />)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Configuration Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-xs text-stat-subtitle py-4 text-center">Unable to load configuration.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { notifications, automation, security, thresholds, backup } = status;
|
||||
|
||||
const agentSummary = (() => {
|
||||
const { discord, slack, webhook } = notifications.agents;
|
||||
const active = [
|
||||
discord.enabled ? 'Discord' : null,
|
||||
slack.enabled ? 'Slack' : null,
|
||||
webhook.enabled ? 'Webhook' : null,
|
||||
].filter(Boolean);
|
||||
return active.length === 0 ? 'None' : active.join(', ');
|
||||
})();
|
||||
|
||||
const ssoLabel = (() => {
|
||||
if (!security.ssoEnabled) return 'Off';
|
||||
const names: Record<string, string> = {
|
||||
oidc_custom: 'OIDC', oidc_google: 'Google', oidc_github: 'GitHub',
|
||||
oidc_okta: 'Okta', ldap: 'LDAP',
|
||||
};
|
||||
return (security.ssoProvider && names[security.ssoProvider]) ?? 'Enabled';
|
||||
})();
|
||||
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Configuration Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-0">
|
||||
<SectionHeader icon={Bell} label="Notifications" />
|
||||
<Row label="Notification agents" value={agentSummary} onClick={open('notifications')} />
|
||||
<Row
|
||||
label="Alert rules"
|
||||
value={formatCount(notifications.alertRules, 'rule')}
|
||||
onClick={open('notifications')}
|
||||
/>
|
||||
<Row
|
||||
label="Notification routing"
|
||||
value={notifications.routingRules.locked ? '' : formatCount(notifications.routingRules.enabledCount, 'route')}
|
||||
locked={notifications.routingRules.locked}
|
||||
requiredTier={notifications.routingRules.locked ? notifications.routingRules.requiredTier : undefined}
|
||||
onClick={open('notification-routing')}
|
||||
/>
|
||||
|
||||
<SectionHeader icon={Zap} label="Automation" />
|
||||
<Row
|
||||
label="Auto-heal policies"
|
||||
value={automation.autoHeal.total === 0 ? 'None' : `${automation.autoHeal.enabled} / ${automation.autoHeal.total} active`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Auto-update stacks"
|
||||
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total}`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Webhooks"
|
||||
value={automation.webhooks.locked ? '' : formatCount(automation.webhooks.enabled, 'active')}
|
||||
locked={automation.webhooks.locked}
|
||||
requiredTier={automation.webhooks.locked ? automation.webhooks.requiredTier : undefined}
|
||||
onClick={open('webhooks')}
|
||||
/>
|
||||
<Row
|
||||
label="Scheduled tasks"
|
||||
value={automation.scheduledTasks.locked ? '' : formatCount(automation.scheduledTasks.enabled, 'active')}
|
||||
locked={automation.scheduledTasks.locked}
|
||||
requiredTier={automation.scheduledTasks.locked ? automation.scheduledTasks.requiredTier : undefined}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
|
||||
<SectionHeader icon={Shield} label="Security" />
|
||||
<Row
|
||||
label="MFA"
|
||||
value={security.mfaEnabled === null ? 'Not set up' : security.mfaEnabled ? 'On' : 'Off'}
|
||||
onClick={open('account')}
|
||||
/>
|
||||
<Row label="SSO" value={ssoLabel} onClick={open('sso')} />
|
||||
<Row
|
||||
label="Vulnerability scanning"
|
||||
value={security.scanPolicies.locked ? '' : formatCount(security.scanPolicies.enabled, 'policy')}
|
||||
locked={security.scanPolicies.locked}
|
||||
requiredTier={security.scanPolicies.locked ? security.scanPolicies.requiredTier : undefined}
|
||||
onClick={open('security')}
|
||||
/>
|
||||
|
||||
<SectionHeader icon={HardDrive} label="Backups & Thresholds" />
|
||||
<Row
|
||||
label="Cloud Backup"
|
||||
value={backup.locked ? '' : backup.provider === 'disabled' ? 'Disabled' : backup.provider === 'sencho' ? 'Sencho Cloud' : `Custom S3${backup.autoUpload ? ' (auto)' : ''}`}
|
||||
locked={backup.locked}
|
||||
requiredTier={backup.locked ? backup.requiredTier : undefined}
|
||||
onClick={open('cloud-backup')}
|
||||
/>
|
||||
<Row
|
||||
label="Alert thresholds"
|
||||
value={`CPU ${thresholds.cpuLimit}% · RAM ${thresholds.ramLimit}% · Disk ${thresholds.diskLimit}%`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Crash detection"
|
||||
value={thresholds.globalCrash ? 'On' : 'Off'}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Area, AreaChart, CartesianGrid, ReferenceDot, XAxis, YAxis } from 'recharts';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { MetricPoint, SystemStats } from './types';
|
||||
|
||||
interface HistoricalChartsProps {
|
||||
metrics: MetricPoint[];
|
||||
systemStats: SystemStats | null;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' },
|
||||
ram: { label: 'RAM Usage (GB)', color: 'var(--chart-2)' },
|
||||
};
|
||||
|
||||
export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps) {
|
||||
const chartData = useMemo(() => {
|
||||
const buckets: Record<string, { time: string; timestamp: number; cpu: number; ram: number }> = {};
|
||||
const cores = systemStats?.cpu.cores || 1;
|
||||
|
||||
metrics.forEach(m => {
|
||||
const date = new Date(m.timestamp);
|
||||
date.setSeconds(0, 0);
|
||||
const key = date.getTime() + '';
|
||||
|
||||
if (!buckets[key]) {
|
||||
buckets[key] = {
|
||||
time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
timestamp: date.getTime(),
|
||||
cpu: 0,
|
||||
ram: 0,
|
||||
};
|
||||
}
|
||||
buckets[key].cpu += (m.cpu_percent / cores);
|
||||
buckets[key].ram += (m.memory_mb / 1024);
|
||||
});
|
||||
|
||||
return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [metrics, systemStats]);
|
||||
|
||||
const hasData = chartData.length > 0;
|
||||
|
||||
const cpuPeak = useMemo(() => {
|
||||
if (chartData.length === 0) return null;
|
||||
let peak = chartData[0];
|
||||
for (const row of chartData) {
|
||||
if (row.cpu > peak.cpu) peak = row;
|
||||
}
|
||||
return peak;
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">CPU</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
last 24h · normalized over cores
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{hasData ? (
|
||||
<ChartContainer config={chartConfig} className="w-full h-full">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="dash-cpu-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, (dataMax: number) => Math.max(100, Math.ceil(dataMax / 10) * 10)]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={1.25}
|
||||
fill="url(#dash-cpu-fill)"
|
||||
/>
|
||||
{cpuPeak ? (
|
||||
<ReferenceDot
|
||||
x={cpuPeak.time}
|
||||
y={cpuPeak.cpu}
|
||||
r={3}
|
||||
fill="var(--chart-2)"
|
||||
stroke="var(--background)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
) : null}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Skeleton className="w-full h-[180px] rounded-md" />
|
||||
<span className="text-xs text-stat-icon">Waiting for CPU metrics...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">Memory</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
last 24h · total allocation
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{hasData ? (
|
||||
<ChartContainer config={chartConfig} className="w-full h-full">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="dash-ram-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-2)" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="var(--chart-2)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ram"
|
||||
stroke="var(--chart-2)"
|
||||
strokeWidth={1.25}
|
||||
fill="url(#dash-ram-fill)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Skeleton className="w-full h-[180px] rounded-md" />
|
||||
<span className="text-xs text-stat-icon">Waiting for RAM metrics...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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-info' },
|
||||
image_update_available: { icon: RefreshCw, className: 'text-warning' },
|
||||
auto_heal_restarted: { icon: RotateCcw, className: 'text-info' },
|
||||
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-info' },
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
export { HealthStatusBar } from './HealthStatusBar';
|
||||
export { ResourceGauges } from './ResourceGauges';
|
||||
export { StackHealthTable } from './StackHealthTable';
|
||||
export { HistoricalCharts } from './HistoricalCharts';
|
||||
export { RecentAlerts } from './RecentAlerts';
|
||||
export { ConfigurationStatus } from './ConfigurationStatus';
|
||||
export { RecentActivity } from './RecentActivity';
|
||||
export { useDashboardData } from './useDashboardData';
|
||||
export { useConfigurationStatus } from './useConfigurationStatus';
|
||||
export { useRecentActivity } from './useRecentActivity';
|
||||
export type * from './types';
|
||||
export type { ConfigurationStatus as ConfigurationStatusPayload } from './useConfigurationStatus';
|
||||
export type { ActivityItem } from './useRecentActivity';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
|
||||
export interface AgentStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigurationStatus {
|
||||
tier: 'community' | 'paid';
|
||||
variant: 'skipper' | 'admiral' | null;
|
||||
notifications: {
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus };
|
||||
alertRules: number;
|
||||
routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'admiral' };
|
||||
};
|
||||
automation: {
|
||||
autoHeal: { total: number; enabled: number };
|
||||
autoUpdate: { enabled: number; total: number };
|
||||
scheduledTasks: { total: number; enabled: number; locked: boolean; requiredTier: 'admiral' };
|
||||
webhooks: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' };
|
||||
};
|
||||
security: {
|
||||
mfaEnabled: boolean | null;
|
||||
ssoEnabled: boolean;
|
||||
ssoProvider: string | null;
|
||||
scanPolicies: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' };
|
||||
};
|
||||
thresholds: {
|
||||
cpuLimit: number;
|
||||
ramLimit: number;
|
||||
diskLimit: number;
|
||||
dockerJanitorGb: number;
|
||||
globalCrash: boolean;
|
||||
};
|
||||
backup: {
|
||||
provider: 'disabled' | 'sencho' | 'custom';
|
||||
autoUpload: boolean;
|
||||
locked: boolean;
|
||||
requiredTier: 'admiral';
|
||||
};
|
||||
}
|
||||
|
||||
export function useConfigurationStatus() {
|
||||
const { activeNode } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const nodeIdRef = useRef(nodeId);
|
||||
useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]);
|
||||
|
||||
const [status, setStatus] = useState<ConfigurationStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/dashboard/configuration');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as ConfigurationStatus;
|
||||
setStatus(data);
|
||||
} catch {
|
||||
// Silent; stale data stays visible
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(null);
|
||||
setLoading(true);
|
||||
const currentNodeId = nodeId;
|
||||
const guard = () => { if (nodeIdRef.current === currentNodeId) void fetchStatus(); };
|
||||
guard();
|
||||
return visibilityInterval(guard, 60_000);
|
||||
}, [nodeId, fetchStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => void fetchStatus();
|
||||
window.addEventListener('sencho:state-invalidate', handler);
|
||||
return () => window.removeEventListener('sencho:state-invalidate', handler);
|
||||
}, [fetchStatus]);
|
||||
|
||||
return { status, loading };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
import type {
|
||||
Stats,
|
||||
SystemStats,
|
||||
@@ -14,43 +15,6 @@ const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, t
|
||||
const SPARK_BUCKETS = 20;
|
||||
const SPARK_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Start a polling interval that pauses when the tab is hidden.
|
||||
* Returns a cleanup function that stops the interval.
|
||||
*/
|
||||
function visibilityInterval(fn: () => void, ms: number): () => void {
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const start = () => {
|
||||
if (interval) return;
|
||||
interval = setInterval(fn, ms);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onVisChange = () => {
|
||||
if (document.hidden) {
|
||||
stop();
|
||||
} else {
|
||||
fn(); // Fetch immediately on re-focus
|
||||
start();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisChange);
|
||||
start();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener('visibilitychange', onVisChange);
|
||||
};
|
||||
}
|
||||
|
||||
function bucketCpu(points: MetricPoint[], windowMs: number, buckets: number): number[] {
|
||||
if (points.length === 0) return Array(buckets).fill(0);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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,194 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatCount } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import type { ConfigurationStatusPayload } from '@/components/dashboard';
|
||||
|
||||
interface FleetNodeConfiguration {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
status: 'online' | 'offline';
|
||||
configuration: ConfigurationStatusPayload | null;
|
||||
}
|
||||
|
||||
function TierChip({ tier }: { tier: string }) {
|
||||
const label = tier === 'admiral' ? 'Admiral' : 'Skipper';
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-warning/80">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ icon: Icon, label, value, locked, requiredTier }: {
|
||||
icon: typeof Bell;
|
||||
label: string;
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
requiredTier?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<Icon className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} />
|
||||
<span className={`text-xs flex-1 ${locked ? 'text-stat-subtitle/60' : 'text-stat-subtitle'}`}>{label}</span>
|
||||
{locked && requiredTier
|
||||
? <TierChip tier={requiredTier} />
|
||||
: <span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node }: { node: FleetNodeConfiguration }) {
|
||||
if (!node.configuration) {
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium text-sm text-stat-value">{node.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-stat-subtitle">
|
||||
{node.type === 'remote' ? 'Remote' : 'Local'}
|
||||
</Badge>
|
||||
<WifiOff className="h-3.5 w-3.5 text-stat-subtitle ml-auto" strokeWidth={1.5} />
|
||||
<span className="text-xs text-stat-subtitle">Offline</span>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle/60">Node is unreachable. Configuration unavailable.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { notifications, automation, security, backup, thresholds } = node.configuration;
|
||||
|
||||
const agentCount = [
|
||||
notifications.agents.discord.enabled,
|
||||
notifications.agents.slack.enabled,
|
||||
notifications.agents.webhook.enabled,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium text-sm text-stat-value">{node.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-stat-subtitle">
|
||||
{node.type === 'remote' ? 'Remote' : 'Local'}
|
||||
</Badge>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-success" strokeWidth={1.5} />
|
||||
<span className="text-xs text-success">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
|
||||
<SummaryRow icon={Bell} label="Agents"
|
||||
value={agentCount === 0 ? 'None' : `${agentCount} active`} />
|
||||
<SummaryRow icon={Bell} label="Alert rules"
|
||||
value={formatCount(notifications.alertRules, 'rule')} />
|
||||
<SummaryRow icon={Zap} label="Auto-heal"
|
||||
value={automation.autoHeal.total === 0
|
||||
? 'None'
|
||||
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
|
||||
<SummaryRow icon={Zap} label="Webhooks"
|
||||
value={
|
||||
automation.webhooks.locked
|
||||
? ''
|
||||
: formatCount(automation.webhooks.enabled, 'active')
|
||||
}
|
||||
locked={automation.webhooks.locked}
|
||||
requiredTier={automation.webhooks.locked ? automation.webhooks.requiredTier : undefined} />
|
||||
<SummaryRow icon={Shield} label="MFA"
|
||||
value={security.mfaEnabled === null ? 'Not set' : security.mfaEnabled ? 'On' : 'Off'} />
|
||||
<SummaryRow icon={Shield} label="Scanning"
|
||||
value={
|
||||
security.scanPolicies.locked
|
||||
? ''
|
||||
: formatCount(security.scanPolicies.enabled, 'policy')
|
||||
}
|
||||
locked={security.scanPolicies.locked}
|
||||
requiredTier={security.scanPolicies.locked ? security.scanPolicies.requiredTier : undefined} />
|
||||
<SummaryRow icon={HardDrive} label="Backup"
|
||||
value={
|
||||
backup.locked
|
||||
? ''
|
||||
: backup.provider === 'disabled' ? 'Disabled' : 'Enabled'
|
||||
}
|
||||
locked={backup.locked}
|
||||
requiredTier={backup.locked ? backup.requiredTier : undefined} />
|
||||
<SummaryRow icon={HardDrive} label="Crash detect"
|
||||
value={thresholds.globalCrash ? 'On' : 'Off'} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function FleetConfiguration() {
|
||||
const [nodes, setNodes] = useState<FleetNodeConfiguration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/fleet/configuration', { localOnly: true });
|
||||
if (!res.ok) {
|
||||
setError('Failed to fetch fleet configuration.');
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as FleetNodeConfiguration[];
|
||||
setNodes(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Unable to reach the server.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i} className="bg-card shadow-card-bevel">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="h-4 w-32 rounded-sm bg-accent/10 animate-pulse" />
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div key={j} className="h-3 rounded-sm bg-accent/10 animate-pulse" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-stat-subtitle">
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-stat-subtitle">
|
||||
<p className="text-sm">No nodes configured.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
||||
{nodes.map(node => <NodeCard key={node.id} node={node} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user