feat(dashboard): improve scheduled work UX (#21)
This commit is contained in:
@@ -28,6 +28,8 @@ type UsageRow = DashboardOverview['usage']['rows'][number];
|
|||||||
type RiskLevel = 'ok' | 'warn' | 'critical';
|
type RiskLevel = 'ok' | 'warn' | 'critical';
|
||||||
type UsageGroup = 'primary' | 'codex';
|
type UsageGroup = 'primary' | 'codex';
|
||||||
type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled';
|
type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled';
|
||||||
|
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
|
||||||
|
type TaskResultTone = 'ok' | 'fail' | 'none';
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 15_000;
|
const REFRESH_INTERVAL_MS = 15_000;
|
||||||
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
|
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
|
||||||
@@ -82,6 +84,46 @@ function formatDate(value: string | null | undefined, locale: Locale): string {
|
|||||||
}).format(date);
|
}).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 {
|
function formatPct(value: number): string {
|
||||||
if (value < 0) return '-';
|
if (value < 0) return '-';
|
||||||
return `${Math.round(value)}%`;
|
return `${Math.round(value)}%`;
|
||||||
@@ -127,6 +169,52 @@ function queueLabel(
|
|||||||
return parts.join(' · ');
|
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) {
|
function navItems(t: Messages) {
|
||||||
return [
|
return [
|
||||||
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
|
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
|
||||||
@@ -691,111 +779,178 @@ function TaskPanel({
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
t: Messages;
|
t: Messages;
|
||||||
}) {
|
}) {
|
||||||
const sortedTasks = useMemo(
|
const taskGroups = useMemo(() => {
|
||||||
() =>
|
const groups: Record<TaskGroupKey, DashboardTask[]> = {
|
||||||
[...tasks].sort((a, b) => {
|
watchers: [],
|
||||||
const statusRank = { active: 0, paused: 1, completed: 2 } as const;
|
scheduled: [],
|
||||||
const rankDelta = statusRank[a.status] - statusRank[b.status];
|
paused: [],
|
||||||
if (rankDelta !== 0) return rankDelta;
|
completed: [],
|
||||||
return (a.nextRun ?? a.createdAt).localeCompare(
|
};
|
||||||
b.nextRun ?? b.createdAt,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
[tasks],
|
|
||||||
);
|
|
||||||
|
|
||||||
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 <EmptyState>{t.tasks.empty}</EmptyState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="task-board" aria-label={t.tasks.cardsAria}>
|
||||||
<div className="table-wrap desktop-table">
|
{taskGroups.map((group) => {
|
||||||
<table>
|
const label = t.tasks.groups[group.key];
|
||||||
<thead>
|
const groupHead = (
|
||||||
<tr>
|
<div className="task-group-head">
|
||||||
<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="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>
|
<div>
|
||||||
<strong>{task.isWatcher ? t.tasks.ciWatch : task.id}</strong>
|
<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>
|
<span className="mono-chip">{task.groupFolder}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="task-status-line">
|
||||||
<span className={`pill pill-${task.status}`}>
|
<span className={`pill pill-${task.status}`}>
|
||||||
{statusLabel(task.status, t)}
|
{statusLabel(task.status, t)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
{task.ciProvider ? (
|
||||||
<p>{task.promptPreview || t.tasks.emptyPrompt}</p>
|
<span className="task-provider">
|
||||||
<div className="record-card-grid">
|
{task.ciProvider}
|
||||||
<span>
|
|
||||||
<small>{t.tasks.schedule}</small>
|
|
||||||
<strong>{task.scheduleType}</strong>
|
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-time-grid">
|
||||||
<span>
|
<span>
|
||||||
<small>{t.tasks.next}</small>
|
<small>{t.tasks.next}</small>
|
||||||
<strong>{formatDate(task.nextRun, locale)}</strong>
|
<strong>{formatTaskDate(task.nextRun, locale)}</strong>
|
||||||
|
<em>{formatRelativeDate(task.nextRun, locale, t)}</em>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<small>{t.tasks.last}</small>
|
<small>{t.tasks.last}</small>
|
||||||
<strong>{formatDate(task.lastRun, locale)}</strong>
|
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
|
||||||
|
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<small>{t.tasks.context}</small>
|
<small>{t.tasks.schedule}</small>
|
||||||
<strong>{task.contextMode}</strong>
|
<strong>{task.scheduleType}</strong>
|
||||||
|
<em>{task.scheduleValue}</em>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{task.lastResult ? (
|
|
||||||
<p className="record-id">
|
{task.suspendedUntil ? (
|
||||||
{t.tasks.lastResult}: {task.lastResult}
|
<div className="task-suspended">
|
||||||
</p>
|
<span>{t.tasks.suspendedUntil}</span>
|
||||||
) : null}
|
<strong>
|
||||||
</article>
|
{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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ export interface Messages {
|
|||||||
tasks: {
|
tasks: {
|
||||||
empty: string;
|
empty: string;
|
||||||
cardsAria: string;
|
cardsAria: string;
|
||||||
|
groups: {
|
||||||
|
watchers: string;
|
||||||
|
scheduled: string;
|
||||||
|
paused: string;
|
||||||
|
completed: string;
|
||||||
|
};
|
||||||
|
count: string;
|
||||||
|
groupEmpty: string;
|
||||||
task: string;
|
task: string;
|
||||||
status: string;
|
status: string;
|
||||||
schedule: string;
|
schedule: string;
|
||||||
@@ -98,8 +106,16 @@ export interface Messages {
|
|||||||
context: string;
|
context: string;
|
||||||
ciWatch: string;
|
ciWatch: string;
|
||||||
emptyPrompt: string;
|
emptyPrompt: string;
|
||||||
|
prompt: string;
|
||||||
until: string;
|
until: string;
|
||||||
|
suspendedUntil: string;
|
||||||
lastResult: string;
|
lastResult: string;
|
||||||
|
result: string;
|
||||||
|
resultOk: string;
|
||||||
|
resultFail: string;
|
||||||
|
noResult: string;
|
||||||
|
noTime: string;
|
||||||
|
now: string;
|
||||||
};
|
};
|
||||||
status: {
|
status: {
|
||||||
processing: string;
|
processing: string;
|
||||||
@@ -222,6 +238,14 @@ export const messages = {
|
|||||||
tasks: {
|
tasks: {
|
||||||
empty: '예약 작업 없음.',
|
empty: '예약 작업 없음.',
|
||||||
cardsAria: '예약 작업 카드',
|
cardsAria: '예약 작업 카드',
|
||||||
|
groups: {
|
||||||
|
watchers: 'CI 감시',
|
||||||
|
scheduled: '예약',
|
||||||
|
paused: '일시정지',
|
||||||
|
completed: '완료',
|
||||||
|
},
|
||||||
|
count: '개',
|
||||||
|
groupEmpty: '해당 없음',
|
||||||
task: '작업',
|
task: '작업',
|
||||||
status: '상태',
|
status: '상태',
|
||||||
schedule: '스케줄',
|
schedule: '스케줄',
|
||||||
@@ -230,8 +254,16 @@ export const messages = {
|
|||||||
context: '컨텍스트',
|
context: '컨텍스트',
|
||||||
ciWatch: 'CI 감시',
|
ciWatch: 'CI 감시',
|
||||||
emptyPrompt: '(빈 미리보기)',
|
emptyPrompt: '(빈 미리보기)',
|
||||||
|
prompt: '프롬프트',
|
||||||
until: '까지',
|
until: '까지',
|
||||||
|
suspendedUntil: '정지 해제',
|
||||||
lastResult: '최근 결과',
|
lastResult: '최근 결과',
|
||||||
|
result: '결과 없음',
|
||||||
|
resultOk: '정상',
|
||||||
|
resultFail: '실패',
|
||||||
|
noResult: '결과 없음',
|
||||||
|
noTime: '-',
|
||||||
|
now: '지금',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
processing: '처리중',
|
processing: '처리중',
|
||||||
@@ -338,6 +370,14 @@ export const messages = {
|
|||||||
tasks: {
|
tasks: {
|
||||||
empty: 'No scheduled work.',
|
empty: 'No scheduled work.',
|
||||||
cardsAria: 'Scheduled task cards',
|
cardsAria: 'Scheduled task cards',
|
||||||
|
groups: {
|
||||||
|
watchers: 'CI watch',
|
||||||
|
scheduled: 'Scheduled',
|
||||||
|
paused: 'Paused',
|
||||||
|
completed: 'Completed',
|
||||||
|
},
|
||||||
|
count: 'items',
|
||||||
|
groupEmpty: 'None',
|
||||||
task: 'task',
|
task: 'task',
|
||||||
status: 'status',
|
status: 'status',
|
||||||
schedule: 'schedule',
|
schedule: 'schedule',
|
||||||
@@ -346,8 +386,16 @@ export const messages = {
|
|||||||
context: 'context',
|
context: 'context',
|
||||||
ciWatch: 'CI Watch',
|
ciWatch: 'CI Watch',
|
||||||
emptyPrompt: '(empty preview)',
|
emptyPrompt: '(empty preview)',
|
||||||
|
prompt: 'prompt',
|
||||||
until: 'until',
|
until: 'until',
|
||||||
|
suspendedUntil: 'resume',
|
||||||
lastResult: 'last result',
|
lastResult: 'last result',
|
||||||
|
result: 'no result',
|
||||||
|
resultOk: 'ok',
|
||||||
|
resultFail: 'failed',
|
||||||
|
noResult: 'no result',
|
||||||
|
noTime: '-',
|
||||||
|
now: 'now',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
processing: 'processing',
|
processing: 'processing',
|
||||||
@@ -454,6 +502,14 @@ export const messages = {
|
|||||||
tasks: {
|
tasks: {
|
||||||
empty: '暂无计划任务。',
|
empty: '暂无计划任务。',
|
||||||
cardsAria: '计划任务卡片',
|
cardsAria: '计划任务卡片',
|
||||||
|
groups: {
|
||||||
|
watchers: 'CI 监控',
|
||||||
|
scheduled: '计划',
|
||||||
|
paused: '暂停',
|
||||||
|
completed: '完成',
|
||||||
|
},
|
||||||
|
count: '项',
|
||||||
|
groupEmpty: '无',
|
||||||
task: '任务',
|
task: '任务',
|
||||||
status: '状态',
|
status: '状态',
|
||||||
schedule: '计划',
|
schedule: '计划',
|
||||||
@@ -462,8 +518,16 @@ export const messages = {
|
|||||||
context: '上下文',
|
context: '上下文',
|
||||||
ciWatch: 'CI 监控',
|
ciWatch: 'CI 监控',
|
||||||
emptyPrompt: '(空预览)',
|
emptyPrompt: '(空预览)',
|
||||||
|
prompt: '提示',
|
||||||
until: '直到',
|
until: '直到',
|
||||||
|
suspendedUntil: '恢复',
|
||||||
lastResult: '最近结果',
|
lastResult: '最近结果',
|
||||||
|
result: '无结果',
|
||||||
|
resultOk: '正常',
|
||||||
|
resultFail: '失败',
|
||||||
|
noResult: '无结果',
|
||||||
|
noTime: '-',
|
||||||
|
now: '现在',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
processing: '处理中',
|
processing: '处理中',
|
||||||
@@ -570,6 +634,14 @@ export const messages = {
|
|||||||
tasks: {
|
tasks: {
|
||||||
empty: '予定作業なし。',
|
empty: '予定作業なし。',
|
||||||
cardsAria: '予定作業カード',
|
cardsAria: '予定作業カード',
|
||||||
|
groups: {
|
||||||
|
watchers: 'CI監視',
|
||||||
|
scheduled: '予定',
|
||||||
|
paused: '一時停止',
|
||||||
|
completed: '完了',
|
||||||
|
},
|
||||||
|
count: '件',
|
||||||
|
groupEmpty: 'なし',
|
||||||
task: '作業',
|
task: '作業',
|
||||||
status: '状態',
|
status: '状態',
|
||||||
schedule: '予定',
|
schedule: '予定',
|
||||||
@@ -578,8 +650,16 @@ export const messages = {
|
|||||||
context: 'コンテキスト',
|
context: 'コンテキスト',
|
||||||
ciWatch: 'CI監視',
|
ciWatch: 'CI監視',
|
||||||
emptyPrompt: '(空のプレビュー)',
|
emptyPrompt: '(空のプレビュー)',
|
||||||
|
prompt: 'プロンプト',
|
||||||
until: 'まで',
|
until: 'まで',
|
||||||
|
suspendedUntil: '再開',
|
||||||
lastResult: '直近結果',
|
lastResult: '直近結果',
|
||||||
|
result: '結果なし',
|
||||||
|
resultOk: '正常',
|
||||||
|
resultFail: '失敗',
|
||||||
|
noResult: '結果なし',
|
||||||
|
noTime: '-',
|
||||||
|
now: '今',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
processing: '処理中',
|
processing: '処理中',
|
||||||
|
|||||||
@@ -837,6 +837,236 @@ progress::-moz-progress-bar {
|
|||||||
background: rgba(255, 250, 240, 0.45);
|
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 {
|
.record-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -1139,6 +1369,39 @@ progress::-moz-progress-bar {
|
|||||||
.record-card-grid {
|
.record-card-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
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) {
|
@media (max-width: 380px) {
|
||||||
@@ -1176,6 +1439,14 @@ progress::-moz-progress-bar {
|
|||||||
.record-card-grid {
|
.record-card-grid {
|
||||||
grid-template-columns: 1fr;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
Reference in New Issue
Block a user