Phase 3: env masking, image updates, port conflicts, resources, templates (0.3.0)
Backend:
- update_service: registry manifest digest check (Docker Hub/ghcr/lscr/private
v2 token auth) vs local RepoDigests; in-memory cache + background loop
- port_service: parse compose ports, check /proc/net/tcp[6] + docker bindings
- template_service + bundled templates (jellyfin/vaultwarden/uptime-kuma/
paperless-ngx/gitea) with {{VAR}} placeholders; custom templates in DB
- compose_edit set_resources (deploy.resources.limits/reservations)
- routers: images, ports, templates, editor/set-resources
- Template model; background update task wired into lifespan
Frontend:
- EnvEditor (table + raw, sensitive masking, quick-insert)
- Images page + UpdateBadge + dashboard 'updates available' banner
- PortConflictDialog pre-deploy check on Deploy
- ResourcePanel (CPU/RAM sliders) as editor Limits tab
- Templates page with per-variable instantiate form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b553c1b861
commit
22d9864436
@@ -1,9 +1,11 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Cpu, MemoryStick, HardDrive, Container, Clock } from "lucide-react";
|
||||
import { Cpu, MemoryStick, HardDrive, Container, Clock, ArrowUpCircle } from "lucide-react";
|
||||
import { Card, Spinner } from "@/components/ui";
|
||||
import { StackCard } from "@/components/stacks/StackCard";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { systemApi } from "@/api/system";
|
||||
import { imagesApi } from "@/api/images";
|
||||
import { formatBytes, formatUptime, relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useStackActions } from "@/hooks/useStackActions";
|
||||
@@ -15,9 +17,22 @@ export function Dashboard() {
|
||||
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
||||
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
|
||||
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
|
||||
const updates = useQuery({ queryKey: ["image-updates"], queryFn: imagesApi.updates, refetchInterval: 60000 });
|
||||
|
||||
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{updateCount > 0 && (
|
||||
<Link
|
||||
to="/images"
|
||||
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
>
|
||||
<ArrowUpCircle className="h-5 w-5" />
|
||||
{updateCount} image update{updateCount > 1 ? "s" : ""} available — view on the Images page.
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Resource bar */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react";
|
||||
import { Button, Card, Spinner } from "@/components/ui";
|
||||
import { imagesApi, type ImageRow } from "@/api/images";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { formatBytes, relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function UpdateBadge({ row }: { row: ImageRow }) {
|
||||
const u = row.update;
|
||||
if (!u) return <span className="inline-flex items-center gap-1 text-xs text-slate-400"><HelpCircle className="h-3.5 w-3.5" /> not checked</span>;
|
||||
if (u.error) return <span className="text-xs text-amber-500">⚠ {u.error}</span>;
|
||||
if (u.update_available)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" /> update available
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> up to date
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Images() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const qc = useQueryClient();
|
||||
const [checking, setChecking] = useState(false);
|
||||
const { data, isLoading } = useQuery({ queryKey: ["images"], queryFn: imagesApi.list });
|
||||
|
||||
const check = async () => {
|
||||
setChecking(true);
|
||||
const t = toast.loading("Checking for updates…");
|
||||
try {
|
||||
await imagesApi.check();
|
||||
await qc.invalidateQueries({ queryKey: ["images"] });
|
||||
toast.success("Update check complete", { id: t });
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e), { id: t });
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">{data?.length ?? 0} image tags</p>
|
||||
{isAdmin && (
|
||||
<Button onClick={check} loading={checking}>
|
||||
<RefreshCw className="h-4 w-4" /> Check updates
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="overflow-x-auto p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 text-left text-xs text-slate-500 dark:border-slate-700">
|
||||
<tr>
|
||||
<th className="p-3">Image</th>
|
||||
<th className="p-3">Used by</th>
|
||||
<th className="p-3">Size</th>
|
||||
<th className="p-3">Created</th>
|
||||
<th className="p-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.map((row) => (
|
||||
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
|
||||
<td className="p-3 font-mono text-xs">{row.tag}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.created ? relativeTime(row.created) : "—"}
|
||||
</td>
|
||||
<td className="p-3"><UpdateBadge row={row} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,5 @@ export function Placeholder({ title, phase }: { title: string; phase: string })
|
||||
);
|
||||
}
|
||||
|
||||
export const Networks = () => <Placeholder title="Networks" phase="Phase 2" />;
|
||||
export const Images = () => <Placeholder title="Images" phase="Phase 3" />;
|
||||
export const Templates = () => <Placeholder title="Templates" phase="Phase 3" />;
|
||||
export const Networks = () => <Placeholder title="Networks" phase="Phase 4" />;
|
||||
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
|
||||
|
||||
@@ -5,7 +5,10 @@ import Editor from "@monaco-editor/react";
|
||||
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
|
||||
import { Button, Card, Input } from "@/components/ui";
|
||||
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
||||
import { EnvEditor } from "@/components/env/EnvEditor";
|
||||
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { portsApi, type PortConflict } from "@/api/ports";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useThemeStore } from "@/store/theme";
|
||||
import { toast } from "sonner";
|
||||
@@ -33,6 +36,8 @@ export function StackEditor() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [convertOpen, setConvertOpen] = useState(false);
|
||||
const [runCmd, setRunCmd] = useState("");
|
||||
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const existing = useQuery({
|
||||
queryKey: ["stack", id],
|
||||
@@ -79,6 +84,22 @@ export function StackEditor() {
|
||||
}
|
||||
};
|
||||
|
||||
const onDeploy = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const found = await portsApi.conflicts(yaml, id);
|
||||
if (found.length > 0) {
|
||||
setConflicts(found);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* if the check fails, fall through and let compose surface errors */
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
save(true);
|
||||
};
|
||||
|
||||
const convert = async () => {
|
||||
try {
|
||||
const { yaml: converted } = await stacksApi.convert(runCmd);
|
||||
@@ -145,14 +166,9 @@ export function StackEditor() {
|
||||
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
|
||||
/>
|
||||
) : (
|
||||
<Editor
|
||||
height="100%"
|
||||
language="ini"
|
||||
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||
value={env}
|
||||
onChange={(v) => setEnv(v ?? "")}
|
||||
options={{ minimap: { enabled: false }, fontSize: 13 }}
|
||||
/>
|
||||
<div className="h-full p-3">
|
||||
<EnvEditor value={env} onChange={setEnv} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -168,10 +184,21 @@ export function StackEditor() {
|
||||
<Button variant="outline" onClick={() => save(false)} loading={saving}>
|
||||
<Save className="h-4 w-4" /> Save Draft
|
||||
</Button>
|
||||
<Button onClick={() => save(true)} loading={saving}>
|
||||
<Button onClick={onDeploy} loading={saving || checking}>
|
||||
<Rocket className="h-4 w-4" /> Deploy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conflicts && (
|
||||
<PortConflictDialog
|
||||
conflicts={conflicts}
|
||||
onContinue={() => {
|
||||
setConflicts(null);
|
||||
save(true);
|
||||
}}
|
||||
onCancel={() => setConflicts(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LayoutTemplate, Cpu, Package } from "lucide-react";
|
||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Templates() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const [selected, setSelected] = useState<TemplateDetail | null>(null);
|
||||
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
|
||||
|
||||
const open = async (t: TemplateSummary) => {
|
||||
try {
|
||||
setSelected(await templatesApi.get(t.id));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{data?.map((t) => (
|
||||
<Card key={t.id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{t.source === "custom" ? (
|
||||
<Package className="h-5 w-5 text-accent dark:text-accent-dark" />
|
||||
) : (
|
||||
<LayoutTemplate className="h-5 w-5 text-accent dark:text-accent-dark" />
|
||||
)}
|
||||
<span className="font-semibold">{t.name}</span>
|
||||
{t.source === "custom" && <Badge>custom</Badge>}
|
||||
</div>
|
||||
{t.description && <p className="text-sm text-slate-500">{t.description}</p>}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.tags.map((tag) => (
|
||||
<span key={tag} className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{t.gpu && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Cpu className="h-3 w-3" /> {t.gpu}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button variant="outline" className="mt-2" onClick={() => open(t)}>
|
||||
Use template
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<UseTemplateDialog template={selected} onClose={() => setSelected(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UseTemplateDialog({
|
||||
template,
|
||||
onClose,
|
||||
}: {
|
||||
template: TemplateDetail;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState(template.name);
|
||||
const [values, setValues] = useState<Record<string, string>>(
|
||||
Object.fromEntries(template.variables.map((v) => [v.name, v.default]))
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Stack name required");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await templatesApi.instantiate(template.id, name, values);
|
||||
toast.success(`Stack '${res.name}' created`);
|
||||
navigate(`/stacks/${res.id}/edit`);
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="max-h-[85vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||
<h2 className="mb-3 text-lg font-semibold">Use “{template.name}”</h2>
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Stack name</span>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
{template.variables.map((v) => (
|
||||
<label key={v.name} className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
{v.name}
|
||||
{v.description && <span className="ml-1 font-normal text-slate-400">— {v.description}</span>}
|
||||
</span>
|
||||
<Input
|
||||
value={values[v.name] ?? ""}
|
||||
onChange={(e) => setValues((s) => ({ ...s, [v.name]: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={create} loading={busy}>Create stack</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user