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
+157
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, HardDrive, Cpu, Plug } from "lucide-react";
|
||||
import { RefreshCw, HardDrive, Cpu, Plug, Gauge } from "lucide-react";
|
||||
import { VolumeWizard } from "@/components/volumes/VolumeWizard";
|
||||
import { GPUSelector } from "@/components/gpu/GPUSelector";
|
||||
import { DevicePanel } from "@/components/gpu/DevicePanel";
|
||||
import { ResourcePanel } from "@/components/stacks/ResourcePanel";
|
||||
import { editorApi } from "@/api/editor";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type Tab = "volumes" | "gpu" | "devices";
|
||||
type Tab = "volumes" | "gpu" | "devices" | "resources";
|
||||
|
||||
export function EditorHelperPanel({
|
||||
yaml,
|
||||
@@ -83,6 +84,7 @@ export function EditorHelperPanel({
|
||||
["volumes", "Volumes", HardDrive],
|
||||
["gpu", "GPU", Cpu],
|
||||
["devices", "Devices", Plug],
|
||||
["resources", "Limits", Gauge],
|
||||
] as [Tab, string, typeof Cpu][]).map(([id, label, Icon]) => (
|
||||
<button
|
||||
key={id}
|
||||
@@ -130,6 +132,14 @@ export function EditorHelperPanel({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tab === "resources" && (
|
||||
<ResourcePanel
|
||||
onApply={(res) => {
|
||||
if (!guard()) return;
|
||||
run(() => editorApi.setResources(yaml, service, res));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
import type { PortConflict } from "@/api/ports";
|
||||
|
||||
export function PortConflictDialog({
|
||||
conflicts,
|
||||
onContinue,
|
||||
onCancel,
|
||||
}: {
|
||||
conflicts: PortConflict[];
|
||||
onContinue: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-lg rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||
<div className="mb-3 flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">Port conflicts detected</h2>
|
||||
</div>
|
||||
<ul className="mb-4 space-y-2">
|
||||
{conflicts.map((c, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded-lg border border-slate-200 px-3 py-2 text-sm dark:border-slate-700"
|
||||
>
|
||||
Port <span className="font-mono font-semibold">{c.port}</span>/
|
||||
{c.protocol}
|
||||
{c.service && <span className="text-slate-500"> ({c.service})</span>} →
|
||||
already in use by <span className="font-medium">{c.used_by}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Edit Compose
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onContinue}>
|
||||
Continue anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import { Check as CheckIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
export function ResourcePanel({
|
||||
onApply,
|
||||
}: {
|
||||
onApply: (res: {
|
||||
cpus?: number | null;
|
||||
memory?: string | null;
|
||||
cpus_reserve?: number | null;
|
||||
memory_reserve?: string | null;
|
||||
}) => void;
|
||||
}) {
|
||||
const [cpuOn, setCpuOn] = useState(false);
|
||||
const [cpus, setCpus] = useState(1);
|
||||
const [memOn, setMemOn] = useState(false);
|
||||
const [mem, setMem] = useState(512); // MB
|
||||
|
||||
const apply = () =>
|
||||
onApply({
|
||||
cpus: cpuOn ? cpus : null,
|
||||
memory: memOn ? `${mem}m` : null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={cpuOn} onChange={(e) => setCpuOn(e.target.checked)} />
|
||||
CPU limit
|
||||
</label>
|
||||
{cpuOn && (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="range"
|
||||
min={0.25}
|
||||
max={16}
|
||||
step={0.25}
|
||||
value={cpus}
|
||||
onChange={(e) => setCpus(Number(e.target.value))}
|
||||
className="w-full accent-sky-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">{cpus} CPU(s)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input type="checkbox" checked={memOn} onChange={(e) => setMemOn(e.target.checked)} />
|
||||
Memory limit
|
||||
</label>
|
||||
{memOn && (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="range"
|
||||
min={64}
|
||||
max={16384}
|
||||
step={64}
|
||||
value={mem}
|
||||
onChange={(e) => setMem(Number(e.target.value))}
|
||||
className="w-full accent-sky-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
{mem >= 1024 ? `${(mem / 1024).toFixed(1)} GB` : `${mem} MB`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400">
|
||||
Generates a <code>deploy.resources.limits</code> block. Disable a checkbox
|
||||
and apply to remove that limit.
|
||||
</p>
|
||||
|
||||
<Button onClick={apply}>
|
||||
<CheckIcon className="h-4 w-4" /> Apply to YAML
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user