From f253523d5ba4cae2e6cb4abbb3b2c0f3affab769 Mon Sep 17 00:00:00 2001 From: Doug Horner Date: Tue, 2 Jun 2026 22:56:21 -0400 Subject: [PATCH] feat(client): dense card-default views for containers and nodes Redesign the Containers and Nodes list pages to fix horizontal scrolling and improve mobile usability. - Default to a compact card (div-per-row) layout instead of a wide table; each card is a single dense row with inline metadata. - Add a persisted Cards/Table view toggle (localStorage) so the table remains available on demand. - Shorten container template to its image name+tag (full ref on hover). - Make header and row action buttons icon-only on mobile (with aria-labels), restoring text labels at the sm breakpoint. - Keep SSH host:port from wrapping mid-address. Verified no horizontal scroll and no overflow at 360px width. --- .gitignore | 1 + .../pages/containers/ContainersListPage.tsx | 352 +++++++++++++----- .../client/src/pages/nodes/NodesListPage.tsx | 184 ++++++++- 3 files changed, 418 insertions(+), 119 deletions(-) diff --git a/.gitignore b/.gitignore index 45adf944..1176a0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .env .tmp-verify/ +.playwright-mcp/ diff --git a/create-a-container/client/src/pages/containers/ContainersListPage.tsx b/create-a-container/client/src/pages/containers/ContainersListPage.tsx index 4ebd8fc4..ec5a195f 100644 --- a/create-a-container/client/src/pages/containers/ContainersListPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainersListPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Link, useLocation, useParams } from 'react-router'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { @@ -6,6 +7,8 @@ import { AlertTitle, Badge, Button, + Card, + CardTitle, PageHeader, Spinner, Table, @@ -16,12 +19,26 @@ import { TableRow, useToast, } from '@mieweb/ui'; -import { Code2, Container as ContainerIcon, ExternalLink, Pencil, Plus, Terminal, Trash2 } from 'lucide-react'; +import { + Code2, + Container as ContainerIcon, + ExternalLink, + LayoutGrid, + Pencil, + Plus, + Rows3, + Server, + Terminal, + Trash2, +} from 'lucide-react'; import { api, ApiError } from '@/lib/api'; import { useSession } from '@/lib/auth'; import { keys, queries } from '@/lib/queries'; import type { Container } from '@/lib/types'; +type ViewMode = 'cards' | 'table'; +const VIEW_STORAGE_KEY = 'containers:view'; + function statusVariant(s: string): 'default' | 'success' | 'warning' | 'danger' | 'secondary' { switch (s) { case 'running': @@ -39,6 +56,143 @@ function statusVariant(s: string): 'default' | 'success' | 'warning' | 'danger' } } +const linkClass = 'text-(--color-primary,#1d4ed8) hover:underline'; + +/** Shorten a full image ref to just its name+tag, e.g. ghcr.io/mieweb/base:latest -> base:latest */ +function templateTitle(template: string | null): string { + if (!template) return '—'; + return template.split('/').pop() || template; +} + +function NodeLink({ c }: { c: Container }) { + if (!c.nodeApiUrl) return <>{c.nodeName || '—'}; + return ( + + {c.nodeName || c.nodeApiUrl} + + ); +} + +function HttpLinks({ c, limit }: { c: Container; limit?: number }) { + if (c.httpEntries.length === 0) return ; + const entries = limit ? c.httpEntries.slice(0, limit) : c.httpEntries; + return ( + + {entries.map((h) => + h.externalUrl ? ( + + + ) : ( + + :{h.port} + + ), + )} + + ); +} + +function SshLinks({ c, sessionUser }: { c: Container; sessionUser?: string }) { + if (!c.sshHost || !c.sshPort) return ; + return ( + + + {c.sshHost}:{c.sshPort} + + {sessionUser && ( + <> + + + + + + )} + + ); +} + +function RowActions({ + c, + siteId, + onDelete, + deleting, +}: { + c: Container; + siteId?: string; + onDelete: (id: number) => void; + deleting: boolean; +}) { + return ( + <> + {c.creationJobId && ( + + + + )} + + + + + + ); +} + +function Meta({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + export function ContainersListPage() { const { siteId } = useParams<{ siteId: string }>(); const qc = useQueryClient(); @@ -48,6 +202,19 @@ export function ContainersListPage() { const location = useLocation(); const dnsWarnings = (location.state as { dnsWarnings?: string[] } | null)?.dnsWarnings; + const [view, setView] = useState(() => { + const stored = typeof window !== 'undefined' ? window.localStorage.getItem(VIEW_STORAGE_KEY) : null; + return stored === 'table' ? 'table' : 'cards'; + }); + const changeView = (next: ViewMode) => { + setView(next); + try { + window.localStorage.setItem(VIEW_STORAGE_KEY, next); + } catch { + /* ignore storage failures */ + } + }; + const { data: site } = useQuery({ queryKey: keys.site(siteId!), queryFn: () => queries.getSite(siteId!), @@ -68,6 +235,8 @@ export function ContainersListPage() { onError: (err: ApiError) => toast.error(err.message), }); + const hasContainers = !!data && data.length > 0; + return (
} actions={ -
+
+ {hasContainers && ( +
+ + +
+ )} - + -
@@ -117,7 +316,47 @@ export function ContainersListPage() { )} - {data && data.length > 0 && ( + {hasContainers && view === 'cards' && ( +
+ {data.map((c: Container) => ( + +
+ + {c.hostname} + + {c.status} +
+
+ +
+
+ + + + + + {templateTitle(c.template)} + + + + + + + + +
+
+ ))} +
+ )} + + {hasContainers && view === 'table' && ( @@ -138,106 +377,19 @@ export function ContainersListPage() { {c.status} - {c.nodeApiUrl ? ( - - {c.nodeName || c.nodeApiUrl} - - ) : ( - c.nodeName || '—' - )} + - - {c.template || '—'} + + {templateTitle(c.template)} - {c.httpEntries.length === 0 ? ( - '—' - ) : ( -
- {c.httpEntries.slice(0, 2).map((h) => - h.externalUrl ? ( - - - {h.externalUrl.replace(/^https?:\/\//, '')} - - ) : ( - - :{h.port} - - ), - )} -
- )} +
- - {c.sshHost && c.sshPort ? ( -
- {c.sshHost}:{c.sshPort} - {sessionUser && ( - <> - - - - - - )} -
- ) : ( - '—' - )} + + - {c.creationJobId && ( - - - - )} - - - - +
))} diff --git a/create-a-container/client/src/pages/nodes/NodesListPage.tsx b/create-a-container/client/src/pages/nodes/NodesListPage.tsx index d2628c06..004b146e 100644 --- a/create-a-container/client/src/pages/nodes/NodesListPage.tsx +++ b/create-a-container/client/src/pages/nodes/NodesListPage.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Link, useParams } from 'react-router'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { @@ -5,6 +6,8 @@ import { AlertDescription, Badge, Button, + Card, + CardTitle, PageHeader, Spinner, Table, @@ -15,11 +18,71 @@ import { TableRow, useToast, } from '@mieweb/ui'; -import { Download, Pencil, Plus, Server, Trash2 } from 'lucide-react'; +import { Download, LayoutGrid, Pencil, Plus, Rows3, Server, Trash2 } from 'lucide-react'; import { api, ApiError } from '@/lib/api'; import { keys, queries } from '@/lib/queries'; import type { Node } from '@/lib/types'; +type ViewMode = 'cards' | 'table'; +const VIEW_STORAGE_KEY = 'nodes:view'; + +function Meta({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +function NvidiaBadge({ n }: { n: Node }) { + return n.nvidiaAvailable ? ( + Available + ) : ( + No + ); +} + +function CredentialsBadge({ n }: { n: Node }) { + return n.hasSecret ? Set : Missing; +} + +function RowActions({ + n, + siteId, + onDelete, + deleting, +}: { + n: Node; + siteId?: string; + onDelete: (id: number) => void; + deleting: boolean; +}) { + return ( + <> + + + + + + ); +} + export function NodesListPage() { const { siteId } = useParams<{ siteId: string }>(); const qc = useQueryClient(); @@ -31,6 +94,19 @@ export function NodesListPage() { enabled: !!siteId, }); + const [view, setView] = useState(() => { + const stored = typeof window !== 'undefined' ? window.localStorage.getItem(VIEW_STORAGE_KEY) : null; + return stored === 'table' ? 'table' : 'cards'; + }); + const changeView = (next: ViewMode) => { + setView(next); + try { + window.localStorage.setItem(VIEW_STORAGE_KEY, next); + } catch { + /* ignore storage failures */ + } + }; + const del = useMutation({ mutationFn: (id: number) => api.delete(`/api/v1/sites/${siteId}/nodes/${id}`), onSuccess: () => { @@ -40,6 +116,8 @@ export function NodesListPage() { onError: (err: ApiError) => toast.error(err.message), }); + const hasNodes = !!data && data.length > 0; + return (
} actions={ -
+
+ {hasNodes && ( +
+ + +
+ )} - + - +
} /> {error && {(error as ApiError).message}} {isLoading &&
} - {data && ( + {data && data.length === 0 && ( + + No nodes yet. Add one with the button above. + + )} + + {hasNodes && view === 'cards' && ( +
+ {data.map((n: Node) => ( + +
+ + {n.name} + + +
+
+ +
+
+ + {n.ipv4Address || '—'} + + + + {n.apiUrl || '—'} + + + + + +
+
+ ))} +
+ )} + + {hasNodes && view === 'table' && (
@@ -77,21 +230,14 @@ export function NodesListPage() { {n.name} {n.ipv4Address || '—'} {n.apiUrl || '—'} - {n.nvidiaAvailable ? Available : No} - {n.hasSecret ? Set : Missing} + + + + + + - - - - + ))}