mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export interface User {
|
||||
export interface Connection {
|
||||
id: string
|
||||
name: string
|
||||
type: "pve" | "pbs"
|
||||
host: string
|
||||
port: number
|
||||
authType: "token" | "password"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user