dad2202756
Settings.tsx: ServiceConfigEditor derived editable state (name, config,
secrets) from the instance prop via useState, but the parent rendered it
without a key. Switching services in the rail reused the same component, so
name/config stayed pinned to the previously selected service while
instance.id/service_type (read live from props) pointed at the new one —
saving then wrote the stale values onto the wrong row (e.g. saving qBittorrent
renamed it "Jellyfin" with Jellyfin's URL). Add key={selectedService.id} so
the editor remounts and resets on switch.
ServicePage: add a Settings shortcut in the header that deep-links to
/settings?tab=services&service=<id>. Settings now reads tab + service query
params (useSearchParams) to open the Services tab with that service
pre-selected, via a new initialServiceId prop on ServicesAdminCard.
Tests: new Settings.services.test.tsx regression test (fails without the key,
passes with it); wrap existing Settings tests in MemoryRouter since Settings
now uses useSearchParams. 166/166 frontend tests pass; typecheck + ESLint clean.
163 lines
4.3 KiB
TypeScript
163 lines
4.3 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { Settings as SettingsIcon } from "lucide-react";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { useServiceInstances } from "../hooks/useServices";
|
|
import type { ServiceInstance } from "../types";
|
|
import { SectionCard } from "../components/SectionCard";
|
|
import { getServiceBinding } from "../integrations/registry";
|
|
import {
|
|
OVERVIEW_TAB,
|
|
serviceContentTabs,
|
|
type ContentTab,
|
|
} from "./service-tabs";
|
|
|
|
export function ServicePage() {
|
|
const { serviceType = "", serviceId = "" } = useParams<{
|
|
serviceType: string;
|
|
serviceId: string;
|
|
}>();
|
|
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
|
const navigate = useNavigate();
|
|
|
|
const instance = useMemo(
|
|
() => services.find((s) => s.id === serviceId),
|
|
[services, serviceId],
|
|
);
|
|
const binding = getServiceBinding(serviceType);
|
|
const contentTabs = useMemo(
|
|
() => serviceContentTabs(serviceType),
|
|
[serviceType],
|
|
);
|
|
const siblings = useMemo(
|
|
() => services.filter((s) => s.service_type === serviceType && s.enabled),
|
|
[services, serviceType],
|
|
);
|
|
const showInstanceTabs = siblings.length > 1;
|
|
|
|
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
|
|
|
|
if (!binding) {
|
|
return (
|
|
<Alert>
|
|
<AlertDescription>Unknown service type: {serviceType}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
if (!instance) {
|
|
return (
|
|
<Alert>
|
|
<AlertDescription>Service not found.</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
const widgetsContent =
|
|
binding.widgets.length > 0 ? (
|
|
<div className="flex flex-col gap-2">
|
|
{binding.widgets.map((w) => (
|
|
<div
|
|
key={w.kind}
|
|
className="flex items-center justify-between rounded border p-2"
|
|
>
|
|
<div>
|
|
<div className="font-medium">{w.name}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{w.description}
|
|
</div>
|
|
</div>
|
|
<Badge variant="outline">{w.kind}</Badge>
|
|
</div>
|
|
))}
|
|
<p className="text-xs text-muted-foreground">
|
|
Add these to the dashboard from the dashboard's edit dialog.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
No widget kinds for this service type.
|
|
</p>
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
|
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">{binding.name}</Badge>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
navigate(
|
|
`/settings?tab=services&service=${encodeURIComponent(instance.id)}`,
|
|
)
|
|
}
|
|
>
|
|
<SettingsIcon className="mr-1 h-3 w-3" />
|
|
Settings
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Instance tabs (only when >1 enabled sibling) */}
|
|
{showInstanceTabs ? (
|
|
<Tabs value={instance.id}>
|
|
<TabsList>
|
|
{siblings.map((sibling: ServiceInstance) => (
|
|
<TabsTrigger
|
|
key={sibling.id}
|
|
value={sibling.id}
|
|
onClick={() =>
|
|
navigate(`/services/${serviceType}/${sibling.id}`)
|
|
}
|
|
>
|
|
{sibling.name}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
) : null}
|
|
|
|
{/* Content tabs */}
|
|
<Tabs defaultValue="Overview">
|
|
<TabsList>
|
|
<TabsTrigger value="Overview">Overview</TabsTrigger>
|
|
{contentTabs.map((tab) => (
|
|
<TabsTrigger key={tab.label} value={tab.label}>
|
|
{tab.label}
|
|
</TabsTrigger>
|
|
))}
|
|
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{allTabs.map((tab) => {
|
|
const TabComponent = tab.Component;
|
|
return (
|
|
<TabsContent key={tab.label} value={tab.label}>
|
|
<TabComponent instance={instance} />
|
|
</TabsContent>
|
|
);
|
|
})}
|
|
|
|
<TabsContent value="Widgets">
|
|
<SectionCard
|
|
title="Widgets"
|
|
description="Widget kinds this service provides."
|
|
>
|
|
{widgetsContent}
|
|
</SectionCard>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|