Security hardening: auth encapsulation, pool restriction, rate limiting, invites, async webhooks
Some checks failed
CI — P2 Drift (Go + Node) / agent (push) Successful in 43s
CI — P2 Drift (Go + Node) / saas (push) Failing after 5s
CI — P3 Alert / test (push) Failing after 4s
CI — P4 Portal / test (push) Failing after 4s
CI — P5 Cost / test (push) Failing after 4s
CI — P6 Run / saas (push) Failing after 5s
CI — P2 Drift (Go + Node) / build-push (push) Failing after 7s
CI — P3 Alert / build-push (push) Has been skipped
CI — P4 Portal / build-push (push) Has been skipped
CI — P5 Cost / build-push (push) Has been skipped
CI — P6 Run / build-push (push) Failing after 5s

Phase 1 (Security Critical):
- Auth plugin encapsulation: replaced global addHook with Fastify plugin scope
- Removed startsWith URL matching; public routes registered outside auth scope
- JWT verify now enforces algorithms: ['HS256'] (prevents algorithm confusion)
- Raw pool no longer exported from db.ts; systemQuery() + getPoolForAuth() instead
- withTenant() remains primary tenant-scoped query path

Phase 2 (Infrastructure):
- docker-compose.yml: all secrets via env var substitution (${VAR:-default})
- Per-service Postgres users (dd0c_drift, dd0c_alert, etc.) in docker-init-db.sh
- .env.example with all configurable secrets
- build-push.sh uses $REGISTRY_PASSWORD instead of hardcoded
- .gitignore excludes .env files
- @fastify/rate-limit: 100 req/min global, 5/min login, 3/min signup
- CORS_ORIGIN default changed from '*' to 'http://localhost:5173'

Phase 3 (Product):
- Team invite flow: tenant_invites table, POST /invite, GET /invites, DELETE /invites/:id
- Signup accepts optional invite_token to join existing tenant
- Async webhook ingestion (P3): LPUSH to Redis, BRPOP worker, dead-letter queue

Console:
- All 5 product modules wired: drift, alert, portal, cost, run
- PageHeader accepts children prop
- 71 modules, 70KB gzipped production build

