Files
ClusterCanvas/webui/src/NodeDetailPanel.tsx
T
Squid 32af91fd30 Add per-node action groups with schedules, runs, and logs.
Let operators manage ordered action groups on each host, run them manually or on a schedule over SSH, and inspect disk-backed JSON run logs from a node detail UI.
2026-07-19 20:01:11 +02:00

1183 lines
35 KiB
TypeScript

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, Action>,
): 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<MeResponse | null>(null)
const [node, setNode] = useState<Node | null>(null)
const [loadError, setLoadError] = useState<string | null>(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 <p className="config-hint">Loading</p>
}
if (loadError || !node) {
return (
<div className="config-panel">
<button type="button" className="node-detail-back" onClick={goBack}>
Back to {SECTION_TITLES[section]}
</button>
<p className="config-error">{loadError || 'Node not found'}</p>
</div>
)
}
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 (
<div className="config-panel node-detail-panel">
<button type="button" className="node-detail-back" onClick={goBack}>
Back to {SECTION_TITLES[section]}
</button>
<header className="node-detail-header">
<h2>{node.name}</h2>
<p className="config-hint">
{node.host_ip} · {node.username} · {node.group_name}
</p>
<code className="node-id">{node.id}</code>
</header>
<div
className="config-tabs"
role="tablist"
aria-label={`${node.name} detail`}
>
{NODE_DETAIL_TABS.map((tab) => {
const isSelected = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
role="tab"
className={
isSelected ? 'config-tab config-tab-active' : 'config-tab'
}
aria-selected={isSelected}
onClick={() => goToTab(tab.id)}
>
{tab.label}
</button>
)
})}
</div>
<div className="config-tab-panel" role="tabpanel">
{activeTab === 'overview' ? (
<NodeDetailOverviewTab node={node} />
) : null}
{activeTab === 'actions' ? (
<NodeActionsTab
nodeId={node.id}
canCreate={canCreate}
canUpdate={canUpdate}
canDelete={canDelete}
canRun={canRun}
/>
) : null}
{activeTab === 'logs' ? (
<NodeActionLogsTab nodeId={node.id} canRead={canReadLogs} />
) : null}
</div>
</div>
)
}
function NodeDetailOverviewTab({ node }: { node: Node }) {
return (
<dl className="node-detail-overview">
<div>
<dt>Name</dt>
<dd>{node.name}</dd>
</div>
<div>
<dt>Host IP</dt>
<dd>{node.host_ip}</dd>
</div>
<div>
<dt>Username</dt>
<dd>{node.username}</dd>
</div>
<div>
<dt>Group</dt>
<dd>{node.group_name}</dd>
</div>
<div>
<dt>Key algorithm</dt>
<dd>{node.key_algo}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{new Date(node.created_at).toLocaleString()}</dd>
</div>
</dl>
)
}
function NodeActionsTab({
nodeId,
canCreate,
canUpdate,
canDelete,
canRun,
}: {
nodeId: string
canCreate: boolean
canUpdate: boolean
canDelete: boolean
canRun: boolean
}) {
const [groups, setGroups] = useState<ReadonlyArray<NodeActionGroup>>([])
const [library, setLibrary] = useState<ReadonlyArray<Action>>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [newGroupName, setNewGroupName] = useState('')
const [busyKey, setBusyKey] = useState<string | null>(null)
const [lastRun, setLastRun] = useState<ActionGroupRunRecord | null>(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 <p className="config-hint">Loading action groups</p>
}
return (
<div className="node-actions-tab">
{error ? <p className="config-error">{error}</p> : null}
{canCreate ? (
<div className="node-action-group-create">
<input
type="text"
placeholder="New action group name"
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
aria-label="New action group name"
/>
<button
type="button"
disabled={busyKey === 'create-group' || !newGroupName.trim()}
onClick={() => {
void handleCreateGroup()
}}
>
Add Action Group
</button>
</div>
) : (
<p className="config-hint">
You need <code>actions.create</code> to add action groups.
</p>
)}
{groups.length === 0 ? (
<p className="config-hint">No action groups on this host yet.</p>
) : (
<ul className="node-action-group-list">
{groups.map((group) => (
<li key={group.id} className="node-action-group-card">
<ActionGroupEditor
nodeId={nodeId}
group={group}
library={library}
libraryById={libraryById}
canCreate={canCreate}
canUpdate={canUpdate}
canDelete={canDelete}
canRun={canRun}
busyKey={busyKey}
setBusyKey={setBusyKey}
setError={setError}
onGroupChange={replaceGroup}
onGroupsChange={setGroups}
onRunComplete={setLastRun}
/>
</li>
))}
</ul>
)}
{lastRun ? (
<div className="node-action-run-result">
<h3>Last run result</h3>
<p className="config-hint">
{lastRun.group_name} · {lastRun.trigger} ·{' '}
{new Date(lastRun.finished_at).toLocaleString()}
</p>
{lastRun.actions.map((actionResult) => (
<details key={actionResult.item_id} open>
<summary>
{actionResult.action_name} (exit {actionResult.exit_code})
{actionResult.error ? ` — ${actionResult.error}` : ''}
</summary>
{actionResult.stdout ? (
<pre className="action-log-pre">{actionResult.stdout}</pre>
) : null}
{actionResult.stderr ? (
<pre className="action-log-pre action-log-stderr">
{actionResult.stderr}
</pre>
) : null}
</details>
))}
</div>
) : null}
</div>
)
}
function ActionGroupEditor({
nodeId,
group,
library,
libraryById,
canCreate,
canUpdate,
canDelete,
canRun,
busyKey,
setBusyKey,
setError,
onGroupChange,
onGroupsChange,
onRunComplete,
}: {
nodeId: string
group: NodeActionGroup
library: ReadonlyArray<Action>
libraryById: Map<string, Action>
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<NodeActionGroup>) => void
onRunComplete: (run: ActionGroupRunRecord) => void
}) {
const [nameDraft, setNameDraft] = useState(group.name)
const [scheduleDraft, setScheduleDraft] = useState<ActionGroupSchedule>(
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<string | null>(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 (
<div>
<div className="node-action-group-heading">
{canUpdate ? (
<input
type="text"
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
aria-label="Action group name"
/>
) : (
<strong>{group.name}</strong>
)}
<div className="node-action-group-toolbar">
{canRun ? (
<button
type="button"
disabled={busyKey === `run-group-${group.id}` || group.items.length === 0}
onClick={() => {
void handleRunGroup()
}}
>
Run group
</button>
) : null}
{canUpdate ? (
<button
type="button"
disabled={busyKey === `save-${group.id}`}
onClick={() => {
void saveSchedule()
}}
>
Save
</button>
) : null}
{canDelete ? (
<button
type="button"
className="danger"
disabled={busyKey === `delete-group-${group.id}`}
onClick={() => {
void handleDeleteGroup()
}}
>
Delete group
</button>
) : null}
</div>
</div>
{group.last_run_at ? (
<p className="config-hint">
Last run: {new Date(group.last_run_at).toLocaleString()}
</p>
) : (
<p className="config-hint">Never run</p>
)}
<fieldset className="node-action-schedule" disabled={!canUpdate}>
<legend>Schedule</legend>
<label className="checkbox-row">
<input
type="checkbox"
checked={scheduleDraft.enabled}
onChange={(event) =>
setScheduleDraft((previous) => ({
...previous,
enabled: event.target.checked,
}))
}
/>
Enabled
</label>
<label>
Kind
<select
value={scheduleDraft.kind}
onChange={(event) =>
setScheduleDraft((previous) => ({
...previous,
kind: event.target.value as ActionGroupSchedule['kind'],
}))
}
>
<option value="interval">Interval</option>
<option value="times">Times of day / week</option>
</select>
</label>
{scheduleDraft.kind === 'interval' ? (
<label>
Every (seconds)
<input
type="number"
min={1}
value={scheduleDraft.every_seconds || 3600}
onChange={(event) =>
setScheduleDraft((previous) => ({
...previous,
every_seconds: Number(event.target.value) || 1,
}))
}
/>
</label>
) : (
<ScheduleTimesEditor
times={scheduleDraft.times || []}
onChange={(times) =>
setScheduleDraft((previous) => ({ ...previous, times }))
}
/>
)}
</fieldset>
<h4>Actions</h4>
{group.items.length === 0 ? (
<p className="config-hint">No actions in this group yet.</p>
) : (
<ol className="node-action-item-list">
{group.items.map((item, index) => (
<li key={item.id} className="node-action-item">
<div className="node-action-item-heading">
<span>
{itemDisplayName(item, libraryById)}{' '}
<span className="config-hint">({item.source})</span>
</span>
<div className="node-action-item-toolbar">
{canUpdate ? (
<>
<button
type="button"
disabled={index === 0 || Boolean(busyKey)}
onClick={() => {
void moveItem(item.id, -1)
}}
aria-label="Move up"
>
</button>
<button
type="button"
disabled={
index === group.items.length - 1 || Boolean(busyKey)
}
onClick={() => {
void moveItem(item.id, 1)
}}
aria-label="Move down"
>
</button>
</>
) : null}
{canRun ? (
<button
type="button"
disabled={busyKey === `run-item-${item.id}`}
onClick={() => {
void (async () => {
setBusyKey(`run-item-${item.id}`)
setError(null)
try {
const response = await runActionGroupItem(
nodeId,
group.id,
item.id,
)
onRunComplete(response.run)
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to run action',
)
} finally {
setBusyKey(null)
}
})()
}}
>
Run
</button>
) : null}
{item.source === 'library' && canCreate ? (
<button
type="button"
disabled={busyKey === `clone-${item.id}`}
onClick={() => {
void (async () => {
setBusyKey(`clone-${item.id}`)
setError(null)
try {
const response = await cloneActionGroupItem(
nodeId,
group.id,
item.id,
)
onGroupChange(response.group)
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to clone',
)
} finally {
setBusyKey(null)
}
})()
}}
>
Clone to customize
</button>
) : null}
{item.source === 'local' && canUpdate ? (
<button
type="button"
onClick={() => {
setEditingItemId(item.id)
setEditName(item.name || '')
setEditBody(item.body || '')
}}
>
Edit
</button>
) : null}
{canDelete ? (
<button
type="button"
className="danger"
disabled={busyKey === `delete-item-${item.id}`}
onClick={() => {
void (async () => {
setBusyKey(`delete-item-${item.id}`)
setError(null)
try {
const response = await deleteActionGroupItem(
nodeId,
group.id,
item.id,
)
onGroupChange(response.group)
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to delete action',
)
} finally {
setBusyKey(null)
}
})()
}}
>
Remove
</button>
) : null}
</div>
</div>
{editingItemId === item.id ? (
<div className="node-action-item-edit">
<label>
Name
<input
type="text"
value={editName}
onChange={(event) => setEditName(event.target.value)}
/>
</label>
<label>
Body
<textarea
value={editBody}
rows={4}
onChange={(event) => setEditBody(event.target.value)}
/>
</label>
<div className="node-action-item-toolbar">
<button
type="button"
onClick={() => {
void (async () => {
setBusyKey(`edit-${item.id}`)
setError(null)
try {
const response = await patchActionGroupItem(
nodeId,
group.id,
item.id,
{ name: editName, body: editBody },
)
onGroupChange(response.group)
setEditingItemId(null)
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to save action',
)
} finally {
setBusyKey(null)
}
})()
}}
>
Save action
</button>
<button
type="button"
onClick={() => setEditingItemId(null)}
>
Cancel
</button>
</div>
</div>
) : null}
</li>
))}
</ol>
)}
{canCreate ? (
<div className="node-action-adders">
<div className="node-action-add-row">
<select
value={libraryPick}
onChange={(event) => setLibraryPick(event.target.value)}
aria-label="Library action"
>
<option value="">Add from library</option>
{library.map((action) => (
<option key={action.id} value={action.id}>
{action.name}
</option>
))}
</select>
<button
type="button"
disabled={!libraryPick || busyKey === `add-lib-${group.id}`}
onClick={() => {
void handleAddLibrary()
}}
>
Add library action
</button>
</div>
<div className="node-action-add-custom">
<input
type="text"
placeholder="Custom action name"
value={customName}
onChange={(event) => setCustomName(event.target.value)}
/>
<select
value={customKind}
onChange={(event) =>
setCustomKind(event.target.value as 'shell' | 'script')
}
>
<option value="shell">Shell</option>
<option value="script">Script</option>
</select>
<textarea
placeholder="Command or script body"
value={customBody}
rows={3}
onChange={(event) => setCustomBody(event.target.value)}
/>
<button
type="button"
disabled={
!customName.trim() ||
!customBody.trim() ||
busyKey === `add-custom-${group.id}`
}
onClick={() => {
void handleAddCustom()
}}
>
Add custom action
</button>
</div>
</div>
) : null}
</div>
)
}
function ScheduleTimesEditor({
times,
onChange,
}: {
times: ReadonlyArray<{ time: string; days_of_week?: ReadonlyArray<number> }>
onChange: (
times: Array<{ time: string; days_of_week?: ReadonlyArray<number> }>,
) => void
}) {
function updateAt(
index: number,
next: { time: string; days_of_week?: ReadonlyArray<number> },
) {
const copy = times.map((entry) => ({
time: entry.time,
days_of_week: entry.days_of_week ? [...entry.days_of_week] : undefined,
}))
copy[index] = {
time: next.time,
days_of_week: next.days_of_week ? [...next.days_of_week] : undefined,
}
onChange(copy)
}
return (
<div className="schedule-times-editor">
{times.map((spec, index) => (
<div key={index} className="schedule-time-row">
<input
type="time"
value={spec.time || '00:00'}
onChange={(event) =>
updateAt(index, { ...spec, time: event.target.value })
}
/>
<div className="schedule-weekdays">
{WEEKDAY_LABELS.map((label, day) => {
const selected = (spec.days_of_week || []).includes(day)
return (
<label key={label} className="checkbox-row">
<input
type="checkbox"
checked={selected}
onChange={(event) => {
const current = new Set(spec.days_of_week || [])
if (event.target.checked) {
current.add(day)
} else {
current.delete(day)
}
updateAt(index, {
time: spec.time,
days_of_week: Array.from(current).sort(),
})
}}
/>
{label}
</label>
)
})}
</div>
<button
type="button"
onClick={() => onChange(times.filter((_, i) => i !== index))}
>
Remove time
</button>
</div>
))}
<button
type="button"
onClick={() => onChange([...times, { time: '09:00', days_of_week: [] }])}
>
Add time
</button>
<p className="config-hint">
Leave weekdays unchecked to run every day at that time.
</p>
</div>
)
}
function NodeActionLogsTab({
nodeId,
canRead,
}: {
nodeId: string
canRead: boolean
}) {
const [files, setFiles] = useState<ReadonlyArray<ActionLogFileInfo>>([])
const [selectedFilename, setSelectedFilename] = useState<string | null>(null)
const [logFile, setLogFile] = useState<ActionLogFile | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLoadingContent, setIsLoadingContent] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!canRead) {
setIsLoading(false)
return
}
let isCancelled = false
async function load() {
setIsLoading(true)
setError(null)
try {
const response = await fetchActionLogs(nodeId)
if (!isCancelled) {
setFiles(response.files)
if (response.files.length > 0) {
setSelectedFilename(response.files[0].filename)
}
}
} catch (err) {
if (!isCancelled) {
setError(err instanceof Error ? err.message : 'Failed to list logs')
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void load()
return () => {
isCancelled = true
}
}, [nodeId, canRead])
useEffect(() => {
if (!selectedFilename || !canRead) {
setLogFile(null)
return
}
let isCancelled = false
async function loadContent() {
setIsLoadingContent(true)
setError(null)
try {
const response = await fetchActionLogFile(nodeId, selectedFilename!)
if (!isCancelled) {
setLogFile(response.log)
}
} catch (err) {
if (!isCancelled) {
setError(err instanceof Error ? err.message : 'Failed to load log')
setLogFile(null)
}
} finally {
if (!isCancelled) {
setIsLoadingContent(false)
}
}
}
void loadContent()
return () => {
isCancelled = true
}
}, [nodeId, selectedFilename, canRead])
if (!canRead) {
return (
<p className="config-hint">
You need the <code>logs.read</code> permission to view action logs.
</p>
)
}
if (isLoading) {
return <p className="config-hint">Loading logs</p>
}
return (
<div className="node-action-logs-layout">
{error ? <p className="config-error">{error}</p> : null}
<aside className="node-action-logs-list" aria-label="Log files">
{files.length === 0 ? (
<p className="config-hint">No log files yet.</p>
) : (
<ul>
{files.map((file) => (
<li key={file.filename}>
<button
type="button"
className={
selectedFilename === file.filename
? 'log-file-button log-file-button-active'
: 'log-file-button'
}
onClick={() => setSelectedFilename(file.filename)}
>
<strong>{file.group_name}</strong>
<span className="config-hint">{file.filename}</span>
</button>
</li>
))}
</ul>
)}
</aside>
<section className="node-action-logs-content" aria-label="Log content">
{!selectedFilename ? (
<p className="config-hint">Select a log file.</p>
) : isLoadingContent ? (
<p className="config-hint">Loading</p>
) : logFile ? (
<div>
<h3>{logFile.group_name}</h3>
{logFile.runs.length === 0 ? (
<p className="config-hint">No runs recorded.</p>
) : (
[...logFile.runs].reverse().map((run) => (
<details key={run.id} className="action-log-run">
<summary>
{new Date(run.started_at).toLocaleString()} · {run.trigger}
{run.actor ? ` · ${run.actor}` : ''}
</summary>
<pre className="action-log-pre">
{JSON.stringify(run, null, 2)}
</pre>
</details>
))
)}
</div>
) : (
<p className="config-hint">No content.</p>
)}
</section>
</div>
)
}