@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.0
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 +82 -104
- package/README.zh.md +82 -104
- package/lib/client.js +574 -581
- package/lib/client.js.map +1 -1
- package/lib/index.js +2144 -5
- package/lib/types/client/PluginSettingsCard.d.ts +7 -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/core/controller.d.ts +40 -15
- package/lib/types/core/schedule.d.ts +4 -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 +42 -0
- package/lib/types/host-routes.d.ts +24 -0
- package/lib/types/host-runner.d.ts +30 -0
- package/lib/types/host-service.d.ts +41 -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 +72 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +21 -4
- package/src/client/board/TaskCard.tsx +24 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/host-api.ts +95 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/core/controller.ts +205 -24
- package/src/core/schedule.ts +20 -3
- 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 +478 -0
- package/src/host-routes.ts +188 -0
- package/src/host-runner.ts +134 -0
- package/src/host-service.ts +209 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +213 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { TaskUpdatePatch } from './core/use-cases/task-update.ts';
|
|
2
|
+
import { type NewTaskInput, type TaskRecord, type TaskStatus } from './core/tasks.ts';
|
|
3
|
+
export declare const TASK_BOARD_SCHEMA_VERSION: 2;
|
|
4
|
+
export declare const TASK_BOARD_API_PREFIX = "/api/task-board";
|
|
5
|
+
export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported';
|
|
6
|
+
export interface TaskBoardPowerSnapshot {
|
|
7
|
+
platform: string;
|
|
8
|
+
phase: PowerPhase;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
runningSessions: number;
|
|
11
|
+
armedSchedules: number;
|
|
12
|
+
sessionStateKnown: boolean;
|
|
13
|
+
lastError?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface TaskBoardSchedulerSnapshot {
|
|
16
|
+
timeZone: string;
|
|
17
|
+
/** Opaque identity of the current Host ledger generation. */
|
|
18
|
+
ledgerId?: string;
|
|
19
|
+
lastTickAt?: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface TaskBoardSnapshot {
|
|
23
|
+
schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION;
|
|
24
|
+
revision: number;
|
|
25
|
+
tasks: TaskRecord[];
|
|
26
|
+
scheduler: TaskBoardSchedulerSnapshot;
|
|
27
|
+
power: TaskBoardPowerSnapshot;
|
|
28
|
+
}
|
|
29
|
+
export type TaskBoardAction = {
|
|
30
|
+
kind: 'import';
|
|
31
|
+
sourceId: string;
|
|
32
|
+
tasks: TaskRecord[];
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'create';
|
|
35
|
+
id: string;
|
|
36
|
+
input: NewTaskInput;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'update';
|
|
39
|
+
taskId: string;
|
|
40
|
+
patch: TaskUpdatePatch;
|
|
41
|
+
} | {
|
|
42
|
+
kind: 'delete';
|
|
43
|
+
taskId: string;
|
|
44
|
+
} | {
|
|
45
|
+
kind: 'move';
|
|
46
|
+
taskId: string;
|
|
47
|
+
status: TaskStatus;
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'archive';
|
|
50
|
+
taskId: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: 'restore';
|
|
53
|
+
taskId: string;
|
|
54
|
+
} | {
|
|
55
|
+
kind: 'set-schedule';
|
|
56
|
+
taskId: string;
|
|
57
|
+
patch: {
|
|
58
|
+
enabled?: boolean;
|
|
59
|
+
cron?: string;
|
|
60
|
+
};
|
|
61
|
+
} | {
|
|
62
|
+
kind: 'run';
|
|
63
|
+
taskId: string;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'rerun';
|
|
66
|
+
taskId: string;
|
|
67
|
+
};
|
|
68
|
+
export interface TaskBoardActionEnvelope {
|
|
69
|
+
requestId: string;
|
|
70
|
+
action: TaskBoardAction;
|
|
71
|
+
}
|
|
72
|
+
export declare function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -42,15 +42,18 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
45
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.
|
|
46
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.
|
|
47
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.
|
|
48
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.
|
|
49
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
51
|
-
"@deepseek-ai/dsh-
|
|
45
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
|
|
46
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
47
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
50
|
+
"@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.7",
|
|
51
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
|
|
52
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
53
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.7",
|
|
52
54
|
"@types/react": "~18.3.1",
|
|
53
55
|
"@types/react-dom": "^18.3.5",
|
|
56
|
+
"@types/node": "^22.20.0",
|
|
54
57
|
"jsdom": "^25.0.0",
|
|
55
58
|
"lightningcss": "1.32.0",
|
|
56
59
|
"react": "^18.3.1",
|
|
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
56
56
|
* already provides the selection.
|
|
57
57
|
*/
|
|
58
58
|
alwaysOpen?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Hide the save/discard footer: cards whose body applies its own changes
|
|
61
|
+
* immediately (an embedded external settings section) have no staged
|
|
62
|
+
* edits, so the footer would sit there permanently disabled.
|
|
63
|
+
*/
|
|
64
|
+
hideFooter?: boolean
|
|
59
65
|
/** The plugin's controls. */
|
|
60
66
|
children: ReactNode
|
|
61
67
|
}
|
|
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
140
146
|
<div className={css.body}>
|
|
141
147
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
142
148
|
{props.children}
|
|
149
|
+
{props.hideFooter === true
|
|
150
|
+
? null
|
|
151
|
+
: (
|
|
143
152
|
<div className={css.footer}>
|
|
144
153
|
{state.failed
|
|
145
154
|
? (
|
|
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
165
174
|
{props.t(!state.saving ? 'settings.save' : 'settings.saving')}
|
|
166
175
|
</button>
|
|
167
176
|
</div>
|
|
177
|
+
)}
|
|
168
178
|
</div>
|
|
169
179
|
)
|
|
170
180
|
: null}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* plugin-configuration section renders, bound to the `task-board`
|
|
5
|
-
* namespace.
|
|
2
|
+
* Task-board settings for availability, agent announcement, and optional Host
|
|
3
|
+
* idle-sleep protection. Registers into the `settings.plugin.item` slot the
|
|
4
|
+
* plugin-configuration section renders, bound to the `task-board` namespace.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
9
8
|
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import type { TaskBoardPowerSnapshot } from '../protocol.ts'
|
|
10
11
|
import { PluginSettingsCard, BooleanField } from './PluginSettingsCard.tsx'
|
|
11
12
|
import { CardForm, booleanField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
12
13
|
|
|
@@ -16,6 +17,8 @@ export interface TaskBoardSettings {
|
|
|
16
17
|
enabled?: boolean
|
|
17
18
|
/** Whether the board announces itself in every agent's system prompt. */
|
|
18
19
|
announceToAgent?: boolean
|
|
20
|
+
/** Prevent host idle sleep while sessions run or schedules are armed. */
|
|
21
|
+
preventIdleSleep?: boolean
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/** What the task-board card renders. */
|
|
@@ -24,6 +27,8 @@ export interface TaskBoardSettingsCardState extends CardShell {
|
|
|
24
27
|
enabled: CardFieldState
|
|
25
28
|
/** System-prompt announcement flag. */
|
|
26
29
|
announceToAgent: CardFieldState
|
|
30
|
+
/** Idle-system-sleep protection flag. */
|
|
31
|
+
preventIdleSleep: CardFieldState
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** The registration-side face the card's slot entry injects. */
|
|
@@ -44,6 +49,7 @@ export class TaskBoardSettingsCardController {
|
|
|
44
49
|
this.form = new CardForm(scope, [
|
|
45
50
|
booleanField('enabled'),
|
|
46
51
|
booleanField('announceToAgent'),
|
|
52
|
+
booleanField('preventIdleSleep'),
|
|
47
53
|
])
|
|
48
54
|
this.store = this.form.bind(() => this.projection())
|
|
49
55
|
}
|
|
@@ -53,6 +59,7 @@ export class TaskBoardSettingsCardController {
|
|
|
53
59
|
...this.form.shell(),
|
|
54
60
|
enabled: this.form.field('enabled'),
|
|
55
61
|
announceToAgent: this.form.field('announceToAgent'),
|
|
62
|
+
preventIdleSleep: this.form.field('preventIdleSleep'),
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
|
|
@@ -88,6 +95,23 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
|
|
|
88
95
|
const { t } = props
|
|
89
96
|
const state = props.useTaskBoardSettingsCard(snapshot => snapshot)
|
|
90
97
|
const disabled = !state.writable
|
|
98
|
+
const [power, setPower] = useState<TaskBoardPowerSnapshot | undefined>()
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
let live = true
|
|
101
|
+
const refresh = async (): Promise<void> => {
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch('/api/task-board/state', { cache: 'no-store', credentials: 'same-origin' })
|
|
104
|
+
if (!response.ok) return
|
|
105
|
+
const snapshot = await response.json() as { power?: TaskBoardPowerSnapshot }
|
|
106
|
+
if (live) setPower(snapshot.power)
|
|
107
|
+
} catch {
|
|
108
|
+
// The settings form remains usable while the host status is reconnecting.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
void refresh()
|
|
112
|
+
const timer = setInterval(() => { void refresh() }, 5_000)
|
|
113
|
+
return () => { live = false; clearInterval(timer) }
|
|
114
|
+
}, [])
|
|
91
115
|
const fieldProps = {
|
|
92
116
|
overriddenLabel: t('settings.overridden'),
|
|
93
117
|
resetLabel: t('settings.reset'),
|
|
@@ -99,6 +123,7 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
|
|
|
99
123
|
t={t}
|
|
100
124
|
titleKey="settings.title"
|
|
101
125
|
descriptionKey="settings.description"
|
|
126
|
+
defaultOpen={false}
|
|
102
127
|
state={state}
|
|
103
128
|
onSave={props.save}
|
|
104
129
|
onDiscard={props.discard}
|
|
@@ -127,6 +152,28 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
|
|
|
127
152
|
onEdit={(text) => { props.edit('announceToAgent', text) }}
|
|
128
153
|
onReset={() => { props.resetField('announceToAgent') }}
|
|
129
154
|
/>
|
|
155
|
+
<BooleanField
|
|
156
|
+
id="settings-task-board-prevent-idle-sleep"
|
|
157
|
+
label={t('settings.preventIdleSleep')}
|
|
158
|
+
hint={t('settings.preventIdleSleepHint')}
|
|
159
|
+
inheritLabel={t('settings.inherit')}
|
|
160
|
+
onLabel={t('settings.on')}
|
|
161
|
+
offLabel={t('settings.off')}
|
|
162
|
+
{...fieldProps}
|
|
163
|
+
{...state.preventIdleSleep}
|
|
164
|
+
onEdit={(text) => { props.edit('preventIdleSleep', text) }}
|
|
165
|
+
onReset={() => { props.resetField('preventIdleSleep') }}
|
|
166
|
+
/>
|
|
167
|
+
<p>
|
|
168
|
+
{t('settings.powerStatus', {
|
|
169
|
+
platform: power?.platform ?? t('settings.powerUnknown'),
|
|
170
|
+
phase: power?.phase ?? t('settings.powerUnknown'),
|
|
171
|
+
running: String(power?.runningSessions ?? 0),
|
|
172
|
+
schedules: String(power?.armedSchedules ?? 0),
|
|
173
|
+
})}
|
|
174
|
+
</p>
|
|
175
|
+
<p>{t('settings.powerBoundary')}</p>
|
|
176
|
+
{power?.lastError !== undefined && <p>{t('settings.powerError', { error: power.lastError })}</p>}
|
|
130
177
|
</PluginSettingsCard>
|
|
131
178
|
)
|
|
132
179
|
}
|
|
@@ -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. */
|
|
@@ -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,6 +96,15 @@ 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
110
|
<section className={css.column} data-status="archived">
|
|
@@ -97,7 +114,7 @@ export function TaskBoard({ controller }: { controller: BoardController }) {
|
|
|
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>
|
|
@@ -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,40 @@ 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-pending={pending || undefined}
|
|
36
51
|
onClick={onClick}
|
|
37
52
|
title={task.description !== '' ? task.description : task.title}
|
|
38
53
|
>
|
|
@@ -40,27 +55,28 @@ function TaskCardInner({ task, onClick }: { task: TaskRecord; onClick: () => voi
|
|
|
40
55
|
{task.description !== '' && <span className={css.cardExcerpt}>{task.description}</span>}
|
|
41
56
|
<span className={css.cardMeta}>
|
|
42
57
|
<span className={css.cardTime}>{t('board.updated')} {formatTime(task.updatedAt)}</span>
|
|
43
|
-
{task.schedule?.enabled === true && (
|
|
58
|
+
{!archived && task.schedule?.enabled === true && (
|
|
44
59
|
<span
|
|
45
60
|
className={css.cardSchedule}
|
|
46
61
|
title={task.schedule.nextRunAt !== undefined
|
|
47
|
-
? `${t('card.scheduled')} · ${
|
|
62
|
+
? `${t('card.scheduled')} · ${formatHostTimestamp(task.schedule.nextRunAt, timeZone)}`
|
|
48
63
|
: t('card.scheduled')}
|
|
49
64
|
>
|
|
50
65
|
{t('card.scheduled')}
|
|
51
66
|
</span>
|
|
52
67
|
)}
|
|
53
68
|
{latest !== undefined && (
|
|
54
|
-
<span className={css.cardRun} data-result={latest.result}>
|
|
69
|
+
<span className={css.cardRun} data-result={archived ? undefined : latest.result}>
|
|
55
70
|
{runs} {t('board.runs')}
|
|
56
71
|
</span>
|
|
57
72
|
)}
|
|
58
73
|
{latest?.sessionId !== undefined && (
|
|
59
74
|
<span className={css.cardSession} title={latest.sessionId}>⌁</span>
|
|
60
75
|
)}
|
|
61
|
-
{task.status === 'running' && <span className={css.cardSpinner} aria-hidden="true" />}
|
|
76
|
+
{!archived && (task.status === 'running' || pending) && <span className={css.cardSpinner} aria-hidden="true" />}
|
|
62
77
|
</span>
|
|
63
|
-
{
|
|
78
|
+
{!archived && pending && <span className={css.cardRunningLabel}>{t('board.pending')}…</span>}
|
|
79
|
+
{!archived && latest !== undefined && executionLabel(latest) === 'running' && (
|
|
64
80
|
<span className={css.cardRunningLabel}>{t('detail.result.running')}…</span>
|
|
65
81
|
)}
|
|
66
82
|
</button>
|