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
+157
View File
@@ -0,0 +1,157 @@
import { useMemo, useState } from "react";
import { Plus, Trash2, Eye, EyeOff, Table, FileText } from "lucide-react";
import { Button, Input } from "@/components/ui";
interface Row {
key: string;
value: string;
}
const SENSITIVE_RE = /(PASS|SECRET|TOKEN|KEY|APIKEY|PWD|CREDENTIAL)/i;
function parseEnv(text: string): Row[] {
return text
.split("\n")
.filter((l) => l.trim() && !l.trim().startsWith("#") && l.includes("="))
.map((l) => {
const idx = l.indexOf("=");
return { key: l.slice(0, idx).trim(), value: l.slice(idx + 1) };
});
}
function serialize(rows: Row[]): string {
return rows
.filter((r) => r.key.trim())
.map((r) => `${r.key.trim()}=${r.value}`)
.join("\n")
.concat(rows.length ? "\n" : "");
}
const QUICK = [
{ key: "PUID", value: "1000" },
{ key: "PGID", value: "1000" },
{ key: "TZ", value: "Europe/Berlin" },
];
export function EnvEditor({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [mode, setMode] = useState<"table" | "raw">("table");
const [reveal, setReveal] = useState<Record<number, boolean>>({});
const rows = useMemo(() => parseEnv(value), [value]);
const update = (next: Row[]) => onChange(serialize(next));
const setRow = (i: number, patch: Partial<Row>) =>
update(rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
const addRow = (row: Row = { key: "", value: "" }) => update([...rows, row]);
const delRow = (i: number) => update(rows.filter((_, idx) => idx !== i));
return (
<div className="flex h-full flex-col">
<div className="mb-2 flex items-center gap-2">
<button
onClick={() => setMode("table")}
className={
mode === "table"
? "flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
: "flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-xs dark:border-slate-600"
}
>
<Table className="h-3.5 w-3.5" /> Table
</button>
<button
onClick={() => setMode("raw")}
className={
mode === "raw"
? "flex items-center gap-1 rounded bg-accent px-2 py-1 text-xs text-white dark:bg-accent-dark dark:text-slate-900"
: "flex items-center gap-1 rounded border border-slate-300 px-2 py-1 text-xs dark:border-slate-600"
}
>
<FileText className="h-3.5 w-3.5" /> Raw
</button>
{mode === "table" && (
<div className="ml-auto flex gap-1">
{QUICK.map((q) => (
<button
key={q.key}
onClick={() => addRow(q)}
className="rounded border border-slate-300 px-2 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
+ {q.key}
</button>
))}
</div>
)}
</div>
{mode === "raw" ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
className="flex-1 resize-none rounded-lg border border-slate-300 bg-white p-3 font-mono text-xs dark:border-slate-600 dark:bg-slate-800"
placeholder="KEY=value"
/>
) : (
<div className="flex-1 overflow-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs text-slate-500">
<tr>
<th className="pb-1">Key</th>
<th className="pb-1">Value</th>
<th className="pb-1 w-10" />
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const sensitive = SENSITIVE_RE.test(r.key);
const masked = sensitive && !reveal[i];
return (
<tr key={i}>
<td className="pr-2 py-1">
<Input value={r.key} onChange={(e) => setRow(i, { key: e.target.value })} />
</td>
<td className="pr-2 py-1">
<div className="flex items-center gap-1">
<Input
type={masked ? "password" : "text"}
value={r.value}
onChange={(e) => setRow(i, { value: e.target.value })}
/>
{sensitive && (
<button
onClick={() => setReveal((s) => ({ ...s, [i]: !s[i] }))}
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
title={masked ? "Reveal" : "Hide"}
>
{masked ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
)}
</div>
</td>
<td className="py-1">
<button
onClick={() => delRow(i)}
className="rounded p-1.5 text-red-500 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<Button variant="outline" className="mt-2" onClick={() => addRow()}>
<Plus className="h-4 w-4" /> Add variable
</Button>
</div>
)}
</div>
);
}