Add Show Widget for library actions with Overview charts from last run output.
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
type ActionWidgetSnapshot,
|
||||
type DiskWidgetData,
|
||||
type MemoryWidgetData,
|
||||
fetchNodeActionWidgets,
|
||||
} from './api/client'
|
||||
|
||||
type RingSegment = {
|
||||
value: number
|
||||
color: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function formatMiB(value: number): string {
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(1)} GiB`
|
||||
}
|
||||
return `${value} MiB`
|
||||
}
|
||||
|
||||
function RingChart({
|
||||
segments,
|
||||
size = 120,
|
||||
strokeWidth = 16,
|
||||
}: {
|
||||
segments: ReadonlyArray<RingSegment>
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0)
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
let offset = 0
|
||||
|
||||
if (total <= 0) {
|
||||
return (
|
||||
<svg
|
||||
className="action-widget-ring"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--ctp-surface1)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="action-widget-ring"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
role="img"
|
||||
aria-label={segments.map((segment) => `${segment.label} ${segment.value}`).join(', ')}
|
||||
>
|
||||
<g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
|
||||
{segments.map((segment) => {
|
||||
const value = Math.max(0, segment.value)
|
||||
const length = (value / total) * circumference
|
||||
const dashOffset = -offset
|
||||
offset += length
|
||||
return (
|
||||
<circle
|
||||
key={segment.label}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={segment.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${length} ${circumference - length}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryRingWidget({
|
||||
title,
|
||||
data,
|
||||
updatedAt,
|
||||
error,
|
||||
}: {
|
||||
title: string
|
||||
data?: MemoryWidgetData
|
||||
updatedAt?: string
|
||||
error?: string
|
||||
}) {
|
||||
const used = data?.used ?? 0
|
||||
const free = data?.free ?? 0
|
||||
const buffCache = data?.buff_cache ?? 0
|
||||
const total = data?.total ?? used + free + buffCache
|
||||
|
||||
return (
|
||||
<article className="action-widget action-widget-memory">
|
||||
<header className="action-widget-header">
|
||||
<h3>{title}</h3>
|
||||
{updatedAt ? (
|
||||
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
|
||||
) : null}
|
||||
</header>
|
||||
{error ? <p className="action-widget-error">{error}</p> : null}
|
||||
{!data && !error ? (
|
||||
<p className="config-hint">Run this action to populate the widget.</p>
|
||||
) : null}
|
||||
{data ? (
|
||||
<div className="action-widget-body">
|
||||
<RingChart
|
||||
segments={[
|
||||
{ value: used, color: 'var(--ctp-red)', label: 'Used' },
|
||||
{ value: free, color: 'var(--ctp-green)', label: 'Free' },
|
||||
{
|
||||
value: buffCache,
|
||||
color: 'var(--ctp-yellow)',
|
||||
label: 'Buff/cache',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="action-widget-stats">
|
||||
<p className="action-widget-total">Total {formatMiB(total)}</p>
|
||||
<ul className="action-widget-legend">
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-used" />
|
||||
Used {formatMiB(used)}
|
||||
</li>
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-free" />
|
||||
Free {formatMiB(free)}
|
||||
</li>
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-cache" />
|
||||
Buff/cache {formatMiB(buffCache)}
|
||||
</li>
|
||||
<li>Shared {formatMiB(data.shared)}</li>
|
||||
<li>Available {formatMiB(data.available)}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function DiskRingWidget({
|
||||
title,
|
||||
data,
|
||||
updatedAt,
|
||||
error,
|
||||
}: {
|
||||
title: string
|
||||
data?: DiskWidgetData
|
||||
updatedAt?: string
|
||||
error?: string
|
||||
}) {
|
||||
const usedMiB = data?.used_mib ?? 0
|
||||
const availMiB = data?.avail_mib ?? 0
|
||||
const sizeLabel = data?.size || '—'
|
||||
const mount = data?.mounted_on || ''
|
||||
|
||||
return (
|
||||
<article className="action-widget action-widget-disk">
|
||||
<header className="action-widget-header">
|
||||
<h3>{title}</h3>
|
||||
{updatedAt ? (
|
||||
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
|
||||
) : null}
|
||||
</header>
|
||||
{error ? <p className="action-widget-error">{error}</p> : null}
|
||||
{!data && !error ? (
|
||||
<p className="config-hint">Run this action to populate the widget.</p>
|
||||
) : null}
|
||||
{data ? (
|
||||
<div className="action-widget-body">
|
||||
<RingChart
|
||||
segments={[
|
||||
{ value: usedMiB || 1, color: 'var(--ctp-yellow)', label: 'Used' },
|
||||
{
|
||||
value: availMiB || 1,
|
||||
color: 'var(--ctp-blue)',
|
||||
label: 'Available',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="action-widget-stats">
|
||||
<p className="action-widget-total">
|
||||
Size {sizeLabel}
|
||||
{mount ? ` · ${mount}` : ''}
|
||||
</p>
|
||||
<ul className="action-widget-legend">
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-disk-used" />
|
||||
Used {data.used} ({data.use_percent})
|
||||
</li>
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-disk-avail" />
|
||||
Avail {data.avail}
|
||||
</li>
|
||||
<li className="action-widget-fs">{data.filesystem}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function RawWidget({
|
||||
title,
|
||||
stdout,
|
||||
updatedAt,
|
||||
error,
|
||||
}: {
|
||||
title: string
|
||||
stdout: string
|
||||
updatedAt?: string
|
||||
error?: string
|
||||
}) {
|
||||
return (
|
||||
<article className="action-widget action-widget-raw">
|
||||
<header className="action-widget-header">
|
||||
<h3>{title}</h3>
|
||||
{updatedAt ? (
|
||||
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
|
||||
) : null}
|
||||
</header>
|
||||
{error ? <p className="action-widget-error">{error}</p> : null}
|
||||
{!stdout && !error ? (
|
||||
<p className="config-hint">Run this action to populate the widget.</p>
|
||||
) : null}
|
||||
{stdout ? <pre className="action-widget-pre">{stdout}</pre> : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionWidgetCard({ widget }: { widget: ActionWidgetSnapshot }) {
|
||||
if (widget.widget_type === 'memory') {
|
||||
return (
|
||||
<MemoryRingWidget
|
||||
title={widget.title || 'Memory'}
|
||||
data={widget.memory}
|
||||
updatedAt={widget.updated_at}
|
||||
error={widget.error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (widget.widget_type === 'disk') {
|
||||
return (
|
||||
<DiskRingWidget
|
||||
title={widget.title || 'Disk'}
|
||||
data={widget.disk}
|
||||
updatedAt={widget.updated_at}
|
||||
error={widget.error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<RawWidget
|
||||
title={widget.title || 'Output'}
|
||||
stdout={widget.raw_stdout || ''}
|
||||
updatedAt={widget.updated_at}
|
||||
error={widget.error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeActionWidgetsPanel({ nodeId }: { nodeId: string }) {
|
||||
const [widgets, setWidgets] = useState<ActionWidgetSnapshot[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadWidgets() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const response = await fetchNodeActionWidgets(nodeId)
|
||||
if (!isCancelled) {
|
||||
setWidgets([...response.widgets])
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : 'Failed to load widgets',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadWidgets()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [nodeId])
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="config-hint">Loading widgets…</p>
|
||||
}
|
||||
if (loadError) {
|
||||
return (
|
||||
<p className="config-empty groups-error" role="alert">
|
||||
{loadError}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (widgets.length === 0) {
|
||||
return (
|
||||
<p className="config-hint">
|
||||
No action widgets yet. Enable Show Widget on a library action, add it to
|
||||
an action group, and run it.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="action-widgets-grid">
|
||||
{widgets.map((widget) => (
|
||||
<ActionWidgetCard key={`${widget.node_id}-${widget.item_id}`} widget={widget} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1927,3 +1927,142 @@ button.save-dirty {
|
||||
color: var(--ctp-green);
|
||||
border-color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.node-detail-overview-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.node-action-widgets-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.node-action-widgets-heading {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-widgets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.action-widget {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: color-mix(in srgb, var(--ctp-mantle) 55%, transparent);
|
||||
}
|
||||
|
||||
.action-widget-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.action-widget-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-widget-header time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.action-widget-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-widget-ring {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.action-widget-stats {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-widget-total {
|
||||
margin: 0 0 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-widget-legend {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.action-widget-legend li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.action-widget-swatch {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 0.15rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.action-widget-swatch-used {
|
||||
background: var(--ctp-red);
|
||||
}
|
||||
|
||||
.action-widget-swatch-free {
|
||||
background: var(--ctp-green);
|
||||
}
|
||||
|
||||
.action-widget-swatch-cache {
|
||||
background: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.action-widget-swatch-disk-used {
|
||||
background: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.action-widget-swatch-disk-avail {
|
||||
background: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.action-widget-fs {
|
||||
word-break: break-all;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.action-widget-error {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--ctp-red);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.action-widget-pre {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.65rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
+119
-19
@@ -34,6 +34,7 @@ import {
|
||||
type Action,
|
||||
type ActionEnvVar,
|
||||
type ActionKind,
|
||||
type ActionWidgetType,
|
||||
type AuthAuditEvent,
|
||||
type Group,
|
||||
type GroupScopeKind,
|
||||
@@ -1121,13 +1122,18 @@ function ActionsConfigTab() {
|
||||
const [draftBody, setDraftBody] = useState('')
|
||||
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
|
||||
const [draftRequiresSudo, setDraftRequiresSudo] = useState(false)
|
||||
const [draftShowWidget, setDraftShowWidget] = useState(false)
|
||||
const [draftWidgetType, setDraftWidgetType] =
|
||||
useState<ActionWidgetType>('raw')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [editingIsBuiltin, setEditingIsBuiltin] = useState(false)
|
||||
|
||||
const isEditing = editingActionId !== null
|
||||
const showEditor = editorKind !== null
|
||||
const canCreateActions = Boolean(me?.permissions?.includes('actions.create'))
|
||||
const canUpdateActions = Boolean(me?.permissions?.includes('actions.update'))
|
||||
const canDeleteActions = Boolean(me?.permissions?.includes('actions.delete'))
|
||||
const fieldsLocked = editingIsBuiltin
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
@@ -1173,6 +1179,9 @@ function ActionsConfigTab() {
|
||||
setDraftBody('')
|
||||
setDraftEnv([emptyEnvRow()])
|
||||
setDraftRequiresSudo(false)
|
||||
setDraftShowWidget(false)
|
||||
setDraftWidgetType('raw')
|
||||
setEditingIsBuiltin(false)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -1185,21 +1194,28 @@ function ActionsConfigTab() {
|
||||
setShowTypePicker(false)
|
||||
setEditorKind(kind)
|
||||
setEditingActionId(null)
|
||||
setEditingIsBuiltin(false)
|
||||
setDraftName('')
|
||||
setDraftDescription('')
|
||||
setDraftBody('')
|
||||
setDraftEnv([emptyEnvRow()])
|
||||
setDraftRequiresSudo(false)
|
||||
setDraftShowWidget(false)
|
||||
setDraftWidgetType('raw')
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
function startEdit(action: Action) {
|
||||
if (action.builtin || !canUpdateActions) {
|
||||
if (!canUpdateActions) {
|
||||
return
|
||||
}
|
||||
if (action.builtin && !canUpdateActions) {
|
||||
return
|
||||
}
|
||||
setShowTypePicker(false)
|
||||
setEditorKind(action.kind)
|
||||
setEditingActionId(action.id)
|
||||
setEditingIsBuiltin(Boolean(action.builtin))
|
||||
setDraftName(action.name)
|
||||
setDraftDescription(action.description)
|
||||
setDraftBody(action.body)
|
||||
@@ -1209,6 +1225,14 @@ function ActionsConfigTab() {
|
||||
: [emptyEnvRow()],
|
||||
)
|
||||
setDraftRequiresSudo(Boolean(action.requires_sudo))
|
||||
setDraftShowWidget(Boolean(action.show_widget))
|
||||
setDraftWidgetType(
|
||||
action.widget_type === 'memory' ||
|
||||
action.widget_type === 'disk' ||
|
||||
action.widget_type === 'raw'
|
||||
? action.widget_type
|
||||
: 'raw',
|
||||
)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -1250,14 +1274,25 @@ function ActionsConfigTab() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const env = normalizedEnv()
|
||||
const widgetType = draftShowWidget ? draftWidgetType : ('' as const)
|
||||
const response = isEditing
|
||||
? await patchAction(editingActionId!, {
|
||||
name: draftName,
|
||||
description: draftDescription,
|
||||
body: draftBody,
|
||||
env,
|
||||
requires_sudo: draftRequiresSudo,
|
||||
})
|
||||
? await patchAction(
|
||||
editingActionId!,
|
||||
editingIsBuiltin
|
||||
? {
|
||||
show_widget: draftShowWidget,
|
||||
widget_type: widgetType,
|
||||
}
|
||||
: {
|
||||
name: draftName,
|
||||
description: draftDescription,
|
||||
body: draftBody,
|
||||
env,
|
||||
requires_sudo: draftRequiresSudo,
|
||||
show_widget: draftShowWidget,
|
||||
widget_type: widgetType,
|
||||
},
|
||||
)
|
||||
: await createAction({
|
||||
name: draftName,
|
||||
description: draftDescription,
|
||||
@@ -1265,6 +1300,8 @@ function ActionsConfigTab() {
|
||||
body: draftBody,
|
||||
env,
|
||||
requires_sudo: draftRequiresSudo,
|
||||
show_widget: draftShowWidget,
|
||||
widget_type: widgetType,
|
||||
})
|
||||
setActions([...response.actions])
|
||||
clearEditor()
|
||||
@@ -1355,6 +1392,14 @@ function ActionsConfigTab() {
|
||||
sudo
|
||||
</span>
|
||||
) : null}
|
||||
{action.show_widget ? (
|
||||
<span className="user-badge">
|
||||
widget
|
||||
{action.widget_type
|
||||
? `: ${action.widget_type}`
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
{action.description || 'No description'}
|
||||
@@ -1371,13 +1416,13 @@ function ActionsConfigTab() {
|
||||
<button
|
||||
type="button"
|
||||
className="groups-edit"
|
||||
disabled={action.builtin || !canUpdateActions}
|
||||
disabled={!canUpdateActions}
|
||||
title={
|
||||
action.builtin
|
||||
? 'Built-in actions cannot be edited'
|
||||
: canUpdateActions
|
||||
? undefined
|
||||
: 'Requires actions.update'
|
||||
canUpdateActions
|
||||
? action.builtin
|
||||
? 'Edit widget settings'
|
||||
: undefined
|
||||
: 'Requires actions.update'
|
||||
}
|
||||
onClick={() => startEdit(action)}
|
||||
>
|
||||
@@ -1482,7 +1527,9 @@ function ActionsConfigTab() {
|
||||
>
|
||||
<h2 id="action-editor-title">
|
||||
{isEditing
|
||||
? `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
|
||||
? editingIsBuiltin
|
||||
? `Widget settings · ${draftName}`
|
||||
: `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
|
||||
: `New ${editorKind === 'shell' ? 'shell command' : 'script'}`}
|
||||
</h2>
|
||||
|
||||
@@ -1492,12 +1539,20 @@ function ActionsConfigTab() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{editingIsBuiltin ? (
|
||||
<p className="config-hint">
|
||||
Built-in actions keep a fixed command body. You can still enable
|
||||
or change the Overview widget.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="action-name">Name</label>
|
||||
<input
|
||||
id="action-name"
|
||||
className="config-input"
|
||||
value={draftName}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1508,6 +1563,7 @@ function ActionsConfigTab() {
|
||||
id="action-description"
|
||||
className="config-input"
|
||||
value={draftDescription}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setDraftDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1521,6 +1577,7 @@ function ActionsConfigTab() {
|
||||
id="action-body"
|
||||
className="config-input"
|
||||
value={draftBody}
|
||||
disabled={fieldsLocked}
|
||||
placeholder="echo {{node.ip}}"
|
||||
onChange={(event) => setDraftBody(event.target.value)}
|
||||
/>
|
||||
@@ -1529,21 +1586,25 @@ function ActionsConfigTab() {
|
||||
id="action-body"
|
||||
className="config-input actions-script-input"
|
||||
value={draftBody}
|
||||
disabled={fieldsLocked}
|
||||
rows={8}
|
||||
placeholder={"#!/bin/sh\necho {{node.host}}"}
|
||||
onChange={(event) => setDraftBody(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<p className="config-hint">
|
||||
Do not include a leading <code>sudo</code>; check Requires Sudo
|
||||
instead so ClusterCanvas runs with <code>sudo -n</code>.
|
||||
</p>
|
||||
{!fieldsLocked ? (
|
||||
<p className="config-hint">
|
||||
Do not include a leading <code>sudo</code>; check Requires Sudo
|
||||
instead so ClusterCanvas runs with <code>sudo -n</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draftRequiresSudo}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) =>
|
||||
setDraftRequiresSudo(event.target.checked)
|
||||
}
|
||||
@@ -1551,6 +1612,41 @@ function ActionsConfigTab() {
|
||||
Requires Sudo
|
||||
</label>
|
||||
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draftShowWidget}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked
|
||||
setDraftShowWidget(checked)
|
||||
if (checked && !draftWidgetType) {
|
||||
setDraftWidgetType('raw')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Show Widget
|
||||
</label>
|
||||
{draftShowWidget ? (
|
||||
<div className="config-field">
|
||||
<label htmlFor="action-widget-type">Widget type</label>
|
||||
<select
|
||||
id="action-widget-type"
|
||||
className="config-input"
|
||||
value={draftWidgetType}
|
||||
onChange={(event) =>
|
||||
setDraftWidgetType(event.target.value as ActionWidgetType)
|
||||
}
|
||||
>
|
||||
<option value="memory">Memory (free -m ring)</option>
|
||||
<option value="disk">Disk (df -h ring)</option>
|
||||
<option value="raw">Raw text</option>
|
||||
</select>
|
||||
<p className="config-hint">
|
||||
Parsed from the last run of this action on each node Overview.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label>Environment variables</label>
|
||||
<p className="config-hint">
|
||||
@@ -1565,6 +1661,7 @@ function ActionsConfigTab() {
|
||||
aria-label={`ENV name ${index + 1}`}
|
||||
placeholder="NAME"
|
||||
value={row.name}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) =>
|
||||
updateEnvRow(index, 'name', event.target.value)
|
||||
}
|
||||
@@ -1574,6 +1671,7 @@ function ActionsConfigTab() {
|
||||
aria-label={`ENV value ${index + 1}`}
|
||||
placeholder="value or {{node.ip}}"
|
||||
value={row.value}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) =>
|
||||
updateEnvRow(index, 'value', event.target.value)
|
||||
}
|
||||
@@ -1582,6 +1680,7 @@ function ActionsConfigTab() {
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
aria-label={`Remove ENV row ${index + 1}`}
|
||||
disabled={fieldsLocked}
|
||||
onClick={() => removeEnvRow(index)}
|
||||
>
|
||||
Remove
|
||||
@@ -1592,6 +1691,7 @@ function ActionsConfigTab() {
|
||||
<button
|
||||
type="button"
|
||||
className="groups-new"
|
||||
disabled={fieldsLocked}
|
||||
onClick={() => addEnvRow()}
|
||||
>
|
||||
Add ENV variable
|
||||
|
||||
+203
-37
@@ -5,6 +5,7 @@ import {
|
||||
type ActionLogFile,
|
||||
type ActionLogFileInfo,
|
||||
type ActionGroupRunRecord,
|
||||
type ActionEnvVar,
|
||||
type MeResponse,
|
||||
type Node,
|
||||
type NodeActionGroup,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
collectSudoersLines,
|
||||
itemRequiresSudo,
|
||||
} from './actionSudoers'
|
||||
import { NodeActionWidgetsPanel } from './ActionWidgets'
|
||||
import { SECTION_TITLES } from './documentTitle'
|
||||
import {
|
||||
type NodeDetailTabId,
|
||||
@@ -132,6 +134,7 @@ function normalizeItemForCompare(item: NodeActionItem): unknown {
|
||||
requires_sudo: Boolean(item.requires_sudo),
|
||||
set_variable: item.set_variable || '',
|
||||
run_if: item.run_if || '',
|
||||
widget_label: item.widget_label || '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,39 +450,46 @@ function formValuesToIntervalSeconds(
|
||||
|
||||
function NodeDetailOverviewTab({ node }: { node: Node }) {
|
||||
return (
|
||||
<dl className="node-detail-overview">
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>
|
||||
<span
|
||||
className={healthDotClass(node.health_ok)}
|
||||
title={healthDotLabel(node.health_ok)}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
{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>
|
||||
<div className="node-detail-overview-wrap">
|
||||
<dl className="node-detail-overview">
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>
|
||||
<span
|
||||
className={healthDotClass(node.health_ok)}
|
||||
title={healthDotLabel(node.health_ok)}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
{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>
|
||||
|
||||
<section className="node-action-widgets-section" aria-label="Action widgets">
|
||||
<h2 className="node-action-widgets-heading">Action widgets</h2>
|
||||
<NodeActionWidgetsPanel nodeId={node.id} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1038,6 +1048,8 @@ function ActionGroupEditor({
|
||||
const [editRequiresSudo, setEditRequiresSudo] = useState(false)
|
||||
const [editSetVariable, setEditSetVariable] = useState('')
|
||||
const [editRunIf, setEditRunIf] = useState('')
|
||||
const [editWidgetLabel, setEditWidgetLabel] = useState('')
|
||||
const [editEnv, setEditEnv] = useState<ActionEnvVar[]>([])
|
||||
const [resolvedPaths, setResolvedPaths] = useState<Record<string, string>>({})
|
||||
const [resolveMissing, setResolveMissing] = useState<string[]>([])
|
||||
const [resolveBusy, setResolveBusy] = useState(false)
|
||||
@@ -1221,8 +1233,10 @@ function ActionGroupEditor({
|
||||
name: draftItem.name,
|
||||
body: draftItem.body,
|
||||
requires_sudo: Boolean(draftItem.requires_sudo),
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable || '',
|
||||
run_if: draftItem.run_if || '',
|
||||
widget_label: draftItem.widget_label || '',
|
||||
},
|
||||
)
|
||||
latest = patched.group
|
||||
@@ -1241,12 +1255,16 @@ function ActionGroupEditor({
|
||||
name: draftItem.name,
|
||||
body: draftItem.body,
|
||||
requires_sudo: Boolean(draftItem.requires_sudo),
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable || '',
|
||||
run_if: draftItem.run_if || '',
|
||||
widget_label: draftItem.widget_label || '',
|
||||
}
|
||||
: {
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable || '',
|
||||
run_if: draftItem.run_if || '',
|
||||
widget_label: draftItem.widget_label || '',
|
||||
}
|
||||
const patched = await patchActionGroupItem(
|
||||
nodeId,
|
||||
@@ -1268,8 +1286,10 @@ function ActionGroupEditor({
|
||||
? await createActionGroupItem(nodeId, group.id, {
|
||||
source: 'library',
|
||||
library_action_id: draftItem.library_action_id,
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable,
|
||||
run_if: draftItem.run_if,
|
||||
widget_label: draftItem.widget_label,
|
||||
})
|
||||
: await createActionGroupItem(nodeId, group.id, {
|
||||
source: 'local',
|
||||
@@ -1281,6 +1301,7 @@ function ActionGroupEditor({
|
||||
requires_sudo: Boolean(draftItem.requires_sudo),
|
||||
set_variable: draftItem.set_variable,
|
||||
run_if: draftItem.run_if,
|
||||
widget_label: draftItem.widget_label,
|
||||
})
|
||||
latest = created.group
|
||||
const createdItem = latest.items.find(
|
||||
@@ -1290,15 +1311,20 @@ function ActionGroupEditor({
|
||||
idMap.set(draftItem.id, createdItem.id)
|
||||
if (
|
||||
draftItem.source === 'library' &&
|
||||
(draftItem.set_variable || draftItem.run_if)
|
||||
(draftItem.set_variable ||
|
||||
draftItem.run_if ||
|
||||
draftItem.widget_label ||
|
||||
(draftItem.env && draftItem.env.length > 0))
|
||||
) {
|
||||
const patched = await patchActionGroupItem(
|
||||
nodeId,
|
||||
group.id,
|
||||
createdItem.id,
|
||||
{
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable || '',
|
||||
run_if: draftItem.run_if || '',
|
||||
widget_label: draftItem.widget_label || '',
|
||||
},
|
||||
)
|
||||
latest = patched.group
|
||||
@@ -1353,12 +1379,19 @@ function ActionGroupEditor({
|
||||
if (!libraryPick) {
|
||||
return
|
||||
}
|
||||
const libraryAction = libraryById.get(libraryPick)
|
||||
setItemsDraft((previous) => [
|
||||
...previous,
|
||||
{
|
||||
id: newDraftItemId(),
|
||||
source: 'library',
|
||||
library_action_id: libraryPick,
|
||||
env: libraryAction
|
||||
? libraryAction.env.map((envVar) => ({ ...envVar }))
|
||||
: [],
|
||||
widget_label: libraryAction?.show_widget
|
||||
? libraryAction.name
|
||||
: undefined,
|
||||
},
|
||||
])
|
||||
setLibraryPick('')
|
||||
@@ -1458,6 +1491,7 @@ function ActionGroupEditor({
|
||||
requires_sudo: libraryAction.requires_sudo || undefined,
|
||||
set_variable: entry.set_variable,
|
||||
run_if: entry.run_if,
|
||||
widget_label: entry.widget_label,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
@@ -1465,6 +1499,12 @@ function ActionGroupEditor({
|
||||
}
|
||||
|
||||
function handleApplyItemEdit(item: NodeActionItem) {
|
||||
const normalizedEnv = editEnv
|
||||
.map((row) => ({
|
||||
name: row.name.trim(),
|
||||
value: row.value,
|
||||
}))
|
||||
.filter((row) => row.name !== '' || row.value.trim() !== '')
|
||||
setItemsDraft((previous) =>
|
||||
previous.map((entry) => {
|
||||
if (entry.id !== item.id) {
|
||||
@@ -1475,21 +1515,33 @@ function ActionGroupEditor({
|
||||
...entry,
|
||||
name: editName,
|
||||
body: editBody,
|
||||
env: normalizedEnv,
|
||||
requires_sudo: editRequiresSudo || undefined,
|
||||
set_variable: editSetVariable.trim() || undefined,
|
||||
run_if: editRunIf.trim() || undefined,
|
||||
widget_label: editWidgetLabel.trim() || undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
env: normalizedEnv,
|
||||
set_variable: editSetVariable.trim() || undefined,
|
||||
run_if: editRunIf.trim() || undefined,
|
||||
widget_label: editWidgetLabel.trim() || undefined,
|
||||
}
|
||||
}),
|
||||
)
|
||||
setEditingItemId(null)
|
||||
}
|
||||
|
||||
function libraryShowsWidget(item: NodeActionItem): boolean {
|
||||
if (item.source !== 'library') {
|
||||
return false
|
||||
}
|
||||
const libraryAction = libraryById.get(item.library_action_id || '')
|
||||
return Boolean(libraryAction?.show_widget)
|
||||
}
|
||||
|
||||
async function handleRunGroup() {
|
||||
if (isDirty) {
|
||||
setError('Save or undo changes before running this group.')
|
||||
@@ -1790,6 +1842,24 @@ function ActionGroupEditor({
|
||||
setEditRequiresSudo(Boolean(item.requires_sudo))
|
||||
setEditSetVariable(item.set_variable || '')
|
||||
setEditRunIf(item.run_if || '')
|
||||
setEditWidgetLabel(item.widget_label || '')
|
||||
const libraryAction =
|
||||
item.source === 'library'
|
||||
? libraryById.get(item.library_action_id || '')
|
||||
: undefined
|
||||
const baseEnv =
|
||||
item.env && item.env.length > 0
|
||||
? item.env.map((envVar) => ({ ...envVar }))
|
||||
: libraryAction
|
||||
? libraryAction.env.map((envVar) => ({
|
||||
...envVar,
|
||||
}))
|
||||
: []
|
||||
setEditEnv(
|
||||
baseEnv.length > 0
|
||||
? baseEnv
|
||||
: [{ name: '', value: '' }],
|
||||
)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
@@ -1810,6 +1880,7 @@ function ActionGroupEditor({
|
||||
</div>
|
||||
{(item.set_variable ||
|
||||
item.run_if ||
|
||||
item.widget_label ||
|
||||
itemRequiresSudo(item, libraryById)) &&
|
||||
!(isEditing && editingItemId === item.id) ? (
|
||||
<p className="config-hint node-action-item-vars">
|
||||
@@ -1823,7 +1894,11 @@ function ActionGroupEditor({
|
||||
)
|
||||
) : null}
|
||||
{itemRequiresSudo(item, libraryById) &&
|
||||
(item.set_variable || item.run_if)
|
||||
(item.set_variable || item.run_if || item.widget_label)
|
||||
? ' · '
|
||||
: null}
|
||||
{item.widget_label ? `Widget: ${item.widget_label}` : null}
|
||||
{item.widget_label && (item.set_variable || item.run_if)
|
||||
? ' · '
|
||||
: null}
|
||||
{item.set_variable ? `Set $${item.set_variable}` : null}
|
||||
@@ -1864,13 +1939,104 @@ function ActionGroupEditor({
|
||||
</>
|
||||
) : (
|
||||
<p className="config-hint">
|
||||
Library action body is read-only. Clone to customize the
|
||||
command, or edit variables below.
|
||||
Library action body is read-only. Override ENV (e.g. MOUNT)
|
||||
or widget label below, or clone to customize the command.
|
||||
{itemRequiresSudo(item, libraryById)
|
||||
? ' This library action requires sudo.'
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{libraryShowsWidget(item) ? (
|
||||
<label>
|
||||
Widget label
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Overview title"
|
||||
value={editWidgetLabel}
|
||||
onChange={(event) =>
|
||||
setEditWidgetLabel(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{libraryShowsWidget(item) ? (
|
||||
<div className="config-field">
|
||||
<span>ENV overrides</span>
|
||||
<p className="config-hint">
|
||||
For disk widgets, set MOUNT to the path (e.g. / or
|
||||
/data).
|
||||
</p>
|
||||
<div className="actions-env-rows">
|
||||
{editEnv.map((row, index) => (
|
||||
<div
|
||||
key={`item-env-${index}`}
|
||||
className="actions-env-row"
|
||||
>
|
||||
<input
|
||||
className="config-input"
|
||||
aria-label={`ENV name ${index + 1}`}
|
||||
placeholder="NAME"
|
||||
value={row.name}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setEditEnv((previous) =>
|
||||
previous.map((entry, rowIndex) =>
|
||||
rowIndex === index
|
||||
? { ...entry, name: value }
|
||||
: entry,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="config-input"
|
||||
aria-label={`ENV value ${index + 1}`}
|
||||
placeholder="value"
|
||||
value={row.value}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setEditEnv((previous) =>
|
||||
previous.map((entry, rowIndex) =>
|
||||
rowIndex === index
|
||||
? { ...entry, value }
|
||||
: entry,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
onClick={() => {
|
||||
setEditEnv((previous) => {
|
||||
if (previous.length <= 1) {
|
||||
return [{ name: '', value: '' }]
|
||||
}
|
||||
return previous.filter(
|
||||
(_, rowIndex) => rowIndex !== index,
|
||||
)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-new"
|
||||
onClick={() =>
|
||||
setEditEnv((previous) => [
|
||||
...previous,
|
||||
{ name: '', value: '' },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add ENV variable
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<label>
|
||||
Set variable
|
||||
<input
|
||||
|
||||
@@ -904,6 +904,8 @@ export async function fetchAuthLogs(
|
||||
|
||||
export type ActionKind = 'shell' | 'script'
|
||||
|
||||
export type ActionWidgetType = 'memory' | 'disk' | 'raw'
|
||||
|
||||
export type ActionEnvVar = {
|
||||
name: string
|
||||
value: string
|
||||
@@ -917,6 +919,8 @@ export type Action = {
|
||||
body: string
|
||||
env: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo: boolean
|
||||
show_widget: boolean
|
||||
widget_type?: ActionWidgetType | ''
|
||||
builtin: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -933,6 +937,8 @@ export type CreateActionRequest = {
|
||||
body: string
|
||||
env: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
show_widget?: boolean
|
||||
widget_type?: ActionWidgetType | ''
|
||||
}
|
||||
|
||||
export type PatchActionRequest = {
|
||||
@@ -941,6 +947,8 @@ export type PatchActionRequest = {
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
show_widget?: boolean
|
||||
widget_type?: ActionWidgetType | ''
|
||||
}
|
||||
|
||||
export async function fetchActions(
|
||||
@@ -1033,6 +1041,7 @@ export type NodeActionItem = {
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
widget_label?: string
|
||||
}
|
||||
|
||||
export type NodeActionGroup = {
|
||||
@@ -1133,6 +1142,7 @@ export type CreateActionGroupItemRequest = {
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
widget_label?: string
|
||||
}
|
||||
|
||||
export type PatchActionGroupItemRequest = {
|
||||
@@ -1144,6 +1154,7 @@ export type PatchActionGroupItemRequest = {
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
widget_label?: string
|
||||
}
|
||||
|
||||
export async function fetchActionGroups(
|
||||
@@ -1359,3 +1370,58 @@ export async function fetchActionLogFile(
|
||||
}
|
||||
return (await response.json()) as ActionLogFileResponse
|
||||
}
|
||||
|
||||
export type MemoryWidgetData = {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
shared: number
|
||||
buff_cache: number
|
||||
available: number
|
||||
}
|
||||
|
||||
export type DiskWidgetData = {
|
||||
filesystem: string
|
||||
size: string
|
||||
used: string
|
||||
avail: string
|
||||
use_percent: string
|
||||
mounted_on: string
|
||||
size_mib?: number
|
||||
used_mib?: number
|
||||
avail_mib?: number
|
||||
}
|
||||
|
||||
export type ActionWidgetSnapshot = {
|
||||
node_id: string
|
||||
item_id: string
|
||||
group_id: string
|
||||
library_action_id: string
|
||||
widget_type: ActionWidgetType | ''
|
||||
title: string
|
||||
updated_at: string
|
||||
exit_code: number
|
||||
error?: string
|
||||
raw_stdout: string
|
||||
memory?: MemoryWidgetData
|
||||
disk?: DiskWidgetData
|
||||
}
|
||||
|
||||
export type ActionWidgetsResponse = {
|
||||
widgets: ReadonlyArray<ActionWidgetSnapshot>
|
||||
}
|
||||
|
||||
export async function fetchNodeActionWidgets(
|
||||
nodeId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionWidgetsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-widgets`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionWidgetsResponse
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user