Admin customer detail: "Open Grafana drilldown" button

Wires the existing customer-drilldown dashboard JSON to the customer
detail page. Button opens ${Grafana.BaseUrl}/d/customer-drilldown
in a new tab with var-customer=<customer-id> pre-filled, kiosk mode,
light theme.

- Fetches /api/grafana/config (cached 5min, reuses the existing
  TanStack query key so GrafanaInfoCard's cache is shared).
- Button disabled with tooltip explaining when Grafana baseUrl isn't
  configured for the Admin stack (points to Settings → Grafana).
- Customer id is URI-encoded before interpolation (defence in depth —
  it's a UUID, but encodeURIComponent costs nothing).
- Dashboard UID hardcoded as 'customer-drilldown' to match the
  provisioned JSON. Renaming requires changing both together.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-18 10:37:48 +02:00
parent aaa522058e
commit 59c3f949d0

View File

@ -1,15 +1,20 @@
import { Card, Descriptions, Tabs, Table, Tag, Typography, Button, Space, Spin, Result } from 'antd';
import { Card, Descriptions, Tabs, Table, Tag, Typography, Button, Space, Spin, Result, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { ArrowLeftOutlined, LineChartOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import {
fetchFleetCustomerDetail, type FleetSite, type FleetDevice,
type FleetRecentMeasurement, type FleetIngestEvent,
} from '../api/fleet';
import { fetchGrafanaConfig } from '../api/grafana';
const { Text } = Typography;
// UID of the customer-drilldown dashboard provisioned in grafana/dashboards-admin/.
// Coordinated change: rename here and in the JSON together.
const CUSTOMER_DRILLDOWN_UID = 'customer-drilldown';
export function AdminCustomerDetailPage() {
const { id = '' } = useParams<{ id: string }>();
const navigate = useNavigate();
@ -18,6 +23,17 @@ export function AdminCustomerDetailPage() {
queryFn: () => fetchFleetCustomerDetail(id),
refetchInterval: 30_000,
});
const { data: grafana } = useQuery({
queryKey: ['grafana-config'],
queryFn: fetchGrafanaConfig,
staleTime: 5 * 60_000,
});
const drilldownUrl = (() => {
if (!data || !grafana?.baseUrl) return null;
const base = grafana.baseUrl.replace(/\/$/, '');
return `${base}/d/${encodeURIComponent(CUSTOMER_DRILLDOWN_UID)}?orgId=1&kiosk=tv&theme=light&var-customer=${encodeURIComponent(data.id)}`;
})();
if (isLoading) return <div style={{ textAlign: 'center', padding: 64 }}><Spin size="large" /></div>;
if (error || !data) {
@ -56,10 +72,28 @@ export function AdminCustomerDetailPage() {
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/customers')}>Customers</Button>
<Text strong style={{ fontSize: 20 }}>{data.code} · {data.name}</Text>
{data.isActive ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>}
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/customers')}>Customers</Button>
<Text strong style={{ fontSize: 20 }}>{data.code} · {data.name}</Text>
{data.isActive ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>}
</Space>
<Tooltip
title={
drilldownUrl
? `Opens the Grafana customer-drilldown dashboard with var-customer=${data.id}`
: 'Grafana base URL is not configured for this Admin stack — see Settings → Grafana.'
}
>
<Button
type="primary"
icon={<LineChartOutlined />}
disabled={!drilldownUrl}
onClick={() => drilldownUrl && window.open(drilldownUrl, '_blank', 'noopener')}
>
Open Grafana drilldown
</Button>
</Tooltip>
</Space>
<Card size="small">