Private
Public Access
1
0

feat(M3): Host Management, Groups, Users, CIDR Discovery

- pm-core::models: Host, HostSummary, Group, User, DiscoveryResult
  types + request payloads for all CRUD operations
- pm-core::audit: Tamper-evident hash-chained audit log writer
  (SHA-256 chain, non-fatal, covers all M3 events)
- pm-web/routes/hosts: Full host CRUD with RBAC scoping;
  FQDN DNS resolution on registration; host↔group membership;
  operator group-scoped access enforcement; audit on register/remove
- pm-web/routes/groups: Full group CRUD; host↔group and user↔group
  membership management; admin-only create/delete/update
- pm-web/routes/users: Full user CRUD (admin); current user profile;
  password hashing (Argon2id); role management; session revocation
- pm-web/routes/discovery: CIDR scan with bounded concurrency
  (128 workers), TCP probe with 2s timeout, reverse DNS lookup,
  scan results table, register-from-discovery flow with audit log
- Frontend: HostsPage (filterable table with health chips),
  HostDetailPage, GroupsPage (create/delete dialog),
  UsersPage (create/revoke sessions)
- App.tsx updated with all M3 routes wired to real pages
- cargo check --workspace: zero errors

Closes M3.
This commit is contained in:
2026-04-23 16:25:08 +00:00
parent 6811f84a7c
commit a6eb762962
17 changed files with 1887 additions and 51 deletions

View File

