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
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:
205
products/console/src/modules/alert/AlertDashboard.tsx
Normal file
205
products/console/src/modules/alert/AlertDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
products/console/src/modules/alert/AlertDetail.tsx
Normal file
229
products/console/src/modules/alert/AlertDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
products/console/src/modules/alert/NotificationConfig.tsx
Normal file
185
products/console/src/modules/alert/NotificationConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
products/console/src/modules/alert/WebhookSecrets.tsx
Normal file
254
products/console/src/modules/alert/WebhookSecrets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
products/console/src/modules/alert/api.ts
Normal file
101
products/console/src/modules/alert/api.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
20
products/console/src/modules/alert/manifest.tsx
Normal file
20
products/console/src/modules/alert/manifest.tsx
Normal 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 /> },
|
||||
],
|
||||
};
|
||||
206
products/console/src/modules/cost/CostBaselines.tsx
Normal file
206
products/console/src/modules/cost/CostBaselines.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
products/console/src/modules/cost/CostDashboard.tsx
Normal file
205
products/console/src/modules/cost/CostDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
products/console/src/modules/cost/GovernanceRules.tsx
Normal file
207
products/console/src/modules/cost/GovernanceRules.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
products/console/src/modules/cost/api.ts
Normal file
79
products/console/src/modules/cost/api.ts
Normal 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' });
|
||||
}
|
||||
19
products/console/src/modules/cost/manifest.tsx
Normal file
19
products/console/src/modules/cost/manifest.tsx
Normal 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 /> },
|
||||
],
|
||||
};
|
||||
194
products/console/src/modules/portal/PortalDashboard.tsx
Normal file
194
products/console/src/modules/portal/PortalDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
products/console/src/modules/portal/ServiceCreate.tsx
Normal file
111
products/console/src/modules/portal/ServiceCreate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
products/console/src/modules/portal/ServiceDetail.tsx
Normal file
238
products/console/src/modules/portal/ServiceDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
products/console/src/modules/portal/api.ts
Normal file
54
products/console/src/modules/portal/api.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
19
products/console/src/modules/portal/manifest.tsx
Normal file
19
products/console/src/modules/portal/manifest.tsx
Normal 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 /> },
|
||||
],
|
||||
};
|
||||
174
products/console/src/modules/run/ApprovalQueue.tsx
Normal file
174
products/console/src/modules/run/ApprovalQueue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
products/console/src/modules/run/RunDashboard.tsx
Normal file
179
products/console/src/modules/run/RunDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
products/console/src/modules/run/RunbookCreate.tsx
Normal file
104
products/console/src/modules/run/RunbookCreate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
217
products/console/src/modules/run/RunbookDetail.tsx
Normal file
217
products/console/src/modules/run/RunbookDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
products/console/src/modules/run/api.ts
Normal file
80
products/console/src/modules/run/api.ts
Normal 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' });
|
||||
}
|
||||
21
products/console/src/modules/run/manifest.tsx
Normal file
21
products/console/src/modules/run/manifest.tsx
Normal 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 /> },
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user