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:
2026-07-19 20:01:11 +02:00
parent 50568127c5
commit 32af91fd30
22 changed files with 4337 additions and 32 deletions
+221 -5
View File
@@ -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
View File
@@ -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
+339
View File
@@ -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
}
+34 -8
View File
@@ -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
View File
@@ -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}`
}