mirror of
https://github.com/hansjone/dsh-ops-cron.git
synced 2026-10-11 12:53:22 +08:00
Isolate scheduled tasks by empNo, aligned with uds-auth workspaces.
Super/fallback admins see all jobs grouped by user folder; admin/user only see their own. Stamp ownership on create, filter HTTP/tools, and prefer the owner's provisioned cwd on fire. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0ffc48b43a
commit
7d27179e51
12 changed files with 785 additions and 141 deletions
219
lib/client.js
219
lib/client.js
|
|
@ -191,6 +191,7 @@ window.__ModuleLoader__.load({
|
|||
catalog: { groups: [], current: null },
|
||||
presets: { items: [], current: null },
|
||||
imCatalog: { available: true, options: [], loading: false },
|
||||
viewer: null,
|
||||
}
|
||||
|
||||
function jobStamp(rows) {
|
||||
|
|
@ -332,7 +333,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
newJob: '新建任务', name: '名称', prompt: '提示词', kind: '日程',
|
||||
cron: 'Cron(循环)', at: '一次性时间', expr: 'Cron 表达式', atTime: '时间',
|
||||
create: '创建', pause: '暂停', resume: '恢复', runNow: '立即运行', remove: '删除',
|
||||
emptyJobs: '还没有定时任务。点右上角 + 新建。', next: '下次', last: '上次',
|
||||
emptyJobs: '还没有定时任务。点右上角 + 新建。', unassignedOwner: '未归属', ownerClaim: '改派归属', next: '下次', last: '上次',
|
||||
expandRuns: '展开记录', collapseRuns: '收起记录',
|
||||
search: '搜索', searchPlaceholder: '搜索任务', searchClear: '清除搜索', searchEmpty: '没有匹配的任务。',
|
||||
paused: '已暂停',
|
||||
|
|
@ -373,7 +374,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
newJob: 'New job', name: 'Name', prompt: 'Prompt', kind: 'Schedule',
|
||||
cron: 'Cron (recurring)', at: 'One-shot time', expr: 'Cron expression', atTime: 'Time',
|
||||
create: 'Create', pause: 'Pause', resume: 'Resume', runNow: 'Run now', remove: 'Delete',
|
||||
emptyJobs: 'No jobs yet. Use + to create one.', next: 'Next', last: 'Last',
|
||||
emptyJobs: 'No jobs yet. Use + to create one.', unassignedOwner: 'Unassigned', ownerClaim: 'Reassign owner', next: 'Next', last: 'Last',
|
||||
expandRuns: 'Show runs', collapseRuns: 'Hide runs',
|
||||
search: 'Search', searchPlaceholder: 'Search jobs', searchClear: 'Clear search', searchEmpty: 'No matching jobs.',
|
||||
paused: 'Paused',
|
||||
|
|
@ -696,12 +697,13 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
return ''
|
||||
}
|
||||
|
||||
function JobList({ t, jobs, runs, selection, expanded, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) {
|
||||
function JobList({ t, jobs, runs, selection, expanded, viewer, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) {
|
||||
const skin = workspaceSkin()
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchOn, setSearchOn] = useState(false)
|
||||
const searchRef = useRef(null)
|
||||
const needle = query.trim().toLowerCase()
|
||||
const canViewAll = !!viewer?.canViewAll
|
||||
const visibleJobs = !needle ? jobs : jobs.filter((job) => {
|
||||
const hay = [
|
||||
job.name,
|
||||
|
|
@ -710,10 +712,140 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
job.schedule?.at,
|
||||
job.provider,
|
||||
job.model,
|
||||
job.ownerEmpNo,
|
||||
job.ownerDisplayName,
|
||||
...(runs.filter((run) => run.jobId === job.id).map((run) => run.summary || run.status)),
|
||||
].join(' ').toLowerCase()
|
||||
return hay.includes(needle)
|
||||
})
|
||||
|
||||
function ownerKey(job) {
|
||||
return String(job?.ownerEmpNo || '__unassigned__')
|
||||
}
|
||||
|
||||
function ownerLabel(job) {
|
||||
const key = ownerKey(job)
|
||||
if (key === '__unassigned__') return t('unassignedOwner')
|
||||
const name = String(job?.ownerDisplayName || '').trim()
|
||||
return name && name !== key ? `${key} · ${name}` : key
|
||||
}
|
||||
|
||||
function renderJobGroup(job) {
|
||||
const open = expanded[job.id] === true || !!needle
|
||||
const paused = job.enabled === false
|
||||
const jobRuns = runs.filter((run) => run.jobId === job.id)
|
||||
const selectedJob = selection?.type === 'job' && selection.jobId === job.id
|
||||
const containsCurrent = selectedJob || (selection?.type === 'run' && selection.jobId === job.id)
|
||||
return h('div', { className: skin.group, key: job.id },
|
||||
h('div', {
|
||||
className: skin.project,
|
||||
role: 'treeitem',
|
||||
'aria-expanded': open,
|
||||
'data-on': selectedJob ? 'true' : 'false',
|
||||
'data-paused': paused ? 'true' : 'false',
|
||||
onClick: () => onSelectJob(job.id),
|
||||
},
|
||||
h('span', {
|
||||
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
|
||||
'data-on': open && containsCurrent ? 'true' : 'false',
|
||||
}, folderIcon(open)),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
|
||||
title: open ? t('collapseRuns') : t('expandRuns'),
|
||||
onClick: (e) => { e.stopPropagation(); onToggleGroup(job.id) },
|
||||
},
|
||||
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.rowActs, onClick: (e) => e.stopPropagation() },
|
||||
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
|
||||
playIcon() || '▶'),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: skin.rowIcon,
|
||||
title: paused ? t('resume') : t('pause'),
|
||||
onClick: () => onToggle(job),
|
||||
}, pauseIcon() || (paused ? '▶' : '⏸')),
|
||||
h('button', { type: 'button', className: skin.rowIcon, title: t('remove'), onClick: () => onRemove(job.id) },
|
||||
trashIcon() || '×'),
|
||||
),
|
||||
),
|
||||
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
|
||||
h('div', { className: 'dsh-ct-runsInner' },
|
||||
jobRuns.map((run) => {
|
||||
const selectedRun = selection?.type === 'run' && selection.runId === run.id
|
||||
return h('div', {
|
||||
key: run.id,
|
||||
className: skin.session,
|
||||
role: 'treeitem',
|
||||
'data-on': selectedRun ? 'true' : 'false',
|
||||
onClick: () => onSelectRun(job.id, run),
|
||||
},
|
||||
h('span', { className: skin.slot }, sessionIcon() || '·'),
|
||||
h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status),
|
||||
h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function renderGrouped() {
|
||||
const groups = new Map()
|
||||
for (const job of visibleJobs) {
|
||||
const key = ownerKey(job)
|
||||
if (!groups.has(key)) groups.set(key, { key, label: ownerLabel(job), jobs: [] })
|
||||
groups.get(key).jobs.push(job)
|
||||
}
|
||||
const ordered = [...groups.values()].sort((a, b) => {
|
||||
if (a.key === '__unassigned__') return 1
|
||||
if (b.key === '__unassigned__') return -1
|
||||
return String(a.label).localeCompare(String(b.label), 'zh')
|
||||
})
|
||||
return ordered.map((group) => {
|
||||
const folderId = `owner:${group.key}`
|
||||
const open = expanded[folderId] !== false || !!needle
|
||||
const containsCurrent = group.jobs.some((job) => (
|
||||
(selection?.type === 'job' && selection.jobId === job.id)
|
||||
|| (selection?.type === 'run' && selection.jobId === job.id)
|
||||
))
|
||||
return h('div', { className: skin.group, key: folderId },
|
||||
h('div', {
|
||||
className: skin.project,
|
||||
role: 'treeitem',
|
||||
'aria-expanded': open,
|
||||
'data-on': containsCurrent ? 'true' : 'false',
|
||||
onClick: () => onToggleGroup(folderId),
|
||||
},
|
||||
h('span', {
|
||||
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
|
||||
'data-on': open && containsCurrent ? 'true' : 'false',
|
||||
}, folderIcon(open)),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
|
||||
title: open ? t('collapseRuns') : t('expandRuns'),
|
||||
onClick: (e) => { e.stopPropagation(); onToggleGroup(folderId) },
|
||||
},
|
||||
h('span', { className: joinClass(skin.arrow, open ? skin.arrowOpen : '') },
|
||||
chevronIcon() || '▸')),
|
||||
h('span', { className: skin.projectText },
|
||||
h('span', { className: skin.title }, `${group.label} · ${group.jobs.length}`)),
|
||||
),
|
||||
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
|
||||
h('div', { className: 'dsh-ct-runsInner' },
|
||||
group.jobs.map((job) => renderJobGroup(job)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return h(React.Fragment, null,
|
||||
h('div', { className: skin.sectionHeader },
|
||||
h('span', { className: joinClass(skin.sectionLabel, searchOn ? skin.sectionLabelHidden : '') }, t('title')),
|
||||
|
|
@ -768,71 +900,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
h('div', { className: skin.list },
|
||||
jobs.length === 0 ? h('p', { className: skin.empty }, t('emptyJobs'))
|
||||
: visibleJobs.length === 0 ? h('p', { className: skin.empty }, t('searchEmpty'))
|
||||
: visibleJobs.map((job) => {
|
||||
const open = expanded[job.id] === true || !!needle
|
||||
const paused = job.enabled === false
|
||||
const jobRuns = runs.filter((run) => run.jobId === job.id)
|
||||
const selectedJob = selection?.type === 'job' && selection.jobId === job.id
|
||||
const containsCurrent = selectedJob || (selection?.type === 'run' && selection.jobId === job.id)
|
||||
return h('div', { className: skin.group, key: job.id },
|
||||
h('div', {
|
||||
className: skin.project,
|
||||
role: 'treeitem',
|
||||
'aria-expanded': open,
|
||||
'data-on': selectedJob ? 'true' : 'false',
|
||||
'data-paused': paused ? 'true' : 'false',
|
||||
onClick: () => onSelectJob(job.id),
|
||||
},
|
||||
h('span', {
|
||||
className: joinClass(skin.slot, skin.folder, open && containsCurrent ? skin.folderOn : ''),
|
||||
'data-on': open && containsCurrent ? 'true' : 'false',
|
||||
}, folderIcon(open)),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: joinClass(skin.slot, skin.chevron, 'dsh-ct-chevronHit'),
|
||||
title: open ? t('collapseRuns') : t('expandRuns'),
|
||||
onClick: (e) => { e.stopPropagation(); onToggleGroup(job.id) },
|
||||
},
|
||||
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.rowActs, onClick: (e) => e.stopPropagation() },
|
||||
h('button', { type: 'button', className: skin.rowIcon, title: t('runNow'), onClick: () => onRun(job.id) },
|
||||
playIcon() || '▶'),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
className: skin.rowIcon,
|
||||
title: paused ? t('resume') : t('pause'),
|
||||
onClick: () => onToggle(job),
|
||||
}, pauseIcon() || (paused ? '▶' : '⏸')),
|
||||
h('button', { type: 'button', className: skin.rowIcon, title: t('remove'), onClick: () => onRemove(job.id) },
|
||||
trashIcon() || '×'),
|
||||
),
|
||||
),
|
||||
h('div', { className: 'dsh-ct-runs', 'data-open': open ? 'true' : 'false' },
|
||||
h('div', { className: 'dsh-ct-runsInner' },
|
||||
jobRuns.map((run) => {
|
||||
const selectedRun = selection?.type === 'run' && selection.runId === run.id
|
||||
return h('div', {
|
||||
role: 'treeitem',
|
||||
'aria-selected': selectedRun,
|
||||
key: run.id,
|
||||
className: joinClass(skin.session, selectedRun ? skin.sessionOn : ''),
|
||||
'data-on': selectedRun ? 'true' : 'false',
|
||||
onClick: () => onSelectRun(job.id, run),
|
||||
},
|
||||
h('span', { className: skin.slot }),
|
||||
h('span', { className: skin.title }, (run.summary || run.status || '').split('\n')[0] || run.status),
|
||||
h('span', { className: skin.time }, formatTime(run.actualAt || run.scheduledAt, job.schedule?.timezone)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
: (canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -851,9 +919,10 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
return '__custom__'
|
||||
}
|
||||
|
||||
function CwdField({ t, form, setForm, workspaces }) {
|
||||
function CwdField({ t, form, setForm, workspaces, viewer }) {
|
||||
const rows = Array.isArray(workspaces) ? workspaces : []
|
||||
const mode = cwdSelectValue(form.cwd, rows)
|
||||
const allowCustom = !!viewer?.canViewAll
|
||||
return h('label', null, t('cwd'),
|
||||
h('select', {
|
||||
value: mode,
|
||||
|
|
@ -869,9 +938,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
},
|
||||
h('option', { value: '' }, t('cwdRecent')),
|
||||
...rows.map((row) => h('option', { value: row.path, key: row.id || row.path }, `${row.title} — ${row.path}`)),
|
||||
h('option', { value: '__custom__' }, t('cwdCustom')),
|
||||
...(allowCustom ? [h('option', { value: '__custom__' }, t('cwdCustom'))] : []),
|
||||
),
|
||||
mode === '__custom__'
|
||||
allowCustom && mode === '__custom__'
|
||||
? h('input', {
|
||||
placeholder: t('cwdPlaceholder'),
|
||||
value: form.cwd,
|
||||
|
|
@ -1041,7 +1110,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
h('label', null, t('scheduleTz'), h('input', { value: form.timezone, onChange: (e) => setForm({ ...form, timezone: e.target.value }) })),
|
||||
h('label', null, t('timeout'), h('input', { type: 'number', min: 1, max: 240, value: form.timeoutMinutes, onChange: (e) => setForm({ ...form, timeoutMinutes: Number(e.target.value) }) })),
|
||||
),
|
||||
h(CwdField, { t, form, setForm, workspaces }),
|
||||
h(CwdField, { t, form, setForm, workspaces, viewer }),
|
||||
h(ModelField, { t, form, setForm, catalog }),
|
||||
h(PresetField, { t, form, setForm, presets }),
|
||||
h('label', null, t('delivery'),
|
||||
|
|
@ -1072,6 +1141,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
|
||||
function CronApp({ t, faces }) {
|
||||
const [jobs, setJobs] = useState(() => listSnapshot.jobs)
|
||||
const [viewer, setViewer] = useState(() => listSnapshot.viewer)
|
||||
const [runs, setRuns] = useState(() => listSnapshot.runs)
|
||||
const [selection, setSelection] = useState({ type: 'new' })
|
||||
const [expanded, setExpanded] = useState({})
|
||||
|
|
@ -1140,11 +1210,14 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
const nextJobs = jobBody.jobs || []
|
||||
const nextRuns = histBody.runs || []
|
||||
const nextWorkspaces = wsBody.workspaces || []
|
||||
const nextViewer = jobBody.viewer || wsBody.viewer || histBody.viewer || null
|
||||
const nextCatalog = { groups: modelBody.groups || [], current: modelBody.current || null }
|
||||
const nextPresets = { items: presetBody.items || [], current: presetBody.current || null }
|
||||
if (jobStamp(listSnapshot.jobs) !== jobStamp(nextJobs)) {
|
||||
listSnapshot.jobs = nextJobs
|
||||
listSnapshot.viewer = nextViewer
|
||||
setJobs(nextJobs)
|
||||
setViewer(nextViewer)
|
||||
}
|
||||
if (runStamp(listSnapshot.runs) !== runStamp(nextRuns)) {
|
||||
listSnapshot.runs = nextRuns
|
||||
|
|
@ -1348,7 +1421,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba
|
|||
|
||||
return h(React.Fragment, null,
|
||||
h(JobList, {
|
||||
t, jobs, runs, selection, expanded,
|
||||
t, jobs, runs, selection, viewer, expanded,
|
||||
onSelectJob: selectJob,
|
||||
onSelectRun: selectRun,
|
||||
onNew: selectNew,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue