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
@@ -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>
);
}