8d2e4c9bfd
Four coupled changes to the services-as-hub IA:
1. Nav entries use service TYPE names (Jellyfin, SSH Tasks, Alertmanager,
Grafana, Prometheus, Backups, Authentik) instead of conceptual names
(Media, Files, Actions, Alerts, Users). ssh_tasks collapses to one
entry ('SSH Tasks') instead of two. The content tabs inside each
service page surface the concepts (Files, Actions).
2. Service page gains a two-level tab structure when multiple enabled
instances of the same type exist: instance tabs on top ([Main Jellyfin]
[Backup Jellyfin]), content tabs below ([Overview] [Media] [Requests]
[Widgets]). Clicking an instance tab navigates to the sibling's route.
Single instance: no instance tabs. Replaces the dropdown switcher.
3. Config tab (connection fields, secrets, enable/disable, delete) moves
from the service page to Settings > Services tab. The service page
becomes a PURE operational view (Overview + content tabs + Widgets) --
no save/delete/config state. Settings gains a 4th tab 'Services' with
ServiceConfigEditor per instance (schema-driven config fields, secrets
with leave-blank-to-keep semantics, ConfirmDialog on delete).
4. Overview tab is now a configurable widget grid per service instance.
Each instance manages its own set of widgets on its Overview. Backend
widget list endpoints gain ?service_id= and ?scope= (dashboard|service)
filter params; the main Dashboard uses scope=dashboard to exclude
service-scoped widgets. The OverviewTab reuses WidgetInstanceCard +
WidgetConfigDialog. Empty state CTA for instances with no widgets.
All service-tab stubs are replaced; stubs.tsx deleted.
272 backend tests pass (+1 widget filter); 121 frontend tests pass (+3
instance-tabs + OverviewTab); lint/build green both sides.
147 lines
3.9 KiB
TypeScript
147 lines
3.9 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Badge } from "@/components/ui/badge";
|
|
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>
|
|
<Badge variant="outline">{binding.name}</Badge>
|
|
</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>
|
|
);
|
|
}
|