Initial Commit
This commit is contained in:
12
PatchProbe.Dashboard/index.html
Normal file
12
PatchProbe.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>PatchProbe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2543
PatchProbe.Dashboard/package-lock.json
generated
Normal file
2543
PatchProbe.Dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
PatchProbe.Dashboard/package.json
Normal file
24
PatchProbe.Dashboard/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "patchprobe-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.0.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
44
PatchProbe.Dashboard/src/App.jsx
Normal file
44
PatchProbe.Dashboard/src/App.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './hooks/useAuth.jsx';
|
||||
import Layout from './components/Layout.jsx';
|
||||
import Login from './pages/Login.jsx';
|
||||
import Dashboard from './pages/Dashboard.jsx';
|
||||
import Devices from './pages/Devices.jsx';
|
||||
import DeviceDetail from './pages/DeviceDetail.jsx';
|
||||
import Scans from './pages/Scans.jsx';
|
||||
import ScanDetail from './pages/ScanDetail.jsx';
|
||||
import Admin from './pages/Admin.jsx';
|
||||
|
||||
function PrivateRoute({ children }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
if (isLoading) return <div className="flex h-screen items-center justify-center text-zinc-400">Loading…</div>;
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="devices" element={<Devices />} />
|
||||
<Route path="devices/:id" element={<DeviceDetail />} />
|
||||
<Route path="scans" element={<Scans />} />
|
||||
<Route path="scans/:id" element={<ScanDetail />} />
|
||||
<Route path="admin" element={<Admin />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
62
PatchProbe.Dashboard/src/components/JsonViewer.jsx
Normal file
62
PatchProbe.Dashboard/src/components/JsonViewer.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
function JsonNode({ data, depth = 0 }) {
|
||||
const [collapsed, setCollapsed] = useState(depth > 2);
|
||||
|
||||
if (data === null) return <span className="text-red-400">null</span>;
|
||||
if (data === true) return <span className="text-blue-400">true</span>;
|
||||
if (data === false) return <span className="text-blue-400">false</span>;
|
||||
if (typeof data === 'number') return <span className="text-amber-400">{data}</span>;
|
||||
if (typeof data === 'string') return <span className="text-green-400">"{data}"</span>;
|
||||
|
||||
const isArray = Array.isArray(data);
|
||||
const entries = isArray ? data.map((v, i) => [i, v]) : Object.entries(data);
|
||||
const open = isArray ? '[' : '{';
|
||||
const close = isArray ? ']' : '}';
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <span className="text-zinc-400">{open}{close}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
className="text-zinc-500 hover:text-zinc-300 mr-1 font-mono text-xs"
|
||||
>
|
||||
{collapsed ? '▶' : '▼'}
|
||||
</button>
|
||||
<span className="text-zinc-400">{open}</span>
|
||||
{collapsed ? (
|
||||
<button
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="text-zinc-500 hover:text-zinc-300 text-xs mx-1"
|
||||
>
|
||||
{entries.length} {isArray ? 'items' : 'keys'} …
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ paddingLeft: '1.25rem' }}>
|
||||
{entries.map(([key, val], idx) => (
|
||||
<div key={key}>
|
||||
{!isArray && (
|
||||
<span className="text-zinc-300">"{key}"</span>
|
||||
)}
|
||||
{!isArray && <span className="text-zinc-500">: </span>}
|
||||
<JsonNode data={val} depth={depth + 1} />
|
||||
{idx < entries.length - 1 && <span className="text-zinc-600">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && <span className="text-zinc-400">{close}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JsonViewer({ data }) {
|
||||
return (
|
||||
<pre className="font-mono text-sm leading-relaxed whitespace-pre-wrap break-all">
|
||||
<JsonNode data={data} depth={0} />
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
64
PatchProbe.Dashboard/src/components/Layout.jsx
Normal file
64
PatchProbe.Dashboard/src/components/Layout.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth.jsx';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Dashboard', icon: '▦' },
|
||||
{ to: '/devices', label: 'Devices', icon: '⬡' },
|
||||
{ to: '/scans', label: 'Scans', icon: '≡' },
|
||||
{ to: '/admin', label: 'Admin', icon: '⚙' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-zinc-950 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-52 flex flex-col flex-shrink-0 bg-zinc-900 border-r border-zinc-800">
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-zinc-800">
|
||||
<span className="text-lg font-semibold tracking-tight text-zinc-100">PatchProbe</span>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map(({ to, label, icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="text-base leading-none">{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User + logout */}
|
||||
<div className="px-4 py-4 border-t border-zinc-800">
|
||||
<p className="text-xs text-zinc-500 truncate mb-2">{user?.displayName ?? user?.username}</p>
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto px-8 py-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
PatchProbe.Dashboard/src/components/StatusBadge.jsx
Normal file
14
PatchProbe.Dashboard/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function StatusBadge({ variant, children }) {
|
||||
const styles = {
|
||||
green: 'bg-green-950 text-green-400 border-green-800',
|
||||
red: 'bg-red-950 text-red-400 border-red-800',
|
||||
amber: 'bg-amber-950 text-amber-400 border-amber-800',
|
||||
indigo: 'bg-indigo-950 text-indigo-400 border-indigo-800',
|
||||
zinc: 'bg-zinc-800 text-zinc-400 border-zinc-700',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${styles[variant] ?? styles.zinc}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
36
PatchProbe.Dashboard/src/hooks/useAuth.jsx
Normal file
36
PatchProbe.Dashboard/src/hooks/useAuth.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { authApi } from '../lib/api.js';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: user, isLoading, error } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: authApi.me,
|
||||
retry: false,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: authApi.logout,
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
window.location.href = '/login';
|
||||
},
|
||||
});
|
||||
|
||||
const isAuthenticated = !!user && !error;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isLoading, isAuthenticated, logout: logoutMutation.mutate }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
8
PatchProbe.Dashboard/src/index.css
Normal file
8
PatchProbe.Dashboard/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background-color: theme(--color-zinc-950);
|
||||
color: theme(--color-zinc-100);
|
||||
}
|
||||
}
|
||||
56
PatchProbe.Dashboard/src/lib/api.js
Normal file
56
PatchProbe.Dashboard/src/lib/api.js
Normal file
@@ -0,0 +1,56 @@
|
||||
class ApiError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const opts = { method, credentials: 'include', headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(path, opts);
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (!res.ok) throw new ApiError(data.error ?? 'Request failed', res.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request('GET', path),
|
||||
post: (path, body) => request('POST', path, body),
|
||||
delete: (path) => request('DELETE', path),
|
||||
};
|
||||
|
||||
// Typed helpers for each resource
|
||||
export const authApi = {
|
||||
status: () => api.get('/api/auth/status'),
|
||||
me: () => api.get('/api/auth/me'),
|
||||
logout: () => api.post('/api/auth/logout'),
|
||||
registerBegin: (body) => api.post('/api/auth/register/begin', body),
|
||||
registerFinish: (body) => api.post('/api/auth/register/finish', body),
|
||||
loginBegin: () => api.post('/api/auth/login/begin'),
|
||||
loginFinish: (body) => api.post('/api/auth/login/finish', body),
|
||||
passkeys: () => api.get('/api/auth/passkeys'),
|
||||
deletePasskey: (id) => api.delete(`/api/auth/passkeys/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
export const devicesApi = {
|
||||
list: () => api.get('/api/admin/devices'),
|
||||
revoke: (id) => api.delete(`/api/admin/devices/${id}`),
|
||||
scans: (id) => api.get(`/api/admin/devices/${id}/scans`),
|
||||
};
|
||||
|
||||
export const scansApi = {
|
||||
list: () => api.get('/api/scans'),
|
||||
get: (id) => api.get(`/api/scans/${id}`),
|
||||
remove: (id) => api.delete(`/api/scans/${id}`),
|
||||
};
|
||||
|
||||
export const tokensApi = {
|
||||
list: () => api.get('/api/admin/tokens'),
|
||||
create: (body) => api.post('/api/admin/tokens', body),
|
||||
revoke: (tok) => api.delete(`/api/admin/tokens/${tok}`),
|
||||
};
|
||||
22
PatchProbe.Dashboard/src/main.jsx
Normal file
22
PatchProbe.Dashboard/src/main.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
281
PatchProbe.Dashboard/src/pages/Admin.jsx
Normal file
281
PatchProbe.Dashboard/src/pages/Admin.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { tokensApi, authApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enrollment tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TokensSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState({ label: '', expiresInDays: '', maxUses: '' });
|
||||
const [created, setCreated] = useState(null);
|
||||
|
||||
const { data: tokens = [] } = useQuery({ queryKey: ['tokens'], queryFn: tokensApi.list });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body) => tokensApi.create(body),
|
||||
onSuccess: (data) => {
|
||||
setCreated(data.token);
|
||||
setForm({ label: '', expiresInDays: '', maxUses: '' });
|
||||
queryClient.invalidateQueries({ queryKey: ['tokens'] });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: tokensApi.revoke,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tokens'] }),
|
||||
});
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault();
|
||||
const body = { label: form.label };
|
||||
if (form.expiresInDays) body.expiresInDays = parseInt(form.expiresInDays, 10);
|
||||
if (form.maxUses) body.maxUses = parseInt(form.maxUses, 10);
|
||||
createMutation.mutate(body);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Enrollment Tokens</h2>
|
||||
|
||||
{/* Created token — show once */}
|
||||
{created && (
|
||||
<div className="bg-indigo-950 border border-indigo-800 rounded-xl p-4">
|
||||
<p className="text-xs text-indigo-400 mb-1 font-medium">Token created — copy now, it won't be shown again:</p>
|
||||
<code className="text-sm text-indigo-200 break-all">{created}</code>
|
||||
<button onClick={() => setCreated(null)} className="mt-2 block text-xs text-indigo-500 hover:text-indigo-400">
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
<form onSubmit={submit} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<p className="text-sm font-medium text-zinc-300 mb-4">Create token</p>
|
||||
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||
<div className="col-span-3 md:col-span-1">
|
||||
<label className="block text-xs text-zinc-500 mb-1">Label *</label>
|
||||
<input
|
||||
required value={form.label} onChange={e => setForm(f => ({ ...f, label: e.target.value }))}
|
||||
placeholder="lab-devices"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Expires in (days)</label>
|
||||
<input
|
||||
type="number" min="1" value={form.expiresInDays} onChange={e => setForm(f => ({ ...f, expiresInDays: e.target.value }))}
|
||||
placeholder="never"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Max uses</label>
|
||||
<input
|
||||
type="number" min="1" value={form.maxUses} onChange={e => setForm(f => ({ ...f, maxUses: e.target.value }))}
|
||||
placeholder="unlimited"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit" disabled={createMutation.isPending}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Token list */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Token</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Label</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Expires</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Uses</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{tokens.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-6 text-center text-zinc-500">No tokens</td></tr>
|
||||
)}
|
||||
{tokens.map(t => (
|
||||
<tr key={t.tokenMasked + t.label} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3 font-mono text-xs text-zinc-400">{t.tokenMasked}</td>
|
||||
<td className="px-4 py-3 text-zinc-300">{t.label}</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(t.expiresAt)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-400 text-xs">
|
||||
{t.usedCount}{t.maxUses ? ` / ${t.maxUses}` : ''}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{t.revokedAt
|
||||
? <StatusBadge variant="red">Revoked</StatusBadge>
|
||||
: t.active
|
||||
? <StatusBadge variant="green">Active</StatusBadge>
|
||||
: <StatusBadge variant="zinc">Exhausted</StatusBadge>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!t.revokedAt && (
|
||||
<button
|
||||
onClick={() => { if (confirm('Revoke this token?')) revokeMutation.mutate(t.id); }}
|
||||
className="text-xs text-red-500 hover:text-red-400"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Passkeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PasskeysSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [regForm, setRegForm] = useState({ username: '', displayName: '' });
|
||||
const [showRegForm, setShowRegForm] = useState(false);
|
||||
|
||||
const { data: passkeys = [] } = useQuery({ queryKey: ['passkeys'], queryFn: authApi.passkeys });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: authApi.deletePasskey,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['passkeys'] }),
|
||||
});
|
||||
|
||||
async function addPasskey(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = await authApi.registerBegin(regForm);
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await authApi.registerFinish(credential);
|
||||
setShowRegForm(false);
|
||||
setRegForm({ username: '', displayName: '' });
|
||||
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
||||
} catch (err) {
|
||||
setError(err.message ?? 'Registration failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Passkeys</h2>
|
||||
<button
|
||||
onClick={() => setShowRegForm(s => !s)}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
{showRegForm ? 'Cancel' : '+ Add passkey'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 text-red-400 text-sm px-3 py-2 rounded-md">{error}</div>
|
||||
)}
|
||||
|
||||
{showRegForm && (
|
||||
<form onSubmit={addPasskey} className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 space-y-3">
|
||||
<p className="text-sm font-medium text-zinc-300">Register new passkey</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Username</label>
|
||||
<input required value={regForm.username} onChange={e => setRegForm(f => ({ ...f, username: e.target.value }))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Display name</label>
|
||||
<input required value={regForm.displayName} onChange={e => setRegForm(f => ({ ...f, displayName: e.target.value }))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={busy}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Waiting for passkey…' : 'Register passkey'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">ID</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Type</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Registered</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Last used</th>
|
||||
<th className="text-right px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{passkeys.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">No passkeys</td></tr>
|
||||
)}
|
||||
{passkeys.map(p => (
|
||||
<tr key={p.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3 font-mono text-xs text-zinc-400">{p.idMasked}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge variant={p.backedUp ? 'indigo' : 'zinc'}>
|
||||
{p.deviceType ?? 'unknown'}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(p.createdAt)}</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(p.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{passkeys.length > 1 && (
|
||||
<button
|
||||
onClick={() => { if (confirm('Remove this passkey?')) deleteMutation.mutate(p.id); }}
|
||||
className="text-xs text-red-500 hover:text-red-400"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function Admin() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Admin</h1>
|
||||
<TokensSection />
|
||||
<PasskeysSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
PatchProbe.Dashboard/src/pages/Dashboard.jsx
Normal file
84
PatchProbe.Dashboard/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { devicesApi, scansApi } from '../lib/api.js';
|
||||
|
||||
function StatCard({ label, value, sub, to }) {
|
||||
const inner = (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className="text-3xl font-semibold text-zinc-100">{value ?? '—'}</p>
|
||||
{sub && <p className="text-xs text-zinc-500 mt-1">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
return to ? <Link to={to} className="hover:border-indigo-700 transition-colors rounded-xl block">{inner}</Link> : inner;
|
||||
}
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: devices } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
|
||||
const { data: scans } = useQuery({ queryKey: ['scans'], queryFn: scansApi.list });
|
||||
|
||||
const activeDevices = devices?.filter(d => !d.revoked) ?? [];
|
||||
const revokedDevices = devices?.filter(d => d.revoked) ?? [];
|
||||
const pendingReboots = scans?.filter(s => s.pendingReboot) ?? [];
|
||||
const recent = scans?.slice(0, 10) ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Dashboard</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard label="Active Devices" value={activeDevices.length} sub={`${revokedDevices.length} revoked`} to="/devices" />
|
||||
<StatCard label="Total Scans" value={scans?.length} sub="all time" to="/scans" />
|
||||
<StatCard label="Pending Reboots" value={pendingReboots.length} sub="across all scans" />
|
||||
<StatCard label="Updates Detected" value={scans?.reduce((n, s) => n + (s.applicableUpdateCount ?? 0), 0)} sub="total across all scans" />
|
||||
</div>
|
||||
|
||||
{/* Recent scans */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">Recent Scans</h2>
|
||||
<Link to="/scans" className="text-xs text-indigo-400 hover:text-indigo-300">View all →</Link>
|
||||
</div>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Machine</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Collected</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Updates</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Reboot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{recent.length === 0 && (
|
||||
<tr><td colSpan={4} className="px-4 py-6 text-center text-zinc-500">No scans yet</td></tr>
|
||||
)}
|
||||
{recent.map(s => (
|
||||
<tr key={s.id} className="hover:bg-zinc-800/50">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/scans/${s.id}`} className="text-zinc-100 hover:text-indigo-400">
|
||||
{s.machineName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{fmt(s.collectedAt)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{s.pendingReboot
|
||||
? <span className="text-amber-400">Yes</span>
|
||||
: <span className="text-zinc-500">No</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
PatchProbe.Dashboard/src/pages/DeviceDetail.jsx
Normal file
110
PatchProbe.Dashboard/src/pages/DeviceDetail.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { devicesApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams();
|
||||
const { data: devices = [] } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
|
||||
const { data: scans = [], isLoading } = useQuery({
|
||||
queryKey: ['device-scans', id],
|
||||
queryFn: () => devicesApi.scans(id),
|
||||
});
|
||||
|
||||
const device = devices.find(d => d.id === id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<Link to="/devices" className="hover:text-zinc-300">Devices</Link>
|
||||
<span>/</span>
|
||||
<span className="text-zinc-300">{device?.machineName ?? id}</span>
|
||||
</div>
|
||||
|
||||
{/* Device info */}
|
||||
{device && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Machine name</p>
|
||||
<p className="text-sm text-zinc-100 font-medium">{device.machineName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Status</p>
|
||||
{device.revoked
|
||||
? <StatusBadge variant="red">Revoked</StatusBadge>
|
||||
: <StatusBadge variant="green">Active</StatusBadge>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Enrolled</p>
|
||||
<p className="text-xs text-zinc-300">{fmt(device.enrolledAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Last seen</p>
|
||||
<p className="text-xs text-zinc-300">{fmt(device.lastSeenAt)}</p>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">Device ID</p>
|
||||
<p className="text-xs text-zinc-500 font-mono">{device.id}</p>
|
||||
</div>
|
||||
{device.deviceFingerprint && (
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<p className="text-xs text-zinc-500 mb-1">Fingerprint</p>
|
||||
<p className="text-xs text-zinc-500 font-mono">{device.deviceFingerprint}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scans */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wider mb-3">Scan History</h2>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Collected</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Updates</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Reboot</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Admin</th>
|
||||
<th className="text-right px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{isLoading && (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">Loading…</td></tr>
|
||||
)}
|
||||
{!isLoading && scans.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-zinc-500">No scans yet</td></tr>
|
||||
)}
|
||||
{scans.map(s => (
|
||||
<tr key={s.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3 text-zinc-300">{fmt(s.collectedAt)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.pendingReboot
|
||||
? <StatusBadge variant="amber">Yes</StatusBadge>
|
||||
: <span className="text-zinc-500 text-xs">No</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.ranAsAdministrator
|
||||
? <StatusBadge variant="indigo">Admin</StatusBadge>
|
||||
: <span className="text-zinc-500 text-xs">No</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link to={`/scans/${s.id}`} className="text-xs text-indigo-400 hover:text-indigo-300">View →</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
PatchProbe.Dashboard/src/pages/Devices.jsx
Normal file
95
PatchProbe.Dashboard/src/pages/Devices.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { devicesApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function Devices() {
|
||||
const queryClient = useQueryClient();
|
||||
const [filter, setFilter] = useState('active'); // 'active' | 'all'
|
||||
const { data: devices = [], isLoading } = useQuery({ queryKey: ['devices'], queryFn: devicesApi.list });
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: devicesApi.revoke,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }),
|
||||
});
|
||||
|
||||
const visible = filter === 'active' ? devices.filter(d => !d.revoked) : devices;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Devices</h1>
|
||||
<div className="flex gap-1 bg-zinc-800 rounded-lg p-0.5">
|
||||
{['active', 'all'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md capitalize transition-colors ${
|
||||
filter === f ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Machine</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Enrolled</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Last Seen</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Status</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{isLoading && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-zinc-500">Loading…</td></tr>
|
||||
)}
|
||||
{!isLoading && visible.length === 0 && (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-zinc-500">No devices found</td></tr>
|
||||
)}
|
||||
{visible.map(d => (
|
||||
<tr key={d.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/devices/${d.id}`} className="text-zinc-100 hover:text-indigo-400 font-medium">
|
||||
{d.machineName}
|
||||
</Link>
|
||||
<p className="text-xs text-zinc-600 mt-0.5 font-mono">{d.id.slice(0, 8)}…</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(d.enrolledAt)}</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(d.lastSeenAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{d.revoked
|
||||
? <StatusBadge variant="red">Revoked</StatusBadge>
|
||||
: <StatusBadge variant="green">Active</StatusBadge>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!d.revoked && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Revoke ${d.machineName}?`)) revokeMutation.mutate(d.id);
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
PatchProbe.Dashboard/src/pages/Login.jsx
Normal file
132
PatchProbe.Dashboard/src/pages/Login.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
|
||||
import { authApi } from '../lib/api.js';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState(null); // { hasUsers }
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [regForm, setRegForm] = useState({ username: '', displayName: '' });
|
||||
|
||||
useEffect(() => {
|
||||
authApi.status().then(setStatus).catch(() => setStatus({ hasUsers: true }));
|
||||
}, []);
|
||||
|
||||
async function onSignIn() {
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = await authApi.loginBegin();
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
await authApi.loginFinish(credential);
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err.message ?? 'Sign-in failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegister(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = await authApi.registerBegin(regForm);
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
await authApi.registerFinish(credential);
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err.message ?? 'Registration failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-semibold text-zinc-100">PatchProbe</h1>
|
||||
<p className="text-sm text-zinc-500 mt-1">Patch management dashboard</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6 space-y-4">
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-950 border border-red-800 text-red-400 text-sm px-3 py-2 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* First-time setup */}
|
||||
{status && !status.hasUsers && (
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400 mb-4">
|
||||
No admin account exists yet. Register your first passkey to get started.
|
||||
</p>
|
||||
<form onSubmit={onRegister} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={regForm.username}
|
||||
onChange={e => setRegForm(f => ({ ...f, username: e.target.value }))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Display name</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={regForm.displayName}
|
||||
onChange={e => setRegForm(f => ({ ...f, displayName: e.target.value }))}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-indigo-500"
|
||||
placeholder="Admin"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Registering…' : 'Register passkey'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sign in */}
|
||||
{status && status.hasUsers && (
|
||||
<div>
|
||||
<p className="text-sm text-zinc-400 mb-4">
|
||||
Sign in using a passkey registered on this device.
|
||||
</p>
|
||||
<button
|
||||
onClick={onSignIn}
|
||||
disabled={busy}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Waiting for passkey…' : 'Sign in with passkey'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status && (
|
||||
<p className="text-sm text-zinc-500 text-center">Connecting…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
PatchProbe.Dashboard/src/pages/ScanDetail.jsx
Normal file
110
PatchProbe.Dashboard/src/pages/ScanDetail.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { scansApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
import JsonViewer from '../components/JsonViewer.jsx';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'long' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
function Section({ title, data, defaultOpen = false }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const isEmpty = data == null || (Array.isArray(data) && data.length === 0) || (typeof data === 'object' && Object.keys(data).length === 0);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-zinc-200">{title}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{isEmpty && <span className="text-xs text-zinc-600">empty</span>}
|
||||
<span className="text-zinc-500">{open ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-5 py-4 border-t border-zinc-800 overflow-x-auto">
|
||||
{isEmpty
|
||||
? <p className="text-zinc-500 text-sm">No data</p>
|
||||
: <JsonViewer data={data} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScanDetail() {
|
||||
const { id } = useParams();
|
||||
const { data: scan, isLoading, error } = useQuery({
|
||||
queryKey: ['scan', id],
|
||||
queryFn: () => scansApi.get(id),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-zinc-500">Loading…</div>;
|
||||
if (error) return <div className="text-red-400">Failed to load scan: {error.message}</div>;
|
||||
|
||||
const c = scan?.collector;
|
||||
const wu = scan?.windowsUpdate;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||
<Link to="/scans" className="hover:text-zinc-300">Scans</Link>
|
||||
<span>/</span>
|
||||
<span className="text-zinc-300">{c?.machineName}</span>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Machine</p>
|
||||
<p className="text-sm font-medium text-zinc-100">{c?.machineName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Collected</p>
|
||||
<p className="text-xs text-zinc-300">{fmt(c?.collectedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Applicable Updates</p>
|
||||
<p className="text-2xl font-semibold text-zinc-100">{wu?.applicableUpdates?.length ?? 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Pending Reboot</p>
|
||||
{scan?.pendingReboot?.anyPending
|
||||
? <StatusBadge variant="amber">Yes</StatusBadge>
|
||||
: <StatusBadge variant="green">No</StatusBadge>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Ran as Admin</p>
|
||||
{c?.ranAsAdministrator
|
||||
? <StatusBadge variant="indigo">Yes</StatusBadge>
|
||||
: <StatusBadge variant="red">No</StatusBadge>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-1">Schema</p>
|
||||
<p className="text-xs text-zinc-500">{scan?.schemaVersion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-3">
|
||||
<Section title="OS" data={scan?.os} defaultOpen />
|
||||
<Section title="Device" data={scan?.device} />
|
||||
<Section title="Applicable Updates" data={wu?.applicableUpdates} />
|
||||
<Section title="Update History" data={wu?.history} />
|
||||
<Section title="Windows Update Policy" data={wu?.policy} />
|
||||
<Section title="Pending Reboot" data={scan?.pendingReboot} />
|
||||
<Section title="Installed Hotfixes" data={scan?.installedHotfixes} />
|
||||
<Section title="CBS / DISM Packages" data={scan?.cbsPackages} />
|
||||
<Section title="Drivers" data={scan?.drivers} />
|
||||
<Section title="Recent Update Events" data={scan?.recentUpdateEvents} />
|
||||
<Section title="Collector Metadata" data={scan?.collector} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
PatchProbe.Dashboard/src/pages/Scans.jsx
Normal file
93
PatchProbe.Dashboard/src/pages/Scans.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { scansApi } from '../lib/api.js';
|
||||
import StatusBadge from '../components/StatusBadge.jsx';
|
||||
|
||||
function fmt(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(iso));
|
||||
}
|
||||
|
||||
export default function Scans() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const { data: scans = [], isLoading } = useQuery({ queryKey: ['scans'], queryFn: scansApi.list });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: scansApi.remove,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scans'] }),
|
||||
});
|
||||
|
||||
const visible = search
|
||||
? scans.filter(s => s.machineName?.toLowerCase().includes(search.toLowerCase()))
|
||||
: scans;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Scans</h1>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter by machine…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 w-52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<th className="text-left px-4 py-3 font-medium">Machine</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Collected</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Updates</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Reboot</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Admin</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{isLoading && (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-zinc-500">Loading…</td></tr>
|
||||
)}
|
||||
{!isLoading && visible.length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-zinc-500">No scans found</td></tr>
|
||||
)}
|
||||
{visible.map(s => (
|
||||
<tr key={s.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/scans/${s.id}`} className="text-zinc-100 hover:text-indigo-400 font-medium">
|
||||
{s.machineName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 text-xs">{fmt(s.collectedAt)}</td>
|
||||
<td className="px-4 py-3 text-right text-zinc-300">{s.applicableUpdateCount}</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.pendingReboot
|
||||
? <StatusBadge variant="amber">Yes</StatusBadge>
|
||||
: <span className="text-zinc-500 text-xs">No</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.ranAsAdministrator
|
||||
? <StatusBadge variant="indigo">Admin</StatusBadge>
|
||||
: <span className="text-zinc-500 text-xs">No</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-3">
|
||||
<Link to={`/scans/${s.id}`} className="text-xs text-indigo-400 hover:text-indigo-300">View</Link>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this scan?')) deleteMutation.mutate(s.id); }}
|
||||
className="text-xs text-red-500 hover:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
PatchProbe.Dashboard/vite.config.js
Normal file
16
PatchProbe.Dashboard/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: '../PatchProbe.Server/public',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user