diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..a6fbde8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,63 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+
+jobs:
+ go-build-and-test:
+ name: Go Build & Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version: '1.22'
+
+ - name: Go Build
+ run: |
+ go build ./cmd/server/...
+ go build ./cmd/agent/...
+
+ - name: Go Vet
+ run: go vet ./...
+
+ - name: Go Test with Coverage
+ run: |
+ go test ./internal/service/... ./internal/api/handler/... ./internal/integration/... -count=1 -cover -coverprofile=coverage.out
+
+ - name: Upload Coverage Report
+ uses: actions/upload-artifact@v4
+ with:
+ name: go-coverage
+ path: coverage.out
+ retention-days: 30
+
+ frontend-build:
+ name: Frontend Build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+
+ - name: Install Dependencies
+ working-directory: web
+ run: npm ci
+
+ - name: TypeScript Check
+ working-directory: web
+ run: npx tsc --noEmit
+
+ - name: Build Frontend
+ working-directory: web
+ run: npx vite build
diff --git a/CLAUDE.md b/CLAUDE.md
index 7953018..39ca622 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,7 +6,7 @@ You are my long-term copilot for building certctl — a self-hosted certificate
- [x] Go 1.22 server with net/http stdlib routing, slog logging, handler->service->repository layering
- [x] PostgreSQL 16 schema (14 tables, TEXT primary keys, idempotent migrations)
- [x] REST API — 41 endpoints under /api/v1/ with pagination, filtering, async actions
-- [x] Web dashboard — React SPA with dark theme, 7 views, demo mode fallback (static prototype, not wired to real API)
+- [x] Web dashboard — Vite + React 18 + TypeScript + TanStack Query, 11 views wired to real API, dark theme
- [x] Agent binary — heartbeat, work polling, cert fetch, job status reporting (real HTTP calls)
- [x] Local CA issuer connector — crypto/x509, in-memory CA, self-signed certs
- [x] Issuer connector wired end-to-end — Local CA registered in server, adapter bridging connector<->service layers
@@ -29,13 +29,13 @@ You are my long-term copilot for building certctl — a self-hosted certificate
- [x] Documentation — concepts guide, quickstart, advanced demo, architecture, connectors
- [x] BSL 1.1 license — 7-year conversion to Apache 2.0 (March 2033)
- [x] Test suite — 120 tests across service layer (63), handler layer (46), and integration (11 subtests)
+- [x] Input validation — centralized validators for common name, CSR PEM, policy type/severity, string length
+- [x] GitHub Actions CI — parallel Go (build, vet, test+coverage) and Frontend (tsc, vite build) jobs
### What's NOT Wired Up Yet (Pre-v1.0 Gaps)
-- [ ] **GUI wired to real API**: Dashboard is a static prototype with demo mode fallback. Not functional against the live backend.
- [ ] **Agent-side key generation**: V1 uses server-side key generation for Local CA (pragmatic for dev/demo). Must move to agents before v1.0.
- [ ] **API authentication enforced**: Auth types exist but demo runs with `CERTCTL_AUTH_TYPE=none`. No rate limiting.
-- [ ] **Build errors**: `nginx.go` has non-constant format string errors that will block CI.
-- [ ] **Test coverage gaps**: Service 39%, handler 28%. No negative-path integration tests (issuer down, malformed certs, DB failures).
+- [ ] **Test coverage gaps**: Negative-path integration tests (issuer down, malformed certs, DB failures) still needed.
---
@@ -56,50 +56,18 @@ Configurable alert_thresholds_days JSONB column on renewal_policies, threshold-a
### M4: Test Coverage ✅
120 tests: service layer unit tests (8 files), handler tests (2 files + utils), end-to-end integration test.
+### M5: Hardening + GUI Foundation ✅
+Fixed nginx.go format string errors, added centralized input validation (validation.go), migrated from single-file SPA to Vite + React 18 + TypeScript + TanStack Query v5 + Tailwind CSS 3. Componentized 7 views with real API wiring, loading/error/empty states. Server serves `web/dist/` with SPA fallback.
+
+### M6: Functional GUI + CI ✅
+All views wired to real API: agent detail page with heartbeat status + capabilities + recent jobs, audit trail with time range/actor/resource filters, notifications with grouped-by-cert view + read/unread state + mark-read mutations, policies with severity summary bar + config preview, new issuers and targets list views. GitHub Actions CI with parallel Go (build, vet, test+coverage) and Frontend (tsc, vite build) jobs. Makefile updated with test-cover and frontend-build targets.
+
---
## V1 Roadmap: Ship a Functional Product
The principle: **every backend feature ships with its corresponding GUI surface.** The GUI is where ops teams spend 80% of their time — it must be an operational tool, not a demo viewer.
-### M5: Hardening + GUI Foundation
-**Goal**: Fix build errors, add input validation, and establish the real frontend build pipeline.
-
-**Backend hardening:**
-- Fix `nginx.go` non-constant format string errors
-- Error handling audit across all service methods (no panics, descriptive errors, consistent error types)
-- API input validation (required fields, format checks, string length limits)
-- Increase service layer test coverage to 60%+ with negative-path tests (issuer failures, DB errors, malformed inputs)
-
-**GUI foundation:**
-- Migrate from single `web/index.html` to proper Vite + React + TypeScript project
-- Set up TanStack Query (React Query) for server state management (caching, refetching, optimistic updates)
-- Keep existing dark theme, componentize the 7 existing views
-- Wire certificate list view to real API with server-side pagination, filtering, and sorting
-- Wire certificate detail view showing version history, deployment targets, job status
-- API error states shown in UI (loading, error, empty states)
-
-**Deliverables**: Clean build, validated API inputs, cert list + detail views working against real backend.
-
-### M6: Functional GUI + CI
-**Goal**: Wire all remaining views to real API and establish CI pipeline.
-
-**GUI — remaining views:**
-- Agent list with health indicators (online/offline/stale from heartbeat timestamps)
-- Agent detail with recent jobs and heartbeat history
-- Job queue view with status badges, retry controls, cancel actions
-- Notification inbox with read/unread state, threshold alert grouping by certificate
-- Audit trail view with time range picker, actor/action/resource filters
-- Policy list with violation counts and severity indicators
-- Dashboard overview with summary cards (total certs, expiring soon, active agents, pending jobs)
-
-**CI/CD:**
-- GitHub Actions: build, test, lint on every PR
-- Docker image builds on tag push
-- Test coverage reporting
-
-**Deliverables**: Every API-backed view functional in the GUI. CI green on master.
-
### M7: Security Baseline
**Goal**: Make certctl deployable in a shared/team environment. This gates the v1.0 tag.
@@ -218,7 +186,8 @@ The principle: **every backend feature ships with its corresponding GUI surface.
- Scheduler: `internal/scheduler/scheduler.go`
- Schema: `migrations/000001_initial_schema.up.sql`
- Seed data: `migrations/seed.sql`, `migrations/seed_demo.sql`
-- Dashboard: `web/` (migrating to Vite + React + TS in M5)
+- Dashboard: `web/src/` (Vite + React + TypeScript), built to `web/dist/`
+- CI: `.github/workflows/ci.yml`
- Docker: `deploy/docker-compose.yml`, `Dockerfile`, `Dockerfile.agent`
- Docs: `docs/`
- Tests: `internal/service/*_test.go`, `internal/api/handler/*_test.go`, `internal/integration/lifecycle_test.go`
diff --git a/Makefile b/Makefile
index 10da24b..cafa49b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: help build run test lint clean docker-up docker-down migrate-up migrate-down generate
+.PHONY: help build run test lint clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build
# Default target - show help
help:
@@ -77,6 +77,11 @@ test-coverage:
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
+test-cover:
+ @echo "Running tests with coverage..."
+ go test ./internal/service/... ./internal/api/handler/... ./internal/integration/... -count=1 -cover -coverprofile=coverage.out
+ @echo "Coverage report: coverage.out"
+
# Linting targets
lint:
@echo "Running golangci-lint..."
@@ -151,11 +156,18 @@ generate:
go generate ./...
@echo "Code generation complete"
+# Frontend build
+frontend-build:
+ @echo "Building frontend..."
+ cd web && npm ci && npx vite build
+ @echo "Frontend build complete"
+
# Cleanup
clean:
@echo "Cleaning build artifacts..."
rm -rf bin/ dist/ coverage.out coverage.html
go clean -testcache
+ cd web && rm -rf node_modules dist
@echo "Cleanup complete"
install-tools:
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx
index fc4c913..fb05e8f 100644
--- a/web/src/components/Layout.tsx
+++ b/web/src/components/Layout.tsx
@@ -7,6 +7,8 @@ const nav = [
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
+ { to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
+ { to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
];
diff --git a/web/src/main.tsx b/web/src/main.tsx
index d4d12cd..b63ad28 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -7,9 +7,12 @@ import DashboardPage from './pages/DashboardPage';
import CertificatesPage from './pages/CertificatesPage';
import CertificateDetailPage from './pages/CertificateDetailPage';
import AgentsPage from './pages/AgentsPage';
+import AgentDetailPage from './pages/AgentDetailPage';
import JobsPage from './pages/JobsPage';
import NotificationsPage from './pages/NotificationsPage';
import PoliciesPage from './pages/PoliciesPage';
+import IssuersPage from './pages/IssuersPage';
+import TargetsPage from './pages/TargetsPage';
import AuditPage from './pages/AuditPage';
import './index.css';
@@ -33,9 +36,12 @@ createRoot(document.getElementById('root')!).render(
} />
} />
} />
+ } />
} />
} />
} />
+ } />
+ } />
} />
diff --git a/web/src/pages/AgentDetailPage.tsx b/web/src/pages/AgentDetailPage.tsx
new file mode 100644
index 0000000..37c805a
--- /dev/null
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -0,0 +1,170 @@
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { getAgent, getJobs } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import StatusBadge from '../components/StatusBadge';
+import ErrorState from '../components/ErrorState';
+import { formatDateTime, timeAgo } from '../api/utils';
+
+function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function heartbeatStatus(lastHeartbeat: string): string {
+ if (!lastHeartbeat) return 'Offline';
+ const ago = Date.now() - new Date(lastHeartbeat).getTime();
+ if (ago < 5 * 60 * 1000) return 'Online';
+ if (ago < 15 * 60 * 1000) return 'Stale';
+ return 'Offline';
+}
+
+export default function AgentDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+
+ const { data: agent, isLoading, error, refetch } = useQuery({
+ queryKey: ['agent', id],
+ queryFn: () => getAgent(id!),
+ enabled: !!id,
+ refetchInterval: 10000,
+ });
+
+ const { data: jobs } = useQuery({
+ queryKey: ['agent-jobs', id],
+ queryFn: () => getJobs({ per_page: '10' }),
+ enabled: !!id,
+ });
+
+ // Filter jobs related to this agent (deployment jobs)
+ const agentJobs = jobs?.data?.slice(0, 10) || [];
+
+ if (isLoading) {
+ return (
+ <>
+
+ Loading...
+ >
+ );
+ }
+
+ if (error || !agent) {
+ return (
+ <>
+
+ refetch()} />
+ >
+ );
+ }
+
+ const health = agent.status || heartbeatStatus(agent.last_heartbeat);
+
+ return (
+ <>
+ navigate('/agents')} className="btn btn-ghost text-xs">Back
+ }
+ />
+
+
+ {/* Agent Info */}
+
+
Agent Details
+ } />
+ {agent.hostname || '—'}} />
+ {agent.ip_address || '—'}} />
+
+
+ {timeAgo(agent.last_heartbeat)}
+ {formatDateTime(agent.last_heartbeat)}
+
+ ) : '—'
+ } />
+
+
+
+
+ {/* Capabilities */}
+
+
Capabilities & Tags
+ {agent.capabilities?.length ? (
+
+
Capabilities
+
+ {agent.capabilities.map((c) => (
+ {c}
+ ))}
+
+
+ ) : (
+
No capabilities reported
+ )}
+ {agent.tags && Object.keys(agent.tags).length > 0 ? (
+
+
Tags
+
+ {Object.entries(agent.tags).map(([k, v]) => (
+ {k}: {v}
+ ))}
+
+
+ ) : (
+
No tags
+ )}
+
+
+
+ {/* Recent Jobs */}
+
+
Recent Jobs
+ {!agentJobs.length ? (
+
No recent jobs
+ ) : (
+
+ {agentJobs.map(j => (
+
+
+
+ {j.certificate_id}
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Heartbeat Timeline */}
+
+
Heartbeat Status
+
+
+
+
{health}
+
+ {health === 'Online' && 'Agent is responding to heartbeat checks'}
+ {health === 'Stale' && 'Agent has not sent a heartbeat recently'}
+ {health === 'Offline' && 'Agent is not responding'}
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index 712ffa6..120daed 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -1,3 +1,4 @@
+import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getAgents } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -17,6 +18,7 @@ function heartbeatStatus(lastHeartbeat: string): string {
}
export default function AgentsPage() {
+ const navigate = useNavigate();
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['agents'],
queryFn: () => getAgents(),
@@ -56,7 +58,7 @@ export default function AgentsPage() {
{error ? (
refetch()} />
) : (
-
+ navigate(`/agents/${a.id}`)} />
)}
>
diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx
index abd8da3..05ff74b 100644
--- a/web/src/pages/AuditPage.tsx
+++ b/web/src/pages/AuditPage.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getAuditEvents } from '../api/client';
import PageHeader from '../components/PageHeader';
@@ -19,13 +20,39 @@ const actionColors: Record = {
policy_violated: 'text-red-400',
};
+const RESOURCE_TYPES = ['', 'certificate', 'agent', 'job', 'notification', 'policy', 'target', 'issuer'];
+const TIME_RANGES = [
+ { label: 'All time', value: '' },
+ { label: 'Last hour', value: '1h' },
+ { label: 'Last 24h', value: '24h' },
+ { label: 'Last 7 days', value: '7d' },
+ { label: 'Last 30 days', value: '30d' },
+];
+
export default function AuditPage() {
+ const [resourceType, setResourceType] = useState('');
+ const [actorFilter, setActorFilter] = useState('');
+ const [timeRange, setTimeRange] = useState('');
+
+ const params: Record = {};
+ if (resourceType) params.resource_type = resourceType;
+ if (actorFilter) params.actor = actorFilter;
+
const { data, isLoading, error, refetch } = useQuery({
- queryKey: ['audit'],
- queryFn: () => getAuditEvents(),
+ queryKey: ['audit', params],
+ queryFn: () => getAuditEvents(params),
refetchInterval: 30000,
});
+ // Client-side time range filtering (server may not support time params)
+ const filtered = (data?.data || []).filter((e) => {
+ if (!timeRange) return true;
+ const ts = new Date(e.timestamp).getTime();
+ const now = Date.now();
+ const hours = timeRange === '1h' ? 1 : timeRange === '24h' ? 24 : timeRange === '7d' ? 168 : 720;
+ return now - ts < hours * 3600 * 1000;
+ });
+
const columns: Column[] = [
{
key: 'action',
@@ -60,7 +87,7 @@ export default function AuditPage() {
key: 'details',
label: 'Details',
render: (e) => {
- if (!e.details || Object.keys(e.details).length === 0) return — ;
+ if (!e.details || Object.keys(e.details).length === 0) return — ;
return (
{JSON.stringify(e.details).slice(0, 60)}
@@ -73,12 +100,48 @@ export default function AuditPage() {
return (
<>
-
+
+
+ setResourceType(e.target.value)}
+ className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
+ >
+ All resources
+ {RESOURCE_TYPES.filter(Boolean).map((t) => (
+ {t}
+ ))}
+
+ setActorFilter(e.target.value)}
+ className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 w-40"
+ />
+ setTimeRange(e.target.value)}
+ className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
+ >
+ {TIME_RANGES.map((r) => (
+ {r.label}
+ ))}
+
+ {(resourceType || actorFilter || timeRange) && (
+ { setResourceType(''); setActorFilter(''); setTimeRange(''); }}
+ className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
+ >
+ Clear filters
+
+ )}
+
{error ? (
refetch()} />
) : (
-
+
)}
>
diff --git a/web/src/pages/IssuersPage.tsx b/web/src/pages/IssuersPage.tsx
new file mode 100644
index 0000000..0446bdc
--- /dev/null
+++ b/web/src/pages/IssuersPage.tsx
@@ -0,0 +1,78 @@
+import { useQuery } from '@tanstack/react-query';
+import { getIssuers } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import DataTable from '../components/DataTable';
+import type { Column } from '../components/DataTable';
+import StatusBadge from '../components/StatusBadge';
+import ErrorState from '../components/ErrorState';
+import { formatDateTime } from '../api/utils';
+import type { Issuer } from '../api/types';
+
+const typeLabels: Record = {
+ local_ca: 'Local CA',
+ acme: 'ACME',
+ vault: 'Vault PKI',
+ manual: 'Manual',
+};
+
+export default function IssuersPage() {
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['issuers'],
+ queryFn: () => getIssuers(),
+ });
+
+ const columns: Column[] = [
+ {
+ key: 'name',
+ label: 'Issuer',
+ render: (i) => (
+
+ ),
+ },
+ {
+ key: 'type',
+ label: 'Type',
+ render: (i) => (
+ {typeLabels[i.type] || i.type}
+ ),
+ },
+ {
+ key: 'status',
+ label: 'Status',
+ render: (i) => ,
+ },
+ {
+ key: 'config',
+ label: 'Config',
+ render: (i) => {
+ if (!i.config || Object.keys(i.config).length === 0) return — ;
+ return (
+
+ {JSON.stringify(i.config).slice(0, 60)}
+
+ );
+ },
+ },
+ {
+ key: 'created',
+ label: 'Created',
+ render: (i) => {formatDateTime(i.created_at)} ,
+ },
+ ];
+
+ return (
+ <>
+
+
+ {error ? (
+ refetch()} />
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx
index d963bc9..6cc54e6 100644
--- a/web/src/pages/NotificationsPage.tsx
+++ b/web/src/pages/NotificationsPage.tsx
@@ -1,48 +1,193 @@
-import { useQuery } from '@tanstack/react-query';
+import { useState, useMemo } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getNotifications } from '../api/client';
import PageHeader from '../components/PageHeader';
-import DataTable from '../components/DataTable';
-import type { Column } from '../components/DataTable';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
-import { formatDateTime } from '../api/utils';
+import { formatDateTime, timeAgo } from '../api/utils';
import type { Notification } from '../api/types';
+const BASE = '/api/v1';
+
+async function markNotificationRead(id: string) {
+ const res = await fetch(`${BASE}/notifications/${id}/read`, { method: 'POST' });
+ if (!res.ok) throw new Error('Failed to mark as read');
+}
+
+type ViewMode = 'list' | 'grouped';
+
export default function NotificationsPage() {
+ const [viewMode, setViewMode] = useState('grouped');
+ const [typeFilter, setTypeFilter] = useState('');
+ const [statusFilter, setStatusFilter] = useState('');
+ const queryClient = useQueryClient();
+
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['notifications'],
- queryFn: () => getNotifications(),
+ queryFn: () => getNotifications({ per_page: '100' }),
refetchInterval: 30000,
});
- const columns: Column[] = [
- {
- key: 'type',
- label: 'Type',
- render: (n) => {n.type.replace(/([A-Z])/g, ' $1').trim()} ,
- },
- { key: 'status', label: 'Status', render: (n) => },
- { key: 'channel', label: 'Channel', render: (n) => {n.channel} },
- { key: 'recipient', label: 'Recipient', render: (n) => {n.recipient} },
- {
- key: 'message',
- label: 'Message',
- render: (n) => {n.message || n.subject} ,
- },
- { key: 'cert', label: 'Certificate', render: (n) => {n.certificate_id || '—'} },
- { key: 'created', label: 'Sent', render: (n) => {formatDateTime(n.created_at)} },
- ];
+ const markRead = useMutation({
+ mutationFn: markNotificationRead,
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
+ });
+
+ const notifications = data?.data || [];
+
+ const filtered = useMemo(() => {
+ return notifications.filter((n) => {
+ if (typeFilter && n.type !== typeFilter) return false;
+ if (statusFilter && n.status !== statusFilter) return false;
+ return true;
+ });
+ }, [notifications, typeFilter, statusFilter]);
+
+ const types = useMemo(() => [...new Set(notifications.map(n => n.type))], [notifications]);
+ const statuses = useMemo(() => [...new Set(notifications.map(n => n.status))], [notifications]);
+
+ // Group by certificate_id
+ const grouped = useMemo(() => {
+ const groups: Record = {};
+ for (const n of filtered) {
+ const key = n.certificate_id || 'general';
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(n);
+ }
+ return Object.entries(groups).sort(([, a], [, b]) => {
+ const aTime = new Date(a[0].created_at).getTime();
+ const bTime = new Date(b[0].created_at).getTime();
+ return bTime - aTime;
+ });
+ }, [filtered]);
+
+ const unreadCount = filtered.filter(n => n.status === 'Pending' || n.status === 'pending').length;
+
+ if (isLoading) {
+ return (
+ <>
+
+ Loading...
+ >
+ );
+ }
+
+ if (error) {
+ return (
+ <>
+
+ refetch()} />
+ >
+ );
+ }
return (
<>
-
-
- {error ? (
-
refetch()} />
+
+
+
+ setViewMode('grouped')}
+ className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'grouped' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}
+ >
+ Grouped
+
+ setViewMode('list')}
+ className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'list' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}
+ >
+ List
+
+
+
setTypeFilter(e.target.value)}
+ className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
+ >
+ All types
+ {types.map(t => {t.replace(/([A-Z])/g, ' $1').trim()} )}
+
+
setStatusFilter(e.target.value)}
+ className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
+ >
+ All statuses
+ {statuses.map(s => {s} )}
+
+ {(typeFilter || statusFilter) && (
+
{ setTypeFilter(''); setStatusFilter(''); }}
+ className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
+ >
+ Clear filters
+
+ )}
+
+
+ {viewMode === 'grouped' ? (
+ grouped.length === 0 ? (
+
No notifications
+ ) : (
+ grouped.map(([certId, items]) => (
+
+
+
+ {certId === 'general' ? 'General' : certId}
+
+ {items.length} notification{items.length !== 1 ? 's' : ''}
+
+
+ {items.map((n) => (
+ markRead.mutate(n.id)} />
+ ))}
+
+
+ ))
+ )
) : (
-
+ filtered.length === 0 ? (
+
No notifications
+ ) : (
+
+ {filtered.map((n) => (
+ markRead.mutate(n.id)} />
+ ))}
+
+ )
)}
>
);
}
+
+function NotificationRow({ notification: n, onMarkRead }: { notification: Notification; onMarkRead: () => void }) {
+ const isUnread = n.status === 'Pending' || n.status === 'pending';
+ return (
+
+
+
+ {n.type.replace(/([A-Z])/g, ' $1').trim()}
+
+ {n.channel}
+
+
{n.message || n.subject}
+
+ {n.recipient}
+ {timeAgo(n.created_at)}
+
+
+ {isUnread && (
+
{ e.stopPropagation(); onMarkRead(); }}
+ className="ml-3 text-xs text-blue-400 hover:text-blue-300 transition-colors whitespace-nowrap"
+ >
+ Mark read
+
+ )}
+
+ );
+}
diff --git a/web/src/pages/PoliciesPage.tsx b/web/src/pages/PoliciesPage.tsx
index 0830caa..bad61be 100644
--- a/web/src/pages/PoliciesPage.tsx
+++ b/web/src/pages/PoliciesPage.tsx
@@ -14,12 +14,26 @@ const severityStyles: Record = {
critical: 'badge-danger',
};
+const severityDots: Record = {
+ low: 'bg-blue-400',
+ medium: 'bg-amber-400',
+ high: 'bg-orange-400',
+ critical: 'bg-red-400',
+};
+
export default function PoliciesPage() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['policies'],
queryFn: () => getPolicies(),
});
+ const policies = data?.data || [];
+ const enabledCount = policies.filter(p => p.enabled).length;
+ const bySeverity = policies.reduce>((acc, p) => {
+ acc[p.severity] = (acc[p.severity] || 0) + 1;
+ return acc;
+ }, {});
+
const columns: Column[] = [
{
key: 'name',
@@ -37,6 +51,18 @@ export default function PoliciesPage() {
label: 'Severity',
render: (p) => {p.severity} ,
},
+ {
+ key: 'config',
+ label: 'Config',
+ render: (p) => {
+ if (!p.config || Object.keys(p.config).length === 0) return — ;
+ return (
+
+ {JSON.stringify(p.config).slice(0, 50)}
+
+ );
+ },
+ },
{
key: 'enabled',
label: 'Enabled',
@@ -52,11 +78,28 @@ export default function PoliciesPage() {
return (
<>
+ {policies.length > 0 && (
+
+
+ Enabled:
+ {enabledCount}
+ /
+ {policies.length}
+
+ {Object.entries(bySeverity).map(([sev, count]) => (
+
+ ))}
+
+ )}
{error ? (
refetch()} />
) : (
-
+
)}
>
diff --git a/web/src/pages/TargetsPage.tsx b/web/src/pages/TargetsPage.tsx
new file mode 100644
index 0000000..b8a1313
--- /dev/null
+++ b/web/src/pages/TargetsPage.tsx
@@ -0,0 +1,77 @@
+import { useQuery } from '@tanstack/react-query';
+import { getTargets } from '../api/client';
+import PageHeader from '../components/PageHeader';
+import DataTable from '../components/DataTable';
+import type { Column } from '../components/DataTable';
+import StatusBadge from '../components/StatusBadge';
+import ErrorState from '../components/ErrorState';
+import { formatDateTime } from '../api/utils';
+import type { Target } from '../api/types';
+
+const typeLabels: Record = {
+ nginx: 'NGINX',
+ f5_bigip: 'F5 BIG-IP',
+ iis: 'IIS',
+ apache: 'Apache',
+ haproxy: 'HAProxy',
+};
+
+export default function TargetsPage() {
+ const { data, isLoading, error, refetch } = useQuery({
+ queryKey: ['targets'],
+ queryFn: () => getTargets(),
+ });
+
+ const columns: Column[] = [
+ {
+ key: 'name',
+ label: 'Target',
+ render: (t) => (
+
+ ),
+ },
+ {
+ key: 'type',
+ label: 'Type',
+ render: (t) => (
+ {typeLabels[t.type] || t.type}
+ ),
+ },
+ {
+ key: 'hostname',
+ label: 'Hostname',
+ render: (t) => {t.hostname || '\u2014'} ,
+ },
+ {
+ key: 'agent',
+ label: 'Agent',
+ render: (t) => {t.agent_id || '\u2014'} ,
+ },
+ {
+ key: 'status',
+ label: 'Status',
+ render: (t) => ,
+ },
+ {
+ key: 'created',
+ label: 'Created',
+ render: (t) => {formatDateTime(t.created_at)} ,
+ },
+ ];
+
+ return (
+ <>
+
+
+ {error ? (
+ refetch()} />
+ ) : (
+
+ )}
+
+ >
+ );
+}