mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
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:
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user