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 = { containers: 'container', vms: 'vm', docker: 'docker', } const RESOURCE_ADD_LABEL: Record = { 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 ( ) } function UserIcon() { return ( ) } function LogoutIcon() { return ( ) } function FiltersChevronIcon({ expanded }: { expanded: boolean }) { return ( ) } function ProfilePanel({ onSignedOut, }: { onSignedOut: () => void }) { const [me, setMe] = useState(null) const [loadError, setLoadError] = useState(null) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [passwordTotp, setPasswordTotp] = useState('') const [passwordError, setPasswordError] = useState(null) const [passwordSuccess, setPasswordSuccess] = useState(null) const [totpPassword, setTotpPassword] = useState('') const [totpReauthCode, setTotpReauthCode] = useState('') const [pendingSecret, setPendingSecret] = useState(null) const [pendingUrl, setPendingUrl] = useState(null) const [qrDataUrl, setQrDataUrl] = useState(null) const [confirmCode, setConfirmCode] = useState('') const [disableCode, setDisableCode] = useState('') const [totpError, setTotpError] = useState(null) const [totpMessage, setTotpMessage] = useState(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 (

{loadError}

) } if (!me) { return (

Loading…

) } return (

Account

Signed in as {me.username}

Change password

void handleChangePassword(event)}> setCurrentPassword(event.target.value)} required /> setNewPassword(event.target.value)} required /> setConfirmPassword(event.target.value)} required /> {me.totp_confirmed ? ( <> setPasswordTotp(event.target.value)} required /> ) : null} {passwordError ? (

{passwordError}

) : null} {passwordSuccess ? (

{passwordSuccess}

) : null}

Authenticator (TOTP)

{!me.totp_enabled ? (

An administrator must enable TOTP on the server before you can enroll.

) : me.totp_confirmed ? (
void handleDisableTotp(event)}>

Authenticator is enabled on your account.

setTotpPassword(event.target.value)} required /> setDisableCode(event.target.value)} required /> {totpError ? (

{totpError}

) : null} {totpMessage ? (

{totpMessage}

) : null}
) : pendingSecret ? (
void handleConfirmTotp(event)}> {qrDataUrl ? ( TOTP QR code ) : null}

Secret: {pendingSecret}

setConfirmCode(event.target.value)} required /> {totpError ? (

{totpError}

) : null}
) : (
void handleBeginTotp(event)}>

Re-enter your password to begin authenticator enrollment.

setTotpPassword(event.target.value)} required /> {totpError ? (

{totpError}

) : null} {totpMessage ? (

{totpMessage}

) : null}
)}
) } function UsersConfigTab() { const [users, setUsers] = useState>([]) const [groups, setGroups] = useState>([]) const [me, setMe] = useState(null) const [isLoadingUsers, setIsLoadingUsers] = useState(true) const [usersLoadError, setUsersLoadError] = useState(null) const [actionError, setActionError] = useState(null) const [createUsername, setCreateUsername] = useState('') const [createPassword, setCreatePassword] = useState('') const [createGroupNames, setCreateGroupNames] = useState>([]) const [editingUserId, setEditingUserId] = useState(null) const [editGroupNames, setEditGroupNames] = useState>([]) const [setPasswordUserId, setSetPasswordUserId] = useState(null) const [setPasswordValue, setSetPasswordValue] = useState('') type PendingAction = | { kind: 'delete'; userId: string } | { kind: 'grace' run: (credentials?: ReauthCredentials) => Promise } const [pendingAction, setPendingAction] = useState(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, groupName: string, ): ReadonlyArray { if (current.includes(groupName)) { return current.filter((name) => name !== groupName) } return [...current, groupName] } async function runGraceMutation(mutation: () => Promise) { 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 (
{pendingAction?.kind === 'delete' ? ( setPendingAction(null)} onConfirm={handleDeleteConfirm} /> ) : null} {pendingAction?.kind === 'grace' ? ( setPendingAction(null)} onConfirm={handleGraceConfirm} /> ) : null} {canManageUsers ? (
void handleCreateUser(event)} >

Create user

setCreateUsername(event.target.value)} required /> setCreatePassword(event.target.value)} required />
Groups (optional) {groups.map((group) => ( ))}
) : null}

Configured accounts

{actionError ? (

{actionError}

) : null} {isLoadingUsers ? (

Loading…

) : usersLoadError ? (

{usersLoadError}

) : users.length === 0 ? (

No users configured yet.

) : (
    {users.map((user) => (
  • {user.username} {user.is_admin ? ( Admin ) : null}
    Groups:{' '} {user.group_names.length > 0 ? user.group_names.join(', ') : '—'}
    Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '} {user.totp_confirmed ? 'confirmed' : 'not confirmed'}
    Created: {formatUserTimestamp(user.created_at)} · Password changed: {formatUserTimestamp(user.password_changed_at)}
    ID: {user.id}
    {canManageUsers && editingUserId === user.id ? (
    Groups {groups.map((group) => ( ))}
    ) : null} {canManageUsers && setPasswordUserId === user.id ? (
    setSetPasswordValue(event.target.value)} />
    ) : null}
    {canManageUsers ? (
    {user.totp_confirmed ? ( ) : null}
    ) : ( )}
  • ))}
)}
) } function useThemePreference() { const [themePreference, setThemePreference] = useState(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 (

Auto follows the browser preference. Light and Dark always use Catppuccin Latte or Mocha.

) } 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([]) const [me, setMe] = useState(null) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [submitError, setSubmitError] = useState(null) const [showTypePicker, setShowTypePicker] = useState(false) const [editorKind, setEditorKind] = useState(null) const [editingActionId, setEditingActionId] = useState(null) const [draftName, setDraftName] = useState('') const [draftDescription, setDraftDescription] = useState('') const [draftBody, setDraftBody] = useState('') const [draftEnv, setDraftEnv] = useState([emptyEnvRow()]) const [draftRequiresSudo, setDraftRequiresSudo] = useState(false) const [draftShowWidget, setDraftShowWidget] = useState(false) const [draftWidgetType, setDraftWidgetType] = useState('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 (

Pre-defined and custom actions for managed nodes. Placeholders and ENV vars are expanded when an action runs.

{submitError && !showEditor ? (

{submitError}

) : null}

Configured actions

{isLoading ? (

Loading…

) : loadError ? (

{loadError}

) : actions.length === 0 ? (

No actions configured yet.

) : (
    {actions.map((action) => { const isSelected = editingActionId === action.id return (
  • {action.name} {action.kind} {action.builtin ? ( built-in ) : null} {action.requires_sudo ? ( sudo ) : null} {action.show_widget ? ( widget {action.widget_type ? `: ${action.widget_type}` : ''} ) : null}
    {action.description || 'No description'}
    {action.body.length > 80 ? `${action.body.slice(0, 80)}…` : action.body}
  • ) })}
)}
{showTypePicker ? (

Add Action

Choose the type of action to create.

) : null} {showEditor && editorKind ? (

{isEditing ? editingIsBuiltin ? `Widget settings · ${draftName}` : `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}` : `New ${editorKind === 'shell' ? 'shell command' : 'script'}`}

{submitError ? (

{submitError}

) : null} {editingIsBuiltin ? (

Built-in actions keep a fixed command body. You can still enable or change the Overview widget.

) : null}
setDraftName(event.target.value)} />
setDraftDescription(event.target.value)} />
{editorKind === 'shell' ? ( setDraftBody(event.target.value)} /> ) : (