All 6 projects compile clean (tsc --noEmit).
This commit is contained in:
2026-03-02 23:53:55 +00:00
parent be3f37cfdd
commit eb953cdea5
67 changed files with 4461 additions and 410 deletions

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { EmptyState } from '../../shared/EmptyState';
import {
fetchIncidentSummary,
fetchIncidents,
type Incident,
type IncidentSummary,
type Severity,
type IncidentStatus,
} from './api';
const severityColor: Record<Severity, 'red' | 'yellow' | 'blue' | 'cyan'> = {
critical: 'red',
high: 'yellow',
warning: 'yellow',
info: 'blue',
};
// Use orange-ish for 'high' — override via custom class since Badge only has named colors
// We'll map high→yellow and warning→yellow but label them differently
const severityBadgeColor = (s: Severity): 'red' | 'yellow' | 'blue' => {
if (s === 'critical') return 'red';
if (s === 'high' || s === 'warning') return 'yellow';
return 'blue';
};
const statusBadgeColor = (s: IncidentStatus): 'red' | 'cyan' | 'green' => {
if (s === 'open') return 'red';
if (s === 'acknowledged') return 'cyan';
return 'green';
};
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export function AlertDashboard() {
const navigate = useNavigate();
const [summary, setSummary] = useState<IncidentSummary | null>(null);
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
Promise.all([fetchIncidentSummary(), fetchIncidents()])
.then(([sum, inc]) => {
if (!cancelled) {
setSummary(sum);
setIncidents(inc);
}
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const columns = [
{
key: 'severity',
header: 'Severity',
sortable: true,
render: (row: Incident) => (
<Badge color={severityBadgeColor(row.severity)}>
{row.severity}
</Badge>
),
},
{
key: 'title',
header: 'Incident',
sortable: true,
render: (row: Incident) => (
<span className="text-white font-medium">{row.title}</span>
),
},
{
key: 'status',
header: 'Status',
sortable: true,
render: (row: Incident) => (
<Badge color={statusBadgeColor(row.status)}>
{row.status}
</Badge>
),
},
{
key: 'source',
header: 'Source',
sortable: true,
render: (row: Incident) => (
<span className="text-gray-400">{row.source}</span>
),
},
{
key: 'count',
header: 'Count',
sortable: true,
render: (row: Incident) => (
<span className="text-gray-400 tabular-nums">{row.count}</span>
),
},
{
key: 'lastSeen',
header: 'Last Seen',
sortable: true,
render: (row: Incident) => (
<span className="text-gray-500 text-xs">{formatTime(row.lastSeen)}</span>
),
},
];
return (
<div>
<PageHeader
title="Alert Intelligence"
description="Incident management and alert correlation"
>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/alert/notifications')}>
Notifications
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/alert/webhooks')}>
Webhooks
</Button>
</div>
</PageHeader>
{/* Summary stats */}
{!loading && !error && summary && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[
{ label: 'Total Incidents', value: summary.total, color: 'text-white' },
{ label: 'Open', value: summary.open, color: 'text-red-400' },
{ label: 'Acknowledged', value: summary.acknowledged, color: 'text-cyan-400' },
{ label: 'Resolved', value: summary.resolved, color: 'text-emerald-400' },
].map((stat) => (
<div key={stat.label} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">{stat.label}</p>
<p className={`text-2xl font-bold mt-1 tabular-nums ${stat.color}`}>{stat.value}</p>
</div>
))}
</div>
)}
<Card noPadding>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading incidents</span>
</div>
</div>
)}
{error && (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
Failed to load incidents: {error}
</div>
</div>
)}
{!loading && !error && incidents.length === 0 && (
<EmptyState
icon="🔔"
title="No incidents"
description="No incidents have been reported yet. Configure webhook integrations to start receiving alerts."
/>
)}
{!loading && !error && incidents.length > 0 && (
<Table
columns={columns}
data={incidents}
rowKey={(row) => row.id}
onRowClick={(row) => navigate(`/alert/incidents/${row.id}`)}
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,229 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { EmptyState } from '../../shared/EmptyState';
import {
fetchIncident,
updateIncidentStatus,
type IncidentDetail as IncidentDetailType,
type Severity,
type IncidentStatus,
} from './api';
const severityBadgeColor = (s: Severity): 'red' | 'yellow' | 'blue' => {
if (s === 'critical') return 'red';
if (s === 'high' || s === 'warning') return 'yellow';
return 'blue';
};
const statusBadgeColor = (s: IncidentStatus): 'red' | 'cyan' | 'green' => {
if (s === 'open') return 'red';
if (s === 'acknowledged') return 'cyan';
return 'green';
};
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString();
}
export function AlertDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [incident, setIncident] = useState<IncidentDetailType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [updating, setUpdating] = useState(false);
useEffect(() => {
if (!id) return;
let cancelled = false;
setLoading(true);
fetchIncident(id)
.then((data) => {
if (!cancelled) setIncident(data);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [id]);
const handleStatusChange = async (status: IncidentStatus) => {
if (!id || !incident) return;
setUpdating(true);
try {
await updateIncidentStatus(id, status);
setIncident({ ...incident, status });
} catch (err: any) {
setError(err.message);
} finally {
setUpdating(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading incident</span>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
Failed to load incident: {error}
</div>
<Button variant="secondary" size="sm" className="mt-4" onClick={() => navigate('/alert')}>
Back to incidents
</Button>
</div>
);
}
if (!incident) {
return (
<EmptyState
icon="🔍"
title="Incident not found"
description="This incident may have been deleted or does not exist."
/>
);
}
return (
<div>
<PageHeader
title={incident.title}
description={`Incident ${incident.id}`}
>
<Button variant="secondary" size="sm" onClick={() => navigate('/alert')}>
Back
</Button>
</PageHeader>
{/* Metadata */}
<Card>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Severity</p>
<div className="mt-1">
<Badge color={severityBadgeColor(incident.severity)}>{incident.severity}</Badge>
</div>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Status</p>
<div className="mt-1">
<Badge color={statusBadgeColor(incident.status)}>{incident.status}</Badge>
</div>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Source</p>
<p className="text-white text-sm mt-1">{incident.source}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Alert Count</p>
<p className="text-white text-sm mt-1 tabular-nums">{incident.count}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Fingerprint</p>
<p className="text-gray-400 text-xs mt-1 font-mono break-all">{incident.fingerprint}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">First Seen</p>
<p className="text-gray-400 text-sm mt-1">{formatTimestamp(incident.firstSeen)}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Last Seen</p>
<p className="text-gray-400 text-sm mt-1">{formatTimestamp(incident.lastSeen)}</p>
</div>
</div>
</Card>
{/* Status actions */}
<div className="flex gap-2 mt-4 mb-6">
{incident.status !== 'acknowledged' && (
<Button
variant="secondary"
size="sm"
disabled={updating}
onClick={() => handleStatusChange('acknowledged')}
>
Acknowledge
</Button>
)}
{incident.status !== 'resolved' && (
<Button
variant="primary"
size="sm"
disabled={updating}
onClick={() => handleStatusChange('resolved')}
>
Resolve
</Button>
)}
{incident.status === 'resolved' && (
<Button
variant="danger"
size="sm"
disabled={updating}
onClick={() => handleStatusChange('open')}
>
Reopen
</Button>
)}
</div>
{/* Alert timeline */}
<h3 className="text-white font-semibold text-sm mb-3">Alert Timeline</h3>
<Card noPadding>
{incident.alerts.length === 0 ? (
<EmptyState
icon="📭"
title="No alerts"
description="No correlated alerts found for this incident."
/>
) : (
<div className="divide-y divide-gray-800">
{incident.alerts.map((alert) => (
<div key={alert.id} className="px-4 py-3 flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
<div className="w-2 h-2 rounded-full bg-indigo-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm">{alert.message}</p>
<div className="flex items-center gap-3 mt-1">
<span className="text-gray-500 text-xs">{formatTimestamp(alert.timestamp)}</span>
<span className="text-gray-600 text-xs">via {alert.source}</span>
</div>
{alert.labels && Object.keys(alert.labels).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{Object.entries(alert.labels).map(([k, v]) => (
<span key={k} className="text-xs bg-gray-800 text-gray-400 px-1.5 py-0.5 rounded">
{k}={v}
</span>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,185 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Button } from '../../shared/Button';
import {
fetchNotificationConfig,
updateNotificationConfig,
type NotificationSettings,
type Severity,
} from './api';
const severityOptions: Severity[] = ['critical', 'high', 'warning', 'info'];
export function NotificationConfig() {
const navigate = useNavigate();
const [config, setConfig] = useState<NotificationSettings>({
webhook_url: '',
slack_channel: '',
email: '',
pagerduty_key: '',
severity_threshold: 'warning',
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
let cancelled = false;
fetchNotificationConfig()
.then((data) => {
if (!cancelled) setConfig(data);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccess(false);
try {
await updateNotificationConfig(config);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const updateField = (field: keyof NotificationSettings, value: string) => {
setConfig((prev) => ({ ...prev, [field]: value }));
};
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading configuration</span>
</div>
</div>
);
}
return (
<div>
<PageHeader
title="Notification Settings"
description="Configure how you receive incident notifications"
>
<Button variant="secondary" size="sm" onClick={() => navigate('/alert')}>
Back
</Button>
</PageHeader>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm mb-4">
{error}
</div>
)}
{success && (
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3 text-emerald-400 text-sm mb-4">
Configuration saved successfully.
</div>
)}
<Card>
<div className="space-y-5">
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
Webhook URL
</label>
<input
type="url"
value={config.webhook_url}
onChange={(e) => updateField('webhook_url', e.target.value)}
placeholder="https://hooks.example.com/alerts"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
Slack Channel
</label>
<input
type="text"
value={config.slack_channel}
onChange={(e) => updateField('slack_channel', e.target.value)}
placeholder="#incidents"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
Email
</label>
<input
type="email"
value={config.email}
onChange={(e) => updateField('email', e.target.value)}
placeholder="oncall@example.com"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
PagerDuty Integration Key
</label>
<input
type="text"
value={config.pagerduty_key}
onChange={(e) => updateField('pagerduty_key', e.target.value)}
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono"
/>
</div>
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
Severity Threshold
</label>
<p className="text-gray-600 text-xs mb-2">Only notify for incidents at or above this severity.</p>
<div className="flex gap-2">
{severityOptions.map((sev) => (
<button
key={sev}
onClick={() => updateField('severity_threshold', sev)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
config.severity_threshold === sev
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
{sev}
</button>
))}
</div>
</div>
<div className="pt-2">
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Configuration'}
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,254 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Button } from '../../shared/Button';
import { Modal } from '../../shared/Modal';
import { EmptyState } from '../../shared/EmptyState';
import {
fetchWebhookSecrets,
upsertWebhookSecret,
deleteWebhookSecret,
type WebhookSecret,
} from './api';
const knownProviders = ['Datadog', 'PagerDuty', 'OpsGenie', 'Grafana'];
function maskSecret(secret: string): string {
if (secret.length <= 8) return '••••••••';
return secret.slice(0, 4) + '••••••••' + secret.slice(-4);
}
function formatTimestamp(iso: string): string {
return new Date(iso).toLocaleString();
}
export function WebhookSecrets() {
const navigate = useNavigate();
const [secrets, setSecrets] = useState<WebhookSecret[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [editProvider, setEditProvider] = useState('');
const [editSecret, setEditSecret] = useState('');
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const loadSecrets = async () => {
setLoading(true);
setError(null);
try {
const data = await fetchWebhookSecrets();
setSecrets(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSecrets();
}, []);
const openAdd = () => {
setEditProvider('');
setEditSecret('');
setModalOpen(true);
};
const openEdit = (ws: WebhookSecret) => {
setEditProvider(ws.provider);
setEditSecret('');
setModalOpen(true);
};
const handleSave = async () => {
if (!editProvider.trim() || !editSecret.trim()) return;
setSaving(true);
try {
await upsertWebhookSecret(editProvider.trim(), editSecret.trim());
setModalOpen(false);
await loadSecrets();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleDelete = async (provider: string) => {
setDeleting(provider);
try {
await deleteWebhookSecret(provider);
await loadSecrets();
} catch (err: any) {
setError(err.message);
} finally {
setDeleting(null);
}
};
const columns = [
{
key: 'provider',
header: 'Provider',
sortable: true,
render: (row: WebhookSecret) => (
<span className="text-white font-medium">{row.provider}</span>
),
},
{
key: 'secret',
header: 'Secret',
render: (row: WebhookSecret) => (
<span className="text-gray-400 font-mono text-xs">{maskSecret(row.secret)}</span>
),
},
{
key: 'updated_at',
header: 'Updated',
sortable: true,
render: (row: WebhookSecret) => (
<span className="text-gray-500 text-xs">{formatTimestamp(row.updated_at)}</span>
),
},
{
key: 'actions',
header: '',
render: (row: WebhookSecret) => (
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>
Edit
</Button>
<Button
variant="danger"
size="sm"
disabled={deleting === row.provider}
onClick={(e) => { e.stopPropagation(); handleDelete(row.provider); }}
>
{deleting === row.provider ? 'Deleting…' : 'Delete'}
</Button>
</div>
),
},
];
return (
<div>
<PageHeader
title="Webhook Secrets"
description="Manage signing secrets for monitoring integrations"
>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => navigate('/alert')}>
Back
</Button>
<Button size="sm" onClick={openAdd}>
Add Secret
</Button>
</div>
</PageHeader>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm mb-4">
{error}
</div>
)}
<Card noPadding>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading secrets</span>
</div>
</div>
)}
{!loading && !error && secrets.length === 0 && (
<EmptyState
icon="🔑"
title="No webhook secrets"
description="Add signing secrets for your monitoring providers to verify incoming webhooks."
/>
)}
{!loading && !error && secrets.length > 0 && (
<Table
columns={columns}
data={secrets}
rowKey={(row) => row.provider}
/>
)}
</Card>
{/* Add/Edit Modal */}
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editProvider ? `Edit ${editProvider} Secret` : 'Add Webhook Secret'}>
<div className="p-5 space-y-4">
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
Provider
</label>
{editProvider ? (
<p className="text-white text-sm">{editProvider}</p>
) : (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{knownProviders.map((p) => (
<button
key={p}
onClick={() => setEditProvider(p)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
editProvider === p
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
}`}
>
{p}
</button>
))}
</div>
<input
type="text"
value={editProvider}
onChange={(e) => setEditProvider(e.target.value)}
placeholder="Or type a custom provider name"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
)}
</div>
<div>
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
Signing Secret
</label>
<input
type="password"
value={editSecret}
onChange={(e) => setEditSecret(e.target.value)}
placeholder="Enter signing secret"
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono"
/>
</div>
<div className="flex gap-2 pt-2">
<Button onClick={handleSave} disabled={saving || !editProvider.trim() || !editSecret.trim()}>
{saving ? 'Saving…' : 'Save'}
</Button>
<Button variant="secondary" onClick={() => setModalOpen(false)}>
Cancel
</Button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { apiFetch } from '../../shell/api';
// --- Types ---
export interface IncidentSummary {
total: number;
open: number;
acknowledged: number;
resolved: number;
}
export type Severity = 'critical' | 'high' | 'warning' | 'info';
export type IncidentStatus = 'open' | 'acknowledged' | 'resolved';
export interface Incident {
id: string;
title: string;
severity: Severity;
status: IncidentStatus;
source: string;
fingerprint: string;
firstSeen: string;
lastSeen: string;
count: number;
}
export interface IncidentDetail extends Incident {
alerts: AlertEvent[];
}
export interface AlertEvent {
id: string;
timestamp: string;
source: string;
message: string;
labels?: Record<string, string>;
}
export interface NotificationSettings {
webhook_url: string;
slack_channel: string;
email: string;
pagerduty_key: string;
severity_threshold: Severity;
}
export interface WebhookSecret {
provider: string;
secret: string;
created_at: string;
updated_at: string;
}
// --- API calls ---
export async function fetchIncidentSummary(): Promise<IncidentSummary> {
return apiFetch<IncidentSummary>('/api/v1/incidents/summary');
}
export async function fetchIncidents(): Promise<Incident[]> {
return apiFetch<Incident[]>('/api/v1/incidents');
}
export async function fetchIncident(id: string): Promise<IncidentDetail> {
return apiFetch<IncidentDetail>(`/api/v1/incidents/${encodeURIComponent(id)}`);
}
export async function updateIncidentStatus(id: string, status: IncidentStatus): Promise<void> {
await apiFetch(`/api/v1/incidents/${encodeURIComponent(id)}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
}
export async function fetchNotificationConfig(): Promise<NotificationSettings> {
return apiFetch<NotificationSettings>('/api/v1/notifications/config');
}
export async function updateNotificationConfig(config: NotificationSettings): Promise<void> {
await apiFetch('/api/v1/notifications/config', {
method: 'PUT',
body: JSON.stringify(config),
});
}
export async function fetchWebhookSecrets(): Promise<WebhookSecret[]> {
return apiFetch<WebhookSecret[]>('/api/v1/webhooks/secrets');
}
export async function upsertWebhookSecret(provider: string, secret: string): Promise<void> {
await apiFetch('/api/v1/webhooks/secrets', {
method: 'PUT',
body: JSON.stringify({ provider, secret }),
});
}
export async function deleteWebhookSecret(provider: string): Promise<void> {
await apiFetch(`/api/v1/webhooks/secrets/${encodeURIComponent(provider)}`, {
method: 'DELETE',
});
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import type { ModuleManifest } from '../drift/manifest.js';
import { AlertDashboard } from './AlertDashboard';
import { AlertDetail } from './AlertDetail';
import { NotificationConfig } from './NotificationConfig';
import { WebhookSecrets } from './WebhookSecrets';
export const alertManifest: ModuleManifest = {
id: 'alert',
name: 'Alert Intelligence',
icon: '🔔',
path: '/alert',
entitlement: 'alert',
routes: [
{ path: 'alert', element: <AlertDashboard /> },
{ path: 'alert/incidents/:id', element: <AlertDetail /> },
{ path: 'alert/notifications', element: <NotificationConfig /> },
{ path: 'alert/webhooks', element: <WebhookSecrets /> },
],
};

View File

@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Button } from '../../shared/Button';
import { Modal } from '../../shared/Modal';
import { EmptyState } from '../../shared/EmptyState';
import { fetchBaselines, updateBaseline, type Baseline } from './api';
function formatCurrency(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function CostBaselines() {
const navigate = useNavigate();
const [baselines, setBaselines] = useState<Baseline[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editItem, setEditItem] = useState<Baseline | null>(null);
const [editForm, setEditForm] = useState({ monthly_budget: '', alert_threshold_pct: '' });
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
fetchBaselines()
.then((data) => {
if (!cancelled) setBaselines(data);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
function openEdit(b: Baseline) {
setEditItem(b);
setEditForm({
monthly_budget: String(b.monthly_budget),
alert_threshold_pct: String(b.alert_threshold_pct),
});
}
async function handleSave() {
if (!editItem) return;
setSaving(true);
try {
const updated = await updateBaseline(editItem.id, {
monthly_budget: Number(editForm.monthly_budget),
alert_threshold_pct: Number(editForm.alert_threshold_pct),
});
setBaselines((prev) => prev.map((b) => (b.id === editItem.id ? updated : b)));
setEditItem(null);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
const spendRatio = (b: Baseline) => {
if (b.monthly_budget === 0) return 0;
return Math.round((b.current_spend / b.monthly_budget) * 100);
};
const columns = [
{
key: 'service',
header: 'Service',
sortable: true,
render: (row: Baseline) => (
<span className="text-white font-medium">{row.service}</span>
),
},
{
key: 'account_id',
header: 'Account',
sortable: true,
render: (row: Baseline) => (
<span className="text-gray-400 text-xs font-mono">{row.account_id}</span>
),
},
{
key: 'monthly_budget',
header: 'Budget',
sortable: true,
render: (row: Baseline) => (
<span className="text-gray-300 tabular-nums">{formatCurrency(row.monthly_budget)}</span>
),
},
{
key: 'current_spend',
header: 'Current Spend',
sortable: true,
render: (row: Baseline) => {
const pct = spendRatio(row);
const color = pct > 100 ? 'text-red-400' : pct > 80 ? 'text-yellow-400' : 'text-cyan-400';
return (
<span className={`tabular-nums font-medium ${color}`}>
{formatCurrency(row.current_spend)} ({pct}%)
</span>
);
},
},
{
key: 'alert_threshold_pct',
header: 'Alert At',
sortable: true,
render: (row: Baseline) => (
<span className="text-gray-400 tabular-nums">{row.alert_threshold_pct}%</span>
),
},
{
key: 'actions',
header: '',
render: (row: Baseline) => (
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>
Edit
</Button>
),
},
];
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
return (
<div>
<PageHeader
title="Cost Baselines"
description="Manage monthly budgets and alert thresholds"
action={<Button variant="secondary" onClick={() => navigate('/cost')}> Back</Button>}
/>
{error && (
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
)}
<Card noPadding>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading baselines</span>
</div>
</div>
)}
{!loading && !error && baselines.length === 0 && (
<EmptyState
icon="📊"
title="No baselines configured"
description="Baselines are auto-created when services report cost data."
/>
)}
{!loading && !error && baselines.length > 0 && (
<Table
columns={columns}
data={baselines}
rowKey={(row) => row.id}
/>
)}
</Card>
<Modal open={!!editItem} onClose={() => setEditItem(null)} title={`Edit Baseline: ${editItem?.service ?? ''}`}>
<div className="space-y-4">
<div>
<label className={labelClass}>Monthly Budget ($)</label>
<input
className={inputClass}
type="number"
min="0"
step="0.01"
value={editForm.monthly_budget}
onChange={(e) => setEditForm({ ...editForm, monthly_budget: e.target.value })}
/>
</div>
<div>
<label className={labelClass}>Alert Threshold (%)</label>
<input
className={inputClass}
type="number"
min="0"
max="200"
value={editForm.alert_threshold_pct}
onChange={(e) => setEditForm({ ...editForm, alert_threshold_pct: e.target.value })}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setEditItem(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { EmptyState } from '../../shared/EmptyState';
import { fetchCostSummary, fetchAnomalies, acknowledgeAnomaly, type Anomaly, type CostSummary } from './api';
function severityColor(severity: string): 'green' | 'yellow' | 'red' | 'cyan' {
if (severity === 'critical') return 'red';
if (severity === 'high') return 'red';
if (severity === 'medium') return 'yellow';
return 'green';
}
function statusColor(status: string): 'green' | 'yellow' | 'gray' {
if (status === 'resolved') return 'green';
if (status === 'acknowledged') return 'yellow';
return 'gray';
}
function formatCurrency(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export function CostDashboard() {
const navigate = useNavigate();
const [summary, setSummary] = useState<CostSummary | null>(null);
const [anomalies, setAnomalies] = useState<Anomaly[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
Promise.all([fetchCostSummary(), fetchAnomalies()])
.then(([sum, anoms]) => {
if (!cancelled) {
setSummary(sum);
setAnomalies(anoms);
}
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
async function handleAcknowledge(id: string) {
try {
const updated = await acknowledgeAnomaly(id);
setAnomalies((prev) => prev.map((a) => (a.id === id ? updated : a)));
} catch (err: any) {
setError(err.message);
}
}
const columns = [
{
key: 'service',
header: 'Service',
sortable: true,
render: (row: Anomaly) => (
<span className="text-white font-medium">{row.service}</span>
),
},
{
key: 'severity',
header: 'Severity',
sortable: true,
render: (row: Anomaly) => (
<Badge color={severityColor(row.severity)}>{row.severity}</Badge>
),
},
{
key: 'delta',
header: 'Overspend',
sortable: true,
render: (row: Anomaly) => (
<span className="text-red-400 tabular-nums font-medium">
+{formatCurrency(row.delta)} ({row.delta_pct > 0 ? '+' : ''}{row.delta_pct}%)
</span>
),
},
{
key: 'actual_cost',
header: 'Actual',
sortable: true,
render: (row: Anomaly) => (
<span className="text-gray-300 tabular-nums">{formatCurrency(row.actual_cost)}</span>
),
},
{
key: 'status',
header: 'Status',
sortable: true,
render: (row: Anomaly) => (
<div className="flex items-center gap-2">
<Badge color={statusColor(row.status)}>{row.status}</Badge>
{row.status === 'open' && (
<button
onClick={(e) => { e.stopPropagation(); handleAcknowledge(row.id); }}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
Ack
</button>
)}
</div>
),
},
{
key: 'detected_at',
header: 'Detected',
sortable: true,
render: (row: Anomaly) => (
<span className="text-gray-500 text-xs">{formatTime(row.detected_at)}</span>
),
},
];
return (
<div>
<PageHeader
title="Cost Anomaly Detection"
description="Monitor AWS spend and detect cost anomalies"
action={
<div className="flex gap-2">
<Button variant="secondary" onClick={() => navigate('/cost/baselines')}>Baselines</Button>
<Button variant="secondary" onClick={() => navigate('/cost/governance')}>Governance</Button>
</div>
}
/>
{!loading && !error && summary && (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{[
{ label: 'Total Spend', value: formatCurrency(summary.total_spend), color: 'text-white' },
{ label: 'Anomalies', value: summary.anomaly_count, color: 'text-red-400' },
{ label: 'Potential Savings', value: formatCurrency(summary.potential_savings), color: 'text-cyan-400' },
].map((stat) => (
<div key={stat.label} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">{stat.label}</p>
<p className={`text-2xl font-bold mt-1 tabular-nums ${stat.color}`}>{stat.value}</p>
</div>
))}
</div>
)}
<Card noPadding>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading cost data</span>
</div>
</div>
)}
{error && (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
Failed to load cost data: {error}
</div>
</div>
)}
{!loading && !error && anomalies.length === 0 && (
<EmptyState
icon="✅"
title="No anomalies detected"
description="Your spend is within expected baselines. Nice."
/>
)}
{!loading && !error && anomalies.length > 0 && (
<Table
columns={columns}
data={anomalies}
rowKey={(row) => row.id}
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,207 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { EmptyState } from '../../shared/EmptyState';
import { fetchGovernanceRules, createGovernanceRule, deleteGovernanceRule, type GovernanceRule } from './api';
function actionColor(action: string): 'red' | 'yellow' | 'blue' {
if (action === 'block') return 'red';
if (action === 'alert') return 'yellow';
return 'blue';
}
export function GovernanceRules() {
const navigate = useNavigate();
const [rules, setRules] = useState<GovernanceRule[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ name: '', description: '', condition: '', action: 'alert' as 'alert' | 'block' | 'tag', enabled: true });
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
fetchGovernanceRules()
.then((data) => {
if (!cancelled) setRules(data);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const created = await createGovernanceRule(form);
setRules((prev) => [...prev, created]);
setShowCreate(false);
setForm({ name: '', description: '', condition: '', action: 'alert', enabled: true });
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm('Delete this governance rule?')) return;
try {
await deleteGovernanceRule(id);
setRules((prev) => prev.filter((r) => r.id !== id));
} catch (err: any) {
setError(err.message);
}
}
const columns = [
{
key: 'name',
header: 'Rule',
sortable: true,
render: (row: GovernanceRule) => (
<div>
<span className="text-white font-medium">{row.name}</span>
<p className="text-gray-500 text-xs mt-0.5">{row.description}</p>
</div>
),
},
{
key: 'condition',
header: 'Condition',
render: (row: GovernanceRule) => (
<code className="text-xs text-cyan-400 bg-gray-800 px-2 py-0.5 rounded font-mono">{row.condition}</code>
),
},
{
key: 'action',
header: 'Action',
sortable: true,
render: (row: GovernanceRule) => (
<Badge color={actionColor(row.action)}>{row.action}</Badge>
),
},
{
key: 'enabled',
header: 'Status',
render: (row: GovernanceRule) => (
<Badge color={row.enabled ? 'green' : 'gray'}>{row.enabled ? 'Active' : 'Disabled'}</Badge>
),
},
{
key: 'actions',
header: '',
render: (row: GovernanceRule) => (
<Button size="sm" variant="danger" onClick={(e) => { e.stopPropagation(); handleDelete(row.id); }}>
Delete
</Button>
),
},
];
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
return (
<div>
<PageHeader
title="Governance Rules"
description="Define cost governance policies and automated actions"
action={
<div className="flex gap-2">
<Button variant="secondary" onClick={() => navigate('/cost')}> Back</Button>
<Button onClick={() => setShowCreate(!showCreate)}>+ New Rule</Button>
</div>
}
/>
{error && (
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
)}
{showCreate && (
<Card className="mb-6">
<form onSubmit={handleCreate} className="space-y-4 max-w-xl">
<div>
<label className={labelClass}>Rule Name *</label>
<input className={inputClass} required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="max-ec2-spend" />
</div>
<div>
<label className={labelClass}>Description *</label>
<input className={inputClass} required value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Block EC2 spend over $10k/month" />
</div>
<div>
<label className={labelClass}>Condition *</label>
<input className={inputClass} required value={form.condition} onChange={(e) => setForm({ ...form, condition: e.target.value })} placeholder="service.spend > 10000 AND service.type == 'ec2'" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Action *</label>
<select className={inputClass} value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value as 'alert' | 'block' | 'tag' })}>
<option value="alert">Alert</option>
<option value="block">Block</option>
<option value="tag">Tag</option>
</select>
</div>
<div>
<label className={labelClass}>Enabled</label>
<select className={inputClass} value={form.enabled ? 'true' : 'false'} onChange={(e) => setForm({ ...form, enabled: e.target.value === 'true' })}>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={saving}>{saving ? 'Creating…' : 'Create Rule'}</Button>
<Button variant="secondary" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
</div>
</form>
</Card>
)}
<Card noPadding>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading rules</span>
</div>
</div>
)}
{!loading && !error && rules.length === 0 && (
<EmptyState
icon="📋"
title="No governance rules"
description="Create rules to automate cost governance policies."
actionLabel="+ New Rule"
onAction={() => setShowCreate(true)}
/>
)}
{!loading && !error && rules.length > 0 && (
<Table
columns={columns}
data={rules}
rowKey={(row) => row.id}
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { apiFetch } from '../../shell/api';
export interface Anomaly {
id: string;
service: string;
account_id: string;
severity: 'low' | 'medium' | 'high' | 'critical';
expected_cost: number;
actual_cost: number;
delta: number;
delta_pct: number;
detected_at: string;
status: 'open' | 'acknowledged' | 'resolved';
}
export interface Baseline {
id: string;
service: string;
account_id: string;
monthly_budget: number;
alert_threshold_pct: number;
current_spend: number;
updated_at: string;
}
export interface GovernanceRule {
id: string;
name: string;
description: string;
condition: string;
action: 'alert' | 'block' | 'tag';
enabled: boolean;
created_at: string;
}
export interface CostSummary {
total_spend: number;
anomaly_count: number;
potential_savings: number;
month: string;
}
export async function fetchCostSummary(): Promise<CostSummary> {
return apiFetch<CostSummary>('/api/v1/cost/summary');
}
export async function fetchAnomalies(): Promise<Anomaly[]> {
return apiFetch<Anomaly[]>('/api/v1/cost/anomalies');
}
export async function acknowledgeAnomaly(id: string): Promise<Anomaly> {
return apiFetch<Anomaly>(`/api/v1/cost/anomalies/${encodeURIComponent(id)}/acknowledge`, { method: 'POST' });
}
export async function fetchBaselines(): Promise<Baseline[]> {
return apiFetch<Baseline[]>('/api/v1/cost/baselines');
}
export async function updateBaseline(id: string, payload: { monthly_budget?: number; alert_threshold_pct?: number }): Promise<Baseline> {
return apiFetch<Baseline>(`/api/v1/cost/baselines/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function fetchGovernanceRules(): Promise<GovernanceRule[]> {
return apiFetch<GovernanceRule[]>('/api/v1/cost/governance');
}
export async function createGovernanceRule(payload: Omit<GovernanceRule, 'id' | 'created_at'>): Promise<GovernanceRule> {
return apiFetch<GovernanceRule>('/api/v1/cost/governance', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function deleteGovernanceRule(id: string): Promise<void> {
await apiFetch<void>(`/api/v1/cost/governance/${encodeURIComponent(id)}`, { method: 'DELETE' });
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import type { RouteObject } from 'react-router-dom';
import { CostDashboard } from './CostDashboard';
import { CostBaselines } from './CostBaselines';
import { GovernanceRules } from './GovernanceRules';
import type { ModuleManifest } from '../drift/manifest';
export const costManifest: ModuleManifest = {
id: 'cost',
name: 'Cost Anomaly',
icon: '💰',
path: '/cost',
entitlement: 'cost',
routes: [
{ path: 'cost', element: <CostDashboard /> },
{ path: 'cost/baselines', element: <CostBaselines /> },
{ path: 'cost/governance', element: <GovernanceRules /> },
],
};

View File

@@ -0,0 +1,194 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { EmptyState } from '../../shared/EmptyState';
import { fetchServices, type Service } from './api';
function tierColor(tier: string): 'red' | 'yellow' | 'cyan' {
if (tier === 'critical') return 'red';
if (tier === 'standard') return 'yellow';
return 'cyan';
}
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export function PortalDashboard() {
const navigate = useNavigate();
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
useEffect(() => {
let cancelled = false;
fetchServices()
.then((data) => {
if (!cancelled) setServices(data);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const filtered = services.filter((s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.owner.toLowerCase().includes(search.toLowerCase()) ||
s.language.toLowerCase().includes(search.toLowerCase())
);
const totalServices = services.length;
const criticalCount = services.filter((s) => s.tier === 'critical').length;
const standardCount = services.filter((s) => s.tier === 'standard').length;
const languages = new Set(services.map((s) => s.language)).size;
const columns = [
{
key: 'name',
header: 'Service',
sortable: true,
render: (row: Service) => (
<span className="text-white font-medium">{row.name}</span>
),
},
{
key: 'owner',
header: 'Owner',
sortable: true,
render: (row: Service) => (
<span className="text-gray-400">{row.owner}</span>
),
},
{
key: 'tier',
header: 'Tier',
sortable: true,
render: (row: Service) => (
<Badge color={tierColor(row.tier)}>{row.tier}</Badge>
),
},
{
key: 'language',
header: 'Language',
sortable: true,
render: (row: Service) => (
<Badge color="blue">{row.language}</Badge>
),
},
{
key: 'last_updated',
header: 'Last Updated',
sortable: true,
render: (row: Service) => (
<span className="text-gray-500 text-xs">{formatTime(row.last_updated)}</span>
),
},
];
return (
<div>
<PageHeader
title="Service Portal"
description="Browse and manage your service catalog"
action={
<Button onClick={() => navigate('/portal/create')}>+ New Service</Button>
}
/>
{!loading && !error && services.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[
{ label: 'Total Services', value: totalServices, color: 'text-white' },
{ label: 'Critical', value: criticalCount, color: 'text-red-400' },
{ label: 'Standard', value: standardCount, color: 'text-yellow-400' },
{ label: 'Languages', value: languages, color: 'text-cyan-400' },
].map((stat) => (
<div key={stat.label} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">{stat.label}</p>
<p className={`text-2xl font-bold mt-1 tabular-nums ${stat.color}`}>{stat.value}</p>
</div>
))}
</div>
)}
{!loading && !error && services.length > 0 && (
<div className="mb-4">
<input
type="text"
placeholder="Search services by name, owner, or language…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2 text-sm text-gray-300 placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
)}
<Card noPadding>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading services</span>
</div>
</div>
)}
{error && (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
Failed to load services: {error}
</div>
</div>
)}
{!loading && !error && services.length === 0 && (
<EmptyState
icon="🏗️"
title="No services registered"
description="Add your first service to the catalog to get started."
actionLabel="+ New Service"
onAction={() => navigate('/portal/create')}
/>
)}
{!loading && !error && filtered.length > 0 && (
<Table
columns={columns}
data={filtered}
rowKey={(row) => row.id}
onRowClick={(row) => navigate(`/portal/${row.id}`)}
/>
)}
{!loading && !error && services.length > 0 && filtered.length === 0 && (
<EmptyState
icon="🔍"
title="No matching services"
description="Try adjusting your search query."
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Button } from '../../shared/Button';
import { createService } from './api';
export function ServiceCreate() {
const navigate = useNavigate();
const [form, setForm] = useState({
name: '',
description: '',
owner: '',
tier: 'standard',
language: '',
repo_url: '',
docs_url: '',
tags: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const created = await createService({
name: form.name,
description: form.description,
owner: form.owner,
tier: form.tier,
language: form.language,
repo_url: form.repo_url || undefined,
docs_url: form.docs_url || undefined,
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
});
navigate(`/portal/${created.id}`);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
return (
<div>
<PageHeader
title="Register Service"
description="Add a new service to the catalog"
action={<Button variant="secondary" onClick={() => navigate('/portal')}> Back</Button>}
/>
{error && (
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
)}
<Card>
<form onSubmit={handleSubmit} className="space-y-4 max-w-xl">
<div>
<label className={labelClass}>Name *</label>
<input className={inputClass} required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="my-service" />
</div>
<div>
<label className={labelClass}>Description *</label>
<textarea className={`${inputClass} resize-none`} rows={3} required value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="What does this service do?" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Owner *</label>
<input className={inputClass} required value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })} placeholder="team-platform" />
</div>
<div>
<label className={labelClass}>Tier *</label>
<select className={inputClass} value={form.tier} onChange={(e) => setForm({ ...form, tier: e.target.value })}>
<option value="critical">Critical</option>
<option value="standard">Standard</option>
<option value="experimental">Experimental</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Language *</label>
<input className={inputClass} required value={form.language} onChange={(e) => setForm({ ...form, language: e.target.value })} placeholder="TypeScript" />
</div>
<div>
<label className={labelClass}>Repository URL</label>
<input className={inputClass} value={form.repo_url} onChange={(e) => setForm({ ...form, repo_url: e.target.value })} placeholder="https://github.com/org/repo" />
</div>
<div>
<label className={labelClass}>Documentation URL</label>
<input className={inputClass} value={form.docs_url} onChange={(e) => setForm({ ...form, docs_url: e.target.value })} placeholder="https://docs.example.com/my-service" />
</div>
<div>
<label className={labelClass}>Tags (comma-separated)</label>
<input className={inputClass} value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="backend, api, grpc" />
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={saving}>{saving ? 'Creating…' : 'Create Service'}</Button>
<Button variant="secondary" type="button" onClick={() => navigate('/portal')}>Cancel</Button>
</div>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { Modal } from '../../shared/Modal';
import { fetchService, updateService, deleteService, type Service } from './api';
function tierColor(tier: string): 'red' | 'yellow' | 'cyan' {
if (tier === 'critical') return 'red';
if (tier === 'standard') return 'yellow';
return 'cyan';
}
export function ServiceDetail() {
const { serviceId } = useParams<{ serviceId: string }>();
const navigate = useNavigate();
const [service, setService] = useState<Service | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editForm, setEditForm] = useState({ name: '', description: '', owner: '', tier: '', language: '', repo_url: '', docs_url: '', tags: '' });
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!serviceId) return;
let cancelled = false;
fetchService(serviceId)
.then((data) => {
if (!cancelled) {
setService(data);
setEditForm({
name: data.name,
description: data.description,
owner: data.owner,
tier: data.tier,
language: data.language,
repo_url: data.repo_url || '',
docs_url: data.docs_url || '',
tags: data.tags.join(', '),
});
}
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [serviceId]);
async function handleSave() {
if (!serviceId) return;
setSaving(true);
try {
const updated = await updateService(serviceId, {
name: editForm.name,
description: editForm.description,
owner: editForm.owner,
tier: editForm.tier,
language: editForm.language,
repo_url: editForm.repo_url || undefined,
docs_url: editForm.docs_url || undefined,
tags: editForm.tags.split(',').map((t) => t.trim()).filter(Boolean),
});
setService(updated);
setEditOpen(false);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!serviceId || !confirm('Delete this service?')) return;
try {
await deleteService(serviceId);
navigate('/portal');
} catch (err: any) {
setError(err.message);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading service</span>
</div>
</div>
);
}
if (error && !service) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
Failed to load service: {error}
</div>
</div>
);
}
if (!service) return null;
const metaFields = [
{ label: 'Owner', value: service.owner },
{ label: 'Tier', value: <Badge color={tierColor(service.tier)}>{service.tier}</Badge> },
{ label: 'Language', value: <Badge color="blue">{service.language}</Badge> },
{ label: 'Created', value: new Date(service.created_at).toLocaleDateString() },
{ label: 'Last Updated', value: new Date(service.last_updated).toLocaleDateString() },
];
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
return (
<div>
<PageHeader
title={service.name}
description={service.description}
action={
<div className="flex gap-2">
<Button variant="secondary" onClick={() => navigate('/portal')}> Back</Button>
<Button onClick={() => setEditOpen(true)}>Edit</Button>
<Button variant="danger" onClick={handleDelete}>Delete</Button>
</div>
}
/>
{error && (
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card header={<span className="text-sm font-semibold text-white">Metadata</span>}>
<dl className="space-y-3">
{metaFields.map((f) => (
<div key={f.label} className="flex items-center justify-between">
<dt className="text-xs text-gray-500 uppercase tracking-wider">{f.label}</dt>
<dd className="text-sm text-gray-300">{f.value}</dd>
</div>
))}
</dl>
</Card>
<Card header={<span className="text-sm font-semibold text-white">Links & Tags</span>}>
<div className="space-y-3">
{service.repo_url && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Repository</p>
<a href={service.repo_url} target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 text-sm break-all">
{service.repo_url}
</a>
</div>
)}
{service.docs_url && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Documentation</p>
<a href={service.docs_url} target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 text-sm break-all">
{service.docs_url}
</a>
</div>
)}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Tags</p>
<div className="flex flex-wrap gap-1.5">
{service.tags.length > 0 ? (
service.tags.map((tag) => (
<Badge key={tag} color="gray">{tag}</Badge>
))
) : (
<span className="text-gray-600 text-sm">No tags</span>
)}
</div>
</div>
</div>
</Card>
</div>
<Modal open={editOpen} onClose={() => setEditOpen(false)} title="Edit Service">
<div className="space-y-4">
<div>
<label className={labelClass}>Name</label>
<input className={inputClass} value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} />
</div>
<div>
<label className={labelClass}>Description</label>
<textarea className={`${inputClass} resize-none`} rows={3} value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Owner</label>
<input className={inputClass} value={editForm.owner} onChange={(e) => setEditForm({ ...editForm, owner: e.target.value })} />
</div>
<div>
<label className={labelClass}>Tier</label>
<select className={inputClass} value={editForm.tier} onChange={(e) => setEditForm({ ...editForm, tier: e.target.value })}>
<option value="critical">Critical</option>
<option value="standard">Standard</option>
<option value="experimental">Experimental</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Language</label>
<input className={inputClass} value={editForm.language} onChange={(e) => setEditForm({ ...editForm, language: e.target.value })} />
</div>
<div>
<label className={labelClass}>Repository URL</label>
<input className={inputClass} value={editForm.repo_url} onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })} />
</div>
<div>
<label className={labelClass}>Docs URL</label>
<input className={inputClass} value={editForm.docs_url} onChange={(e) => setEditForm({ ...editForm, docs_url: e.target.value })} />
</div>
<div>
<label className={labelClass}>Tags (comma-separated)</label>
<input className={inputClass} value={editForm.tags} onChange={(e) => setEditForm({ ...editForm, tags: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { apiFetch } from '../../shell/api';
export interface Service {
id: string;
name: string;
description: string;
owner: string;
tier: 'critical' | 'standard' | 'experimental';
language: string;
repo_url?: string;
docs_url?: string;
tags: string[];
last_updated: string;
created_at: string;
}
export interface ServiceCreatePayload {
name: string;
description: string;
owner: string;
tier: string;
language: string;
repo_url?: string;
docs_url?: string;
tags: string[];
}
export async function fetchServices(): Promise<Service[]> {
return apiFetch<Service[]>('/api/v1/services');
}
export async function fetchService(id: string): Promise<Service> {
return apiFetch<Service>(`/api/v1/services/${encodeURIComponent(id)}`);
}
export async function createService(payload: ServiceCreatePayload): Promise<Service> {
return apiFetch<Service>('/api/v1/services', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateService(id: string, payload: Partial<ServiceCreatePayload>): Promise<Service> {
return apiFetch<Service>(`/api/v1/services/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteService(id: string): Promise<void> {
await apiFetch<void>(`/api/v1/services/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import type { RouteObject } from 'react-router-dom';
import { PortalDashboard } from './PortalDashboard';
import { ServiceDetail } from './ServiceDetail';
import { ServiceCreate } from './ServiceCreate';
import type { ModuleManifest } from '../drift/manifest';
export const portalManifest: ModuleManifest = {
id: 'portal',
name: 'Service Portal',
icon: '🏗️',
path: '/portal',
entitlement: 'portal',
routes: [
{ path: 'portal', element: <PortalDashboard /> },
{ path: 'portal/:serviceId', element: <ServiceDetail /> },
{ path: 'portal/create', element: <ServiceCreate /> },
],
};

View File

@@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { EmptyState } from '../../shared/EmptyState';
import { fetchApprovals, approveExecution, rejectExecution, type Approval } from './api';
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function statusColor(status: string): 'green' | 'yellow' | 'red' {
if (status === 'approved') return 'green';
if (status === 'pending') return 'yellow';
return 'red';
}
export function ApprovalQueue() {
const navigate = useNavigate();
const [approvals, setApprovals] = useState<Approval[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchApprovals()
.then((data) => {
if (!cancelled) setApprovals(data);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
async function handleApprove(id: string) {
try {
const updated = await approveExecution(id);
setApprovals((prev) => prev.map((a) => (a.id === id ? updated : a)));
} catch (err: any) {
setError(err.message);
}
}
async function handleReject(id: string) {
try {
const updated = await rejectExecution(id);
setApprovals((prev) => prev.map((a) => (a.id === id ? updated : a)));
} catch (err: any) {
setError(err.message);
}
}
const pendingCount = approvals.filter((a) => a.status === 'pending').length;
const columns = [
{
key: 'runbook_name',
header: 'Runbook',
sortable: true,
render: (row: Approval) => (
<span className="text-white font-medium">{row.runbook_name}</span>
),
},
{
key: 'requested_by',
header: 'Requested By',
sortable: true,
render: (row: Approval) => (
<span className="text-gray-400">{row.requested_by}</span>
),
},
{
key: 'mode',
header: 'Mode',
render: (row: Approval) => (
<Badge color="red">{row.mode}</Badge>
),
},
{
key: 'status',
header: 'Status',
sortable: true,
render: (row: Approval) => (
<Badge color={statusColor(row.status)}>{row.status}</Badge>
),
},
{
key: 'requested_at',
header: 'Requested',
sortable: true,
render: (row: Approval) => (
<span className="text-gray-500 text-xs">{formatTime(row.requested_at)}</span>
),
},
{
key: 'actions',
header: '',
render: (row: Approval) => (
row.status === 'pending' ? (
<div className="flex gap-2">
<Button size="sm" onClick={(e) => { e.stopPropagation(); handleApprove(row.id); }}>
Approve
</Button>
<Button size="sm" variant="danger" onClick={(e) => { e.stopPropagation(); handleReject(row.id); }}>
Reject
</Button>
</div>
) : null
),
},
];
return (
<div>
<PageHeader
title="Approval Queue"
description={pendingCount > 0 ? `${pendingCount} pending approval${pendingCount > 1 ? 's' : ''}` : 'Review and approve runbook executions'}
action={<Button variant="secondary" onClick={() => navigate('/run')}> Back</Button>}
/>
{error && (
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
)}
<Card noPadding>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading approvals</span>
</div>
</div>
)}
{!loading && !error && approvals.length === 0 && (
<EmptyState
icon="✅"
title="No pending approvals"
description="All clear. No runbook executions waiting for review."
/>
)}
{!loading && !error && approvals.length > 0 && (
<Table
columns={columns}
data={approvals}
rowKey={(row) => row.id}
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { EmptyState } from '../../shared/EmptyState';
import { fetchRunbooks, type Runbook } from './api';
function statusColor(status?: string): 'green' | 'yellow' | 'red' | 'cyan' | 'gray' {
if (status === 'success') return 'green';
if (status === 'running') return 'cyan';
if (status === 'failed') return 'red';
if (status === 'pending_approval') return 'yellow';
return 'gray';
}
function formatTime(iso?: string): string {
if (!iso) return 'Never';
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export function RunDashboard() {
const navigate = useNavigate();
const [runbooks, setRunbooks] = useState<Runbook[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchRunbooks()
.then((data) => {
if (!cancelled) setRunbooks(data);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const totalRunbooks = runbooks.length;
const successCount = runbooks.filter((r) => r.last_status === 'success').length;
const failedCount = runbooks.filter((r) => r.last_status === 'failed').length;
const pendingCount = runbooks.filter((r) => r.last_status === 'pending_approval').length;
const columns = [
{
key: 'name',
header: 'Runbook',
sortable: true,
render: (row: Runbook) => (
<div>
<span className="text-white font-medium">{row.name}</span>
<p className="text-gray-500 text-xs mt-0.5 max-w-xs truncate">{row.description}</p>
</div>
),
},
{
key: 'author',
header: 'Author',
sortable: true,
render: (row: Runbook) => (
<span className="text-gray-400">{row.author}</span>
),
},
{
key: 'requires_approval',
header: 'Approval',
render: (row: Runbook) => (
<Badge color={row.requires_approval ? 'yellow' : 'gray'}>
{row.requires_approval ? 'Required' : 'Auto'}
</Badge>
),
},
{
key: 'last_status',
header: 'Last Status',
sortable: true,
render: (row: Runbook) => (
row.last_status
? <Badge color={statusColor(row.last_status)}>{row.last_status.replace('_', ' ')}</Badge>
: <span className="text-gray-600 text-xs"></span>
),
},
{
key: 'last_run',
header: 'Last Run',
sortable: true,
render: (row: Runbook) => (
<span className="text-gray-500 text-xs">{formatTime(row.last_run)}</span>
),
},
];
return (
<div>
<PageHeader
title="Runbook Automation"
description="Manage and execute operational runbooks"
action={
<div className="flex gap-2">
<Button variant="secondary" onClick={() => navigate('/run/approvals')}>Approvals</Button>
<Button onClick={() => navigate('/run/create')}>+ New Runbook</Button>
</div>
}
/>
{!loading && !error && runbooks.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[
{ label: 'Total Runbooks', value: totalRunbooks, color: 'text-white' },
{ label: 'Succeeded', value: successCount, color: 'text-green-400' },
{ label: 'Failed', value: failedCount, color: 'text-red-400' },
{ label: 'Pending', value: pendingCount, color: 'text-yellow-400' },
].map((stat) => (
<div key={stat.label} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<p className="text-xs text-gray-500 uppercase tracking-wider">{stat.label}</p>
<p className={`text-2xl font-bold mt-1 tabular-nums ${stat.color}`}>{stat.value}</p>
</div>
))}
</div>
)}
<Card noPadding>
{loading && (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading runbooks</span>
</div>
</div>
)}
{error && (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
Failed to load runbooks: {error}
</div>
</div>
)}
{!loading && !error && runbooks.length === 0 && (
<EmptyState
icon="⚡"
title="No runbooks yet"
description="Create your first runbook to automate operational tasks."
actionLabel="+ New Runbook"
onAction={() => navigate('/run/create')}
/>
)}
{!loading && !error && runbooks.length > 0 && (
<Table
columns={columns}
data={runbooks}
rowKey={(row) => row.id}
onRowClick={(row) => navigate(`/run/${row.id}`)}
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Button } from '../../shared/Button';
import { createRunbook } from './api';
export function RunbookCreate() {
const navigate = useNavigate();
const [form, setForm] = useState({
name: '',
description: '',
yaml_content: '',
requires_approval: false,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
const created = await createRunbook(form);
navigate(`/run/${created.id}`);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
return (
<div>
<PageHeader
title="Create Runbook"
description="Define a new operational runbook"
action={<Button variant="secondary" onClick={() => navigate('/run')}> Back</Button>}
/>
{error && (
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
)}
<Card>
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
<div>
<label className={labelClass}>Name *</label>
<input
className={inputClass}
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="restart-service"
/>
</div>
<div>
<label className={labelClass}>Description *</label>
<textarea
className={`${inputClass} resize-none`}
rows={2}
required
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Gracefully restart a service with health checks"
/>
</div>
<div>
<label className={labelClass}>YAML Definition *</label>
<textarea
className={`${inputClass} resize-y font-mono text-cyan-400`}
rows={16}
required
value={form.yaml_content}
onChange={(e) => setForm({ ...form, yaml_content: e.target.value })}
placeholder={`steps:\n - name: drain-connections\n action: exec\n command: kubectl drain ...\n - name: restart-pods\n action: exec\n command: kubectl rollout restart ...`}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="requires_approval"
checked={form.requires_approval}
onChange={(e) => setForm({ ...form, requires_approval: e.target.checked })}
className="rounded border-gray-700 bg-gray-800 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-gray-950"
/>
<label htmlFor="requires_approval" className="text-sm text-gray-400">
Require approval before live execution
</label>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={saving}>{saving ? 'Creating…' : 'Create Runbook'}</Button>
<Button variant="secondary" type="button" onClick={() => navigate('/run')}>Cancel</Button>
</div>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,217 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { PageHeader } from '../../shared/PageHeader';
import { Card } from '../../shared/Card';
import { Table } from '../../shared/Table';
import { Badge } from '../../shared/Badge';
import { Button } from '../../shared/Button';
import { EmptyState } from '../../shared/EmptyState';
import { fetchRunbook, fetchExecutions, executeRunbook, type Runbook, type Execution } from './api';
function statusColor(status: string): 'green' | 'yellow' | 'red' | 'cyan' | 'gray' {
if (status === 'success') return 'green';
if (status === 'running' || status === 'queued') return 'cyan';
if (status === 'failed') return 'red';
if (status === 'pending_approval') return 'yellow';
return 'gray';
}
function formatTime(iso?: string): string {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
const diff = now.getTime() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export function RunbookDetail() {
const { runbookId } = useParams<{ runbookId: string }>();
const navigate = useNavigate();
const [runbook, setRunbook] = useState<Runbook | null>(null);
const [executions, setExecutions] = useState<Execution[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [execMode, setExecMode] = useState<'dry_run' | 'live'>('dry_run');
const [executing, setExecuting] = useState(false);
useEffect(() => {
if (!runbookId) return;
let cancelled = false;
Promise.all([fetchRunbook(runbookId), fetchExecutions(runbookId)])
.then(([rb, execs]) => {
if (!cancelled) {
setRunbook(rb);
setExecutions(execs);
}
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [runbookId]);
async function handleExecute() {
if (!runbookId) return;
setExecuting(true);
setError(null);
try {
const exec = await executeRunbook(runbookId, execMode);
setExecutions((prev) => [exec, ...prev]);
} catch (err: any) {
setError(err.message);
} finally {
setExecuting(false);
}
}
const execColumns = [
{
key: 'id',
header: 'Execution',
render: (row: Execution) => (
<span className="text-gray-400 text-xs font-mono">{row.id.slice(0, 8)}</span>
),
},
{
key: 'mode',
header: 'Mode',
render: (row: Execution) => (
<Badge color={row.mode === 'live' ? 'red' : 'cyan'}>{row.mode}</Badge>
),
},
{
key: 'status',
header: 'Status',
sortable: true,
render: (row: Execution) => (
<Badge color={statusColor(row.status)}>{row.status.replace('_', ' ')}</Badge>
),
},
{
key: 'triggered_by',
header: 'Triggered By',
render: (row: Execution) => (
<span className="text-gray-400 text-sm">{row.triggered_by}</span>
),
},
{
key: 'started_at',
header: 'Started',
sortable: true,
render: (row: Execution) => (
<span className="text-gray-500 text-xs">{formatTime(row.started_at)}</span>
),
},
{
key: 'finished_at',
header: 'Finished',
render: (row: Execution) => (
<span className="text-gray-500 text-xs">{formatTime(row.finished_at)}</span>
),
},
];
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="flex items-center gap-3 text-gray-400">
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="text-sm">Loading runbook</span>
</div>
</div>
);
}
if (error && !runbook) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
Failed to load runbook: {error}
</div>
</div>
);
}
if (!runbook) return null;
const selectClass = 'bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500 transition-colors';
return (
<div>
<PageHeader
title={runbook.name}
description={runbook.description}
action={
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={() => navigate('/run')}> Back</Button>
<select className={selectClass} value={execMode} onChange={(e) => setExecMode(e.target.value as 'dry_run' | 'live')}>
<option value="dry_run">Dry Run</option>
<option value="live">Live</option>
</select>
<Button onClick={handleExecute} disabled={executing}>
{executing ? 'Executing…' : '▶ Execute'}
</Button>
</div>
}
/>
{error && (
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<Card header={<span className="text-sm font-semibold text-white">Details</span>}>
<dl className="space-y-3">
{[
{ label: 'Author', value: runbook.author },
{ label: 'Approval', value: runbook.requires_approval ? 'Required' : 'Auto' },
{ label: 'Created', value: new Date(runbook.created_at).toLocaleDateString() },
{ label: 'Updated', value: new Date(runbook.updated_at).toLocaleDateString() },
].map((f) => (
<div key={f.label} className="flex items-center justify-between">
<dt className="text-xs text-gray-500 uppercase tracking-wider">{f.label}</dt>
<dd className="text-sm text-gray-300">{f.value}</dd>
</div>
))}
</dl>
</Card>
<Card className="lg:col-span-2" header={<span className="text-sm font-semibold text-white">YAML Definition</span>}>
<pre className="bg-gray-950 border border-gray-800 rounded-lg p-4 overflow-x-auto text-xs text-cyan-400 font-mono leading-relaxed max-h-80 overflow-y-auto">
<code>{runbook.yaml_content}</code>
</pre>
</Card>
</div>
<Card noPadding header={<span className="text-sm font-semibold text-white">Execution History</span>}>
{executions.length === 0 ? (
<EmptyState
icon="📜"
title="No executions yet"
description="Run this runbook to see execution history."
/>
) : (
<Table
columns={execColumns}
data={executions}
rowKey={(row) => row.id}
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { apiFetch } from '../../shell/api';
export interface Runbook {
id: string;
name: string;
description: string;
yaml_content: string;
author: string;
requires_approval: boolean;
last_run?: string;
last_status?: 'success' | 'failed' | 'running' | 'pending_approval';
created_at: string;
updated_at: string;
}
export interface Execution {
id: string;
runbook_id: string;
mode: 'dry_run' | 'live';
status: 'queued' | 'running' | 'success' | 'failed' | 'pending_approval' | 'cancelled';
started_at: string;
finished_at?: string;
output?: string;
triggered_by: string;
}
export interface Approval {
id: string;
execution_id: string;
runbook_name: string;
requested_by: string;
requested_at: string;
mode: 'live';
status: 'pending' | 'approved' | 'rejected';
}
export interface RunbookCreatePayload {
name: string;
description: string;
yaml_content: string;
requires_approval?: boolean;
}
export async function fetchRunbooks(): Promise<Runbook[]> {
return apiFetch<Runbook[]>('/api/v1/runbooks');
}
export async function fetchRunbook(id: string): Promise<Runbook> {
return apiFetch<Runbook>(`/api/v1/runbooks/${encodeURIComponent(id)}`);
}
export async function createRunbook(payload: RunbookCreatePayload): Promise<Runbook> {
return apiFetch<Runbook>('/api/v1/runbooks', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function executeRunbook(id: string, mode: 'dry_run' | 'live'): Promise<Execution> {
return apiFetch<Execution>(`/api/v1/runbooks/${encodeURIComponent(id)}/execute`, {
method: 'POST',
body: JSON.stringify({ mode }),
});
}
export async function fetchExecutions(runbookId: string): Promise<Execution[]> {
return apiFetch<Execution[]>(`/api/v1/runbooks/${encodeURIComponent(runbookId)}/executions`);
}
export async function fetchApprovals(): Promise<Approval[]> {
return apiFetch<Approval[]>('/api/v1/approvals');
}
export async function approveExecution(approvalId: string): Promise<Approval> {
return apiFetch<Approval>(`/api/v1/approvals/${encodeURIComponent(approvalId)}/approve`, { method: 'POST' });
}
export async function rejectExecution(approvalId: string): Promise<Approval> {
return apiFetch<Approval>(`/api/v1/approvals/${encodeURIComponent(approvalId)}/reject`, { method: 'POST' });
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import type { RouteObject } from 'react-router-dom';
import { RunDashboard } from './RunDashboard';
import { RunbookDetail } from './RunbookDetail';
import { RunbookCreate } from './RunbookCreate';
import { ApprovalQueue } from './ApprovalQueue';
import type { ModuleManifest } from '../drift/manifest';
export const runManifest: ModuleManifest = {
id: 'run',
name: 'Runbook Automation',
icon: '⚡',
path: '/run',
entitlement: 'run',
routes: [
{ path: 'run', element: <RunDashboard /> },
{ path: 'run/create', element: <RunbookCreate /> },
{ path: 'run/approvals', element: <ApprovalQueue /> },
{ path: 'run/:runbookId', element: <RunbookDetail /> },
],
};

View File

@@ -4,14 +4,16 @@ interface PageHeaderProps {
title: string;
description?: string;
action?: React.ReactNode;
children?: React.ReactNode;
}
export function PageHeader({ title, description, action }: PageHeaderProps) {
export function PageHeader({ title, description, action, children }: PageHeaderProps) {
return (
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">{title}</h1>
{description && <p className="text-gray-500 text-sm mt-1">{description}</p>}
{children}
</div>
{action && <div>{action}</div>}
</div>

View File

@@ -4,9 +4,13 @@ import { useAuth } from './AuthProvider';
import { LoginPage } from './LoginPage';
import { Layout } from './Layout';
import { driftManifest } from '../modules/drift/manifest.js';
import { alertManifest } from '../modules/alert/manifest.js';
import { portalManifest } from '../modules/portal/manifest.js';
import { costManifest } from '../modules/cost/manifest.js';
import { runManifest } from '../modules/run/manifest.js';
import type { ModuleManifest } from '../modules/drift/manifest.js';
const allModules: ModuleManifest[] = [driftManifest];
const allModules: ModuleManifest[] = [driftManifest, alertManifest, portalManifest, costManifest, runManifest];
function OverviewPage() {
return (