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.
1183 lines
35 KiB
TypeScript
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>
|
|
)
|
|
}
|