import { useEffect, useState } from 'react' import { type Action, type ActionGroupSchedule, type ActionLogFile, type ActionLogFileInfo, type ActionGroupRunRecord, type MeResponse, type Node, type NodeActionGroup, type NodeActionItem, cloneActionGroupItem, createActionGroup, createActionGroupItem, deleteActionGroup, deleteActionGroupItem, fetchActionGroups, fetchActionLogFile, fetchActionLogs, fetchActions, fetchMe, fetchNode, patchActionGroup, patchActionGroupItem, reorderActionGroupItems, runActionGroup, runActionGroupItem, } from './api/client' import { SECTION_TITLES } from './documentTitle' import { type NodeDetailTabId, type ResourceSectionId, navigateTo, pathFor, } from './navigation' const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const const NODE_DETAIL_TABS: ReadonlyArray<{ id: NodeDetailTabId; label: string }> = [ { id: 'overview', label: 'Overview' }, { id: 'actions', label: 'Actions' }, { id: 'logs', label: 'Logs' }, ] function defaultSchedule(): ActionGroupSchedule { return { enabled: false, kind: 'interval', every_seconds: 3600, times: [], } } function itemDisplayName( item: NodeActionItem, libraryById: Map, ): string { if (item.source === 'local') { return item.name || 'Untitled action' } const libraryAction = libraryById.get(item.library_action_id || '') return libraryAction?.name || `Library ${item.library_action_id || 'action'}` } export function NodeDetailPanel({ section, nodeId, activeTab, }: { section: ResourceSectionId nodeId: string activeTab: NodeDetailTabId }) { const [me, setMe] = useState(null) const [node, setNode] = useState(null) const [loadError, setLoadError] = useState(null) const [isLoading, setIsLoading] = useState(true) useEffect(() => { let isCancelled = false async function load() { setIsLoading(true) setLoadError(null) try { const [meResponse, nodeResponse] = await Promise.all([ fetchMe(), fetchNode(nodeId), ]) if (!isCancelled) { setMe(meResponse) setNode(nodeResponse.node) } } catch (err) { if (!isCancelled) { setLoadError(err instanceof Error ? err.message : 'Failed to load node') setNode(null) } } finally { if (!isCancelled) { setIsLoading(false) } } } void load() return () => { isCancelled = true } }, [nodeId]) function goToTab(tab: NodeDetailTabId) { navigateTo(pathFor(section, 'overview', 'overview', nodeId, tab)) } function goBack() { navigateTo(pathFor(section)) } if (isLoading) { return

Loading…

} if (loadError || !node) { return (

{loadError || 'Node not found'}

) } const canCreate = Boolean(me?.permissions?.includes('actions.create')) const canUpdate = Boolean(me?.permissions?.includes('actions.update')) const canDelete = Boolean(me?.permissions?.includes('actions.delete')) const canRun = Boolean(me?.permissions?.includes('jobs.run')) const canReadLogs = Boolean(me?.permissions?.includes('logs.read')) return (

{node.name}

{node.host_ip} · {node.username} · {node.group_name}

{node.id}
{NODE_DETAIL_TABS.map((tab) => { const isSelected = activeTab === tab.id return ( ) })}
{activeTab === 'overview' ? ( ) : null} {activeTab === 'actions' ? ( ) : null} {activeTab === 'logs' ? ( ) : null}
) } function NodeDetailOverviewTab({ node }: { node: Node }) { return (
Name
{node.name}
Host IP
{node.host_ip}
Username
{node.username}
Group
{node.group_name}
Key algorithm
{node.key_algo}
Created
{new Date(node.created_at).toLocaleString()}
) } function NodeActionsTab({ nodeId, canCreate, canUpdate, canDelete, canRun, }: { nodeId: string canCreate: boolean canUpdate: boolean canDelete: boolean canRun: boolean }) { const [groups, setGroups] = useState>([]) const [library, setLibrary] = useState>([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [newGroupName, setNewGroupName] = useState('') const [busyKey, setBusyKey] = useState(null) const [lastRun, setLastRun] = useState(null) const libraryById = new Map(library.map((action) => [action.id, action])) async function reload() { const [groupsResponse, actionsResponse] = await Promise.all([ fetchActionGroups(nodeId), fetchActions(), ]) setGroups(groupsResponse.groups) setLibrary(actionsResponse.actions) } useEffect(() => { let isCancelled = false async function load() { setIsLoading(true) setError(null) try { await reload() } catch (err) { if (!isCancelled) { setError(err instanceof Error ? err.message : 'Failed to load actions') } } finally { if (!isCancelled) { setIsLoading(false) } } } void load() return () => { isCancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [nodeId]) async function handleCreateGroup() { const name = newGroupName.trim() if (!name) { return } setBusyKey('create-group') setError(null) try { await createActionGroup(nodeId, { name, schedule: defaultSchedule(), }) setNewGroupName('') await reload() } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create group') } finally { setBusyKey(null) } } function replaceGroup(updated: NodeActionGroup) { setGroups((previous) => previous.map((group) => (group.id === updated.id ? updated : group)), ) } if (isLoading) { return

Loading action groups…

} return (
{error ?

{error}

: null} {canCreate ? (
setNewGroupName(event.target.value)} aria-label="New action group name" />
) : (

You need actions.create to add action groups.

)} {groups.length === 0 ? (

No action groups on this host yet.

) : (
    {groups.map((group) => (
  • ))}
)} {lastRun ? (

Last run result

{lastRun.group_name} · {lastRun.trigger} ·{' '} {new Date(lastRun.finished_at).toLocaleString()}

{lastRun.actions.map((actionResult) => (
{actionResult.action_name} (exit {actionResult.exit_code}) {actionResult.error ? ` — ${actionResult.error}` : ''} {actionResult.stdout ? (
{actionResult.stdout}
) : null} {actionResult.stderr ? (
                  {actionResult.stderr}
                
) : null}
))}
) : null}
) } function ActionGroupEditor({ nodeId, group, library, libraryById, canCreate, canUpdate, canDelete, canRun, busyKey, setBusyKey, setError, onGroupChange, onGroupsChange, onRunComplete, }: { nodeId: string group: NodeActionGroup library: ReadonlyArray libraryById: Map canCreate: boolean canUpdate: boolean canDelete: boolean canRun: boolean busyKey: string | null setBusyKey: (key: string | null) => void setError: (message: string | null) => void onGroupChange: (group: NodeActionGroup) => void onGroupsChange: (groups: ReadonlyArray) => void onRunComplete: (run: ActionGroupRunRecord) => void }) { const [nameDraft, setNameDraft] = useState(group.name) const [scheduleDraft, setScheduleDraft] = useState( group.schedule || defaultSchedule(), ) const [libraryPick, setLibraryPick] = useState('') const [customName, setCustomName] = useState('') const [customBody, setCustomBody] = useState('') const [customKind, setCustomKind] = useState<'shell' | 'script'>('shell') const [editingItemId, setEditingItemId] = useState(null) const [editName, setEditName] = useState('') const [editBody, setEditBody] = useState('') useEffect(() => { setNameDraft(group.name) setScheduleDraft(group.schedule || defaultSchedule()) }, [group]) async function saveSchedule() { setBusyKey(`save-${group.id}`) setError(null) try { const response = await patchActionGroup(nodeId, group.id, { name: nameDraft.trim(), schedule: scheduleDraft, }) onGroupChange(response.group) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save group') } finally { setBusyKey(null) } } async function handleDeleteGroup() { if (!window.confirm(`Delete action group “${group.name}”?`)) { return } setBusyKey(`delete-group-${group.id}`) setError(null) try { const response = await deleteActionGroup(nodeId, group.id) onGroupsChange(response.groups) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete group') } finally { setBusyKey(null) } } async function handleAddLibrary() { if (!libraryPick) { return } setBusyKey(`add-lib-${group.id}`) setError(null) try { const response = await createActionGroupItem(nodeId, group.id, { source: 'library', library_action_id: libraryPick, }) onGroupChange(response.group) setLibraryPick('') } catch (err) { setError(err instanceof Error ? err.message : 'Failed to add action') } finally { setBusyKey(null) } } async function handleAddCustom() { setBusyKey(`add-custom-${group.id}`) setError(null) try { const response = await createActionGroupItem(nodeId, group.id, { source: 'local', name: customName.trim(), description: '', kind: customKind, body: customBody.trim(), env: [], }) onGroupChange(response.group) setCustomName('') setCustomBody('') } catch (err) { setError(err instanceof Error ? err.message : 'Failed to add custom action') } finally { setBusyKey(null) } } async function moveItem(itemId: string, direction: -1 | 1) { const ids = group.items.map((item) => item.id) const index = ids.indexOf(itemId) const nextIndex = index + direction if (index < 0 || nextIndex < 0 || nextIndex >= ids.length) { return } const reordered = [...ids] const [moved] = reordered.splice(index, 1) reordered.splice(nextIndex, 0, moved) setBusyKey(`order-${group.id}`) setError(null) try { const response = await reorderActionGroupItems(nodeId, group.id, reordered) onGroupChange(response.group) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to reorder') } finally { setBusyKey(null) } } async function handleRunGroup() { setBusyKey(`run-group-${group.id}`) setError(null) try { const response = await runActionGroup(nodeId, group.id) onRunComplete(response.run) const refreshed = await fetchActionGroups(nodeId) onGroupsChange(refreshed.groups) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to run group') } finally { setBusyKey(null) } } return (
{canUpdate ? ( setNameDraft(event.target.value)} aria-label="Action group name" /> ) : ( {group.name} )}
{canRun ? ( ) : null} {canUpdate ? ( ) : null} {canDelete ? ( ) : null}
{group.last_run_at ? (

Last run: {new Date(group.last_run_at).toLocaleString()}

) : (

Never run

)}
Schedule {scheduleDraft.kind === 'interval' ? ( ) : ( setScheduleDraft((previous) => ({ ...previous, times })) } /> )}

Actions

{group.items.length === 0 ? (

No actions in this group yet.

) : (
    {group.items.map((item, index) => (
  1. {itemDisplayName(item, libraryById)}{' '} ({item.source})
    {canUpdate ? ( <> ) : null} {canRun ? ( ) : null} {item.source === 'library' && canCreate ? ( ) : null} {item.source === 'local' && canUpdate ? ( ) : null} {canDelete ? ( ) : null}
    {editingItemId === item.id ? (