New top-level portal/ project, peer to console/ and firmware/. Delivers a .NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no changes to existing console or firmware. Backend (src/Tau.Acuvim.Portal/): - .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout). - Single AppDbContext with identity / app / monitoring schemas. - MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation) + IdentityBootstrapper (seeded admin + branding) on startup. - Pure CostCalculator + DB-backed RateService for tariffs (effective-dated, TOU periods, VAT, fixed charges, per-municipality timezone). - BrandingService with logo upload to mounted volume. - Time-series ingest + bucketed query services (time_bucket aggregates, ON CONFLICT for idempotent re-delivery). - ConfigOverviewService with redaction-by-construction (passwords never in payload). - DataProtection keys persisted to /data/keys volume for cookie survival across container restarts. Frontend (frontend/): - React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query. - BrandingProvider + ThemedRoot for live re-themed white-labelling. - RequireAuth / RequireRole guards. - Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin), Settings tabs (Branding / Rates / Users / Grafana / App config). Infra: - Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose files. Three services per customer; Traefik subdomain + same-origin /grafana path-prefix routing wired with labels. - Grafana 11 with provisioned timescaledb datasource (uid pinned) and starter power-overview.json dashboard with device template variable. - Compose project name documented as lowercase (Compose v2 requirement). Tests (tests/Tau.Acuvim.Portal.Tests/): - xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap, VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl. Production refusal), TariffValidator, DayOfWeekFlag. - All passing locally against .NET 10. Docs: - README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer provisioning, secret rotation, backup, troubleshooting), TESTING.md (manual integration scenarios, frontend test scaffolding recipe). Production safety guards: - Refuses to start if Authentication:DefaultAdminPassword is unchanged default in Production. - Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in Production. - Prod Grafana ships with anonymous off and auth mode unset (three options documented in README Security) so iframe refuses to load until a deliberate prod auth choice is made. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
82 lines
2.7 KiB
TypeScript
82 lines
2.7 KiB
TypeScript
import { Card, Descriptions, Table, Typography, Button, Space, message } from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { fetchGrafanaConfig, type GrafanaDashboard } from '../../api/grafana';
|
|
|
|
const { Text, Paragraph } = Typography;
|
|
|
|
interface DashboardRow extends GrafanaDashboard {
|
|
url: string;
|
|
}
|
|
|
|
export function GrafanaInfoCard() {
|
|
const { data, isLoading } = useQuery({ queryKey: ['grafana-config'], queryFn: fetchGrafanaConfig });
|
|
|
|
const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? '';
|
|
const rows: DashboardRow[] = (data?.dashboards ?? []).map((d) => ({
|
|
...d,
|
|
url: baseUrl ? `${baseUrl}/d/${encodeURIComponent(d.uid)}?orgId=1&kiosk=tv&theme=light` : '',
|
|
}));
|
|
|
|
const copy = async (text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
message.success('Copied');
|
|
} catch {
|
|
message.error('Copy failed');
|
|
}
|
|
};
|
|
|
|
const columns: ColumnsType<DashboardRow> = [
|
|
{ title: 'Title', dataIndex: 'title', key: 'title' },
|
|
{ title: 'UID', dataIndex: 'uid', key: 'uid' },
|
|
{ title: 'Description', dataIndex: 'description', key: 'desc' },
|
|
{
|
|
title: 'Embed URL',
|
|
dataIndex: 'url',
|
|
key: 'url',
|
|
render: (url: string) => (
|
|
<Space>
|
|
<Text code style={{ fontSize: 11 }}>{url}</Text>
|
|
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(url)} />
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Card loading={isLoading}>
|
|
<Descriptions title="Grafana" column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
|
<Descriptions.Item label="Base URL">
|
|
<Space>
|
|
<Text code>{data?.baseUrl}</Text>
|
|
{data?.baseUrl && (
|
|
<Button size="small" icon={<LinkOutlined />} onClick={() => window.open(data.baseUrl, '_blank')}>
|
|
Open
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Default dashboard UID">
|
|
<Text code>{data?.defaultDashboardUid || '(unset)'}</Text>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="Dashboards configured">{data?.dashboards.length ?? 0}</Descriptions.Item>
|
|
</Descriptions>
|
|
|
|
<Paragraph type="secondary" style={{ fontSize: 12 }}>
|
|
Add dashboards by dropping JSON into <Text code>grafana/dashboards/</Text> and adding a matching entry
|
|
to <Text code>Grafana.Dashboards</Text> in configuration.
|
|
</Paragraph>
|
|
|
|
<Table<DashboardRow>
|
|
rowKey="uid"
|
|
size="small"
|
|
columns={columns}
|
|
dataSource={rows}
|
|
pagination={false}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|