feat(prometheus-direct-charting): slice 3 — remove grafana + config rewrite + changelog

Remove the entire Grafana surface: integrations/grafana.py, GrafanaWidgetSource
(+ _fetch_chart, now redundant since prometheus chart exists), GrafanaLinkWidget,
LinksTab, get_grafana_status endpoint, useGrafanaStatus hook, GrafanaStatus type,
fetchGrafanaStatus client fn, registry/nav/tab entries (FE+BE). Rewrite
config.yaml thin-dashboard rule to match reality (recharts is sanctioned for
Prometheus-backed series). CHANGELOG migration note added.

Backend: 293 pytest pass, ruff clean. Frontend: build+lint green (0 errors).
SC-115/116 grep-clean (only prometheus_range.py migration comments + Dashboard.test
shortcut fixture remain — both spec-allowed).
This commit is contained in:
Developer
2026-07-08 22:33:44 +00:00
parent 65bae95e3c
commit 67ca0fc3bc
27 changed files with 134 additions and 937 deletions
+1 -1
View File
@@ -65,7 +65,7 @@ const SECTION_META: Record<
custom: { label: "Custom", icon: LayoutDashboard },
};
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus"]);
function widgetSection(
widget: WidgetInstance,
+2 -2
View File
@@ -548,8 +548,8 @@ export function ServicesPage() {
{services.length === 0 ? (
<Alert>
<AlertDescription>
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud,
or SSH task runner.
No services yet. Add a Prometheus, Jellyfin, Nextcloud, or SSH
task runner.
</AlertDescription>
</Alert>
) : (
@@ -1,194 +0,0 @@
/**
* 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,58 +0,0 @@
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);
});
});
-3
View File
@@ -8,7 +8,6 @@ import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
import { OverviewTab } from "./OverviewTab";
import { AlertsTab } from "./AlertsTab";
import { LinksTab } from "./LinksTab";
import { MetricsTab } from "./MetricsTab";
import { MediaTab } from "./MediaTab";
import { RequestsTab } from "./RequestsTab";
@@ -56,8 +55,6 @@ export function serviceContentTabs(serviceType: string): ContentTab[] {
];
case "alertmanager":
return [{ label: "Alerts", Component: AlertsTab }];
case "grafana":
return [{ label: "Links", Component: LinksTab }];
case "prometheus":
return [{ label: "Metrics", Component: MetricsTab }];
default: