Add nodes.delete with reauth, activity logs, and Administrators editing.
Allow deleting managed hosts with mandatory inline re-auth, record node mutations in an audit log with a sidebar Logs view, and let Administrators group permissions be edited from the Groups tab.
This commit is contained in:
@@ -60,6 +60,24 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
.sidebar-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-category-label {
|
||||
margin: 0 0 0.15rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -395,6 +413,21 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.node-delete {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--ctp-red);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ctp-red);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-delete:hover {
|
||||
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
|
||||
}
|
||||
|
||||
.node-test-result {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
@@ -875,3 +908,39 @@
|
||||
word-break: break-all;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.logs-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logs-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logs-table th,
|
||||
.logs-table td {
|
||||
padding: 0.55rem 0.65rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.logs-table th {
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext0);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.logs-table code {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
+113
-3
@@ -30,6 +30,7 @@ function stubFetchOk() {
|
||||
'nodes.read',
|
||||
'nodes.exec',
|
||||
'nodes.update',
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'users.manage',
|
||||
@@ -70,6 +71,7 @@ function stubFetchOk() {
|
||||
'nodes.read',
|
||||
'nodes.exec',
|
||||
'nodes.update',
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'users.manage',
|
||||
@@ -373,6 +375,7 @@ describe('App', () => {
|
||||
'nodes.read',
|
||||
'nodes.exec',
|
||||
'nodes.update',
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'users.manage',
|
||||
@@ -466,6 +469,7 @@ describe('App', () => {
|
||||
'nodes.read',
|
||||
'nodes.exec',
|
||||
'nodes.update',
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'users.manage',
|
||||
@@ -749,14 +753,13 @@ describe('App', () => {
|
||||
|
||||
expect(await screen.findByText('Operators')).toBeInTheDocument()
|
||||
expect(screen.getByText('Administrators')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1)
|
||||
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(2)
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: 'Delete' })
|
||||
expect(deleteButtons[0]).toBeDisabled()
|
||||
expect(deleteButtons[1]).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
await user.click(screen.getAllByRole('button', { name: 'Edit' })[1])
|
||||
|
||||
expect(screen.getByText('Edit group')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Group name')).toHaveValue('Operators')
|
||||
@@ -778,6 +781,113 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('edits Administrators group permissions from the Groups tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
const administratorsGroup = {
|
||||
name: 'Administrators',
|
||||
scope_kind: 'group' as const,
|
||||
scope_name: 'Admin Group',
|
||||
permissions: ['users.manage', 'roles.manage'],
|
||||
}
|
||||
const updatedAdministratorsGroup = {
|
||||
...administratorsGroup,
|
||||
permissions: ['users.manage', 'roles.manage', 'nodes.delete'],
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (url.includes('/health')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ok' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ service: 'clustercanvas', version: '0.1.0' }),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/groups') && init?.method === 'POST') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
groups: [updatedAdministratorsGroup],
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (url.includes('/api/v1/groups')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
groups: [administratorsGroup],
|
||||
}),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Cluster Canvas' })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Configuration' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'Groups' }))
|
||||
|
||||
expect(await screen.findByText('Administrators')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
|
||||
expect(screen.getByText('Edit group')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Group name')).toHaveValue('Administrators')
|
||||
expect(screen.getByLabelText('Group name')).toHaveAttribute('readonly')
|
||||
expect(screen.getByLabelText('users.manage')).toBeChecked()
|
||||
expect(screen.getByLabelText('nodes.delete')).not.toBeChecked()
|
||||
|
||||
await user.click(screen.getByLabelText('nodes.delete'))
|
||||
await user.click(screen.getByRole('button', { name: 'Save changes' }))
|
||||
|
||||
expect(await screen.findByText('Create group')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Permissions: users.manage, roles.manage, nodes.delete',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/v1/groups'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
group: {
|
||||
name: 'Administrators',
|
||||
scope_kind: 'group',
|
||||
scope_name: 'Admin Group',
|
||||
permissions: ['users.manage', 'roles.manage', 'nodes.delete'],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a new group from the Groups tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
const createdGroup = {
|
||||
|
||||
+288
-59
@@ -6,6 +6,7 @@ import {
|
||||
createNode,
|
||||
createUser,
|
||||
deleteGroup,
|
||||
deleteNode,
|
||||
deleteUser,
|
||||
DEFAULT_NETWORK_SETTINGS,
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
fetchGroups,
|
||||
fetchMe,
|
||||
fetchNetwork,
|
||||
fetchNodeLogs,
|
||||
fetchNodes,
|
||||
fetchSecurity,
|
||||
fetchSetupStatus,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
type MeResponse,
|
||||
type NetworkSettings,
|
||||
type Node,
|
||||
type NodeAuditEvent,
|
||||
type NodeKind,
|
||||
type ReauthCredentials,
|
||||
type SecuritySettings,
|
||||
@@ -76,6 +79,7 @@ const SECTION_TITLES: Record<SectionId, string> = {
|
||||
vms: 'Virtual Machines',
|
||||
docker: 'Docker',
|
||||
containers: 'Containers',
|
||||
logs: 'Logs',
|
||||
profile: 'Profile',
|
||||
configuration: 'Configuration',
|
||||
setup: 'Setup',
|
||||
@@ -109,6 +113,7 @@ const DEFAULT_PERMISSIONS = [
|
||||
'nodes.read',
|
||||
'nodes.exec',
|
||||
'nodes.update',
|
||||
'nodes.delete',
|
||||
'jobs.read',
|
||||
'jobs.run',
|
||||
'users.manage',
|
||||
@@ -1034,9 +1039,6 @@ function GroupsConfigTab() {
|
||||
}
|
||||
|
||||
function handleEditGroup(group: Group) {
|
||||
if (group.name === ADMINISTRATORS_GROUP_NAME) {
|
||||
return
|
||||
}
|
||||
setEditingGroupName(group.name)
|
||||
setDraftGroupName(group.name)
|
||||
setDraftScopeKind(group.scope_kind)
|
||||
@@ -1061,8 +1063,8 @@ function GroupsConfigTab() {
|
||||
async function handleSaveGroup() {
|
||||
setSubmitError(null)
|
||||
|
||||
if (draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
|
||||
setSubmitError('Administrators group cannot be created or modified here')
|
||||
if (!isEditing && draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
|
||||
setSubmitError('Administrators group cannot be created here')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1133,15 +1135,13 @@ function GroupsConfigTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="groups-li-actions">
|
||||
{!isAdministrators ? (
|
||||
<button
|
||||
type="button"
|
||||
className="groups-edit"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="groups-edit"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
@@ -1770,10 +1770,12 @@ function ResourceOverviewTab({
|
||||
section,
|
||||
canRead,
|
||||
canExec,
|
||||
canDelete,
|
||||
}: {
|
||||
section: ResourceSectionId
|
||||
canRead: boolean
|
||||
canExec: boolean
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -1782,6 +1784,34 @@ function ResourceOverviewTab({
|
||||
const [testResults, setTestResults] = useState(
|
||||
{} as Record<string, { ok: boolean; message: string }>,
|
||||
)
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const requireTotp = Boolean(me?.totp_confirmed && me?.totp_enabled)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const response = await fetchMe()
|
||||
if (!isCancelled) {
|
||||
setMe(response)
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setMe(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadMe()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!canRead) {
|
||||
@@ -1846,6 +1876,19 @@ function ResourceOverviewTab({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm(credentials: ReauthCredentials) {
|
||||
if (!pendingDeleteNodeId) {
|
||||
return
|
||||
}
|
||||
const response = await deleteNode(pendingDeleteNodeId, credentials)
|
||||
setNodes(
|
||||
response.nodes.filter(
|
||||
(node) => node.kind === RESOURCE_SECTION_KIND[section],
|
||||
),
|
||||
)
|
||||
setPendingDeleteNodeId(null)
|
||||
}
|
||||
|
||||
if (!canRead) {
|
||||
return (
|
||||
<p className="config-hint">
|
||||
@@ -1872,52 +1915,79 @@ function ResourceOverviewTab({
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="node-list">
|
||||
{nodes.map((node) => {
|
||||
const testResult = testResults[node.id]
|
||||
return (
|
||||
<li key={node.id} className="node-list-item">
|
||||
<div className="node-list-heading">
|
||||
<strong>{node.name}</strong>
|
||||
<code className="node-id">{node.id}</code>
|
||||
</div>
|
||||
<p className="config-hint">
|
||||
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
|
||||
{node.key_algo}
|
||||
</p>
|
||||
{canExec ? (
|
||||
<div className="node-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="node-test-ssh"
|
||||
disabled={testingNodeId === node.id}
|
||||
onClick={() => {
|
||||
void handleTestSSH(node.id)
|
||||
}}
|
||||
>
|
||||
{testingNodeId === node.id
|
||||
? 'Testing SSH…'
|
||||
: 'Test SSH connection'}
|
||||
</button>
|
||||
{testResult ? (
|
||||
<p
|
||||
className={
|
||||
testResult.ok
|
||||
? 'node-test-result node-test-result-ok'
|
||||
: 'node-test-result node-test-result-fail'
|
||||
}
|
||||
role="status"
|
||||
>
|
||||
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
|
||||
{testResult.message}
|
||||
</p>
|
||||
) : null}
|
||||
<>
|
||||
{pendingDeleteNodeId ? (
|
||||
<ReauthModal
|
||||
title="Confirm delete"
|
||||
hint="Re-enter your password to delete this host."
|
||||
requireTotp={requireTotp}
|
||||
confirmLabel="Delete host"
|
||||
onCancel={() => {
|
||||
setPendingDeleteNodeId(null)
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
) : null}
|
||||
<ul className="node-list">
|
||||
{nodes.map((node) => {
|
||||
const testResult = testResults[node.id]
|
||||
return (
|
||||
<li key={node.id} className="node-list-item">
|
||||
<div className="node-list-heading">
|
||||
<strong>{node.name}</strong>
|
||||
<code className="node-id">{node.id}</code>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<p className="config-hint">
|
||||
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
|
||||
{node.key_algo}
|
||||
</p>
|
||||
{canExec || canDelete ? (
|
||||
<div className="node-actions">
|
||||
{canExec ? (
|
||||
<button
|
||||
type="button"
|
||||
className="node-test-ssh"
|
||||
disabled={testingNodeId === node.id}
|
||||
onClick={() => {
|
||||
void handleTestSSH(node.id)
|
||||
}}
|
||||
>
|
||||
{testingNodeId === node.id
|
||||
? 'Testing SSH…'
|
||||
: 'Test SSH connection'}
|
||||
</button>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
className="node-delete"
|
||||
onClick={() => {
|
||||
setPendingDeleteNodeId(node.id)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
{testResult ? (
|
||||
<p
|
||||
className={
|
||||
testResult.ok
|
||||
? 'node-test-result node-test-result-ok'
|
||||
: 'node-test-result node-test-result-fail'
|
||||
}
|
||||
role="status"
|
||||
>
|
||||
{testResult.ok ? 'Succeeded: ' : 'Failed: '}
|
||||
{testResult.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2270,6 +2340,7 @@ function ResourceSectionPanel({
|
||||
|
||||
const canRead = Boolean(me?.permissions?.includes('nodes.read'))
|
||||
const canExec = Boolean(me?.permissions?.includes('nodes.exec'))
|
||||
const canDelete = Boolean(me?.permissions?.includes('nodes.delete'))
|
||||
const tabs = resourceTabsFor(section)
|
||||
|
||||
return (
|
||||
@@ -2313,6 +2384,7 @@ function ResourceSectionPanel({
|
||||
section={section}
|
||||
canRead={canRead}
|
||||
canExec={canExec}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
) : null}
|
||||
{activeResourceTab === 'security' ? (
|
||||
@@ -2330,6 +2402,137 @@ function ResourceSectionPanel({
|
||||
)
|
||||
}
|
||||
|
||||
function LogsPanel() {
|
||||
type KindFilter = 'all' | NodeKind
|
||||
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
|
||||
const [events, setEvents] = useState<ReadonlyArray<NodeAuditEvent>>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [canRead, setCanRead] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function load() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const me = await fetchMe()
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
const allowed = Boolean(me?.permissions?.includes('nodes.read'))
|
||||
setCanRead(allowed)
|
||||
if (!allowed) {
|
||||
setEvents([])
|
||||
return
|
||||
}
|
||||
const response = await fetchNodeLogs(
|
||||
kindFilter === 'all' ? undefined : kindFilter,
|
||||
)
|
||||
if (!isCancelled) {
|
||||
setEvents(response.events)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : 'Failed to load logs',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [kindFilter])
|
||||
|
||||
const filters: ReadonlyArray<{ id: KindFilter; label: string }> = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'container', label: 'Containers' },
|
||||
{ id: 'docker', label: 'Docker' },
|
||||
{ id: 'vm', label: 'Virtual Machines' },
|
||||
]
|
||||
|
||||
if (!isLoading && !canRead) {
|
||||
return (
|
||||
<p className="config-hint">
|
||||
You need the <code>nodes.read</code> permission to view node activity
|
||||
logs.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="logs-panel">
|
||||
<div className="logs-filters" role="tablist" aria-label="Log kind filter">
|
||||
{filters.map((filter) => {
|
||||
const isSelected = kindFilter === filter.id
|
||||
return (
|
||||
<button
|
||||
key={filter.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
className={
|
||||
isSelected ? 'config-tab config-tab-active' : 'config-tab'
|
||||
}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => setKindFilter(filter.id)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isLoading ? <p className="config-hint">Loading…</p> : null}
|
||||
{loadError ? <p className="config-error">{loadError}</p> : null}
|
||||
{!isLoading && !loadError && events.length === 0 ? (
|
||||
<p className="config-hint">No node activity recorded yet.</p>
|
||||
) : null}
|
||||
{!isLoading && events.length > 0 ? (
|
||||
<table className="logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">When</th>
|
||||
<th scope="col">Action</th>
|
||||
<th scope="col">Actor</th>
|
||||
<th scope="col">Node</th>
|
||||
<th scope="col">Kind</th>
|
||||
<th scope="col">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td>{formatUserTimestamp(event.at)}</td>
|
||||
<td>
|
||||
<code>{event.action}</code>
|
||||
</td>
|
||||
<td>{event.actor}</td>
|
||||
<td>
|
||||
<strong>{event.node_name}</strong>
|
||||
<div>
|
||||
<code className="node-id">{event.node_id}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td>{event.node_kind}</td>
|
||||
<td>{event.detail || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigurationPanel({
|
||||
activeConfigTab,
|
||||
onConfigTabChange,
|
||||
@@ -2432,6 +2635,7 @@ function App() {
|
||||
)
|
||||
const [backendVersion, setBackendVersion] = useState('…')
|
||||
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
|
||||
const [canReadLogs, setCanReadLogs] = useState(false)
|
||||
const { themePreference, updateThemePreference } = useThemePreference()
|
||||
|
||||
const profileLabel = profileDisplayName ?? 'Profile'
|
||||
@@ -2471,6 +2675,7 @@ function App() {
|
||||
}
|
||||
|
||||
setProfileDisplayName(me.username)
|
||||
setCanReadLogs(Boolean(me.permissions?.includes('nodes.read')))
|
||||
setBootState({ kind: 'ready', username: me.username })
|
||||
if (
|
||||
window.location.pathname === '/setup' ||
|
||||
@@ -2577,6 +2782,9 @@ function App() {
|
||||
<LoginPage
|
||||
onLoggedIn={(username) => {
|
||||
setProfileDisplayName(username)
|
||||
void fetchMe().then((me) => {
|
||||
setCanReadLogs(Boolean(me?.permissions?.includes('nodes.read')))
|
||||
})
|
||||
setBootState({ kind: 'ready', username })
|
||||
navigateTo('/')
|
||||
setActiveSection('overview')
|
||||
@@ -2628,6 +2836,24 @@ function App() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{canReadLogs ? (
|
||||
<div className="sidebar-logs">
|
||||
<p className="sidebar-category-label">Logs</p>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
activeSection === 'logs'
|
||||
? 'nav-item nav-item-active'
|
||||
: 'nav-item'
|
||||
}
|
||||
aria-current={activeSection === 'logs' ? 'page' : undefined}
|
||||
onClick={() => goToSection('logs')}
|
||||
>
|
||||
Activity
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2675,10 +2901,13 @@ function App() {
|
||||
<ProfilePanel
|
||||
onSignedOut={() => {
|
||||
setProfileDisplayName(null)
|
||||
setCanReadLogs(false)
|
||||
setBootState({ kind: 'login' })
|
||||
navigateTo('/login')
|
||||
}}
|
||||
/>
|
||||
) : activeSection === 'logs' ? (
|
||||
<LogsPanel />
|
||||
) : isResourceSectionId(activeSection) ? (
|
||||
<ResourceSectionPanel
|
||||
section={activeSection}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deleteGroup,
|
||||
deleteNode,
|
||||
deleteUser,
|
||||
fetchGroups,
|
||||
fetchHealth,
|
||||
fetchNetwork,
|
||||
fetchNodeLogs,
|
||||
fetchSecurity,
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
@@ -326,6 +328,69 @@ describe('deleteUser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNode', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sends DELETE by id with reauth credentials and returns nodes', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ nodes: [] }),
|
||||
})
|
||||
|
||||
await deleteNode(
|
||||
'node-1',
|
||||
{ current_password: 'correct horse battery staple extra' },
|
||||
fetchMock,
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/nodes/node-1',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
current_password: 'correct horse battery staple extra',
|
||||
totp_code: '',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchNodeLogs', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns parsed logs payload with optional kind filter', async () => {
|
||||
const events = [
|
||||
{
|
||||
id: 'e1',
|
||||
at: '2026-01-01T00:00:00Z',
|
||||
action: 'create',
|
||||
actor: 'Admin',
|
||||
node_id: 'n1',
|
||||
node_name: 'ct1',
|
||||
node_kind: 'container',
|
||||
},
|
||||
]
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ events }),
|
||||
})
|
||||
|
||||
const payload = await fetchNodeLogs('container', fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/node-logs?kind=container',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
expect(payload.events).toEqual(events)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchSecurity', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
||||
@@ -675,6 +675,28 @@ export async function createNode(
|
||||
return (await response.json()) as NodeResponse
|
||||
}
|
||||
|
||||
export async function deleteNode(
|
||||
id: string,
|
||||
credentials: ReauthCredentials,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodesResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
current_password: credentials.current_password,
|
||||
totp_code: credentials.totp_code ?? '',
|
||||
}),
|
||||
},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodesResponse
|
||||
}
|
||||
|
||||
export async function fetchNode(
|
||||
id: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
@@ -709,3 +731,32 @@ export async function testNodeSSH(
|
||||
}
|
||||
return (await response.json()) as NodeSSHTestResponse
|
||||
}
|
||||
|
||||
export type NodeAuditAction = 'create' | 'delete' | 'test_ssh' | 'update'
|
||||
|
||||
export type NodeAuditEvent = {
|
||||
id: string
|
||||
at: string
|
||||
action: NodeAuditAction
|
||||
actor: string
|
||||
node_id: string
|
||||
node_name: string
|
||||
node_kind: NodeKind
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export type NodeLogsResponse = {
|
||||
events: ReadonlyArray<NodeAuditEvent>
|
||||
}
|
||||
|
||||
export async function fetchNodeLogs(
|
||||
kind?: NodeKind,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<NodeLogsResponse> {
|
||||
const query = kind ? `?kind=${encodeURIComponent(kind)}` : ''
|
||||
const response = await apiFetch(`/api/v1/node-logs${query}`, {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as NodeLogsResponse
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('pathFor', () => {
|
||||
expect(pathFor('containers', 'overview', 'add')).toBe('/containers/add')
|
||||
expect(pathFor('docker', 'overview', 'add')).toBe('/docker/add')
|
||||
expect(pathFor('profile')).toBe('/profile')
|
||||
expect(pathFor('logs')).toBe('/logs')
|
||||
expect(pathFor('setup')).toBe('/setup')
|
||||
expect(pathFor('login')).toBe('/login')
|
||||
expect(pathFor('configuration')).toBe('/configuration')
|
||||
@@ -31,6 +32,7 @@ describe('parseLocation', () => {
|
||||
'/containers/add',
|
||||
'/docker/overview',
|
||||
'/profile',
|
||||
'/logs',
|
||||
'/setup',
|
||||
'/login',
|
||||
'/configuration',
|
||||
|
||||
@@ -3,6 +3,7 @@ export type SectionId =
|
||||
| 'vms'
|
||||
| 'docker'
|
||||
| 'containers'
|
||||
| 'logs'
|
||||
| 'profile'
|
||||
| 'configuration'
|
||||
| 'setup'
|
||||
@@ -92,6 +93,10 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (first === 'logs' && segments.length === 1) {
|
||||
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
|
||||
}
|
||||
|
||||
if (RESOURCE_SECTION_IDS.has(first)) {
|
||||
const section = first as ResourceSectionId
|
||||
if (segments.length === 1) {
|
||||
@@ -141,6 +146,9 @@ export function pathFor(
|
||||
if (section === 'profile') {
|
||||
return '/profile'
|
||||
}
|
||||
if (section === 'logs') {
|
||||
return '/logs'
|
||||
}
|
||||
if (isResourceSectionId(section)) {
|
||||
if (resourceTab === 'overview') {
|
||||
return `/${section}`
|
||||
|
||||
Reference in New Issue
Block a user