feat(dashboard): improve scheduled work UX (#21)

This commit is contained in:
Eyejoker
2026-04-26 23:05:44 +09:00
committed by GitHub
parent 11ab18280c
commit 1e4b97bfe4
3 changed files with 604 additions and 98 deletions

View File

@@ -28,6 +28,8 @@ type UsageRow = DashboardOverview['usage']['rows'][number];
type RiskLevel = 'ok' | 'warn' | 'critical';
type UsageGroup = 'primary' | 'codex';
type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled';
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
type TaskResultTone = 'ok' | 'fail' | 'none';
const REFRESH_INTERVAL_MS = 15_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
@@ -82,6 +84,46 @@ function formatDate(value: string | null | undefined, locale: Locale): string {
}).format(date);
}
function formatTaskDate(
value: string | null | undefined,
locale: Locale,
): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(localeTags[locale], {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function formatRelativeDate(
value: string | null | undefined,
locale: Locale,
t: Messages,
): string {
if (!value) return t.tasks.noTime;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 45_000) return t.tasks.now;
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['day', 86_400_000],
['hour', 3_600_000],
['minute', 60_000],
];
const [unit, unitMs] =
units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!;
return new Intl.RelativeTimeFormat(localeTags[locale], {
numeric: 'auto',
style: 'short',
}).format(Math.round(diffMs / unitMs), unit);
}
function formatPct(value: number): string {
if (value < 0) return '-';
return `${Math.round(value)}%`;
@@ -127,6 +169,52 @@ function queueLabel(
return parts.join(' · ');
}
const SECRET_ASSIGNMENT_RE =
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
const SECRET_VALUE_RE =
/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g;
function safePreview(
value: string | null | undefined,
fallback: string,
): string {
const cleaned = (value ?? '')
.replace(SECRET_ASSIGNMENT_RE, '$1=<redacted>')
.replace(SECRET_VALUE_RE, '<redacted-token>')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return fallback;
return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned;
}
function taskGroupKey(task: DashboardTask): TaskGroupKey {
if (task.status === 'completed') return 'completed';
if (task.status === 'paused') return 'paused';
if (task.isWatcher) return 'watchers';
return 'scheduled';
}
function taskResultTone(task: DashboardTask): TaskResultTone {
if (!task.lastResult) return 'none';
const normalized = task.lastResult.toLowerCase();
if (
normalized.includes('fail') ||
normalized.includes('error') ||
normalized.includes('timeout') ||
normalized.includes('cancel') ||
normalized.includes('reject')
) {
return 'fail';
}
return 'ok';
}
function taskDisplayName(task: DashboardTask, t: Messages): string {
if (task.isWatcher) return t.tasks.ciWatch;
if (task.scheduleType) return task.scheduleType;
return task.id;
}
function navItems(t: Messages) {
return [
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
@@ -691,111 +779,178 @@ function TaskPanel({
locale: Locale;
t: Messages;
}) {
const sortedTasks = useMemo(
() =>
[...tasks].sort((a, b) => {
const statusRank = { active: 0, paused: 1, completed: 2 } as const;
const rankDelta = statusRank[a.status] - statusRank[b.status];
if (rankDelta !== 0) return rankDelta;
return (a.nextRun ?? a.createdAt).localeCompare(
b.nextRun ?? b.createdAt,
);
}),
[tasks],
);
const taskGroups = useMemo(() => {
const groups: Record<TaskGroupKey, DashboardTask[]> = {
watchers: [],
scheduled: [],
paused: [],
completed: [],
};
if (sortedTasks.length === 0) {
for (const task of tasks) {
groups[taskGroupKey(task)].push(task);
}
for (const groupTasks of Object.values(groups)) {
groupTasks.sort((a, b) =>
(a.nextRun ?? a.lastRun ?? a.createdAt).localeCompare(
b.nextRun ?? b.lastRun ?? b.createdAt,
),
);
}
return [
{ key: 'watchers' as const, tasks: groups.watchers },
{ key: 'scheduled' as const, tasks: groups.scheduled },
{ key: 'paused' as const, tasks: groups.paused },
{ key: 'completed' as const, tasks: groups.completed },
];
}, [tasks]);
if (tasks.length === 0) {
return <EmptyState>{t.tasks.empty}</EmptyState>;
}
return (
<>
<div className="table-wrap desktop-table">
<table>
<thead>
<tr>
<th>{t.tasks.task}</th>
<th>{t.tasks.status}</th>
<th>{t.tasks.schedule}</th>
<th>{t.tasks.next}</th>
<th>{t.tasks.last}</th>
</tr>
</thead>
<tbody>
{sortedTasks.map((task) => (
<tr key={task.id}>
<td>
<strong>{task.isWatcher ? t.tasks.ciWatch : task.id}</strong>
<span>{task.promptPreview || t.tasks.emptyPrompt}</span>
<small>
{task.groupFolder} · {task.promptLength} {t.units.chars}
</small>
</td>
<td>
<span className={`pill pill-${task.status}`}>
{statusLabel(task.status, t)}
</span>
{task.suspendedUntil ? (
<small>
{t.tasks.until} {formatDate(task.suspendedUntil, locale)}
</small>
) : null}
</td>
<td>
{task.scheduleType} · {task.scheduleValue}
<small>{task.contextMode}</small>
</td>
<td>{formatDate(task.nextRun, locale)}</td>
<td>
{formatDate(task.lastRun, locale)}
{task.lastResult ? <small>{task.lastResult}</small> : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="task-board" aria-label={t.tasks.cardsAria}>
{taskGroups.map((group) => {
const label = t.tasks.groups[group.key];
const groupHead = (
<div className="task-group-head">
<div>
<span className="eyebrow">{label}</span>
<strong>
{group.tasks.length} {t.tasks.count}
</strong>
</div>
<span className={`pill pill-${group.key}`}>
{group.tasks.length}
</span>
</div>
);
const groupBody =
group.tasks.length === 0 ? (
<div className="task-group-empty">{t.tasks.groupEmpty}</div>
) : (
<div className="task-list">
{group.tasks.map((task) => {
const resultTone = taskResultTone(task);
const lastResult = safePreview(
task.lastResult,
t.tasks.noResult,
);
return (
<article
className={`task-card task-card-${group.key}`}
key={task.id}
>
<div className="task-card-main">
<div className="task-title">
<strong>{taskDisplayName(task, t)}</strong>
<span className="mono-chip">{task.groupFolder}</span>
</div>
<div className="task-status-line">
<span className={`pill pill-${task.status}`}>
{statusLabel(task.status, t)}
</span>
{task.ciProvider ? (
<span className="task-provider">
{task.ciProvider}
</span>
) : null}
</div>
</div>
<div className="mobile-record-list" aria-label={t.tasks.cardsAria}>
{sortedTasks.map((task) => (
<article className="record-card task-card" key={task.id}>
<div className="record-card-head">
<div>
<strong>{task.isWatcher ? t.tasks.ciWatch : task.id}</strong>
<span className="mono-chip">{task.groupFolder}</span>
</div>
<span className={`pill pill-${task.status}`}>
{statusLabel(task.status, t)}
</span>
<div className="task-time-grid">
<span>
<small>{t.tasks.next}</small>
<strong>{formatTaskDate(task.nextRun, locale)}</strong>
<em>{formatRelativeDate(task.nextRun, locale, t)}</em>
</span>
<span>
<small>{t.tasks.last}</small>
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
</span>
<span>
<small>{t.tasks.schedule}</small>
<strong>{task.scheduleType}</strong>
<em>{task.scheduleValue}</em>
</span>
</div>
{task.suspendedUntil ? (
<div className="task-suspended">
<span>{t.tasks.suspendedUntil}</span>
<strong>
{formatTaskDate(task.suspendedUntil, locale)}
</strong>
<em>
{formatRelativeDate(task.suspendedUntil, locale, t)}
</em>
</div>
) : null}
<div className={`task-result result-${resultTone}`}>
<span>
{resultTone === 'fail'
? t.tasks.resultFail
: resultTone === 'ok'
? t.tasks.resultOk
: t.tasks.result}
</span>
<strong>{lastResult}</strong>
</div>
<details className="task-prompt">
<summary>{t.tasks.prompt}</summary>
<p>
{safePreview(task.promptPreview, t.tasks.emptyPrompt)}
</p>
<small>
{task.id} · {task.contextMode} · {task.promptLength}{' '}
{t.units.chars}
</small>
</details>
</article>
);
})}
</div>
<p>{task.promptPreview || t.tasks.emptyPrompt}</p>
<div className="record-card-grid">
<span>
<small>{t.tasks.schedule}</small>
<strong>{task.scheduleType}</strong>
</span>
<span>
<small>{t.tasks.next}</small>
<strong>{formatDate(task.nextRun, locale)}</strong>
</span>
<span>
<small>{t.tasks.last}</small>
<strong>{formatDate(task.lastRun, locale)}</strong>
</span>
<span>
<small>{t.tasks.context}</small>
<strong>{task.contextMode}</strong>
</span>
</div>
{task.lastResult ? (
<p className="record-id">
{t.tasks.lastResult}: {task.lastResult}
</p>
) : null}
</article>
))}
</div>
</>
);
if (group.key === 'completed') {
return (
<details
className="task-group task-group-completed"
key={group.key}
>
<summary className="task-group-head">
<div>
<span className="eyebrow">{label}</span>
<strong>
{group.tasks.length} {t.tasks.count}
</strong>
</div>
<span className={`pill pill-${group.key}`}>
{group.tasks.length}
</span>
</summary>
{groupBody}
</details>
);
}
return (
<section
className={`task-group task-group-${group.key}`}
key={group.key}
>
{groupHead}
{groupBody}
</section>
);
})}
</div>
);
}

View File

@@ -90,6 +90,14 @@ export interface Messages {
tasks: {
empty: string;
cardsAria: string;
groups: {
watchers: string;
scheduled: string;
paused: string;
completed: string;
};
count: string;
groupEmpty: string;
task: string;
status: string;
schedule: string;
@@ -98,8 +106,16 @@ export interface Messages {
context: string;
ciWatch: string;
emptyPrompt: string;
prompt: string;
until: string;
suspendedUntil: string;
lastResult: string;
result: string;
resultOk: string;
resultFail: string;
noResult: string;
noTime: string;
now: string;
};
status: {
processing: string;
@@ -222,6 +238,14 @@ export const messages = {
tasks: {
empty: '예약 작업 없음.',
cardsAria: '예약 작업 카드',
groups: {
watchers: 'CI 감시',
scheduled: '예약',
paused: '일시정지',
completed: '완료',
},
count: '개',
groupEmpty: '해당 없음',
task: '작업',
status: '상태',
schedule: '스케줄',
@@ -230,8 +254,16 @@ export const messages = {
context: '컨텍스트',
ciWatch: 'CI 감시',
emptyPrompt: '(빈 미리보기)',
prompt: '프롬프트',
until: '까지',
suspendedUntil: '정지 해제',
lastResult: '최근 결과',
result: '결과 없음',
resultOk: '정상',
resultFail: '실패',
noResult: '결과 없음',
noTime: '-',
now: '지금',
},
status: {
processing: '처리중',
@@ -338,6 +370,14 @@ export const messages = {
tasks: {
empty: 'No scheduled work.',
cardsAria: 'Scheduled task cards',
groups: {
watchers: 'CI watch',
scheduled: 'Scheduled',
paused: 'Paused',
completed: 'Completed',
},
count: 'items',
groupEmpty: 'None',
task: 'task',
status: 'status',
schedule: 'schedule',
@@ -346,8 +386,16 @@ export const messages = {
context: 'context',
ciWatch: 'CI Watch',
emptyPrompt: '(empty preview)',
prompt: 'prompt',
until: 'until',
suspendedUntil: 'resume',
lastResult: 'last result',
result: 'no result',
resultOk: 'ok',
resultFail: 'failed',
noResult: 'no result',
noTime: '-',
now: 'now',
},
status: {
processing: 'processing',
@@ -454,6 +502,14 @@ export const messages = {
tasks: {
empty: '暂无计划任务。',
cardsAria: '计划任务卡片',
groups: {
watchers: 'CI 监控',
scheduled: '计划',
paused: '暂停',
completed: '完成',
},
count: '项',
groupEmpty: '无',
task: '任务',
status: '状态',
schedule: '计划',
@@ -462,8 +518,16 @@ export const messages = {
context: '上下文',
ciWatch: 'CI 监控',
emptyPrompt: '(空预览)',
prompt: '提示',
until: '直到',
suspendedUntil: '恢复',
lastResult: '最近结果',
result: '无结果',
resultOk: '正常',
resultFail: '失败',
noResult: '无结果',
noTime: '-',
now: '现在',
},
status: {
processing: '处理中',
@@ -570,6 +634,14 @@ export const messages = {
tasks: {
empty: '予定作業なし。',
cardsAria: '予定作業カード',
groups: {
watchers: 'CI監視',
scheduled: '予定',
paused: '一時停止',
completed: '完了',
},
count: '件',
groupEmpty: 'なし',
task: '作業',
status: '状態',
schedule: '予定',
@@ -578,8 +650,16 @@ export const messages = {
context: 'コンテキスト',
ciWatch: 'CI監視',
emptyPrompt: '(空のプレビュー)',
prompt: 'プロンプト',
until: 'まで',
suspendedUntil: '再開',
lastResult: '直近結果',
result: '結果なし',
resultOk: '正常',
resultFail: '失敗',
noResult: '結果なし',
noTime: '-',
now: '今',
},
status: {
processing: '処理中',

View File

@@ -837,6 +837,236 @@ progress::-moz-progress-bar {
background: rgba(255, 250, 240, 0.45);
}
.task-board {
display: grid;
gap: 12px;
}
.task-group {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid rgba(43, 55, 38, 0.11);
border-radius: 22px;
background: rgba(255, 250, 240, 0.54);
}
.task-group-completed {
display: block;
}
.task-group-completed[open] {
display: grid;
}
.task-group-completed summary {
cursor: pointer;
list-style: none;
}
.task-group-completed summary::-webkit-details-marker {
display: none;
}
.task-group-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.task-group-head strong {
display: block;
margin-top: 2px;
font-size: 18px;
letter-spacing: -0.03em;
}
.task-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 360px));
gap: 10px;
}
.task-card {
display: grid;
gap: 9px;
min-width: 0;
padding: 12px;
border: 1px solid rgba(43, 55, 38, 0.1);
border-radius: 18px;
background: rgba(255, 250, 240, 0.72);
}
.task-card-main,
.task-time-grid,
.task-suspended,
.task-result {
min-width: 0;
}
.task-card-main {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
}
.task-title {
min-width: 0;
}
.task-title strong {
display: block;
overflow: hidden;
font-size: 15px;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-status-line {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.task-provider {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
background: rgba(43, 55, 38, 0.08);
}
.task-time-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.task-time-grid > span,
.task-suspended,
.task-result {
padding: 9px;
border: 1px solid rgba(43, 55, 38, 0.08);
border-radius: 14px;
background: rgba(43, 55, 38, 0.04);
}
.task-time-grid small,
.task-suspended span,
.task-result span,
.task-time-grid em,
.task-suspended em {
color: var(--muted);
font-size: 11px;
font-style: normal;
font-weight: 780;
}
.task-time-grid small,
.task-time-grid strong,
.task-time-grid em,
.task-suspended span,
.task-suspended strong,
.task-suspended em,
.task-result span,
.task-result strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-time-grid strong,
.task-suspended strong,
.task-result strong {
margin-top: 3px;
}
.task-suspended {
border-color: rgba(181, 138, 34, 0.22);
background: rgba(181, 138, 34, 0.08);
}
.task-result {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
border-color: rgba(63, 127, 81, 0.15);
}
.result-fail {
border-color: rgba(183, 71, 52, 0.28);
background: rgba(183, 71, 52, 0.08);
}
.result-none {
border-color: rgba(109, 115, 95, 0.14);
}
.task-prompt {
color: var(--muted);
}
.task-prompt summary {
min-height: 32px;
color: var(--accent-strong);
font-size: 12px;
font-weight: 900;
cursor: pointer;
}
.task-prompt p {
margin: 4px 0;
overflow-wrap: anywhere;
}
.task-prompt small {
display: block;
overflow: hidden;
font-family:
'SF Mono', 'Cascadia Code', 'Roboto Mono', ui-monospace, monospace;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-group-empty {
padding: 12px;
border-radius: 14px;
color: var(--muted);
background: rgba(43, 55, 38, 0.04);
}
.pill-watchers {
color: #173e23;
background: rgba(63, 127, 81, 0.18);
}
.pill-scheduled {
color: #6e4a05;
background: rgba(181, 138, 34, 0.16);
}
.pill-paused {
color: #6e4a05;
background: rgba(181, 138, 34, 0.2);
}
.pill-completed {
color: #625d52;
background: rgba(109, 115, 95, 0.16);
}
.record-card {
display: grid;
gap: 14px;
@@ -1139,6 +1369,39 @@ progress::-moz-progress-bar {
.record-card-grid {
grid-template-columns: 1fr 1fr;
}
.task-group {
padding: 10px;
}
.task-list {
grid-template-columns: 1fr;
gap: 8px;
}
.task-card {
gap: 7px;
padding: 9px;
}
.task-card-main {
gap: 7px;
}
.task-time-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.task-time-grid > span,
.task-suspended,
.task-result {
padding: 7px;
}
.task-prompt summary {
min-height: 28px;
}
}
@media (max-width: 380px) {
@@ -1176,6 +1439,14 @@ progress::-moz-progress-bar {
.record-card-grid {
grid-template-columns: 1fr;
}
.task-time-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.task-time-grid > span:last-child {
grid-column: auto;
}
}
@media (prefers-reduced-motion: reduce) {