feat(ui): section Docker interactive sur la tuile machine (tâche 3)
Branche le frontend sur le backend Docker (SJ-4/5/6) : - scan, configuration des racines Compose, liste stacks + services avec badges de statut (candidat/activé/maj dispo/à jour) - activer/ignorer/désactiver un stack ; pull-check (non destructif) - apply/down/prune via action_request + confirmation Popup (design system) - toute action streamée auto-sélectionne la machine → flux visible dans le terminal de droite (outputHub rejoue le buffer) - api client : docker settings/roots/scan/stacks/status + action-requests - icônes trash/check, styles docker-* (variables CSS uniquement) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,8 @@ const ICON_MAP = {
|
|||||||
download: 'download',
|
download: 'download',
|
||||||
upload: 'upload',
|
upload: 'upload',
|
||||||
database: 'database',
|
database: 'database',
|
||||||
|
trash: 'trash',
|
||||||
|
check: 'check',
|
||||||
folder: 'folder',
|
folder: 'folder',
|
||||||
docker: 'boxes-stacked',
|
docker: 'boxes-stacked',
|
||||||
package: 'box-open',
|
package: 'box-open',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// client/src/features/machines/MachineTile.tsx
|
// client/src/features/machines/MachineTile.tsx
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { MachineStatus, MachineView } from "@shared/types.js";
|
import type { ActionType, MachineStatus, MachineView } from "@shared/types.js";
|
||||||
import { Button, Icon, IconButton, StatusLed } from "../../components/ui-kit.js";
|
import { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js";
|
||||||
|
import { api, type DockerSettingsView, type DockerStackRow, type StackStatus } from "../../lib/api.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
machine: MachineView;
|
machine: MachineView;
|
||||||
@@ -114,7 +115,7 @@ export function MachineTile({
|
|||||||
open={dockerOpen}
|
open={dockerOpen}
|
||||||
onToggle={() => setDockerOpen((value) => !value)}
|
onToggle={() => setDockerOpen((value) => !value)}
|
||||||
/>
|
/>
|
||||||
{dockerOpen && <DockerSection />}
|
{dockerOpen && <DockerSection machineId={machine.id} onSelect={onSelect} />}
|
||||||
|
|
||||||
<SectionToggle
|
<SectionToggle
|
||||||
icon="script"
|
icon="script"
|
||||||
@@ -161,20 +162,296 @@ function SectionToggle({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DockerSection() {
|
interface ConfirmState {
|
||||||
|
action: ActionType;
|
||||||
|
stackId?: string;
|
||||||
|
aggressive?: boolean;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DockerSection({ machineId, onSelect }: { machineId: string; onSelect: (id: string) => void }) {
|
||||||
|
const [settings, setSettings] = useState<DockerSettingsView | null>(null);
|
||||||
|
const [stacks, setStacks] = useState<DockerStackRow[] | null>(null);
|
||||||
|
const [rootsInput, setRootsInput] = useState("");
|
||||||
|
const [showRoots, setShowRoots] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||||
|
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [s, st] = await Promise.all([api.dockerSettings(machineId), api.dockerStacks(machineId)]);
|
||||||
|
setSettings(s);
|
||||||
|
setStacks(st);
|
||||||
|
if (s.roots.length) setRootsInput(s.roots.map((r) => r.path).join("\n"));
|
||||||
|
} catch (err) {
|
||||||
|
setMsg({ kind: "err", text: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [machineId]);
|
||||||
|
|
||||||
|
async function withBusy(key: string, fn: () => Promise<void>) {
|
||||||
|
setBusy(key);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
setMsg({ kind: "err", text: (err as Error).message });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveRoots = () =>
|
||||||
|
withBusy("roots", async () => {
|
||||||
|
const paths = rootsInput.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (!paths.length) throw new Error("Indique au moins une racine Compose.");
|
||||||
|
await api.dockerSetRoots(machineId, paths);
|
||||||
|
await load();
|
||||||
|
setShowRoots(false);
|
||||||
|
setMsg({ kind: "ok", text: "Racines enregistrées." });
|
||||||
|
});
|
||||||
|
|
||||||
|
const scan = () =>
|
||||||
|
withBusy("scan", async () => {
|
||||||
|
onSelect(machineId); // bascule le terminal de droite sur cette machine
|
||||||
|
await api.dockerScan(machineId);
|
||||||
|
setMsg({ kind: "ok", text: "Scan lancé… (voir le terminal de droite)" });
|
||||||
|
window.setTimeout(() => void load(), 6000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const setStatus = (stackId: string, status: StackStatus) =>
|
||||||
|
withBusy(`stack:${stackId}`, async () => {
|
||||||
|
await api.setStackStatus(machineId, stackId, status);
|
||||||
|
await load();
|
||||||
|
});
|
||||||
|
|
||||||
|
const pullCheck = (stackId: string) =>
|
||||||
|
withBusy(`pull:${stackId}`, async () => {
|
||||||
|
onSelect(machineId); // bascule le terminal de droite sur cette machine
|
||||||
|
await api.runAction(machineId, "docker_pull_check", stackId);
|
||||||
|
setMsg({ kind: "ok", text: "Pull-check lancé… (voir le terminal de droite)" });
|
||||||
|
window.setTimeout(() => void load(), 8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const runConfirmed = () =>
|
||||||
|
withBusy("confirm", async () => {
|
||||||
|
if (!confirm) return;
|
||||||
|
onSelect(machineId); // bascule le terminal de droite sur cette machine
|
||||||
|
const req = await api.createActionRequest(machineId, {
|
||||||
|
action: confirm.action,
|
||||||
|
stackId: confirm.stackId,
|
||||||
|
aggressive: confirm.aggressive,
|
||||||
|
});
|
||||||
|
await api.approveActionRequest(req.id);
|
||||||
|
setConfirm(null);
|
||||||
|
setMsg({ kind: "ok", text: `${confirm.label} : demande approuvée et lancée.` });
|
||||||
|
window.setTimeout(() => void load(), 8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabledExists = (stacks ?? []).some((s) => s.status === "enabled");
|
||||||
|
const lastScan = settings?.settings?.lastScanAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="machine-section-body">
|
<div className="machine-section-body">
|
||||||
<div className="machine-section-row">
|
<div className="docker-toolbar">
|
||||||
<span className="mono">Docker non scanné</span>
|
<Button icon="refresh" size="sm" onClick={busy ? undefined : scan}>
|
||||||
<Button icon="cog" size="sm" onClick={() => undefined}>Paramètres</Button>
|
{busy === "scan" ? "Scan…" : "Scanner"}
|
||||||
</div>
|
</Button>
|
||||||
<div className="machine-placeholder">
|
<Button icon="cog" size="sm" variant="ghost" onClick={() => setShowRoots((v) => !v)}>
|
||||||
Roots compose, stacks, upgrades image et prune seront affichés ici dès que le backend Docker sera disponible.
|
Racines{settings?.roots.length ? ` (${settings.roots.length})` : ""}
|
||||||
|
</Button>
|
||||||
|
{enabledExists && (
|
||||||
|
<Button
|
||||||
|
icon="trash"
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() =>
|
||||||
|
setConfirm({
|
||||||
|
action: "docker_prune_images",
|
||||||
|
label: "Prune images",
|
||||||
|
detail: "Supprime les images Docker inutilisées (mode sûr : dangling uniquement) sur cette machine.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Prune
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{lastScan && <span className="docker-laststamp mono">scan {formatDate(lastScan)}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showRoots && (
|
||||||
|
<div className="docker-roots">
|
||||||
|
<span className="label">Racines Compose (une par ligne)</span>
|
||||||
|
<textarea
|
||||||
|
className="su-field settings-textarea"
|
||||||
|
value={rootsInput}
|
||||||
|
onChange={(e) => setRootsInput(e.target.value)}
|
||||||
|
placeholder={"/home/gilles/docker\n/opt/stacks"}
|
||||||
|
/>
|
||||||
|
<Button icon="check" size="sm" variant="primary" onClick={busy ? undefined : saveRoots}>
|
||||||
|
{busy === "roots" ? "Enregistrement…" : "Enregistrer & activer"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stacks === null ? (
|
||||||
|
<div className="machine-placeholder">Chargement…</div>
|
||||||
|
) : stacks.length === 0 ? (
|
||||||
|
<div className="machine-placeholder">
|
||||||
|
Aucun stack détecté. Déclare des racines Compose puis lance un scan.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="docker-stacks">
|
||||||
|
{stacks.map((stack) => (
|
||||||
|
<DockerStackCard
|
||||||
|
key={stack.id}
|
||||||
|
stack={stack}
|
||||||
|
busy={busy}
|
||||||
|
onActivate={() => setStatus(stack.id, "enabled")}
|
||||||
|
onIgnore={() => setStatus(stack.id, "ignored")}
|
||||||
|
onDisable={() => setStatus(stack.id, "candidate")}
|
||||||
|
onPullCheck={() => pullCheck(stack.id)}
|
||||||
|
onApply={() =>
|
||||||
|
setConfirm({
|
||||||
|
action: "docker_compose_apply",
|
||||||
|
stackId: stack.id,
|
||||||
|
label: `Appliquer ${stack.name}`,
|
||||||
|
detail: `Recrée les conteneurs du stack « ${stack.name} » (docker compose up -d). Bref redémarrage du service.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onDown={() =>
|
||||||
|
setConfirm({
|
||||||
|
action: "docker_compose_down",
|
||||||
|
stackId: stack.id,
|
||||||
|
label: `Arrêter ${stack.name}`,
|
||||||
|
detail: `Arrête le stack « ${stack.name} » (docker compose down). Les volumes sont préservés.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{msg && <p className={`docker-msg ${msg.kind === "err" ? "docker-msg-err" : "docker-msg-ok"}`}>{msg.text}</p>}
|
||||||
|
|
||||||
|
<Popup
|
||||||
|
open={confirm !== null}
|
||||||
|
onClose={() => setConfirm(null)}
|
||||||
|
title={confirm?.label ?? ""}
|
||||||
|
width={420}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" icon="close" onClick={() => setConfirm(null)}>Annuler</Button>
|
||||||
|
<Button variant="danger" icon="check" onClick={busy === "confirm" ? undefined : runConfirmed}>
|
||||||
|
{busy === "confirm" ? "Lancement…" : "Confirmer"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>{confirm?.detail}</p>
|
||||||
|
<p className="docker-confirm-note">
|
||||||
|
<Icon name="shield" size={13} style={undefined} /> Action tracée comme demande validée (action_request).
|
||||||
|
</p>
|
||||||
|
</Popup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DockerStackCard({
|
||||||
|
stack,
|
||||||
|
busy,
|
||||||
|
onActivate,
|
||||||
|
onIgnore,
|
||||||
|
onDisable,
|
||||||
|
onPullCheck,
|
||||||
|
onApply,
|
||||||
|
onDown,
|
||||||
|
}: {
|
||||||
|
stack: DockerStackRow;
|
||||||
|
busy: string | null;
|
||||||
|
onActivate: () => void;
|
||||||
|
onIgnore: () => void;
|
||||||
|
onDisable: () => void;
|
||||||
|
onPullCheck: () => void;
|
||||||
|
onApply: () => void;
|
||||||
|
onDown: () => void;
|
||||||
|
}) {
|
||||||
|
const isEnabled = stack.status === "enabled";
|
||||||
|
const stackBusy = busy === `stack:${stack.id}`;
|
||||||
|
const pullBusy = busy === `pull:${stack.id}`;
|
||||||
|
return (
|
||||||
|
<div className="docker-stack">
|
||||||
|
<div className="docker-stack-head">
|
||||||
|
<span className="docker-stack-name">{stack.name}</span>
|
||||||
|
<DockerBadge status={stack.status} />
|
||||||
|
{stack.detectedBy && <span className="docker-stack-by mono">{stack.detectedBy}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="docker-stack-actions">
|
||||||
|
{stack.status === "candidate" && (
|
||||||
|
<>
|
||||||
|
<Button icon="check" size="sm" variant="primary" onClick={stackBusy ? undefined : onActivate}>Activer</Button>
|
||||||
|
<Button icon="close" size="sm" variant="ghost" onClick={stackBusy ? undefined : onIgnore}>Ignorer</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isEnabled && (
|
||||||
|
<>
|
||||||
|
<Button icon="download" size="sm" onClick={pullBusy ? undefined : onPullCheck}>
|
||||||
|
{pullBusy ? "Pull…" : "Pull-check"}
|
||||||
|
</Button>
|
||||||
|
<Button icon="upgrade" size="sm" variant="primary" onClick={onApply}>Appliquer</Button>
|
||||||
|
<Button icon="power" size="sm" variant="danger" onClick={onDown}>Down</Button>
|
||||||
|
<Button icon="close" size="sm" variant="ghost" onClick={stackBusy ? undefined : onDisable}>Désactiver</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stack.services.length > 0 && (
|
||||||
|
<div className="docker-services">
|
||||||
|
{stack.services.map((svc) => (
|
||||||
|
<div key={svc.id} className="docker-service">
|
||||||
|
<span className="docker-service-name mono">{svc.imageRef ?? svc.serviceName}</span>
|
||||||
|
<DockerBadge status={svc.status ?? "unknown"} />
|
||||||
|
{svc.status === "updates_available" && (
|
||||||
|
<span className="docker-service-diff mono">
|
||||||
|
{shortId(svc.currentImageId)} → {shortId(svc.candidateImageId)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DockerBadge({ status }: { status: string }) {
|
||||||
|
const tone =
|
||||||
|
status === "updates_available" ? "warn" :
|
||||||
|
status === "up_to_date" || status === "enabled" ? "ok" :
|
||||||
|
status === "error" ? "err" :
|
||||||
|
status === "candidate" ? "info" : "off";
|
||||||
|
const label =
|
||||||
|
status === "updates_available" ? "maj dispo" :
|
||||||
|
status === "up_to_date" ? "à jour" :
|
||||||
|
status === "enabled" ? "activé" :
|
||||||
|
status === "candidate" ? "candidat" :
|
||||||
|
status === "ignored" ? "ignoré" :
|
||||||
|
status === "error" ? "erreur" : status;
|
||||||
|
return <span className={`docker-badge docker-badge-${tone}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(id: string | null): string {
|
||||||
|
if (!id) return "—";
|
||||||
|
const hex = id.startsWith("sha256:") ? id.slice(7) : id;
|
||||||
|
return hex.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function PostInstallSection() {
|
function PostInstallSection() {
|
||||||
return (
|
return (
|
||||||
<div className="machine-section-body">
|
<div className="machine-section-body">
|
||||||
|
|||||||
+69
-2
@@ -35,10 +35,34 @@ export const api = {
|
|||||||
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
|
createMachine: (body: unknown) => req<MachineView>("/machines", { method: "POST", body: JSON.stringify(body) }),
|
||||||
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
|
refresh: (id: string) => req<UpdateSnapshot>(`/machines/${id}/refresh`, { method: "POST" }),
|
||||||
snapshot: (id: string) => req<UpdateSnapshot>(`/machines/${id}/snapshot`),
|
snapshot: (id: string) => req<UpdateSnapshot>(`/machines/${id}/snapshot`),
|
||||||
runAction: (id: string, action: ActionType) =>
|
runAction: (id: string, action: ActionType, stackId?: string) =>
|
||||||
req<{ ok: boolean }>(`/machines/${id}/actions`, { method: "POST", body: JSON.stringify({ action }) }),
|
req<{ ok: boolean }>(`/machines/${id}/actions`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(stackId ? { action, stackId } : { action }),
|
||||||
|
}),
|
||||||
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
deleteMachine: (id: string) => req<{ ok: boolean }>(`/machines/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// --- Docker ---
|
||||||
|
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
||||||
|
dockerSetRoots: (id: string, paths: string[], scanDepth = 4) =>
|
||||||
|
req<DockerSettingsView>(`/machines/${id}/docker/roots`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ paths, scanDepth }),
|
||||||
|
}),
|
||||||
|
dockerScan: (id: string) => req<{ ok: boolean }>(`/machines/${id}/docker/scan`, { method: "POST" }),
|
||||||
|
dockerStacks: (id: string) => req<DockerStackRow[]>(`/machines/${id}/docker/stacks`),
|
||||||
|
setStackStatus: (id: string, stackId: string, status: StackStatus) =>
|
||||||
|
req<DockerStackRow>(`/machines/${id}/docker/stacks/${stackId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// --- Demandes d'action destructive ---
|
||||||
|
createActionRequest: (id: string, body: { action: ActionType; stackId?: string; aggressive?: boolean; summary?: string }) =>
|
||||||
|
req<ActionRequestRow>(`/machines/${id}/action-requests`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
approveActionRequest: (reqId: string, approvedBy = "ui") =>
|
||||||
|
req<ActionRequestRow>(`/action-requests/${reqId}/approve`, { method: "POST", body: JSON.stringify({ approvedBy }) }),
|
||||||
|
|
||||||
// --- Sauvegarde / restauration de la base ---
|
// --- Sauvegarde / restauration de la base ---
|
||||||
dbInfo: () => req<DbInfo>("/system/db/info"),
|
dbInfo: () => req<DbInfo>("/system/db/info"),
|
||||||
/** Télécharge l'archive de la base et déclenche le téléchargement navigateur. */
|
/** Télécharge l'archive de la base et déclenche le téléchargement navigateur. */
|
||||||
@@ -82,3 +106,46 @@ export interface DbRestoreResult {
|
|||||||
safetyBackup: string;
|
safetyBackup: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StackStatus = "candidate" | "enabled" | "ignored" | "error";
|
||||||
|
|
||||||
|
export interface DockerSettingsView {
|
||||||
|
settings: { machineId: string; enabled: number; scanDepth: number; pruneMode: string; lastScanAt: string | null; lastPullCheckAt: string | null } | null;
|
||||||
|
roots: Array<{ id: string; path: string; enabled: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerServiceRow {
|
||||||
|
id: string;
|
||||||
|
stackId: string;
|
||||||
|
serviceName: string;
|
||||||
|
imageRef: string | null;
|
||||||
|
currentImageId: string | null;
|
||||||
|
currentDigest: string | null;
|
||||||
|
candidateImageId: string | null;
|
||||||
|
candidateDigest: string | null;
|
||||||
|
versionLabel: string | null;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerStackRow {
|
||||||
|
id: string;
|
||||||
|
machineId: string;
|
||||||
|
name: string;
|
||||||
|
workingDir: string;
|
||||||
|
status: StackStatus;
|
||||||
|
detectedBy: string | null;
|
||||||
|
lastScanAt: string | null;
|
||||||
|
lastUpdateAt: string | null;
|
||||||
|
composeFiles: string[];
|
||||||
|
services: DockerServiceRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionRequestRow {
|
||||||
|
id: string;
|
||||||
|
machineId: string;
|
||||||
|
action: ActionType;
|
||||||
|
risk: string | null;
|
||||||
|
status: "pending" | "approved" | "rejected" | "executed" | "expired";
|
||||||
|
summary: string | null;
|
||||||
|
executionId: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,6 +158,67 @@ body {
|
|||||||
.machine-section-row { justify-content: space-between; gap: 8px; }
|
.machine-section-row { justify-content: space-between; gap: 8px; }
|
||||||
.machine-placeholder { color: var(--ink-3); font-size: 12px; line-height: 1.35; }
|
.machine-placeholder { color: var(--ink-3); font-size: 12px; line-height: 1.35; }
|
||||||
.machine-check-row { gap: 8px; color: var(--ink-2); font-size: 13px; }
|
.machine-check-row { gap: 8px; color: var(--ink-2); font-size: 13px; }
|
||||||
|
|
||||||
|
/* --- Docker section --- */
|
||||||
|
.docker-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.docker-laststamp { color: var(--ink-3); font-size: 11px; margin-left: auto; }
|
||||||
|
.docker-roots {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.docker-roots .settings-textarea { min-height: 60px; }
|
||||||
|
.docker-stacks { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.docker-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
.docker-stack-head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.docker-stack-name { font-weight: 600; color: var(--ink-1); font-size: 13px; }
|
||||||
|
.docker-stack-by { color: var(--ink-3); font-size: 11px; margin-left: auto; }
|
||||||
|
.docker-stack-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.docker-services {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--border-1);
|
||||||
|
}
|
||||||
|
.docker-service { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||||
|
.docker-service-name { color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.docker-service-diff { color: var(--ink-3); font-size: 11px; margin-left: auto; }
|
||||||
|
.docker-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.docker-badge-ok { color: var(--ok); border-color: var(--ok); }
|
||||||
|
.docker-badge-warn { color: var(--warn); border-color: var(--warn); }
|
||||||
|
.docker-badge-err { color: var(--err); border-color: var(--err); }
|
||||||
|
.docker-badge-info { color: var(--accent); border-color: var(--accent-soft); }
|
||||||
|
.docker-badge-off { color: var(--ink-3); }
|
||||||
|
.docker-msg { font-size: 12px; margin: 4px 0 0; }
|
||||||
|
.docker-msg-ok { color: var(--ok); }
|
||||||
|
.docker-msg-err { color: var(--err); }
|
||||||
|
.docker-confirm-note { display: flex; align-items: center; gap: 7px; color: var(--ink-3); font-size: 12px; margin-top: 10px; }
|
||||||
.machine-check-row input { accent-color: var(--accent); }
|
.machine-check-row input { accent-color: var(--accent); }
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user