@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +83 -105
- package/README.zh.md +83 -105
- package/lib/client.js +902 -633
- package/lib/client.js.map +1 -1
- package/lib/index.js +2274 -5
- package/lib/types/client/PluginSettingsCard.d.ts +29 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +47 -15
- package/lib/types/core/schedule.d.ts +11 -2
- package/lib/types/core/tasks.d.ts +6 -6
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +52 -0
- package/lib/types/host-routes.d.ts +26 -0
- package/lib/types/host-runner.d.ts +41 -0
- package/lib/types/host-service.d.ts +45 -0
- package/lib/types/index.d.ts +17 -9
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +78 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +24 -7
- package/src/client/board/TaskCard.tsx +25 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +104 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +222 -24
- package/src/core/schedule.ts +77 -13
- package/src/core/store.ts +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +539 -0
- package/src/host-routes.ts +196 -0
- package/src/host-runner.ts +152 -0
- package/src/host-service.ts +226 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +220 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* New-task modal: title + description + the prompt that execution will send.
|
|
3
|
-
* Creates through the
|
|
3
|
+
* Creates through the Host and closes only after the Host confirms it.
|
|
4
4
|
*/
|
|
5
5
|
import { useEffect, useState } from 'react'
|
|
6
6
|
import type { BoardController } from '../../core/controller.ts'
|
|
@@ -22,6 +22,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
22
22
|
const [scheduleCron, setScheduleCron] = useState('')
|
|
23
23
|
const [scheduleError, setScheduleError] = useState<string | undefined>(undefined)
|
|
24
24
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
25
|
+
const [pending, setPending] = useState(false)
|
|
25
26
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
26
27
|
|
|
27
28
|
// The workspace list and preset roster arrive from the runtime after mount;
|
|
@@ -31,7 +32,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
31
32
|
[controller],
|
|
32
33
|
)
|
|
33
34
|
|
|
34
|
-
const submit = (): void => {
|
|
35
|
+
const submit = async (): Promise<void> => {
|
|
35
36
|
if (scheduleEnabled) {
|
|
36
37
|
const cron = scheduleCron.trim()
|
|
37
38
|
if (cron === '' || !isValidCron(cron)) {
|
|
@@ -39,7 +40,8 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
39
40
|
return
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
+
setPending(true)
|
|
44
|
+
const task = await controller.createTaskConfirmed({
|
|
43
45
|
title,
|
|
44
46
|
description,
|
|
45
47
|
prompt,
|
|
@@ -49,7 +51,8 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
49
51
|
schedule: scheduleEnabled ? { enabled: true, cron: scheduleCron.trim() } : undefined,
|
|
50
52
|
})
|
|
51
53
|
if (task === undefined) {
|
|
52
|
-
|
|
54
|
+
setPending(false)
|
|
55
|
+
setError(controller.getSnapshot().transportError ?? t('new.required'))
|
|
53
56
|
return
|
|
54
57
|
}
|
|
55
58
|
onClose()
|
|
@@ -66,7 +69,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
66
69
|
className={css.modal}
|
|
67
70
|
role="dialog"
|
|
68
71
|
aria-label={t('board.new')}
|
|
69
|
-
onSubmit={event => { event.preventDefault(); submit() }}
|
|
72
|
+
onSubmit={event => { event.preventDefault(); void submit() }}
|
|
70
73
|
>
|
|
71
74
|
<h2 className={css.modalTitle}>{t('board.new')}</h2>
|
|
72
75
|
|
|
@@ -205,7 +208,7 @@ export function NewTaskModal({ controller, onClose }: { controller: BoardControl
|
|
|
205
208
|
<button type="button" className={css.ghostButton} onClick={onClose}>
|
|
206
209
|
{t('new.cancel')}
|
|
207
210
|
</button>
|
|
208
|
-
<button type="submit" className={css.primaryButton}>
|
|
211
|
+
<button type="submit" className={css.primaryButton} disabled={pending}>
|
|
209
212
|
{t('new.submit')}
|
|
210
213
|
</button>
|
|
211
214
|
</footer>
|
|
@@ -26,9 +26,9 @@ function matchesFilter(task: TaskRecord, filter: string): boolean {
|
|
|
26
26
|
* re-renders only when its own task changes — not when a sibling card status,
|
|
27
27
|
* the filter, or the selection moves.
|
|
28
28
|
*/
|
|
29
|
-
const MemoTaskCard = memo(function MemoTaskCard({ task, onOpen }: { task: TaskRecord; onOpen: (id: string) => void }) {
|
|
29
|
+
const MemoTaskCard = memo(function MemoTaskCard({ task, pending, timeZone, onOpen }: { task: TaskRecord; pending: boolean; timeZone?: string; onOpen: (id: string) => void }) {
|
|
30
30
|
const onClick = useCallback(() => { onOpen(task.id) }, [task.id, onOpen])
|
|
31
|
-
return <TaskCard task={task} onClick={onClick} />
|
|
31
|
+
return <TaskCard task={task} pending={pending} timeZone={timeZone} onClick={onClick} />
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
/** Board component; subscribes to the controller snapshot. */
|
|
@@ -50,7 +50,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
50
50
|
const openTask = useCallback((id: string): void => { controller.openTask(id) }, [controller])
|
|
51
51
|
|
|
52
52
|
return (
|
|
53
|
-
<div className={css.board} data-dsh-taskboard-board="">
|
|
53
|
+
<div className={css.board} data-dsh-taskboard-board="" data-dsh-plugin="task-board">
|
|
54
54
|
<header className={css.boardHeader}>
|
|
55
55
|
<button
|
|
56
56
|
type="button"
|
|
@@ -62,6 +62,14 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
62
62
|
<span>{t('board.close')}</span>
|
|
63
63
|
</button>
|
|
64
64
|
<h2 className={css.boardTitle}>{t('board.title')}</h2>
|
|
65
|
+
{snapshot.host !== undefined && (
|
|
66
|
+
<span className={css.detailMeta}>
|
|
67
|
+
{t('board.hostMeta', {
|
|
68
|
+
revision: String(snapshot.host.revision),
|
|
69
|
+
timeZone: snapshot.host.scheduler.timeZone,
|
|
70
|
+
})}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
65
73
|
<input
|
|
66
74
|
className={css.search}
|
|
67
75
|
type="search"
|
|
@@ -88,16 +96,25 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
88
96
|
</button>
|
|
89
97
|
</header>
|
|
90
98
|
|
|
99
|
+
{snapshot.transportError !== undefined && (
|
|
100
|
+
<div className={css.formError}>
|
|
101
|
+
{t('board.hostError', { error: snapshot.transportError })}{' '}
|
|
102
|
+
<button type="button" className={css.linkButton} onClick={() => { void controller.retryHostSync() }}>
|
|
103
|
+
{t('board.retryHost')}
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
91
108
|
<div className={css.columns}>
|
|
92
109
|
{archiveView ? (
|
|
93
|
-
<section className={css.column} data-status="archived">
|
|
110
|
+
<section className={css.column} data-status="archived" data-dsh-part="column">
|
|
94
111
|
<header className={css.columnHeader}>
|
|
95
112
|
<h3 className={css.columnTitle}>{t('board.archive')}</h3>
|
|
96
113
|
<span className={css.columnCount}>{visible.length}</span>
|
|
97
114
|
</header>
|
|
98
115
|
<div className={css.cards}>
|
|
99
116
|
{visible.map(task => (
|
|
100
|
-
<MemoTaskCard key={task.id} task={task} onOpen={openTask} />
|
|
117
|
+
<MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
|
|
101
118
|
))}
|
|
102
119
|
{visible.length === 0 && <div className={css.columnEmpty}>{t('archive.empty')}</div>}
|
|
103
120
|
</div>
|
|
@@ -106,7 +123,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
106
123
|
COLUMNS.map(column => {
|
|
107
124
|
const tasks = visible.filter(task => task.status === column.status)
|
|
108
125
|
return (
|
|
109
|
-
<section key={column.status} className={css.column} data-status={column.status}>
|
|
126
|
+
<section key={column.status} className={css.column} data-status={column.status} data-dsh-part="column">
|
|
110
127
|
<header className={css.columnHeader}>
|
|
111
128
|
<span className={css.statusDot} data-status={column.status} aria-hidden="true" />
|
|
112
129
|
<h3 className={css.columnTitle}>{t(STATUS_KEY[column.status])}</h3>
|
|
@@ -114,7 +131,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
114
131
|
</header>
|
|
115
132
|
<div className={css.cards}>
|
|
116
133
|
{tasks.map(task => (
|
|
117
|
-
<MemoTaskCard key={task.id} task={task} onOpen={openTask} />
|
|
134
|
+
<MemoTaskCard key={task.id} task={task} pending={snapshot.pendingTaskIds.includes(task.id)} timeZone={snapshot.host?.scheduler.timeZone} onOpen={openTask} />
|
|
118
135
|
))}
|
|
119
136
|
{tasks.length === 0 && <div className={css.columnEmpty}>{t('board.empty')}</div>}
|
|
120
137
|
</div>
|
|
@@ -14,25 +14,41 @@ import { t } from '../locales.ts'
|
|
|
14
14
|
import css from '../board.module.css'
|
|
15
15
|
|
|
16
16
|
/** Compact relative/absolute time label. */
|
|
17
|
-
export function
|
|
17
|
+
export function formatHostTimestamp(ms: number, timeZone?: string): string {
|
|
18
|
+
try {
|
|
19
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
20
|
+
dateStyle: 'medium',
|
|
21
|
+
timeStyle: 'medium',
|
|
22
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
23
|
+
}).format(new Date(ms))
|
|
24
|
+
} catch {
|
|
25
|
+
return new Date(ms).toISOString()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatTime(ms: number, timeZone?: string): string {
|
|
18
30
|
const date = new Date(ms)
|
|
19
31
|
const now = Date.now()
|
|
20
32
|
const minutes = Math.floor((now - ms) / 60000)
|
|
21
33
|
if (minutes < 1) return t('time.justNow')
|
|
22
34
|
if (minutes < 60) return `${minutes}m`
|
|
23
35
|
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h`
|
|
36
|
+
if (timeZone !== undefined) return formatHostTimestamp(ms, timeZone)
|
|
24
37
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
/** One card in a column. */
|
|
28
|
-
function TaskCardInner({ task, onClick }: { task: TaskRecord; onClick: () => void }) {
|
|
41
|
+
function TaskCardInner({ task, pending, timeZone, onClick }: { task: TaskRecord; pending: boolean; timeZone?: string; onClick: () => void }) {
|
|
29
42
|
const latest = task.executions[task.executions.length - 1]
|
|
30
43
|
const runs = task.executions.length
|
|
44
|
+
const archived = task.archivedAt !== undefined
|
|
31
45
|
return (
|
|
32
46
|
<button
|
|
33
47
|
type="button"
|
|
34
48
|
className={css.card}
|
|
35
|
-
data-status={task.status}
|
|
49
|
+
data-status={archived ? 'archived' : task.status}
|
|
50
|
+
data-dsh-part="card"
|
|
51
|
+
data-pending={pending || undefined}
|
|
36
52
|
onClick={onClick}
|
|
37
53
|
title={task.description !== '' ? task.description : task.title}
|
|
38
54
|
>
|
|
@@ -40,27 +56,28 @@ function TaskCardInner({ task, onClick }: { task: TaskRecord; onClick: () => voi
|
|
|
40
56
|
{task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
|
|
41
57
|
<span className={css.cardMeta}>
|
|
42
58
|
<span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
|
|
43
|
-
{task.schedule?.enabled === true && (
|
|
59
|
+
{!archived && task.schedule?.enabled === true && (
|
|
44
60
|
<span
|
|
45
61
|
className={css.cardSchedule}
|
|
46
62
|
title={task.schedule.nextRunAt !== undefined
|
|
47
|
-
? `${t('card.scheduled')} · ${
|
|
63
|
+
? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
|
|
48
64
|
: t('card.scheduled')}
|
|
49
65
|
>
|
|
50
66
|
{t('card.scheduled')}
|
|
51
67
|
</span>
|
|
52
68
|
)}
|
|
53
69
|
{latest !== undefined && (
|
|
54
|
-
<span className={css.cardRun} data-result={latest.result}>
|
|
70
|
+
<span className={css.cardRun} data-result={archived ? undefined : latest.result}>
|
|
55
71
|
{runs} {t('board.runs')}
|
|
56
72
|
</span>
|
|
57
73
|
)}
|
|
58
74
|
{latest?.sessionId !== undefined && (
|
|
59
75
|
<span className={css.cardSession} title={latest.sessionId}>⌁</span>
|
|
60
76
|
)}
|
|
61
|
-
{task.status === 'running' && <span className={css.cardSpinner} aria-hidden="true" />}
|
|
77
|
+
{!archived && (task.status === 'running' || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
|
|
62
78
|
</span>
|
|
63
|
-
{
|
|
79
|
+
{!archived && pending && <span className={css.cardRunningLabel}>{t('board.pending')}…</span>}
|
|
80
|
+
{!archived && latest !== undefined && executionLabel(latest) === 'running' && (
|
|
64
81
|
<span className={css.cardRunningLabel}>{t('detail.result.running')}…</span>
|
|
65
82
|
)}
|
|
66
83
|
</button>
|
|
@@ -12,7 +12,7 @@ import { t, type TaskBoardKey } from '../locales.ts'
|
|
|
12
12
|
import { SCHEDULE_PRESETS } from '../schedule-presets.ts'
|
|
13
13
|
import css from '../board.module.css'
|
|
14
14
|
import { ConfirmDialog } from './ConfirmDialog.tsx'
|
|
15
|
-
import { formatTime } from './TaskCard.tsx'
|
|
15
|
+
import { formatHostTimestamp, formatTime } from './TaskCard.tsx'
|
|
16
16
|
import { STATUS_KEY } from './status-key.ts'
|
|
17
17
|
|
|
18
18
|
/** Execution outcome → locale key. */
|
|
@@ -23,7 +23,7 @@ const RESULT_KEY: Record<NonNullable<ExecutionRecord['result']>, TaskBoardKey> =
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** One execution-history row. */
|
|
26
|
-
function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpen: (sessionId: string) => void }) {
|
|
26
|
+
function ExecutionRow({ execution, timeZone, onOpen }: { execution: ExecutionRecord; timeZone?: string; onOpen: (sessionId: string) => void }) {
|
|
27
27
|
const result = execution.result
|
|
28
28
|
return (
|
|
29
29
|
<li className={css.executionRow} data-result={result}>
|
|
@@ -31,8 +31,8 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
31
31
|
{result === undefined ? t('detail.result.running') : t(RESULT_KEY[result])}
|
|
32
32
|
</span>
|
|
33
33
|
<span className={css.executionTimes}>
|
|
34
|
-
{t('detail.executionStarted')} {formatTime(execution.startedAt)}
|
|
35
|
-
{execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt)}`}
|
|
34
|
+
{t('detail.executionStarted')} {formatTime(execution.startedAt, timeZone)}
|
|
35
|
+
{execution.endedAt !== undefined && ` · ${t('detail.executionEnded')} ${formatTime(execution.endedAt, timeZone)}`}
|
|
36
36
|
</span>
|
|
37
37
|
{execution.sessionId !== undefined && (
|
|
38
38
|
<button
|
|
@@ -52,7 +52,7 @@ function ExecutionRow({ execution, onOpen }: { execution: ExecutionRecord; onOpe
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** The execution-target editor: workspace / mode / permission pickers. */
|
|
55
|
-
function ExecutionSettingsSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
55
|
+
function ExecutionSettingsSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
56
56
|
const [options, setOptions] = useState(controller.getSnapshot().executionOptions)
|
|
57
57
|
useEffect(
|
|
58
58
|
() => controller.subscribe(() => setOptions(controller.getSnapshot().executionOptions)),
|
|
@@ -75,6 +75,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
75
75
|
<select
|
|
76
76
|
className={css.select}
|
|
77
77
|
value={workspaceId}
|
|
78
|
+
disabled={pending}
|
|
78
79
|
onChange={event => { controller.updateTask(task.id, { workspaceId: event.target.value }) }}
|
|
79
80
|
>
|
|
80
81
|
<option value="">{t('exec.workspace.recent')}</option>
|
|
@@ -89,6 +90,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
89
90
|
<select
|
|
90
91
|
className={css.select}
|
|
91
92
|
value={mode}
|
|
93
|
+
disabled={pending}
|
|
92
94
|
onChange={event => { controller.updateTask(task.id, { mode: event.target.value }) }}
|
|
93
95
|
>
|
|
94
96
|
<option value="">{t('exec.mode.default')}</option>
|
|
@@ -107,6 +109,7 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
107
109
|
<select
|
|
108
110
|
className={css.select}
|
|
109
111
|
value={permission}
|
|
112
|
+
disabled={pending}
|
|
110
113
|
onChange={event => { controller.updateTask(task.id, { permission: event.target.value === '' ? undefined : event.target.value as TaskPermission }) }}
|
|
111
114
|
>
|
|
112
115
|
<option value="">{t('exec.permission.default')}</option>
|
|
@@ -120,13 +123,14 @@ function ExecutionSettingsSection({ controller, task }: { controller: BoardContr
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
/** The scheduled-runs editor: enable toggle, cron input + presets, next-run info. */
|
|
123
|
-
function ScheduleSection({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
126
|
+
function ScheduleSection({ controller, task, pending }: { controller: BoardController; task: TaskRecord; pending: boolean }) {
|
|
124
127
|
const schedule = task.schedule
|
|
125
128
|
const [cron, setCron] = useState(schedule?.cron ?? '0 9 * * *')
|
|
126
129
|
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
|
|
127
130
|
const [nextRunAt, setNextRunAt] = useState<number | undefined>(schedule?.nextRunAt)
|
|
128
131
|
const [lastTriggeredAt, setLastTriggeredAt] = useState<number | undefined>(schedule?.lastTriggeredAt)
|
|
129
132
|
const [error, setError] = useState<string | undefined>(undefined)
|
|
133
|
+
const timeZone = controller.getSnapshot().host?.scheduler.timeZone
|
|
130
134
|
|
|
131
135
|
// Keep the editor in sync when the task record changes underneath (the
|
|
132
136
|
// schedule rolls forward as runs trigger).
|
|
@@ -158,8 +162,11 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
158
162
|
return
|
|
159
163
|
}
|
|
160
164
|
setError(undefined)
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
const submitted = controller.setSchedule(task.id, {
|
|
166
|
+
enabled: next,
|
|
167
|
+
...(next && trimmed !== schedule?.cron ? { cron: trimmed } : {}),
|
|
168
|
+
})
|
|
169
|
+
if (submitted && !controller.isHostBacked()) setEnabled(next)
|
|
163
170
|
}
|
|
164
171
|
|
|
165
172
|
const applyPreset = (preset: string): void => {
|
|
@@ -173,8 +180,8 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
173
180
|
? t('detail.schedule.notScheduled')
|
|
174
181
|
: nextRunAt <= Date.now()
|
|
175
182
|
? t('detail.schedule.dueSoon')
|
|
176
|
-
:
|
|
177
|
-
const lastLabel = lastTriggeredAt === undefined ? '—' :
|
|
183
|
+
: formatHostTimestamp(nextRunAt, timeZone)
|
|
184
|
+
const lastLabel = lastTriggeredAt === undefined ? '—' : formatHostTimestamp(lastTriggeredAt, timeZone)
|
|
178
185
|
|
|
179
186
|
return (
|
|
180
187
|
<section className={css.detailSection}>
|
|
@@ -183,6 +190,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
183
190
|
<input
|
|
184
191
|
type="checkbox"
|
|
185
192
|
checked={enabled}
|
|
193
|
+
disabled={pending}
|
|
186
194
|
onChange={event => { toggleEnabled(event.target.checked) }}
|
|
187
195
|
/>
|
|
188
196
|
<span>{t('detail.schedule.enable')}</span>
|
|
@@ -191,6 +199,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
191
199
|
<input
|
|
192
200
|
className={`${css.input} ${css.scheduleInput}${error !== undefined ? ` ${css.scheduleInputInvalid}` : ''}`}
|
|
193
201
|
value={cron}
|
|
202
|
+
disabled={pending}
|
|
194
203
|
placeholder="0 9 * * *"
|
|
195
204
|
spellCheck={false}
|
|
196
205
|
aria-label={t('detail.schedule.cron')}
|
|
@@ -201,6 +210,7 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
201
210
|
<select
|
|
202
211
|
className={css.schedulePreset}
|
|
203
212
|
value=""
|
|
213
|
+
disabled={pending}
|
|
204
214
|
aria-label={t('detail.schedule.presets')}
|
|
205
215
|
onChange={event => { applyPreset(event.target.value) }}
|
|
206
216
|
>
|
|
@@ -222,19 +232,26 @@ function ScheduleSection({ controller, task }: { controller: BoardController; ta
|
|
|
222
232
|
/** Task detail overlay. */
|
|
223
233
|
export function TaskDetail({ controller, task }: { controller: BoardController; task: TaskRecord }) {
|
|
224
234
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
225
|
-
const running = task.status === 'running'
|
|
226
235
|
|
|
227
236
|
// Keep the overlay in sync if the task record changes underneath.
|
|
228
237
|
const [latest, setLatest] = useState(task)
|
|
229
238
|
useEffect(() => { setLatest(task) }, [task])
|
|
230
239
|
const current = latest
|
|
240
|
+
const snapshot = controller.getSnapshot()
|
|
241
|
+
const running = current.status === 'running'
|
|
242
|
+
const archived = current.archivedAt !== undefined
|
|
243
|
+
const pending = snapshot.pendingTaskIds.includes(current.id)
|
|
244
|
+
const transportError = snapshot.transportError
|
|
245
|
+
const timeZone = snapshot.host?.scheduler.timeZone
|
|
231
246
|
|
|
232
247
|
return (
|
|
233
248
|
<div className={css.modalBackdrop} onMouseDown={event => { if (event.target === event.currentTarget) controller.closeTask() }}>
|
|
234
249
|
<div className={css.detail} role="dialog" aria-label={t('detail.title')}>
|
|
235
250
|
<header className={css.detailHeader}>
|
|
236
251
|
<h2 className={css.detailTitle}>{current.title}</h2>
|
|
237
|
-
<span className={css.statusBadge} data-status={current.status}>
|
|
252
|
+
<span className={css.statusBadge} data-status={archived ? 'archived' : current.status}>
|
|
253
|
+
{archived ? t('board.archive') : t(STATUS_KEY[current.status])}
|
|
254
|
+
</span>
|
|
238
255
|
<button
|
|
239
256
|
type="button"
|
|
240
257
|
className={css.iconButton}
|
|
@@ -246,6 +263,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
246
263
|
</header>
|
|
247
264
|
|
|
248
265
|
<div className={css.detailBody}>
|
|
266
|
+
{transportError !== undefined && (
|
|
267
|
+
<div className={css.formError}>
|
|
268
|
+
{t('board.hostError', { error: transportError })}{' '}
|
|
269
|
+
<button type="button" className={css.linkButton} onClick={() => { void controller.retryHostSync() }}>
|
|
270
|
+
{t('board.retryHost')}
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
249
274
|
<section className={css.detailSection}>
|
|
250
275
|
<h4>{t('detail.description')}</h4>
|
|
251
276
|
<p className={css.detailText}>{current.description !== '' ? current.description : '—'}</p>
|
|
@@ -256,9 +281,12 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
256
281
|
<pre className={css.promptBlock}>{current.prompt !== '' ? current.prompt : current.title}</pre>
|
|
257
282
|
</section>
|
|
258
283
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
284
|
+
{!archived && (
|
|
285
|
+
<>
|
|
286
|
+
<ExecutionSettingsSection controller={controller} task={current} pending={pending} />
|
|
287
|
+
<ScheduleSection controller={controller} task={current} pending={pending} />
|
|
288
|
+
</>
|
|
289
|
+
)}
|
|
262
290
|
|
|
263
291
|
<section className={css.detailSection}>
|
|
264
292
|
<h4>{t('detail.execution')}</h4>
|
|
@@ -270,6 +298,7 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
270
298
|
<ExecutionRow
|
|
271
299
|
key={execution.id}
|
|
272
300
|
execution={execution}
|
|
301
|
+
timeZone={timeZone}
|
|
273
302
|
onOpen={sessionId => { controller.openSession(sessionId) }}
|
|
274
303
|
/>
|
|
275
304
|
))}
|
|
@@ -277,45 +306,49 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
277
306
|
)}
|
|
278
307
|
</section>
|
|
279
308
|
|
|
280
|
-
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
309
|
+
{!archived && (
|
|
310
|
+
<section className={css.detailSection}>
|
|
311
|
+
<h4>{t('board.status')}</h4>
|
|
312
|
+
<div className={css.moveRow}>
|
|
313
|
+
{MANUAL_STATUSES.map(status => (
|
|
314
|
+
<button
|
|
315
|
+
key={status}
|
|
316
|
+
type="button"
|
|
317
|
+
className={css.ghostButton}
|
|
318
|
+
disabled={current.status === status || running || pending}
|
|
319
|
+
onClick={() => { controller.moveTask(current.id, status) }}
|
|
320
|
+
>
|
|
321
|
+
{t(`status.move.${status}` as TaskBoardKey)}
|
|
322
|
+
</button>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
</section>
|
|
326
|
+
)}
|
|
296
327
|
</div>
|
|
297
328
|
|
|
298
329
|
<footer className={css.detailFooter}>
|
|
299
|
-
<
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
330
|
+
{!archived && pending && <span className={css.detailMeta}>{t('board.pending')}…</span>}
|
|
331
|
+
{!archived && (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
className={css.primaryButton}
|
|
335
|
+
disabled={running || pending}
|
|
336
|
+
onClick={() => {
|
|
337
|
+
void controller.rerunTask(current.id).then(() => {
|
|
338
|
+
if (controller.getSnapshot().transportError === undefined) controller.closeTask()
|
|
339
|
+
})
|
|
340
|
+
}}
|
|
341
|
+
>
|
|
342
|
+
{current.executions.length === 0 ? t('detail.run') : t('detail.rerun')}
|
|
343
|
+
</button>
|
|
344
|
+
)}
|
|
345
|
+
{archived ? (
|
|
313
346
|
<button
|
|
314
347
|
type="button"
|
|
315
348
|
className={css.primaryButton}
|
|
349
|
+
disabled={pending}
|
|
316
350
|
onClick={() => {
|
|
317
351
|
controller.restoreTask(current.id)
|
|
318
|
-
controller.closeTask()
|
|
319
352
|
}}
|
|
320
353
|
>
|
|
321
354
|
{t('detail.restore')}
|
|
@@ -325,9 +358,9 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
325
358
|
<button
|
|
326
359
|
type="button"
|
|
327
360
|
className={css.ghostButton}
|
|
361
|
+
disabled={pending}
|
|
328
362
|
onClick={() => {
|
|
329
363
|
controller.archiveTask(current.id)
|
|
330
|
-
controller.closeTask()
|
|
331
364
|
}}
|
|
332
365
|
>
|
|
333
366
|
{t('detail.archive')}
|
|
@@ -337,13 +370,14 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
337
370
|
<button
|
|
338
371
|
type="button"
|
|
339
372
|
className={css.dangerButton}
|
|
373
|
+
disabled={pending}
|
|
340
374
|
onClick={() => { setConfirmDelete(true) }}
|
|
341
375
|
>
|
|
342
376
|
{t('detail.delete')}
|
|
343
377
|
</button>
|
|
344
378
|
<span className={css.detailMeta}>
|
|
345
|
-
{t('board.created')} {formatTime(current.createdAt)}
|
|
346
|
-
{
|
|
379
|
+
{t('board.created')} {formatTime(current.createdAt, timeZone)}
|
|
380
|
+
{archived && ` · ${t('detail.archivedAt', { time: formatTime(current.archivedAt!, timeZone) })}`}
|
|
347
381
|
</span>
|
|
348
382
|
</footer>
|
|
349
383
|
</div>
|
|
@@ -358,7 +392,6 @@ export function TaskDetail({ controller, task }: { controller: BoardController;
|
|
|
358
392
|
onConfirm={() => {
|
|
359
393
|
setConfirmDelete(false)
|
|
360
394
|
controller.deleteTask(current.id)
|
|
361
|
-
controller.closeTask()
|
|
362
395
|
}}
|
|
363
396
|
/>
|
|
364
397
|
)}
|
|
@@ -49,6 +49,7 @@ export function mountBoard(controller: BoardController): () => void {
|
|
|
49
49
|
if (column === undefined) return
|
|
50
50
|
container = document.createElement('div')
|
|
51
51
|
container.dataset.dshTaskboardView = ''
|
|
52
|
+
container.dataset.dshPlugin = 'task-board'
|
|
52
53
|
container.className = css.boardView
|
|
53
54
|
column.appendChild(container)
|
|
54
55
|
root = createRoot(container)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { TaskRecord } from '../core/tasks.ts'
|
|
2
|
+
import {
|
|
3
|
+
TASK_BOARD_API_PREFIX,
|
|
4
|
+
type TaskBoardAction,
|
|
5
|
+
type TaskBoardActionEnvelope,
|
|
6
|
+
type TaskBoardEventPayload,
|
|
7
|
+
type TaskBoardSnapshot,
|
|
8
|
+
} from '../protocol.ts'
|
|
9
|
+
|
|
10
|
+
const IMPORT_MARKER = 'dsh.taskBoard.v2.hostImported'
|
|
11
|
+
const SOURCE_KEY = 'dsh.taskBoard.v2.sourceId'
|
|
12
|
+
const IMPORT_REQUEST_KEY = 'dsh.taskBoard.v2.importRequestId'
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
14
|
+
|
|
15
|
+
function uuid(): string {
|
|
16
|
+
return globalThis.crypto?.randomUUID?.() ?? `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readJson<T>(response: Response): Promise<T> {
|
|
20
|
+
const body = await response.json() as T & { error?: string }
|
|
21
|
+
if (!response.ok) throw new Error(body.error ?? `task-board request failed: ${response.status}`)
|
|
22
|
+
return body
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TaskBoardHostTransport {
|
|
26
|
+
bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot>
|
|
27
|
+
state(): Promise<TaskBoardSnapshot>
|
|
28
|
+
action(action: TaskBoardAction): Promise<TaskBoardSnapshot>
|
|
29
|
+
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class HttpTaskBoardHostTransport implements TaskBoardHostTransport {
|
|
33
|
+
constructor(private readonly storage: Pick<Storage, 'getItem' | 'setItem'> | undefined = globalThis.localStorage) {}
|
|
34
|
+
|
|
35
|
+
async bootstrap(legacy: readonly TaskRecord[]): Promise<TaskBoardSnapshot> {
|
|
36
|
+
const initial = await this.state()
|
|
37
|
+
const ledgerId = initial.scheduler.ledgerId
|
|
38
|
+
if (legacy.length > 0 && ledgerId !== undefined && this.storage?.getItem(IMPORT_MARKER) !== ledgerId) {
|
|
39
|
+
let sourceId = this.storage?.getItem(SOURCE_KEY)
|
|
40
|
+
if (sourceId === null || sourceId === undefined || sourceId === '') {
|
|
41
|
+
sourceId = uuid()
|
|
42
|
+
this.storage?.setItem(SOURCE_KEY, sourceId)
|
|
43
|
+
}
|
|
44
|
+
let requestId = this.storage?.getItem(IMPORT_REQUEST_KEY)
|
|
45
|
+
if (requestId === null || requestId === undefined || requestId === '') {
|
|
46
|
+
requestId = uuid()
|
|
47
|
+
this.storage?.setItem(IMPORT_REQUEST_KEY, requestId)
|
|
48
|
+
}
|
|
49
|
+
const snapshot = await this.post(requestId, { kind: 'import', sourceId, tasks: [...legacy] })
|
|
50
|
+
this.storage?.setItem(IMPORT_MARKER, snapshot.scheduler.ledgerId ?? ledgerId)
|
|
51
|
+
return snapshot
|
|
52
|
+
}
|
|
53
|
+
return initial
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async state(): Promise<TaskBoardSnapshot> {
|
|
57
|
+
return await this.request(`${TASK_BOARD_API_PREFIX}/state`, { cache: 'no-store' })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async action(action: TaskBoardAction): Promise<TaskBoardSnapshot> {
|
|
61
|
+
return await this.post(uuid(), action)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private async post(requestId: string, action: TaskBoardAction): Promise<TaskBoardSnapshot> {
|
|
65
|
+
const envelope: TaskBoardActionEnvelope = { requestId, action }
|
|
66
|
+
return await this.request(`${TASK_BOARD_API_PREFIX}/action`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
body: JSON.stringify(envelope),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async request(url: string, init: RequestInit): Promise<TaskBoardSnapshot> {
|
|
74
|
+
const controller = new AbortController()
|
|
75
|
+
const timeout = globalThis.setTimeout(() => { controller.abort() }, REQUEST_TIMEOUT_MS)
|
|
76
|
+
try {
|
|
77
|
+
return await readJson<TaskBoardSnapshot>(await fetch(url, { ...init, signal: controller.signal }))
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (controller.signal.aborted) throw new Error(`task-board Host request timed out after ${REQUEST_TIMEOUT_MS / 1_000}s`)
|
|
80
|
+
throw error
|
|
81
|
+
} finally {
|
|
82
|
+
globalThis.clearTimeout(timeout)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
subscribe(listener: (event?: TaskBoardEventPayload) => void): () => void {
|
|
87
|
+
const events = new EventSource(`${TASK_BOARD_API_PREFIX}/events`)
|
|
88
|
+
events.onmessage = (message: MessageEvent<string>): void => {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(message.data) as TaskBoardEventPayload
|
|
91
|
+
if (parsed === null || typeof parsed !== 'object' || typeof parsed.revision !== 'number') throw new Error('invalid event frame')
|
|
92
|
+
listener(parsed)
|
|
93
|
+
} catch {
|
|
94
|
+
listener()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const onVisible = (): void => { if (document.visibilityState === 'visible') listener() }
|
|
98
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
99
|
+
return () => {
|
|
100
|
+
document.removeEventListener('visibilitychange', onVisible)
|
|
101
|
+
events.close()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|