- 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.
79 lines
3.2 KiB
TypeScript
79 lines
3.2 KiB
TypeScript
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>
|
|
)
|
|
}
|