Rebase services-as-hub-ia onto mobile-responsive-parity

Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
This commit is contained in:
Developer
2026-06-26 20:55:00 +00:00
parent b583d5a365
commit 01527ae4f0
75 changed files with 5343 additions and 4926 deletions
+71 -59
View File
@@ -5,7 +5,6 @@ import {
NavLink,
useLocation,
Outlet,
Navigate,
} from "react-router-dom";
import {
QueryClient,
@@ -13,22 +12,22 @@ import {
useQuery,
} from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Applications } from "./pages/Applications";
import { NamedDashboardPage } from "./pages/NamedDashboardPage";
import { Settings } from "./pages/Settings";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { ServicePage } from "./pages/ServicePage";
import { ServiceTypePage } from "./pages/ServiceTypePage";
import { ServicesPage } from "./pages/ServicesPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
import { usePersistentState } from "./hooks/usePersistentState";
import { useIsMobile } from "./hooks/useIsMobile";
import { useServiceInstances } from "./hooks/useServices";
import { useDashboards } from "./hooks/useDashboards";
import { configuredNavEntries } from "./integrations/navEntries";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -45,12 +44,6 @@ import {
} from "@/components/ui/sheet";
import {
LayoutDashboard,
Activity,
DatabaseBackup,
Monitor,
Users,
Zap,
FolderOpen,
Settings as SettingsIcon,
Menu,
Sun,
@@ -59,6 +52,7 @@ import {
ChevronLeft,
ChevronRight,
Boxes,
LayoutTemplate,
} from "lucide-react";
const queryClient = new QueryClient({
@@ -66,9 +60,6 @@ const queryClient = new QueryClient({
queries: {
retry: 1,
refetchOnWindowFocus: false,
// Pause interval-based refetches (widgets ~30s, queue status 5s,
// media build progress 1s) when the tab is hidden. Saves battery on
// mobile (D8 follow-up). Build progress polls resume on return.
refetchIntervalInBackground: false,
},
},
@@ -92,18 +83,40 @@ function useDarkMode() {
return [darkMode, () => setDarkMode((prev) => !prev)] as const;
}
// Navigation items for sidebar
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/observability", label: "Observability", icon: Activity },
{ path: "/media", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen },
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
// Navigation items are data-driven (spec R1). Built from configured services + dashboards.
interface NavItem {
path: string;
label: string;
icon: LucideIcon;
}
function useNavItems() {
const { data: services = [] } = useServiceInstances();
const { data: dashboards = [] } = useDashboards();
return useMemo<NavItem[]>(() => {
const configuredTypes = new Set(
services.filter((s) => s.enabled).map((s) => s.service_type),
);
const serviceEntries = configuredNavEntries(configuredTypes).map((e) => ({
path: e.path,
label: e.label,
icon: e.icon,
}));
const dashboardEntries = dashboards.map((d) => ({
path: `/d/${d.slug}`,
label: d.label,
icon: LayoutTemplate,
}));
return [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
...dashboardEntries,
...serviceEntries,
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
}, [services, dashboards]);
}
function Sidebar({
collapsed,
@@ -115,6 +128,7 @@ function Sidebar({
isMobile: boolean;
}) {
const location = useLocation();
const navItems = useNavItems();
if (isMobile) return null;
@@ -199,11 +213,12 @@ function Sidebar({
function MobileDrawer() {
const [open, setOpen] = useState(false);
const location = useLocation();
const navItems = useNavItems();
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="mobile-touch-target md:hidden">
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
@@ -263,6 +278,7 @@ function TopBar({
});
const backendLabel = appVersion?.backend_label || "…";
const navItems = useNavItems();
const pageTitle =
navItems.find((item) => item.path === location.pathname)?.label ||
"Dashboard";
@@ -290,7 +306,7 @@ function TopBar({
variant="ghost"
size="icon"
onClick={onToggleDarkMode}
className="mobile-touch-target h-8 w-8"
className="h-8 w-8"
>
{darkMode ? (
<Sun className="h-4 w-4" />
@@ -303,7 +319,7 @@ function TopBar({
variant="ghost"
size="sm"
onClick={onSignOut}
className="mobile-touch-target gap-2"
className="gap-2"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
@@ -428,6 +444,18 @@ function AuthenticatedApp() {
);
}
function NotFoundPage() {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<h2 className="text-xl font-semibold">Not found</h2>
<p className="text-sm text-muted-foreground">This page doesn't exist.</p>
<Button asChild>
<NavLink to="/">Back to dashboard</NavLink>
</Button>
</div>
);
}
function AppInner() {
const [darkMode, toggleDarkMode] = useDarkMode();
@@ -439,26 +467,18 @@ function AppInner() {
<Routes>
<Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/d/:slug" element={<NamedDashboardPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
/>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
@@ -475,26 +495,18 @@ function AppInner() {
}
>
<Route path="/" element={<Dashboard />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/d/:slug" element={<NamedDashboardPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
/>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
+65
View File
@@ -0,0 +1,65 @@
/** API client for the Authentik service (directory + messaging). */
import { get, post } from "./shared";
export interface AuthentikUser {
pk: number;
username: string;
name: string;
email: string;
is_active: boolean;
avatar: string | null;
[key: string]: unknown;
}
export interface AuthentikUsersResponse {
items: AuthentikUser[];
total: number;
page: number;
page_size: number;
error?: string;
}
export async function fetchAuthentikUsers(
serviceId: string,
params: { search?: string; page?: number; page_size?: number },
): Promise<AuthentikUsersResponse> {
return get<AuthentikUsersResponse>(
`/api/services/authentik/${serviceId}/users`,
{
search: params.search ?? "",
page: String(params.page ?? 1),
page_size: String(params.page_size ?? 50),
},
);
}
export interface AuthentikMessageInput {
recipient_emails: string[];
subject: string;
html_body: string;
}
export interface AuthentikMessageResponse {
status: string;
request_id?: string;
recipient_count?: number;
error?: string;
}
export async function sendAuthentikMessage(
serviceId: string,
input: AuthentikMessageInput,
): Promise<AuthentikMessageResponse> {
return post<AuthentikMessageResponse>(
`/api/services/authentik/${serviceId}/message`,
input,
);
}
export async function fetchAuthentikMessageStatus(
serviceId: string,
): Promise<Record<string, unknown>> {
return get<Record<string, unknown>>(
`/api/services/authentik/${serviceId}/message/status`,
);
}
+50
View File
@@ -0,0 +1,50 @@
/**
* API client for the named-dashboards backend (Slice 3).
*/
import { del, get, post, put } from "./shared";
export interface NamedDashboard {
id: string;
label: string;
slug: string;
sort_order: number;
payload: Record<string, unknown>;
created_at: number;
updated_at: number;
}
export interface NamedDashboardInput {
id?: string | null;
label: string;
slug?: string;
sort_order: number;
payload: Record<string, unknown>;
}
export async function fetchDashboards(): Promise<NamedDashboard[]> {
return get<NamedDashboard[]>("/api/dashboards");
}
export async function fetchDashboardBySlug(
slug: string,
): Promise<NamedDashboard> {
return get<NamedDashboard>(
`/api/dashboards/slug/${encodeURIComponent(slug)}`,
);
}
export async function createDashboard(
input: NamedDashboardInput,
): Promise<NamedDashboard> {
return post<NamedDashboard>("/api/dashboards", input);
}
export async function updateDashboard(
input: NamedDashboardInput,
): Promise<NamedDashboard> {
return put<NamedDashboard>(`/api/dashboards`, input);
}
export async function deleteDashboard(id: string): Promise<{ status: string }> {
return del<{ status: string }>(`/api/dashboards/${id}`);
}
@@ -1,667 +0,0 @@
import { useMemo, useState, type ElementType, type ReactNode } from "react";
import { Link } from "react-router-dom";
import {
Activity,
AlertTriangle,
Bell,
CheckCircle2,
ChevronDown,
ExternalLink,
Gauge,
Inbox,
Radio,
RefreshCw,
Server,
ServerOff,
XCircle,
} from "lucide-react";
import {
useAlertmanagerAlerts,
useAlertmanagerStatus,
useGrafanaStatus,
usePrometheusStatus,
usePrometheusTargets,
useMonitoringMachines,
} from "../hooks/useObservability";
import { useServiceInstances } from "../hooks/useServices";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type {
AlertmanagerAlert,
MonitoringMachine,
PrometheusTarget,
} from "../types";
function severityVariant(
severity: string,
): "default" | "secondary" | "destructive" | "outline" {
switch (severity.toLowerCase()) {
case "critical":
return "destructive";
case "warning":
return "default";
case "info":
return "secondary";
default:
return "outline";
}
}
function HealthCard({
title,
status,
detail,
icon: Icon,
isLoading,
}: {
title: string;
status: "ok" | "warning" | "error" | "unknown";
detail: string;
icon: ElementType;
isLoading?: boolean;
}) {
const statusIcon =
status === "ok" ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : status === "warning" ? (
<AlertTriangle className="h-5 w-5 text-amber-500" />
) : status === "error" ? (
<XCircle className="h-5 w-5 text-red-500" />
) : (
<Radio className="h-5 w-5 text-muted-foreground" />
);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
{isLoading ? <Skeleton className="h-5 w-5" /> : statusIcon}
<span className="text-2xl font-bold capitalize">{status}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
</CardContent>
</Card>
);
}
function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: ElementType;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Icon className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">{title}</div>
<div className="max-w-md text-sm text-muted-foreground">
{description}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
);
}
function QueryError({
label,
error,
refetch,
}: {
label: string;
error: Error | null;
refetch: () => void;
}) {
if (!error) return null;
return (
<Alert variant="destructive">
<AlertTitle>{label} failed</AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{error.message}</span>
<Button variant="outline" size="sm" className="mobile-touch-target" onClick={() => refetch()}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-start justify-between gap-2">
<div className="font-medium text-sm">{alert.name}</div>
<div className="flex items-center gap-1">
<Badge variant={severityVariant(alert.severity)}>
{alert.severity}
</Badge>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{alert.summary || alert.description}
</div>
{alert.active_since && (
<div className="mt-1 text-[10px] text-muted-foreground">
Since {new Date(alert.active_since).toLocaleString()}
</div>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
{alert.description && (
<div>
<span className="font-medium">Description:</span>{" "}
{alert.description}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
{alert.job_name && (
<div>
<span className="font-medium">Job:</span> {alert.job_name}
</div>
)}
{alert.category && (
<div>
<span className="font-medium">Category:</span> {alert.category}
</div>
)}
<div>
<span className="font-medium">State:</span> {alert.state}
</div>
<div>
<span className="font-medium">Since:</span>{" "}
{alert.active_since
? new Date(alert.active_since).toLocaleString()
: "unknown"}
</div>
</div>
{alert.labels && Object.keys(alert.labels).length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{Object.entries(alert.labels).map(([key, value]) => (
<Badge key={key} variant="secondary" className="text-[10px]">
{key}={value}
</Badge>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
return (
<div className="space-y-3">
{targets.map((target, idx) => (
<div key={idx} className="rounded-lg border p-3">
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
{target.labels && Object.keys(target.labels).length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{Object.entries(target.labels).map(([key, value]) => (
<Badge key={key} variant="outline" className="text-[10px]">
{key}: {value}
</Badge>
))}
</div>
)}
</div>
))}
</div>
);
}
function GrafanaLinkCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
return (
<div className="rounded-md border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="font-medium">{title}</div>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
Open in Grafana
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</div>
</div>
);
}
export function ObservabilityPage() {
const {
data: alertsSummary,
isLoading: alertsLoading,
error: alertsError,
refetch: refetchAlerts,
} = useAlertmanagerAlerts();
const {
data: alertmanagerStatus,
isLoading: statusLoading,
error: statusError,
refetch: refetchStatus,
} = useAlertmanagerStatus();
const {
data: grafanaStatus,
isLoading: grafanaLoading,
error: grafanaError,
refetch: refetchGrafana,
} = useGrafanaStatus();
const {
data: prometheusStatus,
isLoading: prometheusLoading,
error: prometheusError,
refetch: refetchPrometheus,
} = usePrometheusStatus();
const {
data: prometheusTargets,
isLoading: targetsLoading,
error: targetsError,
refetch: refetchTargets,
} = usePrometheusTargets();
const {
data: machines = [],
isLoading: machinesLoading,
error: machinesError,
refetch: refetchMachines,
} = useMonitoringMachines();
const { data: grafanaServices = [] } = useServiceInstances("grafana");
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
const grafanaService =
grafanaServices.find((s) => s.enabled) ?? grafanaServices[0];
const GRAFANA_BASE_URL =
(grafanaService?.config?.base_url as string | undefined) ?? "";
const selectedMachine = useMemo<MonitoringMachine | null>(
() =>
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
[machines, selectedMachineId],
);
const nodeExporterDashboardUrl = useMemo(() => {
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
const instance = `${selectedMachine.host || "localhost"}:9100`;
return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
}, [selectedMachine, GRAFANA_BASE_URL]);
const logsUrl = useMemo(() => {
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
const container =
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent(
JSON.stringify({
datasource: "Loki",
queries: [{ refId: "A", expr: `{container="${container}"}` }],
range: { from: "now-1h", to: "now" },
}),
)}`;
}, [selectedMachine, GRAFANA_BASE_URL]);
const alertmanagerStatusDetail = alertmanagerStatus?.up
? alertmanagerStatus.version
? `version ${alertmanagerStatus.version}`
: "reachable"
: "unreachable";
const targetsCount = prometheusTargets?.length ?? 0;
const targetsStatus: "ok" | "warning" | "error" | "unknown" = targetsLoading
? "unknown"
: targetsError
? "error"
: targetsCount > 0
? "ok"
: "warning";
const alertStatus: "ok" | "warning" | "error" | "unknown" = alertsLoading
? "unknown"
: alertsError
? "error"
: (alertsSummary?.total ?? 0) > 0
? alertsSummary?.alerts.some((a) => a.severity === "critical")
? "error"
: "warning"
: "ok";
const machinesStatus: "ok" | "warning" | "error" | "unknown" = machinesLoading
? "unknown"
: machinesError
? "error"
: machines.length > 0
? "ok"
: "warning";
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">Observability</h1>
<p className="text-sm text-muted-foreground">
Unified view of metrics, logs, and alerts from Prometheus, Loki, and
Alertmanager. Deep dashboards live in Grafana.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<HealthCard
title="Alertmanager"
status={
statusError
? "error"
: alertmanagerStatus?.up
? "ok"
: statusLoading
? "unknown"
: "error"
}
detail={alertmanagerStatusDetail}
icon={Bell}
isLoading={statusLoading}
/>
<HealthCard
title="Active Alerts"
status={alertStatus}
detail={`${alertsSummary?.total ?? 0} firing alert${(alertsSummary?.total ?? 0) === 1 ? "" : "s"}`}
icon={AlertTriangle}
isLoading={alertsLoading}
/>
<HealthCard
title="Prometheus Targets"
status={targetsStatus}
detail={`${targetsCount} remote Node Exporter target${targetsCount === 1 ? "" : "s"}`}
icon={Radio}
isLoading={targetsLoading}
/>
<HealthCard
title="Machines"
status={machinesStatus}
detail={`${machines.length} monitoring machine${machines.length === 1 ? "" : "s"}`}
icon={Server}
isLoading={machinesLoading}
/>
<HealthCard
title="Grafana"
status={
grafanaError
? "error"
: grafanaStatus?.up
? "ok"
: grafanaLoading
? "unknown"
: "error"
}
detail={
grafanaStatus?.up
? grafanaStatus.version
? `version ${grafanaStatus.version}`
: "reachable"
: grafanaStatus?.error === "no_service_configured"
? "not configured"
: "unreachable"
}
icon={Gauge}
isLoading={grafanaLoading}
/>
<HealthCard
title="Prometheus"
status={
prometheusError
? "error"
: prometheusStatus?.up
? "ok"
: prometheusLoading
? "unknown"
: "error"
}
detail={
prometheusStatus?.up
? prometheusStatus.version
? `version ${prometheusStatus.version}`
: "reachable"
: prometheusStatus?.error === "no_service_configured"
? "not configured"
: "unreachable"
}
icon={Radio}
isLoading={prometheusLoading}
/>
</div>
<div className="space-y-3">
{statusError && (
<QueryError
label="Alertmanager status"
error={statusError}
refetch={refetchStatus}
/>
)}
{alertsError && (
<QueryError
label="Active alerts"
error={alertsError}
refetch={refetchAlerts}
/>
)}
{targetsError && (
<QueryError
label="Prometheus targets"
error={targetsError}
refetch={refetchTargets}
/>
)}
{machinesError && (
<QueryError
label="Monitoring machines"
error={machinesError}
refetch={refetchMachines}
/>
)}
{grafanaError && (
<QueryError
label="Grafana status"
error={grafanaError}
refetch={refetchGrafana}
/>
)}
{prometheusError && (
<QueryError
label="Prometheus status"
error={prometheusError}
refetch={refetchPrometheus}
/>
)}
</div>
{alertsSummary?.error && (
<Alert variant="destructive">
<AlertTitle>Alertmanager unreachable</AlertTitle>
<AlertDescription>
The UI cannot reach Alertmanager right now. Alerts shown here may be
stale.
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-4 w-4" />
Recent Alerts
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{alertsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !alertsSummary || alertsSummary.total === 0 ? (
<EmptyState
icon={Inbox}
title="No active alerts"
description="Everything looks quiet. Alertmanager will list firing alerts here when they occur."
/>
) : (
<>
{alertsSummary.alerts.map((alert, idx) => (
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
))}
{alertsSummary.total > alertsSummary.alerts.length && (
<div className="text-center text-xs text-muted-foreground">
{alertsSummary.total - alertsSummary.alerts.length} more
alert
{alertsSummary.total - alertsSummary.alerts.length === 1
? ""
: "s"}{" "}
in Alertmanager
</div>
)}
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Prometheus Targets
</CardTitle>
</CardHeader>
<CardContent>
{targetsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !prometheusTargets || prometheusTargets.length === 0 ? (
<EmptyState
icon={Radio}
title="No Node Exporter targets"
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
action={
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
}
/>
) : (
<TargetsTable targets={prometheusTargets} />
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Machine Dashboard
</CardTitle>
<Select
value={selectedMachine?.id ?? ""}
onValueChange={setSelectedMachineId}
disabled={machines.length === 0}
>
<SelectTrigger className="w-full sm:w-[240px]">
<SelectValue placeholder="Select machine" />
</SelectTrigger>
<SelectContent>
{machines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="space-y-4">
{selectedMachine ? (
GRAFANA_BASE_URL ? (
<>
<GrafanaLinkCard
title={`${selectedMachine.name} metrics`}
description="Open the Node Exporter overview dashboard for this machine in Grafana."
href={nodeExporterDashboardUrl}
/>
<GrafanaLinkCard
title={`${selectedMachine.name} logs`}
description="Explore Loki logs for this machine in Grafana."
href={logsUrl}
/>
</>
) : (
<EmptyState
icon={Gauge}
title="No Grafana service configured"
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
action={
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
<Link to="/services">Open Services</Link>
</Button>
}
/>
)
) : (
<EmptyState
icon={ServerOff}
title="No machine selected"
description="Add monitoring machines in Settings to see Grafana drill-down links."
action={
<Button variant="outline" size="sm" className="mobile-touch-target" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { useNavigate } from "react-router-dom";
import { Boxes, ChevronRight, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Pinned service link rendered on named dashboards. A card-shaped shortcut
* that navigates to a service page (or a specific tab via query param).
*
* The `target` is a route path like `/services/jellyfin/svc-1` or
* `/services/ssh_tasks/svc-2?tab=Files`.
*/
export interface PinnedServiceLinkProps {
label: string;
target: string;
icon?: LucideIcon;
className?: string;
}
export function PinnedServiceLink({
label,
target,
icon: Icon = Boxes,
className,
}: PinnedServiceLinkProps) {
const navigate = useNavigate();
return (
<button
type="button"
onClick={() => navigate(target)}
className={cn(
"mobile-touch-target group flex min-h-16 w-full items-center justify-between rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
<div className="flex items-center gap-3">
<Icon className="size-5 shrink-0 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{label}</span>
</div>
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
);
}
/**
* Static helper: build a target path for a pinned service link.
* Returns `/services/:type/:id` or with a `?tab=` suffix when provided.
*/
// eslint-disable-next-line react-refresh/only-export-components
export function serviceLinkTarget(
serviceType: string,
serviceId: string,
tab?: string,
): string {
const base = `/services/${serviceType}/${serviceId}`;
return tab ? `${base}?tab=${tab}` : base;
}
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { PinnedServiceLink } from "../PinnedServiceLink";
function renderLink() {
return render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route
path="/"
element={
<PinnedServiceLink
label="My Jellyfin"
target="/services/jellyfin/svc-1"
/>
}
/>
<Route
path="/services/jellyfin/svc-1"
element={<div>target page</div>}
/>
</Routes>
</MemoryRouter>,
);
}
describe("PinnedServiceLink", () => {
it("renders the label", () => {
renderLink();
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
});
it("navigates to the target on click", async () => {
const user = userEvent.setup();
renderLink();
await user.click(screen.getByText("My Jellyfin"));
expect(screen.getByText("target page")).toBeInTheDocument();
});
});
+43
View File
@@ -0,0 +1,43 @@
/** Hooks for the Authentik directory + messaging tabs. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchAuthentikMessageStatus,
fetchAuthentikUsers,
sendAuthentikMessage,
} from "../api/authentik";
export function useAuthentikUsers(
serviceId: string,
params: { search?: string; page?: number; page_size?: number },
) {
return useQuery({
queryKey: ["authentik", "users", serviceId, params],
queryFn: () => fetchAuthentikUsers(serviceId, params),
staleTime: 10_000,
});
}
export function useSendAuthentikMessage(serviceId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: {
recipient_emails: string[];
subject: string;
html_body: string;
}) => sendAuthentikMessage(serviceId, input),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["authentik", "message-status", serviceId],
});
},
});
}
export function useAuthentikMessageStatus(serviceId: string) {
return useQuery({
queryKey: ["authentik", "message-status", serviceId],
queryFn: () => fetchAuthentikMessageStatus(serviceId),
refetchInterval: 5_000,
staleTime: 0,
});
}
+47
View File
@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createDashboard,
deleteDashboard,
fetchDashboardBySlug,
fetchDashboards,
updateDashboard,
type NamedDashboardInput,
} from "../api/dashboards";
export function useDashboards() {
return useQuery({
queryKey: ["dashboards"],
queryFn: fetchDashboards,
staleTime: 30 * 1000,
});
}
export function useDashboardBySlug(slug: string | undefined) {
return useQuery({
queryKey: ["dashboards", "slug", slug],
queryFn: () => fetchDashboardBySlug(slug!),
enabled: !!slug,
staleTime: 30 * 1000,
});
}
export function useSaveDashboard() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: NamedDashboardInput) =>
input.id ? updateDashboard(input) : createDashboard(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
},
});
}
export function useDeleteDashboard() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteDashboard(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
},
});
}
-11
View File
@@ -1,11 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client";
import type { UserDirectoryResponse } from "../types";
export function useUsers(jellyfinServiceId?: string) {
return useQuery<UserDirectoryResponse>({
queryKey: ["users", jellyfinServiceId ?? "default"],
queryFn: () => fetchUsers(jellyfinServiceId),
staleTime: 30_000,
});
}
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries";
describe("navEntries", () => {
it("returns no entries when no types are configured", () => {
expect(configuredNavEntries(new Set())).toEqual([]);
});
it("returns Media when jellyfin is configured", () => {
const entries = configuredNavEntries(new Set(["jellyfin"]));
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe("Media");
expect(entries[0].path).toBe("/services/jellyfin");
});
it("returns Files + Actions when ssh_tasks is configured", () => {
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.label)).toEqual(["Files", "Actions"]);
});
it("returns all observability entries", () => {
const entries = configuredNavEntries(
new Set(["alertmanager", "grafana", "prometheus"]),
);
expect(entries.map((e) => e.label)).toEqual([
"Alerts",
"Grafana",
"Prometheus",
]);
});
it("returns Backups + Users when configured", () => {
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
expect(entries.map((e) => e.label)).toEqual(["Backups", "Users"]);
});
it("nextcloud has no nav entries in the static map", () => {
expect(
SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"),
).toEqual([]);
});
it("preserves declaration order across mixed types", () => {
const entries = configuredNavEntries(
new Set(["authentik", "ssh_tasks", "jellyfin"]),
);
expect(entries.map((e) => e.label)).toEqual([
"Media",
"Files",
"Actions",
"Users",
]);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Service-type → conditional nav-entry map.
*
* Each configured service type contributes one or more top-level nav entries
* that appear only when at least one enabled instance of that type exists.
* See OpenSpec change `services-as-hub-ia`, spec R1.2.
*/
import {
Activity,
DatabaseBackup,
FolderOpen,
GanttChartSquare,
Link2,
Monitor,
Users,
Zap,
type LucideIcon,
} from "lucide-react";
export interface NavEntry {
serviceType: string;
label: string;
icon: LucideIcon;
/** Route path for this entry. */
path: string;
}
/**
* Static mapping from service type to its conditional nav entries.
* `nextcloud` has no entries (no operational content).
*/
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
{
serviceType: "jellyfin",
label: "Media",
icon: Monitor,
path: "/services/jellyfin",
},
{
serviceType: "ssh_tasks",
label: "Files",
icon: FolderOpen,
path: "/services/ssh_tasks",
},
{
serviceType: "ssh_tasks",
label: "Actions",
icon: Zap,
path: "/services/ssh_tasks",
},
{
serviceType: "alertmanager",
label: "Alerts",
icon: Activity,
path: "/services/alertmanager",
},
{
serviceType: "grafana",
label: "Grafana",
icon: Link2,
path: "/services/grafana",
},
{
serviceType: "prometheus",
label: "Prometheus",
icon: GanttChartSquare,
path: "/services/prometheus",
},
{
serviceType: "backups",
label: "Backups",
icon: DatabaseBackup,
path: "/services/backups",
},
{
serviceType: "authentik",
label: "Users",
icon: Users,
path: "/services/authentik",
},
];
/**
* Filter the static entries to those whose service type is configured (present
* in the `configuredTypes` set). Returns a flat list in declaration order.
*/
export function configuredNavEntries(configuredTypes: Set<string>): NavEntry[] {
return SERVICE_TYPE_NAV_ENTRIES.filter((e) =>
configuredTypes.has(e.serviceType),
);
}
-133
View File
@@ -1,133 +0,0 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { TabsTrigger } from "@/components/ui/tabs";
import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useServiceInstances } from "../hooks/useServices";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
function JellyfinLibraryStats() {
const [searchParams] = useSearchParams();
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
jellyfinServices.find((s) => s.enabled)?.id ||
"";
const { data: counts } = useCounts(selectedServiceId || undefined);
const { data: libraries } = useLibraries(selectedServiceId || undefined);
return (
<SectionCard
title="Library stats"
description="Compact Jellyfin summary for the selected machine."
action={
<Badge variant="outline">
{selectedServiceId ? "Selected service" : "Default service"}
</Badge>
}
>
<div className="flex flex-col gap-2">
{counts ? (
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Total</span>
<div className="text-base leading-tight font-extrabold">
{(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Movies</span>
<div className="text-base leading-tight font-extrabold">
{counts.movies.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Series</span>
<div className="text-base leading-tight font-extrabold">
{counts.series.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Episodes</span>
<div className="text-base leading-tight font-extrabold">
{counts.episodes.toLocaleString()}
</div>
</div>
</div>
) : null}
{libraries?.length ? (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{libraries.map((library) => (
<div
key={library.library}
className="rounded-lg border bg-card px-3 py-2"
>
<div className="flex flex-col gap-1">
<span className="truncate text-sm font-semibold">
{library.library}
</span>
<span className="text-sm text-muted-foreground">
Total {library.total.toLocaleString()} · Movies{" "}
{library.movies.toLocaleString()} · Series{" "}
{library.series.toLocaleString()}
</span>
</div>
</div>
))}
</div>
) : null}
</div>
</SectionCard>
);
}
export function Applications() {
const [tab, setTab] = useState("jellyfin");
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold">Applications</h1>
<p className="text-sm text-muted-foreground">
Browse application-specific tools from a compact tabbed workspace.
</p>
</div>
<TabbedCard
value={tab}
onChange={setTab}
tabs={[
<TabsTrigger key="jellyfin" value="jellyfin">
Jellyfin
</TabsTrigger>,
<TabsTrigger key="nextcloud" value="nextcloud">
Nextcloud
</TabsTrigger>,
]}
>
{tab === "jellyfin" ? (
<div className="flex flex-col gap-4">
<JellyfinLibraryStats />
<Media />
</div>
) : (
<div className="rounded-lg border bg-card p-3">
<Alert>
<AlertDescription>
Nextcloud support will be added in a future update.
</AlertDescription>
</Alert>
</div>
)}
</TabbedCard>
</div>
);
}
+27 -27
View File
@@ -46,7 +46,7 @@ import { DialogFooter } from "../components/DialogFooter";
import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
// --- Mobile section grouping (spec R7.2) ---
// --- Mobile section grouping (mobile-parity) ---
const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const;
type SectionId = (typeof SECTION_ORDER)[number];
@@ -102,7 +102,6 @@ function MobileWidgetSections({
}) {
return (
<>
{/* Anchor bar — horizontally scrollable pills (spec R7.2, md:hidden) */}
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{sections.map((section) => {
const meta = SECTION_META[section.id];
@@ -127,7 +126,6 @@ function MobileWidgetSections({
);
})}
</div>
{/* Sectioned widgets — single column (spec R7.1) */}
<div className="grid grid-cols-1 gap-4">
{sections.map((section) => (
<section
@@ -148,6 +146,7 @@ function MobileWidgetSections({
);
}
function emptyShortcut(): DashboardShortcutInput {
return {
id: null,
@@ -354,7 +353,6 @@ function ShortcutDialog({
<div className="flex items-center gap-2">
<Switch
id="shortcut-enabled"
className="mobile-touch-target"
checked={draft.enabled}
onCheckedChange={(checked) =>
onChange({ ...draft, enabled: checked })
@@ -425,24 +423,13 @@ function ShortcutCard({
size="sm"
disabled={!shortcut.enabled || !href}
onClick={onOpen}
className="mobile-touch-target"
>
Open
</Button>
<Button
size="sm"
variant="outline"
onClick={onEdit}
className="mobile-touch-target"
>
<Button size="sm" variant="outline" onClick={onEdit}>
Edit
</Button>
<Button
size="sm"
variant="destructive"
onClick={onDelete}
className="mobile-touch-target"
>
<Button size="sm" variant="destructive" onClick={onDelete}>
Delete
</Button>
</div>
@@ -508,23 +495,36 @@ export function Dashboard() {
return (
<div className="flex flex-col gap-4">
{services.length === 0 ? (
<SectionCard
title="Welcome to Manage"
description="Add a service to get started."
>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
No services configured yet. Add a Jellyfin, SSH target, Authentik,
or observability service to populate the navigation and
dashboards.
</p>
<Button
variant="outline"
onClick={() => navigate("/services")}
className="w-fit"
>
Add a service
</Button>
</div>
</SectionCard>
) : null}
<SectionCard
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
action={
<div className="flex gap-2">
<Button
variant="outline"
className="mobile-touch-target"
onClick={() => setWidgetDialogOpen(true)}
>
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
Edit dashboard
</Button>
<Button
variant="outline"
className="mobile-touch-target"
onClick={openCreateShortcut}
>
<Button variant="outline" onClick={openCreateShortcut}>
Add shortcut
</Button>
</div>
-1
View File
@@ -1 +0,0 @@
export { FileBrowser } from "./FileBrowser.impl";
+91
View File
@@ -0,0 +1,91 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { Boxes } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { useDashboardBySlug } from "../hooks/useDashboards";
import { PinnedServiceLink } from "../components/PinnedServiceLink";
/**
* Payload model for named dashboards (design choice: inline items, not widget
* instance ids). The payload stores an ordered list of items:
*
* ```
* { items: DashboardItem[] }
* ```
*
* Where `DashboardItem` is either a pinned service link (this slice) or a
* future widget reference (follow-up). Widget composition on named dashboards
* is deferred — the main Dashboard already has the rich widget config dialog.
*/
interface LinkItem {
type: "link";
label: string;
target: string;
}
type DashboardItem = LinkItem;
function parseItems(payload: Record<string, unknown>): DashboardItem[] {
const items = payload.items;
if (!Array.isArray(items)) return [];
return items.filter(
(item): item is LinkItem =>
typeof item === "object" &&
item !== null &&
item.type === "link" &&
typeof item.label === "string" &&
typeof item.target === "string",
);
}
export function NamedDashboardPage() {
const { slug = "" } = useParams<{ slug: string }>();
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
const items = useMemo(
() => parseItems(dashboard?.payload ?? {}),
[dashboard?.payload],
);
if (isLoading) {
return <Skeleton className="h-32 w-full" />;
}
if (isError || !dashboard) {
return (
<Alert>
<AlertDescription>
Dashboard not found. It may have been deleted or the link is invalid.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
</div>
{items.length === 0 ? (
<Alert>
<AlertDescription>
This dashboard has no shortcuts yet. Add pinned service links from
the dashboard management panel on the Services page.
</AlertDescription>
</Alert>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((item, index) => (
<PinnedServiceLink
key={`${item.target}-${index}`}
label={item.label}
target={item.target}
icon={Boxes}
/>
))}
</div>
)}
</div>
);
}
+288 -240
View File
@@ -1,11 +1,19 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
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,
@@ -13,6 +21,7 @@ import {
useServiceTypes,
} from "../hooks/useServices";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import type {
ServiceInstance,
ServiceInstanceInput,
@@ -20,8 +29,12 @@ import type {
} from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { SheetForm } from "@/components/ui/sheet-form";
import { getServiceBinding } from "../integrations/registry";
import {
OVERVIEW_TAB,
serviceContentTabs,
type ContentTab,
} from "./service-tabs";
function Field({
label,
@@ -52,6 +65,7 @@ export function ServicePage() {
}>();
const { data: services = [] } = useServiceInstances(serviceType || undefined);
const { data: types = [] } = useServiceTypes();
const navigate = useNavigate();
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
@@ -64,24 +78,35 @@ export function ServicePage() {
() => 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 navigate = useNavigate();
const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const [hydrated, setHydrated] = useState(false);
const isMobile = useIsMobile();
// The mobile SheetForm opens by default when the page loads: this page is
// reached via /services/:serviceType/:serviceId, always editing an existing
// instance, so there is no separate "open edit" trigger on mobile.
const [sheetOpen, setSheetOpen] = useState(true);
// Hydrate local form state once the instance loads.
if (instance && !hydrated) {
setName(instance.name);
setEnabled(instance.enabled);
setDraftConfig({ ...instance.config });
setDraftSecrets({});
setHydrated(true);
}
@@ -102,84 +127,77 @@ export function ServicePage() {
}
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: {}, // secrets are managed via the dedicated inputs below
secrets: onlyChangedSecrets,
enabled,
};
}
async function save() {
await saveService.mutateAsync(buildInput());
// R4.5: close the sheet on successful save and return to the services list
// (on mobile the sheet IS the page, so closing it would strand the user).
if (isMobile) {
setSheetOpen(false);
navigate("/services");
}
// Clear secret drafts after a successful save so the inputs reset to
// "leave blank to keep" state.
setDraftSecrets({});
}
const configFields = (
<ServiceConnectionFields
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
// The config + widgets body, shared between desktop tabs and mobile SheetForm.
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>
);
const configBody = (
<ConfigBody
instance={instance}
typeInfo={typeInfo}
draftConfig={draftConfig}
onConfigChange={setDraftConfig}
isMobile={isMobile}
draftSecrets={draftSecrets}
onSecretsChange={setDraftSecrets}
name={name}
enabled={enabled}
onNameChange={setName}
onEnabledChange={setEnabled}
onSave={save}
savePending={saveService.isPending}
onDelete={() => setDeleteOpen(true)}
/>
);
const widgetsCard =
binding.widgets.length > 0 ? (
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
<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>
</SectionCard>
) : null;
const confirmDelete = (
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
}}
/>
);
// Dirty when any editable field diverges from the persisted instance (mobile SheetForm R4.5 guard).
const isDirty =
name !== instance.name ||
enabled !== instance.enabled ||
JSON.stringify(draftConfig) !== JSON.stringify(instance.config);
// Mobile: render inside a SheetForm (open on mount; cancel navigates back).
if (isMobile) {
return (
<div className="flex flex-col gap-4">
@@ -193,113 +211,153 @@ export function ServicePage() {
navigate("/services");
}}
isPending={saveService.isPending}
isDirty={isDirty}
isDirty={
name !== instance.name ||
enabled !== instance.enabled ||
JSON.stringify(draftConfig) !== JSON.stringify(instance.config)
}
>
<div className="flex flex-col gap-6">
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
{configFields}
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
Delete service
</Button>
{widgetsCard}
{allTabs.map((tab) => {
const TabComponent = tab.Component;
return (
<div key={tab.label}>
<h3 className="mb-2 text-sm font-semibold text-muted-foreground">
{tab.label}
</h3>
<TabComponent instance={instance} />
</div>
);
})}
{widgetsContent}
{configBody}
</div>
</SheetForm>
{confirmDelete}
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
navigate("/services");
}}
/>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
{/* Header + instance switcher */}
<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 className="flex items-center gap-2">
{showSwitcher ? (
<Select
value={instance.id}
onValueChange={(id) => navigate(`/services/${serviceType}/${id}`)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{siblings.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Badge variant="outline">{binding.name}</Badge>
</div>
</div>
<SectionCard title="General">
<div className="flex flex-col gap-3">
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
className="mobile-touch-target"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
<div className="flex justify-between">
<Button
className="mobile-touch-target"
onClick={save}
disabled={saveService.isPending}
>
Save
</Button>
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
Delete
</Button>
</div>
</div>
</SectionCard>
{/* Tab skeleton */}
<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>
<TabsTrigger value="Config">Config</TabsTrigger>
</TabsList>
{configFields}
{allTabs.map((tab) => {
const TabComponent = tab.Component;
return (
<TabsContent key={tab.label} value={tab.label}>
<TabComponent instance={instance} />
</TabsContent>
);
})}
{widgetsCard}
<TabsContent value="Widgets">
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
{widgetsContent}
</SectionCard>
</TabsContent>
{confirmDelete}
<TabsContent value="Config">{configBody}</TabsContent>
</Tabs>
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
navigate("/services");
}}
/>
</div>
);
}
function ServiceConnectionFields({
function ConfigBody({
instance,
typeInfo,
draftConfig,
onConfigChange,
isMobile,
draftSecrets,
onSecretsChange,
name,
enabled,
onNameChange,
onEnabledChange,
onSave,
savePending,
onDelete,
}: {
instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined;
draftConfig: Record<string, unknown>;
onConfigChange: (config: Record<string, unknown>) => void;
isMobile: boolean;
draftSecrets: Record<string, string>;
onSecretsChange: (secrets: Record<string, string>) => void;
name: string;
enabled: boolean;
onNameChange: (name: string) => void;
onEnabledChange: (enabled: boolean) => void;
onSave: () => void;
savePending: boolean;
onDelete: () => void;
}) {
const saveService = useSaveServiceInstance();
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const properties =
(
(typeInfo?.config_schema ?? {}) as {
@@ -322,107 +380,97 @@ function ServiceConnectionFields({
{ type: typeof value === "number" ? "integer" : "string" },
]);
function handleUpdateConnection() {
const onlyChanged = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
saveService.mutate({
id: instance.id,
service_type: instance.service_type,
name: instance.name,
config: draftConfig,
secrets: onlyChanged,
enabled: instance.enabled,
});
setDraftSecrets({});
}
const fields = (
<div className="flex flex-col gap-3">
{configEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No connection config.</p>
) : (
<div className="flex flex-col gap-3">
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
onConfigChange({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</Field>
);
})}
</div>
)}
{Object.keys(instance.secrets_set).length === 0 ? (
<p className="text-sm text-muted-foreground">No secret fields.</p>
) : (
<div className="flex flex-col gap-3">
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
<div key={key} className="flex flex-col gap-1.5">
<Field
label={key}
htmlFor={`secret-${key}`}
helper="Leave blank to keep the current value."
>
<Input
id={`secret-${key}`}
type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</Field>
{isSet ? <Badge variant="secondary">set</Badge> : null}
</div>
))}
</div>
)}
<Button className="mobile-touch-target" onClick={handleUpdateConnection}>
Update connection
</Button>
</div>
);
// On mobile the fields render inside the SheetForm body without a card
// wrapper (the SheetForm already provides the container). On desktop they
// keep their original SectionCard framing.
if (isMobile) {
return <div className="flex flex-col gap-3">{fields}</div>;
}
return (
<SectionCard
title="Connection"
description="Edit non-secret connection config and secret values."
>
{fields}
<SectionCard title="Config">
<div className="flex flex-col gap-3">
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={enabled}
onCheckedChange={onEnabledChange}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
{configEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No connection config.</p>
) : (
<div className="flex flex-col gap-3">
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
onConfigChange({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</Field>
);
})}
</div>
)}
{Object.keys(instance.secrets_set).length === 0 ? null : (
<div className="flex flex-col gap-3">
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
<div key={key} className="flex flex-col gap-1.5">
<Field
label={key}
htmlFor={`secret-${key}`}
helper="Leave blank to keep the current value."
>
<Input
id={`secret-${key}`}
type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
onSecretsChange({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</Field>
{isSet ? <Badge variant="secondary">set</Badge> : null}
</div>
))}
</div>
)}
<div className="flex justify-between">
<Button onClick={onSave} disabled={savePending}>
Save
</Button>
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
</div>
</div>
</SectionCard>
);
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Handles `/services/:type` (no instance id). Resolves the first enabled
* instance and redirects. Shows an empty state if none are configured.
*/
import { useMemo } from "react";
import { Link, useParams, Navigate } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useServiceInstances } from "../hooks/useServices";
export function ServiceTypePage() {
const { serviceType = "" } = useParams<{ serviceType: string }>();
const { data: instances = [], isLoading } = useServiceInstances(
serviceType || undefined,
);
const firstEnabled = useMemo(
() => instances.find((s) => s.enabled) ?? instances[0],
[instances],
);
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (firstEnabled) {
return (
<Navigate to={`/services/${serviceType}/${firstEnabled.id}`} replace />
);
}
return (
<Alert>
<AlertDescription className="flex flex-col gap-3">
<span>No {serviceType} service configured.</span>
<Button asChild className="w-fit">
<Link to="/services">Add a service</Link>
</Button>
</AlertDescription>
</Alert>
);
}
+259 -6
View File
@@ -12,13 +12,31 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ExternalLink, Plus, Trash2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ChevronDown,
ChevronUp,
ExternalLink,
Plus,
Trash2,
} from "lucide-react";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
} from "../hooks/useServices";
import { useServiceTypes } from "../hooks/useServices";
import {
useDashboards,
useDeleteDashboard,
useSaveDashboard,
} from "../hooks/useDashboards";
import type {
SecretFieldInfo,
ServiceInstance,
@@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import { getServiceBinding } from "../integrations/registry";
import { serviceLinkTarget } from "../components/PinnedServiceLink";
import type { NamedDashboardInput } from "../api/dashboards";
interface CreateDraft {
serviceType: string;
@@ -195,7 +215,7 @@ function CreateServiceDialog({
{!draft ? (
<div className="flex flex-col gap-2">
{types.map((t) => (
<Button className="mobile-touch-target"
<Button
key={t.service_type}
variant="outline"
onClick={() => setDraft(emptyDraft(t.service_type))}
@@ -234,7 +254,6 @@ function CreateServiceDialog({
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
className="mobile-touch-target"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
@@ -258,6 +277,239 @@ function CreateServiceDialog({
);
}
// --- Named dashboards management (Slice 10.3) ---
function DashboardManagementCard() {
const { data: dashboards = [] } = useDashboards();
const saveDashboard = useSaveDashboard();
const deleteDashboard = useDeleteDashboard();
const { data: services = [] } = useServiceInstances();
const [createOpen, setCreateOpen] = useState(false);
const [newLabel, setNewLabel] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [linkDashId, setLinkDashId] = useState<string | null>(null);
const [linkLabel, setLinkLabel] = useState("");
const [linkTarget, setLinkTarget] = useState("");
const enabledServices = useMemo(
() => services.filter((s) => s.enabled),
[services],
);
function createDashboard() {
if (!newLabel.trim()) return;
const input: NamedDashboardInput = {
label: newLabel.trim(),
sort_order: dashboards.length,
payload: { items: [] },
};
saveDashboard.mutate(input);
setNewLabel("");
setCreateOpen(false);
}
function reorder(dashId: string, direction: -1 | 1) {
const sorted = [...dashboards].sort((a, b) => a.sort_order - b.sort_order);
const idx = sorted.findIndex((d) => d.id === dashId);
const swapIdx = idx + direction;
if (swapIdx < 0 || swapIdx >= sorted.length) return;
const a = sorted[idx];
const b = sorted[swapIdx];
saveDashboard.mutate({
...a,
sort_order: b.sort_order,
payload: a.payload,
});
saveDashboard.mutate({
...b,
sort_order: a.sort_order,
payload: b.payload,
});
}
function addPinnedLink() {
if (!linkDashId || !linkLabel.trim() || !linkTarget.trim()) return;
const dash = dashboards.find((d) => d.id === linkDashId);
if (!dash) return;
const items = Array.isArray(dash.payload.items)
? (dash.payload.items as unknown[])
: [];
items.push({ type: "link", label: linkLabel.trim(), target: linkTarget });
saveDashboard.mutate({
id: dash.id,
label: dash.label,
sort_order: dash.sort_order,
payload: { items },
});
setLinkLabel("");
setLinkTarget("");
}
return (
<SectionCard
title="Dashboards"
description="Named dashboards appear in the top nav. Compose them from pinned service links."
action={
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 h-3 w-3" />
New dashboard
</Button>
}
>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No named dashboards yet. Create one to add pinned service links.
</p>
) : (
<div className="flex flex-col gap-3">
{[...dashboards]
.sort((a, b) => a.sort_order - b.sort_order)
.map((d, idx, arr) => (
<div key={d.id} className="rounded border p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium">{d.label}</span>
<Badge variant="outline">/{d.slug}</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={idx === 0}
onClick={() => reorder(d.id, -1)}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={idx === arr.length - 1}
onClick={() => reorder(d.id, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeleteId(d.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{Array.isArray(d.payload.items) &&
(d.payload.items as unknown[]).length > 0 ? (
<span className="text-xs text-muted-foreground">
{(d.payload.items as unknown[]).length} pinned link(s)
</span>
) : (
<span className="text-xs text-muted-foreground">
No links yet
</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-end gap-2">
<Field label="Link label" htmlFor={`link-label-${d.id}`}>
<Input
id={`link-label-${d.id}`}
className="w-40"
placeholder="My Jellyfin"
value={linkDashId === d.id ? linkLabel : ""}
onChange={(e) => {
setLinkDashId(d.id);
setLinkLabel(e.target.value);
}}
/>
</Field>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`link-target-${d.id}`}>Service</Label>
<Select
value={linkDashId === d.id ? linkTarget : ""}
onValueChange={(v) => {
setLinkDashId(d.id);
setLinkTarget(v);
}}
>
<SelectTrigger
id={`link-target-${d.id}`}
className="w-56"
>
<SelectValue placeholder="Pick a service" />
</SelectTrigger>
<SelectContent>
{enabledServices.map((s) => (
<SelectItem
key={s.id}
value={serviceLinkTarget(s.service_type, s.id)}
>
{s.name} ({s.service_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
disabled={
linkDashId !== d.id ||
!linkLabel.trim() ||
!linkTarget.trim()
}
onClick={addPinnedLink}
>
Add link
</Button>
</div>
</div>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>New dashboard</DialogTitle>
</DialogHeader>
<Field label="Label" htmlFor="dash-label">
<Input
id="dash-label"
placeholder="Storage overview"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") createDashboard();
}}
/>
</Field>
<DialogFooter
onCancel={() => setCreateOpen(false)}
onConfirm={createDashboard}
confirmLabel="Create"
confirmDisabled={!newLabel.trim() || saveDashboard.isPending}
/>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(deleteId)}
title="Delete dashboard?"
message="This removes the named dashboard and its pinned links."
confirmLabel="Delete"
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (deleteId) deleteDashboard.mutate(deleteId);
setDeleteId(null);
}}
/>
</SectionCard>
);
}
export function ServicesPage() {
const navigate = useNavigate();
const { data: services = [] } = useServiceInstances();
@@ -287,7 +539,7 @@ export function ServicesPage() {
title="Services"
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
action={
<Button variant="outline" onClick={() => setCreateOpen(true)} className="mobile-touch-target">
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 h-3 w-3" />
Add service
</Button>
@@ -329,7 +581,6 @@ export function ServicesPage() {
<Button
variant="ghost"
size="sm"
className="mobile-touch-target"
onClick={() =>
navigate(`/services/${s.service_type}/${s.id}`)
}
@@ -339,7 +590,7 @@ export function ServicesPage() {
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8 text-destructive"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(s.id)}
>
<Trash2 className="h-4 w-4" />
@@ -354,6 +605,8 @@ export function ServicesPage() {
)}
</SectionCard>
<DashboardManagementCard />
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
-1
View File
@@ -1 +0,0 @@
export { UsersPage } from "./UsersPage.impl";
File diff suppressed because it is too large Load Diff
@@ -1,125 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Actions } from "../Actions";
import type { SavedTask, ServiceInstance } from "../../types";
const saveTaskMutate = vi.fn().mockResolvedValue({
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "",
enabled: true,
default_service_id: "",
notes: "",
});
const deleteTaskMutate = vi.fn();
const runTaskMutate = vi.fn().mockResolvedValue({});
let sshServices: ServiceInstance[] = [];
let tasks: SavedTask[] = [];
vi.mock("../../hooks/useSettings", () => ({
useTasks: () => ({ data: tasks }),
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
useTaskRuns: () => ({ data: { items: [] } }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: sshServices }),
}));
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
return {
id: "s1",
service_type: "ssh_tasks",
name: "Box",
config: { host: "box", username: "u" },
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
...overrides,
} as ServiceInstance;
}
function task(overrides: Partial<SavedTask> = {}): SavedTask {
return {
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "systemctl restart foo",
enabled: true,
default_service_id: "",
notes: "",
created_at: 0,
updated_at: 0,
...overrides,
} as SavedTask;
}
beforeEach(() => {
saveTaskMutate.mockClear();
deleteTaskMutate.mockClear();
runTaskMutate.mockClear();
sshServices = [];
tasks = [];
});
describe("Actions", () => {
it("shows the empty state and creates a task via the editor dialog", async () => {
render(<Actions />);
expect(screen.getByText("No action selected")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add action" }),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Add action" }));
// Editor dialog opened (Name field is unique to the editor).
expect(screen.getByLabelText("Name")).toBeInTheDocument();
// Controlled input parity: name + default shell type flow through.
await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
await userEvent.click(screen.getByRole("button", { name: "Save action" }));
expect(saveTaskMutate).toHaveBeenCalledTimes(1);
const saved = saveTaskMutate.mock.calls[0][0];
expect(saved.name).toBe("Restart svc");
expect(saved.task_type).toBe("shell");
expect(saved.default_service_id).toBe("");
});
it("disables the Run button until a run service is selected", async () => {
sshServices = [sshService()];
tasks = [task()];
render(<Actions />);
// Selecting a saved task tab exposes the detail + Run control.
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
const runButton = screen.getByRole("button", { name: "Run action" });
expect(runButton).toBeDisabled();
});
it("runs a task on the selected SSH task service", async () => {
sshServices = [sshService()];
tasks = [task()];
render(<Actions />);
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
await userEvent.click(
screen.getByRole("combobox", { name: "Run on SSH task service" }),
);
await userEvent.click(screen.getByRole("option", { name: "Box" }));
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
expect(runTaskMutate).toHaveBeenCalledTimes(1);
expect(runTaskMutate).toHaveBeenCalledWith({
taskId: "t1",
serviceId: "s1",
});
});
});
@@ -1,75 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Applications } from "../Applications";
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
// so this slice-4 test stays focused on the migrated Applications shell and
// does not pull the still-MUI DataGrid into the jsdom render.
vi.mock("../Media", () => ({
Media: () => <div data-testid="media-child">Media</div>,
}));
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({
data: [
{
id: "m1",
name: "Main",
enabled: true,
services: ["jellyfin"],
},
],
}),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 100 },
}),
useLibraries: () => ({
data: [
{ library: "Movies", total: 10, movies: 10, series: 0 },
{ library: "Shows", total: 5, movies: 0, series: 5 },
],
}),
}));
describe("Applications", () => {
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
render(<Applications />);
// Library stats header.
expect(screen.getByText("Library stats")).toBeInTheDocument();
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
expect(screen.getByText("115")).toBeInTheDocument();
expect(screen.getByText("Episodes")).toBeInTheDocument();
// Library rows render their per-library totals (unique strings).
expect(
screen.getByText(/Total 10 · Movies 10 · Series 0/),
).toBeInTheDocument();
expect(
screen.getByText(/Total 5 · Movies 0 · Series 5/),
).toBeInTheDocument();
// Tabs present.
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
expect(screen.getByTestId("media-child")).toBeInTheDocument();
});
});
+4 -171
View File
@@ -2,18 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dashboard } from "../Dashboard";
import type {
DashboardShortcut,
ServiceInstance,
WidgetInstance,
} from "../../types";
import type { DashboardShortcut } from "../../types";
// Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering widgets or their data queries.
vi.mock("../../components/WidgetInstance", () => ({
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
<div data-testid="widget-stub">{widget.title}</div>
),
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
}));
vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
@@ -27,15 +21,11 @@ vi.mock("react-router-dom", () => ({
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }),
}));
// --- Dynamic mock state (reset in beforeEach) ---
let widgetInstances: WidgetInstance[] = [];
let serviceInstances: ServiceInstance[] = [];
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: widgetInstances }),
useWidgetInstances: () => ({ data: [] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: serviceInstances }),
useServiceInstances: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
@@ -75,26 +65,8 @@ beforeEach(() => {
saveShortcutMutate.mockClear();
deleteShortcutMutate.mockClear();
shortcuts = [];
widgetInstances = [];
serviceInstances = [];
setMatchMedia(false); // desktop by default
});
// --- matchMedia mock for useIsMobile (jsdom has no native matchMedia) ---
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query === "(max-width: 768px)" ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
describe("Dashboard", () => {
it("shows the empty-state alert when there are no shortcuts", () => {
render(<Dashboard />);
@@ -142,142 +114,3 @@ describe("Dashboard", () => {
expect(saved.shortcut_type).toBe("website");
});
});
// --- Mobile layout tests (spec R7.1, R7.2) ---
function makeWidget(overrides: Partial<WidgetInstance> = {}): WidgetInstance {
return {
id: "w1",
service_id: null,
widget_kind: "static",
title: "Widget 1",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
...overrides,
};
}
function makeService(
overrides: Partial<ServiceInstance> = {},
): ServiceInstance {
return {
id: "svc1",
service_type: "jellyfin",
name: "Jellyfin",
config: {},
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
...overrides,
};
}
describe("Dashboard mobile layout", () => {
it("renders widgets in a single column with an anchor bar below md", () => {
setMatchMedia(true); // mobile
serviceInstances = [
makeService({ id: "graf", service_type: "grafana" }),
makeService({ id: "jelly", service_type: "jellyfin" }),
];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
makeWidget({
id: "w-media",
service_id: "jelly",
widget_kind: "activity",
title: "Jellyfin Activity",
}),
makeWidget({
id: "w-backup",
service_id: null,
widget_kind: "backups",
title: "Backup Summary",
}),
];
render(<Dashboard />);
// Anchor bar pills are visible for populated sections (each label appears
// in both the pill and the section heading, so use getAllByText).
expect(screen.getAllByText("Observability").length).toBeGreaterThanOrEqual(
1,
);
expect(screen.getAllByText("Media").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Backups").length).toBeGreaterThanOrEqual(1);
// Sections with no widgets are NOT rendered.
expect(screen.queryByText("Custom")).not.toBeInTheDocument();
// Each widget renders.
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
expect(screen.getByText("Jellyfin Activity")).toBeInTheDocument();
expect(screen.getByText("Backup Summary")).toBeInTheDocument();
});
it("does NOT render the anchor bar at desktop width", () => {
setMatchMedia(false); // desktop
serviceInstances = [makeService({ id: "graf", service_type: "grafana" })];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
];
render(<Dashboard />);
// Widget renders (flat list, no section wrappers).
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
// No section headings or anchor pills on desktop.
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
expect(screen.queryByText("Media")).not.toBeInTheDocument();
});
it("anchor bar pills jump to their section via scrollIntoView", async () => {
setMatchMedia(true); // mobile
serviceInstances = [
makeService({ id: "graf", service_type: "grafana" }),
makeService({ id: "jelly", service_type: "jellyfin" }),
];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
makeWidget({
id: "w-media",
service_id: "jelly",
widget_kind: "activity",
title: "Jellyfin Activity",
}),
];
const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView");
render(<Dashboard />);
// The Media section element exists.
expect(document.getElementById("dashboard-section-media")).not.toBeNull();
// Click the "Media" anchor pill (button role disambiguates from heading).
const mediaPill = screen.getByRole("button", { name: "Media" });
await userEvent.click(mediaPill);
expect(scrollSpy).toHaveBeenCalled();
scrollSpy.mockRestore();
});
});
@@ -1,198 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileBrowser } from "../FileBrowser.impl";
import type { DirectoryListing, MonitoringMachine } from "../../types";
// usePersistentState (browserState) reads/writes localStorage; clear between tests
// so the selectedPath / currentDir state never leaks across cases.
beforeEach(() => {
window.localStorage.clear();
setMatchMedia(false);
});
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["files", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
notes: "",
...overrides,
};
}
function listingFixture(
entries: {
name: string;
type: string;
size: number;
mtime: number;
}[],
): DirectoryListing {
return { path: "/", entries, count: entries.length };
}
let listing: DirectoryListing;
let machines: MonitoringMachine[];
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
}));
vi.mock("../../hooks/useFiles", () => ({
useDirectoryListing: () => ({
data: listing,
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
useJobTemplates: () => ({ data: [] }),
useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
}));
beforeEach(() => {
machines = [machineFixture()];
listing = listingFixture([
{ name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
{ name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
{ name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
]);
});
/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
function setMatchMedia(matches: boolean) {
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => listeners.push(listener),
removeEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}));
}
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
render(<FileBrowser />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
// The leading selection column header is empty (checkbox); the 5 data
// columns are Type, Name, Ext, Size, Modified in that order.
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
expect(headers.filter((h) => h === "Type").length).toBe(1);
expect(headers.filter((h) => h === "Modified").length).toBe(1);
});
it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
render(<FileBrowser />);
// The selected-file path surfaces in the Browser status caption once chosen.
expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
await userEvent.click(screen.getByText("video.mkv"));
expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
// A recognized video file enters the ffprobe branch; with empty ffprobe
// data it shows the "No ffprobe data available." status (proving the
// selected file routed into the Media info preview flow).
expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
});
it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
render(<FileBrowser />);
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd and
// NO "Selected:" segment (directories are opened, not selected for preview).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
});
describe("FileBrowser (mobile card layout — slice 4)", () => {
it("renders cards with file/dir name as primary below md", () => {
setMatchMedia(true);
render(<FileBrowser />);
// Card titles (the 'name' field rendered as primary).
expect(screen.getByText("movies")).toBeInTheDocument();
expect(screen.getByText("video.mkv")).toBeInTheDocument();
expect(screen.getByText("notes.txt")).toBeInTheDocument();
// Desktop table column headers must NOT render.
const headers = screen.queryAllByRole("columnheader");
expect(headers).toHaveLength(0);
});
it("tapping a directory card navigates into it", async () => {
setMatchMedia(true);
render(<FileBrowser />);
// Directory card is a button wrapping the 'movies' text.
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd
// and NO 'Selected:' segment (directories are opened, not selected).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
it("renders the path/breadcrumb controls on mobile", () => {
setMatchMedia(true);
render(<FileBrowser />);
// The 'Remote path' label and its input are part of the Browser section
// card (outside the table), so they render on both breakpoints.
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
});
it("renders the DataTable at desktop width (1280px)", () => {
setMatchMedia(false);
render(<FileBrowser />);
// Desktop path: table column headers are present.
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
});
});
-356
View File
@@ -1,356 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Media } from "../Media";
import type {
MediaIndexStatus,
MediaItem,
MediaQueryResponse,
MonitoringMachine,
} from "../../types";
// Shared navigate mock so the row-click test can assert the call. The vi.mock
// factory is hoisted above this const, but it only closes over `navigate`
// lazily (the arrow runs at render time, well after init) — no TDZ access.
const navigate = vi.fn();
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["jellyfin", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
notes: "",
...overrides,
};
}
function statusFixture(
overrides: Partial<MediaIndexStatus> = {},
): MediaIndexStatus {
return {
exists: true,
item_count: 2,
updated_at: 1,
updated_at_label: "now",
build_duration_seconds: null,
build_running: false,
build_stage: "",
build_message: "",
build_progress: null,
build_items_processed: 0,
build_items_total: 0,
build_current_library: "",
build_library_index: 0,
build_libraries_total: 0,
build_library_progress: null,
build_library_items_processed: 0,
build_library_items_total: 0,
build_elapsed_seconds: null,
build_eta_seconds: null,
build_library_elapsed_seconds: null,
build_library_eta_seconds: null,
build_cancel_requested: false,
build_pid: null,
build_error: "",
...overrides,
};
}
function mediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "1",
title: "Inception",
series: "",
season: "",
episode: null,
type: "Movie",
year: 2010,
runtime_min: 148,
size: "12.4 GB",
bitrate: "35.0 Mbps",
hdr: "HDR10",
video: "HEVC",
resolution: "4K",
date_added: "2024-01-01",
library: "Movies",
path: "/media/movies/Inception.mkv",
...overrides,
};
}
let status: MediaIndexStatus;
let queryResult: MediaQueryResponse;
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
useSearchParams: () => [
new URLSearchParams("jellyfin_service_id=jfs1"),
vi.fn(),
],
}));
vi.mock("../../hooks/useMedia", () => ({
useMediaStatus: () => ({ data: status }),
useMediaQuery: () => ({ data: queryResult, isLoading: false }),
useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [machineFixture()] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({ data: undefined }),
useLibraries: () => ({ data: undefined }),
}));
// usePersistentState reads/writes localStorage; clear between tests so the
// offset/pageSize/columnVisibility state never leaks across cases.
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
// so the DataTable path renders by default; mobile tests override.
beforeEach(() => {
setMatchMedia(false);
window.localStorage.clear();
navigate.mockClear();
status = statusFixture();
queryResult = {
items: [
mediaItem({
id: "1",
title: "Inception",
path: "/media/movies/Inception.mkv",
}),
mediaItem({
id: "2",
title: "Matrix",
path: "/media/movies/Matrix.mkv",
}),
],
total: 2,
limit: 100,
offset: 0,
};
});
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
function setMatchMedia(matches: boolean) {
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => listeners.push(listener),
removeEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}));
}
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
it("exposes exactly the 15 locked toggleable columns", async () => {
render(<Media />);
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
const toggleable = screen
.getAllByRole("menuitemcheckbox")
.map((item) => (item.textContent ?? "").trim());
expect([...toggleable].sort()).toEqual(
[
"title",
"series",
"season",
"episode",
"type",
"year",
"runtime_min",
"size",
"bitrate",
"hdr",
"video",
"resolution",
"date_added",
"library",
"path",
].sort(),
);
// The leading selection column is never toggleable (enableHiding=false).
expect(toggleable).toHaveLength(15);
expect(toggleable).not.toContain("__select__");
});
it("renders the 15 data column headers", () => {
render(<Media />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => (h.textContent ?? "").trim());
for (const expected of [
"Title",
"Series",
"Season",
"Episode",
"Type",
"Year",
"Runtime",
"Size",
"Bitrate",
"HDR",
"Video codec",
"Resolution",
"Date added",
"Library",
"Path",
]) {
expect(headers).toContain(expected);
}
});
it("navigates to the file browser at the item path on row click", async () => {
render(<Media />);
await userEvent.click(screen.getByText("Inception"));
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
);
});
it("does NOT navigate when toggling a row selection checkbox", async () => {
render(<Media />);
const firstCheckbox = screen.getAllByRole("checkbox", {
name: "Select row",
})[0];
await userEvent.click(firstCheckbox);
expect(firstCheckbox).toBeChecked();
expect(navigate).not.toHaveBeenCalled();
});
it("renders the server-driven pagination total + page controls", () => {
render(<Media />);
// DataTable manual-pagination footer surfaces the server total + pager.
// ("Page 1 of 1" also appears in the page caption, so match all and assert
// the pager footer text is present alongside the unique total.)
expect(screen.getByText("2 rows")).toBeInTheDocument();
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
});
it("disables Build index while a build is running", () => {
status = statusFixture({ build_running: true });
render(<Media />);
expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
// Stop + Force stop surface only while running.
expect(
screen.getByRole("button", { name: "Stop build" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Force stop" }),
).toBeInTheDocument();
});
});
describe("Media (mobile card layout — slice 3)", () => {
it("renders cards with the title as primary below md", () => {
setMatchMedia(true);
render(<Media />);
// Card titles render (primary field).
expect(screen.getByText("Inception")).toBeInTheDocument();
expect(screen.getByText("Matrix")).toBeInTheDocument();
// Card field labels render (at least once per row).
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
// Desktop table headers do NOT render on mobile.
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
});
it("hides the column-visibility toggle below md", () => {
setMatchMedia(true);
render(<Media />);
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
});
it("renders pagination controls below the cards on mobile", () => {
setMatchMedia(true);
render(<Media />);
expect(screen.getByText("2 rows")).toBeInTheDocument();
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Next page" }),
).toBeInTheDocument();
});
it("navigates to the file browser when a card is tapped on mobile", async () => {
setMatchMedia(true);
render(<Media />);
await userEvent.click(screen.getByText("Inception"));
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
);
});
it("renders the DataTable (not cards) at desktop width", () => {
render(<Media />);
// Desktop column headers render.
expect(
screen.getByRole("columnheader", { name: "Title" }),
).toBeInTheDocument();
// Column-visibility toggle is present.
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { NamedDashboardPage } from "../NamedDashboardPage";
vi.mock("../../hooks/useDashboards", () => ({
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
}));
import { useDashboardBySlug } from "../../hooks/useDashboards";
function renderPage(slug: string) {
return render(
<MemoryRouter initialEntries={[`/d/${slug}`]}>
<Routes>
<Route path="/d/:slug" element={<NamedDashboardPage />} />
</Routes>
</MemoryRouter>,
);
}
describe("NamedDashboardPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders loading state", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as never);
renderPage("storage");
// Skeleton renders during load.
expect(document.querySelector(".h-32")).toBeInTheDocument();
});
it("renders 404 when dashboard not found", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
} as never);
renderPage("nonexistent");
expect(screen.getByText(/Dashboard not found/i)).toBeInTheDocument();
});
it("renders pinned links for a known dashboard", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: {
id: "d1",
label: "Storage",
slug: "storage",
sort_order: 0,
payload: {
items: [
{
type: "link",
label: "My Jellyfin",
target: "/services/jellyfin/svc-1",
},
],
},
created_at: 1,
updated_at: 1,
},
isLoading: false,
isError: false,
} as never);
renderPage("storage");
expect(screen.getByText("Storage")).toBeInTheDocument();
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
});
it("renders empty state when dashboard has no items", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: {
id: "d2",
label: "Empty",
slug: "empty",
sort_order: 0,
payload: {},
created_at: 1,
updated_at: 1,
},
isLoading: false,
isError: false,
} as never);
renderPage("empty");
expect(screen.getByText("Empty")).toBeInTheDocument();
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
});
});
+92 -106
View File
@@ -1,20 +1,14 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServicePage } from "../ServicePage";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../../types";
// --- fixtures ---
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
const instance: ServiceInstance = {
id: "svc-1",
service_type: "grafana",
name: "Production Grafana",
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
service_type: "jellyfin",
name: "Main Jellyfin",
config: { base_url: "https://jf.example.com", user_id: "u1" },
secrets_set: { api_key: true },
enabled: true,
created_at: 1_700_000_000,
@@ -22,128 +16,120 @@ const instance: ServiceInstance = {
};
const typeInfo: ServiceTypeInfo = {
service_type: "grafana",
name: "Grafana",
description: "Dashboards, metrics, and logs.",
service_type: "jellyfin",
name: "Jellyfin",
description: "Media server",
config_schema: {
type: "object",
properties: {
base_url: { type: "string", description: "Absolute URL." },
timeout_seconds: { type: "integer" },
},
properties: { base_url: { type: "string" } },
},
secret_fields: [{ key: "api_key", label: "API key", required: false }],
widget_kinds: [],
};
// --- mocks ---
const secondInstance: ServiceInstance = {
...instance,
id: "svc-2",
name: "Backup Jellyfin",
};
const mutateAsync = vi.fn();
const mutate = vi.fn();
const deleteMutate = vi.fn();
const saveMutateAsync = vi.fn();
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [instance] }),
useServiceInstances: () => ({
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
?.__svcInstances ?? [instance],
}),
useServiceTypes: () => ({ data: [typeInfo] }),
useSaveServiceInstance: () => ({
mutateAsync,
mutate,
mutateAsync: saveMutateAsync,
mutate: vi.fn(),
isPending: false,
}),
useDeleteServiceInstance: () => ({ mutate: deleteMutate, isPending: false }),
useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("react-router-dom", () => ({
useParams: () => ({
serviceType: "grafana",
serviceId: "svc-1",
vi.mock("../../integrations/registry", () => ({
getServiceBinding: () => ({
name: "Jellyfin",
description: "Media server",
widgets: [],
}),
useNavigate: () => vi.fn(),
}));
// jsdom has no window.matchMedia; stub it. Default to desktop (matches: false).
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
function renderServicePage(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
setMatchMedia(false);
mutateAsync.mockReset();
mutate.mockReset();
deleteMutate.mockReset();
});
describe("ServicePage (desktop)", () => {
it("renders the full-page layout with the service name and connection card", () => {
render(<ServicePage />);
// Page heading (desktop only — mobile uses SheetForm title)
expect(
screen.getByRole("heading", { name: "Production Grafana" }),
).toBeInTheDocument();
// Connection section card title
expect(screen.getByText("Connection")).toBeInTheDocument();
// General Save button
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
describe("ServicePage tab skeleton", () => {
it("renders Overview + Media + Requests + Widgets + Config for jellyfin", () => {
renderServicePage("/services/jellyfin/svc-1");
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Config" })).toBeInTheDocument();
});
it("does not render the SheetForm at desktop width", () => {
render(<ServicePage />);
// SheetForm renders a dialog with role="dialog" only when open; on
// desktop the page layout is used, so no dialog should be present.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("ServicePage (mobile SheetForm — slice 6)", () => {
beforeEach(() => setMatchMedia(true));
it("renders the SheetForm with the service name as title below md", () => {
render(<ServicePage />);
// SheetForm title is rendered inside a SheetTitle (role="heading").
it("does NOT render Media/Requests for non-jellyfin types", () => {
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [sshInstance];
renderServicePage("/services/ssh_tasks/ssh-1");
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Production Grafana" }),
).toBeInTheDocument();
// The dialog (Sheet content) should be present on mobile.
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Desktop page header description is NOT rendered inside the SheetForm.
expect(
screen.queryByText("Dashboards, metrics, and logs."),
screen.queryByRole("tab", { name: "Media" }),
).not.toBeInTheDocument();
});
it("edits the name field and Save calls the save mutation", async () => {
render(<ServicePage />);
const nameInput = screen.getByLabelText("Name");
expect(nameInput).toHaveValue("Production Grafana");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Renamed Grafana");
const saveButton = screen.getByRole("button", { name: "Save" });
await userEvent.click(saveButton);
expect(mutateAsync).toHaveBeenCalledTimes(1);
const input = mutateAsync.mock.calls[0][0] as ServiceInstanceInput;
expect(input.name).toBe("Renamed Grafana");
expect(input.id).toBe("svc-1");
// Lock the full save payload (config draft, enabled, secrets sentinel).
expect(input.enabled).toBe(true);
expect(input.secrets).toEqual({});
expect(input.config).toMatchObject({ base_url: "https://grafana.example.com" });
it("shows instance switcher when >1 sibling of same type", () => {
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance, secondInstance];
const { container } = renderServicePage("/services/jellyfin/svc-1");
// The switcher renders as a Select trigger (combobox).
expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
});
it("renders the connection config fields as editable inside the SheetForm", () => {
render(<ServicePage />);
const urlInput = screen.getByLabelText("base_url");
expect(urlInput).toHaveValue("https://grafana.example.com");
it("hides instance switcher when only one instance", () => {
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance];
const { container } = renderServicePage("/services/jellyfin/svc-1");
// No select trigger rendered (only one instance).
expect(
container.querySelector("[role='combobox']"),
).not.toBeInTheDocument();
});
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
const { userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
saveMutateAsync.mockReset();
renderServicePage("/services/jellyfin/svc-1");
// Open the Config tab and type a new api_key.
await user.click(screen.getByRole("tab", { name: "Config" }));
const secretInput = screen.getByLabelText("api_key");
await user.type(secretInput, "new-secret-value");
// Save and assert the typed secret is in the payload (not secrets: {}).
await user.click(screen.getByRole("button", { name: "Save" }));
expect(saveMutateAsync).toHaveBeenCalledTimes(1);
const input = saveMutateAsync.mock.calls[0][0] as {
secrets: Record<string, string>;
};
expect(input.secrets).toEqual({ api_key: "new-secret-value" });
});
});
+6 -104
View File
@@ -6,10 +6,12 @@ import type { MonitoringMachine } from "../../types";
const saveMachineMutate = vi.fn().mockResolvedValue({});
const deleteMachineMutate = vi.fn();
const testSSHMutate = vi.fn().mockResolvedValue({
message: "SSH auth succeeded",
known_hosts_updated: true,
});
const testSSHMutate = vi
.fn()
.mockResolvedValue({
message: "SSH auth succeeded",
known_hosts_updated: true,
});
let machines: MonitoringMachine[] = [];
@@ -111,103 +113,3 @@ describe("Settings", () => {
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
});
});
// jsdom has no window.matchMedia; default to desktop so existing tests are
// unaffected.
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
describe("Settings (mobile SheetForm — slice 7)", () => {
beforeEach(() => setMatchMedia(true));
it("opens the machine editor in a SheetForm below md", async () => {
machines = [localMachine()];
render(<Settings />);
// Open the editor via the detail-pane Edit button (visible text).
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
// SheetForm renders a dialog; the DialogTitle shows the editor title.
expect(screen.getByText("Edit machine")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Desktop DialogDescription text is not rendered as a dialog description
// on mobile (the MachineEditor has its own hint labels, which is fine).
expect(
screen.queryByRole("heading", { name: "Create machine" }),
).not.toBeInTheDocument();
});
it("saves a machine via the SheetForm on mobile", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Renamed node");
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
const saved = saveMachineMutate.mock.calls[0][0];
expect(saved.name).toBe("Renamed node");
expect(saved.mode).toBe("local");
});
it("cancel closes the SheetForm on mobile", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
expect(screen.getByRole("dialog")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
// The sheet is now closed — the dialog role should no longer be present.
// (The page content itself is still rendered; only the sheet unmounts.)
expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
});
it("prompts before discarding unsaved machine edits (R4.5)", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
// Edit the name to make the form dirty.
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Dirty name");
// Cancel should NOT immediately close — the discard confirm appears.
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("heading", { name: "Discard changes?" }),
).toBeInTheDocument();
// The editor is still open.
expect(screen.getByText("Edit machine")).toBeInTheDocument();
});
});
@@ -1,407 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UsersPage } from "../UsersPage.impl";
import { TooltipProvider } from "../../components/ui/tooltip";
import type {
NowPlayingSession,
UserDirectoryItem,
UserDirectoryResponse,
} from "../../types";
// jsdom has no window.matchMedia; the shared `useIsMobile` hook and the
// compose dialog viewport hook must not blow up during render. Stub to
// "desktop" (matches: false) by default; the slice-5 describe block flips it
// to mobile for card-layout assertions.
beforeEach(() => {
if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
// The compose formatting actions defer a focus/selection restore via
// requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
// synchronously, so make it synchronous so the slice-6b compose test can
// observe the html-body value update.
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof window.requestAnimationFrame;
});
// Keep the drawer's nested session panel out of the DOM under test.
vi.mock("../../components/SessionActivityPanel", () => ({
SessionActivityPanel: ({
selectedUserLabel,
}: {
selectedUserLabel: string;
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
}));
let users: UserDirectoryItem[] = [];
let activity: NowPlayingSession[] = [];
function directoryResponse(): UserDirectoryResponse {
return {
items: users,
total: users.length,
jellyseerr_configured: true,
jellyseerr_available: true,
jellyseerr_error: "",
jellyseerr_jellyfin_user_count: 0,
jellyseerr_user_count: 0,
enriched_count: 0,
};
}
vi.mock("../../hooks/useUsers", () => ({
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
}));
vi.mock("../../hooks/useDashboard", () => ({
useActivity: () => ({ data: activity }),
}));
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
}));
vi.mock("../../hooks/useSendUserMessage", () => ({
useSendUserMessage: () => ({
isPending: false,
isError: false,
isSuccess: false,
reset: vi.fn(),
mutateAsync: vi.fn(),
}),
}));
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
let currentParams: Record<string, string> = {};
const setSearchParams = vi.fn((next: Record<string, string>) => {
currentParams = { ...next };
});
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
}));
function userFixture(
overrides: Partial<UserDirectoryItem> = {},
): UserDirectoryItem {
return {
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
email: "alice@example.com",
email_source: "jellyfin",
avatar: "",
avatar_source: "",
contactable: true,
source: "jellyfin",
source_summary: "",
name_source: "jellyfin",
access_source: "jellyfin",
jellyseerr_user_id: null,
jellyseerr_username: "",
user_type: 1,
user_type_label: "User",
role: "admin",
permissions: 1,
permissions_label: "Administrator",
request_count: 0,
...overrides,
};
}
beforeEach(() => {
users = [];
activity = [];
currentParams = {};
setSearchParams.mockClear();
});
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
it("renders the directory table and metric counts", () => {
users = [userFixture()];
render(<UsersPage />);
expect(screen.getByText("Total users")).toBeInTheDocument();
expect(screen.getByText("User list")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("toggles row selection and reflects the selected-count badge", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
expect(screen.getByText("0 selected")).toBeInTheDocument();
// Selection-across-pagination: toggling a row updates the selected-id set.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("1 selected")).toBeInTheDocument();
// Toggling again removes it (the set survives, membership flips).
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("selects all visible rows via the header select-all checkbox", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
await userEvent.click(
screen.getByRole("checkbox", { name: "Select all visible users" }),
);
expect(screen.getByText("2 selected")).toBeInTheDocument();
});
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
users = [userFixture({ jellyfin_id: "u1" })];
render(<UsersPage />);
// Clicking the row body (not the checkbox) opens the detail drawer.
await userEvent.click(screen.getByText("Alice"));
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
});
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
users = [
userFixture({
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
}),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
activity = [
{
user: "alice",
title: "Movie",
type: "Movie",
state: "playing",
transcoding: "no",
transcoding_type: "",
device: "Web",
session_id: "s1",
},
{
user: "bob",
title: "Show",
type: "Episode",
state: "paused",
transcoding: "no",
transcoding_type: "",
device: "TV",
session_id: "s2",
},
];
render(<UsersPage />);
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
"success",
);
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
"warning",
);
});
it("renders the user detail drawer (Sheet) when a user is selected", () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
currentParams = { user: "u1" };
render(<UsersPage />);
// buildUserDrawerModel title = display name; rendered as the drawer heading.
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
// Drawer sections (identity / contact actions) + the activity panel render.
expect(screen.getByText("Identity")).toBeInTheDocument();
expect(screen.getByText("Contact actions")).toBeInTheDocument();
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
});
});
describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
it("opens compose and inserts bold markup into the html body", async () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
// The formatting toolbar renders <Tooltip> (shadcn), which in the app is
// wrapped by a global <TooltipProvider> in App.tsx; supply it here.
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
// Select a deliverable user so the "Message selected" button enables.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// Compose dialog opens (shadcn Dialog family).
expect(
screen.getByRole("heading", { name: "Message selected users" }),
).toBeInTheDocument();
// Bold action wraps the cursor selection in <strong></strong> via the
// preserved insertMarkup helper (markup insertion actions parity).
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
const body = screen.getByRole("textbox", {
name: "HTML message body",
}) as HTMLTextAreaElement;
expect(body.value).toContain("<strong>");
});
});
describe("UsersPage (mobile card layout — slice 5)", () => {
beforeEach(() => {
window.matchMedia = ((query: string) => ({
matches: query.includes("768"),
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
});
it("renders user cards with display name as primary below md", () => {
users = [
userFixture({ jellyfin_id: "u1", display_name: "Alice" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
// Activity field label should appear per card.
expect(screen.getAllByText("Activity")).toHaveLength(2);
});
it("toggles selection from the card checkbox without opening the drawer", async () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
const checkbox = screen.getByRole("checkbox", {
name: /Select Alice/i,
});
expect(checkbox).toHaveAttribute("data-state", "unchecked");
await userEvent.click(checkbox);
expect(checkbox).toHaveAttribute("data-state", "checked");
// Drawer stays closed: the session-panel stub only renders when the
// drawer opens via a card-body tap, not via the checkbox.
expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
});
it("renders compose in a SheetForm below md with send button", async () => {
users = [
userFixture({
jellyfin_id: "u1",
display_name: "Alice",
email: "alice@example.com",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
// Select the deliverable user via the mobile card checkbox.
await userEvent.click(
screen.getByRole("checkbox", { name: /Select Alice/i }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// On mobile, compose opens in a SheetForm (not a Dialog). The SheetForm
// header carries the title and the footer carries the Send button.
expect(screen.getByText("Message selected users")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Send message" }),
).toBeInTheDocument();
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
});
it("prompts before discarding unsaved compose edits (R4.5)", async () => {
users = [
userFixture({
jellyfin_id: "u1",
display_name: "Alice",
email: "alice@example.com",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
await userEvent.click(
screen.getByRole("checkbox", { name: /Select Alice/i }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// Type a subject to make the compose form dirty.
await userEvent.type(screen.getByLabelText("Subject"), "Urgent update");
// Cancel should NOT immediately close — the discard confirm appears.
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("heading", { name: "Discard changes?" }),
).toBeInTheDocument();
});
});
@@ -1,18 +1,25 @@
/**
* ActionsTab operational content for the ssh_tasks service page.
*
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
* provides the active ssh_tasks service id, which is used as the default run
* service. The page-level header is removed (the service page provides it).
* The task editor dialog, saved-task rail, and run history are preserved.
*/
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
import {
useDeleteTask,
useRunTask,
useSaveTask,
useTaskRuns,
useTasks,
} from "../hooks/useSettings";
import { useServiceInstances } from "../hooks/useServices";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
import { SelectionRailCard } from "../components/SelectionRailCard";
} from "../../hooks/useSettings";
import { DialogFooter } from "../../components/DialogFooter";
import { HoverEditButton } from "../../components/HoverEditButton";
import { SectionCard } from "../../components/SectionCard";
import { SelectionRailCard } from "../../components/SelectionRailCard";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,13 +44,8 @@ import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
// Radix Select disallows empty-string item values; the "None" option maps to
// this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__";
type ActionTab = "new" | string;
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
function FormField({
label,
htmlFor,
@@ -106,16 +108,11 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
function TaskEditor({
task,
services,
onChange,
}: {
task: SavedTaskInput;
services: ServiceInstance[];
onChange: (task: SavedTaskInput) => void;
}) {
const selectedService = services.find(
(service) => service.id === task.default_service_id,
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-2">
@@ -124,11 +121,7 @@ function TaskEditor({
</p>
<Badge variant="outline">{task.task_type}</Badge>
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
{selectedService && (
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
)}
</div>
<div className="flex flex-col gap-2">
<FormField label="Name" htmlFor="task-name">
<Input
@@ -159,31 +152,6 @@ function TaskEditor({
</Select>
</FormField>
</div>
<div className="min-w-[220px] flex-1">
<FormField label="Default SSH task service">
<Select
value={task.default_service_id || NONE}
onValueChange={(value) =>
onChange({
...task,
default_service_id: value === NONE ? "" : value,
})
}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{services.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
</div>
<FormField label="Notes">
<Input
@@ -216,7 +184,6 @@ function TaskDialog({
open,
task,
baseline,
services,
onClose,
onChange,
onSave,
@@ -225,7 +192,6 @@ function TaskDialog({
open: boolean;
task: SavedTaskInput;
baseline: SavedTaskInput;
services: ServiceInstance[];
onClose: () => void;
onChange: (task: SavedTaskInput) => void;
onSave: () => void;
@@ -235,12 +201,10 @@ function TaskDialog({
if (
!sameTask(task, baseline) &&
!window.confirm("Discard unsaved changes?")
) {
)
return;
}
onClose();
};
return (
<Dialog
open={open}
@@ -254,10 +218,10 @@ function TaskDialog({
<DialogDescription>
Save a reusable server task. Shell commands run via{" "}
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
Runs execute on the selected SSH task service instance.
Runs execute on this SSH task service instance.
</DialogDescription>
</DialogHeader>
<TaskEditor task={task} services={services} onChange={onChange} />
<TaskEditor task={task} onChange={onChange} />
<DialogFooter
onCancel={requestClose}
cancelLabel="Cancel"
@@ -266,7 +230,7 @@ function TaskDialog({
confirmBusyLabel="Save action"
secondaryAction={
onDelete ? (
<Button variant="destructive" onClick={onDelete} className="mobile-touch-target">
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
) : undefined
@@ -277,8 +241,7 @@ function TaskDialog({
);
}
export function Actions() {
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
const { data: tasks = [] } = useTasks();
const saveTask = useSaveTask();
const deleteTask = useDeleteTask();
@@ -288,9 +251,11 @@ export function Actions() {
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
emptyTask(),
);
const [runServiceId, setRunServiceId] = useState("");
const [editOpen, setEditOpen] = useState(false);
// Default to this instance's service id for task runs.
const runServiceId = instance.id;
const selectedTask = useMemo(
() => tasks.find((task) => task.id === tab) ?? null,
[tasks, tab],
@@ -303,14 +268,6 @@ export function Actions() {
setEditOpen(true);
};
const createNew = () => {
const initial = emptyTask();
setDraft(initial);
setDraftBaseline(initial);
setRunServiceId(sshServices[0]?.id || "");
setEditOpen(true);
};
const saveDraft = async () => {
const saved = await saveTask.mutateAsync(draft);
setTab(saved.id);
@@ -328,20 +285,8 @@ export function Actions() {
setDraftBaseline(nextDraft);
};
const editingTask = selectedTask;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">Actions</h1>
<p className="text-xs text-muted-foreground">
Save reusable server tasks and switch between them with tabs.
</p>
</div>
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
</div>
<div className="flex flex-col gap-4">
{saveTask.error && (
<Alert variant="destructive">
<AlertDescription>{String(saveTask.error)}</AlertDescription>
@@ -367,8 +312,8 @@ export function Actions() {
<Button
variant="outline"
size="sm"
className="mobile-touch-target w-full"
onClick={createNew}
className="w-full"
onClick={() => openEdit(emptyTask())}
>
Add action
</Button>
@@ -406,23 +351,23 @@ export function Actions() {
</SelectionRailCard>
<div className="flex flex-col gap-4">
{editingTask ? (
{selectedTask ? (
<SectionCard
title={editingTask.name}
title={selectedTask.name}
description="Open the editor popup to modify this action."
action={
<div className="flex flex-row flex-wrap items-center gap-2">
<Button className="mobile-touch-target"
<Button
variant="outline"
onClick={() => openEdit(initialFromTask(editingTask))}
onClick={() => openEdit(initialFromTask(selectedTask))}
>
Edit
</Button>
<Button className="mobile-touch-target"
disabled={runTask.isPending || !runServiceId}
<Button
disabled={runTask.isPending}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
taskId: selectedTask.id,
serviceId: runServiceId,
});
}}
@@ -432,35 +377,7 @@ export function Actions() {
</div>
}
>
<div className="flex flex-wrap items-center gap-2">
<FormField
label="Run on SSH task service"
htmlFor="run-service-id"
>
<Select
value={runServiceId}
onValueChange={(value) => setRunServiceId(value)}
>
<SelectTrigger
id="run-service-id"
className="min-w-[240px]"
size="sm"
>
<SelectValue placeholder="Select service" />
</SelectTrigger>
<SelectContent>
{sshServices.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<Separator />
<p className="text-sm font-semibold">Recent runs</p>
{selectedRuns.data?.items?.length ? (
<div className="flex flex-col gap-2">
@@ -509,25 +426,16 @@ export function Actions() {
)}
</SectionCard>
) : (
<div className="flex flex-col gap-4">
<SectionCard
title="No action selected"
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
>
{tasks[0] && (
<Button variant="outline" onClick={() => setTab(tasks[0].id)} className="mobile-touch-target">
Select first action
</Button>
)}
</SectionCard>
<SectionCard title="What this panel shows">
<p className="text-xs text-muted-foreground">
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</p>
</SectionCard>
</div>
<SectionCard
title="No action selected"
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
>
{tasks[0] && (
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
Select first action
</Button>
)}
</SectionCard>
)}
</div>
</div>
@@ -536,7 +444,6 @@ export function Actions() {
open={editOpen}
task={draft}
baseline={draftBaseline}
services={sshServices}
onClose={() => setEditOpen(false)}
onChange={setDraft}
onSave={saveDraft}
@@ -0,0 +1,188 @@
/**
* Alertmanager Alerts tab (spec R2.4, R8.2).
*
* Lifts the Alertmanager alerts content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Renders the active-alert
* summary (total + by severity) and the expandable alert list.
*
* The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global /
* first-configured for now — they don't accept a service_id yet. Wiring
* `instance.id` into them is a documented follow-up once the hooks gain the
* parameter. The `instance` prop is accepted for future scoping.
*/
import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
import {
useAlertmanagerAlerts,
useAlertmanagerStatus,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { AlertmanagerAlert, ServiceInstance } from "../../types";
function severityVariant(
severity: string,
): "default" | "secondary" | "destructive" | "outline" {
switch (severity.toLowerCase()) {
case "critical":
return "destructive";
case "warning":
return "default";
case "info":
return "secondary";
default:
return "outline";
}
}
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-start justify-between gap-2">
<div className="font-medium text-sm">{alert.name}</div>
<div className="flex items-center gap-1">
<Badge variant={severityVariant(alert.severity)}>
{alert.severity}
</Badge>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{alert.summary || alert.description}
</div>
{alert.active_since && (
<div className="mt-1 text-[10px] text-muted-foreground">
Since {new Date(alert.active_since).toLocaleString()}
</div>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
{alert.description && (
<div>
<span className="font-medium">Description:</span>{" "}
{alert.description}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
{alert.job_name && (
<div>
<span className="font-medium">Job:</span> {alert.job_name}
</div>
)}
{alert.category && (
<div>
<span className="font-medium">Category:</span> {alert.category}
</div>
)}
<div>
<span className="font-medium">State:</span> {alert.state}
</div>
<div>
<span className="font-medium">Since:</span>{" "}
{alert.active_since
? new Date(alert.active_since).toLocaleString()
: "unknown"}
</div>
</div>
{alert.labels && Object.keys(alert.labels).length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{Object.entries(alert.labels).map(([key, value]) => (
<Badge key={key} variant="secondary" className="text-[10px]">
{key}={value}
</Badge>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
// Global / first-configured hooks for now; instance.id scoping is a
// follow-up (see file docstring).
void instance;
const {
data: alertsSummary,
isLoading: alertsLoading,
error: alertsError,
} = useAlertmanagerAlerts();
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus();
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: statusLoading
? "checking…"
: "unreachable";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Bell className="h-4 w-4" />
Alertmanager {statusDetail}
</div>
{alertsError && (
<Alert variant="destructive">
<AlertTitle>Failed to load alerts</AlertTitle>
<AlertDescription>{alertsError.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Active Alerts ({alertsSummary?.total ?? 0})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{alertsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !alertsSummary || alertsSummary.total === 0 ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Inbox className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No active alerts</div>
<div className="max-w-md text-sm text-muted-foreground">
Everything looks quiet. Firing alerts will appear here.
</div>
</div>
) : (
<>
{alertsSummary.alerts.map((alert, idx) => (
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
))}
{alertsSummary.total > alertsSummary.alerts.length && (
<div className="text-center text-xs text-muted-foreground">
{alertsSummary.total - alertsSummary.alerts.length} more alert
{alertsSummary.total - alertsSummary.alerts.length === 1
? ""
: "s"}{" "}
in Alertmanager
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,5 +1,15 @@
import { useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
/**
* FilesTab operational content for the ssh_tasks service page.
*
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
* instance id (from the `instance` prop) replaces the machine_id. The initial
* path is read from `?path=` search param for deep-link support (resolves the
* MediaTab row-click navigation from slice 5). Everything else directory
* listing, path bar, ffprobe preview, job execution is preserved.
*/
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
@@ -7,7 +17,7 @@ import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -20,18 +30,27 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TabsTrigger } from "@/components/ui/tabs";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../hooks/useFiles";
import { usePersistentState } from "../hooks/usePersistentState";
import { useIsMobile } from "../hooks/useIsMobile";
import { useMonitoringSettings } from "../hooks/useSettings";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
} from "../../hooks/useFiles";
import { usePersistentState } from "../../hooks/usePersistentState";
import { SectionCard } from "../../components/SectionCard";
import type { ServiceInstance } from "../../types";
import { useIsMobile } from "../../hooks/useIsMobile";
// Mobile card fields (mobile-parity pattern).
const fileCardFields: MobileCardField<DisplayRow>[] = [
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
{ key: "type", label: "Type", render: (r) => r.type },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
];
// --- Types (lifted verbatim) ---
interface DisplayRow {
id: string;
@@ -83,6 +102,8 @@ interface FfprobeData {
streams?: FfprobeStream[];
}
// --- Helpers (lifted verbatim) ---
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -154,9 +175,8 @@ function isVideoFile(name: string): boolean {
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
// Design §3.2: referentially-stable column defs (a new array each render would
// destabilize the TanStack table instance and drop controlled selection).
// Visibility-only: no sorting, no sizing/resizing (design §3.3).
// --- Column defs (lifted verbatim) ---
const fileColumns: ColumnDef<DisplayRow>[] = [
{
accessorKey: "type",
@@ -187,19 +207,9 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
},
];
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
// Name is the primary identifier; type distinguishes dir/file/up at a glance;
// size and modified give the at-a-glance info a user browsing files on a phone
// needs. Ext is redundant with the name on mobile (the extension is visible in
// the filename itself). See OpenSpec change `mobile-responsive-parity`.
const fileCardFields: MobileCardField<DisplayRow>[] = [
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
{ key: "type", label: "Type", render: (r) => r.type },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
];
// --- State + helpers (lifted) ---
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
const FILE_TAB_STATE_KEY = "manage.files.tabState";
type FileBrowserState = {
currentDir: string;
@@ -217,6 +227,8 @@ function defaultFileBrowserState(): FileBrowserState {
};
}
// --- Ffprobe rendering (lifted verbatim) ---
function FfprobeChip({
children,
variant = "outline",
@@ -234,15 +246,9 @@ function StreamBlock({ children }: { children: React.ReactNode }) {
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
const format = data.format ?? {};
const streams = data.streams ?? [];
const videoStreams = streams.filter(
(stream) => stream.codec_type === "video",
);
const audioStreams = streams.filter(
(stream) => stream.codec_type === "audio",
);
const subtitleStreams = streams.filter(
(stream) => stream.codec_type === "subtitle",
);
const videoStreams = streams.filter((s) => s.codec_type === "video");
const audioStreams = streams.filter((s) => s.codec_type === "audio");
const subtitleStreams = streams.filter((s) => s.codec_type === "subtitle");
return (
<div className="flex flex-col gap-4">
@@ -250,7 +256,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
<div className="text-base font-semibold">ffprobe details</div>
<div className="text-xs text-muted-foreground">{path}</div>
</div>
<Card>
<CardContent className="flex flex-col gap-3">
<div className="text-sm font-semibold">Container / format</div>
@@ -286,11 +291,9 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-3">
<div className="text-sm font-semibold">Streams</div>
{videoStreams.length > 0 && (
<div>
<div className="text-xs text-muted-foreground">Video streams</div>
@@ -340,9 +343,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</FfprobeChip>
)}
{stream.width && stream.height && (
<FfprobeChip variant="outline">
{`${stream.width}×${stream.height}`}
</FfprobeChip>
<FfprobeChip variant="outline">{`${stream.width}×${stream.height}`}</FfprobeChip>
)}
{stream.pix_fmt && (
<FfprobeChip variant="outline">
@@ -350,14 +351,10 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</FfprobeChip>
)}
{stream.display_aspect_ratio && (
<FfprobeChip variant="outline">
{`DAR ${stream.display_aspect_ratio}`}
</FfprobeChip>
<FfprobeChip variant="outline">{`DAR ${stream.display_aspect_ratio}`}</FfprobeChip>
)}
{stream.sample_aspect_ratio && (
<FfprobeChip variant="outline">
{`SAR ${stream.sample_aspect_ratio}`}
</FfprobeChip>
<FfprobeChip variant="outline">{`SAR ${stream.sample_aspect_ratio}`}</FfprobeChip>
)}
{stream.level !== undefined &&
stream.level !== null && (
@@ -399,7 +396,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</div>
</div>
)}
{audioStreams.length > 0 && (
<div>
<div className="text-xs text-muted-foreground">Audio streams</div>
@@ -448,7 +444,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</div>
</div>
)}
{subtitleStreams.length > 0 && (
<div>
<div className="text-xs text-muted-foreground">
@@ -481,7 +476,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</div>
</div>
)}
{streams.length === 0 && (
<div className="text-sm text-muted-foreground">
No streams found.
@@ -489,16 +483,16 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
)}
</CardContent>
</Card>
{Object.keys(format.tags ?? {}).length > 0 && (
<Card>
<CardContent className="flex flex-col gap-2">
<div className="text-sm font-semibold">Tags</div>
<div className="flex flex-row flex-wrap gap-1.5">
{Object.entries(format.tags ?? {}).map(([key, value]) => (
<FfprobeChip key={key} variant="outline">
{`${key}: ${value}`}
</FfprobeChip>
<FfprobeChip
key={key}
variant="outline"
>{`${key}: ${value}`}</FfprobeChip>
))}
</div>
</CardContent>
@@ -508,57 +502,36 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
);
}
function InfoAlert({ children }: { children: React.ReactNode }) {
return (
<Alert>
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}
// --- Component ---
export function FileBrowser() {
const [searchParams, setSearchParams] = useSearchParams();
export function FilesTab({ instance }: { instance: ServiceInstance }) {
const isMobile = useIsMobile();
const machineId = instance.id;
const [searchParams] = useSearchParams();
const requestedPath = searchParams.get("path");
const [columnVisibility, setColumnVisibility] = useState<
Record<string, boolean>
>({});
const { data: machines } = useMonitoringSettings();
const fileMachines = useMemo(
() =>
(machines ?? []).filter(
(machine) =>
machine.enabled &&
(machine.services.includes("files") ||
machine.services.includes("monitoring")),
),
[machines],
);
const initialRequestedPath = searchParams.get("path");
const initialMachineId =
searchParams.get("machine_id") || fileMachines[0]?.id || "";
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
FILE_BROWSER_STATE_KEY,
`${FILE_TAB_STATE_KEY}.${instance.id}`,
() => {
const requestedPath = initialRequestedPath ?? "/";
const path = requestedPath ?? "/";
const selectedPath =
requestedPath !== "/" &&
(isVideoFile(requestedPath) || requestedPath.includes("."))
? requestedPath.replace(/\/+$/, "")
path !== "/" && (isVideoFile(path) || path.includes("."))
? path.replace(/\/+$/, "")
: null;
const currentDir = selectedPath
? selectedPath.replace(/\/[^/]+$/, "") || "/"
: requestedPath.replace(/\/+$/, "") || "/";
: path.replace(/\/+$/, "") || "/";
return {
...defaultFileBrowserState(),
currentDir,
pathInput: requestedPath || currentDir,
pathInput: path || currentDir,
selectedPath,
};
},
);
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
const navigateToSettings = useNavigate();
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
setBrowserState((current) => ({ ...current, ...patch }));
@@ -567,7 +540,7 @@ export function FileBrowser() {
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir, selectedMachineId || undefined);
} = useDirectoryListing(currentDir, machineId);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
@@ -575,10 +548,10 @@ export function FileBrowser() {
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
selectedMachineId || undefined,
machineId,
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob(selectedMachineId || undefined);
const runJob = useRunJob(machineId);
const navigate = (path: string) => {
updateBrowserState({
@@ -588,18 +561,6 @@ export function FileBrowser() {
});
};
const setMachine = (machineId: string) => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (machineId) next.set("machine_id", machineId);
else next.delete("machine_id");
return next;
},
{ replace: true },
);
};
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
@@ -634,8 +595,6 @@ export function FileBrowser() {
}
}
// Preserved row-click behavior (MUI DataGrid onRowClick): dir/up rows navigate;
// file rows select the file for ffprobe preview (also feeds pathInput).
const handleRowClick = (row: DisplayRow) => {
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
@@ -648,8 +607,6 @@ export function FileBrowser() {
});
};
// Single-select checkbox behavior (DataTable adds a selection column under
// enableRowSelection): mirrors the row-click selection for file rows.
const rowSelection: RowSelectionState = selectedPath
? { [selectedPath]: true }
: {};
@@ -677,216 +634,182 @@ export function FileBrowser() {
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
<div className="flex flex-col gap-4.5">
<div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold">File Browser</h2>
<Badge variant="outline">
{fileMachines.length
? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}`
: "No file machines"}
</Badge>
</div>
<TabbedCard
value={fileMachines.length > 0 ? selectedMachineId : ""}
onChange={setMachine}
tabs={fileMachines.map((machine) => (
<TabsTrigger key={machine.id} value={machine.id}>
{`${machine.name} · ${machine.mode}`}
</TabsTrigger>
))}
<div className="flex flex-col gap-4">
<SectionCard
title="Browser"
description="Read-only listing with explicit open/select actions."
>
{fileMachines.length > 0 ? (
<div className="flex flex-col gap-4">
<SectionCard
title="Browser"
description="Read-only listing with explicit open/select actions."
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 md:flex-row">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="remote-path">Remote path</Label>
<Input
id="remote-path"
value={pathInput}
onChange={(e) =>
updateBrowserState({ pathInput: e.target.value })
}
onKeyDown={handlePathSubmit}
/>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<Button
variant="outline"
className="w-full md:w-auto mobile-touch-target"
onClick={() => navigate(pathInput || "/")}
>
Open
</Button>
<Button
variant="outline"
className="w-full md:w-auto mobile-touch-target"
onClick={() => refetch()}
>
Refresh
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{`Current: ${currentDir} `}
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
{listing ? `| Entries: ${listing.count}` : ""}
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{String(error)}</AlertDescription>
</Alert>
)}
<div className="rounded-lg border bg-card">
{isMobile ? (
<div className="p-4">
<MobileCardRow
rows={rows}
fields={fileCardFields}
getRowId={(row) => row.id}
onRowClick={handleRowClick}
/>
</div>
) : (
<DataTable
columns={fileColumns}
data={rows}
getRowId={(row) => row.id}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={handleSelectionChange}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
emptyMessage={
isLoading
? "Loading directory..."
: "This directory is empty."
}
/>
)}
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 md:flex-row">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="remote-path">Remote path</Label>
<Input
id="remote-path"
value={pathInput}
onChange={(e) =>
updateBrowserState({ pathInput: e.target.value })
}
onKeyDown={handlePathSubmit}
/>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<Button
variant="outline"
className="w-full md:w-auto"
onClick={() => navigate(pathInput || "/")}
>
Open
</Button>
<Button
variant="outline"
className="w-full md:w-auto"
onClick={() => refetch()}
>
Refresh
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{`Current: ${currentDir} `}
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
{listing ? `| Entries: ${listing.count}` : ""}
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{String(error)}</AlertDescription>
</Alert>
)}
<div className="rounded-lg border bg-card">
{isMobile ? (
<div className="p-4">
<MobileCardRow
rows={rows}
fields={fileCardFields}
getRowId={(row) => row.id}
onRowClick={handleRowClick}
/>
</div>
) : (
<DataTable
columns={fileColumns}
data={rows}
getRowId={(row) => row.id}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={handleSelectionChange}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
emptyMessage={
isLoading ? "Loading directory..." : "This directory is empty."
}
/>
)}
</div>
</div>
</SectionCard>
<SectionCard
title="Media info"
description="ffprobe metadata for the selected media file."
>
{selectedPath ? (
isVideoFile(selectedPath) ? (
ffprobeError ? (
<Alert variant="destructive">
<AlertDescription>{String(ffprobeError)}</AlertDescription>
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Alert>
<AlertDescription>Loading ffprobe data...</AlertDescription>
</Alert>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Alert>
<AlertDescription>No ffprobe data available.</AlertDescription>
</Alert>
)
) : (
<Alert>
<AlertDescription>
Select a video file to view ffprobe details.
</AlertDescription>
</Alert>
)
) : (
<Alert>
<AlertDescription>
Select a file in Browser to view ffprobe details.
</AlertDescription>
</Alert>
)}
</SectionCard>
<SectionCard
title="Jobs"
description="Run predefined safe jobs against the selected file."
>
{selectedPath && templates && templates.length > 0 ? (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="job-template">Job template</Label>
<Select
value={selectedJob}
onValueChange={(value) =>
updateBrowserState({ selectedJob: value })
}
>
<SelectTrigger id="job-template" className="w-full">
<SelectValue placeholder="Select a job" />
</SelectTrigger>
<SelectContent>
{templates.map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SectionCard>
<SectionCard
title="Media info"
description="ffprobe metadata for the selected media file."
>
{selectedPath ? (
isVideoFile(selectedPath) ? (
ffprobeError ? (
<Alert variant="destructive">
<AlertDescription>
{String(ffprobeError)}
</AlertDescription>
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<InfoAlert>Loading ffprobe data...</InfoAlert>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<InfoAlert>No ffprobe data available.</InfoAlert>
)
) : (
<InfoAlert>
Select a video file to view ffprobe details.
</InfoAlert>
)
) : (
<InfoAlert>
Select a file in Browser to view ffprobe details.
</InfoAlert>
)}
</SectionCard>
<SectionCard
title="Jobs"
description="Run predefined safe jobs against the selected file."
>
{selectedPath && templates && templates.length > 0 ? (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="job-template">Job template</Label>
<Select
value={selectedJob}
onValueChange={(value) =>
updateBrowserState({ selectedJob: value })
}
>
<SelectTrigger id="job-template" className="w-full">
<SelectValue placeholder="Select a job" />
</SelectTrigger>
<SelectContent>
{templates.map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
<Button className="mobile-touch-target"
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({
jobKey: selectedJob,
path: selectedPath,
})
}
>
Run job
</Button>
{selectedTemplate && (
<div className="self-center text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
)}
</div>
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
<Button
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
}
>
Run job
</Button>
{selectedTemplate && (
<div className="self-center text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
{runJob.data && (
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
{`Exit: ${runJob.data.exit_status}`}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</div>
) : (
<InfoAlert>Select a file in Browser to run jobs.</InfoAlert>
)}
</SectionCard>
)}
</div>
</div>
{runJob.data && (
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
{`Exit: ${runJob.data.exit_status}`}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</div>
) : (
<Alert>
<AlertDescription>
No file-capable machines are configured yet.
Select a file in Browser to run jobs.
</AlertDescription>
<AlertAction>
<Button
variant="outline"
size="sm"
onClick={() => navigateToSettings("/settings")}
className="mobile-touch-target"
>
Open Settings
</Button>
</AlertAction>
</Alert>
)}
</TabbedCard>
</SectionCard>
</div>
);
}
@@ -1,3 +1,15 @@
/**
* JobsTab operational content for the backups service page.
*
* Lifted from the old top-level `components/BackupsPage.tsx`. The three
* sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim.
*
* NOTE: the backup hooks currently query globally (no service_id filter).
* The backend gained `service_id` attribution in Slice 3, but the hooks don't
* yet accept a serviceId param. This tab shows ALL backups data for now;
* per-instance scoping by `instance.id` is a follow-up once the hooks gain the
* parameter.
*/
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -5,12 +17,16 @@ import {
useBackupAlerts,
useBackupJobs,
useBackupRuns,
} from "../hooks/useBackups";
import BackupAlertsTable from "./BackupAlertsTable";
import BackupJobsTable from "./BackupJobsTable";
import BackupRunsTable from "./BackupRunsTable";
} from "../../hooks/useBackups";
import BackupAlertsTable from "../../components/BackupAlertsTable";
import BackupJobsTable from "../../components/BackupJobsTable";
import BackupRunsTable from "../../components/BackupRunsTable";
import type { ServiceInstance } from "../../types";
export default function BackupsPage() {
export function JobsTab({ instance }: { instance: ServiceInstance }) {
// instance.id is not yet used — backup hooks query globally (see file
// docstring). Per-instance scoping is a follow-up.
void instance;
const [tab, setTab] = useState("jobs");
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
@@ -35,7 +51,6 @@ export default function BackupsPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="jobs">Jobs</TabsTrigger>
@@ -0,0 +1,194 @@
/**
* Grafana Links tab (spec R2.4, R8.2).
*
* Lifts the Grafana deep-link content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Shows service health + the
* configured Grafana deep-links (node-exporter dashboard, Loki logs per
* machine).
*
* The hooks (useGrafanaStatus, useMonitoringMachines) are global /
* first-configured for now. Wiring `instance.id` into the status hook is a
* follow-up. The machine links use the configured Grafana base_url from the
* instance's config.
*/
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react";
import {
useGrafanaStatus,
useMonitoringMachines,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ServiceInstance } from "../../types";
function GrafanaLinkCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
return (
<div className="rounded-md border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="font-medium">{title}</div>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
<Button variant="outline" size="sm" asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
Open in Grafana
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</div>
</div>
);
}
export function LinksTab({ instance }: { instance: ServiceInstance }) {
const { data: status, isLoading, error } = useGrafanaStatus();
const { data: machines = [], isLoading: machinesLoading } =
useMonitoringMachines();
const [selectedMachineId, setSelectedMachineId] = useState("");
const grafanaBaseUrl =
(instance.config?.base_url as string | undefined) ?? "";
const selectedMachine = useMemo(
() =>
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
[machines, selectedMachineId],
);
const nodeExporterDashboardUrl = useMemo(() => {
if (!selectedMachine || !grafanaBaseUrl) return "";
const inst = `${selectedMachine.host || "localhost"}:9100`;
return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`;
}, [selectedMachine, grafanaBaseUrl]);
const logsUrl = useMemo(() => {
if (!selectedMachine || !grafanaBaseUrl) return "";
const container =
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent(
JSON.stringify({
datasource: "Loki",
queries: [{ refId: "A", expr: `{container="${container}"}` }],
range: { from: "now-1h", to: "now" },
}),
)}`;
}, [selectedMachine, grafanaBaseUrl]);
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: isLoading
? "checking…"
: error
? "unreachable"
: "not configured";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Gauge className="h-4 w-4" />
Grafana {statusDetail}
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to reach Grafana</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Machine Dashboard
</CardTitle>
{machines.length > 0 ? (
<Select
value={selectedMachine?.id ?? ""}
onValueChange={setSelectedMachineId}
disabled={machinesLoading}
>
<SelectTrigger className="w-full sm:w-[240px]">
<SelectValue placeholder="Select machine" />
</SelectTrigger>
<SelectContent>
{machines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : selectedMachine && grafanaBaseUrl ? (
<>
<GrafanaLinkCard
title={`${selectedMachine.name} metrics`}
description="Open the Node Exporter overview dashboard for this machine in Grafana."
href={nodeExporterDashboardUrl}
/>
<GrafanaLinkCard
title={`${selectedMachine.name} logs`}
description="Explore Loki logs for this machine in Grafana."
href={logsUrl}
/>
</>
) : !grafanaBaseUrl ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Gauge className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No Grafana base URL configured</div>
<div className="max-w-md text-sm text-muted-foreground">
Add a Grafana service instance to enable deep-links to
dashboards and logs.
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/services">Open Services</Link>
</Button>
</div>
) : (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<ServerOff className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No machine selected</div>
<div className="max-w-md text-sm text-muted-foreground">
Add monitoring machines in Settings to see Grafana drill-down
links.
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,5 +1,13 @@
/**
* MediaTab operational content for the Jellyfin service page.
*
* Lifted from the old top-level `pages/Media.tsx`. The service-id source is
* changed from URL search params to the `instance` prop (the active service
* instance selected on the service page). The service-selection dropdown and
* its URL-sync effect are removed; everything else is preserved verbatim.
*/
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import type {
ColumnDef,
OnChangeFn,
@@ -34,12 +42,14 @@ import {
useBuildIndex,
useStopBuildIndex,
useForceStopBuildIndex,
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
import { useIsMobile } from "../hooks/useIsMobile";
import type { MediaItem } from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard";
} from "../../hooks/useMedia";
import { usePersistentState } from "../../hooks/usePersistentState";
import { useIsMobile } from "../../hooks/useIsMobile";
import type { MediaItem, ServiceInstance } from "../../types";
import { useCounts, useLibraries } from "../../hooks/useDashboard";
import { useServiceInstances } from "../../hooks/useServices";
// --- Format helpers (lifted verbatim from Media.tsx) ---
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-";
@@ -52,10 +62,8 @@ function formatDuration(seconds: number | null | undefined): string {
return `${secs}s`;
}
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
// TanStack table instance stays stable — an unstable columns array drops the
// controlled selection/visibility state (7a discovery). Visibility-only parity
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
// --- Column definitions (lifted verbatim) ---
const mediaColumns: ColumnDef<MediaItem>[] = [
{ accessorKey: "title", header: "Title" },
{ accessorKey: "series", header: "Series" },
@@ -74,25 +82,15 @@ const mediaColumns: ColumnDef<MediaItem>[] = [
{ accessorKey: "path", header: "Path" },
];
// Stable path-derived identity so row selection survives server-driven paging
// (design §3.4): the id is the item's filesystem path, which is stable across
// limit/offset page changes.
function getMediaRowId(row: MediaItem): string {
return row.path;
}
// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
// Title is the primary identifier; size/HDR/library/year give the at-a-glance
// tech + context info a user scanning the library on a phone needs. Runtime,
// bitrate, resolution, codec etc. live on the desktop table only.
// Mobile card fields (mobile-parity pattern): title primary + 4 key fields.
const mediaCardFields: MobileCardField<MediaItem>[] = [
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{
key: "hdr",
label: "HDR",
render: (r) => r.hdr || "-",
},
{ key: "hdr", label: "HDR", render: (r) => r.hdr || "-" },
{ key: "library", label: "Library", render: (r) => r.library || "-" },
{
key: "year",
@@ -101,12 +99,10 @@ const mediaCardFields: MobileCardField<MediaItem>[] = [
},
];
// Mobile pagination uses the shared TablePagination component
// (frontend/src/components/ui/table-pagination.tsx).
// --- Persistent filter/sort/pagination state (lifted verbatim) ---
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
const SMALL_BREAKPOINT = "(max-width: 900px)";
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
const MOBILE_HIDDEN_COLUMNS = [
"series",
"season",
@@ -159,6 +155,8 @@ function usePrefersSmallScreen(): boolean {
return small;
}
// --- Small UI helpers (lifted verbatim) ---
function FilterSelect({
id,
label,
@@ -191,9 +189,6 @@ function FilterSelect({
);
}
// LinearProgress → Progress: determinate value drives the shadcn Progress; the
// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
// "indeterminate" affordance for unknown build progress.
function BuildProgress({ value }: { value: number | null }) {
if (value == null) {
return (
@@ -203,31 +198,26 @@ function BuildProgress({ value }: { value: number | null }) {
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
}
export function Media() {
// --- Component ---
export function MediaTab({ instance }: { instance: ServiceInstance }) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
const isSmall = usePrefersSmallScreen();
const isMobile = useIsMobile();
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
jellyfinServices.find((s) => s.enabled)?.id ||
"";
const { data: counts } = useCounts(selectedServiceId || undefined);
const { data: libraries } = useLibraries(selectedServiceId || undefined);
const { data: status } = useMediaStatus(selectedServiceId || undefined);
const buildIndex = useBuildIndex(selectedServiceId || undefined);
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
const forceStopBuildIndex = useForceStopBuildIndex(
selectedServiceId || undefined,
);
const serviceId = instance.id;
const { data: counts } = useCounts(serviceId);
const { data: libraries } = useLibraries(serviceId);
const { data: status } = useMediaStatus(serviceId);
const buildIndex = useBuildIndex(serviceId);
const stopBuildIndex = useStopBuildIndex(serviceId);
const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
MEDIA_TAB_STATE_KEY,
defaultMediaTabState,
);
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
// without pageSize/columnVisibility) never yields undefined fields.
const mediaState: MediaTabState = {
...defaultMediaTabState(),
...rawMediaState,
@@ -239,19 +229,6 @@ export function Media() {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useEffect(() => {
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("jellyfin_service_id", selectedServiceId);
return next;
},
{ replace: true },
);
}
}, [searchParams, selectedServiceId, setSearchParams]);
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
search,
@@ -260,12 +237,10 @@ export function Media() {
sort_order: sortOrder,
limit: pageSize,
offset,
jellyfinServiceId: selectedServiceId || undefined,
jellyfinServiceId: serviceId,
enabled: status?.exists ?? false,
});
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
// persistent media state and drive useMediaQuery { limit, offset }.
const pageIndex = Math.floor(offset / pageSize);
const pagination: PaginationState = { pageIndex, pageSize };
@@ -275,8 +250,6 @@ export function Media() {
? updater({ pageIndex, pageSize })
: updater;
const nextPageSize = next.pageSize || pageSize;
// Restart at page 0 whenever the page size changes (keeps offset sane
// under server-driven paging).
const nextOffset =
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
setMediaState((current) => ({
@@ -296,9 +269,6 @@ export function Media() {
});
};
// On small screens force the same set of columns hidden as the pre-rework
// DataGrid `columnVisibilityModel` mobile override; on desktop the user
// toggles freely (the toggleable set still equals the locked 15).
const effectiveColumnVisibility = useMemo(() => {
const base = mediaState.columnVisibility ?? {};
if (!isSmall) return base;
@@ -307,10 +277,15 @@ export function Media() {
return merged;
}, [mediaState.columnVisibility, isSmall]);
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
// the item's path.
const handleRowClick = (row: MediaItem) => {
navigate(`/files?path=${encodeURIComponent(row.path)}`);
// Navigate to the ssh_tasks service page with the path query param.
// If an ssh_tasks instance exists, open its Files tab; otherwise land
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
const sshInstance = sshServices.find((s) => s.enabled);
const base = sshInstance
? `/services/ssh_tasks/${sshInstance.id}`
: "/services/ssh_tasks";
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
};
const total = queryResult?.total ?? 0;
@@ -346,35 +321,6 @@ export function Media() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Jellyfin</h2>
<div className="flex flex-col gap-1.5">
<Label htmlFor="media-service">Service</Label>
<Select
value={selectedServiceId}
onValueChange={(value) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("jellyfin_service_id", value);
return next;
},
{ replace: true },
)
}
>
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
{jellyfinServices.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{status?.exists ? (
<p className="text-sm text-muted-foreground">
Index: {status.item_count.toLocaleString()} items
@@ -397,7 +343,7 @@ export function Media() {
</p>
)}
<Button className="mobile-touch-target"
<Button
variant="outline"
onClick={() => buildIndex.mutate()}
disabled={
@@ -408,7 +354,7 @@ export function Media() {
</Button>
{buildRunning && (
<>
<Button className="mobile-touch-target"
<Button
variant="destructive"
onClick={() => stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested}
@@ -419,7 +365,7 @@ export function Media() {
</Button>
<Button
variant="outline"
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10 mobile-touch-target"
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
onClick={() => forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending}
>
@@ -0,0 +1,129 @@
/** MessagingTab — compose email to Authentik users via the mail queue. */
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
useAuthentikUsers,
useSendAuthentikMessage,
} from "../../hooks/useAuthentik";
import type { ServiceInstance } from "../../types";
const DEFAULT_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
export function MessagingTab({ instance }: { instance: ServiceInstance }) {
const [search, setSearch] = useState("");
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
const [subject, setSubject] = useState("");
const [htmlBody, setHtmlBody] = useState(DEFAULT_BODY);
const { data } = useAuthentikUsers(instance.id, {
search,
page: 1,
page_size: 100,
});
const sendMessage = useSendAuthentikMessage(instance.id);
const users = (data?.items ?? []).filter((u) => u.email);
const error = data?.error;
function toggleEmail(email: string) {
setSelectedEmails((prev) => {
const next = new Set(prev);
if (next.has(email)) next.delete(email);
else next.add(email);
return next;
});
}
function handleSend() {
if (!subject.trim() || selectedEmails.size === 0) return;
sendMessage.mutate({
recipient_emails: Array.from(selectedEmails),
subject: subject.trim(),
html_body: htmlBody,
});
}
const canSend =
subject.trim() !== "" && selectedEmails.size > 0 && !sendMessage.isPending;
return (
<div className="flex flex-col gap-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{sendMessage.data ? (
<Alert>
<AlertDescription>
{sendMessage.data.status === "queued"
? `Message queued (${sendMessage.data.recipient_count ?? 0} recipients, request ${sendMessage.data.request_id?.slice(0, 8) ?? ""}).`
: `Error: ${sendMessage.data.error ?? "unknown"}`}
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="msg-search">Find recipients</Label>
<Input
id="msg-search"
placeholder="Search users to add as recipients…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
/>
{users.length > 0 ? (
<div className="flex flex-wrap gap-2">
{users.slice(0, 20).map((user) => (
<Button
key={user.pk}
variant={selectedEmails.has(user.email) ? "default" : "outline"}
size="sm"
onClick={() => toggleEmail(user.email)}
>
{user.name || user.username}
</Button>
))}
</div>
) : null}
{selectedEmails.size > 0 ? (
<p className="text-sm text-muted-foreground">
{selectedEmails.size} recipient
{selectedEmails.size === 1 ? "" : "s"} selected.
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="msg-subject">Subject</Label>
<Input
id="msg-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="msg-body">Message (HTML)</Label>
<Textarea
id="msg-body"
rows={8}
value={htmlBody}
onChange={(e) => setHtmlBody(e.target.value)}
className="font-mono text-xs"
/>
</div>
<div>
<Button onClick={handleSend} disabled={!canSend}>
{sendMessage.isPending ? "Sending…" : "Send message"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
/**
* Prometheus Metrics tab (spec R2.4, R8.2).
*
* Lifts the Prometheus status + targets content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Shows service health and
* the Node Exporter scrape-targets list.
*
* The hooks (usePrometheusStatus, usePrometheusTargets) are global /
* first-configured for now. Wiring `instance.id` is a follow-up.
*/
import { Radio } from "lucide-react";
import {
usePrometheusStatus,
usePrometheusTargets,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import type { PrometheusTarget, ServiceInstance } from "../../types";
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
return (
<div className="space-y-3">
{targets.map((target, idx) => (
<div key={idx} className="rounded-lg border p-3">
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
{target.labels && Object.keys(target.labels).length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{Object.entries(target.labels).map(([key, value]) => (
<Badge key={key} variant="outline" className="text-[10px]">
{key}: {value}
</Badge>
))}
</div>
)}
</div>
))}
</div>
);
}
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
// Global / first-configured hooks for now; instance.id scoping is a
// follow-up (see file docstring).
void instance;
const {
data: status,
isLoading: statusLoading,
error: statusError,
} = usePrometheusStatus();
const {
data: targets,
isLoading: targetsLoading,
error: targetsError,
} = usePrometheusTargets();
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: statusLoading
? "checking…"
: "unreachable";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Radio className="h-4 w-4" />
Prometheus {statusDetail}
</div>
{statusError && (
<Alert variant="destructive">
<AlertTitle>Failed to reach Prometheus</AlertTitle>
<AlertDescription>{statusError.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Node Exporter Targets ({targets?.length ?? 0})
</CardTitle>
</CardHeader>
<CardContent>
{targetsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !targets || targets.length === 0 ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Radio className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No Node Exporter targets</div>
<div className="max-w-md text-sm text-muted-foreground">
Enable Node Exporter on an SSH machine in Settings to populate
Prometheus scrape targets.
</div>
</div>
) : (
<TargetsTable targets={targets} />
)}
</CardContent>
</Card>
{targetsError && (
<Alert variant="destructive">
<AlertTitle>Failed to load targets</AlertTitle>
<AlertDescription>{targetsError.message}</AlertDescription>
</Alert>
)}
</div>
);
}
@@ -0,0 +1,64 @@
/**
* RequestsTab — Jellyseerr request-management surface on the Jellyfin page.
*
* Jellyseerr was absorbed into Jellyfin config (jellyseerr_url +
* jellyseerr_api_key) in Slice 1. This tab reads those config fields. When
* configured, it shows the URL and a placeholder (no requests backend endpoint
* exists yet — building one is out of scope for this slice). When not
* configured, it shows an empty-state CTA directing the user to add the fields
* to the Jellyfin config.
*/
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ExternalLink } from "lucide-react";
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
const jellyseerrUrl = String(
(instance.config as Record<string, unknown>).jellyseerr_url ?? "",
).trim();
const jellyseerrApiKey = String(
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "",
).trim();
if (!jellyseerrUrl || !jellyseerrApiKey) {
return (
<Alert>
<AlertDescription>
Jellyseerr is not configured for this Jellyfin instance. Add
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_url
</code>
and
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_api_key
</code>
to the Jellyfin config (Config tab) to enable request management.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">Jellyseerr</h3>
<a
href={jellyseerrUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
{jellyseerrUrl}
<ExternalLink className="size-3.5" />
</a>
</div>
<Alert>
<AlertDescription>
Jellyseerr is configured. The requests view will show pending and
recently fulfilled media requests. (This surface is under
development.)
</AlertDescription>
</Alert>
</div>
);
}
@@ -0,0 +1,136 @@
/** UsersTab — Authentik user directory for the Authentik service page. */
import { useState } from "react";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { ServiceInstance } from "../../types";
import { useAuthentikUsers } from "../../hooks/useAuthentik";
const PAGE_SIZE = 25;
export function UsersTab({ instance }: { instance: ServiceInstance }) {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [committedSearch, setCommittedSearch] = useState("");
const { data, isLoading } = useAuthentikUsers(instance.id, {
search: committedSearch,
page,
page_size: PAGE_SIZE,
});
const error = data?.error;
const users = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
function handleSearch() {
setPage(1);
setCommittedSearch(search);
}
return (
<div className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="flex items-center gap-2">
<Input
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
className="max-w-xs"
/>
<Button variant="outline" onClick={handleSearch}>
Search
</Button>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-24">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && users.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
Loading
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
No users found.
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.pk}>
<TableCell className="font-medium">
{user.name || "—"}
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell className="text-muted-foreground">
{user.email || "—"}
</TableCell>
<TableCell>
<Badge variant={user.is_active ? "default" : "secondary"}>
{user.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{total > 0 ? (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} user{total === 1 ? "" : "s"} · Page {page} of {totalPages}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</Button>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { ActionsTab } from "../ActionsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "ssh-1",
service_type: "ssh_tasks",
name: "Storage Server",
config: {},
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useSettings", () => ({
useTasks: () => ({
data: [
{
id: "t1",
name: "Disk usage",
task_type: "shell",
content: "df -h",
enabled: true,
default_service_id: "",
notes: "",
},
],
}),
useTaskRuns: () => ({ data: { items: [] } }),
useSaveTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteTask: () => ({ mutate: vi.fn(), isPending: false }),
useRunTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
function renderTab() {
return render(
<MemoryRouter>
<ActionsTab instance={instance} />
</MemoryRouter>,
);
}
describe("ActionsTab", () => {
it("renders the saved-actions rail and task detail", () => {
renderTab();
expect(screen.getByText("Saved actions")).toBeInTheDocument();
expect(screen.getByText("Disk usage")).toBeInTheDocument();
});
it("renders the Add action button", () => {
renderTab();
expect(
screen.getByRole("button", { name: "Add action" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { AlertsTab } from "../AlertsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "am-1",
service_type: "alertmanager",
name: "Main Alertmanager",
config: { base_url: "https://am.example.com", timeout_seconds: 5 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
useAlertmanagerAlerts: () => ({
data: {
total: 2,
by_severity: { critical: 1, warning: 1 },
alerts: [
{
name: "DiskFull",
severity: "critical",
category: "disk",
job_name: "node",
summary: "Disk is almost full",
description: "Disk usage above 90%",
active_since: "2026-06-26T10:00:00Z",
state: "firing",
labels: { instance: "node1" },
},
{
name: "HighCpu",
severity: "warning",
category: "cpu",
job_name: "node",
summary: "High CPU usage",
description: "",
active_since: "2026-06-26T09:00:00Z",
state: "firing",
labels: {},
},
],
},
isLoading: false,
error: null,
}),
useAlertmanagerStatus: () => ({
data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
isLoading: false,
error: null,
}),
}));
describe("AlertsTab", () => {
it("renders the alert count and alert names", () => {
render(<AlertsTab instance={instance} />);
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
expect(screen.getByText("DiskFull")).toBeInTheDocument();
expect(screen.getByText("HighCpu")).toBeInTheDocument();
});
it("renders severity badges", () => {
render(<AlertsTab instance={instance} />);
expect(screen.getByText("critical")).toBeInTheDocument();
expect(screen.getByText("warning")).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { FilesTab } from "../FilesTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "ssh-1",
service_type: "ssh_tasks",
name: "Storage Server",
config: {},
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useFiles", () => ({
useDirectoryListing: () => ({
data: {
count: 2,
entries: [
{ name: "movies", type: "d", size: 0, mtime: 1700000000 },
{ name: "video.mkv", type: "f", size: 1024, mtime: 1700000000 },
],
},
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
useJobTemplates: () => ({ data: [] }),
useRunJob: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
}));
vi.mock("../../../hooks/usePersistentState", () => ({
usePersistentState: vi.fn((_key: string, initial: () => unknown) => [
initial(),
vi.fn(),
]),
}));
function renderTab(path = "/services/ssh_tasks/ssh-1") {
return render(
<MemoryRouter initialEntries={[path]}>
<FilesTab instance={instance} />
</MemoryRouter>,
);
}
describe("FilesTab", () => {
it("renders the directory listing with instance-scoped hooks", () => {
renderTab();
expect(screen.getByText("movies")).toBeInTheDocument();
expect(screen.getByText("video.mkv")).toBeInTheDocument();
});
it("renders the path bar and browser section", () => {
renderTab();
expect(screen.getByText("Browser")).toBeInTheDocument();
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { JobsTab } from "../JobsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "bkp-1",
service_type: "backups",
name: "Main Backups",
config: { ingestion_label: "default" },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useBackups", () => ({
useBackupJobs: () => ({
data: [
{
id: "job-1",
name: "nightly",
source: "/data",
target: "s3://bucket",
schedule_interval_seconds: 86400,
created_at: 1_700_000_000,
},
],
isLoading: false,
}),
useBackupRuns: () => ({
data: [],
isLoading: false,
}),
useBackupAlerts: () => ({
data: [],
isLoading: false,
}),
useAcknowledgeAlert: () => ({ mutate: vi.fn() }),
}));
function renderTab() {
return render(<JobsTab instance={instance} />);
}
describe("JobsTab", () => {
it("renders the Jobs, Runs, and Alerts sub-tabs", () => {
renderTab();
expect(screen.getByRole("tab", { name: "Jobs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Runs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /Alerts/ })).toBeInTheDocument();
});
it("renders the backup job name in the Jobs tab", () => {
renderTab();
expect(screen.getByText("nightly")).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { LinksTab } from "../LinksTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "graf-1",
service_type: "grafana",
name: "Main Grafana",
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
useGrafanaStatus: () => ({
data: {
up: true,
version: "11.0.0",
service_id: "graf-1",
name: "Main Grafana",
},
isLoading: false,
error: null,
}),
useMonitoringMachines: () => ({
data: [
{
id: "m1",
name: "storage",
mode: "ssh",
host: "10.0.0.5",
enabled: true,
services: [],
port: 22,
username: "admin",
},
],
isLoading: false,
}),
}));
describe("LinksTab", () => {
it("renders the Grafana version and machine dashboard links", () => {
render(<LinksTab instance={instance} />);
expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument();
expect(screen.getByText(/storage metrics/)).toBeInTheDocument();
expect(screen.getByText(/storage logs/)).toBeInTheDocument();
});
it("renders open-in-grafana link buttons", () => {
render(<LinksTab instance={instance} />);
const links = screen.getAllByText("Open in Grafana");
expect(links).toHaveLength(2);
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MediaTab } from "../MediaTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "jellyfin-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config: { base_url: "https://jf.example.com", user_id: "u1" },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useMedia", () => ({
useMediaStatus: () => ({
data: { exists: true, item_count: 42, updated_at_label: "today" },
}),
useMediaQuery: () => ({ data: { items: [], total: 0 }, isLoading: false }),
useBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
useStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
useForceStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("../../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 30 },
}),
useLibraries: () => ({ data: [{ id: "lib1" }] }),
}));
vi.mock("../../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [] }),
}));
vi.mock("../../../hooks/usePersistentState", () => ({
usePersistentState: () => [
{
search: "",
types: "Movie,Episode",
hdrFilter: "All",
sortKey: "title",
sortOrder: "Ascending",
offset: 0,
pageSize: 100,
columnVisibility: {},
},
vi.fn(),
],
}));
function renderTab() {
return render(
<MemoryRouter>
<MediaTab instance={instance} />
</MemoryRouter>,
);
}
describe("MediaTab", () => {
it("renders index status and build controls with instance-scoped data", () => {
renderTab();
expect(screen.getByText(/42 items/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Build index/i }),
).toBeInTheDocument();
});
it("renders library counts", () => {
renderTab();
expect(screen.getByText(/10 movies/)).toBeInTheDocument();
expect(screen.getByText(/5 series/)).toBeInTheDocument();
});
it("renders the filter card with search input", () => {
renderTab();
expect(screen.getByLabelText("Search")).toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MessagingTab } from "../MessagingTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "auth-1",
service_type: "authentik",
name: "Main Authentik",
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
secrets_set: { api_token: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useAuthentik", () => ({
useAuthentikUsers: vi.fn(() => ({
data: {
items: [
{
pk: 1,
username: "alice",
name: "Alice",
email: "alice@example.com",
is_active: true,
},
],
total: 1,
page: 1,
page_size: 100,
},
})),
useSendAuthentikMessage: vi.fn(() => ({
mutate: vi.fn(),
isPending: false,
data: undefined,
})),
}));
describe("MessagingTab", () => {
it("renders the compose form (subject, body, send)", () => {
render(<MessagingTab instance={instance} />);
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
expect(screen.getByLabelText("Message (HTML)")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Send message" }),
).toBeInTheDocument();
});
it("renders recipient toggle buttons from the directory", () => {
render(<MessagingTab instance={instance} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
});
});
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MetricsTab } from "../MetricsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "prom-1",
service_type: "prometheus",
name: "Main Prometheus",
config: { base_url: "https://prom.example.com", timeout_seconds: 10 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
usePrometheusStatus: () => ({
data: {
up: true,
version: "2.52.0",
service_id: "prom-1",
name: "Main Prometheus",
},
isLoading: false,
error: null,
}),
usePrometheusTargets: () => ({
data: [
{
targets: ["10.0.0.5:9100"],
labels: { instance: "storage", job: "node_exporter" },
},
],
isLoading: false,
error: null,
}),
}));
describe("MetricsTab", () => {
it("renders the Prometheus version and target list", () => {
render(<MetricsTab instance={instance} />);
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
});
it("renders the target count in the heading", () => {
render(<MetricsTab instance={instance} />);
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { RequestsTab } from "../RequestsTab";
import type { ServiceInstance } from "../../../types";
function makeInstance(config: Record<string, unknown>): ServiceInstance {
return {
id: "jellyfin-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config,
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
}
describe("RequestsTab", () => {
it("shows empty-state CTA when Jellyseerr is not configured", () => {
render(
<RequestsTab
instance={makeInstance({
base_url: "https://jf.example.com",
user_id: "u1",
})}
/>,
);
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument();
});
it("shows the configured Jellyseerr URL when both fields are set", () => {
render(
<RequestsTab
instance={makeInstance({
base_url: "https://jf.example.com",
jellyseerr_url: "https://requests.example.com",
jellyseerr_api_key: "secret-key",
})}
/>,
);
expect(
screen.getByText("https://requests.example.com"),
).toBeInTheDocument();
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
});
it("shows empty-state when only URL is set (missing api_key)", () => {
render(
<RequestsTab
instance={makeInstance({
jellyseerr_url: "https://requests.example.com",
})}
/>,
);
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { UsersTab } from "../UsersTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "auth-1",
service_type: "authentik",
name: "Main Authentik",
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
secrets_set: { api_token: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useAuthentik", () => ({
useAuthentikUsers: vi.fn(() => ({
data: {
items: [
{
pk: 1,
username: "alice",
name: "Alice",
email: "alice@example.com",
is_active: true,
},
{
pk: 2,
username: "bob",
name: "Bob",
email: "bob@example.com",
is_active: false,
},
],
total: 2,
page: 1,
page_size: 25,
},
isLoading: false,
})),
}));
describe("UsersTab", () => {
it("renders the directory table with users", () => {
render(<UsersTab instance={instance} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("bob")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("Inactive")).toBeInTheDocument();
});
it("renders search input and pagination", () => {
render(<UsersTab instance={instance} />);
expect(screen.getByPlaceholderText("Search users…")).toBeInTheDocument();
expect(screen.getByText(/2 users/)).toBeInTheDocument();
expect(screen.getByText("Previous")).toBeInTheDocument();
expect(screen.getByText("Next")).toBeInTheDocument();
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Per-type content-tab descriptors for the service page skeleton.
*
* Each entry names a tab and its component. The service page renders
* `[Overview, ...contentTabs(type), Widgets, Config]`.
*/
import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
import { OverviewTab } from "./stubs";
import { AlertsTab } from "./AlertsTab";
import { LinksTab } from "./LinksTab";
import { MetricsTab } from "./MetricsTab";
import { MediaTab } from "./MediaTab";
import { RequestsTab } from "./RequestsTab";
import { FilesTab } from "./FilesTab";
import { ActionsTab } from "./ActionsTab";
import { JobsTab } from "./JobsTab";
import { UsersTab } from "./UsersTab";
import { MessagingTab } from "./MessagingTab";
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
export interface ContentTab {
label: string;
Component: ServiceTabComponent;
}
/** Overview tab (shared across all service types). */
export const OVERVIEW_TAB: ContentTab = {
label: "Overview",
Component: OverviewTab,
};
/**
* Returns the type-specific content tabs for a service type.
* Types with no operational content return `[]` (only Overview + Widgets + Config).
*/
export function serviceContentTabs(serviceType: string): ContentTab[] {
switch (serviceType) {
case "jellyfin":
return [
{ label: "Media", Component: MediaTab },
{ label: "Requests", Component: RequestsTab },
];
case "ssh_tasks":
return [
{ label: "Files", Component: FilesTab },
{ label: "Actions", Component: ActionsTab },
];
case "backups":
return [{ label: "Jobs", Component: JobsTab }];
case "authentik":
return [
{ label: "Users", Component: UsersTab },
{ label: "Messaging", Component: MessagingTab },
];
case "alertmanager":
return [{ label: "Alerts", Component: AlertsTab }];
case "grafana":
return [{ label: "Links", Component: LinksTab }];
case "prometheus":
return [{ label: "Metrics", Component: MetricsTab }];
default:
return [];
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Service-page content tab stubs.
*
* Each stub renders a "coming soon" placeholder. Slices 59 replace these with
* real operational content lifted from the old top-level pages. All stubs accept
* an `instance` prop so the real implementations can scope queries by instance.
*/
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription } from "@/components/ui/alert";
function Stub({
label,
instance,
}: {
label: string;
instance: ServiceInstance;
}) {
return (
<Alert>
<AlertDescription>
{label} for {instance.name} coming soon.
</AlertDescription>
</Alert>
);
}
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Service overview" instance={instance} />;
}