Wire wave-3 backend features into the UI

- SettingsPage: DigestSettingsCard (frequency, recipients, send-now) and LifecycleSettingsCard (retention window, enforce toggle) — live-verified save round-trips
- ConnectionsPage: connection type selector (PVE/PBS, port auto-follows type), type badge on each row, Terraform/Ansible export dropdown on PVE connections
- api.ts: Connection.type field
- Live-verified end-to-end against a running backend: settings save, connection create for both types, export request/error-handling path
This commit is contained in:
Anand
2026-09-10 15:16:17 +05:30
parent fadc1f24e3
commit d323b1d68a
5 changed files with 304 additions and 1 deletions
@@ -0,0 +1,130 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ErrorState } from "@/components/ui/error-state"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { api, ApiError } from "@/lib/api"
// Mirrors internal/api/digest_settings.go's digestSettingsResponse.
interface DigestSettings {
enabled: boolean
intervalHours: number
recipients: string[]
lastSentAt?: string
}
const INTERVAL_OPTIONS = [
{ value: "24", label: "Daily" },
{ value: "168", label: "Weekly" },
{ value: "720", label: "Monthly" },
]
/**
* A periodic email summary of fleet health — uptime, backup success rate,
* active alerts, capacity trend — sent via the same SMTP config as the
* Notifications card above (internal/digest reuses notify.Notifier, no
* separate delivery channel).
*/
export function DigestSettingsCard() {
const query = useQuery({
queryKey: ["admin", "settings", "digest"],
queryFn: () => api.get<DigestSettings>("/admin/settings/digest"),
})
return (
<Card>
<CardHeader>
<CardTitle>Fleet health digest</CardTitle>
<CardDescription>A periodic email summary of uptime, active alerts, and capacity trend across every connection.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{query.isError ? (
<ErrorState title="Couldn't load digest settings" onRetry={query.refetch} />
) : query.isLoading ? (
<div className="space-y-3" aria-busy>
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-2/3" />
</div>
) : (
<DigestForm key={JSON.stringify(query.data)} initial={query.data!} />
)}
</CardContent>
</Card>
)
}
function DigestForm({ initial }: { initial: DigestSettings }) {
const queryClient = useQueryClient()
const [enabled, setEnabled] = useState(initial.enabled)
const [intervalHours, setIntervalHours] = useState(String(initial.intervalHours || 168))
const [recipients, setRecipients] = useState(initial.recipients.join(", "))
const save = useMutation({
mutationFn: () =>
api.put<DigestSettings>("/admin/settings/digest", {
enabled,
intervalHours: Number(intervalHours),
recipients: recipients.split(",").map((r) => r.trim()).filter(Boolean),
}),
onSuccess: (data) => {
toast.success("Digest settings saved")
queryClient.setQueryData(["admin", "settings", "digest"], data)
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : "Failed to save digest settings"),
})
const sendNow = useMutation({
mutationFn: () => api.post("/admin/settings/digest/send-now", {}),
onSuccess: () => toast.success("Digest sent"),
onError: (err) => toast.error(err instanceof ApiError ? err.message : "Failed to send digest"),
})
return (
<>
<div className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2.5">
<div>
<p className="text-sm font-medium">Send digest</p>
<p className="text-xs text-[var(--text-muted)]">
{initial.lastSentAt ? `Last sent ${new Date(initial.lastSentAt).toLocaleString()}` : "Never sent yet."}
</p>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Frequency</Label>
<Select value={intervalHours} onValueChange={setIntervalHours}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTERVAL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Recipients (comma-separated)</Label>
<Textarea rows={1} value={recipients} onChange={(e) => setRecipients(e.target.value)} placeholder="ops@example.com, oncall@example.com" />
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" loading={save.isPending} onClick={() => save.mutate()}>
Save
</Button>
<Button size="sm" variant="outline" loading={sendNow.isPending} onClick={() => sendNow.mutate()} disabled={recipients.trim().length === 0}>
Send now
</Button>
</div>
</>
)
}
@@ -0,0 +1,100 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ErrorState } from "@/components/ui/error-state"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { api, ApiError } from "@/lib/api"
// Mirrors internal/api/lifecycle.go's lifecycleSettingsResponse.
interface LifecycleSettings {
retentionDays: number
enforce: boolean
}
/**
* Fleet-wide snapshot retention window (a guest can override with a
* `retain:<N>d` tag). Always runs as a dry-run — logging what it *would*
* delete to /lifecycle/actions — until "enforce" is explicitly turned on;
* see internal/poller/lifecycle.go.
*/
export function LifecycleSettingsCard() {
const query = useQuery({
queryKey: ["settings", "lifecycle"],
queryFn: () => api.get<LifecycleSettings>("/settings/lifecycle"),
})
return (
<Card>
<CardHeader>
<CardTitle>Snapshot retention</CardTitle>
<CardDescription>
Deletes snapshots older than this window automatically a guest tagged <code className="font-mono text-xs">retain:14d</code> overrides it.
Nothing is ever deleted until "Enforce" is turned on below; until then every decision is only logged.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{query.isError ? (
<ErrorState title="Couldn't load lifecycle settings" onRetry={query.refetch} />
) : query.isLoading ? (
<div className="space-y-3" aria-busy>
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-2/3" />
</div>
) : (
<LifecycleForm key={JSON.stringify(query.data)} initial={query.data!} />
)}
</CardContent>
</Card>
)
}
function LifecycleForm({ initial }: { initial: LifecycleSettings }) {
const queryClient = useQueryClient()
const [retentionDays, setRetentionDays] = useState(String(initial.retentionDays || 0))
const [enforce, setEnforce] = useState(initial.enforce)
const save = useMutation({
mutationFn: () =>
api.put<LifecycleSettings>("/settings/lifecycle", {
retentionDays: Number(retentionDays) || 0,
enforce,
}),
onSuccess: (data) => {
toast.success("Lifecycle settings saved")
queryClient.setQueryData(["settings", "lifecycle"], data)
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : "Failed to save lifecycle settings"),
})
return (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Fleet-wide retention (days)</Label>
<Input
type="number"
min={0}
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
placeholder="0 = no fleet-wide default"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2.5">
<div>
<p className="text-sm font-medium">Enforce (actually delete)</p>
<p className="text-xs text-[var(--text-muted)]">Off = dry-run only, every decision still logged to Audit Log.</p>
</div>
<Switch checked={enforce} onCheckedChange={setEnforce} />
</div>
<Button size="sm" loading={save.isPending} onClick={() => save.mutate()}>
Save
</Button>
</>
)
}
+1
View File
@@ -81,6 +81,7 @@ export interface User {
export interface Connection {
id: string
name: string
type: "pve" | "pbs"
host: string
port: number
authType: "token" | "password"
+69 -1
View File
@@ -1,11 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Network, Pencil, Plus, Trash2, Wifi } from "lucide-react"
import { Download, Network, Pencil, Plus, Trash2, Wifi } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { useConfirm } from "@/components/ui/confirm-dialog"
import { EmptyState } from "@/components/ui/empty-state"
import { ErrorState } from "@/components/ui/error-state"
@@ -21,6 +22,7 @@ import { api, ApiError, type Connection, type ConnectionInventory } from "@/lib/
interface FormState {
name: string
type: "pve" | "pbs"
host: string
port: number
authType: "token" | "password"
@@ -34,6 +36,7 @@ interface FormState {
const emptyForm: FormState = {
name: "",
type: "pve",
host: "",
port: 8006,
authType: "token",
@@ -45,6 +48,8 @@ const emptyForm: FormState = {
behindReverseProxy: false,
}
const DEFAULT_PORT: Record<FormState["type"], number> = { pve: 8006, pbs: 8007 }
export function ConnectionsPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
@@ -62,6 +67,7 @@ export function ConnectionsPage() {
setEditingId(conn.id)
setForm({
name: conn.name,
type: conn.type,
host: conn.host,
port: conn.port,
authType: conn.authType,
@@ -110,6 +116,7 @@ export function ConnectionsPage() {
// empty field means "keep the existing credential".
const body: Record<string, unknown> = {
name: form.name,
type: form.type,
host: form.host,
port: form.port,
authType: form.authType,
@@ -146,6 +153,26 @@ export function ConnectionsPage() {
onError: (err) => toast.error(err instanceof ApiError ? err.message : "Failed to remove connection"),
})
// The export endpoints return a raw file (Content-Disposition: attachment),
// not JSON — bypasses the api.ts helper's JSON parsing and triggers a real
// browser save via a throwaway object-URL link, same technique as
// TopologyPage's SVG export.
async function downloadExport(connId: string, format: "terraform" | "ansible", filename: string) {
try {
const res = await fetch(`/api/v1/connections/${connId}/export/${format}`, { credentials: "include" })
if (!res.ok) throw new Error(`Export failed (${res.status})`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error(`Couldn't export as ${format === "terraform" ? "Terraform" : "Ansible"}`)
}
}
async function removeConnection(conn: Connection) {
const ok = await confirm({
title: `Remove ${conn.name}?`,
@@ -181,6 +208,29 @@ export function ConnectionsPage() {
<Label>Name</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Type</Label>
<Select
value={form.type}
onValueChange={(v) => {
const type = v as FormState["type"]
// Only nudge the port when it's still at the other type's
// default — an admin who already typed a custom port
// shouldn't have it silently overwritten by switching type.
const port = form.port === DEFAULT_PORT[form.type] ? DEFAULT_PORT[type] : form.port
setForm({ ...form, type, port })
}}
disabled={!!editingId}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pve">Proxmox VE (cluster or host)</SelectItem>
<SelectItem value="pbs">Proxmox Backup Server</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Host</Label>
<Input
@@ -339,7 +389,25 @@ export function ConnectionsPage() {
<StatusDot status={status.online ? "ok" : "error"} />
</span>
)}
<Badge variant="outline">{conn.type === "pbs" ? "PBS" : "PVE"}</Badge>
<Badge variant={conn.verifyTls ? "ok" : "default"}>{conn.verifyTls ? "TLS verified" : "TLS insecure"}</Badge>
{conn.type === "pve" && (
<DropdownMenu>
<Hint label="Export inventory as IaC">
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" aria-label={`Export ${conn.name} inventory`}>
<Download className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</Hint>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => downloadExport(conn.id, "terraform", `${conn.name}.tf`)}>Terraform (.tf)</DropdownMenuItem>
<DropdownMenuItem onSelect={() => downloadExport(conn.id, "ansible", `${conn.name}-inventory.yml`)}>
Ansible inventory (.yml)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Hint label="Edit connection">
<Button size="icon" variant="ghost" onClick={() => startEdit(conn)} aria-label={`Edit ${conn.name}`}>
<Pencil className="h-4 w-4" />
+4
View File
@@ -7,6 +7,8 @@ import { AgentSettingsCard } from "@/components/settings/AgentSettingsCard"
import { AIProvidersCard } from "@/components/settings/AIProvidersCard"
import { AppearanceCard } from "@/components/settings/AppearanceCard"
import { DefaultPreferencesCard } from "@/components/settings/DefaultPreferencesCard"
import { DigestSettingsCard } from "@/components/settings/DigestSettingsCard"
import { LifecycleSettingsCard } from "@/components/settings/LifecycleSettingsCard"
import { NotificationsSettingsCard } from "@/components/settings/NotificationsSettingsCard"
import { OIDCSettingsCard } from "@/components/settings/OIDCSettingsCard"
import { SecuritySettingsCard } from "@/components/settings/SecuritySettingsCard"
@@ -55,6 +57,8 @@ export function SettingsPage() {
<SecuritySettingsCard />
<SystemSettingsCard />
<NotificationsSettingsCard />
<DigestSettingsCard />
<LifecycleSettingsCard />
<Link
to="/alerts"