Files
dsh-superagent/src/client/execution.tsx
T
2026-10-31 20:05:39 +08:00

128 lines
8.0 KiB
TypeScript

/** Execution status view (active Tickets and pause control) and Execution settings group (Executor capacity and workspace environment). */
import { useRef, useState } from 'react'
import { TICKET_FILTERS, type TicketFilter } from '../app/api.ts'
import { LIMITS } from '../limits.ts'
import { usePoll, type SettingsView, type SourceView, type StateView } from './api.ts'
import { DisclosureRow, Icon, InfoTip, Input, Pill } from './controls.tsx'
import { SaveBar } from './draft.tsx'
import { usePanel } from './provider.tsx'
import { TicketLine } from './rows.tsx'
import { ConfigHistory, Field, Fields, ModelField, numberOf, useBlockDraft, useSettings } from './settings-kit.tsx'
import { localized } from './text.ts'
import { ConfirmButton, Hint, LabelValue, LoadMore, LoadState, Section, StatusHead } from './widgets.tsx'
export function ExecutionStatus({ filter: current, state, reload }: {
filter: TicketFilter; state: StateView; reload: () => Promise<void>
}) {
const { t, call, navigate } = usePanel()
const [limit, setLimit] = useState(20)
const { data, error, reload: reloadTickets } = usePoll(() => call('GET tickets', { query: { state: current, limit } }), [current, limit])
const { paused, load: { maxWorkers } } = state
// Pausing uses its own block revision so toggling pause does not invalidate an open Execution settings draft.
const pause = async () => { await call('POST executor/pause', { body: { paused: !paused } }); await reload() }
const status = state.setup.problem ? { tone: 'warning', label: t('executorWaitingSetup') } : paused ? { tone: 'warning',
label: t('processingPaused') }
: { tone: 'ongoing', label: t('executorRunning') }
const action = paused ? 'resumeProcessing' : 'pauseProcessing'
return <>
<StatusHead hint={t('executionStatusIntro')} settings="execution" />
<div className="dt-row-meta dt-status-line">
<span className="dt-status-text" data-state={status.tone}>{status.label}</span>
{!state.setup.problem && <>
<LabelValue label={t('executingLabel')}
value={`${state.load.running} / ${maxWorkers}`} /><LabelValue label={t('queuedLabel')} value={state.load.queued} />
</>}
<ConfirmButton variant="outline" icon={paused ? 'play' : 'pause'} label={t(action)} title={t(action)} message={t(paused
? 'resumeProcessingConfirm' : 'pauseProcessingConfirm')}
onConfirm={pause} />
</div>
<div className="dt-filters">
{TICKET_FILTERS.map(value => <Pill key={value} active={current === value}
onClick={() => { setLimit(20); navigate({ page: 'execution', query: value === 'open' ? {} : { filter: value } },
true) }}>{t(`ticketFilter.${value}`)}</Pill>)}
</div>
{current === 'idle' && <Hint>{t('idleHint')}</Hint>}
<LoadState error={error} loading={!data} />
{data && (data.items.length ? <ul className="dt-list">{data.items.map(ticket => ticket.stage === 'person'
? <TicketLine key={ticket.id} ticket={ticket} question
onChanged={reloadTickets} /> : <TicketLine key={ticket.id} ticket={ticket} />)}</ul> : <Hint>{t('none')}</Hint>)}
{data?.next_offset != null && <LoadMore onMore={() => setLimit(limit + 20)} />}
</>
}
const firstSentence = (text: string): string => /^[^。;;.]*(?:[。;;]|\.(?=\s|$))?/.exec(text)?.[0] ?? text
function EnvironmentVariables({ groups, sources,
onPick }: { groups: SettingsView['environment']['variables']; sources: SourceView[]; onPick: (key: string) => void }) {
const { t } = usePanel()
const [open, setOpen] = useState(false)
const owner = (id: string) => id === 'executor' ? t('term.executor') : localized(t, sources.find(source => source.id === id)?.title ?? id)
const count = groups.reduce((sum, group) => sum + group.variables.length, 0)
return <DisclosureRow icon={<Icon name="hash" />} title={<>{t('environmentVariables')} <span className="dt-count">{count}</span></>}
open={open} onToggle={() => setOpen(!open)}>
<div className="dt-disclosure-body">
<Hint>{t('environmentPick')}</Hint>
<table className="dt-table">
<thead><tr><th>{t('environmentKey')}</th><th>{t('environmentDescription')}</th><th>{t('environmentOwner')}</th></tr></thead>
<tbody>{groups.flatMap(group => group.variables.map(variable => <tr key={variable.key}>
<td><button type="button" className="dt-textlink dt-mono" onClick={() => onPick(variable.key)}>{variable.key}</button></td>
<td title={localized(t, variable.description)}>{firstSentence(localized(t, variable.description))}</td>
<td>{owner(group.owner)}{!group.enabled && <span className="dt-hint"> · {t('sourceOff')}</span>}</td>
</tr>))}</tbody>
</table>
</div>
</DisclosureRow>
}
export function ExecutionSettings() {
const { t } = usePanel()
const { blocks, environment, sources } = useSettings().settings
// Keep raw user text (such as separators between blocked commands) in the draft and parse only on save.
const executor = blocks.executor.value
const saved = { ...executor, maxWorkers: String(executor.maxWorkers), blockedCommands: executor.blockedCommands.join(' ') }
const draft = useBlockDraft('executor', saved, blocks.executor.revision,
value => ({ ...value, maxWorkers: numberOf(value.maxWorkers), blockedCommands: value.blockedCommands.split(/[\s,]+/).filter(Boolean) }))
const value = draft.value
const edit = (next: Partial<typeof value>) => draft.edit({ ...value, ...next })
const prepare = useRef<HTMLTextAreaElement>(null)
const insert = (key: string) => {
const field = prepare.current
const at = field?.selectionStart ?? value.prepare.length, to = field?.selectionEnd ?? at
edit({ prepare: `${value.prepare.slice(0, at)}"$${key}"${value.prepare.slice(to)}` })
requestAnimationFrame(() => { field?.focus(); const caret = at + key.length + 3; field?.setSelectionRange(caret, caret) })
}
return <div className="dt-form">
<form className="dt-form" onSubmit={event => { void draft.submit(event) }}>
<Fields disabled={draft.busy}>
<div className="dt-form dt-block">
<h2 className="dt-block-title">{t('term.executor')}<InfoTip text={t('executorIntro')} /></h2>
<Section title={t('executorModel')} hint={t('executorModelHint')}><ModelField id="dt-worker-model" value={value.model}
onChange={model => edit({ model })} /></Section>
<Field label={t('maxWorkers')} hint={t('maxWorkersHint')}>
<Input type="number" min={LIMITS.maxWorkers.min} max={LIMITS.maxWorkers.max} required value={value.maxWorkers}
onChange={event => edit({ maxWorkers: event.target.value })} />
</Field>
</div>
<div className="dt-form dt-block">
<h2 className="dt-block-title">{t('term.environment')}<InfoTip text={t('environmentIntro')} /></h2>
<Field label={t('executionDirectory')} hint={t('executionDirectoryHint')} note={value.directory ? undefined
: t('executionDirectoryDefault', { root: environment.root })}>
<Input className="dt-mono" value={value.directory} placeholder={t('executionDirectoryEmpty')} spellCheck={false} pattern="/.*"
onChange={event => edit({ directory: event.target.value })} />
</Field>
<Field label={t('prepareCommand')} hint={t('prepareCommandHint')}>
<textarea ref={prepare} className="dt-textarea dt-mono" rows={3} maxLength={LIMITS.prepareCommand} spellCheck={false}
placeholder={t('prepareCommandEmpty')} value={value.prepare}
onChange={event => edit({ prepare: event.target.value })} />
</Field>
<EnvironmentVariables groups={environment.variables} sources={sources} onPick={insert} />
<Field label={t('blockedCommands')} hint={t('blockedCommandsHint')}>
<Input className="dt-mono" value={value.blockedCommands} placeholder={t('blockedCommandsNone')} spellCheck={false}
onChange={event => edit({ blockedCommands: event.target.value })} />
</Field>
</div>
</Fields>
<SaveBar {...draft} onDiscard={draft.discard} />
</form>
<ConfigHistory blocks={['executor', 'pause']} />
</div>
}