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:
@@ -32,7 +32,6 @@ import type {
|
||||
DashboardShortcutInput,
|
||||
AlertmanagerAlertSummary,
|
||||
AlertmanagerStatus,
|
||||
GrafanaStatus,
|
||||
PrometheusStatus,
|
||||
PrometheusTarget,
|
||||
} from "../types";
|
||||
@@ -299,9 +298,6 @@ export const fetchAlertmanagerAlerts = () =>
|
||||
export const fetchAlertmanagerStatus = () =>
|
||||
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
||||
|
||||
export const fetchGrafanaStatus = () =>
|
||||
get<GrafanaStatus>("/api/monitoring/grafana-status");
|
||||
|
||||
export const fetchPrometheusStatus = () =>
|
||||
get<PrometheusStatus>("/api/monitoring/prometheus-status");
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchAlertmanagerAlerts,
|
||||
fetchAlertmanagerStatus,
|
||||
fetchGrafanaStatus,
|
||||
fetchPrometheusStatus,
|
||||
fetchPrometheusTargets,
|
||||
fetchMonitoringMachines,
|
||||
@@ -28,16 +27,6 @@ export function useAlertmanagerStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrafanaStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "grafana-status"],
|
||||
queryFn: fetchGrafanaStatus,
|
||||
retry: 2,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrometheusStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["observability", "prometheus-status"],
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #4f8cff;
|
||||
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||
/* Status semantic cues — single source of truth for Badges.
|
||||
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||
chart-4=destructive, chart-5=neutral-accent. */
|
||||
--color-chart-1: #4f8cff;
|
||||
@@ -62,7 +62,7 @@
|
||||
--color-border: #334155;
|
||||
--color-input: #334155;
|
||||
--color-ring: #4f8cff;
|
||||
/* Status / Grafana-link semantic cues — single source of truth for Badges.
|
||||
/* Status semantic cues — single source of truth for Badges.
|
||||
chart-1=info/brand, chart-2=success, chart-3=warning,
|
||||
chart-4=destructive, chart-5=neutral-accent. */
|
||||
--color-chart-1: #4f8cff;
|
||||
|
||||
@@ -21,13 +21,9 @@ describe("navEntries", () => {
|
||||
|
||||
it("returns all observability entries", () => {
|
||||
const entries = configuredNavEntries(
|
||||
new Set(["alertmanager", "grafana", "prometheus"]),
|
||||
new Set(["alertmanager", "prometheus"]),
|
||||
);
|
||||
expect(entries.map((e) => e.label)).toEqual([
|
||||
"Alertmanager",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
]);
|
||||
expect(entries.map((e) => e.label)).toEqual(["Alertmanager", "Prometheus"]);
|
||||
});
|
||||
|
||||
it("returns Backups + Authentik when configured", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
GanttChartSquare,
|
||||
Link2,
|
||||
Monitor,
|
||||
Server,
|
||||
Users,
|
||||
@@ -49,12 +48,6 @@ export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
|
||||
icon: Activity,
|
||||
path: "/services/alertmanager",
|
||||
},
|
||||
{
|
||||
serviceType: "grafana",
|
||||
label: "Grafana",
|
||||
icon: Link2,
|
||||
path: "/services/grafana",
|
||||
},
|
||||
{
|
||||
serviceType: "prometheus",
|
||||
label: "Prometheus",
|
||||
|
||||
@@ -12,7 +12,6 @@ describe("service registry", () => {
|
||||
it("registers the backend service types", () => {
|
||||
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
||||
"alertmanager",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
@@ -21,9 +20,6 @@ describe("service registry", () => {
|
||||
});
|
||||
|
||||
it("binds widget kinds per service", () => {
|
||||
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
||||
"link",
|
||||
]);
|
||||
expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
|
||||
"metric",
|
||||
"chart",
|
||||
@@ -43,12 +39,12 @@ describe("service registry", () => {
|
||||
expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]);
|
||||
});
|
||||
|
||||
it("resolves a service-bound widget via the services list", () => {
|
||||
it("resolves a prometheus metric widget via the services list", () => {
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "link",
|
||||
title: "Dashboard",
|
||||
widget_kind: "metric",
|
||||
title: "Metric",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
@@ -58,9 +54,9 @@ describe("service registry", () => {
|
||||
const services: ServiceInstance[] = [
|
||||
{
|
||||
id: "s1",
|
||||
service_type: "grafana",
|
||||
name: "Grafana",
|
||||
config: { base_url: "https://grafana.example.com" },
|
||||
service_type: "prometheus",
|
||||
name: "Prometheus",
|
||||
config: { base_url: "https://prometheus.example.com" },
|
||||
secrets_set: { api_key: true },
|
||||
enabled: true,
|
||||
created_at: 0,
|
||||
@@ -69,7 +65,7 @@ describe("service registry", () => {
|
||||
];
|
||||
const resolved = resolveWidget(widget, services);
|
||||
expect(resolved).toBeDefined();
|
||||
expect(resolved?.refreshIntervalMs).toBe(0);
|
||||
expect(resolved?.refreshIntervalMs).toBe(30_000);
|
||||
});
|
||||
|
||||
it("resolves an alertmanager active_alerts widget", () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
|
||||
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
|
||||
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget";
|
||||
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
|
||||
@@ -69,29 +68,6 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
grafana: {
|
||||
serviceType: "grafana",
|
||||
name: "Grafana",
|
||||
description: "Dashboards, metrics, and logs.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "link",
|
||||
name: "Dashboard link",
|
||||
description: "Deep-link to a Grafana dashboard or panel.",
|
||||
refreshIntervalMs: 0,
|
||||
defaultConfig: { dashboard_uid: "" },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
dashboard_uid: { type: "string" },
|
||||
panel_id: { type: "integer" },
|
||||
},
|
||||
required: ["dashboard_uid"],
|
||||
},
|
||||
component: GrafanaLinkWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
prometheus: {
|
||||
serviceType: "prometheus",
|
||||
name: "Prometheus",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -386,14 +386,6 @@ export interface AlertmanagerStatus {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface GrafanaStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
service_id: string;
|
||||
name: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface PrometheusStatus {
|
||||
up: boolean;
|
||||
version: string;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function GrafanaLinkWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const url = data?.data?.url as string | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-10 w-48" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : url ? (
|
||||
<Button asChild>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
Open Grafana
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No Grafana URL configured.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
|
||||
export { BackupsWidget } from "./BackupsWidget";
|
||||
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||
export { PrometheusChartWidget } from "./PrometheusChartWidget";
|
||||
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
|
||||
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
|
||||
|
||||
Reference in New Issue
Block a user