feat: sortable resource tables and richer dashboard stack-health columns (#1498)

Make the Resources Images, Volumes, and Networks tables sortable with a
shared useTableSort hook and SortableTableHead, move the Images scan-history
control into the Images tab header, and keep the network List/Topology toggle
anchored with Create Network visible in both modes.

Rework the dashboard Stack health table: drop the redundant Host column, add
sortable Stack/Up/CPU/Mem headers, and add Source (local/git) and Port columns.
The status endpoint now labels each stack with its git/local source, computed
outside the cache so linking changes show immediately.

Extract a reusable CreateNetworkDialog and add a create-network action to the
stack-detail Networking tab.
This commit is contained in:
Anso
2026-06-28 05:06:04 -04:00
committed by GitHub
parent cf0db36e78
commit 60536aa614
11 changed files with 512 additions and 190 deletions
@@ -17,6 +17,8 @@
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD } from './helpers/setupTestDb';
import { GitSourceService } from '../services/GitSourceService';
import type { PublicGitSource } from '../services/GitSourceService';
// ── Hoisted mocks (must come before importing the app) ─────────────────
@@ -219,6 +221,45 @@ describe('GET /api/stacks/statuses caching', () => {
await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
expect(mockGetStacks).toHaveBeenCalledTimes(2);
});
it('labels each stack with its git/local source, computed outside the cache', async () => {
mockGetStacks.mockResolvedValue(['web.yml', 'db.yml']);
mockGetBulkStackStatuses.mockResolvedValue({
web: { status: 'running' },
db: { status: 'running' },
});
// Only `web` is linked to a Git source.
const listSpy = vi
.spyOn(GitSourceService.getInstance(), 'list')
.mockReturnValue([{ stack_name: 'web' } as PublicGitSource]);
const first = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
expect(first.body['web.yml'].source).toBe('git');
expect(first.body['db.yml'].source).toBe('local');
// Source is recomputed live even when the Docker-status payload is cached:
// unlinking `web` flips it to local on the next request without a cache flush.
listSpy.mockReturnValue([]);
const second = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
expect(second.body['web.yml'].source).toBe('local');
expect(mockGetBulkStackStatuses).toHaveBeenCalledTimes(1); // status portion served from cache
listSpy.mockRestore();
});
it('falls back to local labels (200, not 500) when the git-source lookup throws', async () => {
mockGetStacks.mockResolvedValue(['web.yml']);
mockGetBulkStackStatuses.mockResolvedValue({ web: { status: 'running' } });
const listSpy = vi
.spyOn(GitSourceService.getInstance(), 'list')
.mockImplementation(() => { throw new Error('db locked'); });
const res = await request(app).get('/api/stacks/statuses').set('Cookie', authCookie);
expect(res.status).toBe(200);
expect(res.body['web.yml'].source).toBe('local');
listSpy.mockRestore();
});
});
// ── /api/system/cache-stats ────────────────────────────────────────────
+17 -1
View File
@@ -252,7 +252,23 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => {
return data;
},
);
res.json(result);
// Git-source labels are computed live, outside the cache, so linking or
// unlinking a stack's Git source is reflected immediately. The Docker
// status portion keeps its short TTL; only the cheap source label is fresh.
// The label is cosmetic, so a lookup failure must not take down the primary
// status payload: fall back to labeling everything 'local'.
let gitStackNames = new Set<string>();
try {
gitStackNames = new Set(GitSourceService.getInstance().list().map((s) => s.stack_name));
} catch (sourceError) {
console.error('Failed to load git sources for status labels; defaulting to local:', sourceError);
}
const withSource: Record<string, BulkStackInfo & { source: 'local' | 'git' }> = {};
for (const [stack, info] of Object.entries(result)) {
const name = stack.replace(/\.(yml|yaml)$/, '');
withSource[stack] = { ...info, source: gitStackNames.has(name) ? 'git' : 'local' };
}
res.json(withSource);
} catch (error) {
console.error('Failed to fetch stack statuses:', error);
res.status(500).json({ error: 'Failed to fetch stack statuses' });
@@ -48,7 +48,6 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
stackStatuses={data.stackStatuses}
metrics={data.metrics}
stackCpuSeries={data.stackCpuSeries}
activeNodeName={activeNodeName}
onNavigateToStack={onNavigateToStack ?? NOOP}
/>
+79 -165
View File
@@ -8,12 +8,8 @@ import { springs } from '@/lib/motion';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from "@/components/ui/modal";
import { ConfirmModal } from "@/components/ui/modal";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TogglePill } from "@/components/ui/toggle-pill";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
@@ -39,6 +35,9 @@ import { FootprintTreemap } from './resources/FootprintTreemap';
import { ImageDetailsSheet } from './resources/ImageDetailsSheet';
import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet';
import { VolumeNameLabel } from './resources/VolumeNameLabel';
import { CreateNetworkDialog } from './resources/CreateNetworkDialog';
import { useTableSort } from '@/hooks/useTableSort';
import { SortableTableHead } from '@/components/ui/sortable-table';
import { NetworkDetailSheet, type NetworkInspectData } from './resources/NetworkDetailSheet';
const NetworkTopologyView = lazy(() => import('./NetworkTopologyView'));
@@ -79,9 +78,6 @@ interface DockerVolume {
isSencho: boolean;
}
const NETWORK_DRIVERS = ['bridge', 'overlay', 'macvlan', 'host', 'none'] as const;
type NetworkDriver = (typeof NETWORK_DRIVERS)[number];
export interface DockerNetwork {
Id: string;
Name: string;
@@ -301,6 +297,23 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) {
);
}
// Stable comparator maps for the resource tables (module scope so useTableSort
// does not re-sort on every render). Mirrors the Security Images sort standard.
const IMAGE_COMPARATORS: Record<'repo' | 'size' | 'status', (a: DockerImage, b: DockerImage) => number> = {
repo: (a, b) => (a.RepoTags?.[0] || '').localeCompare(b.RepoTags?.[0] || ''),
size: (a, b) => a.Size - b.Size,
status: (a, b) => Number(a.Containers > 0) - Number(b.Containers > 0),
};
const VOLUME_COMPARATORS: Record<'name' | 'driver', (a: DockerVolume, b: DockerVolume) => number> = {
name: (a, b) => a.Name.localeCompare(b.Name),
driver: (a, b) => a.Driver.localeCompare(b.Driver),
};
const NETWORK_COMPARATORS: Record<'name' | 'driver' | 'scope', (a: DockerNetwork, b: DockerNetwork) => number> = {
name: (a, b) => a.Name.localeCompare(b.Name),
driver: (a, b) => a.Driver.localeCompare(b.Driver),
scope: (a, b) => a.Scope.localeCompare(b.Scope),
};
// ── Main Component ─────────────────────────────────────────────────────────────
interface ResourcesViewProps {
@@ -341,8 +354,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
// Network create/inspect state
const [showCreateNetwork, setShowCreateNetwork] = useState(false);
const [createNetworkForm, setCreateNetworkForm] = useState<{ name: string; driver: NetworkDriver; subnet: string; gateway: string; internal: boolean; attachable: boolean }>({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false });
const [isCreatingNetwork, setIsCreatingNetwork] = useState(false);
const [inspectNetwork, setInspectNetwork] = useState<NetworkInspectData | null>(null);
const [inspectLoadingId, setInspectLoadingId] = useState<string | null>(null);
const [inspectImageId, setInspectImageId] = useState<string | null>(null);
@@ -591,36 +602,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
}
};
const handleCreateNetwork = async () => {
setIsCreatingNetwork(true);
try {
const res = await apiFetch('/system/networks', {
method: 'POST',
body: JSON.stringify({
name: createNetworkForm.name,
driver: createNetworkForm.driver,
subnet: createNetworkForm.subnet || undefined,
gateway: createNetworkForm.gateway || undefined,
internal: createNetworkForm.internal,
attachable: createNetworkForm.attachable,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data?.error || 'Failed to create network');
}
toast.success(`Network "${createNetworkForm.name}" created`);
setShowCreateNetwork(false);
setCreateNetworkForm({ name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false });
await fetchAllData();
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
} finally {
setIsCreatingNetwork(false);
}
};
const handleInspectNetwork = async (id: string) => {
setInspectLoadingId(id);
try {
@@ -650,6 +631,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
networkFilter === 'unmanaged' ? net.managedStatus !== 'managed' : true
);
// Sortable tables (standard sort behavior across the resource tables).
const imageSort = useTableSort(filteredImages, IMAGE_COMPARATORS, 'repo');
const volumeSort = useTableSort(filteredVolumes, VOLUME_COMPARATORS, 'name');
const networkSort = useTableSort(filteredNetworks, NETWORK_COMPARATORS, 'name');
const handleFootprintFilter = (filter: ResourceFilter) => {
setImageFilter(filter);
setVolumeFilter(filter);
@@ -858,23 +844,6 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
{trivy.available && (
<Button
variant="outline"
size="sm"
className="border-border"
onClick={() => {
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
detail: { view: 'security', tab: 'history' },
}));
}}
title="View completed vulnerability scans and compare them"
aria-label="Open scan history"
>
<History className="w-4 h-4 mr-2" strokeWidth={1.5} />
Scan history
</Button>
)}
</div>
)}
@@ -882,30 +851,49 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
{/* Images */}
<TabsContent value="images" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
<FilterToggle
value={imageFilter}
onChange={setImageFilter}
counts={{
all: images.length,
managed: images.filter(i => i.managedStatus === 'managed').length,
unmanaged: images.filter(i => i.managedStatus !== 'managed').length,
}}
/>
<div className="flex items-center justify-between gap-3">
<FilterToggle
value={imageFilter}
onChange={setImageFilter}
counts={{
all: images.length,
managed: images.filter(i => i.managedStatus === 'managed').length,
unmanaged: images.filter(i => i.managedStatus !== 'managed').length,
}}
/>
{trivy.available && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1.5 mr-3 shrink-0"
onClick={() => {
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
detail: { view: 'security', tab: 'history' },
}));
}}
title="View completed vulnerability scans and compare them"
aria-label="Open scan history"
>
<History className="w-3.5 h-3.5" strokeWidth={1.5} />
Scan history
</Button>
)}
</div>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[120px] text-[11px]">ID</TableHead>
<TableHead className="text-[11px]">Repository:Tag</TableHead>
<TableHead className="text-[11px]">Size</TableHead>
<TableHead className="text-[11px]">Status</TableHead>
<SortableTableHead label="Repository:Tag" columnKey="repo" activeKey={imageSort.sortKey} dir={imageSort.sortDir} onSort={imageSort.toggleSort} />
<SortableTableHead label="Size" columnKey="size" activeKey={imageSort.sortKey} dir={imageSort.sortDir} onSort={imageSort.toggleSort} />
<SortableTableHead label="Status" columnKey="status" activeKey={imageSort.sortKey} dir={imageSort.sortDir} onSort={imageSort.toggleSort} />
<TableHead className="text-right text-[11px]">Action</TableHead>
</TableRow>
</TableHeader>
{isLoading ? <TableSkeleton cols={5} /> : (
<TableBody>
{filteredImages.length === 0 ? (
{imageSort.sorted.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">No images found.</TableCell></TableRow>
) : filteredImages.map((img, i) => (
) : imageSort.sorted.map((img, i) => (
<TableRow
key={img.Id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
@@ -1005,8 +993,8 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="text-[11px]">Name</TableHead>
<TableHead className="text-[11px]">Driver</TableHead>
<SortableTableHead label="Name" columnKey="name" activeKey={volumeSort.sortKey} dir={volumeSort.sortDir} onSort={volumeSort.toggleSort} />
<SortableTableHead label="Driver" columnKey="driver" activeKey={volumeSort.sortKey} dir={volumeSort.sortDir} onSort={volumeSort.toggleSort} />
<TableHead className="hidden md:table-cell text-[11px]">Mountpoint</TableHead>
<TableHead className="text-[11px]">Status</TableHead>
<TableHead className="text-right text-[11px]">Action</TableHead>
@@ -1014,9 +1002,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</TableHeader>
{isLoading ? <TableSkeleton cols={5} /> : (
<TableBody>
{filteredVolumes.length === 0 ? (
{volumeSort.sorted.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">No volumes found.</TableCell></TableRow>
) : filteredVolumes.map((vol, i) => (
) : volumeSort.sorted.map((vol, i) => (
<TableRow
key={vol.Name}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
@@ -1067,7 +1055,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
{/* Networks */}
<TabsContent value="networks" className="m-0 border-0 p-0 animate-in fade-in-0 duration-200">
<div className="flex items-center justify-between">
{networkViewMode === 'list' && (
{networkViewMode === 'list' ? (
<FilterToggle
value={networkFilter}
onChange={setNetworkFilter}
@@ -1077,6 +1065,10 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
unmanaged: networks.filter(n => n.managedStatus !== 'managed').length,
}}
/>
) : (
// Keep a left spacer so justify-between holds the toggle +
// Create Network group anchored on the right in topology mode.
<span aria-hidden="true" />
)}
<div className="flex items-center gap-2 pr-3">
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
@@ -1099,7 +1091,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
Topology
</button>
</div>
{isAdmin && networkViewMode === 'list' && (
{isAdmin && (
<Button
variant="outline"
size="sm"
@@ -1139,18 +1131,18 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[120px] text-[11px]">ID</TableHead>
<TableHead className="text-[11px]">Name</TableHead>
<TableHead className="text-[11px]">Driver</TableHead>
<TableHead className="text-[11px]">Scope</TableHead>
<SortableTableHead label="Name" columnKey="name" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<SortableTableHead label="Driver" columnKey="driver" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<SortableTableHead label="Scope" columnKey="scope" activeKey={networkSort.sortKey} dir={networkSort.sortDir} onSort={networkSort.toggleSort} />
<TableHead className="text-[11px]">Status</TableHead>
<TableHead className="text-right text-[11px]">Actions</TableHead>
</TableRow>
</TableHeader>
{isLoading ? <TableSkeleton cols={6} /> : (
<TableBody>
{filteredNetworks.length === 0 ? (
{networkSort.sorted.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center py-8 text-muted-foreground text-sm">No networks found.</TableCell></TableRow>
) : filteredNetworks.map((net, i) => (
) : networkSort.sorted.map((net, i) => (
<TableRow
key={net.Id}
className="animate-in fade-in-0 duration-200 hover:bg-muted/30 transition-colors"
@@ -1396,89 +1388,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</ConfirmModal>
{/* Create Network Modal */}
<Modal open={showCreateNetwork} onOpenChange={setShowCreateNetwork} size="md">
<ModalHeader
kicker="NETWORKS · NEW"
title="Create network"
description="Create a new Docker network for inter-container communication."
/>
<ModalBody>
<div className="space-y-2">
<Label htmlFor="net-name" className="text-xs font-medium">Name</Label>
<Input
id="net-name"
placeholder="my-network"
className="font-mono text-sm"
value={createNetworkForm.name}
onChange={e => setCreateNetworkForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="net-driver" className="text-xs font-medium">Driver</Label>
<Combobox
options={NETWORK_DRIVERS.map(d => ({ value: d, label: d }))}
value={createNetworkForm.driver}
onValueChange={v => setCreateNetworkForm(f => ({ ...f, driver: (v || 'bridge') as NetworkDriver }))}
placeholder="Select driver..."
searchPlaceholder="Search drivers..."
emptyText="No matching driver."
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="net-subnet" className="text-xs font-medium">Subnet <span className="text-muted-foreground">(optional)</span></Label>
<Input
id="net-subnet"
placeholder="172.20.0.0/16"
className="font-mono text-sm"
value={createNetworkForm.subnet}
onChange={e => setCreateNetworkForm(f => ({ ...f, subnet: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="net-gateway" className="text-xs font-medium">Gateway <span className="text-muted-foreground">(optional)</span></Label>
<Input
id="net-gateway"
placeholder="172.20.0.1"
className="font-mono text-sm"
value={createNetworkForm.gateway}
onChange={e => setCreateNetworkForm(f => ({ ...f, gateway: e.target.value }))}
/>
</div>
</div>
<div className="flex items-center gap-6 pt-1">
<div className="flex items-center gap-2">
<TogglePill
id="net-internal"
checked={createNetworkForm.internal}
onChange={v => setCreateNetworkForm(f => ({ ...f, internal: v }))}
/>
<Label htmlFor="net-internal" className="text-xs cursor-pointer">Internal <span className="text-muted-foreground">(no external access)</span></Label>
</div>
<div className="flex items-center gap-2">
<TogglePill
id="net-attachable"
checked={createNetworkForm.attachable}
onChange={v => setCreateNetworkForm(f => ({ ...f, attachable: v }))}
/>
<Label htmlFor="net-attachable" className="text-xs cursor-pointer">Attachable</Label>
</div>
</div>
</ModalBody>
<ModalFooter
hint={`DRIVER ${createNetworkForm.driver}`}
secondary={
<Button variant="outline" size="sm" onClick={() => setShowCreateNetwork(false)} disabled={isCreatingNetwork}>
Cancel
</Button>
}
primary={
<Button size="sm" onClick={handleCreateNetwork} disabled={!createNetworkForm.name.trim() || isCreatingNetwork}>
{isCreatingNetwork ? 'Creating...' : 'Create network'}
</Button>
}
/>
</Modal>
<CreateNetworkDialog
open={showCreateNetwork}
onOpenChange={setShowCreateNetwork}
onCreated={fetchAllData}
/>
{/* Image Details Sheet */}
<ImageDetailsSheet imageId={inspectImageId} onClose={() => setInspectImageId(null)} />
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Sparkline } from '@/components/ui/sparkline';
import { ChevronLeft, ChevronRight, Layers } from 'lucide-react';
import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
import { classifyRow, type RowState } from './classifyRow';
@@ -10,21 +11,51 @@ interface StackHealthTableProps {
stackStatuses: Record<string, StackStatusEntry>;
metrics: MetricPoint[];
stackCpuSeries: Record<string, StackCpuSeries>;
activeNodeName: string;
onNavigateToStack: (stackFile: string) => void;
}
type SortKey = 'stack' | 'up' | 'cpu' | 'mem';
const PAGE_SIZE = 8;
// Shared by the header and data rows so their columns stay aligned. The
// `max-md:min-w` keeps both at the same width below md, where the card scrolls
// horizontally; desktop is unaffected by the `max-md:` prefix.
const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_minmax(0,120px)_52px_52px_72px_110px_16px] max-md:min-w-[600px]';
// horizontally; desktop is unaffected by the `max-md:` prefix. Columns:
// dot · STACK · SOURCE · PORT · UP · CPU · MEM · CPU·10m · chevron.
const GRID_TEMPLATE = 'grid-cols-[14px_minmax(0,1fr)_64px_56px_52px_52px_72px_110px_16px] max-md:min-w-[640px]';
const formatMemory = (mb: number): string => {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb.toFixed(0)} MB`;
};
// Grid-compatible sortable header cell. Renders a <button> inside the grid
// <span> (the security ImagesTab pattern uses <TableHead>, which is invalid
// inside this CSS-grid layout, so only the sort logic is shared here).
function SortHeader({ label, k, sortKey, sortDir, onSort, align = 'left' }: {
label: string;
k: SortKey;
sortKey: SortKey | null;
sortDir: 'asc' | 'desc';
onSort: (k: SortKey) => void;
align?: 'left' | 'right';
}) {
return (
<span className={align === 'right' ? 'text-right' : undefined}>
<button
type="button"
onClick={() => onSort(k)}
className={cn(
'inline-flex items-center gap-1 uppercase tracking-[0.22em] hover:text-stat-value',
align === 'right' && 'flex-row-reverse',
)}
>
{label}
{sortKey === k && (sortDir === 'asc' ? <ArrowUp className="h-2.5 w-2.5" /> : <ArrowDown className="h-2.5 w-2.5" />)}
</button>
</span>
);
}
function formatUptime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return '--';
const days = Math.floor(seconds / 86400);
@@ -58,10 +89,13 @@ export function StackHealthTable({
stackStatuses,
metrics,
stackCpuSeries,
activeNodeName,
onNavigateToStack,
}: StackHealthTableProps) {
const [page, setPage] = useState(0);
// null = the default health-state ordering (worst first); a SortKey switches
// to user-driven column sort.
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
// Live-tick the current second so uptime labels advance without a parent
// refetch. Thirty-second cadence keeps the DOM calm while still refreshing
// every "Nm" bucket change.
@@ -73,8 +107,8 @@ export function StackHealthTable({
const stackAggregates = useMemo(() => aggregateCurrentUsage(metrics), [metrics]);
const rows = useMemo(() => {
const list = Object.entries(stackStatuses).map(([file, entry]) => {
const baseRows = useMemo(() => {
return Object.entries(stackStatuses).map(([file, entry]) => {
const name = file.replace(/\.(yml|yaml)$/, '');
const agg = stackAggregates[name];
const series = stackCpuSeries[name];
@@ -91,16 +125,44 @@ export function StackHealthTable({
peakIndex: series?.peakIndex ?? -1,
state,
runningSince: entry.runningSince ?? null,
source: entry.source ?? 'local',
mainPort: entry.mainPort ?? null,
};
});
const stateOrder: Record<RowState, number> = { error: 0, warn: 1, healthy: 2 };
}, [stackStatuses, stackAggregates, stackCpuSeries]);
const rows = useMemo(() => {
const list = [...baseRows];
if (sortKey === null) {
const stateOrder: Record<RowState, number> = { error: 0, warn: 1, healthy: 2 };
list.sort((a, b) => {
const diff = stateOrder[a.state] - stateOrder[b.state];
if (diff !== 0) return diff;
return b.peakCpu - a.peakCpu;
});
return list;
}
const dir = sortDir === 'asc' ? 1 : -1;
const nowSecs = now / 1000;
const uptime = (rs: number | null) => (rs !== null ? nowSecs - rs : -1);
list.sort((a, b) => {
const diff = stateOrder[a.state] - stateOrder[b.state];
if (diff !== 0) return diff;
return b.peakCpu - a.peakCpu;
switch (sortKey) {
case 'stack': return a.name.localeCompare(b.name) * dir;
case 'up': return (uptime(a.runningSince) - uptime(b.runningSince)) * dir;
case 'cpu': return ((a.cpu ?? -1) - (b.cpu ?? -1)) * dir;
case 'mem': return ((a.memory ?? -1) - (b.memory ?? -1)) * dir;
// Exhaustive: a new SortKey must add a case or this fails to compile.
default: { const _exhaustive: never = sortKey; return _exhaustive; }
}
});
return list;
}, [stackStatuses, stackAggregates, stackCpuSeries]);
}, [baseRows, sortKey, sortDir, now]);
const toggleSort = (key: SortKey) => {
if (sortKey === key) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortKey(key); setSortDir(key === 'stack' ? 'asc' : 'desc'); }
setPage(0);
};
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
@@ -128,7 +190,7 @@ export function StackHealthTable({
Stack health
</h2>
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
{stackCount} {stackCount === 1 ? 'stack' : 'stacks'} · sorted by load
{stackCount} {stackCount === 1 ? 'stack' : 'stacks'}{sortKey === null ? ' · sorted by load' : ''}
</span>
</div>
{needsPagination ? (
@@ -159,11 +221,12 @@ export function StackHealthTable({
</div>
<div className={`grid ${GRID_TEMPLATE} items-center gap-4 border-t border-border/60 px-[var(--density-row-x)] py-[var(--density-cell-y)] font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle`}>
<span />
<span>STACK</span>
<span>HOST</span>
<span className="text-right">UP</span>
<span className="text-right">CPU</span>
<span className="text-right">MEM</span>
<SortHeader label="STACK" k="stack" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} />
<span>SOURCE</span>
<span>PORT</span>
<SortHeader label="UP" k="up" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} align="right" />
<SortHeader label="CPU" k="cpu" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} align="right" />
<SortHeader label="MEM" k="mem" sortKey={sortKey} sortDir={sortDir} onSort={toggleSort} align="right" />
<span className="text-right">CPU · 10m</span>
<span />
</div>
@@ -184,7 +247,12 @@ export function StackHealthTable({
>
<span className={`h-1.5 w-1.5 rounded-full justify-self-center ${stateDot[row.state]}`} aria-hidden="true" />
<span className="truncate font-mono text-sm text-stat-value">{row.name}</span>
<span className="truncate font-mono text-xs text-stat-subtitle">{activeNodeName}</span>
<span className="truncate font-mono text-[11px] uppercase tracking-wide text-stat-subtitle">
{row.source === 'git' ? 'Git' : 'Local'}
</span>
<span className="truncate font-mono text-xs tabular-nums text-stat-subtitle">
{row.mainPort !== null ? row.mainPort : '--'}
</span>
<span className="text-right font-mono text-xs tabular-nums text-stat-subtitle">
{row.runningSince !== null
? formatUptime(Math.max(0, Math.floor(now / 1000 - row.runningSince)))
@@ -76,6 +76,8 @@ export interface StackStatusEntry {
mainPort?: number;
/** Unix seconds of the oldest running container (approximates stack uptime). */
runningSince?: number;
/** Provenance of the stack: 'git' when linked to a Git source, else 'local'. */
source?: 'local' | 'git';
}
export type HealthLevel = 'healthy' | 'degraded' | 'critical';
@@ -0,0 +1,158 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { TogglePill } from '@/components/ui/toggle-pill';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
const NETWORK_DRIVERS = ['bridge', 'overlay', 'macvlan', 'host', 'none'] as const;
type NetworkDriver = (typeof NETWORK_DRIVERS)[number];
interface CreateNetworkForm {
name: string;
driver: NetworkDriver;
subnet: string;
gateway: string;
internal: boolean;
attachable: boolean;
}
const EMPTY_FORM: CreateNetworkForm = {
name: '', driver: 'bridge', subnet: '', gateway: '', internal: false, attachable: false,
};
interface CreateNetworkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a network is created so the caller can refresh its view. */
onCreated?: () => void | Promise<void>;
}
/**
* Self-contained "Create network" modal. Owns its form state and posts to
* `/system/networks`, so it can be reused from the Resources Networks tab and
* the stack-detail Networking tab without sharing parent state.
*/
export function CreateNetworkDialog({ open, onOpenChange, onCreated }: CreateNetworkDialogProps) {
const [form, setForm] = useState<CreateNetworkForm>(EMPTY_FORM);
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
setCreating(true);
try {
const res = await apiFetch('/system/networks', {
method: 'POST',
body: JSON.stringify({
name: form.name,
driver: form.driver,
subnet: form.subnet || undefined,
gateway: form.gateway || undefined,
internal: form.internal,
attachable: form.attachable,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || `Failed to create network (${res.status})`);
}
toast.success(`Network "${form.name}" created`);
onOpenChange(false);
setForm(EMPTY_FORM);
await onCreated?.();
} catch (error) {
const err = error as Record<string, unknown>;
toast.error(String(err?.message || err?.error || 'Something went wrong.'));
} finally {
setCreating(false);
}
};
return (
<Modal open={open} onOpenChange={onOpenChange} size="md">
<ModalHeader
kicker="NETWORKS · NEW"
title="Create network"
description="Create a new Docker network for inter-container communication."
/>
<ModalBody>
<div className="space-y-2">
<Label htmlFor="net-name" className="text-xs font-medium">Name</Label>
<Input
id="net-name"
placeholder="my-network"
className="font-mono text-sm"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="net-driver" className="text-xs font-medium">Driver</Label>
<Combobox
options={NETWORK_DRIVERS.map(d => ({ value: d, label: d }))}
value={form.driver}
onValueChange={v => setForm(f => ({ ...f, driver: (v || 'bridge') as NetworkDriver }))}
placeholder="Select driver..."
searchPlaceholder="Search drivers..."
emptyText="No matching driver."
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="net-subnet" className="text-xs font-medium">Subnet <span className="text-muted-foreground">(optional)</span></Label>
<Input
id="net-subnet"
placeholder="172.20.0.0/16"
className="font-mono text-sm"
value={form.subnet}
onChange={e => setForm(f => ({ ...f, subnet: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="net-gateway" className="text-xs font-medium">Gateway <span className="text-muted-foreground">(optional)</span></Label>
<Input
id="net-gateway"
placeholder="172.20.0.1"
className="font-mono text-sm"
value={form.gateway}
onChange={e => setForm(f => ({ ...f, gateway: e.target.value }))}
/>
</div>
</div>
<div className="flex items-center gap-6 pt-1">
<div className="flex items-center gap-2">
<TogglePill
id="net-internal"
checked={form.internal}
onChange={v => setForm(f => ({ ...f, internal: v }))}
/>
<Label htmlFor="net-internal" className="text-xs cursor-pointer">Internal <span className="text-muted-foreground">(no external access)</span></Label>
</div>
<div className="flex items-center gap-2">
<TogglePill
id="net-attachable"
checked={form.attachable}
onChange={v => setForm(f => ({ ...f, attachable: v }))}
/>
<Label htmlFor="net-attachable" className="text-xs cursor-pointer">Attachable</Label>
</div>
</div>
</ModalBody>
<ModalFooter
hint={`DRIVER ${form.driver}`}
secondary={
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={creating}>
Cancel
</Button>
}
primary={
<Button size="sm" onClick={handleCreate} disabled={!form.name.trim() || creating}>
{creating ? 'Creating...' : 'Create network'}
</Button>
}
/>
</Modal>
);
}
@@ -1,9 +1,10 @@
import { useEffect, useState, useCallback } from 'react';
import { Network, Globe, Lock, ShieldQuestion, RefreshCw, ArrowRight } from 'lucide-react';
import { Network, Globe, Lock, ShieldQuestion, RefreshCw, ArrowRight, Plus } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { CreateNetworkDialog } from '@/components/resources/CreateNetworkDialog';
// Mirrors the backend networking payload shapes (the frontend never imports
// backend). IntentEntry intentionally keeps only the fields this panel reads.
@@ -110,6 +111,7 @@ export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled
const [loadError, setLoadError] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [showCreateNetwork, setShowCreateNetwork] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -197,11 +199,24 @@ export default function StackNetworkingPanel({ stackName, canEdit, doctorEnabled
<div data-testid="networking-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<span className={LABEL_CLASS}>networking</span>
<button type="button" onClick={() => setReloadKey(k => k + 1)} disabled={refreshing} className={ACTION_CLASS}>
<RefreshCw className={cn('h-3 w-3', refreshing && 'animate-spin')} strokeWidth={1.5} /> refresh
</button>
<div className="flex items-center gap-3">
{canEdit && (
<button type="button" onClick={() => setShowCreateNetwork(true)} className={ACTION_CLASS}>
<Plus className="h-3 w-3" strokeWidth={1.5} /> create network
</button>
)}
<button type="button" onClick={() => setReloadKey(k => k + 1)} disabled={refreshing} className={ACTION_CLASS}>
<RefreshCw className={cn('h-3 w-3', refreshing && 'animate-spin')} strokeWidth={1.5} /> refresh
</button>
</div>
</div>
<CreateNetworkDialog
open={showCreateNetwork}
onOpenChange={setShowCreateNetwork}
onCreated={() => setReloadKey(k => k + 1)}
/>
{/* Exposure intent */}
<section className="flex flex-col gap-2">
<div className={LABEL_CLASS}>exposure intent</div>
@@ -0,0 +1,30 @@
import { ArrowUp, ArrowDown } from 'lucide-react';
import { TableHead } from '@/components/ui/table';
import { cn } from '@/lib/utils';
import type { SortDir } from '@/hooks/useTableSort';
/** Clickable, sort-aware `<TableHead>`. Pairs with the `useTableSort` hook. */
export function SortableTableHead<K extends string>({
label, columnKey, activeKey, dir, onSort, className,
}: {
label: string;
columnKey: K;
activeKey: K;
dir: SortDir;
onSort: (k: K) => void;
className?: string;
}) {
const active = activeKey === columnKey;
return (
<TableHead className={cn('text-[11px] cursor-pointer select-none', className)}>
<button
type="button"
onClick={() => onSort(columnKey)}
className="inline-flex items-center gap-1 hover:text-foreground"
>
{label}
{active && (dir === 'asc' ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />)}
</button>
</TableHead>
);
}
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useTableSort } from '../useTableSort';
interface Row { name: string; size: number }
const ITEMS: Row[] = [
{ name: 'beta', size: 30 },
{ name: 'alpha', size: 10 },
{ name: 'gamma', size: 20 },
];
const COMPARATORS = {
name: (a: Row, b: Row) => a.name.localeCompare(b.name),
size: (a: Row, b: Row) => a.size - b.size,
};
describe('useTableSort', () => {
it('sorts by the initial key and direction', () => {
const { result } = renderHook(() => useTableSort(ITEMS, COMPARATORS, 'name'));
expect(result.current.sorted.map(r => r.name)).toEqual(['alpha', 'beta', 'gamma']);
expect(result.current.sortKey).toBe('name');
expect(result.current.sortDir).toBe('asc');
});
it('flips direction when toggling the active key', () => {
const { result } = renderHook(() => useTableSort(ITEMS, COMPARATORS, 'name'));
act(() => result.current.toggleSort('name'));
expect(result.current.sortDir).toBe('desc');
expect(result.current.sorted.map(r => r.name)).toEqual(['gamma', 'beta', 'alpha']);
});
it('switches key and resets direction to asc', () => {
const { result } = renderHook(() => useTableSort(ITEMS, COMPARATORS, 'name', 'desc'));
act(() => result.current.toggleSort('size'));
expect(result.current.sortKey).toBe('size');
expect(result.current.sortDir).toBe('asc');
expect(result.current.sorted.map(r => r.size)).toEqual([10, 20, 30]);
});
it('does not mutate the input array', () => {
const input = [...ITEMS];
renderHook(() => useTableSort(input, COMPARATORS, 'size'));
expect(input.map(r => r.name)).toEqual(['beta', 'alpha', 'gamma']);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { useMemo, useState } from 'react';
export type SortDir = 'asc' | 'desc';
/**
* Shared client-side table sort. Pass a stable `comparators` map (define it at
* module scope so the memo does not re-sort every render). This is the standard
* sort behavior for data tables, mirroring the Security Images tab.
*/
export function useTableSort<T, K extends string>(
items: T[],
comparators: Record<K, (a: T, b: T) => number>,
// NoInfer so K is inferred from `comparators` only; otherwise `initialKey`
// collapses K to a single literal and the column keys fail to type-check.
initialKey: NoInfer<K>,
initialDir: SortDir = 'asc',
) {
const [sortKey, setSortKey] = useState<K>(initialKey);
const [sortDir, setSortDir] = useState<SortDir>(initialDir);
const sorted = useMemo(() => {
const cmp = comparators[sortKey];
const dir = sortDir === 'asc' ? 1 : -1;
return [...items].sort((a, b) => cmp(a, b) * dir);
}, [items, comparators, sortKey, sortDir]);
const toggleSort = (key: K) => {
if (key === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
else { setSortKey(key); setSortDir('asc'); }
};
return { sorted, sortKey, sortDir, toggleSort };
}