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.
This commit is contained in:
+221
-5
@@ -422,24 +422,60 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(min(100%, 18rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.node-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.node-list-item {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--ctp-surface0) 55%, transparent);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.node-list-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.node-list-heading-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node-edit {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 6px;
|
||||
background: var(--ctp-surface0);
|
||||
color: var(--ctp-text);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-edit:hover {
|
||||
border-color: var(--ctp-overlay0);
|
||||
}
|
||||
|
||||
.node-id {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ctp-subtext0);
|
||||
@@ -1359,3 +1395,183 @@
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.node-detail-back {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.node-detail-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.node-detail-header h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.node-detail-overview {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node-detail-overview dt {
|
||||
font-size: 0.8rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.node-detail-overview dd {
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
|
||||
.node-action-group-create,
|
||||
.node-action-add-row,
|
||||
.node-action-group-heading,
|
||||
.node-action-group-toolbar,
|
||||
.node-action-item-heading,
|
||||
.node-action-item-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-action-group-list {
|
||||
list-style: none;
|
||||
margin: 1rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.node-action-group-card {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: color-mix(in srgb, var(--ctp-surface0) 70%, transparent);
|
||||
}
|
||||
|
||||
.node-action-schedule {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.75rem;
|
||||
margin: 0.75rem 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.node-action-item-list {
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding-left: 1.25rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.node-action-item-edit,
|
||||
.node-action-add-custom,
|
||||
.node-action-adders {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.schedule-times-editor,
|
||||
.schedule-time-row,
|
||||
.schedule-weekdays {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.node-action-run-result {
|
||||
margin-top: 1.25rem;
|
||||
border-top: 1px solid var(--ctp-surface1);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.action-log-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.8rem;
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.65rem;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.action-log-stderr {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.node-action-logs-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 16rem) 1fr;
|
||||
gap: 1rem;
|
||||
min-height: 18rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.node-action-logs-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.node-action-logs-list {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
max-height: 28rem;
|
||||
}
|
||||
|
||||
.node-action-logs-list ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.log-file-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.log-file-button-active,
|
||||
.log-file-button:hover {
|
||||
border-color: var(--ctp-surface2);
|
||||
background: color-mix(in srgb, var(--ctp-surface1) 60%, transparent);
|
||||
}
|
||||
|
||||
.node-action-logs-content {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow: auto;
|
||||
max-height: 28rem;
|
||||
}
|
||||
|
||||
.action-log-run {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
+45
-2
@@ -73,6 +73,7 @@ import {
|
||||
type LogsPageSize,
|
||||
} from './logsPreferences'
|
||||
import { LoginPage } from './LoginPage'
|
||||
import { NodeDetailPanel } from './NodeDetailPanel'
|
||||
import {
|
||||
countAuthFailuresToday,
|
||||
countNodesBySection,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
pathFor,
|
||||
subscribeToLocation,
|
||||
type ConfigTabId,
|
||||
type NodeDetailTabId,
|
||||
type ResourceSectionId,
|
||||
type ResourceTabId,
|
||||
type SectionId,
|
||||
@@ -2546,8 +2548,21 @@ function ResourceOverviewTab({
|
||||
return (
|
||||
<li key={node.id} className="node-list-item">
|
||||
<div className="node-list-heading">
|
||||
<strong>{node.name}</strong>
|
||||
<code className="node-id">{node.id}</code>
|
||||
<div className="node-list-heading-main">
|
||||
<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, 'actions'),
|
||||
)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<p className="config-hint">
|
||||
{node.host_ip} · {node.username} · group {node.group_name} ·{' '}
|
||||
@@ -2919,10 +2934,14 @@ function ResourceSectionPanel({
|
||||
section,
|
||||
activeResourceTab,
|
||||
onResourceTabChange,
|
||||
nodeId,
|
||||
nodeDetailTab,
|
||||
}: {
|
||||
section: ResourceSectionId
|
||||
activeResourceTab: ResourceTabId
|
||||
onResourceTabChange: (tab: ResourceTabId) => void
|
||||
nodeId: string | null
|
||||
nodeDetailTab: NodeDetailTabId
|
||||
}) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [overviewKey, setOverviewKey] = useState(0)
|
||||
@@ -2955,6 +2974,16 @@ function ResourceSectionPanel({
|
||||
const canDelete = Boolean(me?.permissions?.includes('nodes.delete'))
|
||||
const tabs = resourceTabsFor(section)
|
||||
|
||||
if (nodeId) {
|
||||
return (
|
||||
<NodeDetailPanel
|
||||
section={section}
|
||||
nodeId={nodeId}
|
||||
activeTab={nodeDetailTab}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="config-panel">
|
||||
<div
|
||||
@@ -3743,6 +3772,11 @@ function App() {
|
||||
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,
|
||||
)
|
||||
@@ -3764,6 +3798,8 @@ function App() {
|
||||
setActiveSection(location.section)
|
||||
setActiveConfigTab(location.configTab)
|
||||
setActiveResourceTab(location.resourceTab)
|
||||
setActiveNodeId(location.nodeId)
|
||||
setActiveNodeDetailTab(location.nodeDetailTab)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -3916,6 +3952,8 @@ function App() {
|
||||
return
|
||||
}
|
||||
setActiveSection(section)
|
||||
setActiveNodeId(null)
|
||||
setActiveNodeDetailTab('overview')
|
||||
if (section === 'configuration') {
|
||||
navigateTo(pathFor(section, activeConfigTab))
|
||||
return
|
||||
@@ -3932,6 +3970,7 @@ function App() {
|
||||
function goToConfigTab(tab: ConfigTabId) {
|
||||
setActiveSection('configuration')
|
||||
setActiveConfigTab(tab)
|
||||
setActiveNodeId(null)
|
||||
navigateTo(pathFor('configuration', tab))
|
||||
}
|
||||
|
||||
@@ -3940,6 +3979,8 @@ function App() {
|
||||
return
|
||||
}
|
||||
setActiveResourceTab(tab)
|
||||
setActiveNodeId(null)
|
||||
setActiveNodeDetailTab('overview')
|
||||
navigateTo(pathFor(activeSection, 'overview', tab))
|
||||
}
|
||||
|
||||
@@ -4150,6 +4191,8 @@ function App() {
|
||||
section={activeSection}
|
||||
activeResourceTab={activeResourceTab}
|
||||
onResourceTabChange={goToResourceTab}
|
||||
nodeId={activeNodeId}
|
||||
nodeDetailTab={activeNodeDetailTab}
|
||||
/>
|
||||
) : (
|
||||
<p>Coming soon</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -932,3 +932,342 @@ export async function deleteAction(
|
||||
}
|
||||
return (await response.json()) as ActionsResponse
|
||||
}
|
||||
|
||||
export type ActionItemSource = 'library' | 'local'
|
||||
export type ActionGroupScheduleKind = 'interval' | 'times'
|
||||
export type ActionRunTrigger = 'manual' | 'schedule'
|
||||
|
||||
export type ActionGroupTimeSpec = {
|
||||
time: string
|
||||
days_of_week?: ReadonlyArray<number>
|
||||
}
|
||||
|
||||
export type ActionGroupSchedule = {
|
||||
enabled: boolean
|
||||
kind: ActionGroupScheduleKind
|
||||
every_seconds?: number
|
||||
times?: ReadonlyArray<ActionGroupTimeSpec>
|
||||
}
|
||||
|
||||
export type NodeActionItem = {
|
||||
id: string
|
||||
source: ActionItemSource
|
||||
library_action_id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
}
|
||||
|
||||
export type NodeActionGroup = {
|
||||
id: string
|
||||
node_id: string
|
||||
name: string
|
||||
schedule: ActionGroupSchedule
|
||||
items: ReadonlyArray<NodeActionItem>
|
||||
last_run_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ActionGroupsResponse = {
|
||||
groups: ReadonlyArray<NodeActionGroup>
|
||||
}
|
||||
|
||||
export type ActionGroupResponse = {
|
||||
group: NodeActionGroup
|
||||
}
|
||||
|
||||
export type ActionItemRunResult = {
|
||||
item_id: string
|
||||
action_name: string
|
||||
source: string
|
||||
exit_code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
error?: string
|
||||
started_at: string
|
||||
finished_at: string
|
||||
}
|
||||
|
||||
export type ActionGroupRunRecord = {
|
||||
id: string
|
||||
node_id: string
|
||||
group_id: string
|
||||
group_name: string
|
||||
trigger: ActionRunTrigger
|
||||
actor?: string
|
||||
started_at: string
|
||||
finished_at: string
|
||||
actions: ReadonlyArray<ActionItemRunResult>
|
||||
}
|
||||
|
||||
export type ActionRunResponse = {
|
||||
run: ActionGroupRunRecord
|
||||
}
|
||||
|
||||
export type ActionLogFileInfo = {
|
||||
filename: string
|
||||
group_name: string
|
||||
group_id?: string
|
||||
mod_time: string
|
||||
size_bytes: number
|
||||
}
|
||||
|
||||
export type ActionLogFile = {
|
||||
node_id: string
|
||||
group_id: string
|
||||
group_name: string
|
||||
runs: ReadonlyArray<ActionGroupRunRecord>
|
||||
}
|
||||
|
||||
export type ActionLogsListResponse = {
|
||||
files: ReadonlyArray<ActionLogFileInfo>
|
||||
}
|
||||
|
||||
export type ActionLogFileResponse = {
|
||||
log: ActionLogFile
|
||||
}
|
||||
|
||||
export type CreateActionGroupRequest = {
|
||||
name: string
|
||||
schedule?: ActionGroupSchedule
|
||||
}
|
||||
|
||||
export type PatchActionGroupRequest = {
|
||||
name?: string
|
||||
schedule?: ActionGroupSchedule
|
||||
}
|
||||
|
||||
export type CreateActionGroupItemRequest = {
|
||||
source: ActionItemSource
|
||||
library_action_id?: string
|
||||
name?: string
|
||||
description?: string
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
}
|
||||
|
||||
export type PatchActionGroupItemRequest = {
|
||||
name?: string
|
||||
description?: string
|
||||
kind?: ActionKind
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
}
|
||||
|
||||
export async function fetchActionGroups(
|
||||
nodeId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupsResponse
|
||||
}
|
||||
|
||||
export async function createActionGroup(
|
||||
nodeId: string,
|
||||
body: CreateActionGroupRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function patchActionGroup(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
body: PatchActionGroupRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function deleteActionGroup(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupsResponse
|
||||
}
|
||||
|
||||
export async function createActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
body: CreateActionGroupItemRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items`,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function patchActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
body: PatchActionGroupItemRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(body) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function cloneActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}/clone`,
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function deleteActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}`,
|
||||
{ method: 'DELETE' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function reorderActionGroupItems(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemIds: ReadonlyArray<string>,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionGroupResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/order`,
|
||||
{ method: 'PUT', body: JSON.stringify({ item_ids: itemIds }) },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionGroupResponse
|
||||
}
|
||||
|
||||
export async function runActionGroup(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionRunResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/run`,
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionRunResponse
|
||||
}
|
||||
|
||||
export async function runActionGroupItem(
|
||||
nodeId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionRunResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-groups/${encodeURIComponent(groupId)}/items/${encodeURIComponent(itemId)}/run`,
|
||||
{ method: 'POST' },
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionRunResponse
|
||||
}
|
||||
|
||||
export async function fetchActionLogs(
|
||||
nodeId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionLogsListResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-logs`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionLogsListResponse
|
||||
}
|
||||
|
||||
export async function fetchActionLogFile(
|
||||
nodeId: string,
|
||||
filename: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionLogFileResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-logs/${encodeURIComponent(filename)}`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionLogFileResponse
|
||||
}
|
||||
|
||||
@@ -20,6 +20,18 @@ describe('pathFor', () => {
|
||||
expect(pathFor('configuration', 'actions')).toBe('/configuration/actions')
|
||||
expect(pathFor('configuration', 'roles')).toBe('/configuration/roles')
|
||||
})
|
||||
|
||||
it('maps node detail paths', () => {
|
||||
expect(pathFor('containers', 'overview', 'overview', 'node-1')).toBe(
|
||||
'/containers/node-1',
|
||||
)
|
||||
expect(
|
||||
pathFor('containers', 'overview', 'overview', 'node-1', 'actions'),
|
||||
).toBe('/containers/node-1/actions')
|
||||
expect(pathFor('vms', 'overview', 'overview', 'abc', 'logs')).toBe(
|
||||
'/vms/abc/logs',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseLocation', () => {
|
||||
@@ -45,10 +57,19 @@ describe('parseLocation', () => {
|
||||
'/configuration/integrations',
|
||||
'/configuration/network',
|
||||
'/configuration/advanced',
|
||||
'/containers/node-1',
|
||||
'/containers/node-1/actions',
|
||||
'/vms/node-2/logs',
|
||||
]) {
|
||||
const location = parseLocation(path)
|
||||
expect(
|
||||
pathFor(location.section, location.configTab, location.resourceTab),
|
||||
pathFor(
|
||||
location.section,
|
||||
location.configTab,
|
||||
location.resourceTab,
|
||||
location.nodeId,
|
||||
location.nodeDetailTab,
|
||||
),
|
||||
).toBe(path === '/docker/overview' ? '/docker' : path)
|
||||
}
|
||||
})
|
||||
@@ -58,21 +79,29 @@ describe('parseLocation', () => {
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/configuration/unknown')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/vms/extra')).toEqual({
|
||||
section: 'overview',
|
||||
section: 'vms',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: 'extra',
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/docker/extra')).toEqual({
|
||||
expect(parseLocation('/docker/extra/nope')).toEqual({
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,11 +110,8 @@ describe('parseLocation', () => {
|
||||
section: 'configuration',
|
||||
configTab: 'groups',
|
||||
resourceTab: 'overview',
|
||||
})
|
||||
expect(parseLocation('/containers/add/')).toEqual({
|
||||
section: 'containers',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'add',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+54
-10
@@ -22,12 +22,16 @@ export type ConfigTabId =
|
||||
|
||||
export type ResourceTabId = 'overview' | 'security' | 'add'
|
||||
|
||||
export type NodeDetailTabId = 'overview' | 'actions' | 'logs'
|
||||
|
||||
export type ResourceSectionId = 'containers' | 'vms' | 'docker'
|
||||
|
||||
export type AppLocation = {
|
||||
section: SectionId
|
||||
configTab: ConfigTabId
|
||||
resourceTab: ResourceTabId
|
||||
nodeId: string | null
|
||||
nodeDetailTab: NodeDetailTabId
|
||||
}
|
||||
|
||||
const CONFIG_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
@@ -48,6 +52,12 @@ const RESOURCE_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'add',
|
||||
])
|
||||
|
||||
const NODE_DETAIL_TAB_IDS: ReadonlySet<string> = new Set([
|
||||
'overview',
|
||||
'actions',
|
||||
'logs',
|
||||
])
|
||||
|
||||
const RESOURCE_SECTION_IDS: ReadonlySet<string> = new Set([
|
||||
'containers',
|
||||
'vms',
|
||||
@@ -58,6 +68,8 @@ const DEFAULT_LOCATION: AppLocation = {
|
||||
section: 'overview',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
nodeId: null,
|
||||
nodeDetailTab: 'overview',
|
||||
}
|
||||
|
||||
function isConfigTabId(value: string): value is ConfigTabId {
|
||||
@@ -68,6 +80,10 @@ function isResourceTabId(value: string): value is ResourceTabId {
|
||||
return RESOURCE_TAB_IDS.has(value)
|
||||
}
|
||||
|
||||
function isNodeDetailTabId(value: string): value is NodeDetailTabId {
|
||||
return NODE_DETAIL_TAB_IDS.has(value)
|
||||
}
|
||||
|
||||
export function isResourceSectionId(value: SectionId): value is ResourceSectionId {
|
||||
return RESOURCE_SECTION_IDS.has(value)
|
||||
}
|
||||
@@ -81,31 +97,52 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
|
||||
const [first, second] = segments
|
||||
const [first, second, third] = segments
|
||||
|
||||
if (first === 'setup' && segments.length === 1) {
|
||||
return { section: 'setup', configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section: 'setup' }
|
||||
}
|
||||
|
||||
if (first === 'login' && segments.length === 1) {
|
||||
return { section: 'login', configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section: 'login' }
|
||||
}
|
||||
|
||||
if (first === 'profile' && segments.length === 1) {
|
||||
return { section: 'profile', configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section: 'profile' }
|
||||
}
|
||||
|
||||
if (first === 'logs' && segments.length === 1) {
|
||||
return { section: 'logs', configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section: 'logs' }
|
||||
}
|
||||
|
||||
if (RESOURCE_SECTION_IDS.has(first)) {
|
||||
const section = first as ResourceSectionId
|
||||
if (segments.length === 1) {
|
||||
return { section, configTab: 'overview', resourceTab: 'overview' }
|
||||
return { ...DEFAULT_LOCATION, section }
|
||||
}
|
||||
if (segments.length === 2 && isResourceTabId(second)) {
|
||||
return { section, configTab: 'overview', resourceTab: second }
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section,
|
||||
resourceTab: second,
|
||||
}
|
||||
}
|
||||
// /containers/:nodeId or /containers/:nodeId/:detailTab
|
||||
if (segments.length === 2 && !isResourceTabId(second)) {
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section,
|
||||
nodeId: second,
|
||||
nodeDetailTab: 'overview',
|
||||
}
|
||||
}
|
||||
if (segments.length === 3 && !isResourceTabId(second) && isNodeDetailTabId(third)) {
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section,
|
||||
nodeId: second,
|
||||
nodeDetailTab: third,
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_LOCATION }
|
||||
}
|
||||
@@ -113,16 +150,15 @@ export function parseLocation(pathname: string): AppLocation {
|
||||
if (first === 'configuration') {
|
||||
if (segments.length === 1) {
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section: 'configuration',
|
||||
configTab: 'overview',
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
}
|
||||
if (segments.length === 2 && isConfigTabId(second)) {
|
||||
return {
|
||||
...DEFAULT_LOCATION,
|
||||
section: 'configuration',
|
||||
configTab: second,
|
||||
resourceTab: 'overview',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +171,8 @@ export function pathFor(
|
||||
section: SectionId,
|
||||
configTab: ConfigTabId = 'overview',
|
||||
resourceTab: ResourceTabId = 'overview',
|
||||
nodeId: string | null = null,
|
||||
nodeDetailTab: NodeDetailTabId = 'overview',
|
||||
): string {
|
||||
if (section === 'overview') {
|
||||
return '/'
|
||||
@@ -152,6 +190,12 @@ export function pathFor(
|
||||
return '/logs'
|
||||
}
|
||||
if (isResourceSectionId(section)) {
|
||||
if (nodeId) {
|
||||
if (nodeDetailTab === 'overview') {
|
||||
return `/${section}/${nodeId}`
|
||||
}
|
||||
return `/${section}/${nodeId}/${nodeDetailTab}`
|
||||
}
|
||||
if (resourceTab === 'overview') {
|
||||
return `/${section}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user