Files
ClusterCanvas/webui/src/App.tsx
T
Squid 6eef839526 Add Overview page with kind-grouped node cards and RAM/disk metrics.
Batch-fetch action widgets so the dashboard can show free/total memory and disk usage without N+1 requests.
2026-07-20 21:25:55 +02:00

4403 lines
130 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type FormEvent } from 'react'
import { OverviewPanel } from './OverviewPanel'
import {
beginMyTOTP,
changeMyPassword,
confirmMyTOTP,
createNode,
createUser,
createAction,
deleteAction,
deleteGroup,
deleteNode,
deleteUser,
DEFAULT_NETWORK_SETTINGS,
DEFAULT_SECURITY_SETTINGS,
disableMyTOTP,
fetchActions,
fetchAuthLogs,
fetchGroups,
fetchMe,
fetchNetwork,
logout,
fetchNodeLogs,
fetchNodes,
fetchSecurity,
fetchSetupStatus,
fetchStatus,
fetchUsers,
isReauthRequiredError,
patchAction,
patchUser,
reauth,
runNodeHealthCheck,
type AccessMode,
type Action,
type ActionEnvVar,
type ActionKind,
type ActionWidgetType,
type AuthAuditEvent,
type Group,
type GroupScopeKind,
type MeResponse,
type NetworkSettings,
type Node,
type NodeAuditAction,
type NodeAuditEvent,
type NodeKind,
type ReauthCredentials,
type SecuritySettings,
type User,
saveNetwork,
saveSecurity,
upsertGroup,
} from './api/client'
import {
filterAuthEvents,
filterNodeEvents,
pageRangeLabel,
paginateItems,
resolveDateRangeBounds,
sortAuthEvents,
sortNodeEvents,
type AuthActionQuickFilter,
type AuthCategoryFilter,
type AuthLogsSortKey,
type AuthOutcomeFilter,
type DateRangePreset,
type NodeLogsSortKey,
type SortDirection,
} from './logsFiltering'
import {
LOGS_PAGE_SIZE_OPTIONS,
readLogsPageSize,
writeLogsPageSize,
type LogsPageSize,
} from './logsPreferences'
import { LoginPage } from './LoginPage'
import { NodeDetailPanel } from './NodeDetailPanel'
import {
countAuthFailuresToday,
countNodeHealthErrorsBySection,
countNodesBySection,
type NodeSectionCounts,
} from './sidebarCounts'
import { documentTitleFor, SECTION_TITLES } from './documentTitle'
import {
isResourceSectionId,
navigateTo,
parseLocation,
pathFor,
subscribeToLocation,
type ConfigTabId,
type NodeDetailTabId,
type ResourceSectionId,
type ResourceTabId,
type SectionId,
} from './navigation'
import {
validateHostname,
validateListenAddress,
validatePassword,
} from './passwordPolicy'
import { ReauthModal } from './ReauthModal'
import { SetupWizard } from './SetupWizard'
import {
applyThemeFromPreference,
getSystemPrefersDark,
readThemePreference,
writeThemePreference,
type ThemePreference,
} from './theme'
import './App.css'
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'containers', label: 'Containers' },
{ id: 'docker', label: 'Docker' },
{ id: 'vms', label: 'Virtual Machines' },
]
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'users', label: 'Users' },
{ id: 'roles', label: 'Roles' },
{ id: 'groups', label: 'Groups' },
{ id: 'actions', label: 'Actions' },
{ id: 'security', label: 'Security' },
{ id: 'integrations', label: 'Integrations' },
{ id: 'network', label: 'Network' },
{ id: 'advanced', label: 'Advanced' },
]
const RESOURCE_SECTION_KIND: Record<ResourceSectionId, NodeKind> = {
containers: 'container',
vms: 'vm',
docker: 'docker',
}
const RESOURCE_ADD_LABEL: Record<ResourceSectionId, string> = {
containers: 'Add Containers',
vms: 'Add Virtual Machines',
docker: 'Add Docker',
}
const DEFAULT_PERMISSIONS = [
'nodes.read',
'nodes.exec',
'nodes.update',
'nodes.delete',
'jobs.read',
'jobs.run',
'actions.create',
'actions.update',
'actions.delete',
'actions.execute',
'users.manage',
'secrets.manage',
'roles.manage',
'logs.read',
] as const
const ADMINISTRATORS_GROUP_NAME = 'Administrators'
function formatUserTimestamp(value: string): string {
const parsed = Date.parse(value)
if (Number.isNaN(parsed)) {
return value || '—'
}
return new Date(parsed).toLocaleString()
}
function CogIcon() {
return (
<svg
className="cog-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96a7.2 7.2 0 0 0-1.62-.94l-.36-2.54A.48.48 0 0 0 13.9 2h-3.8a.48.48 0 0 0-.48.42l-.36 2.54c-.59.24-1.13.55-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.48a.49.49 0 0 0 .12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94L2.86 14.52a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.3.59.22l2.39-.96c.49.39 1.03.7 1.62.94l.36 2.54c.05.24.24.42.48.42h3.8c.24 0 .43-.18.48-.42l.36-2.54c.59-.24 1.13-.55 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.03-1.58ZM12 15.5A3.5 3.5 0 1 1 12 8.5a3.5 3.5 0 0 1 0 7Z"
/>
</svg>
)
}
function UserIcon() {
return (
<svg
className="user-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12Zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .7.5 1.2 1.2 1.2h16.8c.7 0 1.2-.5 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8Z"
/>
</svg>
)
}
function LogoutIcon() {
return (
<svg
className="logout-icon"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5-5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"
/>
</svg>
)
}
function FiltersChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
className={
expanded
? 'logs-filters-chevron logs-filters-chevron-expanded'
: 'logs-filters-chevron'
}
viewBox="0 0 24 24"
width="16"
height="16"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6z"
/>
</svg>
)
}
function ProfilePanel({
onSignedOut,
}: {
onSignedOut: () => void
}) {
const [me, setMe] = useState<MeResponse | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordTotp, setPasswordTotp] = useState('')
const [passwordError, setPasswordError] = useState<string | null>(null)
const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null)
const [totpPassword, setTotpPassword] = useState('')
const [totpReauthCode, setTotpReauthCode] = useState('')
const [pendingSecret, setPendingSecret] = useState<string | null>(null)
const [pendingUrl, setPendingUrl] = useState<string | null>(null)
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [confirmCode, setConfirmCode] = useState('')
const [disableCode, setDisableCode] = useState('')
const [totpError, setTotpError] = useState<string | null>(null)
const [totpMessage, setTotpMessage] = useState<string | null>(null)
useEffect(() => {
let isCancelled = false
async function load() {
try {
const response = await fetchMe()
if (!isCancelled) {
if (!response) {
onSignedOut()
return
}
setMe(response)
}
} catch (err) {
if (!isCancelled) {
setLoadError(err instanceof Error ? err.message : 'Failed to load profile')
}
}
}
void load()
return () => {
isCancelled = true
}
}, [onSignedOut])
useEffect(() => {
if (!pendingUrl) {
setQrDataUrl(null)
return
}
let isCancelled = false
void import('qrcode').then(async (qrcode) => {
const dataUrl = await qrcode.toDataURL(pendingUrl, { width: 220, margin: 1 })
if (!isCancelled) {
setQrDataUrl(dataUrl)
}
})
return () => {
isCancelled = true
}
}, [pendingUrl])
async function handleChangePassword(event: FormEvent) {
event.preventDefault()
setPasswordError(null)
setPasswordSuccess(null)
if (!me) {
return
}
if (newPassword !== confirmPassword) {
setPasswordError('New passwords do not match')
return
}
const policyError = validatePassword(newPassword, me.username)
if (policyError) {
setPasswordError(policyError)
return
}
try {
await changeMyPassword(currentPassword, newPassword, passwordTotp)
setPasswordSuccess('Password updated. Sign in again.')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setPasswordTotp('')
onSignedOut()
} catch (err) {
setPasswordError(err instanceof Error ? err.message : 'Password change failed')
}
}
async function handleBeginTotp(event: FormEvent) {
event.preventDefault()
setTotpError(null)
setTotpMessage(null)
try {
const began = await beginMyTOTP({
current_password: totpPassword,
totp_code: totpReauthCode,
})
setPendingSecret(began.secret)
setPendingUrl(began.otpauth_url)
setTotpMessage('Scan the QR code, then enter a code to confirm.')
} catch (err) {
setTotpError(err instanceof Error ? err.message : 'Failed to start TOTP enrollment')
}
}
async function handleConfirmTotp(event: FormEvent) {
event.preventDefault()
if (!pendingSecret) {
return
}
setTotpError(null)
try {
await confirmMyTOTP(pendingSecret, confirmCode, {
current_password: totpPassword,
totp_code: totpReauthCode,
})
setPendingSecret(null)
setPendingUrl(null)
setConfirmCode('')
setTotpPassword('')
setTotpReauthCode('')
setTotpMessage('Authenticator enabled.')
const refreshed = await fetchMe()
if (refreshed) {
setMe(refreshed)
}
} catch (err) {
setTotpError(err instanceof Error ? err.message : 'Failed to confirm TOTP')
}
}
async function handleDisableTotp(event: FormEvent) {
event.preventDefault()
setTotpError(null)
try {
await disableMyTOTP({
current_password: totpPassword,
totp_code: disableCode,
})
setDisableCode('')
setTotpPassword('')
setTotpMessage('Authenticator disabled.')
const refreshed = await fetchMe()
if (refreshed) {
setMe(refreshed)
}
} catch (err) {
setTotpError(err instanceof Error ? err.message : 'Failed to disable TOTP')
}
}
if (loadError) {
return (
<div className="profile-panel">
<p className="config-empty" role="alert">
{loadError}
</p>
</div>
)
}
if (!me) {
return (
<div className="profile-panel">
<p className="config-empty">Loading</p>
</div>
)
}
return (
<div className="profile-panel">
<section className="profile-section">
<h2 className="profile-section-title">Account</h2>
<p className="config-hint">Signed in as {me.username}</p>
</section>
<section className="profile-section">
<h2 className="profile-section-title">Change password</h2>
<form className="profile-form" onSubmit={(event) => void handleChangePassword(event)}>
<label htmlFor="profile-current-password">Current password</label>
<input
id="profile-current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
required
/>
<label htmlFor="profile-new-password">New password</label>
<input
id="profile-new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
required
/>
<label htmlFor="profile-confirm-password">Confirm new password</label>
<input
id="profile-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
required
/>
{me.totp_confirmed ? (
<>
<label htmlFor="profile-password-totp">Authenticator code</label>
<input
id="profile-password-totp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={passwordTotp}
onChange={(event) => setPasswordTotp(event.target.value)}
required
/>
</>
) : null}
{passwordError ? (
<p className="groups-error" role="alert">
{passwordError}
</p>
) : null}
{passwordSuccess ? (
<p className="config-hint" role="status">
{passwordSuccess}
</p>
) : null}
<button type="submit" className="config-action-save">
Update password
</button>
</form>
</section>
<section className="profile-section">
<h2 className="profile-section-title">Authenticator (TOTP)</h2>
{!me.totp_enabled ? (
<p className="config-hint">
An administrator must enable TOTP on the server before you can enroll.
</p>
) : me.totp_confirmed ? (
<form className="profile-form" onSubmit={(event) => void handleDisableTotp(event)}>
<p className="config-hint">Authenticator is enabled on your account.</p>
<label htmlFor="profile-disable-password">Password</label>
<input
id="profile-disable-password"
type="password"
autoComplete="current-password"
value={totpPassword}
onChange={(event) => setTotpPassword(event.target.value)}
required
/>
<label htmlFor="profile-disable-code">Current authenticator code</label>
<input
id="profile-disable-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={disableCode}
onChange={(event) => setDisableCode(event.target.value)}
required
/>
{totpError ? (
<p className="groups-error" role="alert">
{totpError}
</p>
) : null}
{totpMessage ? (
<p className="config-hint" role="status">
{totpMessage}
</p>
) : null}
<button type="submit" className="groups-delete">
Disable authenticator
</button>
</form>
) : pendingSecret ? (
<form className="profile-form" onSubmit={(event) => void handleConfirmTotp(event)}>
{qrDataUrl ? (
<img className="totp-qr" src={qrDataUrl} alt="TOTP QR code" />
) : null}
<p className="config-hint">Secret: {pendingSecret}</p>
<label htmlFor="profile-confirm-code">Authenticator code</label>
<input
id="profile-confirm-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={confirmCode}
onChange={(event) => setConfirmCode(event.target.value)}
required
/>
{totpError ? (
<p className="groups-error" role="alert">
{totpError}
</p>
) : null}
<button type="submit" className="config-action-save">
Confirm authenticator
</button>
</form>
) : (
<form className="profile-form" onSubmit={(event) => void handleBeginTotp(event)}>
<p className="config-hint">
Re-enter your password to begin authenticator enrollment.
</p>
<label htmlFor="profile-totp-password">Password</label>
<input
id="profile-totp-password"
type="password"
autoComplete="current-password"
value={totpPassword}
onChange={(event) => setTotpPassword(event.target.value)}
required
/>
{totpError ? (
<p className="groups-error" role="alert">
{totpError}
</p>
) : null}
{totpMessage ? (
<p className="config-hint" role="status">
{totpMessage}
</p>
) : null}
<button type="submit" className="config-action-save">
Begin enrollment
</button>
</form>
)}
</section>
</div>
)
}
function UsersConfigTab() {
const [users, setUsers] = useState<ReadonlyArray<User>>([])
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
const [me, setMe] = useState<MeResponse | null>(null)
const [isLoadingUsers, setIsLoadingUsers] = useState(true)
const [usersLoadError, setUsersLoadError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [createUsername, setCreateUsername] = useState('')
const [createPassword, setCreatePassword] = useState('')
const [createGroupNames, setCreateGroupNames] = useState<ReadonlyArray<string>>([])
const [editingUserId, setEditingUserId] = useState<string | null>(null)
const [editGroupNames, setEditGroupNames] = useState<ReadonlyArray<string>>([])
const [setPasswordUserId, setSetPasswordUserId] = useState<string | null>(null)
const [setPasswordValue, setSetPasswordValue] = useState('')
type PendingAction =
| { kind: 'delete'; userId: string }
| {
kind: 'grace'
run: (credentials?: ReauthCredentials) => Promise<void>
}
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null)
const canManageUsers = Boolean(me?.permissions?.includes('users.manage'))
const requireTotp = Boolean(me?.totp_confirmed && me?.totp_enabled)
useEffect(() => {
let isCancelled = false
async function loadUsers() {
setIsLoadingUsers(true)
setUsersLoadError(null)
try {
const [usersResponse, groupsResponse, meResponse] = await Promise.all([
fetchUsers(),
fetchGroups(),
fetchMe(),
])
if (!isCancelled) {
setUsers(usersResponse.users)
setGroups(groupsResponse.groups)
setMe(meResponse)
}
} catch (err) {
if (!isCancelled) {
setUsersLoadError(
err instanceof Error ? err.message : 'Failed to load users',
)
}
} finally {
if (!isCancelled) {
setIsLoadingUsers(false)
}
}
}
void loadUsers()
return () => {
isCancelled = true
}
}, [])
function toggleGroupSelection(
current: ReadonlyArray<string>,
groupName: string,
): ReadonlyArray<string> {
if (current.includes(groupName)) {
return current.filter((name) => name !== groupName)
}
return [...current, groupName]
}
async function runGraceMutation(mutation: () => Promise<void>) {
setActionError(null)
try {
await mutation()
} catch (err) {
if (isReauthRequiredError(err)) {
setPendingAction({
kind: 'grace',
run: async () => {
// caller will reauth first then call mutation again
await mutation()
},
})
return
}
setActionError(err instanceof Error ? err.message : 'Action failed')
}
}
async function handleCreateUser(event: FormEvent) {
event.preventDefault()
setActionError(null)
const policyError = validatePassword(createPassword, createUsername)
if (policyError) {
setActionError(policyError)
return
}
try {
const response = await createUser({
username: createUsername,
password: createPassword,
group_names: createGroupNames,
})
setUsers(response.users)
setCreateUsername('')
setCreatePassword('')
setCreateGroupNames([])
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Failed to create user')
}
}
async function handleSaveGroups(userId: string) {
await runGraceMutation(async () => {
const response = await patchUser(userId, { group_names: editGroupNames })
setUsers(response.users)
setEditingUserId(null)
})
}
async function handleSetPassword(userId: string) {
const target = users.find((user) => user.id === userId)
const policyError = validatePassword(setPasswordValue, target?.username ?? '')
if (policyError) {
setActionError(policyError)
return
}
await runGraceMutation(async () => {
const response = await patchUser(userId, { password: setPasswordValue })
setUsers(response.users)
setSetPasswordUserId(null)
setSetPasswordValue('')
})
}
async function handleDisableTotp(userId: string) {
await runGraceMutation(async () => {
const response = await patchUser(userId, { disable_totp: true })
setUsers(response.users)
})
}
async function handleDeleteConfirm(credentials: ReauthCredentials) {
if (!pendingAction || pendingAction.kind !== 'delete') {
return
}
const response = await deleteUser(pendingAction.userId, credentials)
setUsers(response.users)
setPendingAction(null)
}
async function handleGraceConfirm(credentials: ReauthCredentials) {
if (!pendingAction || pendingAction.kind !== 'grace') {
return
}
await reauth(credentials.current_password, credentials.totp_code ?? '')
await pendingAction.run()
setPendingAction(null)
}
return (
<div className="groups-panel users-panel">
{pendingAction?.kind === 'delete' ? (
<ReauthModal
title="Confirm delete"
hint="Re-enter your password to delete this user."
requireTotp={requireTotp}
confirmLabel="Delete user"
onCancel={() => setPendingAction(null)}
onConfirm={handleDeleteConfirm}
/>
) : null}
{pendingAction?.kind === 'grace' ? (
<ReauthModal
title="Confirm identity"
hint="Re-authentication is required for this change."
requireTotp={requireTotp}
confirmLabel="Continue"
onCancel={() => setPendingAction(null)}
onConfirm={handleGraceConfirm}
/>
) : null}
{canManageUsers ? (
<form
className="users-create-form"
onSubmit={(event) => void handleCreateUser(event)}
>
<p className="config-hint">Create user</p>
<label htmlFor="create-username">Username</label>
<input
id="create-username"
value={createUsername}
onChange={(event) => setCreateUsername(event.target.value)}
required
/>
<label htmlFor="create-password">Password</label>
<input
id="create-password"
type="password"
autoComplete="new-password"
value={createPassword}
onChange={(event) => setCreatePassword(event.target.value)}
required
/>
<fieldset className="users-group-fieldset">
<legend>Groups (optional)</legend>
{groups.map((group) => (
<label key={group.name} className="permission-option">
<input
type="checkbox"
checked={createGroupNames.includes(group.name)}
onChange={() =>
setCreateGroupNames(
toggleGroupSelection(createGroupNames, group.name),
)
}
/>
<span>{group.name}</span>
</label>
))}
</fieldset>
<button type="submit" className="config-action-save">
Create user
</button>
</form>
) : null}
<div className="groups-list">
<p className="config-hint">Configured accounts</p>
{actionError ? (
<p className="config-empty groups-error" role="alert">
{actionError}
</p>
) : null}
{isLoadingUsers ? (
<p className="config-empty">Loading</p>
) : usersLoadError ? (
<p className="config-empty" role="alert">
{usersLoadError}
</p>
) : users.length === 0 ? (
<p className="config-empty">No users configured yet.</p>
) : (
<ul className="groups-ul">
{users.map((user) => (
<li key={user.id} className="groups-li">
<div className="groups-li-content">
<div className="groups-li-title">
{user.username}
{user.is_admin ? (
<span className="user-badge">Admin</span>
) : null}
</div>
<div className="groups-li-meta">
Groups:{' '}
{user.group_names.length > 0
? user.group_names.join(', ')
: '—'}
</div>
<div className="groups-li-meta">
Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '}
{user.totp_confirmed ? 'confirmed' : 'not confirmed'}
</div>
<div className="groups-li-meta">
Created: {formatUserTimestamp(user.created_at)} · Password
changed: {formatUserTimestamp(user.password_changed_at)}
</div>
<div className="groups-li-meta">ID: {user.id}</div>
{canManageUsers && editingUserId === user.id ? (
<div className="users-inline-edit">
<fieldset className="users-group-fieldset">
<legend>Groups</legend>
{groups.map((group) => (
<label key={group.name} className="permission-option">
<input
type="checkbox"
checked={editGroupNames.includes(group.name)}
onChange={() =>
setEditGroupNames(
toggleGroupSelection(editGroupNames, group.name),
)
}
/>
<span>{group.name}</span>
</label>
))}
</fieldset>
<div className="users-inline-actions">
<button
type="button"
className="config-action-save"
onClick={() => void handleSaveGroups(user.id)}
>
Save groups
</button>
<button
type="button"
className="config-action-revert"
onClick={() => setEditingUserId(null)}
>
Cancel
</button>
</div>
</div>
) : null}
{canManageUsers && setPasswordUserId === user.id ? (
<div className="users-inline-edit">
<label htmlFor={`set-password-${user.id}`}>New password</label>
<input
id={`set-password-${user.id}`}
type="password"
autoComplete="new-password"
value={setPasswordValue}
onChange={(event) => setSetPasswordValue(event.target.value)}
/>
<div className="users-inline-actions">
<button
type="button"
className="config-action-save"
onClick={() => void handleSetPassword(user.id)}
>
Set password
</button>
<button
type="button"
className="config-action-revert"
onClick={() => {
setSetPasswordUserId(null)
setSetPasswordValue('')
}}
>
Cancel
</button>
</div>
</div>
) : null}
</div>
{canManageUsers ? (
<div className="users-row-actions">
<button
type="button"
className="config-action-revert"
onClick={() => {
setEditingUserId(user.id)
setEditGroupNames(user.group_names)
setSetPasswordUserId(null)
}}
>
Groups
</button>
<button
type="button"
className="config-action-revert"
onClick={() => {
setSetPasswordUserId(user.id)
setSetPasswordValue('')
setEditingUserId(null)
}}
>
Password
</button>
{user.totp_confirmed ? (
<button
type="button"
className="config-action-revert"
onClick={() => void handleDisableTotp(user.id)}
>
Disable TOTP
</button>
) : null}
<button
type="button"
className="groups-delete"
disabled={!user.can_delete}
title={
user.can_delete
? undefined
: 'Cannot delete the last administrator'
}
onClick={() =>
setPendingAction({ kind: 'delete', userId: user.id })
}
>
Delete
</button>
</div>
) : (
<button
type="button"
className="groups-delete"
disabled
title="Requires users.manage"
>
Delete
</button>
)}
</li>
))}
</ul>
)}
</div>
</div>
)
}
function useThemePreference() {
const [themePreference, setThemePreference] =
useState<ThemePreference>(readThemePreference)
useEffect(() => {
applyThemeFromPreference(themePreference)
if (themePreference !== 'auto') {
return
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
function handleSystemThemeChange() {
applyThemeFromPreference('auto', getSystemPrefersDark(mediaQuery))
}
mediaQuery.addEventListener('change', handleSystemThemeChange)
return () => {
mediaQuery.removeEventListener('change', handleSystemThemeChange)
}
}, [themePreference])
function updateThemePreference(nextPreference: ThemePreference) {
writeThemePreference(nextPreference)
setThemePreference(nextPreference)
}
return { themePreference, updateThemePreference }
}
function OverviewConfigTab({
draftThemePreference,
onDraftThemePreferenceChange,
}: {
draftThemePreference: ThemePreference
onDraftThemePreferenceChange: (preference: ThemePreference) => void
}) {
return (
<div className="config-field">
<label htmlFor="theme-preference">Theme</label>
<select
id="theme-preference"
className="theme-select"
value={draftThemePreference}
onChange={(event) => {
onDraftThemePreferenceChange(event.target.value as ThemePreference)
}}
>
<option value="auto">Auto</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<p className="config-hint">
Auto follows the browser preference. Light and Dark always use
Catppuccin Latte or Mocha.
</p>
</div>
)
}
const ACTION_PLACEHOLDERS: ReadonlyArray<{
token: string
description: string
example: string
}> = [
{
token: '{{cc.host}}',
description: 'ClusterCanvas public hostname',
example: 'canvas.example.com',
},
{
token: '{{cc.ip}}',
description: 'ClusterCanvas listen / resolved IP',
example: '192.168.1.10',
},
{
token: '{{node.host}}',
description: 'Target node hostname / address as stored',
example: 'vm-web-01',
},
{
token: '{{node.ip}}',
description: 'Target node host IP',
example: '10.0.0.5',
},
{
token: '{{node.username}}',
description: 'SSH username on the target node',
example: 'root',
},
{
token: '{{env.NAME}}',
description: 'Value of an ENV var defined on this action',
example: '{{env.APP_HOME}} → /opt/app',
},
{
token: '{{secret.NAME}}',
description:
'Reserved for the future Secrets page (not resolved yet)',
example: '{{secret.db_password}}',
},
]
function emptyEnvRow(): ActionEnvVar {
return { name: '', value: '' }
}
function ActionsConfigTab() {
const [actions, setActions] = useState<Action[]>([])
const [me, setMe] = useState<MeResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [showTypePicker, setShowTypePicker] = useState(false)
const [editorKind, setEditorKind] = useState<ActionKind | null>(null)
const [editingActionId, setEditingActionId] = useState<string | null>(null)
const [draftName, setDraftName] = useState('')
const [draftDescription, setDraftDescription] = useState('')
const [draftBody, setDraftBody] = useState('')
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
const [draftRequiresSudo, setDraftRequiresSudo] = useState(false)
const [draftShowWidget, setDraftShowWidget] = useState(false)
const [draftWidgetType, setDraftWidgetType] =
useState<ActionWidgetType>('raw')
const [isSaving, setIsSaving] = useState(false)
const [editingIsBuiltin, setEditingIsBuiltin] = useState(false)
const isEditing = editingActionId !== null
const showEditor = editorKind !== null
const canCreateActions = Boolean(me?.permissions?.includes('actions.create'))
const canUpdateActions = Boolean(me?.permissions?.includes('actions.update'))
const canDeleteActions = Boolean(me?.permissions?.includes('actions.delete'))
const fieldsLocked = editingIsBuiltin
useEffect(() => {
let isCancelled = false
async function loadActions() {
setIsLoading(true)
setLoadError(null)
try {
const [actionsResponse, meResponse] = await Promise.all([
fetchActions(),
fetchMe(),
])
if (!isCancelled) {
setActions([...actionsResponse.actions])
setMe(meResponse)
}
} catch (err) {
if (!isCancelled) {
setLoadError(
err instanceof Error ? err.message : 'Failed to load actions',
)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void loadActions()
return () => {
isCancelled = true
}
}, [])
function clearEditor() {
setShowTypePicker(false)
setEditorKind(null)
setEditingActionId(null)
setDraftName('')
setDraftDescription('')
setDraftBody('')
setDraftEnv([emptyEnvRow()])
setDraftRequiresSudo(false)
setDraftShowWidget(false)
setDraftWidgetType('raw')
setEditingIsBuiltin(false)
setSubmitError(null)
}
function openTypePicker() {
clearEditor()
setShowTypePicker(true)
}
function startCreate(kind: ActionKind) {
setShowTypePicker(false)
setEditorKind(kind)
setEditingActionId(null)
setEditingIsBuiltin(false)
setDraftName('')
setDraftDescription('')
setDraftBody('')
setDraftEnv([emptyEnvRow()])
setDraftRequiresSudo(false)
setDraftShowWidget(false)
setDraftWidgetType('raw')
setSubmitError(null)
}
function startEdit(action: Action) {
if (!canUpdateActions) {
return
}
if (action.builtin && !canUpdateActions) {
return
}
setShowTypePicker(false)
setEditorKind(action.kind)
setEditingActionId(action.id)
setEditingIsBuiltin(Boolean(action.builtin))
setDraftName(action.name)
setDraftDescription(action.description)
setDraftBody(action.body)
setDraftEnv(
action.env.length > 0
? action.env.map((envVar) => ({ ...envVar }))
: [emptyEnvRow()],
)
setDraftRequiresSudo(Boolean(action.requires_sudo))
setDraftShowWidget(Boolean(action.show_widget))
setDraftWidgetType(
action.widget_type === 'memory' ||
action.widget_type === 'disk' ||
action.widget_type === 'raw'
? action.widget_type
: 'raw',
)
setSubmitError(null)
}
function updateEnvRow(index: number, field: 'name' | 'value', value: string) {
setDraftEnv((previous) =>
previous.map((row, rowIndex) =>
rowIndex === index ? { ...row, [field]: value } : row,
),
)
}
function addEnvRow() {
setDraftEnv((previous) => [...previous, emptyEnvRow()])
}
function removeEnvRow(index: number) {
setDraftEnv((previous) => {
if (previous.length <= 1) {
return [emptyEnvRow()]
}
return previous.filter((_, rowIndex) => rowIndex !== index)
})
}
function normalizedEnv(): ActionEnvVar[] {
return draftEnv
.map((row) => ({
name: row.name.trim(),
value: row.value,
}))
.filter((row) => row.name !== '' || row.value.trim() !== '')
}
async function handleSaveAction() {
if (!editorKind) {
return
}
setSubmitError(null)
setIsSaving(true)
try {
const env = normalizedEnv()
const widgetType = draftShowWidget ? draftWidgetType : ('' as const)
const response = isEditing
? await patchAction(
editingActionId!,
editingIsBuiltin
? {
show_widget: draftShowWidget,
widget_type: widgetType,
}
: {
name: draftName,
description: draftDescription,
body: draftBody,
env,
requires_sudo: draftRequiresSudo,
show_widget: draftShowWidget,
widget_type: widgetType,
},
)
: await createAction({
name: draftName,
description: draftDescription,
kind: editorKind,
body: draftBody,
env,
requires_sudo: draftRequiresSudo,
show_widget: draftShowWidget,
widget_type: widgetType,
})
setActions([...response.actions])
clearEditor()
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to save action',
)
} finally {
setIsSaving(false)
}
}
async function handleDeleteAction(action: Action) {
if (action.builtin || !canDeleteActions) {
return
}
if (!window.confirm(`Delete action “${action.name}”?`)) {
return
}
setSubmitError(null)
try {
const response = await deleteAction(action.id)
setActions([...response.actions])
if (editingActionId === action.id) {
clearEditor()
}
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to delete action',
)
}
}
return (
<div className="actions-panel">
<p className="config-hint">
Pre-defined and custom actions for managed nodes. Placeholders and ENV
vars are expanded when an action runs.
</p>
{submitError && !showEditor ? (
<p className="config-empty groups-error" role="alert">
{submitError}
</p>
) : null}
<div className="actions-layout">
<div className="actions-list">
<div className="actions-list-header">
<p className="config-hint">Configured actions</p>
<button
type="button"
className="groups-save"
disabled={!canCreateActions}
title={
canCreateActions ? undefined : 'Requires actions.create'
}
onClick={() => openTypePicker()}
>
Add Action
</button>
</div>
{isLoading ? (
<p className="config-empty">Loading</p>
) : loadError ? (
<p className="config-empty" role="alert">
{loadError}
</p>
) : actions.length === 0 ? (
<p className="config-empty">No actions configured yet.</p>
) : (
<ul className="groups-ul">
{actions.map((action) => {
const isSelected = editingActionId === action.id
return (
<li
key={action.id}
className={
isSelected ? 'groups-li groups-li-selected' : 'groups-li'
}
>
<div className="groups-li-content">
<div className="groups-li-title">
{action.name}
<span className="user-badge">{action.kind}</span>
{action.builtin ? (
<span className="user-badge">built-in</span>
) : null}
{action.requires_sudo ? (
<span className="user-badge action-requires-sudo">
sudo
</span>
) : null}
{action.show_widget ? (
<span className="user-badge">
widget
{action.widget_type
? `: ${action.widget_type}`
: ''}
</span>
) : null}
</div>
<div className="groups-li-meta">
{action.description || 'No description'}
</div>
<div className="groups-li-meta">
<code className="actions-body-preview">
{action.body.length > 80
? `${action.body.slice(0, 80)}…`
: action.body}
</code>
</div>
</div>
<div className="groups-li-actions">
<button
type="button"
className="groups-edit"
disabled={!canUpdateActions}
title={
canUpdateActions
? action.builtin
? 'Edit widget settings'
: undefined
: 'Requires actions.update'
}
onClick={() => startEdit(action)}
>
Edit
</button>
<button
type="button"
className="groups-delete"
disabled={action.builtin || !canDeleteActions}
title={
action.builtin
? 'Built-in actions cannot be deleted'
: canDeleteActions
? undefined
: 'Requires actions.delete'
}
onClick={() => void handleDeleteAction(action)}
>
Delete
</button>
</div>
</li>
)
})}
</ul>
)}
</div>
<aside className="actions-placeholders">
<p className="config-hint">Placeholders</p>
<p className="config-hint actions-placeholders-intro">
Use these tokens in the command/script body or ENV values. ENV vars
are applied before the action runs.
</p>
<ul className="actions-placeholder-list">
{ACTION_PLACEHOLDERS.map((entry) => (
<li key={entry.token}>
<code>{entry.token}</code>
<span>{entry.description}</span>
<span className="actions-placeholder-example">
Example: {entry.example}
</span>
</li>
))}
</ul>
</aside>
</div>
{showTypePicker ? (
<div className="reauth-backdrop" role="presentation">
<div
className="reauth-modal actions-type-modal"
role="dialog"
aria-modal="true"
aria-labelledby="action-type-title"
>
<h2 id="action-type-title">Add Action</h2>
<p className="config-hint">Choose the type of action to create.</p>
<div className="actions-type-choices">
<button
type="button"
className="actions-type-choice"
onClick={() => startCreate('shell')}
>
<strong>Shell Command</strong>
<span>
A single command run over SSH on the target node (e.g.{' '}
<code>uptime</code>, <code>df -h</code>).
</span>
</button>
<button
type="button"
className="actions-type-choice"
onClick={() => startCreate('script')}
>
<strong>Script</strong>
<span>
A multi-line shell script uploaded and run on the target node.
</span>
</button>
</div>
<div className="reauth-actions">
<button
type="button"
className="config-action-revert"
onClick={() => clearEditor()}
>
Cancel
</button>
</div>
</div>
</div>
) : null}
{showEditor && editorKind ? (
<div className="reauth-backdrop" role="presentation">
<div
className="reauth-modal actions-editor-modal"
role="dialog"
aria-modal="true"
aria-labelledby="action-editor-title"
>
<h2 id="action-editor-title">
{isEditing
? editingIsBuiltin
? `Widget settings · ${draftName}`
: `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
: `New ${editorKind === 'shell' ? 'shell command' : 'script'}`}
</h2>
{submitError ? (
<p className="config-empty groups-error" role="alert">
{submitError}
</p>
) : null}
{editingIsBuiltin ? (
<p className="config-hint">
Built-in actions keep a fixed command body. You can still enable
or change the Overview widget.
</p>
) : null}
<div className="config-field">
<label htmlFor="action-name">Name</label>
<input
id="action-name"
className="config-input"
value={draftName}
disabled={fieldsLocked}
onChange={(event) => setDraftName(event.target.value)}
/>
</div>
<div className="config-field">
<label htmlFor="action-description">Description</label>
<input
id="action-description"
className="config-input"
value={draftDescription}
disabled={fieldsLocked}
onChange={(event) => setDraftDescription(event.target.value)}
/>
</div>
<div className="config-field">
<label htmlFor="action-body">
{editorKind === 'shell' ? 'Command' : 'Script'}
</label>
{editorKind === 'shell' ? (
<input
id="action-body"
className="config-input"
value={draftBody}
disabled={fieldsLocked}
placeholder="echo {{node.ip}}"
onChange={(event) => setDraftBody(event.target.value)}
/>
) : (
<textarea
id="action-body"
className="config-input actions-script-input"
value={draftBody}
disabled={fieldsLocked}
rows={8}
placeholder={"#!/bin/sh\necho {{node.host}}"}
onChange={(event) => setDraftBody(event.target.value)}
/>
)}
{!fieldsLocked ? (
<p className="config-hint">
Do not include a leading <code>sudo</code>; check Requires Sudo
instead so ClusterCanvas runs with <code>sudo -n</code>.
</p>
) : null}
</div>
<label className="checkbox-row">
<input
type="checkbox"
checked={draftRequiresSudo}
disabled={fieldsLocked}
onChange={(event) =>
setDraftRequiresSudo(event.target.checked)
}
/>
Requires Sudo
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={draftShowWidget}
onChange={(event) => {
const checked = event.target.checked
setDraftShowWidget(checked)
if (checked && !draftWidgetType) {
setDraftWidgetType('raw')
}
}}
/>
Show Widget
</label>
{draftShowWidget ? (
<div className="config-field">
<label htmlFor="action-widget-type">Widget type</label>
<select
id="action-widget-type"
className="config-input"
value={draftWidgetType}
onChange={(event) =>
setDraftWidgetType(event.target.value as ActionWidgetType)
}
>
<option value="memory">Memory (free -m ring)</option>
<option value="disk">Disk (df -h ring)</option>
<option value="raw">Raw text</option>
</select>
<p className="config-hint">
Parsed from the last run of this action on each node Overview.
</p>
</div>
) : null}
<div className="config-field">
<label>Environment variables</label>
<p className="config-hint">
Applied before the command/script runs. Values may use
placeholders.
</p>
<div className="actions-env-rows">
{draftEnv.map((row, index) => (
<div key={`env-${index}`} className="actions-env-row">
<input
className="config-input"
aria-label={`ENV name ${index + 1}`}
placeholder="NAME"
value={row.name}
disabled={fieldsLocked}
onChange={(event) =>
updateEnvRow(index, 'name', event.target.value)
}
/>
<input
className="config-input"
aria-label={`ENV value ${index + 1}`}
placeholder="value or {{node.ip}}"
value={row.value}
disabled={fieldsLocked}
onChange={(event) =>
updateEnvRow(index, 'value', event.target.value)
}
/>
<button
type="button"
className="groups-delete"
aria-label={`Remove ENV row ${index + 1}`}
disabled={fieldsLocked}
onClick={() => removeEnvRow(index)}
>
Remove
</button>
</div>
))}
</div>
<button
type="button"
className="groups-new"
disabled={fieldsLocked}
onClick={() => addEnvRow()}
>
Add ENV variable
</button>
</div>
<div className="reauth-actions">
<button
type="button"
className="config-action-revert"
onClick={() => clearEditor()}
>
Cancel
</button>
<button
type="button"
className="config-action-save"
disabled={isSaving}
onClick={() => void handleSaveAction()}
>
{isSaving ? 'Saving…' : isEditing ? 'Save changes' : 'Save action'}
</button>
</div>
</div>
</div>
) : null}
</div>
)
}
function GroupsConfigTab() {
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
const [isLoadingGroups, setIsLoadingGroups] = useState(true)
const [groupsLoadError, setGroupsLoadError] = useState<string | null>(null)
const [editingGroupName, setEditingGroupName] = useState<string | null>(null)
const [draftGroupName, setDraftGroupName] = useState('')
const [draftScopeKind, setDraftScopeKind] = useState<GroupScopeKind>('node')
const [draftScopeName, setDraftScopeName] = useState('')
const [draftPermissions, setDraftPermissions] = useState<string[]>([])
const [submitError, setSubmitError] = useState<string | null>(null)
const isEditing = editingGroupName !== null
useEffect(() => {
let isCancelled = false
async function loadGroups() {
setIsLoadingGroups(true)
setGroupsLoadError(null)
try {
const response = await fetchGroups()
if (!isCancelled) {
setGroups(response.groups)
}
} catch (err) {
if (!isCancelled) {
setGroupsLoadError(
err instanceof Error ? err.message : 'Failed to load groups',
)
}
} finally {
if (!isCancelled) {
setIsLoadingGroups(false)
}
}
}
void loadGroups()
return () => {
isCancelled = true
}
}, [])
function clearGroupForm() {
setEditingGroupName(null)
setDraftGroupName('')
setDraftScopeKind('node')
setDraftScopeName('')
setDraftPermissions([])
setSubmitError(null)
}
function handleEditGroup(group: Group) {
setEditingGroupName(group.name)
setDraftGroupName(group.name)
setDraftScopeKind(group.scope_kind)
setDraftScopeName(group.scope_name)
setDraftPermissions([...group.permissions])
setSubmitError(null)
}
function togglePermission(permission: string, checked: boolean) {
setDraftPermissions((prev) => {
if (checked) {
if (prev.includes(permission)) {
return prev
}
return [...prev, permission]
}
return prev.filter((item) => item !== permission)
})
}
async function handleSaveGroup() {
setSubmitError(null)
if (!isEditing && draftGroupName.trim() === ADMINISTRATORS_GROUP_NAME) {
setSubmitError('Administrators group cannot be created here')
return
}
try {
const response = await upsertGroup({
name: draftGroupName,
scope_kind: draftScopeKind,
scope_name: draftScopeName,
permissions: draftPermissions,
})
setGroups(response.groups)
clearGroupForm()
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to save group',
)
}
}
async function handleDeleteGroup(groupName: string) {
setSubmitError(null)
try {
const response = await deleteGroup(groupName)
setGroups(response.groups)
if (editingGroupName === groupName) {
clearGroupForm()
}
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to delete group',
)
}
}
return (
<div className="groups-panel">
<div className="groups-list">
<p className="config-hint">Configured groups</p>
{isLoadingGroups ? (
<p className="config-empty">Loading</p>
) : groupsLoadError ? (
<p className="config-empty" role="alert">
{groupsLoadError}
</p>
) : groups.length === 0 ? (
<p className="config-empty">No groups configured yet.</p>
) : (
<ul className="groups-ul">
{groups.map((group) => {
const isAdministrators = group.name === ADMINISTRATORS_GROUP_NAME
const isSelected = editingGroupName === group.name
return (
<li
key={group.name}
className={
isSelected ? 'groups-li groups-li-selected' : 'groups-li'
}
>
<div className="groups-li-content">
<div className="groups-li-title">{group.name}</div>
<div className="groups-li-meta">
Scope: {group.scope_kind}: {group.scope_name}
</div>
<div className="groups-li-meta">
Permissions: {group.permissions.join(', ')}
</div>
</div>
<div className="groups-li-actions">
<button
type="button"
className="groups-edit"
onClick={() => handleEditGroup(group)}
>
Edit
</button>
<button
type="button"
className="groups-delete"
disabled={isAdministrators}
title={
isAdministrators
? 'Administrators group cannot be deleted'
: undefined
}
onClick={() => void handleDeleteGroup(group.name)}
>
Delete
</button>
</div>
</li>
)
})}
</ul>
)}
</div>
<div className="groups-form">
<p className="config-hint">
{isEditing ? 'Edit group' : 'Create group'}
</p>
{submitError ? (
<p className="config-empty groups-error" role="alert">
{submitError}
</p>
) : null}
<div className="config-field">
<label htmlFor="group-name">Group name</label>
<input
id="group-name"
className="config-input"
value={draftGroupName}
readOnly={isEditing}
onChange={(event) => {
setDraftGroupName(event.target.value)
}}
/>
</div>
<div className="config-field">
<label htmlFor="scope-kind">Scope kind</label>
<select
id="scope-kind"
className="theme-select"
value={draftScopeKind}
onChange={(event) => {
setDraftScopeKind(event.target.value as GroupScopeKind)
}}
>
<option value="node">Node</option>
<option value="group">Group</option>
</select>
</div>
<div className="config-field">
<label htmlFor="scope-name">Scope name</label>
<input
id="scope-name"
className="config-input"
value={draftScopeName}
onChange={(event) => {
setDraftScopeName(event.target.value)
}}
/>
</div>
<div className="config-field">
<label>Permissions</label>
<div className="permission-checkboxes">
{DEFAULT_PERMISSIONS.map((permission) => {
const checked = draftPermissions.includes(permission)
return (
<label key={permission} className="permission-option">
<input
type="checkbox"
aria-label={permission}
checked={checked}
onChange={(event) => {
togglePermission(permission, event.target.checked)
}}
/>
<code>{permission}</code>
</label>
)
})}
</div>
</div>
<div className="groups-form-actions">
{isEditing ? (
<button
type="button"
className="groups-new"
onClick={() => clearGroupForm()}
>
New group
</button>
) : null}
<button
type="button"
className="groups-save"
onClick={() => void handleSaveGroup()}
>
{isEditing ? 'Save changes' : 'Save group'}
</button>
</div>
</div>
</div>
)
}
function NetworkConfigTab() {
const [draft, setDraft] = useState<NetworkSettings>(DEFAULT_NETWORK_SETTINGS)
const [rulesText, setRulesText] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saveSucceeded, setSaveSucceeded] = useState(false)
useEffect(() => {
let isCancelled = false
async function loadNetwork() {
setIsLoading(true)
setLoadError(null)
try {
const response = await fetchNetwork()
if (!isCancelled) {
setDraft(response.network)
setRulesText(response.network.rules.join('\n'))
}
} catch (err) {
if (!isCancelled) {
setLoadError(
err instanceof Error
? err.message
: 'Failed to load network settings',
)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void loadNetwork()
return () => {
isCancelled = true
}
}, [])
async function handleSave() {
setSubmitError(null)
setSaveSucceeded(false)
const listenError = validateListenAddress(draft.listen_address)
if (listenError) {
setSubmitError(listenError)
return
}
const hostnameError = validateHostname(draft.public_hostname)
if (hostnameError) {
setSubmitError(hostnameError)
return
}
const rules = rulesText
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
try {
const response = await saveNetwork({ ...draft, rules })
setDraft(response.network)
setRulesText(response.network.rules.join('\n'))
setSaveSucceeded(true)
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to save network settings',
)
}
}
if (isLoading) {
return <p className="config-empty">Loading</p>
}
if (loadError) {
return (
<p className="config-empty" role="alert">
{loadError}
</p>
)
}
return (
<div className="security-panel network-panel">
<p className="config-hint">
Listen bind address and IP access control for the admin UI.
</p>
{submitError ? (
<p className="config-empty groups-error" role="alert">
{submitError}
</p>
) : null}
{saveSucceeded ? (
<p className="config-hint" role="status">
Network settings saved.
</p>
) : null}
<div className="config-field">
<label htmlFor="network-listen">Listen address</label>
<input
id="network-listen"
className="config-input"
value={draft.listen_address}
onChange={(event) => {
setDraft((previous) => ({
...previous,
listen_address: event.target.value,
}))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">
Default 0.0.0.0 (all interfaces). Applied on next process restart.
</p>
</div>
<div className="config-field">
<label htmlFor="network-hostname">Public hostname</label>
<input
id="network-hostname"
className="config-input"
placeholder="admin.mydomain.ext"
value={draft.public_hostname}
onChange={(event) => {
setDraft((previous) => ({
...previous,
public_hostname: event.target.value,
}))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">
Leave empty for local access by IP. When set, the UI must be visited
via that exact hostname.
</p>
</div>
<div className="config-field">
<label htmlFor="network-access-mode">Access mode</label>
<select
id="network-access-mode"
className="theme-select"
value={draft.access_mode}
onChange={(event) => {
setDraft((previous) => ({
...previous,
access_mode: event.target.value as AccessMode,
}))
setSaveSucceeded(false)
}}
>
<option value="open">Open (no IP filter)</option>
<option value="whitelist">Whitelist (deny non-matching)</option>
<option value="blacklist">Blacklist (deny matching)</option>
</select>
</div>
{draft.access_mode !== 'open' ? (
<div className="config-field">
<label htmlFor="network-rules">IP / CIDR rules (one per line)</label>
<textarea
id="network-rules"
className="config-input wizard-rules"
rows={5}
value={rulesText}
onChange={(event) => {
setRulesText(event.target.value)
setSaveSucceeded(false)
}}
/>
</div>
) : null}
<button
type="button"
className="groups-save"
onClick={() => void handleSave()}
>
Save network settings
</button>
</div>
)
}
function validateSecurityDraft(security: SecuritySettings): string | null {
if (
!Number.isInteger(security.idle_timeout_minutes) ||
security.idle_timeout_minutes < 1 ||
security.idle_timeout_minutes > 240
) {
return 'Idle timeout must be an integer between 1 and 240 minutes.'
}
if (
!Number.isInteger(security.session_lifetime_hours) ||
security.session_lifetime_hours < 1
) {
return 'Session lifetime must be an integer of at least 1 hour.'
}
if (
!Number.isInteger(security.reauth_grace_minutes) ||
security.reauth_grace_minutes < 0
) {
return 'Re-auth grace period must be an integer of at least 0 minutes.'
}
return null
}
function parsePositiveIntegerInput(raw: string): number | null {
if (raw.trim() === '') {
return null
}
const parsed = Number(raw)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
return null
}
return parsed
}
function SecurityConfigTab() {
const [draft, setDraft] = useState<SecuritySettings>(DEFAULT_SECURITY_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saveSucceeded, setSaveSucceeded] = useState(false)
useEffect(() => {
let isCancelled = false
async function loadSecurity() {
setIsLoading(true)
setLoadError(null)
try {
const response = await fetchSecurity()
if (!isCancelled) {
setDraft(response.security)
}
} catch (err) {
if (!isCancelled) {
setLoadError(
err instanceof Error ? err.message : 'Failed to load security settings',
)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void loadSecurity()
return () => {
isCancelled = true
}
}, [])
async function handleSave() {
setSubmitError(null)
setSaveSucceeded(false)
const validationError = validateSecurityDraft(draft)
if (validationError) {
setSubmitError(validationError)
return
}
try {
const response = await saveSecurity(draft)
setDraft(response.security)
setSaveSucceeded(true)
} catch (err) {
setSubmitError(
err instanceof Error ? err.message : 'Failed to save security settings',
)
}
}
if (isLoading) {
return <p className="config-empty">Loading</p>
}
if (loadError) {
return (
<p className="config-empty" role="alert">
{loadError}
</p>
)
}
return (
<div className="security-panel">
<p className="config-hint">
Session and re-authentication policy for administrators.
</p>
{submitError ? (
<p className="config-empty groups-error" role="alert">
{submitError}
</p>
) : null}
{saveSucceeded ? (
<p className="config-hint" role="status">
Security settings saved.
</p>
) : null}
<div className="config-field">
<label htmlFor="idle-timeout">Idle timeout (minutes)</label>
<input
id="idle-timeout"
className="config-input"
type="number"
min={1}
max={240}
step={1}
value={draft.idle_timeout_minutes}
onChange={(event) => {
const parsed = parsePositiveIntegerInput(event.target.value)
if (parsed === null) {
setDraft((prev) => ({ ...prev, idle_timeout_minutes: 0 }))
return
}
setDraft((prev) => ({ ...prev, idle_timeout_minutes: parsed }))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">1240 minutes (max 4 hours). Default 30.</p>
</div>
<div className="config-field">
<label htmlFor="session-lifetime">Session lifetime (hours)</label>
<input
id="session-lifetime"
className="config-input"
type="number"
min={1}
step={1}
value={draft.session_lifetime_hours}
onChange={(event) => {
const parsed = parsePositiveIntegerInput(event.target.value)
if (parsed === null) {
setDraft((prev) => ({ ...prev, session_lifetime_hours: 0 }))
return
}
setDraft((prev) => ({ ...prev, session_lifetime_hours: parsed }))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">At least 1 hour. Default 24.</p>
</div>
<div className="config-field">
<label className="permission-option" htmlFor="totp-enabled">
<input
id="totp-enabled"
type="checkbox"
checked={draft.totp_enabled}
onChange={(event) => {
setDraft((prev) => ({
...prev,
totp_enabled: event.target.checked,
}))
setSaveSucceeded(false)
}}
/>
Enable TOTP
</label>
<p className="config-hint">
When enabled, users can enroll authenticator apps from their profile.
</p>
</div>
<div className="config-field">
<label className="permission-option" htmlFor="reauth-sensitive">
<input
id="reauth-sensitive"
type="checkbox"
checked={draft.reauth_sensitive_actions}
onChange={(event) => {
setDraft((prev) => ({
...prev,
reauth_sensitive_actions: event.target.checked,
}))
setSaveSucceeded(false)
}}
/>
Re-auth for sensitive actions
</label>
</div>
<div className="config-field">
<label htmlFor="reauth-grace">Re-auth grace period (minutes)</label>
<input
id="reauth-grace"
className="config-input"
type="number"
min={0}
step={1}
disabled={!draft.reauth_sensitive_actions}
value={draft.reauth_grace_minutes}
onChange={(event) => {
const raw = event.target.value
if (raw.trim() === '') {
setDraft((prev) => ({ ...prev, reauth_grace_minutes: 0 }))
return
}
const parsed = Number(raw)
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
return
}
setDraft((prev) => ({ ...prev, reauth_grace_minutes: parsed }))
setSaveSucceeded(false)
}}
/>
<p className="config-hint">
Skip re-auth if done within this many minutes. 0 means re-auth for
every sensitive action. Default 15.
</p>
</div>
<button
type="button"
className="groups-save"
onClick={() => void handleSave()}
>
Save security settings
</button>
<p className="config-hint">
Service SSH keys and related credentials will also live here later
add keys with a description and an expiry time limit or non-expiry.
</p>
</div>
)
}
function ConfigTabPanelContent({
activeConfigTab,
draftThemePreference,
onDraftThemePreferenceChange,
}: {
activeConfigTab: ConfigTabId
draftThemePreference: ThemePreference
onDraftThemePreferenceChange: (preference: ThemePreference) => void
}) {
if (activeConfigTab === 'overview') {
return (
<OverviewConfigTab
draftThemePreference={draftThemePreference}
onDraftThemePreferenceChange={onDraftThemePreferenceChange}
/>
)
}
if (activeConfigTab === 'roles') {
return (
<div className="roles-panel">
<p className="config-hint">
Built-in permissions that roles can grant.
</p>
<ul className="permission-catalog">
{DEFAULT_PERMISSIONS.map((permission) => (
<li key={permission}>
<code>{permission}</code>
</li>
))}
</ul>
</div>
)
}
if (activeConfigTab === 'groups') {
return <GroupsConfigTab />
}
if (activeConfigTab === 'actions') {
return <ActionsConfigTab />
}
if (activeConfigTab === 'users') {
return <UsersConfigTab />
}
if (activeConfigTab === 'security') {
return <SecurityConfigTab />
}
if (activeConfigTab === 'network') {
return <NetworkConfigTab />
}
return <p className="config-empty">Coming soon</p>
}
function resourceTabsFor(section: ResourceSectionId): ReadonlyArray<{
id: ResourceTabId
label: string
}> {
return [
{ id: 'overview', label: 'Overview' },
{ id: 'security', label: 'Security' },
{ id: 'add', label: RESOURCE_ADD_LABEL[section] },
]
}
function healthDotClass(healthOk: boolean | null | undefined): string {
if (healthOk === true) {
return 'node-health-dot node-health-dot-ok'
}
if (healthOk === false) {
return 'node-health-dot node-health-dot-error'
}
return 'node-health-dot node-health-dot-unknown'
}
function healthDotLabel(healthOk: boolean | null | undefined): string {
if (healthOk === true) {
return 'Healthy'
}
if (healthOk === false) {
return 'Unhealthy'
}
return 'Health unknown'
}
function ResourceOverviewTab({
section,
canRead,
canExec,
canDelete,
onNodesChanged,
}: {
section: ResourceSectionId
canRead: boolean
canExec: boolean
canDelete: boolean
onNodesChanged: () => void
}) {
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [checkingNodeId, setCheckingNodeId] = useState<string | null>(null)
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) {
setIsLoading(false)
setNodes([])
return
}
let isCancelled = false
async function loadNodes() {
setIsLoading(true)
setLoadError(null)
try {
const response = await fetchNodes(RESOURCE_SECTION_KIND[section])
if (!isCancelled) {
setNodes(response.nodes)
}
} catch (err) {
if (!isCancelled) {
setLoadError(
err instanceof Error ? err.message : 'Failed to load nodes',
)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void loadNodes()
return () => {
isCancelled = true
}
}, [section, canRead])
async function handleHealthCheck(nodeId: string) {
setCheckingNodeId(nodeId)
try {
const response = await runNodeHealthCheck(nodeId)
setNodes((previous) =>
previous.map((node) => (node.id === nodeId ? response.node : node)),
)
} catch (err) {
setNodes((previous) =>
previous.map((node) => {
if (node.id !== nodeId) {
return node
}
return {
...node,
health_ok: false,
health_message:
err instanceof Error ? err.message : 'Health check failed',
}
}),
)
} finally {
setCheckingNodeId(null)
onNodesChanged()
}
}
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)
onNodesChanged()
}
if (!canRead) {
return (
<p className="config-hint">
You need the <code>nodes.read</code> permission to view managed hosts.
</p>
)
}
if (isLoading) {
return <p className="config-hint">Loading</p>
}
if (loadError) {
return <p className="config-error">{loadError}</p>
}
if (nodes.length === 0) {
return (
<p className="config-hint">
No hosts registered yet. Use the {RESOURCE_ADD_LABEL[section]} tab to
add one.
</p>
)
}
return (
<>
{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) => {
return (
<li key={node.id} className="node-list-item">
<div className="node-list-heading">
<div className="node-list-heading-main">
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-label={healthDotLabel(node.health_ok)}
role="img"
/>
<strong>{node.name}</strong>
<code className="node-id">{node.id}</code>
</div>
<button
type="button"
className="node-edit"
onClick={() => {
navigateTo(
pathFor(
section,
'overview',
'overview',
node.id,
'healthcheck',
),
)
}}
>
Edit
</button>
</div>
<p className="config-hint">
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
{node.key_algo}
</p>
{node.health_message ? (
<p
className={
node.health_ok
? 'node-test-result node-test-result-ok'
: node.health_ok === false
? 'node-test-result node-test-result-fail'
: 'node-test-result'
}
role="status"
>
{node.health_message}
{node.health_last_checked_at
? ` · ${new Date(node.health_last_checked_at).toLocaleString()}`
: ''}
</p>
) : null}
{canExec || canDelete ? (
<div className="node-actions">
{canExec ? (
<button
type="button"
className="node-test-ssh"
disabled={checkingNodeId === node.id}
onClick={() => {
void handleHealthCheck(node.id)
}}
>
{checkingNodeId === node.id
? 'Checking health…'
: 'Health check'}
</button>
) : null}
{canDelete ? (
<button
type="button"
className="node-delete"
onClick={() => {
setPendingDeleteNodeId(node.id)
}}
>
Delete
</button>
) : null}
</div>
) : null}
</li>
)
})}
</ul>
</>
)
}
function ResourceSecurityTab({ section }: { section: ResourceSectionId }) {
return (
<div className="resource-security-panel">
<p className="config-hint">
Security controls for {SECTION_TITLES[section].toLowerCase()} hosts
(key rotation, hardening, and access policy) will live here.
</p>
</div>
)
}
function ResourceAddTab({
section,
canExec,
onCreated,
}: {
section: ResourceSectionId
canExec: boolean
onCreated: () => void
}) {
const [nodeId, setNodeId] = useState(() => crypto.randomUUID())
const [name, setName] = useState('')
const [hostIP, setHostIP] = useState('')
const [username, setUsername] = useState('clustercanvas')
const [keyMode, setKeyMode] = useState<'generate' | 'paste'>('generate')
const [algorithm, setAlgorithm] = useState<'ed25519' | 'rsa'>('ed25519')
const [rsaBits, setRsaBits] = useState(4096)
const [kdfRounds, setKdfRounds] = useState(100)
const [privateKey, setPrivateKey] = useState('')
const [groupMode, setGroupMode] = useState<'existing' | 'new'>('existing')
const [groupName, setGroupName] = useState('')
const [newGroupName, setNewGroupName] = useState('')
const [groups, setGroups] = useState<ReadonlyArray<Group>>([])
const [createdPublicKey, setCreatedPublicKey] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
let isCancelled = false
async function loadGroups() {
try {
const response = await fetchGroups()
if (!isCancelled) {
setGroups(response.groups)
setGroupName((current) =>
current === '' && response.groups.length > 0
? response.groups[0].name
: current,
)
}
} catch {
// Group list is best-effort for the form.
}
}
void loadGroups()
return () => {
isCancelled = true
}
}, [])
if (!canExec) {
return (
<p className="config-hint">
You need the <code>nodes.exec</code> permission to register a host.
</p>
)
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setSubmitError(null)
setIsSubmitting(true)
try {
const response = await createNode({
id: nodeId,
kind: RESOURCE_SECTION_KIND[section],
name: name.trim(),
host_ip: hostIP.trim(),
username: username.trim() || 'clustercanvas',
...(groupMode === 'new'
? { new_group: { name: newGroupName.trim() } }
: { group_name: groupName }),
...(keyMode === 'generate'
? {
generate: {
algorithm,
...(algorithm === 'rsa' ? { rsa_bits: rsaBits } : {}),
...(algorithm === 'ed25519' ? { kdf_rounds: kdfRounds } : {}),
},
}
: { private_key: privateKey }),
})
setCreatedPublicKey(response.node.public_key)
setName('')
setHostIP('')
setPrivateKey('')
setNodeId(crypto.randomUUID())
onCreated()
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'Failed to create node')
} finally {
setIsSubmitting(false)
}
}
return (
<form className="resource-add-form" onSubmit={handleSubmit}>
<label className="field">
<span>Node UUID</span>
<input
type="text"
className="readonly-field"
value={nodeId}
readOnly
aria-readonly="true"
/>
</label>
<label className="field">
<span>Name</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
required
autoComplete="off"
/>
</label>
<label className="field">
<span>Host IP</span>
<input
type="text"
value={hostIP}
onChange={(event) => setHostIP(event.target.value)}
required
autoComplete="off"
placeholder="192.168.1.10"
/>
</label>
<label className="field">
<span>SSH username</span>
<input
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
required
autoComplete="off"
/>
</label>
<fieldset className="field-group">
<legend>SSH key</legend>
<div className="radio-row">
<label>
<input
type="radio"
name="key-mode"
checked={keyMode === 'generate'}
onChange={() => setKeyMode('generate')}
/>
Generate
</label>
<label>
<input
type="radio"
name="key-mode"
checked={keyMode === 'paste'}
onChange={() => setKeyMode('paste')}
/>
Add existing
</label>
</div>
{keyMode === 'generate' ? (
<>
<div className="radio-row">
<label>
<input
type="radio"
name="key-algo"
checked={algorithm === 'ed25519'}
onChange={() => setAlgorithm('ed25519')}
/>
Ed25519 (Recommended)
</label>
<label>
<input
type="radio"
name="key-algo"
checked={algorithm === 'rsa'}
onChange={() => setAlgorithm('rsa')}
/>
RSA (Compatibility)
</label>
</div>
{algorithm === 'rsa' ? (
<label className="field">
<span>RSA bits</span>
<select
value={rsaBits}
onChange={(event) => setRsaBits(Number(event.target.value))}
>
<option value={2048}>2048</option>
<option value={3072}>3072</option>
<option value={4096}>4096</option>
</select>
</label>
) : (
<label className="field">
<span>KDF rounds</span>
<input
type="number"
min={1}
value={kdfRounds}
onChange={(event) =>
setKdfRounds(Number(event.target.value) || 100)
}
/>
</label>
)}
</>
) : (
<label className="field">
<span>Private key (OpenSSH / PEM)</span>
<textarea
value={privateKey}
onChange={(event) => setPrivateKey(event.target.value)}
rows={8}
required={keyMode === 'paste'}
spellCheck={false}
/>
</label>
)}
</fieldset>
<fieldset className="field-group">
<legend>Scope / group</legend>
<div className="radio-row">
<label>
<input
type="radio"
name="group-mode"
checked={groupMode === 'existing'}
onChange={() => setGroupMode('existing')}
/>
Existing group
</label>
<label>
<input
type="radio"
name="group-mode"
checked={groupMode === 'new'}
onChange={() => setGroupMode('new')}
/>
Create a new group
</label>
</div>
{groupMode === 'existing' ? (
<label className="field">
<span>Group</span>
<select
value={groupName}
onChange={(event) => setGroupName(event.target.value)}
required
>
{groups.map((group) => (
<option key={group.name} value={group.name}>
{group.name}
</option>
))}
</select>
</label>
) : (
<label className="field">
<span>New group name</span>
<input
type="text"
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
required
autoComplete="off"
/>
<span className="config-hint">
Scoped to this node UUID. Set permissions later under
Configuration Groups.
</span>
</label>
)}
</fieldset>
{submitError ? <p className="config-error">{submitError}</p> : null}
{createdPublicKey ? (
<div className="created-public-key">
<p className="config-hint">
Host registered. Install this public key on the remote account:
</p>
<textarea readOnly value={createdPublicKey} rows={3} />
</div>
) : null}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Register host'}
</button>
</form>
)
}
function ResourceSectionPanel({
section,
activeResourceTab,
onResourceTabChange,
nodeId,
nodeDetailTab,
onNodesChanged,
}: {
section: ResourceSectionId
activeResourceTab: ResourceTabId
onResourceTabChange: (tab: ResourceTabId) => void
nodeId: string | null
nodeDetailTab: NodeDetailTabId
onNodesChanged: () => void
}) {
const [me, setMe] = useState<MeResponse | null>(null)
const [overviewKey, setOverviewKey] = useState(0)
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
}
}, [])
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)
if (nodeId) {
return (
<NodeDetailPanel
section={section}
nodeId={nodeId}
activeTab={nodeDetailTab}
onNodesChanged={onNodesChanged}
/>
)
}
return (
<div className="config-panel">
<div
className="config-tabs"
role="tablist"
aria-label={SECTION_TITLES[section]}
>
{tabs.map((tab) => {
const isSelected = activeResourceTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
id={`resource-tab-${section}-${tab.id}`}
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
aria-controls={`resource-tab-panel-${section}`}
tabIndex={isSelected ? 0 : -1}
onClick={() => onResourceTabChange(tab.id)}
>
{tab.label}
</button>
)
})}
</div>
<div
className="config-tab-panel"
role="tabpanel"
id={`resource-tab-panel-${section}`}
aria-labelledby={`resource-tab-${section}-${activeResourceTab}`}
>
{activeResourceTab === 'overview' ? (
<ResourceOverviewTab
key={overviewKey}
section={section}
canRead={canRead}
canExec={canExec}
canDelete={canDelete}
onNodesChanged={onNodesChanged}
/>
) : null}
{activeResourceTab === 'security' ? (
<ResourceSecurityTab section={section} />
) : null}
{activeResourceTab === 'add' ? (
<ResourceAddTab
section={section}
canExec={canExec}
onCreated={() => setOverviewKey((value) => value + 1)}
/>
) : null}
</div>
</div>
)
}
function LogsPanel() {
type LogsSection = 'nodes' | 'auth'
type KindFilter = 'all' | NodeKind
type NodeActionFilter = 'all' | NodeAuditAction
const [logsSection, setLogsSection] = useState<LogsSection>('nodes')
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
const [nodeActionFilter, setNodeActionFilter] =
useState<NodeActionFilter>('all')
const [authOutcomeFilter, setAuthOutcomeFilter] =
useState<AuthOutcomeFilter>('all')
const [authCategoryFilter, setAuthCategoryFilter] =
useState<AuthCategoryFilter>('all')
const [authActionFilter, setAuthActionFilter] =
useState<AuthActionQuickFilter>('all')
const [searchQuery, setSearchQuery] = useState('')
const [datePreset, setDatePreset] = useState<DateRangePreset>('all')
const [customDateFrom, setCustomDateFrom] = useState('')
const [customDateTo, setCustomDateTo] = useState('')
const [pageSize, setPageSize] = useState<LogsPageSize>(() =>
readLogsPageSize(),
)
const [filtersExpanded, setFiltersExpanded] = useState(false)
const [page, setPage] = useState(1)
const [nodeSortKey, setNodeSortKey] = useState<NodeLogsSortKey>('at')
const [authSortKey, setAuthSortKey] = useState<AuthLogsSortKey>('at')
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
const [nodeEvents, setNodeEvents] = useState<ReadonlyArray<NodeAuditEvent>>(
[],
)
const [authEvents, setAuthEvents] = useState<ReadonlyArray<AuthAuditEvent>>(
[],
)
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [canReadLogs, setCanReadLogs] = useState(false)
const [permissionsReady, setPermissionsReady] = useState(false)
useEffect(() => {
let isCancelled = false
async function loadPermissions() {
try {
const me = await fetchMe()
if (isCancelled) {
return
}
setCanReadLogs(Boolean(me?.permissions?.includes('logs.read')))
} catch {
if (!isCancelled) {
setCanReadLogs(false)
}
} finally {
if (!isCancelled) {
setPermissionsReady(true)
}
}
}
void loadPermissions()
return () => {
isCancelled = true
}
}, [])
useEffect(() => {
if (!permissionsReady || !canReadLogs) {
return
}
let isCancelled = false
async function load() {
setIsLoading(true)
setLoadError(null)
try {
if (logsSection === 'nodes') {
const response = await fetchNodeLogs()
if (!isCancelled) {
setNodeEvents(response.events)
}
return
}
const response = await fetchAuthLogs()
if (!isCancelled) {
setAuthEvents(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
}
}, [logsSection, canReadLogs, permissionsReady])
useEffect(() => {
setPage(1)
}, [
logsSection,
kindFilter,
nodeActionFilter,
authOutcomeFilter,
authCategoryFilter,
authActionFilter,
searchQuery,
datePreset,
customDateFrom,
customDateTo,
pageSize,
])
const kindFilters: ReadonlyArray<{ id: KindFilter; label: string }> = [
{ id: 'all', label: 'All' },
{ id: 'container', label: 'Containers' },
{ id: 'docker', label: 'Docker' },
{ id: 'vm', label: 'Virtual Machines' },
]
const nodeActionFilters: ReadonlyArray<{
id: NodeActionFilter
label: string
}> = [
{ id: 'all', label: 'All actions' },
{ id: 'create', label: 'create' },
{ id: 'update', label: 'update' },
{ id: 'delete', label: 'delete' },
{ id: 'test_ssh', label: 'test_ssh' },
{ id: 'health_check', label: 'health_check' },
]
const authOutcomeFilters: ReadonlyArray<{
id: AuthOutcomeFilter
label: string
}> = [
{ id: 'all', label: 'All outcomes' },
{ id: 'failure', label: 'Failures' },
{ id: 'success', label: 'Success' },
]
const authCategoryFilters: ReadonlyArray<{
id: AuthCategoryFilter
label: string
}> = [
{ id: 'all', label: 'All categories' },
{ id: 'auth', label: 'auth' },
{ id: 'user', label: 'user' },
{ id: 'group', label: 'group' },
{ id: 'self', label: 'self' },
]
const authActionFilters: ReadonlyArray<{
id: AuthActionQuickFilter
label: string
}> = [
{ id: 'all', label: 'All events' },
{ id: 'login_failures', label: 'Login failures' },
{ id: 'login', label: 'login' },
{ id: 'logout', label: 'logout' },
{ id: 'user_create', label: 'user_create' },
{ id: 'user_update', label: 'user_update' },
{ id: 'user_delete', label: 'user_delete' },
{ id: 'group_create', label: 'group_create' },
{ id: 'group_update', label: 'group_update' },
{ id: 'group_delete', label: 'group_delete' },
{ id: 'password_change', label: 'password_change' },
{ id: 'totp_enable', label: 'totp_enable' },
{ id: 'totp_disable', label: 'totp_disable' },
]
const datePresets: ReadonlyArray<{ id: DateRangePreset; label: string }> = [
{ id: 'all', label: 'All time' },
{ id: 'today', label: 'Today' },
{ id: '7d', label: 'Last 7 days' },
{ id: '30d', label: 'Last 30 days' },
{ id: 'custom', label: 'Custom' },
]
const { from: dateFrom, to: dateTo } = resolveDateRangeBounds(
datePreset,
customDateFrom,
customDateTo,
)
const filteredNodeEvents = sortNodeEvents(
filterNodeEvents(nodeEvents, {
search: searchQuery,
kind: kindFilter,
action: nodeActionFilter,
dateFrom,
dateTo,
}),
nodeSortKey,
sortDirection,
)
const filteredAuthEvents = sortAuthEvents(
filterAuthEvents(authEvents, {
search: searchQuery,
outcome: authOutcomeFilter,
category: authCategoryFilter,
actionFilter: authActionFilter,
dateFrom,
dateTo,
}),
authSortKey,
sortDirection,
)
const activeEvents =
logsSection === 'nodes' ? filteredNodeEvents : filteredAuthEvents
const nodePagination = paginateItems(filteredNodeEvents, page, pageSize)
const authPagination = paginateItems(filteredAuthEvents, page, pageSize)
const pagination =
logsSection === 'nodes' ? nodePagination : authPagination
const displayNodeEvents =
logsSection === 'nodes' ? nodePagination.pageItems : []
const displayAuthEvents =
logsSection === 'auth' ? authPagination.pageItems : []
function handlePageSizeChange(nextSize: LogsPageSize) {
setPageSize(nextSize)
writeLogsPageSize(nextSize)
}
function toggleNodeSort(key: NodeLogsSortKey) {
if (nodeSortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
return
}
setNodeSortKey(key)
setSortDirection(key === 'at' ? 'desc' : 'asc')
}
function toggleAuthSort(key: AuthLogsSortKey) {
if (authSortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'))
return
}
setAuthSortKey(key)
setSortDirection(key === 'at' ? 'desc' : 'asc')
}
function sortIndicator(active: boolean): string {
if (!active) {
return ''
}
return sortDirection === 'asc' ? ' ▲' : ' ▼'
}
function authRowClassName(event: AuthAuditEvent): string {
if (event.outcome === 'failure') {
return 'logs-row-failure'
}
if (event.category === 'self') {
return 'logs-row-self'
}
if (event.outcome === 'success') {
return 'logs-row-success'
}
return ''
}
function renderFilterChip<T extends string>(
filters: ReadonlyArray<{ id: T; label: string }>,
selected: T,
onSelect: (id: T) => void,
ariaLabel: string,
) {
return (
<div className="logs-filters" role="tablist" aria-label={ariaLabel}>
{filters.map((filter) => {
const isSelected = selected === filter.id
return (
<button
key={filter.id}
type="button"
role="tab"
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
onClick={() => onSelect(filter.id)}
>
{filter.label}
</button>
)
})}
</div>
)
}
if (!permissionsReady) {
return (
<div className="logs-panel">
<p className="config-hint">Loading</p>
</div>
)
}
if (!canReadLogs) {
return (
<p className="config-hint">
You need the <code>logs.read</code> permission to view activity logs.
</p>
)
}
return (
<div className="logs-panel">
<div className="logs-filters" role="tablist" aria-label="Log section">
{(
[
{ id: 'nodes' as const, label: 'Nodes' },
{ id: 'auth' as const, label: 'Users & Auth' },
] as const
).map((section) => {
const isSelected = logsSection === section.id
return (
<button
key={section.id}
type="button"
role="tab"
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
onClick={() => {
setLogsSection(section.id)
setSortDirection('desc')
if (section.id === 'nodes') {
setNodeSortKey('at')
} else {
setAuthSortKey('at')
}
}}
>
{section.label}
</button>
)
})}
</div>
<div className="logs-toolbar">
<label className="logs-search-label" htmlFor="logs-search">
Search
<input
id="logs-search"
type="search"
className="logs-search-input"
placeholder="Node, user, group, event, detail…"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</label>
<label className="logs-page-size-label" htmlFor="logs-page-size">
Rows
<select
id="logs-page-size"
value={pageSize}
onChange={(event) =>
handlePageSizeChange(Number(event.target.value) as LogsPageSize)
}
>
{LOGS_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<button
type="button"
className={
filtersExpanded
? 'logs-filters-toggle logs-filters-toggle-open'
: 'logs-filters-toggle'
}
aria-expanded={filtersExpanded}
aria-controls="logs-filters-panel"
onClick={() => setFiltersExpanded((current) => !current)}
>
<FiltersChevronIcon expanded={filtersExpanded} />
{filtersExpanded ? 'Hide filters' : 'Filters…'}
</button>
</div>
{filtersExpanded ? (
<div
id="logs-filters-panel"
className="logs-filters-panel"
role="region"
aria-label="Log filters"
>
{renderFilterChip(
datePresets,
datePreset,
setDatePreset,
'Date range',
)}
{datePreset === 'custom' ? (
<div className="logs-date-custom">
<label htmlFor="logs-date-from">
From
<input
id="logs-date-from"
type="datetime-local"
value={customDateFrom}
onChange={(event) => setCustomDateFrom(event.target.value)}
/>
</label>
<label htmlFor="logs-date-to">
To
<input
id="logs-date-to"
type="datetime-local"
value={customDateTo}
onChange={(event) => setCustomDateTo(event.target.value)}
/>
</label>
</div>
) : null}
{logsSection === 'nodes'
? renderFilterChip(
kindFilters,
kindFilter,
setKindFilter,
'Log kind filter',
)
: null}
{logsSection === 'nodes'
? renderFilterChip(
nodeActionFilters,
nodeActionFilter,
setNodeActionFilter,
'Node action filter',
)
: null}
{logsSection === 'auth'
? renderFilterChip(
authOutcomeFilters,
authOutcomeFilter,
setAuthOutcomeFilter,
'Auth outcome filter',
)
: null}
{logsSection === 'auth'
? renderFilterChip(
authCategoryFilters,
authCategoryFilter,
setAuthCategoryFilter,
'Auth category filter',
)
: null}
{logsSection === 'auth'
? renderFilterChip(
authActionFilters,
authActionFilter,
setAuthActionFilter,
'Auth event filter',
)
: null}
</div>
) : null}
{isLoading ? <p className="config-hint">Loading</p> : null}
{loadError ? <p className="config-error">{loadError}</p> : null}
{!isLoading && !loadError && activeEvents.length === 0 ? (
<p className="config-hint">
{logsSection === 'nodes'
? 'No node activity matches the current filters.'
: 'No user or auth activity matches the current filters.'}
</p>
) : null}
{!isLoading && displayNodeEvents.length > 0 ? (
<div className="logs-table-scroll">
<table className="logs-table">
<thead>
<tr>
{(
[
{ key: 'at' as const, label: 'When' },
{ key: 'action' as const, label: 'Action' },
{ key: 'actor' as const, label: 'Actor' },
{ key: 'node' as const, label: 'Node' },
{ key: 'kind' as const, label: 'Kind' },
{ key: 'detail' as const, label: 'Detail' },
] as const
).map((column) => (
<th key={column.key} scope="col">
<button
type="button"
className="logs-sort-button"
onClick={() => toggleNodeSort(column.key)}
>
{column.label}
{sortIndicator(nodeSortKey === column.key)}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{displayNodeEvents.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>
</div>
) : null}
{!isLoading && displayAuthEvents.length > 0 ? (
<div className="logs-table-scroll">
<table className="logs-table">
<thead>
<tr>
{(
[
{ key: 'at' as const, label: 'When' },
{ key: 'action' as const, label: 'Action' },
{ key: 'outcome' as const, label: 'Outcome' },
{ key: 'category' as const, label: 'Category' },
{ key: 'actor' as const, label: 'Actor' },
{ key: 'target' as const, label: 'Target' },
{ key: 'detail' as const, label: 'Detail' },
] as const
).map((column) => (
<th key={column.key} scope="col">
<button
type="button"
className="logs-sort-button"
onClick={() => toggleAuthSort(column.key)}
>
{column.label}
{sortIndicator(authSortKey === column.key)}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{displayAuthEvents.map((event) => (
<tr key={event.id} className={authRowClassName(event)}>
<td>{formatUserTimestamp(event.at)}</td>
<td>
<code>{event.action}</code>
</td>
<td>{event.outcome}</td>
<td>{event.category}</td>
<td>{event.actor}</td>
<td>{event.target || '—'}</td>
<td>{event.detail || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{!isLoading && activeEvents.length > 0 ? (
<div className="logs-pagination">
<span className="logs-pagination-label">
{pageRangeLabel(
activeEvents.length,
pagination.safePage,
pageSize,
)}
</span>
<div className="logs-pagination-controls">
<button
type="button"
className="config-tab"
disabled={pagination.safePage <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</button>
<span className="logs-pagination-page">
Page {pagination.safePage} of {pagination.totalPages}
</span>
<button
type="button"
className="config-tab"
disabled={pagination.safePage >= pagination.totalPages}
onClick={() =>
setPage((current) =>
Math.min(pagination.totalPages, current + 1),
)
}
>
Next
</button>
</div>
</div>
) : null}
</div>
)
}
function ConfigurationPanel({
activeConfigTab,
onConfigTabChange,
themePreference,
onThemePreferenceChange,
}: {
activeConfigTab: ConfigTabId
onConfigTabChange: (tab: ConfigTabId) => void
themePreference: ThemePreference
onThemePreferenceChange: (preference: ThemePreference) => void
}) {
const [draftThemePreference, setDraftThemePreference] =
useState<ThemePreference>(themePreference)
useEffect(() => {
setDraftThemePreference(themePreference)
}, [themePreference])
const isDirty = draftThemePreference !== themePreference
return (
<div className="config-panel">
<div className="config-tabs" role="tablist" aria-label="Configuration">
{CONFIG_TABS.map((tab) => {
const isSelected = activeConfigTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
id={`config-tab-${tab.id}`}
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
aria-controls="config-tab-panel"
tabIndex={isSelected ? 0 : -1}
onClick={() => onConfigTabChange(tab.id)}
>
{tab.label}
</button>
)
})}
</div>
<div
className="config-tab-panel"
role="tabpanel"
id="config-tab-panel"
aria-labelledby={`config-tab-${activeConfigTab}`}
>
<ConfigTabPanelContent
activeConfigTab={activeConfigTab}
draftThemePreference={draftThemePreference}
onDraftThemePreferenceChange={setDraftThemePreference}
/>
</div>
<div className="config-actions">
<button
type="button"
className="config-action-revert"
disabled={!isDirty}
onClick={() => setDraftThemePreference(themePreference)}
>
Revert
</button>
<button
type="button"
className="config-action-save"
disabled={!isDirty}
onClick={() => onThemePreferenceChange(draftThemePreference)}
>
Save
</button>
</div>
</div>
)
}
type BootState =
| { kind: 'loading' }
| { kind: 'setup'; clientIp: string }
| { kind: 'login' }
| { kind: 'ready'; username: string }
function App() {
const initialLocation = parseLocation(window.location.pathname)
const [activeSection, setActiveSection] = useState<SectionId>(
initialLocation.section,
)
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
initialLocation.configTab,
)
const [activeResourceTab, setActiveResourceTab] = useState<ResourceTabId>(
initialLocation.resourceTab,
)
const [activeNodeId, setActiveNodeId] = useState<string | null>(
initialLocation.nodeId,
)
const [activeNodeDetailTab, setActiveNodeDetailTab] =
useState<NodeDetailTabId>(initialLocation.nodeDetailTab)
const [profileDisplayName, setProfileDisplayName] = useState<string | null>(
null,
)
const [backendVersion, setBackendVersion] = useState('…')
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
const [canReadLogs, setCanReadLogs] = useState(false)
const [nodeSectionCounts, setNodeSectionCounts] =
useState<NodeSectionCounts | null>(null)
const [nodeHealthErrorCounts, setNodeHealthErrorCounts] =
useState<NodeSectionCounts | null>(null)
const [sidebarCountsRefreshToken, setSidebarCountsRefreshToken] = useState(0)
const [authFailuresToday, setAuthFailuresToday] = useState<number | null>(
null,
)
const { themePreference, updateThemePreference } = useThemePreference()
const profileLabel = profileDisplayName ?? 'Profile'
useEffect(() => {
return subscribeToLocation((pathname) => {
const location = parseLocation(pathname)
setActiveSection(location.section)
setActiveConfigTab(location.configTab)
setActiveResourceTab(location.resourceTab)
setActiveNodeId(location.nodeId)
setActiveNodeDetailTab(location.nodeDetailTab)
})
}, [])
useEffect(() => {
let isCancelled = false
async function boot() {
try {
const setupStatus = await fetchSetupStatus()
if (isCancelled) {
return
}
if (!setupStatus.completed) {
setBootState({ kind: 'setup', clientIp: setupStatus.client_ip })
navigateTo('/setup')
return
}
const me = await fetchMe()
if (isCancelled) {
return
}
if (!me) {
setBootState({ kind: 'login' })
navigateTo('/login')
return
}
setProfileDisplayName(me.username)
setCanReadLogs(Boolean(me.permissions?.includes('logs.read')))
setBootState({ kind: 'ready', username: me.username })
if (
window.location.pathname === '/setup' ||
window.location.pathname === '/login'
) {
navigateTo('/')
}
} catch {
if (!isCancelled) {
setBootState({ kind: 'login' })
navigateTo('/login')
}
}
}
void boot()
return () => {
isCancelled = true
}
}, [])
useEffect(() => {
if (bootState.kind !== 'ready') {
return
}
let isCancelled = false
async function loadSidebarCounts(idleExempt: boolean) {
try {
const nodesResponse = await fetchNodes(undefined, fetch, {
idleExempt,
})
if (!isCancelled) {
setNodeSectionCounts(countNodesBySection(nodesResponse.nodes))
setNodeHealthErrorCounts(
countNodeHealthErrorsBySection(nodesResponse.nodes),
)
}
} catch {
if (!isCancelled) {
setNodeSectionCounts(null)
setNodeHealthErrorCounts(null)
}
}
if (!canReadLogs) {
if (!isCancelled) {
setAuthFailuresToday(null)
}
return
}
try {
const authResponse = await fetchAuthLogs(fetch, { idleExempt })
if (!isCancelled) {
setAuthFailuresToday(countAuthFailuresToday(authResponse.events))
}
} catch {
if (!isCancelled) {
setAuthFailuresToday(null)
}
}
}
void loadSidebarCounts(false)
const pollIntervalId = window.setInterval(() => {
void loadSidebarCounts(true)
}, 30_000)
return () => {
isCancelled = true
window.clearInterval(pollIntervalId)
}
}, [bootState.kind, canReadLogs, activeSection, sidebarCountsRefreshToken])
useEffect(() => {
if (bootState.kind !== 'ready') {
return
}
let isCancelled = false
async function loadBackendVersion() {
try {
const status = await fetchStatus()
if (!isCancelled) {
setBackendVersion(status.version)
}
} catch {
if (!isCancelled) {
setBackendVersion('unavailable')
}
}
}
void loadBackendVersion()
return () => {
isCancelled = true
}
}, [bootState.kind])
useEffect(() => {
if (bootState.kind === 'loading') {
document.title = documentTitleFor('loading')
return
}
if (bootState.kind === 'setup') {
document.title = documentTitleFor('setup')
return
}
if (bootState.kind === 'login') {
document.title = documentTitleFor('login')
return
}
document.title = documentTitleFor(activeSection)
}, [bootState.kind, activeSection])
function goToSection(section: SectionId) {
if (section === 'setup' || section === 'login') {
return
}
setActiveSection(section)
setActiveNodeId(null)
setActiveNodeDetailTab('overview')
if (section === 'configuration') {
navigateTo(pathFor(section, activeConfigTab))
return
}
if (isResourceSectionId(section)) {
setActiveResourceTab('overview')
navigateTo(pathFor(section, 'overview', 'overview'))
return
}
setActiveResourceTab('overview')
navigateTo(pathFor(section))
}
function goToConfigTab(tab: ConfigTabId) {
setActiveSection('configuration')
setActiveConfigTab(tab)
setActiveNodeId(null)
navigateTo(pathFor('configuration', tab))
}
function goToResourceTab(tab: ResourceTabId) {
if (!isResourceSectionId(activeSection)) {
return
}
setActiveResourceTab(tab)
setActiveNodeId(null)
setActiveNodeDetailTab('overview')
navigateTo(pathFor(activeSection, 'overview', tab))
}
function clearSignedInState() {
setProfileDisplayName(null)
setCanReadLogs(false)
}
async function handleLogout() {
try {
await logout()
} catch {
// Still leave the signed-in UI even if the session is already gone.
}
clearSignedInState()
window.history.replaceState(null, '', '/login?reason=logout')
setBootState({ kind: 'login' })
}
if (bootState.kind === 'loading') {
return (
<div className="wizard-shell">
<p className="config-hint">Loading</p>
</div>
)
}
if (bootState.kind === 'setup') {
return (
<SetupWizard
clientIp={bootState.clientIp}
onCompleted={() => {
window.history.replaceState(null, '', '/login?from=setup')
setBootState({ kind: 'login' })
}}
/>
)
}
if (bootState.kind === 'login') {
return (
<LoginPage
onLoggedIn={(username) => {
setProfileDisplayName(username)
void (async () => {
try {
const me = await fetchMe()
setCanReadLogs(Boolean(me?.permissions?.includes('logs.read')))
} catch {
setCanReadLogs(false)
}
setBootState({ kind: 'ready', username })
navigateTo('/')
setActiveSection('overview')
})()
}}
/>
)
}
return (
<div className="app-shell">
<aside className="sidebar" aria-label="Main menu">
<div className="sidebar-header">
<h1 className="brand">Cluster Canvas</h1>
<p className="version-line">Frontend Version {__FRONTEND_VERSION__}</p>
<p className="version-line">Backend Version {backendVersion}</p>
</div>
<nav className="sidebar-nav" aria-label="Sections">
{NAV_ITEMS.slice(0, 1).map((item) => (
<button
key={item.id}
type="button"
className={
activeSection === item.id
? 'nav-item nav-item-active'
: 'nav-item'
}
aria-current={activeSection === item.id ? 'page' : undefined}
onClick={() => goToSection(item.id)}
>
{item.label}
</button>
))}
<div className="nav-section-divider" aria-hidden="true" />
{NAV_ITEMS.slice(1).map((item) => {
const sectionCount =
isResourceSectionId(item.id) && nodeSectionCounts !== null
? nodeSectionCounts[item.id]
: null
const healthErrorCount =
isResourceSectionId(item.id) && nodeHealthErrorCounts !== null
? nodeHealthErrorCounts[item.id]
: null
return (
<button
key={item.id}
type="button"
className={
activeSection === item.id
? 'nav-item nav-item-active'
: 'nav-item'
}
aria-current={activeSection === item.id ? 'page' : undefined}
onClick={() => goToSection(item.id)}
>
<span className="nav-item-label">{item.label}</span>
{healthErrorCount !== null && healthErrorCount > 0 ? (
<span
className="nav-count-badge nav-count-badge-alert"
aria-label={`${healthErrorCount} unhealthy`}
>
{healthErrorCount}
</span>
) : null}
{sectionCount !== null ? (
<span className="nav-count-badge" aria-hidden="true">
{sectionCount}
</span>
) : null}
</button>
)
})}
</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')}
>
<span className="nav-item-label">Activity</span>
{authFailuresToday !== null && authFailuresToday > 0 ? (
<span
className="nav-count-badge nav-count-badge-alert"
aria-hidden="true"
>
{authFailuresToday}
</span>
) : null}
</button>
</div>
) : null}
<div className="sidebar-footer">
<div className="sidebar-footer-profile-row">
<button
type="button"
className={
activeSection === 'profile'
? 'nav-item nav-item-active profile-item'
: 'nav-item profile-item'
}
aria-current={activeSection === 'profile' ? 'page' : undefined}
onClick={() => goToSection('profile')}
>
<UserIcon />
{profileLabel}
</button>
<button
type="button"
className="nav-item logout-item"
aria-label="Log out"
onClick={() => {
void handleLogout()
}}
>
<LogoutIcon />
</button>
</div>
<button
type="button"
className={
activeSection === 'configuration'
? 'nav-item nav-item-active config-item'
: 'nav-item config-item'
}
aria-current={
activeSection === 'configuration' ? 'page' : undefined
}
onClick={() => goToSection('configuration')}
>
<CogIcon />
Configuration
</button>
</div>
</aside>
<main className="content" aria-label={SECTION_TITLES[activeSection]}>
<h2>
{activeSection === 'profile' ? profileLabel : SECTION_TITLES[activeSection]}
</h2>
{activeSection === 'configuration' ? (
<ConfigurationPanel
activeConfigTab={activeConfigTab}
onConfigTabChange={goToConfigTab}
themePreference={themePreference}
onThemePreferenceChange={updateThemePreference}
/>
) : activeSection === 'profile' ? (
<ProfilePanel
onSignedOut={() => {
clearSignedInState()
setBootState({ kind: 'login' })
navigateTo('/login')
}}
/>
) : activeSection === 'logs' ? (
<LogsPanel />
) : isResourceSectionId(activeSection) ? (
<ResourceSectionPanel
section={activeSection}
activeResourceTab={activeResourceTab}
onResourceTabChange={goToResourceTab}
nodeId={activeNodeId}
nodeDetailTab={activeNodeDetailTab}
onNodesChanged={() => {
setSidebarCountsRefreshToken((token) => token + 1)
}}
/>
) : activeSection === 'overview' ? (
<OverviewPanel />
) : (
<p>Coming soon</p>
)}
</main>
</div>
)
}
export default App