Files
Homelab-manager/web/src/pages/Docker.tsx
T
bobbanandClaude Sonnet 5.5 ad1fb5338f Replace the browser's confirm() and prompt() pop-ups with in-app dialogs
All 31 native dialogs (27 confirms, 4 text prompts: ignore reason, two
"exclude a range" boxes, tag rename) now use the app's own Tabler-styled
modals, which follow the light/dark theme.

A small promise-based API (utils/dialogs.ts: confirmDialog, promptDialog)
and a single DialogHost mounted once in App mean call sites just await
it in place of the browser call - no hooks or per-page modal state. Every
call site was already in an async function, so each is a one-line swap.

Each dialog now has a title and a main button that names the action
("Delete", "Stop now", "Run now") instead of "OK", with destructive ones
in red. Escape cancels, Enter confirms, Tab stays inside the dialog, the
page behind stops scrolling, and focus returns to the button that was
clicked. Destructive dialogs start with focus on Cancel so a stray Enter
can't delete anything. Text prompts pre-select their default and disable
the main button until something is typed where it's required, and still
tell cancelling (null) apart from confirming an empty box (""). Clicking
the backdrop cancels, but releasing a text selection over it doesn't.
Dialogs asked for together appear one after another.

Verified in a browser on a test page using the real component and the
app's real stylesheet: Escape, Enter, Tab trapping, focus handling,
required and optional prompts, backdrop clicks, queuing, scroll lock and
dark mode. The 31 call sites themselves were checked by search and
typecheck rather than clicked through in the logged-in app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 23:07:43 +02:00

