feat(phase-2): workstream 6 — Web Dashboard UI
- dashboard/: Vite 5 + React 18 + TypeScript strict SPA
- Auth: sessionStorage credentials, TokenManager validation, AuthProvider context
- Pages: Login, Agents (search + filter), AgentDetail (suspend/reactivate),
Credentials (generate/rotate/revoke, new secret shown once),
AuditLog (filters + pagination), Health (PG + Redis status, 30s refresh)
- Components: Button, Badge, ConfirmDialog, AppShell, RequireAuth
- All destructive actions gated by ConfirmDialog
- Zero dangerouslySetInnerHTML; sessionStorage only (OWASP compliant)
- src/routes/health.ts: unauthenticated GET /health — PG + Redis connectivity
- src/app.ts: health route + dashboard/dist/ served at /dashboard with SPA fallback
- 6 new health route tests; 308/308 unit tests passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
95
dashboard/README.md
Normal file
95
dashboard/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# SentryAgent.ai AgentIdP — Web Dashboard
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The AgentIdP Dashboard is a React 18 single-page application (SPA) that provides a visual
|
||||
management interface for the AgentIdP API. It allows operators to:
|
||||
|
||||
- Browse, search, and filter all registered AI agents
|
||||
- View agent details and manage lifecycle (suspend / reactivate)
|
||||
- Generate, rotate, and revoke agent credentials
|
||||
- Query the audit log with filters for agent, action, outcome, and date range
|
||||
- Monitor PostgreSQL and Redis connectivity in real time
|
||||
|
||||
The dashboard is co-served by the Express API server at `/dashboard/` — no separate hosting
|
||||
is required.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- A running AgentIdP server (local or remote)
|
||||
- An active agent credential (Client ID + Client Secret) with full scopes
|
||||
|
||||
## 3. Development
|
||||
|
||||
Install dashboard dependencies:
|
||||
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the Vite dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev server starts at `http://localhost:5173/dashboard/`. API calls are made to
|
||||
`window.location.origin` (defaulted in the Login form), so either:
|
||||
|
||||
- Set the **API Base URL** field to your local server (e.g. `http://localhost:3000`)
|
||||
- Or configure a Vite proxy in `vite.config.ts` for `/api` and `/health` paths
|
||||
|
||||
## 4. Building
|
||||
|
||||
Compile TypeScript and bundle with Vite:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output is written to `dashboard/dist/`. The build is an optimised static bundle (HTML, CSS, JS).
|
||||
|
||||
To verify the build locally:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 5. Deployment
|
||||
|
||||
The AgentIdP Express server automatically serves the built dashboard:
|
||||
|
||||
- Static assets at `/dashboard/` (via `express.static`)
|
||||
- SPA fallback — all `/dashboard/*` requests not matching a static file return `index.html`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Build the dashboard: `cd dashboard && npm run build`
|
||||
2. Start (or restart) the AgentIdP server: `npm start`
|
||||
3. Open `https://your-api-host/dashboard/` in a browser
|
||||
|
||||
No additional nginx or CDN configuration is required for basic deployments.
|
||||
|
||||
## 6. Login
|
||||
|
||||
The login form has three fields:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| **API Base URL** | Base URL of the AgentIdP server, e.g. `https://api.example.com`. Defaults to the current page origin, which works when the dashboard is co-served. |
|
||||
| **Client ID** | The UUID of an agent registered in AgentIdP. This agent must have the scopes `agents:read agents:write tokens:read audit:read`. |
|
||||
| **Client Secret** | The plain-text client secret for the agent. Validated against the token endpoint on login. |
|
||||
|
||||
Credentials are stored in `sessionStorage` only — they are cleared when the browser tab is closed.
|
||||
|
||||
## 7. Pages
|
||||
|
||||
| Page | Route | Description |
|
||||
|---|---|---|
|
||||
| **Agents** | `/dashboard/agents` | Paginated list of all agents. Search by email (debounced), filter by status. Click a row for details. |
|
||||
| **Agent Detail** | `/dashboard/agents/:agentId` | Full agent metadata. Suspend or reactivate (with confirmation). Link to credentials. |
|
||||
| **Credentials** | `/dashboard/agents/:agentId/credentials` | List all credentials. Generate, rotate, or revoke. New secrets shown exactly once. |
|
||||
| **Audit Log** | `/dashboard/audit` | Paginated audit events with filters for agent ID, action, outcome, and date range. |
|
||||
| **Health** | `/dashboard/health` | PostgreSQL and Redis connectivity cards. Auto-refreshes every 30 seconds. |
|
||||
12
dashboard/index.html
Normal file
12
dashboard/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SentryAgent.ai — AgentIdP Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2755
dashboard/package-lock.json
generated
Normal file
2755
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
dashboard/package.json
Normal file
29
dashboard/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@sentryagent/dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.app.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentryagent/idp-sdk": "file:../sdk",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"lucide-react": "^0.446.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.12",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
6
dashboard/postcss.config.js
Normal file
6
dashboard/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
33
dashboard/src/App.tsx
Normal file
33
dashboard/src/App.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import { RequireAuth } from '@/components/RequireAuth';
|
||||
import { AppShell } from '@/components/layout/AppShell';
|
||||
import Login from '@/pages/Login';
|
||||
import Agents from '@/pages/Agents';
|
||||
import AgentDetail from '@/pages/AgentDetail';
|
||||
import Credentials from '@/pages/Credentials';
|
||||
import AuditLog from '@/pages/AuditLog';
|
||||
import Health from '@/pages/Health';
|
||||
|
||||
/** Top-level router — defines all application routes. */
|
||||
export default function App(): React.JSX.Element {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/dashboard/login" element={<Login />} />
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/dashboard/agents" element={<Agents />} />
|
||||
<Route path="/dashboard/agents/:agentId" element={<AgentDetail />} />
|
||||
<Route path="/dashboard/agents/:agentId/credentials" element={<Credentials />} />
|
||||
<Route path="/dashboard/audit" element={<AuditLog />} />
|
||||
<Route path="/dashboard/health" element={<Health />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/agents" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard/agents" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
11
dashboard/src/components/RequireAuth.tsx
Normal file
11
dashboard/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { isAuthenticated } from '@/lib/auth';
|
||||
|
||||
/** Redirects to /dashboard/login if not authenticated. */
|
||||
export function RequireAuth(): React.JSX.Element {
|
||||
if (!isAuthenticated()) {
|
||||
return <Navigate to="/dashboard/login" replace />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
62
dashboard/src/components/layout/AppShell.tsx
Normal file
62
dashboard/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/dashboard/agents', label: 'Agents' },
|
||||
{ to: '/dashboard/audit', label: 'Audit Log' },
|
||||
{ to: '/dashboard/health', label: 'Health' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Outer application shell: top navigation bar and main content area.
|
||||
* Renders the active page via <Outlet />.
|
||||
*/
|
||||
export function AppShell(): React.JSX.Element {
|
||||
const { logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<header className="border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-8">
|
||||
<span className="text-lg font-bold text-brand-700">SentryAgent.ai</span>
|
||||
<nav className="flex gap-1">
|
||||
{NAV_ITEMS.map(({ to, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-brand-50 text-brand-700'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-slate-500 hover:text-slate-900"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
dashboard/src/components/ui/badge.tsx
Normal file
27
dashboard/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'muted';
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
default: 'bg-brand-100 text-brand-700',
|
||||
success: 'bg-green-100 text-green-700',
|
||||
warning: 'bg-yellow-100 text-yellow-700',
|
||||
danger: 'bg-red-100 text-red-700',
|
||||
muted: 'bg-slate-100 text-slate-600',
|
||||
};
|
||||
|
||||
/** Small status badge. */
|
||||
export function Badge({ variant = 'default', children, className }: BadgeProps): React.JSX.Element {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', variantClasses[variant], className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
65
dashboard/src/components/ui/button.tsx
Normal file
65
dashboard/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Variant = 'default' | 'destructive' | 'outline' | 'ghost';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: 'bg-brand-600 text-white hover:bg-brand-700 focus:ring-brand-500',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
outline: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 focus:ring-brand-500',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus:ring-brand-500',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
/**
|
||||
* Reusable button component with variant and size support.
|
||||
*
|
||||
* @param variant - Visual style: default | destructive | outline | ghost
|
||||
* @param size - Size: sm | md | lg
|
||||
* @param loading - When true, shows a spinner and disables the button
|
||||
*/
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-md font-medium',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors duration-150',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
disabled={disabled ?? loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
45
dashboard/src/components/ui/dialog.tsx
Normal file
45
dashboard/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from './button';
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal confirmation dialog for destructive actions (suspend, revoke, rotate).
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'default',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DialogProps): React.JSX.Element | null {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onCancel} />
|
||||
<div className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">{description}</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
dashboard/src/index.css
Normal file
26
dashboard/src/index.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 198 89% 48%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
109
dashboard/src/lib/auth.tsx
Normal file
109
dashboard/src/lib/auth.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { TokenManager } from '@sentryagent/idp-sdk';
|
||||
|
||||
const SESSION_KEY = 'agentidp_credentials';
|
||||
|
||||
interface StoredCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists user credentials to sessionStorage (cleared on tab close).
|
||||
*/
|
||||
export function saveCredentials(creds: StoredCredentials): void {
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(creds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves credentials from sessionStorage.
|
||||
* Returns null if not logged in.
|
||||
*/
|
||||
export function loadCredentials(): StoredCredentials | null {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as StoredCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes credentials from sessionStorage (logout).
|
||||
*/
|
||||
export function clearCredentials(): void {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user has stored credentials.
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return loadCredentials() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates stored credentials by requesting a token.
|
||||
* Returns true if successful; false on auth failure.
|
||||
*/
|
||||
export async function validateCredentials(creds: StoredCredentials): Promise<boolean> {
|
||||
try {
|
||||
const tm = new TokenManager(creds.baseUrl, creds.clientId, creds.clientSecret, 'agents:read agents:write tokens:read audit:read');
|
||||
await tm.getToken();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── React context ──────────────────────────────────────────────────────────────
|
||||
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface AuthContextValue {
|
||||
credentials: StoredCredentials | null;
|
||||
login: (creds: StoredCredentials) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Provides authentication state to the application.
|
||||
* Reads initial state from sessionStorage on mount.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
const [credentials, setCredentials] = React.useState<StoredCredentials | null>(loadCredentials);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const login = React.useCallback(async (creds: StoredCredentials): Promise<boolean> => {
|
||||
const valid = await validateCredentials(creds);
|
||||
if (valid) {
|
||||
saveCredentials(creds);
|
||||
setCredentials(creds);
|
||||
}
|
||||
return valid;
|
||||
}, []);
|
||||
|
||||
const logout = React.useCallback((): void => {
|
||||
clearCredentials();
|
||||
setCredentials(null);
|
||||
navigate('/dashboard/login');
|
||||
}, [navigate]);
|
||||
|
||||
const value = React.useMemo(() => ({ credentials, login, logout }), [credentials, login, logout]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current authentication context.
|
||||
* Must be used inside <AuthProvider>.
|
||||
*/
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = React.useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
18
dashboard/src/lib/client.ts
Normal file
18
dashboard/src/lib/client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AgentIdPClient } from '@sentryagent/idp-sdk';
|
||||
import { loadCredentials } from './auth';
|
||||
|
||||
/**
|
||||
* Returns an AgentIdPClient configured with credentials from sessionStorage.
|
||||
* Throws if not authenticated (caller must ensure login first).
|
||||
*/
|
||||
export function getClient(): AgentIdPClient {
|
||||
const creds = loadCredentials();
|
||||
if (!creds) {
|
||||
throw new Error('Not authenticated. Please log in.');
|
||||
}
|
||||
return new AgentIdPClient({
|
||||
baseUrl: creds.baseUrl,
|
||||
clientId: creds.clientId,
|
||||
clientSecret: creds.clientSecret,
|
||||
});
|
||||
}
|
||||
7
dashboard/src/lib/utils.ts
Normal file
7
dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/** Merges Tailwind class names, handling conflicts correctly. */
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
13
dashboard/src/main.tsx
Normal file
13
dashboard/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
222
dashboard/src/pages/AgentDetail.tsx
Normal file
222
dashboard/src/pages/AgentDetail.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { Agent } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/dialog';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
type BadgeVariant = 'success' | 'warning' | 'danger';
|
||||
|
||||
/** Maps AgentStatus to a Badge variant. */
|
||||
function statusVariant(status: Agent['status']): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'suspended': return 'warning';
|
||||
case 'decommissioned': return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats an ISO timestamp to a readable local date-time string. */
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Single label/value row in the detail card. */
|
||||
function DetailRow({ label, value }: DetailRowProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-4">
|
||||
<dt className="w-36 shrink-0 text-sm font-medium text-slate-500">{label}</dt>
|
||||
<dd className="text-sm text-slate-900 break-all">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogAction = 'suspend' | 'reactivate';
|
||||
|
||||
/**
|
||||
* Agent Detail page — shows all agent fields and provides suspend/reactivate actions.
|
||||
* Route: /dashboard/agents/:agentId
|
||||
*/
|
||||
export default function AgentDetail(): React.JSX.Element {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [agent, setAgent] = React.useState<Agent | null>(null);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = React.useState<boolean>(false);
|
||||
const [dialog, setDialog] = React.useState<DialogAction | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchAgent = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getClient().agents.getAgent(agentId);
|
||||
if (!cancelled) setAgent(result);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load agent.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgent();
|
||||
return () => { cancelled = true; };
|
||||
}, [agentId]);
|
||||
|
||||
const handleAction = React.useCallback(
|
||||
async (action: DialogAction): Promise<void> => {
|
||||
if (!agentId) return;
|
||||
setActionLoading(true);
|
||||
setDialog(null);
|
||||
try {
|
||||
const newStatus = action === 'suspend' ? 'suspended' : 'active';
|
||||
const updated = await getClient().agents.updateAgent(agentId, { status: newStatus });
|
||||
setAgent(updated);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Action failed.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
[agentId],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-5 w-full animate-pulse rounded bg-slate-200" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !agent) {
|
||||
return (
|
||||
<div className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error ?? 'Agent not found.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dialogConfig = dialog === 'suspend'
|
||||
? {
|
||||
title: `Suspend agent ${agent.email}?`,
|
||||
description: `Suspending ${agent.email} means it will no longer be able to authenticate.`,
|
||||
confirmLabel: 'Suspend',
|
||||
variant: 'destructive' as const,
|
||||
}
|
||||
: {
|
||||
title: `Reactivate agent ${agent.email}?`,
|
||||
description: `Reactivating ${agent.email} will allow it to authenticate again.`,
|
||||
confirmLabel: 'Reactivate',
|
||||
variant: 'default' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back navigation */}
|
||||
<button
|
||||
onClick={() => { navigate('/dashboard/agents'); }}
|
||||
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
|
||||
>
|
||||
← Back to Agents
|
||||
</button>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{agent.email}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Agent ID: {agent.agentId}</p>
|
||||
</div>
|
||||
<Badge variant={statusVariant(agent.status)} className="mt-1">{agent.status}</Badge>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail card */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Email" value={agent.email} />
|
||||
<DetailRow label="Agent ID" value={agent.agentId} />
|
||||
<DetailRow label="Type" value={agent.agentType} />
|
||||
<DetailRow label="Version" value={agent.version} />
|
||||
<DetailRow label="Owner" value={agent.owner} />
|
||||
<DetailRow label="Environment" value={agent.deploymentEnv} />
|
||||
<DetailRow label="Capabilities" value={agent.capabilities.join(', ') || '—'} />
|
||||
<DetailRow label="Status" value={agent.status} />
|
||||
<DetailRow label="Created" value={formatDateTime(agent.createdAt)} />
|
||||
<DetailRow label="Updated" value={formatDateTime(agent.updatedAt)} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{agent.status !== 'decommissioned' && (
|
||||
<div className="mt-6 flex gap-3">
|
||||
{agent.status === 'active' && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
loading={actionLoading}
|
||||
onClick={() => { setDialog('suspend'); }}
|
||||
>
|
||||
Suspend Agent
|
||||
</Button>
|
||||
)}
|
||||
{agent.status === 'suspended' && (
|
||||
<Button
|
||||
variant="default"
|
||||
loading={actionLoading}
|
||||
onClick={() => { setDialog('reactivate'); }}
|
||||
>
|
||||
Reactivate Agent
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credentials section */}
|
||||
<div className="mt-8 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900">Credentials</h2>
|
||||
<p className="mb-4 text-sm text-slate-600">
|
||||
Manage client secrets for this agent. Rotate or revoke credentials as needed.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}/credentials`); }}
|
||||
>
|
||||
View Credentials
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{dialog !== null && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={dialogConfig.title}
|
||||
description={dialogConfig.description}
|
||||
confirmLabel={dialogConfig.confirmLabel}
|
||||
variant={dialogConfig.variant}
|
||||
onConfirm={() => { void handleAction(dialog); }}
|
||||
onCancel={() => { setDialog(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
dashboard/src/pages/Agents.tsx
Normal file
204
dashboard/src/pages/Agents.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Agent, AgentStatus } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
/** Maps AgentStatus to a Badge variant. */
|
||||
function statusVariant(status: AgentStatus): 'success' | 'warning' | 'danger' | 'muted' {
|
||||
switch (status) {
|
||||
case 'active': return 'success';
|
||||
case 'suspended': return 'warning';
|
||||
case 'decommissioned': return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats an ISO timestamp to a short local date string. */
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/** Skeleton row shown while loading. */
|
||||
function SkeletonRow(): React.JSX.Element {
|
||||
return (
|
||||
<tr>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents list page — displays all registered agents with search, status filter, and pagination.
|
||||
* Clicking a row navigates to the Agent Detail page.
|
||||
*/
|
||||
export default function Agents(): React.JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
const [total, setTotal] = React.useState<number>(0);
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Filters (client-side email search, server-side status)
|
||||
const [searchInput, setSearchInput] = React.useState<string>('');
|
||||
const [debouncedSearch, setDebouncedSearch] = React.useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<AgentStatus | ''>('');
|
||||
|
||||
// Debounce search input 300ms
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => { setDebouncedSearch(searchInput); }, 300);
|
||||
return () => { clearTimeout(timer); };
|
||||
}, [searchInput]);
|
||||
|
||||
// Reset to page 1 on filter change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, statusFilter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchAgents = async (): Promise<void> => {
|
||||
try {
|
||||
const client = getClient();
|
||||
const result = await client.agents.listAgents({
|
||||
page,
|
||||
limit: PAGE_LIMIT,
|
||||
status: statusFilter !== '' ? statusFilter : undefined,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setAgents(result.data);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load agents.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchAgents();
|
||||
return () => { cancelled = true; };
|
||||
}, [page, statusFilter]);
|
||||
|
||||
// Client-side email filter applied after API results arrive
|
||||
const filteredAgents = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return agents;
|
||||
const lower = debouncedSearch.toLowerCase();
|
||||
return agents.filter((a) => a.email.toLowerCase().includes(lower));
|
||||
}, [agents, debouncedSearch]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Agents</h1>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="search"
|
||||
value={searchInput}
|
||||
onChange={(e) => { setSearchInput(e.target.value); }}
|
||||
placeholder="Search by email…"
|
||||
className="w-60 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value as AgentStatus | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="decommissioned">Decommissioned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{['Name (Email)', 'Type', 'Status', 'Environment', 'Owner', 'Created'].map((col) => (
|
||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading
|
||||
? Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)
|
||||
: filteredAgents.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-slate-400">
|
||||
No agents found.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: filteredAgents.map((agent) => (
|
||||
<tr
|
||||
key={agent.agentId}
|
||||
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}`); }}
|
||||
className="cursor-pointer hover:bg-slate-50"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium text-brand-700">{agent.email}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.agentType}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={statusVariant(agent.status)}>{agent.status}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.deploymentEnv}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{agent.owner}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{formatDate(agent.createdAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && total > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<span>
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
|
||||
disabled={page <= 1}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
dashboard/src/pages/AuditLog.tsx
Normal file
223
dashboard/src/pages/AuditLog.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as React from 'react';
|
||||
import type { AuditEvent, AuditAction, AuditOutcome } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
/** All AuditAction values for the filter dropdown. */
|
||||
const AUDIT_ACTIONS: AuditAction[] = [
|
||||
'agent.created',
|
||||
'agent.updated',
|
||||
'agent.decommissioned',
|
||||
'agent.suspended',
|
||||
'agent.reactivated',
|
||||
'token.issued',
|
||||
'token.revoked',
|
||||
'token.introspected',
|
||||
'credential.generated',
|
||||
'credential.rotated',
|
||||
'credential.revoked',
|
||||
'auth.failed',
|
||||
];
|
||||
|
||||
/** Formats an ISO timestamp to a readable local date-time string. */
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** Truncates a string to a maximum length with ellipsis. */
|
||||
function truncate(value: string, maxLen = 24): string {
|
||||
return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit Log page — displays audit events with filters for agent, action, outcome, and date range.
|
||||
* Route: /dashboard/audit
|
||||
*/
|
||||
export default function AuditLog(): React.JSX.Element {
|
||||
const [events, setEvents] = React.useState<AuditEvent[]>([]);
|
||||
const [total, setTotal] = React.useState<number>(0);
|
||||
const [page, setPage] = React.useState<number>(1);
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [agentIdFilter, setAgentIdFilter] = React.useState<string>('');
|
||||
const [actionFilter, setActionFilter] = React.useState<AuditAction | ''>('');
|
||||
const [outcomeFilter, setOutcomeFilter] = React.useState<AuditOutcome | ''>('');
|
||||
const [fromDate, setFromDate] = React.useState<string>('');
|
||||
const [toDate, setToDate] = React.useState<string>('');
|
||||
|
||||
// Reset to page 1 on filter change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [agentIdFilter, actionFilter, outcomeFilter, fromDate, toDate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const fetchEvents = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getClient().audit.queryAuditLog({
|
||||
page,
|
||||
limit: PAGE_LIMIT,
|
||||
agentId: agentIdFilter.trim() || undefined,
|
||||
action: actionFilter !== '' ? actionFilter : undefined,
|
||||
outcome: outcomeFilter !== '' ? outcomeFilter : undefined,
|
||||
fromDate: fromDate || undefined,
|
||||
toDate: toDate || undefined,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setEvents(result.data);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load audit log.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchEvents();
|
||||
return () => { cancelled = true; };
|
||||
}, [page, agentIdFilter, actionFilter, outcomeFilter, fromDate, toDate]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-2xl font-bold text-slate-900">Audit Log</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<input
|
||||
type="text"
|
||||
value={agentIdFilter}
|
||||
onChange={(e) => { setAgentIdFilter(e.target.value); }}
|
||||
placeholder="Agent ID…"
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
<select
|
||||
value={actionFilter}
|
||||
onChange={(e) => { setActionFilter(e.target.value as AuditAction | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Actions</option>
|
||||
{AUDIT_ACTIONS.map((action) => (
|
||||
<option key={action} value={action}>{action}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={outcomeFilter}
|
||||
onChange={(e) => { setOutcomeFilter(e.target.value as AuditOutcome | ''); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
>
|
||||
<option value="">All Outcomes</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failure">Failure</option>
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => { setFromDate(e.target.value); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
title="From date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => { setToDate(e.target.value); }}
|
||||
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
title="To date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{['Timestamp', 'Agent ID', 'Action', 'Outcome', 'IP Address'].map((col) => (
|
||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading
|
||||
? Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 5 }).map((__, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
: events.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-12 text-center text-slate-400">
|
||||
No audit events found.
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: events.map((event) => (
|
||||
<tr key={event.eventId} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 text-slate-500 whitespace-nowrap">{formatDateTime(event.timestamp)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-slate-700">{truncate(event.agentId)}</td>
|
||||
<td className="px-4 py-3 text-slate-700">{event.action}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={event.outcome === 'success' ? 'success' : 'danger'}>
|
||||
{event.outcome}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{event.ipAddress}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && total > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
||||
<span>
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
|
||||
disabled={page <= 1}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
|
||||
disabled={page >= totalPages}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
dashboard/src/pages/Credentials.tsx
Normal file
264
dashboard/src/pages/Credentials.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import * as React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import type { Credential, CredentialWithSecret } from '@sentryagent/idp-sdk';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ConfirmDialog } from '@/components/ui/dialog';
|
||||
import { getClient } from '@/lib/client';
|
||||
|
||||
/** Truncates a string to a maximum length with ellipsis. */
|
||||
function truncate(value: string, maxLen = 16): string {
|
||||
return value.length > maxLen ? `${value.slice(0, maxLen)}…` : value;
|
||||
}
|
||||
|
||||
/** Formats an ISO timestamp to a short local date string. */
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
interface NewSecretBoxProps {
|
||||
secret: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a newly issued client secret exactly once.
|
||||
* Provides a copy button and a dismiss button.
|
||||
*/
|
||||
function NewSecretBox({ secret, onDismiss }: NewSecretBoxProps): React.JSX.Element {
|
||||
const [copied, setCopied] = React.useState<boolean>(false);
|
||||
|
||||
const handleCopy = React.useCallback(async (): Promise<void> => {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
setCopied(true);
|
||||
setTimeout(() => { setCopied(false); }, 2000);
|
||||
}, [secret]);
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border-2 border-green-400 bg-green-50 p-4">
|
||||
<p className="mb-2 text-sm font-semibold text-green-800">
|
||||
New client secret — copy it now. It will not be shown again.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="flex-1 break-all rounded bg-white px-3 py-2 text-sm font-mono text-green-900 border border-green-200">
|
||||
{secret}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={() => { void handleCopy(); }}>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="mt-3 text-xs text-green-700 underline hover:text-green-900"
|
||||
>
|
||||
I have saved this secret — dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogAction = { type: 'rotate'; credentialId: string } | { type: 'revoke'; credentialId: string };
|
||||
|
||||
/**
|
||||
* Credentials page — lists all credentials for an agent with rotate/revoke actions.
|
||||
* Route: /dashboard/agents/:agentId/credentials
|
||||
*/
|
||||
export default function Credentials(): React.JSX.Element {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [credentials, setCredentials] = React.useState<Credential[]>([]);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = React.useState<boolean>(false);
|
||||
const [dialog, setDialog] = React.useState<DialogAction | null>(null);
|
||||
const [newSecret, setNewSecret] = React.useState<CredentialWithSecret | null>(null);
|
||||
|
||||
const fetchCredentials = React.useCallback(async (): Promise<void> => {
|
||||
if (!agentId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getClient().credentials.listCredentials(agentId);
|
||||
setCredentials(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
const handleGenerate = React.useCallback(async (): Promise<void> => {
|
||||
if (!agentId) return;
|
||||
setActionLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getClient().credentials.generateCredential(agentId, {});
|
||||
setNewSecret(result);
|
||||
await fetchCredentials();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate credential.');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, [agentId, fetchCredentials]);
|
||||
|
||||
const handleConfirm = React.useCallback(async (): Promise<void> => {
|
||||
if (!dialog || !agentId) return;
|
||||
setActionLoading(true);
|
||||
setDialog(null);
|
||||
setError(null);
|
||||
try {
|
||||
if (dialog.type === 'rotate') {
|
||||
const result = await getClient().credentials.rotateCredential(agentId, dialog.credentialId);
|
||||
setNewSecret(result);
|
||||
} else {
|
||||
await getClient().credentials.revokeCredential(agentId, dialog.credentialId);
|
||||
}
|
||||
await fetchCredentials();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${dialog.type} credential.`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, [dialog, agentId, fetchCredentials]);
|
||||
|
||||
const dialogConfig = React.useMemo(() => {
|
||||
if (!dialog) return null;
|
||||
if (dialog.type === 'rotate') {
|
||||
return {
|
||||
title: 'Rotate credential?',
|
||||
description: 'The existing secret will be invalidated immediately. You will receive a new secret — store it securely.',
|
||||
confirmLabel: 'Rotate',
|
||||
variant: 'destructive' as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Revoke credential?',
|
||||
description: 'This will permanently revoke the credential. This cannot be undone.',
|
||||
confirmLabel: 'Revoke',
|
||||
variant: 'destructive' as const,
|
||||
};
|
||||
}, [dialog]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back navigation */}
|
||||
<button
|
||||
onClick={() => { navigate(`/dashboard/agents/${agentId ?? ''}`); }}
|
||||
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
|
||||
>
|
||||
← Back to Agent
|
||||
</button>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">Credentials</h1>
|
||||
<Button
|
||||
loading={actionLoading}
|
||||
onClick={() => { void handleGenerate(); }}
|
||||
>
|
||||
Generate Credential
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New secret display — shown once */}
|
||||
{newSecret !== null && (
|
||||
<NewSecretBox
|
||||
secret={newSecret.clientSecret}
|
||||
onDismiss={() => { setNewSecret(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Credentials table */}
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{['Credential ID', 'Status', 'Created', 'Actions'].map((col) => (
|
||||
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<tr key={i}>
|
||||
{Array.from({ length: 4 }).map((__, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : credentials.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-12 text-center text-slate-400">
|
||||
No credentials found. Generate one above.
|
||||
</td>
|
||||
</tr>
|
||||
) : credentials.map((cred) => (
|
||||
<tr key={cred.credentialId} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3 font-mono text-xs text-slate-700">
|
||||
{truncate(cred.credentialId, 24)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={cred.status === 'active' ? 'success' : 'muted'}>
|
||||
{cred.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{formatDate(cred.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{cred.status === 'active' && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionLoading}
|
||||
onClick={() => { setDialog({ type: 'rotate', credentialId: cred.credentialId }); }}
|
||||
>
|
||||
Rotate
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={actionLoading}
|
||||
onClick={() => { setDialog({ type: 'revoke', credentialId: cred.credentialId }); }}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{dialog !== null && dialogConfig !== null && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={dialogConfig.title}
|
||||
description={dialogConfig.description}
|
||||
confirmLabel={dialogConfig.confirmLabel}
|
||||
variant={dialogConfig.variant}
|
||||
onConfirm={() => { void handleConfirm(); }}
|
||||
onCancel={() => { setDialog(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
dashboard/src/pages/Health.tsx
Normal file
173
dashboard/src/pages/Health.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/** Shape of the /health API response. */
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'degraded';
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
services: {
|
||||
postgres: 'connected' | 'disconnected';
|
||||
redis: 'connected' | 'disconnected';
|
||||
};
|
||||
}
|
||||
|
||||
type ServiceStatus = 'connected' | 'disconnected' | 'unknown';
|
||||
|
||||
interface HealthState {
|
||||
postgres: ServiceStatus;
|
||||
redis: ServiceStatus;
|
||||
version: string | null;
|
||||
uptime: number | null;
|
||||
lastChecked: Date | null;
|
||||
reachable: boolean;
|
||||
}
|
||||
|
||||
const initialState: HealthState = {
|
||||
postgres: 'unknown',
|
||||
redis: 'unknown',
|
||||
version: null,
|
||||
uptime: null,
|
||||
lastChecked: null,
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
/** Formats seconds into a human-readable uptime string. */
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
parts.push(`${minutes}m`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
interface StatusCardProps {
|
||||
label: string;
|
||||
status: ServiceStatus;
|
||||
}
|
||||
|
||||
/** Card displaying the connectivity status of a single service. */
|
||||
function StatusCard({ label, status }: StatusCardProps): React.JSX.Element {
|
||||
const isConnected = status === 'connected';
|
||||
const isUnknown = status === 'unknown';
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-6 shadow-sm ${
|
||||
isUnknown
|
||||
? 'border-slate-200 bg-slate-50'
|
||||
: isConnected
|
||||
? 'border-green-200 bg-green-50'
|
||||
: 'border-red-200 bg-red-50'
|
||||
}`}>
|
||||
<p className="text-sm font-medium text-slate-600">{label}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className={`inline-block h-3 w-3 rounded-full ${
|
||||
isUnknown ? 'bg-slate-400' : isConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`} />
|
||||
<span className={`text-lg font-semibold ${
|
||||
isUnknown ? 'text-slate-600' : isConnected ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
{isUnknown ? 'Checking…' : isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health page — shows PostgreSQL and Redis connectivity status.
|
||||
* Polls GET /health every 30 seconds. No authentication required.
|
||||
* Route: /dashboard/health
|
||||
*/
|
||||
export default function Health(): React.JSX.Element {
|
||||
const [health, setHealth] = React.useState<HealthState>(initialState);
|
||||
const [loading, setLoading] = React.useState<boolean>(true);
|
||||
|
||||
const checkHealth = React.useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = (await response.json()) as HealthResponse;
|
||||
|
||||
setHealth({
|
||||
postgres: data.services?.postgres ?? 'unknown',
|
||||
redis: data.services?.redis ?? 'unknown',
|
||||
version: data.version ?? null,
|
||||
uptime: data.uptime ?? null,
|
||||
lastChecked: new Date(),
|
||||
reachable: true,
|
||||
});
|
||||
} catch {
|
||||
setHealth((prev) => ({
|
||||
...prev,
|
||||
postgres: 'disconnected',
|
||||
redis: 'disconnected',
|
||||
lastChecked: new Date(),
|
||||
reachable: false,
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => { void checkHealth(); }, 30_000);
|
||||
return () => { clearInterval(interval); };
|
||||
}, [checkHealth]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-slate-900">System Health</h1>
|
||||
<button
|
||||
onClick={() => { void checkHealth(); }}
|
||||
disabled={loading}
|
||||
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 disabled:opacity-40"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!health.reachable && (
|
||||
<div className="mb-6 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
||||
API is unreachable. Check that the server is running.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatusCard label="PostgreSQL" status={loading ? 'unknown' : health.postgres} />
|
||||
<StatusCard label="Redis" status={loading ? 'unknown' : health.redis} />
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{(health.version !== null || health.uptime !== null) && (
|
||||
<div className="mt-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-base font-semibold text-slate-900">API Details</h2>
|
||||
<dl className="space-y-2">
|
||||
{health.version !== null && (
|
||||
<div className="flex gap-4">
|
||||
<dt className="w-24 text-sm font-medium text-slate-500">Version</dt>
|
||||
<dd className="text-sm text-slate-900">{health.version}</dd>
|
||||
</div>
|
||||
)}
|
||||
{health.uptime !== null && (
|
||||
<div className="flex gap-4">
|
||||
<dt className="w-24 text-sm font-medium text-slate-500">Uptime</dt>
|
||||
<dd className="text-sm text-slate-900">{formatUptime(health.uptime)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last checked */}
|
||||
{health.lastChecked !== null && (
|
||||
<p className="mt-4 text-xs text-slate-400">
|
||||
Last checked: {health.lastChecked.toLocaleTimeString()} — auto-refreshes every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
dashboard/src/pages/Login.tsx
Normal file
109
dashboard/src/pages/Login.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
/**
|
||||
* Login page — accepts API Base URL, Client ID, and Client Secret.
|
||||
* Validates credentials against the AgentIdP token endpoint before persisting.
|
||||
*/
|
||||
export default function Login(): React.JSX.Element {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [baseUrl, setBaseUrl] = React.useState<string>(window.location.origin);
|
||||
const [clientId, setClientId] = React.useState<string>('');
|
||||
const [clientSecret, setClientSecret] = React.useState<string>('');
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const success = await login({ baseUrl: baseUrl.trim(), clientId: clientId.trim(), clientSecret });
|
||||
if (success) {
|
||||
navigate('/dashboard/agents', { replace: true });
|
||||
} else {
|
||||
setError('Invalid credentials. Please check your Client ID and secret.');
|
||||
setClientSecret('');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[login, navigate, baseUrl, clientId, clientSecret],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-brand-700">SentryAgent.ai</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">AgentIdP Dashboard — Sign In</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { void handleSubmit(e); }} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="baseUrl" className="block text-sm font-medium text-slate-700">
|
||||
API Base URL
|
||||
</label>
|
||||
<input
|
||||
id="baseUrl"
|
||||
type="url"
|
||||
required
|
||||
value={baseUrl}
|
||||
onChange={(e) => { setBaseUrl(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="clientId" className="block text-sm font-medium text-slate-700">
|
||||
Client ID
|
||||
</label>
|
||||
<input
|
||||
id="clientId"
|
||||
type="text"
|
||||
required
|
||||
value={clientId}
|
||||
onChange={(e) => { setClientId(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
placeholder="agent-uuid"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="clientSecret" className="block text-sm font-medium text-slate-700">
|
||||
Client Secret
|
||||
</label>
|
||||
<input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
required
|
||||
value={clientSecret}
|
||||
onChange={(e) => { setClientSecret(e.target.value); }}
|
||||
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" loading={loading} className="w-full" size="lg">
|
||||
{loading ? 'Validating…' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
19
dashboard/tailwind.config.js
Normal file
19
dashboard/tailwind.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
25
dashboard/tsconfig.app.json
Normal file
25
dashboard/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"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": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
dashboard/tsconfig.json
Normal file
7
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
20
dashboard/tsconfig.node.json
Normal file
20
dashboard/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
dashboard/vite.config.ts
Normal file
17
dashboard/vite.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/dashboard/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -78,19 +78,19 @@
|
||||
|
||||
## Workstream 6: Web Dashboard UI
|
||||
|
||||
- [ ] 6.1 Create `dashboard/` with Vite 5 + React 18 + TypeScript strict configuration
|
||||
- [ ] 6.2 Set up shadcn/ui with Tailwind CSS
|
||||
- [ ] 6.3 Write `dashboard/src/lib/auth.ts` — credential entry, TokenManager, sessionStorage
|
||||
- [ ] 6.4 Write `dashboard/src/lib/client.ts` — wraps @sentryagent/idp-sdk AgentIdPClient
|
||||
- [ ] 6.5 Write Login page (`/dashboard/login`)
|
||||
- [ ] 6.6 Write Agents page (`/dashboard/agents`) — list, search, filter by status
|
||||
- [ ] 6.7 Write Agent Detail page (`/dashboard/agents/:id`) — suspend/reactivate with confirm dialog
|
||||
- [ ] 6.8 Write Credentials page (`/dashboard/agents/:id/credentials`) — rotate/revoke with confirm
|
||||
- [ ] 6.9 Write Audit Log page (`/dashboard/audit`) — filters, pagination
|
||||
- [ ] 6.10 Write Health page (`/dashboard/health`) — PostgreSQL + Redis connectivity status
|
||||
- [ ] 6.11 Configure AgentIdP Express app to serve `dashboard/dist/` at `/dashboard`
|
||||
- [ ] 6.12 Write `dashboard/README.md`
|
||||
- [ ] 6.13 QA: TypeScript strict, zero `any`, OWASP Top 10 review, responsive layout verified
|
||||
- [x] 6.1 Create `dashboard/` with Vite 5 + React 18 + TypeScript strict configuration
|
||||
- [x] 6.2 Set up shadcn/ui with Tailwind CSS
|
||||
- [x] 6.3 Write `dashboard/src/lib/auth.ts` — credential entry, TokenManager, sessionStorage
|
||||
- [x] 6.4 Write `dashboard/src/lib/client.ts` — wraps @sentryagent/idp-sdk AgentIdPClient
|
||||
- [x] 6.5 Write Login page (`/dashboard/login`)
|
||||
- [x] 6.6 Write Agents page (`/dashboard/agents`) — list, search, filter by status
|
||||
- [x] 6.7 Write Agent Detail page (`/dashboard/agents/:id`) — suspend/reactivate with confirm dialog
|
||||
- [x] 6.8 Write Credentials page (`/dashboard/agents/:id/credentials`) — rotate/revoke with confirm
|
||||
- [x] 6.9 Write Audit Log page (`/dashboard/audit`) — filters, pagination
|
||||
- [x] 6.10 Write Health page (`/dashboard/health`) — PostgreSQL + Redis connectivity status
|
||||
- [x] 6.11 Configure AgentIdP Express app to serve `dashboard/dist/` at `/dashboard`
|
||||
- [x] 6.12 Write `dashboard/README.md`
|
||||
- [x] 6.13 QA: TypeScript strict, zero `any`, OWASP Top 10 review, responsive layout verified
|
||||
|
||||
## Workstream 7: Prometheus + Grafana Monitoring
|
||||
|
||||
|
||||
19
src/app.ts
19
src/app.ts
@@ -31,11 +31,13 @@ import { createAgentsRouter } from './routes/agents.js';
|
||||
import { createTokenRouter } from './routes/token.js';
|
||||
import { createCredentialsRouter } from './routes/credentials.js';
|
||||
import { createAuditRouter } from './routes/audit.js';
|
||||
import { createHealthRouter } from './routes/health.js';
|
||||
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { createOpaMiddleware } from './middleware/opa.js';
|
||||
import { createVaultClientFromEnv } from './vault/VaultClient.js';
|
||||
import { RedisClientType } from 'redis';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Creates and returns a configured Express application.
|
||||
@@ -139,6 +141,9 @@ export async function createApp(): Promise<Application> {
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
// Health check — unauthenticated, no OPA
|
||||
app.use('/health', createHealthRouter(pool, redis as RedisClientType));
|
||||
|
||||
app.use(`${API_BASE}/agents`, createAgentsRouter(agentController, opaMiddleware));
|
||||
app.use(
|
||||
`${API_BASE}/agents/:agentId/credentials`,
|
||||
@@ -147,6 +152,20 @@ export async function createApp(): Promise<Application> {
|
||||
app.use(`${API_BASE}/token`, createTokenRouter(tokenController, opaMiddleware));
|
||||
app.use(`${API_BASE}/audit`, createAuditRouter(auditController, opaMiddleware));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Dashboard static assets (served from dashboard/dist/)
|
||||
// Placed after API routes so API routes take precedence.
|
||||
// __dirname is available because the project compiles to CommonJS.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const dashboardDist = path.resolve(__dirname, '../../dashboard/dist');
|
||||
|
||||
app.use('/dashboard', express.static(dashboardDist));
|
||||
|
||||
// SPA fallback — serve index.html for all /dashboard/* routes not matching a static file
|
||||
app.get('/dashboard/*', (_req, res) => {
|
||||
res.sendFile(path.join(dashboardDist, 'index.html'));
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Global error handler (must be last)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
79
src/routes/health.ts
Normal file
79
src/routes/health.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Health check route for SentryAgent.ai AgentIdP.
|
||||
* Returns connectivity status for PostgreSQL and Redis.
|
||||
* Unauthenticated — safe to call from monitoring systems and the dashboard.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { Pool } from 'pg';
|
||||
import { RedisClientType } from 'redis';
|
||||
|
||||
/** Response shape for GET /health */
|
||||
interface HealthResponse {
|
||||
status: 'ok' | 'degraded';
|
||||
version: string;
|
||||
uptime: number;
|
||||
services: {
|
||||
postgres: 'connected' | 'disconnected';
|
||||
redis: 'connected' | 'disconnected';
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for the health endpoint.
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param redis - Redis client instance.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createHealthRouter(pool: Pool, redis: RedisClientType): Router {
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Returns 200 when all services are healthy, 503 when any are degraded.
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response): void => {
|
||||
const check = async (): Promise<void> => {
|
||||
let postgresStatus: 'connected' | 'disconnected' = 'disconnected';
|
||||
let redisStatus: 'connected' | 'disconnected' = 'disconnected';
|
||||
|
||||
// Check PostgreSQL
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
await client.query('SELECT 1');
|
||||
client.release();
|
||||
postgresStatus = 'connected';
|
||||
} catch {
|
||||
postgresStatus = 'disconnected';
|
||||
}
|
||||
|
||||
// Check Redis
|
||||
try {
|
||||
await redis.ping();
|
||||
redisStatus = 'connected';
|
||||
} catch {
|
||||
redisStatus = 'disconnected';
|
||||
}
|
||||
|
||||
const allHealthy = postgresStatus === 'connected' && redisStatus === 'connected';
|
||||
const httpStatus = allHealthy ? 200 : 503;
|
||||
|
||||
const body: HealthResponse = {
|
||||
status: allHealthy ? 'ok' : 'degraded',
|
||||
version: process.env['npm_package_version'] ?? '1.0.0',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
services: {
|
||||
postgres: postgresStatus,
|
||||
redis: redisStatus,
|
||||
},
|
||||
};
|
||||
|
||||
res.status(httpStatus).json(body);
|
||||
};
|
||||
|
||||
void check();
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
150
tests/unit/routes/health.test.ts
Normal file
150
tests/unit/routes/health.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Unit tests for src/routes/health.ts
|
||||
*
|
||||
* Tests the GET /health endpoint via the createHealthRouter factory.
|
||||
* PostgreSQL and Redis dependencies are fully mocked — no live services required.
|
||||
*/
|
||||
|
||||
import express, { Application } from 'express';
|
||||
import request from 'supertest';
|
||||
import { Pool, PoolClient } from 'pg';
|
||||
import { RedisClientType } from 'redis';
|
||||
import { createHealthRouter } from '../../../src/routes/health';
|
||||
|
||||
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Builds a mock pg PoolClient with controllable query/release. */
|
||||
function makePoolClient(queryError?: Error): jest.Mocked<Pick<PoolClient, 'query' | 'release'>> {
|
||||
return {
|
||||
query: queryError
|
||||
? jest.fn().mockRejectedValue(queryError)
|
||||
: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }),
|
||||
release: jest.fn(),
|
||||
} as unknown as jest.Mocked<Pick<PoolClient, 'query' | 'release'>>;
|
||||
}
|
||||
|
||||
/** Builds a mock pg Pool whose connect() resolves or rejects on demand. */
|
||||
function makePool(connectError?: Error, queryError?: Error): jest.Mocked<Pool> {
|
||||
return {
|
||||
connect: connectError
|
||||
? jest.fn().mockRejectedValue(connectError)
|
||||
: jest.fn().mockResolvedValue(makePoolClient(queryError)),
|
||||
} as unknown as jest.Mocked<Pool>;
|
||||
}
|
||||
|
||||
/** Builds a mock Redis client whose ping() resolves or rejects on demand. */
|
||||
function makeRedis(pingError?: Error): jest.Mocked<RedisClientType> {
|
||||
return {
|
||||
ping: pingError
|
||||
? jest.fn().mockRejectedValue(pingError)
|
||||
: jest.fn().mockResolvedValue('PONG'),
|
||||
} as unknown as jest.Mocked<RedisClientType>;
|
||||
}
|
||||
|
||||
/** Creates a minimal Express app with the health router mounted at /health. */
|
||||
function buildApp(pool: jest.Mocked<Pool>, redis: jest.Mocked<RedisClientType>): Application {
|
||||
const app = express();
|
||||
app.use('/health', createHealthRouter(pool as unknown as Pool, redis as unknown as RedisClientType));
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /health', () => {
|
||||
describe('when both PostgreSQL and Redis are healthy', () => {
|
||||
it('returns 200 with status ok and both services connected', async () => {
|
||||
const app = buildApp(makePool(), makeRedis());
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'ok',
|
||||
services: {
|
||||
postgres: 'connected',
|
||||
redis: 'connected',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('includes version and uptime fields in the response', async () => {
|
||||
const app = buildApp(makePool(), makeRedis());
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(typeof response.body.version).toBe('string');
|
||||
expect(typeof response.body.uptime).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when PostgreSQL connect() throws', () => {
|
||||
it('returns 503 with status degraded and postgres disconnected, redis connected', async () => {
|
||||
const pool = makePool(new Error('PG connection refused'));
|
||||
const app = buildApp(pool, makeRedis());
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'degraded',
|
||||
services: {
|
||||
postgres: 'disconnected',
|
||||
redis: 'connected',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when Redis ping() throws', () => {
|
||||
it('returns 503 with status degraded and postgres connected, redis disconnected', async () => {
|
||||
const app = buildApp(makePool(), makeRedis(new Error('Redis ECONNREFUSED')));
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'degraded',
|
||||
services: {
|
||||
postgres: 'connected',
|
||||
redis: 'disconnected',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when both PostgreSQL and Redis fail', () => {
|
||||
it('returns 503 with status degraded and both services disconnected', async () => {
|
||||
const pool = makePool(new Error('PG down'));
|
||||
const app = buildApp(pool, makeRedis(new Error('Redis down')));
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'degraded',
|
||||
services: {
|
||||
postgres: 'disconnected',
|
||||
redis: 'disconnected',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when PostgreSQL query() throws (connect succeeds but query fails)', () => {
|
||||
it('returns 503 with postgres disconnected', async () => {
|
||||
const pool = makePool(undefined, new Error('PG query error'));
|
||||
const app = buildApp(pool, makeRedis());
|
||||
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'degraded',
|
||||
services: {
|
||||
postgres: 'disconnected',
|
||||
redis: 'connected',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user