import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ExternalLink, Plus, Trash2 } from "lucide-react"; import { useDeleteServiceInstance, useSaveServiceInstance, useServiceInstances, } from "../hooks/useServices"; import { useServiceTypes } from "../hooks/useServices"; import type { SecretFieldInfo, ServiceInstance, ServiceInstanceInput, ServiceTypeInfo, } from "../types"; import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { DialogFooter } from "../components/DialogFooter"; import { getServiceBinding } from "../integrations/registry"; interface CreateDraft { serviceType: string; name: string; config: Record; secrets: Record; enabled: boolean; } function emptyDraft(serviceType: string): CreateDraft { return { serviceType, name: "", config: {}, secrets: {}, enabled: true }; } function Field({ label, htmlFor, helper, children, }: { label: string; htmlFor: string; helper?: string; children: React.ReactNode; }) { return (
{children} {helper ? (

{helper}

) : null}
); } function ServiceConfigFields({ type, config, onChange, }: { type: ServiceTypeInfo; config: Record; onChange: (config: Record) => void; }) { const properties = ( type.config_schema as { properties?: Record; } ).properties ?? {}; return (
{Object.entries(properties).map(([key, schema]) => { const isNumber = schema.type === "integer" || schema.type === "number"; return ( onChange({ ...config, [key]: isNumber ? e.target.value === "" ? undefined : Number(e.target.value) : e.target.value, }) } /> ); })}
); } function ServiceSecretFields({ fields, secrets, onChange, }: { fields: SecretFieldInfo[]; secrets: Record; onChange: (secrets: Record) => void; }) { if (fields.length === 0) return null; return (
{fields.map((field) => ( onChange({ ...secrets, [field.key]: e.target.value }) } /> ))}
); } function CreateServiceDialog({ open, onClose, }: { open: boolean; onClose: () => void; }) { const { data: types = [] } = useServiceTypes(); const saveService = useSaveServiceInstance(); const [draft, setDraft] = useState(null); function reset() { setDraft(null); } async function save() { if (!draft) return; if (!draft.name.trim()) return; const input: ServiceInstanceInput = { service_type: draft.serviceType, name: draft.name.trim(), config: draft.config, secrets: draft.secrets, enabled: draft.enabled, }; await saveService.mutateAsync(input); reset(); onClose(); } const selectedType = types.find((t) => t.service_type === draft?.serviceType); return ( { if (!next) { reset(); onClose(); } }} > New service
{!draft ? (
{types.map((t) => ( ))}
) : ( <>

{selectedType?.description}

setDraft({ ...draft, name: e.target.value })} /> {selectedType ? ( setDraft({ ...draft, config })} /> ) : null} {selectedType ? ( setDraft({ ...draft, secrets })} /> ) : null}
setDraft({ ...draft, enabled: checked }) } />
)}
{draft ? ( ) : null}
); } export function ServicesPage() { const navigate = useNavigate(); const { data: services = [] } = useServiceInstances(); const { data: types = [] } = useServiceTypes(); const deleteService = useDeleteServiceInstance(); const [createOpen, setCreateOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); const grouped = useMemo(() => { const map = new Map(); for (const s of services) { const list = map.get(s.service_type) ?? []; list.push(s); map.set(s.service_type, list); } return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0])); }, [services]); const typeName = (t: string) => types.find((x) => x.service_type === t)?.name ?? getServiceBinding(t)?.name ?? t; return (
setCreateOpen(true)}> Add service } > {services.length === 0 ? ( No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud, or SSH task runner. ) : (
{grouped.map(([serviceType, instances]) => (
{typeName(serviceType)}
{instances.map((s) => (
{s.name} {s.service_type} {!s.enabled ? ( disabled ) : null} {Object.entries(s.secrets_set).some(([, v]) => v) ? ( secrets set ) : null}
))}
))}
)}
setCreateOpen(false)} /> setDeleteId(null)} onConfirm={() => { if (deleteId) deleteService.mutate(deleteId); setDeleteId(null); }} />
); }