@ -4,8 +4,12 @@ import { lightTheme } from './theme/theme'
import { useAuthStore } from './store/authStore'
import LoginPage from './pages/LoginPage'
import MfaSetupPage from './pages/MfaSetupPage'
import HostsPage from './pages/HostsPage'
import HostDetailPage from './pages/HostDetailPage'
import GroupsPage from './pages/GroupsPage'
import UsersPage from './pages/UsersPage'
// Placeholder pages — implemented in M3+
// Placeholder pages — implemented in later milestones
const PlaceholderPage = ({ title }: { title: string }) => (
<div style={{ padding: 32 }}>
<h2>{title}</h2>
@ -13,7 +17,6 @@ const PlaceholderPage = ({ title }: { title: string }) => (
</div>
)
// Guard component: redirects to /login if not authenticated
function RequireAuth({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
@ -24,25 +27,28 @@ function App() {
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<Routes>
{/* Public routes */}
{/* Public */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
{/* Protected — M2 */}
<Route path="/" element={<RequireAuth><Navigate to="/dashboard" replace /></RequireAuth>} />
<Route path="/mfa/setup" element={<RequireAuth><MfaSetupPage /></RequireAuth>} />
{/* Protected — M3 */}
<Route path="/dashboard" element={<RequireAuth><PlaceholderPage title="Dashboard" /></RequireAuth>} />
<Route path="/hosts" element={<RequireAuth><PlaceholderPage title="Hosts" /></RequireAuth>} />
<Route path="/hosts/:id" element={<RequireAuth><PlaceholderPage title="Host Detail" /></RequireAuth>} />
<Route path="/hosts" element={<RequireAuth><HostsPage /></RequireAuth>} />
<Route path="/hosts/:id" element={<RequireAuth><HostDetailPage /></RequireAuth>} />
<Route path="/groups" element={<RequireAuth><GroupsPage /></RequireAuth>} />
<Route path="/users" element={<RequireAuth><UsersPage /></RequireAuth>} />
{/* Protected — later milestones */}
<Route path="/jobs" element={<RequireAuth><PlaceholderPage title="Jobs" /></RequireAuth>} />
<Route path="/deployment" element={<RequireAuth><PlaceholderPage title="Patch Deployment" /></RequireAuth>} />
<Route path="/maintenance" element={<RequireAuth><PlaceholderPage title="Maintenance Windows" /></RequireAuth>} />
<Route path="/groups" element={<RequireAuth><PlaceholderPage title="Groups" /></RequireAuth>} />
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
<Route path="/users" element={<RequireAuth><PlaceholderPage title="Users" /></RequireAuth>} />
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
<Route path="/mfa/setup" element={<RequireAuth><MfaSetupPage /></RequireAuth>} />
{/* 404 */}
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
</Routes>
</ThemeProvider>

View File

@ -0,0 +1,78 @@
import { useEffect, useState } from 'react'
import {
Box, Button, CircularProgress, Container, Dialog, DialogActions,
DialogContent, DialogTitle, IconButton, Paper, Table, TableBody,
TableCell, TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography,
} from '@mui/material'
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'
import { apiClient } from '../api/client'
import type { Group } from '../types'
export default function GroupsPage() {
const [groups, setGroups] = useState<Group[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const load = async () => {
setLoading(true)
try { const r = await apiClient.get('/groups'); setGroups(r.data) }
finally { setLoading(false) }
}
useEffect(() => { load() }, [])
const handleCreate = async () => {
await apiClient.post('/groups', { name, description: desc })
setOpen(false); setName(''); setDesc('')
load()
}
const handleDelete = async (id: string) => {
if (!confirm('Delete this group?')) return
await apiClient.delete(`/groups/${id}`)
load()
}
return (
<Container maxWidth="lg" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 2 }}>
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Groups</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Create Group</Button>
</Toolbar>
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead><TableRow>
<TableCell>Name</TableCell><TableCell>Description</TableCell><TableCell>Created</TableCell><TableCell>Actions</TableCell>
</TableRow></TableHead>
<TableBody>
{groups.map(g => (
<TableRow key={g.id} hover>
<TableCell fontWeight={600}>{g.name}</TableCell>
<TableCell>{g.description || '—'}</TableCell>
<TableCell>{new Date(g.created_at).toLocaleDateString()}</TableCell>
<TableCell>
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => handleDelete(g.id)}><DeleteIcon fontSize="small" /></IconButton></Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Create Group</DialogTitle>
<DialogContent>
<TextField fullWidth label="Name" value={name} onChange={e => setName(e.target.value)} margin="normal" required />
<TextField fullWidth label="Description" value={desc} onChange={e => setDesc(e.target.value)} margin="normal" />
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} disabled={!name}>Create</Button>
</DialogActions>
</Dialog>
</Container>
)
}

View File

@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Alert, Box, Button, Chip, CircularProgress, Container, Divider, Grid, Paper, Typography } from '@mui/material'
import { ArrowBack } from '@mui/icons-material'
import { apiClient } from '../api/client'
export default function HostDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [host, setHost] = useState<Record<string, unknown> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiClient.get(`/hosts/${id}`)
.then(r => setHost(r.data))
.catch(() => setError('Host not found or access denied.'))
.finally(() => setLoading(false))
}, [id])
if (loading) return <Box display="flex" justifyContent="center" mt={8}><CircularProgress /></Box>
if (error) return <Container sx={{ mt: 4 }}><Alert severity="error">{error}</Alert></Container>
return (
<Container maxWidth="lg" sx={{ mt: 3 }}>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/hosts')} sx={{ mb: 2 }}>Back to Hosts</Button>
<Paper sx={{ p: 3 }}>
<Typography variant="h5" fontWeight={700} mb={2}>{String(host?.fqdn ?? '')}</Typography>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
{host && Object.entries(host).map(([k, v]) => v !== null && v !== '' ? (
<Grid item xs={12} sm={6} md={4} key={k}>
<Typography variant="caption" color="text.secondary" display="block">{k.replace(/_/g, ' ').toUpperCase()}</Typography>
<Typography variant="body2">{String(v)}</Typography>
</Grid>
) : null)}
</Grid>
</Paper>
</Container>
)
}

View File

@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import {
Box, Button, Chip, CircularProgress, Container, IconButton,
Paper, Table, TableBody, TableCell, TableContainer, TableHead,
TableRow, TextField, Toolbar, Tooltip, Typography,
} from '@mui/material'
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon } from '@mui/icons-material'
import { useNavigate } from 'react-router-dom'
import { apiClient } from '../api/client'
import type { Host, HostHealthStatus } from '../types'
const statusColor = (s: HostHealthStatus) =>
s === 'healthy' ? 'success' : s === 'degraded' ? 'warning' : s === 'unreachable' ? 'error' : 'default'
export default function HostsPage() {
const navigate = useNavigate()
const [hosts, setHosts] = useState<Host[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const load = async () => {
setLoading(true)
try {
const res = await apiClient.get('/hosts', { params: { limit: 100 } })
setHosts(res.data.hosts)
setTotal(res.data.total)
} catch { /* handled by interceptor */ }
finally { setLoading(false) }
}
useEffect(() => { load() }, [])
const filtered = hosts.filter(h =>
h.fqdn.toLowerCase().includes(search.toLowerCase()) ||
h.display_name.toLowerCase().includes(search.toLowerCase())
)
return (
<Container maxWidth="xl" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 2 }}>
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Hosts</Typography>
<TextField size="small" placeholder="Search..." value={search}
onChange={e => setSearch(e.target.value)} sx={{ mr: 2 }} />
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>
</Toolbar>
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>FQDN</TableCell>
<TableCell>Display Name</TableCell>
<TableCell>IP Address</TableCell>
<TableCell>OS</TableCell>
<TableCell>Health</TableCell>
<TableCell>Agent</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.map(h => (
<TableRow key={h.id} hover sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/hosts/${h.id}`)}>
<TableCell>{h.fqdn}</TableCell>
<TableCell>{h.display_name}</TableCell>
<TableCell>{h.ip_address}</TableCell>
<TableCell>{h.os_name ?? h.os_family ?? '—'}</TableCell>
<TableCell>
<Chip size="small" label={h.health_status} color={statusColor(h.health_status)} />
</TableCell>
<TableCell>{h.agent_version ?? '—'}</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<Tooltip title="Delete"><IconButton size="small" color="error">
<DeleteIcon fontSize="small" />
</IconButton></Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Typography variant="caption" color="text.secondary" mt={1} display="block">
Showing {filtered.length} of {total} hosts
</Typography>
</Container>
)
}

View File

@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import {
Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions,
DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
TextField, Toolbar, Tooltip, Typography,
} from '@mui/material'
import { Add as AddIcon, Lock as LockIcon } from '@mui/icons-material'
import { apiClient } from '../api/client'
import type { User } from '../types'
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [form, setForm] = useState({ username: '', email: '', role: 'operator', password: '' })
const load = async () => {
setLoading(true)
try {
const r = await apiClient.get('/users')
setUsers(r.data)
} catch { /* interceptor handles */ }
finally { setLoading(false) }
}
useEffect(() => { load() }, [])
const handleCreate = async () => {
try {
await apiClient.post('/users', form)
setOpen(false)
setForm({ username: '', email: '', role: 'operator', password: '' })
load()
} catch { /* interceptor handles */ }
}
const handleRevoke = async (id: string) => {
await apiClient.post(`/users/${id}/revoke`)
}
return (
<Container maxWidth="lg" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 2 }}>
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Users</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Add User</Button>
</Toolbar>
{loading ? (
<Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box>
) : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Email</TableCell>
<TableCell>Role</TableCell>
<TableCell>MFA</TableCell>
<TableCell>Status</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map(u => (
<TableRow key={u.id} hover>
<TableCell>{u.username}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Chip size="small" label={u.role}
color={u.role === 'admin' ? 'primary' : 'default'} />
</TableCell>
<TableCell>
<Chip size="small" label={u.mfa_enabled ? 'On' : 'Off'}
color={u.mfa_enabled ? 'success' : 'warning'} />
</TableCell>
<TableCell>
<Chip size="small" label={u.is_active ? 'Active' : 'Disabled'}
color={u.is_active ? 'success' : 'error'} />
</TableCell>
<TableCell>
<Tooltip title="Revoke All Sessions">
<IconButton size="small" color="warning" onClick={() => handleRevoke(u.id)}>
<LockIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Add User</DialogTitle>
<DialogContent>
<TextField fullWidth label="Username"
value={form.username}
onChange={e => setForm({ ...form, username: e.target.value })}
margin="normal" required />
<TextField fullWidth label="Email" type="email"
value={form.email}
onChange={e => setForm({ ...form, email: e.target.value })}
margin="normal" required />
<TextField fullWidth label="Password" type="password"
value={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
margin="normal" required />
<Select fullWidth value={form.role}
onChange={e => setForm({ ...form, role: e.target.value })}
sx={{ mt: 1 }}>
<MenuItem value="operator">Operator</MenuItem>
<MenuItem value="admin">Admin</MenuItem>
</Select>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleCreate}
disabled={!form.username || !form.email || !form.password}>
Create
</Button>
</DialogActions>
</Dialog>
</Container>
)
}