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:
menzelj
2026-06-07 16:49:38 +00:00
co-authored by Claude Opus 4.8
parent b553c1b861
commit 22d9864436
32 changed files with 1728 additions and 21 deletions
+129
View File
@@ -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>
);
}