315 lines
13 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
api,
type CurrentUser,
type DockhandContainer,
type DockhandContainersResponse,
type IntegrationSummary,
} from "../api/client";
import { useSortable } from "../hooks/useSortable";
import SortableTh from "../components/SortableTh";
import { usePagination } from "../hooks/usePagination";
import Pagination from "../components/Pagination";
import { downloadCsv } from "../utils/csv";
import { confirmDialog } from "../utils/dialogs";
function updateBadge(c: DockhandContainer) {
if (c.updateAvailable === null) return <span className="text-secondary">—</span>;
if (!c.updateAvailable) return <span className="badge bg-green-lt text-green">Up to date</span>;
return (
<span className="badge bg-yellow-lt text-yellow" title={c.newerVersion ?? undefined}>
Update available
</span>
);
}
function containerStateBadge(state: string) {
switch (state) {
case "running":
return <span className="badge bg-green-lt text-green">Running</span>;
case "exited":
case "dead":
return <span className="badge bg-red-lt text-red">{state === "dead" ? "Dead" : "Exited"}</span>;
case "paused":
return <span className="badge bg-yellow-lt text-yellow">Paused</span>;
case "restarting":
return <span className="badge bg-blue-lt text-blue">Restarting</span>;
default:
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
}
}
export default function Docker({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator";
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<DockhandContainersResponse | null>(null);
const [loading, setLoading] = useState(false);
const [actingOnContainer, setActingOnContainer] = useState<string | null>(null);
const [hostFilter, setHostFilter] = useState("");
const [checkingUpdates, setCheckingUpdates] = useState(false);
useEffect(() => {
api.integrations
.list()
.then((res) => {
const dockhandIntegrations = res.integrations.filter((i) => i.type === "dockhand");
setIntegrations(dockhandIntegrations);
if (!selectedId && dockhandIntegrations.length > 0) setSelectedId(dockhandIntegrations[0].id);
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
function loadContainers(id: number) {
setLoading(true);
setError(null);
api.integrations.dockhand
.containers(id)
.then((res) => setData(res))
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoading(false));
}
useEffect(() => {
setHostFilter("");
if (selected?.enabled) {
loadContainers(selected.id);
} else {
setData(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
const hosts = [...new Set((data?.containers ?? []).map((c) => c.environmentName))].sort();
const filteredContainers = (data?.containers ?? []).filter((c) => !hostFilter || c.environmentName === hostFilter);
const { sorted, sortKey, sortDir, requestSort } = useSortable(filteredContainers);
const { pageItems, page, setPage, pageCount, totalCount } = usePagination(sorted);
function exportCsv() {
if (!sorted) return;
downloadCsv(
"docker-containers.csv",
["Container", "Host", "Image", "State", "Status", "Update available"],
sorted.map((c) => [
c.name,
c.environmentName,
c.image,
c.state,
c.status,
c.updateAvailable === null ? "unknown" : c.updateAvailable ? "yes" : "no",
]),
);
}
async function checkForUpdates() {
if (!selectedId) return;
setCheckingUpdates(true);
setError(null);
try {
await api.integrations.dockhand.checkForUpdates(selectedId);
loadContainers(selectedId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCheckingUpdates(false);
}
}
async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") {
if (!selectedId) return;
if (action === "stop" && !(await confirmDialog({ title: "Stop container", message: `Stop container "${c.name}"?`, confirmLabel: "Stop", danger: true }))) return;
setActingOnContainer(c.id);
setError(null);
try {
await api.integrations.dockhand[action](selectedId, c.environmentId, c.id);
loadContainers(selectedId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setActingOnContainer(null);
}
}
return (
<>
<h2 className="page-title mb-3">Docker</h2>
{error && <div className="alert alert-danger">{error}</div>}
{integrations?.length === 0 ? (
<div className="card">
<div className="card-body text-secondary">
No Dockhand integration configured yet.{" "}
<Link to="/integrations">Add one under Integrations</Link>.
</div>
</div>
) : (
<>
{integrations && integrations.length > 1 && (
<div className="mb-3" style={{ maxWidth: 320 }}>
<select
className="form-select"
value={selectedId ?? ""}
onChange={(e) => setSelectedId(Number(e.target.value))}
>
{integrations.map((i) => (
<option key={i.id} value={i.id} disabled={!i.enabled}>
{i.name}
{!i.enabled ? " — disabled" : ""}
</option>
))}
</select>
</div>
)}
{selected && !selected.enabled ? (
<div className="card">
<div className="card-body text-secondary">
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its
containers.
</div>
</div>
) : (
<div className="card">
<div className="card-header">
<h3 className="card-title">
Containers
{data && (
<span className="text-secondary fw-normal ms-2">
{data.summary.running}/{data.summary.total} running
</span>
)}
{data && data.containers.filter((c) => c.updateAvailable).length > 0 && (
<span className="text-yellow fw-normal ms-2">
{data.containers.filter((c) => c.updateAvailable).length} update(s) available
</span>
)}
</h3>
<div className="card-actions d-flex gap-2">
{hosts.length > 1 && (
<select
className="form-select form-select-sm"
style={{ width: "auto" }}
value={hostFilter}
onChange={(e) => setHostFilter(e.target.value)}
>
<option value="">All hosts</option>
{hosts.map((host) => (
<option key={host} value={host}>
{host}
</option>
))}
</select>
)}
<button className="btn btn-sm btn-outline-secondary" onClick={exportCsv} disabled={!sorted || sorted.length === 0}>
Export CSV
</button>
{canEdit && (
<button className="btn btn-sm btn-outline-secondary" onClick={checkForUpdates} disabled={checkingUpdates}>
{checkingUpdates ? "Checking…" : "Check for updates"}
</button>
)}
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => selected && loadContainers(selected.id)}
disabled={loading}
>
{loading ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<SortableTh<DockhandContainer> label="Container" sortKeyName="name" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DockhandContainer>
label="Host"
sortKeyName="environmentName"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
<SortableTh<DockhandContainer> label="Image" sortKeyName="image" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DockhandContainer> label="Status" sortKeyName="state" activeKey={sortKey} direction={sortDir} onSort={requestSort} />
<SortableTh<DockhandContainer>
label="Update"
sortKeyName="updateAvailable"
activeKey={sortKey}
direction={sortDir}
onSort={requestSort}
/>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{pageItems?.map((c) => (
<tr key={`${c.environmentId}:${c.id}`}>
<td>{c.name}</td>
<td className="text-secondary">{c.environmentName}</td>
<td className="text-secondary">{c.image}</td>
<td>
{containerStateBadge(c.state)}
<div className="text-secondary small">{c.status}</div>
</td>
<td>{updateBadge(c)}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
{c.state === "running" ? (
<>
<button
className="btn btn-sm"
onClick={() => containerAction(c, "restart")}
disabled={actingOnContainer === c.id}
>
Restart
</button>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => containerAction(c, "stop")}
disabled={actingOnContainer === c.id}
>
Stop
</button>
</>
) : (
<button
className="btn btn-sm btn-primary"
onClick={() => containerAction(c, "start")}
disabled={actingOnContainer === c.id}
>
Start
</button>
)}
</div>
</td>
)}
</tr>
))}
{data && sorted?.length === 0 && (
<tr>
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
{hostFilter ? `No containers found on "${hostFilter}".` : "No containers found across any environment."}
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pagination page={page} pageCount={pageCount} totalCount={totalCount} onPageChange={setPage} />
</div>
)}
</>
)}
</>
);
}