Add dd0c Console — modular React dashboard with drift module
- Vite + React + TypeScript + Tailwind CSS - Shell: auth provider, entitlement gate, dynamic sidebar - Shared components: Button, Card, Table, Badge, Modal, EmptyState, PageHeader - Drift module: dashboard, detail view, report viewer - Module manifest pattern for pluggable product UIs - Dockerfile: multi-stage node:22-slim → nginx:alpine - 189KB JS + 17KB CSS (65KB gzipped)
This commit is contained in:
2
products/console/.gitignore
vendored
Normal file
2
products/console/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
21
products/console/Dockerfile
Normal file
21
products/console/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:22-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY <<'EOF' /etc/nginx/conf.d/default.conf
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
products/console/index.html
Normal file
16
products/console/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dd0c Console</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2808
products/console/package-lock.json
generated
Normal file
2808
products/console/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
products/console/package.json
Normal file
26
products/console/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "dd0c-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.1"
|
||||
}
|
||||
}
|
||||
6
products/console/postcss.config.js
Normal file
6
products/console/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
27
products/console/src/index.css
Normal file
27
products/console/src/index.css
Normal file
@@ -0,0 +1,27 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-primary: #6366f1;
|
||||
--color-accent: #22d3ee;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #374151;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
16
products/console/src/main.tsx
Normal file
16
products/console/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './shell/App';
|
||||
import { AuthProvider } from './shell/AuthProvider';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
159
products/console/src/modules/drift/DriftDashboard.tsx
Normal file
159
products/console/src/modules/drift/DriftDashboard.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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 { EmptyState } from '../../shared/EmptyState';
|
||||
import { fetchStacks, type Stack } from './api';
|
||||
|
||||
function driftColor(score: number): 'green' | 'yellow' | 'red' {
|
||||
if (score < 10) return 'green';
|
||||
if (score <= 50) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
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 DriftDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [stacks, setStacks] = useState<Stack[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchStacks()
|
||||
.then((data) => {
|
||||
if (!cancelled) setStacks(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'stackName',
|
||||
header: 'Stack',
|
||||
sortable: true,
|
||||
render: (row: Stack) => (
|
||||
<span className="text-white font-medium">{row.stackName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'driftScore',
|
||||
header: 'Drift Score',
|
||||
sortable: true,
|
||||
render: (row: Stack) => (
|
||||
<Badge color={driftColor(row.driftScore)}>
|
||||
{row.driftScore}%
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalResources',
|
||||
header: 'Resources',
|
||||
sortable: true,
|
||||
render: (row: Stack) => (
|
||||
<span className="text-gray-400 tabular-nums">{row.totalResources}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastScanTime',
|
||||
header: 'Last Scan',
|
||||
sortable: true,
|
||||
render: (row: Stack) => (
|
||||
<span className="text-gray-500 text-xs">{formatTime(row.lastScanTime)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Summary stats
|
||||
const totalStacks = stacks.length;
|
||||
const driftedStacks = stacks.filter((s) => s.driftScore > 0).length;
|
||||
const criticalStacks = stacks.filter((s) => s.driftScore > 50).length;
|
||||
const avgDrift = totalStacks > 0
|
||||
? Math.round(stacks.reduce((sum, s) => sum + s.driftScore, 0) / totalStacks)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Drift Detection"
|
||||
description="Monitor infrastructure drift across your stacks"
|
||||
/>
|
||||
|
||||
{/* Stats row */}
|
||||
{!loading && !error && stacks.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'Total Stacks', value: totalStacks, color: 'text-white' },
|
||||
{ label: 'Drifted', value: driftedStacks, color: 'text-yellow-400' },
|
||||
{ label: 'Critical', value: criticalStacks, color: 'text-red-400' },
|
||||
{ label: 'Avg Drift', value: `${avgDrift}%`, 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 stacks…</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 stacks: {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && stacks.length === 0 && (
|
||||
<EmptyState
|
||||
icon="🔍"
|
||||
title="No stacks found"
|
||||
description="Connect your infrastructure to start monitoring drift."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && stacks.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={stacks}
|
||||
rowKey={(row) => row.stackName}
|
||||
onRowClick={(row) => navigate(`/drift/${row.stackName}`)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
products/console/src/modules/drift/DriftDetail.tsx
Normal file
200
products/console/src/modules/drift/DriftDetail.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } 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 { fetchStackHistory, type DriftReport } from './api';
|
||||
|
||||
function driftColor(score: number): 'green' | 'yellow' | 'red' {
|
||||
if (score < 10) return 'green';
|
||||
if (score <= 50) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function DriftDetail() {
|
||||
const { stackName } = useParams<{ stackName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [reports, setReports] = useState<DriftReport[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stackName) return;
|
||||
let cancelled = false;
|
||||
fetchStackHistory(stackName)
|
||||
.then((data) => {
|
||||
if (!cancelled) setReports(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName]);
|
||||
|
||||
// Find max drift for bar scaling
|
||||
const maxDrift = Math.max(...reports.map((r) => r.driftScore), 1);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={stackName || 'Stack Detail'}
|
||||
description="Drift history and trend analysis"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/drift')}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/drift/${stackName}/report`)}
|
||||
>
|
||||
View Report
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<Card>
|
||||
<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 history…</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<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 history: {error}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && reports.length === 0 && (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title="No drift history"
|
||||
description="No drift reports have been recorded for this stack yet."
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && reports.length > 0 && (
|
||||
<>
|
||||
{/* Drift trend chart (bar visualization) */}
|
||||
<Card
|
||||
header={<span className="text-sm font-semibold text-white">Drift Score Trend</span>}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{reports.map((r) => {
|
||||
const height = Math.max((r.driftScore / maxDrift) * 100, 4);
|
||||
const color =
|
||||
r.driftScore > 50
|
||||
? 'bg-red-500'
|
||||
: r.driftScore > 10
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-emerald-500';
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex-1 flex flex-col items-center justify-end group relative"
|
||||
>
|
||||
<div className="absolute -top-6 opacity-0 group-hover:opacity-100 transition-opacity bg-gray-800 text-xs text-white px-2 py-1 rounded whitespace-nowrap z-10">
|
||||
{r.driftScore}% · {formatDate(r.timestamp)}
|
||||
</div>
|
||||
<div
|
||||
className={`w-full max-w-[24px] rounded-t ${color} transition-all hover:opacity-80`}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-[10px] text-gray-600">
|
||||
<span>{reports.length > 0 ? formatDate(reports[0].timestamp) : ''}</span>
|
||||
<span>{reports.length > 1 ? formatDate(reports[reports.length - 1].timestamp) : ''}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Timeline */}
|
||||
<Card header={<span className="text-sm font-semibold text-white">Report Timeline</span>}>
|
||||
<div className="space-y-0">
|
||||
{reports.map((r, i) => {
|
||||
const prevScore = i > 0 ? reports[i - 1].driftScore : null;
|
||||
const delta = prevScore !== null ? r.driftScore - prevScore : null;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-start gap-4 py-3 border-b border-gray-800/50 last:border-0"
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div className="flex flex-col items-center pt-1">
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
r.driftScore > 50
|
||||
? 'bg-red-500'
|
||||
: r.driftScore > 10
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-emerald-500'
|
||||
}`}
|
||||
/>
|
||||
{i < reports.length - 1 && (
|
||||
<div className="w-px h-full bg-gray-800 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge color={driftColor(r.driftScore)}>
|
||||
{r.driftScore}%
|
||||
</Badge>
|
||||
<span className="text-gray-400 text-xs">
|
||||
{r.driftedResources}/{r.totalResources} resources drifted
|
||||
</span>
|
||||
{delta !== null && delta !== 0 && (
|
||||
<span
|
||||
className={`text-xs ${
|
||||
delta > 0 ? 'text-red-400' : 'text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
{delta > 0 ? '↑' : '↓'} {Math.abs(delta)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-600 mt-1">
|
||||
{formatDate(r.timestamp)} · ID: {r.id.slice(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
products/console/src/modules/drift/DriftReportSubmit.tsx
Normal file
112
products/console/src/modules/drift/DriftReportSubmit.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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 { fetchLatestReport, type DriftReport } from './api';
|
||||
|
||||
function driftColor(score: number): 'green' | 'yellow' | 'red' {
|
||||
if (score < 10) return 'green';
|
||||
if (score <= 50) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
export function DriftReportSubmit() {
|
||||
const { stackName } = useParams<{ stackName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [report, setReport] = useState<DriftReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stackName) return;
|
||||
let cancelled = false;
|
||||
fetchLatestReport(stackName)
|
||||
.then((data) => {
|
||||
if (!cancelled) setReport(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={`Report: ${stackName}`}
|
||||
description="Latest drift report details"
|
||||
action={
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/drift/${stackName}`)}>
|
||||
← Back to History
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<Card>
|
||||
<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 report…</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<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 report: {error}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && report && (
|
||||
<>
|
||||
{/* Summary card */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Drift Score</p>
|
||||
<div className="mt-1">
|
||||
<Badge color={driftColor(report.driftScore)}>
|
||||
{report.driftScore}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Total Resources</p>
|
||||
<p className="text-2xl font-bold text-white mt-1 tabular-nums">{report.totalResources}</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Drifted</p>
|
||||
<p className="text-2xl font-bold text-yellow-400 mt-1 tabular-nums">{report.driftedResources}</p>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Report ID</p>
|
||||
<p className="text-sm font-mono text-gray-400 mt-2 truncate">{report.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Raw JSON */}
|
||||
<Card
|
||||
header={
|
||||
<span className="text-sm font-semibold text-white">Raw Report Data</span>
|
||||
}
|
||||
>
|
||||
<pre className="text-xs text-gray-400 font-mono overflow-x-auto whitespace-pre-wrap leading-relaxed max-h-[60vh] overflow-y-auto">
|
||||
{JSON.stringify(report.report || report, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
products/console/src/modules/drift/api.ts
Normal file
33
products/console/src/modules/drift/api.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { apiFetch } from '../../shell/api';
|
||||
|
||||
const DRIFT_BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
export interface Stack {
|
||||
stackName: string;
|
||||
driftScore: number;
|
||||
totalResources: number;
|
||||
lastScanTime: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface DriftReport {
|
||||
id: string;
|
||||
stackName: string;
|
||||
driftScore: number;
|
||||
totalResources: number;
|
||||
driftedResources: number;
|
||||
timestamp: string;
|
||||
report?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchStacks(): Promise<Stack[]> {
|
||||
return apiFetch<Stack[]>('/api/v1/stacks');
|
||||
}
|
||||
|
||||
export async function fetchStackHistory(stackName: string): Promise<DriftReport[]> {
|
||||
return apiFetch<DriftReport[]>(`/api/v1/stacks/${encodeURIComponent(stackName)}/history`);
|
||||
}
|
||||
|
||||
export async function fetchLatestReport(stackName: string): Promise<DriftReport> {
|
||||
return apiFetch<DriftReport>(`/api/v1/stacks/${encodeURIComponent(stackName)}/report`);
|
||||
}
|
||||
27
products/console/src/modules/drift/manifest.tsx
Normal file
27
products/console/src/modules/drift/manifest.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { DriftDashboard } from './DriftDashboard';
|
||||
import { DriftDetail } from './DriftDetail';
|
||||
import { DriftReportSubmit } from './DriftReportSubmit';
|
||||
|
||||
export interface ModuleManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
path: string;
|
||||
entitlement: string;
|
||||
routes: RouteObject[];
|
||||
}
|
||||
|
||||
export const driftManifest: ModuleManifest = {
|
||||
id: 'drift',
|
||||
name: 'Drift Detection',
|
||||
icon: '🔀',
|
||||
path: '/drift',
|
||||
entitlement: 'drift',
|
||||
routes: [
|
||||
{ path: 'drift', element: <DriftDashboard /> },
|
||||
{ path: 'drift/:stackName', element: <DriftDetail /> },
|
||||
{ path: 'drift/:stackName/report', element: <DriftReportSubmit /> },
|
||||
],
|
||||
};
|
||||
23
products/console/src/shared/Badge.tsx
Normal file
23
products/console/src/shared/Badge.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
color?: 'green' | 'yellow' | 'red' | 'blue' | 'gray' | 'cyan';
|
||||
}
|
||||
|
||||
const colors = {
|
||||
green: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
yellow: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
red: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
blue: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20',
|
||||
gray: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
||||
cyan: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
|
||||
};
|
||||
|
||||
export function Badge({ children, color = 'gray' }: BadgeProps) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium border ${colors[color]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
29
products/console/src/shared/Button.tsx
Normal file
29
products/console/src/shared/Button.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-indigo-500 hover:bg-indigo-600 text-white focus:ring-indigo-500',
|
||||
secondary: 'bg-gray-800 hover:bg-gray-700 text-gray-300 border border-gray-700 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
};
|
||||
|
||||
export function Button({ variant = 'primary', size = 'md', className = '', children, disabled, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-950 disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
products/console/src/shared/Card.tsx
Normal file
21
products/console/src/shared/Card.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
className?: string;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
export function Card({ children, header, className = '', noPadding = false }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-gray-900 border border-gray-800 rounded-xl overflow-hidden ${className}`}>
|
||||
{header && (
|
||||
<div className="px-5 py-3 border-b border-gray-800 flex items-center justify-between">
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
<div className={noPadding ? '' : 'p-5'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
products/console/src/shared/EmptyState.tsx
Normal file
25
products/console/src/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon = '📭', title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<span className="text-4xl mb-4">{icon}</span>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">{title}</h3>
|
||||
{description && <p className="text-gray-500 text-sm max-w-sm mb-4">{description}</p>}
|
||||
{actionLabel && onAction && (
|
||||
<Button variant="primary" size="sm" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
products/console/src/shared/Modal.tsx
Normal file
42
products/console/src/shared/Modal.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-gray-900 border border-gray-800 rounded-xl shadow-2xl max-w-lg w-full mx-4 max-h-[85vh] overflow-y-auto">
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
products/console/src/shared/PageHeader.tsx
Normal file
19
products/console/src/shared/PageHeader.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: 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>}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
products/console/src/shared/Table.tsx
Normal file
56
products/console/src/shared/Table.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (row: T) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
rowKey: (row: T) => string;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
export function Table<T>({ columns, data, rowKey, onRowClick }: TableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase tracking-wider"
|
||||
>
|
||||
<span className={col.sortable ? 'cursor-pointer hover:text-gray-200 select-none' : ''}>
|
||||
{col.header}
|
||||
{col.sortable && <span className="ml-1 text-gray-600">↕</span>}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, i) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`border-b border-gray-800/50 transition-colors ${
|
||||
onRowClick ? 'cursor-pointer hover:bg-gray-800/50' : ''
|
||||
} ${i % 2 === 0 ? 'bg-gray-900' : 'bg-gray-900/50'}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3 text-gray-300">
|
||||
{col.render ? col.render(row) : (row as any)[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
products/console/src/shell/App.tsx
Normal file
63
products/console/src/shell/App.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { LoginPage } from './LoginPage';
|
||||
import { Layout } from './Layout';
|
||||
import { driftManifest } from '../modules/drift/manifest.js';
|
||||
import type { ModuleManifest } from '../modules/drift/manifest.js';
|
||||
|
||||
const allModules: ModuleManifest[] = [driftManifest];
|
||||
|
||||
function OverviewPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Welcome to dd0c Console</h1>
|
||||
<p className="text-gray-500 text-sm">Select a module from the sidebar to get started.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-6">
|
||||
{allModules.map((mod) => (
|
||||
<a
|
||||
key={mod.id}
|
||||
href={mod.path}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-5 hover:border-indigo-500/50 transition-colors group"
|
||||
>
|
||||
<span className="text-2xl">{mod.icon}</span>
|
||||
<h3 className="text-white font-semibold mt-3 group-hover:text-indigo-400 transition-colors">
|
||||
{mod.name}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm mt-1">Manage {mod.name.toLowerCase()}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||||
<div className="animate-pulse text-indigo-500 text-lg font-semibold">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout modules={allModules} />}>
|
||||
<Route index element={<OverviewPage />} />
|
||||
{allModules.map((mod) =>
|
||||
mod.routes.map((route) => (
|
||||
<Route key={route.path} path={route.path} element={route.element} />
|
||||
))
|
||||
)}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
86
products/console/src/shell/AuthProvider.tsx
Normal file
86
products/console/src/shell/AuthProvider.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import { apiFetch } from './api';
|
||||
|
||||
interface User {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
entitlements?: string[];
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
signup: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
function decodeJwtPayload(token: string): User {
|
||||
const base64 = token.split('.')[1];
|
||||
const json = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('dd0c_token');
|
||||
if (stored) {
|
||||
try {
|
||||
const payload = decodeJwtPayload(stored);
|
||||
setUser(payload);
|
||||
setToken(stored);
|
||||
} catch {
|
||||
localStorage.removeItem('dd0c_token');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<{ token: string }>('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
skipAuth: true,
|
||||
});
|
||||
localStorage.setItem('dd0c_token', res.token);
|
||||
setToken(res.token);
|
||||
setUser(decodeJwtPayload(res.token));
|
||||
}, []);
|
||||
|
||||
const signup = useCallback(async (email: string, password: string) => {
|
||||
const res = await apiFetch<{ token: string }>('/api/v1/auth/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
skipAuth: true,
|
||||
});
|
||||
localStorage.setItem('dd0c_token', res.token);
|
||||
setToken(res.token);
|
||||
setUser(decodeJwtPayload(res.token));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('dd0c_token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isLoading, login, signup, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
27
products/console/src/shell/EntitlementGate.tsx
Normal file
27
products/console/src/shell/EntitlementGate.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from './AuthProvider';
|
||||
|
||||
interface EntitlementGateProps {
|
||||
entitlement: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EntitlementGate({ entitlement, children }: EntitlementGateProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Dev mode: if no entitlements array, show everything
|
||||
if (!user?.entitlements || user.entitlements.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!user.entitlements.includes(entitlement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function hasEntitlement(entitlements: string[] | undefined, key: string): boolean {
|
||||
if (!entitlements || entitlements.length === 0) return true; // dev mode
|
||||
return entitlements.includes(key);
|
||||
}
|
||||
52
products/console/src/shell/Layout.tsx
Normal file
52
products/console/src/shell/Layout.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import type { ModuleManifest } from '../modules/drift/manifest.js';
|
||||
|
||||
interface LayoutProps {
|
||||
modules: ModuleManifest[];
|
||||
}
|
||||
|
||||
export function Layout({ modules }: LayoutProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950">
|
||||
<Sidebar
|
||||
modules={modules}
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
/>
|
||||
|
||||
{/* Topbar */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-14 z-20 flex items-center px-4 border-b border-gray-800 bg-gray-950/80 backdrop-blur-sm transition-all duration-200 ${
|
||||
collapsed ? 'left-0 lg:left-16' : 'left-0 lg:left-60'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="text-gray-400 hover:text-white transition-colors mr-4"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-gray-600">v0.1.0</span>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main
|
||||
className={`pt-14 min-h-screen transition-all duration-200 ${
|
||||
collapsed ? 'lg:pl-16' : 'lg:pl-60'
|
||||
}`}
|
||||
>
|
||||
<div className="p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
products/console/src/shell/LoginPage.tsx
Normal file
103
products/console/src/shell/LoginPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { Button } from '../shared/Button';
|
||||
|
||||
export function LoginPage() {
|
||||
const { login, signup } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSignup, setIsSignup] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isSignup) {
|
||||
await signup(email, password);
|
||||
} else {
|
||||
await login(email, password);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Authentication failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
<span className="text-indigo-500">dd0c</span> Console
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-2 text-sm">DevOps intelligence platform</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-xl p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{isSignup ? 'Create account' : 'Sign in'}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
||||
placeholder="you@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={loading}>
|
||||
{loading ? 'Loading…' : isSignup ? 'Create account' : 'Sign in'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
{isSignup ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsSignup(!isSignup); setError(null); }}
|
||||
className="text-indigo-400 hover:text-indigo-300 font-medium"
|
||||
>
|
||||
{isSignup ? 'Sign in' : 'Sign up'}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-600 mt-6">
|
||||
© {new Date().getFullYear()} dd0c · All rights reserved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
products/console/src/shell/Sidebar.tsx
Normal file
121
products/console/src/shell/Sidebar.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useAuth } from './AuthProvider';
|
||||
import { hasEntitlement } from './EntitlementGate';
|
||||
import type { ModuleManifest } from '../modules/drift/manifest.js';
|
||||
|
||||
interface SidebarProps {
|
||||
modules: ModuleManifest[];
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ modules, collapsed, onToggle }: SidebarProps) {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const visibleModules = modules.filter((m) =>
|
||||
hasEntitlement(user?.entitlements, m.entitlement)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{!collapsed && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed top-0 left-0 h-full z-40 bg-gray-950 border-r border-gray-800 flex flex-col transition-all duration-200 ${
|
||||
collapsed ? '-translate-x-full lg:translate-x-0 lg:w-16' : 'w-60'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center h-14 px-4 border-b border-gray-800 shrink-0">
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold tracking-tight">
|
||||
<span className="text-indigo-500">dd0c</span>
|
||||
<span className="text-gray-400 ml-1 text-sm font-normal">console</span>
|
||||
</span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<span className="text-lg font-bold text-indigo-500 mx-auto">d</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-500/10 text-indigo-400'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base">🏠</span>
|
||||
{!collapsed && <span>Overview</span>}
|
||||
</NavLink>
|
||||
|
||||
{visibleModules.length > 0 && (
|
||||
<div className="mt-4">
|
||||
{!collapsed && (
|
||||
<p className="px-4 mx-2 mb-2 text-[10px] font-semibold uppercase tracking-widest text-gray-600">
|
||||
Modules
|
||||
</p>
|
||||
)}
|
||||
{visibleModules.map((mod) => (
|
||||
<NavLink
|
||||
key={mod.id}
|
||||
to={mod.path}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-500/10 text-indigo-400'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base">{mod.icon}</span>
|
||||
{!collapsed && <span>{mod.name}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="border-t border-gray-800 p-3 shrink-0">
|
||||
{!collapsed ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white truncate">{user?.email}</p>
|
||||
<p className="text-[10px] text-gray-600 truncate">{user?.tenantId}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors text-xs shrink-0 ml-2"
|
||||
title="Sign out"
|
||||
>
|
||||
↪
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors text-xs w-full text-center"
|
||||
title="Sign out"
|
||||
>
|
||||
↪
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
products/console/src/shell/api.ts
Normal file
40
products/console/src/shell/api.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
|
||||
interface FetchOptions extends RequestInit {
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options: FetchOptions = {}): Promise<T> {
|
||||
const { skipAuth, ...fetchOptions } = options;
|
||||
const headers = new Headers(fetchOptions.headers);
|
||||
|
||||
if (!skipAuth) {
|
||||
const token = localStorage.getItem('dd0c_token');
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!headers.has('Content-Type') && fetchOptions.body) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new ApiError(res.status, body);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public body: string) {
|
||||
super(`API Error ${status}: ${body}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
9
products/console/src/vite-env.d.ts
vendored
Normal file
9
products/console/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
18
products/console/tailwind.config.js
Normal file
18
products/console/tailwind.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
primary: '#6366f1',
|
||||
accent: '#22d3ee',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
25
products/console/tsconfig.json
Normal file
25
products/console/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
products/console/vite.config.ts
Normal file
15
products/console/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user