diff --git a/frontend/src/components/docker/ContainersTab.jsx b/frontend/src/components/docker/ContainersTab.jsx index 60960b08..46eb7bbc 100644 --- a/frontend/src/components/docker/ContainersTab.jsx +++ b/frontend/src/components/docker/ContainersTab.jsx @@ -6,18 +6,19 @@ import LogToolbar from '../log-viewer/LogToolbar'; import LogContent from '../log-viewer/LogContent'; import EmptyState from '../EmptyState'; import Modal from '@/components/Modal'; -import { Drawer, DataTable, DataTableFooter, Pill } from '@/components/ds'; +import { Drawer, DataTable, DataTableFooter, Pill, SearchField } from '@/components/ds'; import { useTableChrome, GridViewPicker, GridChips, GridFilterButton, - GridToolsMenu, GridFilterDrawer, + GridToolsMenu, GridFilterDrawer, GridBulkBar, } from '@/components/ds/grid'; +import { formatRelativeTime } from '@/utils/time'; import { useTableSort } from '@/hooks/useTableSort'; import { useColumnVisibility } from '@/hooks/useColumnVisibility'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { - Box, Search, X, RefreshCw, Trash2, Play, Square, RotateCw, + Box, X, Trash2, Play, Square, RotateCw, Terminal as TerminalLucide, FileText, Activity, Clock3, Copy, Database, Gauge, Package, Server as ServerIcon, Lock, } from 'lucide-react'; @@ -191,9 +192,20 @@ const ContainersTab = ({ onStatsChange }) => { defaultSorts: [{ key: 'status', direction: 'asc' }], storageKey: 'serverkit-table-docker-containers-sort', }); + // Health is off by default. On a typical host almost every row reads "No + // healthcheck", so the column spent its width restating that the image + // never declared one — and Status already says "(healthy)" for the few that + // do. It is one click away in the column menu, and the "Unhealthy or + // unwatched" view turns it back on, which is where it actually says + // something. const { hiddenKeys, setHiddenKeys, - } = useColumnVisibility({ storageKey: 'serverkit-table-docker-containers-cols' }); + } = useColumnVisibility({ + defaultHidden: ['health'], + storageKey: 'serverkit-table-docker-containers-cols', + }); + const [picked, setPicked] = useState([]); + const [bulkBusy, setBulkBusy] = useState(false); // Not persisted on its own: a grouping worth keeping is a saved view. const [groupBy, setGroupBy] = useState(null); const statsRequestSeq = useRef(0); @@ -347,6 +359,52 @@ const ContainersTab = ({ onStatsChange }) => { } } + const toggleRow = useCallback((key, on) => { + setPicked((prev) => (on ? [...new Set([...prev, key])] : prev.filter((k) => k !== key))); + }, []); + + // Runs the same per-container call the row buttons run, one at a time so a + // single failure is reported against the container that failed rather than + // aborting the rest of the selection. + async function handleBulkAction(action) { + const targets = containers.filter((c) => picked.includes(getContainerId(c))); + const actionable = targets.filter((c) => !isProtectedContainer(c)); + const skipped = targets.length - actionable.length; + if (!actionable.length) { + toast.error(skipped ? 'Only system containers were selected' : 'Nothing selected'); + return; + } + + setBulkBusy(true); + let ok = 0; + const failed = []; + for (const container of actionable) { + const id = getContainerId(container); + try { + if (isRemote) { + if (action === 'start') await api.startRemoteContainer(serverId, id); + else if (action === 'stop') await api.stopRemoteContainer(serverId, id); + else await api.restartRemoteContainer(serverId, id); + } else if (action === 'start') await api.startContainer(id); + else if (action === 'stop') await api.stopContainer(id); + else await api.restartContainer(id); + ok += 1; + } catch { + failed.push(container.name || id); + } + } + setBulkBusy(false); + + if (failed.length) { + toast.error(`${failed.length} failed: ${failed.slice(0, 3).join(', ')}${failed.length > 3 ? '…' : ''}`); + } else { + toast.success(`${ok} container${ok === 1 ? '' : 's'} ${action}${action === 'stop' ? 'ped' : 'ed'}${skipped ? ` · ${skipped} system container${skipped === 1 ? '' : 's'} skipped` : ''}`); + } + setPicked([]); + loadContainers(); + onStatsChange?.(); + } + function parseStats(stats) { if (!stats) return { cpu: 0, memory: 0, available: false }; const source = stats.stats || stats; @@ -374,6 +432,16 @@ const ContainersTab = ({ onStatsChange }) => { )); }, [containers, searchTerm]); + // Declared after `filteredContainers` because it closes over it: a + // useCallback dep array is evaluated during render, so referencing the + // const from above it is a temporal-dead-zone crash, not a lint nit. + const toggleAll = useCallback((on, keys) => { + // DataTable passes no key list when the header box is clicked; fall + // back to everything currently on screen. + const set = keys ?? filteredContainers.map((c) => getContainerId(c)); + setPicked((prev) => (on ? [...new Set([...prev, ...set])] : prev.filter((k) => !set.includes(k)))); + }, [filteredContainers]); + const selectedStats = selectedContainer ? parseStats(containerStats[getContainerId(selectedContainer)]) : { cpu: 0, memory: 0, available: false }; @@ -513,6 +581,9 @@ const ContainersTab = ({ onStatsChange }) => { key: 'resources', header: 'Resources', sortable: true, + // Two labelled bars need a floor, or the column collapses to the + // width of its truncated header on a narrow viewport. + width: 150, // Declared, not inferred: on a host where everything is stopped no // row has a reading at all, and an all-null sample infers no type — // the column would offer filtering on a busy host and not on an @@ -537,20 +608,35 @@ const ContainersTab = ({ onStatsChange }) => { // The raw value is docker's CreatedAt ("2026-08-12 09:12:33 -0400 // EDT"), which both Date.parse and the rule engine read. type: 'date', + width: 110, value: (container) => container.created || container.CreatedAt || null, sortValue: (container) => { const parsed = Date.parse(container.created || container.CreatedAt || ''); return Number.isNaN(parsed) ? null : parsed; }, - render: (container) => ( - {container.created || container.CreatedAt || '-'} - ), + // Docker hands back "2026-08-12 09:12:33 -0400 EDT" — 29 characters + // of which the last 14 are never what you came to read. Shown as an + // age, with the full stamp on hover; the filter still works on the + // real date because `value` is untouched. + render: (container) => { + const raw = container.created || container.CreatedAt || ''; + const parsed = Date.parse(raw); + if (Number.isNaN(parsed)) return -; + return ( + + {formatRelativeTime(new Date(parsed).toISOString())} + + ); + }, }, { key: '__actions', - header: 'Actions', + header: '', sortable: false, hideable: false, + // Fixed: the actions used to sit in an auto-width column, so a long + // Created value pushed its own text underneath them. + width: 132, className: 'text-right', render: (container) => { const containerId = getContainerId(container); @@ -633,17 +719,32 @@ const ContainersTab = ({ onStatsChange }) => { return (
- {/* The view name is this pane's heading, and the table's own - controls ride the same line. `shownCount` is the count that - matches the body: `filteredContainers` has only been through the - search, the column rules are applied inside DataTable. */} + {/* One row of chrome. The view name is this pane's heading and + everything that acts on the table — the fetch scope, the search, + the filter, the "⋮" — rides the same line. This pane is inside + the Docker page's tab group, whose top bar belongs to that page, + so the chrome stays here rather than being hoisted (see + useTopbarChrome). Refresh is not repeated as an icon: it is in + the "⋮" with the other table-wide actions. */} + + chrome.setDrawerOpen(true)} @@ -655,46 +756,21 @@ const ContainersTab = ({ onStatsChange }) => { - {/* What is left of the toolbar is the page's own: the fetch scope - and the search it runs before the table sees a row. The - All/Running/Stopped chips that used to sit here were column - rules in disguise and are built-in views now. */} -
- -
-
- - setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
- -
-
+ {/* Restart/stop/start across a selection. Remove is deliberately + absent: bulk-deleting containers from a checkbox is a mistake + you cannot undo, and the per-row action already asks. Protected + system containers are skipped, not silently failed. */} + setPicked([])}> + + + + {/* Only the SEARCH can empty the pane outright. A column rule that matches nothing keeps the table mounted — the header menu that @@ -719,6 +795,10 @@ const ContainersTab = ({ onStatsChange }) => { onSortsChange={setSorts} groupBy={groupBy} onGroupByChange={setGroupBy} + selectable + selectedKeys={picked} + onToggleRow={toggleRow} + onToggleAll={toggleAll} onRowClick={(container) => setSelectedContainer(container)} rowClassName={(container) => ( `${isContainerRunning(container) ? 'is-running' : 'is-stopped'} ${getContainerId(selectedContainer) === getContainerId(container) ? 'is-selected' : ''}` diff --git a/frontend/src/styles/pages/_docker.scss b/frontend/src/styles/pages/_docker.scss index be75a8f3..3a7347d0 100644 --- a/frontend/src/styles/pages/_docker.scss +++ b/frontend/src/styles/pages/_docker.scss @@ -1230,6 +1230,10 @@ color: $text-secondary; font-size: $font-size-xs; cursor: pointer; + // Sits in the view row next to the search box now, where a flex parent will + // happily wrap "Include stopped" onto two lines and make the row taller + // than everything else on it. + white-space: nowrap; input { width: 14px;