import { useMemo, useState } from "react"; import { useParams, 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useDeleteServiceInstance, useSaveServiceInstance, useServiceInstances, useServiceTypes, } from "../hooks/useServices"; import { useIsMobile } from "../hooks/useIsMobile"; import { SheetForm } from "@/components/ui/sheet-form"; import type { ServiceInstance, ServiceInstanceInput, ServiceTypeInfo, } from "../types"; import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { getServiceBinding } from "../integrations/registry"; import { OVERVIEW_TAB, serviceContentTabs, type ContentTab, } from "./service-tabs"; function Field({ label, htmlFor, helper, children, }: { label: string; htmlFor: string; helper?: string; children: React.ReactNode; }) { return (
{children} {helper ? (

{helper}

) : null}
); } export function ServicePage() { const { serviceType = "", serviceId = "" } = useParams<{ serviceType: string; serviceId: string; }>(); const { data: services = [] } = useServiceInstances(serviceType || undefined); const { data: types = [] } = useServiceTypes(); const navigate = useNavigate(); const saveService = useSaveServiceInstance(); const deleteService = useDeleteServiceInstance(); const instance = useMemo( () => services.find((s) => s.id === serviceId), [services, serviceId], ); const binding = getServiceBinding(serviceType); const typeInfo = useMemo( () => types.find((t) => t.service_type === serviceType), [types, serviceType], ); const contentTabs = useMemo( () => serviceContentTabs(serviceType), [serviceType], ); const siblings = useMemo( () => services.filter((s) => s.service_type === serviceType), [services, serviceType], ); // R3.1: switcher trigger keys off ENABLED siblings (not total). const enabledSiblings = useMemo( () => siblings.filter((s) => s.enabled), [siblings], ); const showSwitcher = enabledSiblings.length > 1; const [name, setName] = useState(""); const [enabled, setEnabled] = useState(true); const [draftConfig, setDraftConfig] = useState>({}); const [draftSecrets, setDraftSecrets] = useState>({}); const [deleteOpen, setDeleteOpen] = useState(false); const [hydrated, setHydrated] = useState(false); const isMobile = useIsMobile(); const [sheetOpen, setSheetOpen] = useState(true); if (instance && !hydrated) { setName(instance.name); setEnabled(instance.enabled); setDraftConfig({ ...instance.config }); setDraftSecrets({}); setHydrated(true); } if (!binding) { return ( Unknown service type: {serviceType} ); } if (!instance) { return ( Service not found. ); } function buildInput(): ServiceInstanceInput { // R2.3/R10.1: collect typed secret drafts. Empty values mean "keep the // existing value" so they are filtered out before sending. const onlyChangedSecrets = Object.fromEntries( Object.entries(draftSecrets).filter(([, v]) => v !== ""), ); return { id: instance!.id, service_type: instance!.service_type, name, config: draftConfig, secrets: onlyChangedSecrets, enabled, }; } async function save() { await saveService.mutateAsync(buildInput()); // Clear secret drafts after a successful save so the inputs reset to // "leave blank to keep" state. setDraftSecrets({}); } const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs]; // The config + widgets body, shared between desktop tabs and mobile SheetForm. const widgetsContent = binding.widgets.length > 0 ? (
{binding.widgets.map((w) => (
{w.name}
{w.description}
{w.kind}
))}

Add these to the dashboard from the dashboard's edit dialog.

) : (

No widget kinds for this service type.

); const configBody = ( setDeleteOpen(true)} /> ); // Mobile: render inside a SheetForm (open on mount; cancel navigates back). if (isMobile) { return (
{ setSheetOpen(false); navigate("/services"); }} isPending={saveService.isPending} isDirty={ name !== instance.name || enabled !== instance.enabled || JSON.stringify(draftConfig) !== JSON.stringify(instance.config) } >
{allTabs.map((tab) => { const TabComponent = tab.Component; return (

{tab.label}

); })} {widgetsContent} {configBody}
setDeleteOpen(false)} onConfirm={() => { deleteService.mutate(instance.id); setDeleteOpen(false); navigate("/services"); }} />
); } return (
{/* Header + instance switcher */}

{instance.name}

{binding.description}

{showSwitcher ? ( ) : null} {binding.name}
{/* Tab skeleton */} Overview {contentTabs.map((tab) => ( {tab.label} ))} Widgets Config {allTabs.map((tab) => { const TabComponent = tab.Component; return ( ); })} {widgetsContent} {configBody} setDeleteOpen(false)} onConfirm={() => { deleteService.mutate(instance.id); setDeleteOpen(false); navigate("/services"); }} />
); } function ConfigBody({ instance, typeInfo, draftConfig, onConfigChange, draftSecrets, onSecretsChange, name, enabled, onNameChange, onEnabledChange, onSave, savePending, onDelete, }: { instance: ServiceInstance; typeInfo: ServiceTypeInfo | undefined; draftConfig: Record; onConfigChange: (config: Record) => void; draftSecrets: Record; onSecretsChange: (secrets: Record) => void; name: string; enabled: boolean; onNameChange: (name: string) => void; onEnabledChange: (enabled: boolean) => void; onSave: () => void; savePending: boolean; onDelete: () => void; }) { const properties = ( (typeInfo?.config_schema ?? {}) as { properties?: Record< string, { type?: string; description?: string; default?: unknown } >; } ).properties ?? {}; const configEntries: Array< [string, { type?: string; description?: string }] > = Object.keys(properties).length > 0 ? Object.entries(properties).map(([key, schema]) => [ key, { type: schema?.type, description: schema?.description }, ]) : Object.entries(instance.config).map(([key, value]) => [ key, { type: typeof value === "number" ? "integer" : "string" }, ]); return (
onNameChange(e.target.value)} />
{configEntries.length === 0 ? (

No connection config.

) : (
{configEntries.map(([key, schema]) => { const isNumber = schema.type === "integer" || schema.type === "number"; return ( onConfigChange({ ...draftConfig, [key]: isNumber ? e.target.value === "" ? undefined : Number(e.target.value) : e.target.value, }) } /> ); })}
)} {Object.keys(instance.secrets_set).length === 0 ? null : (
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
onSecretsChange({ ...draftSecrets, [key]: e.target.value, }) } /> {isSet ? set : null}
))}
)}
); }