Show cron state/labels in sidebar and push archive history.

Surface lifecycle badges and editable monitor fields in the UI, and POST terminal runs to persist_history archive endpoints on settle/tick.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 15:29:04 +08:00
parent b6934b20a3
commit 2e13432b0a
4 changed files with 500 additions and 13 deletions

View file

@ -232,7 +232,7 @@ window.__ModuleLoader__.load({
}
function jobStamp(rows) {
return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.model}:${row.provider}:${row.delivery?.kind}:${row.delivery?.targetId}`).join('|')
return (rows || []).map((row) => `${row.id}:${row.updatedAt}:${row.nextRunAt}:${row.enabled}:${row.lastStatus}:${row.state}:${row.stuck}:${row.model}:${row.provider}:${row.delivery?.kind}:${row.delivery?.targetId}:${JSON.stringify(row.labels || {})}`).join('|')
}
function runStamp(rows) {
@ -352,6 +352,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
.dsh-ct-cwdHint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.4}
.dsh-ct-check{flex-direction:row!important;align-items:center;gap:8px!important}
.dsh-ct-check input{width:auto;margin:0}
.dsh-ct-state{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;letter-spacing:.02em;padding:1px 6px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);vertical-align:middle;margin-left:6px}
.dsh-ct-state[data-state=running],.dsh-ct-state[data-state=pending]{color:var(--dsw-alias-state-business-primary);border-color:currentColor}
.dsh-ct-state[data-state=succeeded]{color:var(--dsw-alias-state-success-primary, #1a7f37);border-color:currentColor}
.dsh-ct-state[data-state=failed],.dsh-ct-state[data-stuck=true]{color:var(--dsw-alias-state-error-primary);border-color:currentColor}
.dsh-ct-state[data-state=paused]{opacity:.75}
.dsh-ct-labels{color:var(--dsw-alias-label-tertiary);font-size:11px;font-weight:400;margin-left:6px}
.dsh-ct-editorActs{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}
.dsh-ct-primary{appearance:none;font:inherit;border:none;border-radius:8px;padding:8px 14px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);cursor:pointer;font-size:13px}
.dsh-ct-secondary{appearance:none;font:inherit;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 14px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:13px}
@ -377,6 +383,27 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
expandRuns: '展开记录', collapseRuns: '收起记录',
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
paused: '已暂停',
state: '状态',
stateIdle: '空闲',
statePending: '排队',
stateRunning: '运行中',
stateSucceeded: '成功',
stateFailed: '失败',
statePaused: '已暂停',
stateStuck: '疑似卡死',
labels: '标签',
labelsHint: '每行 key=value,或 JSON 对象。用于批量监控与发现。',
labelsPlaceholder: 'role=worker\ntask=theory',
persistHistory: '历史保留',
persistRetain: '保留最近 N 条(跟随全局)',
persistForever: '永久保留',
persistArchive: '归档到远程 endpoint',
archiveEndpoint: '归档 URL',
archiveEndpointHint: 'persist_history=archive 时 POST 终态运行到该 http(s) 地址。',
watch: '监听绑定 (JSON)',
watchHint: '可选。例 {"taskId":"..."} 或 {"labels":{"task":"theory"},"match":"all"}',
progressFile: '进度文件',
progressFileHint: '相对工作目录或绝对路径;任务运行时写入 JSON 快照。',
scheduleTz: '时区', cwd: '工作目录', timeout: '超时(分钟)',
cwdRecent: '归属工作区(默认)', cwdCustom: '自定义路径…',
cwdPlaceholder: '/absolute/path',
@ -423,6 +450,27 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
expandRuns: 'Show runs', collapseRuns: 'Hide runs',
search: 'Search', searchPlaceholder: 'Search jobs', searchClear: 'Clear search', searchEmpty: 'No matching jobs.',
paused: 'Paused',
state: 'State',
stateIdle: 'Idle',
statePending: 'Pending',
stateRunning: 'Running',
stateSucceeded: 'Succeeded',
stateFailed: 'Failed',
statePaused: 'Paused',
stateStuck: 'Stuck',
labels: 'Labels',
labelsHint: 'One key=value per line, or a JSON object. Used for discovery and batch monitoring.',
labelsPlaceholder: 'role=worker\ntask=theory',
persistHistory: 'History retention',
persistRetain: 'Retain last N (global setting)',
persistForever: 'Keep forever',
persistArchive: 'Archive to remote endpoint',
archiveEndpoint: 'Archive URL',
archiveEndpointHint: 'When archive is selected, terminal runs are POSTed to this http(s) URL.',
watch: 'Watch binding (JSON)',
watchHint: 'Optional. e.g. {"taskId":"..."} or {"labels":{"task":"theory"},"match":"all"}',
progressFile: 'Progress file',
progressFileHint: 'Path relative to cwd or absolute; the job writes a JSON snapshot while running.',
scheduleTz: 'Time zone', cwd: 'Working directory', timeout: 'Timeout (minutes)',
cwdRecent: 'Owner workspace (default)', cwdCustom: 'Custom path…',
cwdPlaceholder: '/absolute/path',
@ -705,6 +753,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
imBotId: '',
imTargetId: '',
mirrorToSession: false,
labelsText: '',
persistKind: 'retain',
archiveEndpoint: '',
watchText: '',
progressFile: '',
}
}
@ -726,6 +779,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
imBotId: job.delivery?.botId || '',
imTargetId: job.delivery?.targetId || '',
mirrorToSession: job.mirrorToSession === true,
labelsText: formatLabelsText(job.labels),
persistKind: persistHistoryKind(job),
archiveEndpoint: job.persistHistory?.kind === 'archive' ? (job.persistHistory.endpoint || '') : '',
watchText: job.watch ? JSON.stringify(job.watch, null, 2) : '',
progressFile: job.progress?.channel?.file || '',
}
}
@ -736,6 +794,72 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
return t('deliveryDsh')
}
function jobStateKey(job) {
if (job?.enabled === false) return 'paused'
return String(job?.state || 'idle')
}
function jobStateLabel(job, t) {
const key = jobStateKey(job)
const map = {
idle: t('stateIdle'),
pending: t('statePending'),
running: t('stateRunning'),
succeeded: t('stateSucceeded'),
failed: t('stateFailed'),
paused: t('statePaused'),
}
const base = map[key] || key
return job?.stuck ? `${base} · ${t('stateStuck')}` : base
}
function formatLabelsText(labels) {
if (!labels || typeof labels !== 'object') return ''
return Object.entries(labels)
.filter(([k]) => k)
.map(([k, v]) => `${k}=${v}`)
.join('\n')
}
function parseLabelsText(raw) {
const text = String(raw || '').trim()
if (!text) return {}
if (text.startsWith('{')) {
try {
const parsed = JSON.parse(text)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const out = {}
for (const [k, v] of Object.entries(parsed)) {
if (!k) continue
out[String(k)] = String(v ?? '')
}
return out
}
} catch { /* fall through */ }
}
const out = {}
for (const line of text.split(/[\n,]+/)) {
const part = line.trim()
if (!part) continue
const idx = part.indexOf('=')
if (idx <= 0) continue
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim()
}
return out
}
function persistHistoryKind(job) {
const kind = job?.persistHistory?.kind
if (kind === 'forever' || kind === 'archive') return kind
return 'retain'
}
function labelsInline(job) {
const labels = job?.labels
if (!labels || !Object.keys(labels).length) return ''
return Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(' ')
}
function modelKey(provider, model) {
if (!provider || !model) return ''
return `${provider}::${model}`
@ -771,6 +895,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
job.model,
job.ownerEmpNo,
job.ownerDisplayName,
job.state,
labelsInline(job),
...(runs.filter((run) => run.jobId === job.id).map((run) => run.summary || run.status)),
].join(' ').toLowerCase()
return hay.includes(needle)
@ -799,7 +925,11 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
'data-on': selectedRun ? 'true' : 'false',
onClick: () => onSelectRun(job.id, run),
},
h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status),
h('span', { className: skin.title },
(run.state || run.status || '').toString(),
' · ',
(run.summary || '').split('\n')[0] || '',
),
h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)),
)
})
@ -834,9 +964,18 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
chevronIcon() || '▸')),
h('span', { className: skin.projectText },
h('span', { className: skin.title }, paused
? `${job.name} · ${t('paused')}`
: `${job.name} · ${jobDeliveryLabel(job, t)}${jobModelLabel(job) ? ` · ${jobModelLabel(job)}` : ''}`)),
h('span', { className: skin.title },
job.name,
h('span', {
className: 'dsh-ct-state',
'data-state': jobStateKey(job),
'data-stuck': job.stuck ? 'true' : 'false',
}, jobStateLabel(job, t)),
labelsInline(job)
? h('span', { className: 'dsh-ct-labels' }, labelsInline(job))
: null,
),
),
h('span', { className: skin.rowActs, onClick: (e) => e.stopPropagation() },
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
playIcon() || '▶'),
@ -1158,11 +1297,22 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
)
}
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, workspaces, catalog, presets, imCatalog, viewer, onSave, onRun, onPause, onRemove }) {
function JobEditor({ t, form, setForm, isNew, error, lastOutput, nextRunAt, lastRunAt, jobMeta, workspaces, catalog, presets, imCatalog, viewer, onSave, onRun, onPause, onRemove }) {
return h('div', { className: 'dsh-ct-editor' },
h('h1', null, isNew ? t('newJob') : (form.name || t('title'))),
h('p', { className: 'dsh-ct-editorLead' }, t('editorLead')),
error ? h('p', { className: 'dsh-ct-error' }, error) : null,
!isNew && jobMeta
? h('p', { className: 'dsh-ct-editorLead' },
h('span', {
className: 'dsh-ct-state',
'data-state': jobStateKey(jobMeta),
'data-stuck': jobMeta.stuck ? 'true' : 'false',
}, `${t('state')}: ${jobStateLabel(jobMeta, t)}`),
jobMeta.watch ? ` · watch` : '',
jobMeta.progress?.channel?.file ? ` · progress` : '',
)
: null,
h('label', null, t('name'), h('input', { value: form.name, onChange: (e) => setForm({ ...form, name: e.target.value }) })),
h('label', null, t('prompt'), h('textarea', { value: form.prompt, onChange: (e) => setForm({ ...form, prompt: e.target.value }) })),
h('div', { className: 'dsh-ct-editorRow' },
@ -1191,6 +1341,52 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
h(CwdField, { t, form, setForm, workspaces, viewer }),
h(ModelField, { t, form, setForm, catalog }),
h(PresetField, { t, form, setForm, presets }),
h('label', null, t('labels'),
h('textarea', {
value: form.labelsText || '',
placeholder: t('labelsPlaceholder'),
onChange: (e) => setForm({ ...form, labelsText: e.target.value }),
rows: 3,
}),
),
h('span', { className: 'dsh-ct-cwdHint' }, t('labelsHint')),
h('label', null, t('persistHistory'),
h('select', {
value: form.persistKind || 'retain',
onChange: (e) => setForm({ ...form, persistKind: e.target.value }),
},
h('option', { value: 'retain' }, t('persistRetain')),
h('option', { value: 'forever' }, t('persistForever')),
h('option', { value: 'archive' }, t('persistArchive')),
),
),
form.persistKind === 'archive'
? h(React.Fragment, null,
h('label', null, t('archiveEndpoint'), h('input', {
value: form.archiveEndpoint || '',
placeholder: 'https://example.com/cron-archive',
onChange: (e) => setForm({ ...form, archiveEndpoint: e.target.value }),
})),
h('span', { className: 'dsh-ct-cwdHint' }, t('archiveEndpointHint')),
)
: null,
h('label', null, t('watch'),
h('textarea', {
value: form.watchText || '',
placeholder: '{"labels":{"task":"theory"},"match":"all"}',
onChange: (e) => setForm({ ...form, watchText: e.target.value }),
rows: 3,
}),
),
h('span', { className: 'dsh-ct-cwdHint' }, t('watchHint')),
h('label', null, t('progressFile'),
h('input', {
value: form.progressFile || '',
placeholder: 'progress.json',
onChange: (e) => setForm({ ...form, progressFile: e.target.value }),
}),
),
h('span', { className: 'dsh-ct-cwdHint' }, t('progressFileHint')),
h('label', null, t('delivery'),
h('select', {
value: form.deliveryKind || 'dsh',
@ -1474,6 +1670,28 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
? { kind: 'im', botId: form.imBotId, targetId: form.imTargetId }
: { kind: 'dsh' },
mirrorToSession: form.mirrorToSession === true,
labels: parseLabelsText(form.labelsText),
persistHistory: form.persistKind === 'forever'
? { kind: 'forever' }
: form.persistKind === 'archive'
? { kind: 'archive', endpoint: String(form.archiveEndpoint || '').trim() }
: { kind: 'retain' },
}
const watchRaw = String(form.watchText || '').trim()
if (watchRaw) {
try {
payload.watch = JSON.parse(watchRaw)
} catch {
throw new Error('watch must be valid JSON')
}
} else {
payload.watch = null
}
const progressFile = String(form.progressFile || '').trim()
if (progressFile) {
payload.progress = { channel: { file: progressFile }, metrics: [] }
} else {
payload.progress = null
}
if (selection.type === 'job' && selection.jobId) {
await api(`/jobs/${selection.jobId}`, { method: 'PATCH', body: JSON.stringify(payload) })
@ -1519,6 +1737,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
t, form, setForm, isNew: selection.type === 'new', error, lastOutput,
nextRunAt: selectedJob?.nextRunAt,
lastRunAt: selectedJob?.lastRunAt,
jobMeta: selectedJob || null,
workspaces,
catalog,
presets,