Initial Commit

This commit is contained in:
2026-05-25 10:29:38 +08:00
commit c42c9aea2a
64 changed files with 5919 additions and 0 deletions

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View File

@@ -0,0 +1,8 @@
@import "tailwindcss";
@layer base {
body {
background-color: theme(--color-zinc-950);
color: theme(--color-zinc-100);
}
}

View 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}`),
};

View 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>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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',
},
},
});