2026-06-03 05:08:00 +00:00

428 lines
22 KiB
JavaScript

// SchedulesPage — mirrors the Tasks 3-pane shell: list | detail | history
// Data model derives from ui/src/pages/SchedulesPage.tsx.
const DAYS = ['日', '月', '火', '水', '木', '金', '土'];
function parseCronToDisplay(cron) {
if (cron === 'once') return '一回のみ';
const parts = (cron || '').split(' ');
if (parts.length !== 5) return cron;
const [min, hour, dom, , dow] = parts;
const hhmm = `${hour}:${String(min).padStart(2, '0')}`;
if (dom !== '*' && dow === '*') return `毎月${dom}${hhmm}`;
if (dow !== '*' && dom === '*') return `毎週${DAYS[Number(dow)] ?? dow}${hhmm}`;
if (dom === '*' && dow === '*') return `毎日 ${hhmm}`;
return cron;
}
function formatDateShort(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString('ja-JP', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function relativeFromNow(iso) {
if (!iso) return '—';
const diff = new Date(iso).getTime() - Date.now();
const abs = Math.abs(diff);
const mins = Math.round(abs / 60000);
const hrs = Math.round(mins / 60);
const days = Math.round(hrs / 24);
const unit = mins < 60 ? `${mins}` : hrs < 24 ? `${hrs}時間` : `${days}`;
return diff >= 0 ? `${unit}` : `${unit}`;
}
// ── Left: list of schedules ──────────────────────────────────────────────
function ScheduleListItem({ sch, active, onClick }) {
return (
<button onClick={onClick} style={{
width: '100%', textAlign: 'left', padding: '10px 12px', borderRadius: 12,
border: '1px solid ' + (active ? '#3b82f6' : '#e2e8f0'),
background: active ? '#eff6ff' : '#fff',
cursor: 'pointer', transition: 'background .15s', fontFamily: 'inherit',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{
width: 8, height: 8, borderRadius: 9999, flexShrink: 0,
background: sch.isActive ? '#22c55e' : '#cbd5e1',
}} />
<div style={{
flex: 1, minWidth: 0,
fontSize: 13, fontWeight: 700, color: sch.isActive ? '#0f172a' : '#64748b',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{sch.title || 'タイトルなし'}</div>
{sch.triggerKind === 'event' && (
<span style={{
fontSize: 10, fontWeight: 700, color: '#5b21b6', background: '#ede9fe',
padding: '2px 6px', borderRadius: 4, flexShrink: 0,
}}>event</span>
)}
</div>
<div style={{ marginTop: 4, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{sch.triggerKind === 'event' ? sch.eventSource : parseCronToDisplay(sch.cronExpression)}
</div>
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>
{sch.isActive
? (sch.nextRunAt ? `次回 ${formatDateShort(sch.nextRunAt)} (${relativeFromNow(sch.nextRunAt)})` : '次回未定')
: '停止中'}
</div>
</button>
);
}
function ScheduleListPane({ items, activeId, onSelect, filter, setFilter, search, setSearch, onOpenCreate }) {
const filtered = items.filter(s => {
if (filter === 'active' && !s.isActive) return false;
if (filter === 'paused' && s.isActive) return false;
if (filter === 'event' && s.triggerKind !== 'event') return false;
if (search && !(s.title + s.body).toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const counts = {
all: items.length,
active: items.filter(s => s.isActive).length,
paused: items.filter(s => !s.isActive).length,
event: items.filter(s => s.triggerKind === 'event').length,
};
const chipStyle = (on) => ({
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
border: '1px solid ' + (on ? '#2563eb' : '#e2e8f0'),
background: on ? '#eff6ff' : '#fff',
color: on ? '#1d4ed8' : '#64748b', fontFamily: 'inherit',
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<button onClick={onOpenCreate} style={{
width: '100%', padding: '10px 14px', marginBottom: 10, background: '#2563eb',
color: '#fff', borderRadius: 12, fontSize: 13, fontWeight: 700, border: 'none',
cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', gap: 6, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
新しいスケジュール
</button>
<div style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 11,
color: '#64748b', padding: '0 2px 10px',
}}>
<span><b style={{ color: '#334155', fontWeight: 700 }}>{counts.all}</b> </span>
<span style={{ color: '#cbd5e1' }}>·</span>
<span><b style={{ color: '#16a34a', fontWeight: 700 }}>{counts.active}</b> </span>
{counts.paused > 0 && <span><b style={{ color: '#64748b', fontWeight: 700 }}>{counts.paused}</b> </span>}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0',
borderRadius: 12, padding: '6px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<IconSearch width={14} height={14} style={{ color: '#94a3b8', flexShrink: 0 }} />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="検索..."
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0 }} />
</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
<button style={chipStyle(filter === 'all')} onClick={() => setFilter('all')}>All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.all}</span></button>
<button style={chipStyle(filter === 'active')} onClick={() => setFilter('active')}>有効 <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.active}</span></button>
<button style={chipStyle(filter === 'paused')} onClick={() => setFilter('paused')}>停止 <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.paused}</span></button>
<button style={chipStyle(filter === 'event')} onClick={() => setFilter('event')}>Event <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.event}</span></button>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
{filtered.map(s => <ScheduleListItem key={s.id} sch={s} active={activeId === s.id} onClick={() => onSelect(s.id)} />)}
{filtered.length === 0 && (
(search || filter !== 'all') ? (
<EmptyState
compact
icon={<IconSearch width={18} height={18} />}
title="該当するスケジュールはありません"
hint="検索やフィルタを変えてみてください。"
action={
<button onClick={() => { setSearch(''); setFilter('all'); }} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 700,
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
cursor: 'pointer', fontFamily: 'inherit',
}}>フィルタをクリア</button>
}
/>
) : (
<EmptyState
compact
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="スケジュールがありません"
hint="定期実行やイベントトリガーを登録するとここに表示されます。"
/>
)
)}
</div>
</div>
);
}
// ── Center: schedule detail editor ───────────────────────────────────────
function FormRow({ label, help, children }) {
return (
<label style={{ display: 'block', marginBottom: 14 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: '#475569', marginBottom: 4 }}>{label}</div>
{children}
{help && <div style={{ fontSize: 10, color: '#94a3b8', marginTop: 4 }}>{help}</div>}
</label>
);
}
function TextInput(props) {
return <input {...props} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a',
...(props.style || {}),
}} />;
}
function SelectInput({ children, ...props }) {
return <select {...props} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a',
}}>{children}</select>;
}
function ScheduleDetail({ sch, onPatch, onTrigger, onDelete }) {
if (!sch) {
return (
<div style={{ padding: 40, display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<EmptyState
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="スケジュールを選択してください"
hint="左のリストから編集したいスケジュールを開きます。"
/>
</div>
);
}
const isEvent = sch.triggerKind === 'event';
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Header */}
<div style={{
flexShrink: 0, padding: '14px 20px', borderBottom: '1px solid #e2e8f0', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{
width: 10, height: 10, borderRadius: 9999,
background: sch.isActive ? '#22c55e' : '#cbd5e1',
}} />
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>SCHEDULE #{sch.id}</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{sch.title || 'タイトルなし'}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={() => onTrigger(sch.id)} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #bfdbfe', color: '#1d4ed8',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
今すぐ実行
</button>
<button onClick={() => onPatch(sch.id, { isActive: !sch.isActive })} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #e2e8f0', color: '#475569',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>{sch.isActive ? '停止' : '再開'}</button>
<button onClick={() => onDelete(sch.id)} style={{
padding: '6px 12px', background: '#fff', border: '1px solid #fecaca', color: '#dc2626',
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
}}>削除</button>
</div>
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
<div style={{ maxWidth: 640, margin: '0 auto' }}>
{/* Summary strip */}
<div style={{
display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap',
}}>
<StatChip label="トリガー" value={isEvent ? 'Event' : 'Cron'} />
<StatChip label={isEvent ? 'ソース' : 'スケジュール'} value={isEvent ? sch.eventSource : parseCronToDisplay(sch.cronExpression)} />
<StatChip label="ピース" value={sch.pieceName} color="#2563eb" />
{sch.isActive
? <StatChip label="次回実行" value={sch.nextRunAt ? relativeFromNow(sch.nextRunAt) : '—'} />
: <StatChip label="ステータス" value="停止中" color="#64748b" />}
</div>
{/* Form card */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
基本情報
</div>
<FormRow label="タイトル">
<TextInput value={sch.title || ''} onChange={e => onPatch(sch.id, { title: e.target.value })} placeholder="週次ニュースまとめ" />
</FormRow>
<FormRow label="プロンプト" help="エージェントに送るメッセージ">
<textarea value={sch.body} onChange={e => onPatch(sch.id, { body: e.target.value })} rows={4} style={{
width: '100%', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 10,
outline: 'none', color: '#0f172a', resize: 'vertical', lineHeight: 1.55,
}} />
</FormRow>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormRow label="ピース">
<SelectInput value={sch.pieceName} onChange={e => onPatch(sch.id, { pieceName: e.target.value })}>
<option value="auto">auto</option>
<option value="chat">chat</option>
<option value="research">research</option>
<option value="general">general</option>
<option value="x-ai-digest">x-ai-digest</option>
</SelectInput>
</FormRow>
<FormRow label="出力フォーマット">
<SelectInput value={sch.outputFormat || 'markdown'} onChange={e => onPatch(sch.id, { outputFormat: e.target.value })}>
<option value="markdown">markdown</option>
<option value="plain">plain</option>
<option value="json">json</option>
</SelectInput>
</FormRow>
</div>
</div>
{/* Trigger card */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase', marginBottom: 14 }}>
トリガー
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
{['cron', 'event'].map(k => (
<button key={k} onClick={() => onPatch(sch.id, { triggerKind: k })} style={{
flex: 1, padding: '10px 12px', borderRadius: 10,
border: '1px solid ' + (sch.triggerKind === k ? '#2563eb' : '#e2e8f0'),
background: sch.triggerKind === k ? '#eff6ff' : '#fff',
color: sch.triggerKind === k ? '#1d4ed8' : '#64748b',
fontWeight: 700, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
textAlign: 'left', display: 'flex', flexDirection: 'column', gap: 2,
}}>
<span>{k === 'cron' ? '定期実行 (Cron)' : 'イベントトリガー'}</span>
<span style={{ fontSize: 10, color: sch.triggerKind === k ? '#3b82f6' : '#94a3b8', fontWeight: 500 }}>
{k === 'cron' ? '毎日 / 毎週 / カスタム' : 'GitHub / Mail / Webhook'}
</span>
</button>
))}
</div>
{!isEvent && (
<>
<FormRow label="Cron 式" help="分 時 日 月 曜日 · 例: 0 7 * * * = 毎日 07:00 (UTC)">
<TextInput value={sch.cronExpression} onChange={e => onPatch(sch.id, { cronExpression: e.target.value })}
style={{ fontFamily: 'IBM Plex Mono, monospace' }} placeholder="0 7 * * *" />
</FormRow>
<div style={{ padding: '10px 12px', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 10, fontSize: 12, color: '#475569' }}>
<b style={{ color: '#0f172a' }}>{parseCronToDisplay(sch.cronExpression)}</b>
{sch.nextRunAt && <span> · 次回 {formatDateShort(sch.nextRunAt)} ({relativeFromNow(sch.nextRunAt)})</span>}
</div>
</>
)}
{isEvent && (
<>
<FormRow label="イベントソース">
<SelectInput value={sch.eventSource || ''} onChange={e => onPatch(sch.id, { eventSource: e.target.value })}>
<option value="github.issue.opened">github.issue.opened</option>
<option value="github.pr.opened">github.pr.opened</option>
<option value="gitea.push">gitea.push</option>
<option value="mail.received">mail.received</option>
<option value="webhook.custom">webhook.custom</option>
</SelectInput>
</FormRow>
<FormRow label="フィルタ条件" help="該当イベントが発火した時のみ実行">
<TextInput value={sch.eventFilter || ''} onChange={e => onPatch(sch.id, { eventFilter: e.target.value })}
style={{ fontFamily: 'IBM Plex Mono, monospace' }} placeholder='repo == "agent-orchestrator" && label == "bug"' />
</FormRow>
</>
)}
</div>
{/* History */}
<div style={{
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
marginTop: 16, boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14,
}}>
<div style={{ fontSize: 11, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>
実行履歴
</div>
<span style={{ fontSize: 11, color: '#94a3b8' }}>直近 {sch.history?.length || 0} </span>
</div>
{(sch.history || []).length === 0 && (
<EmptyState
compact
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>}
title="まだ実行されていません"
hint={sch.isActive ? '次回の実行後にここに履歴が追加されます。' : 'スケジュールは停止中です。「再開」で有効化できます。'}
/>
)}
<div style={{ display: 'flex', flexDirection: 'column' }}>
{(sch.history || []).map((h, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0',
borderTop: i === 0 ? 'none' : '1px solid #f1f5f9',
}}>
<StatusBadge status={h.status} small />
<div style={{ flex: 1, fontSize: 12, color: '#334155' }}>
<a href="#" style={{ color: '#2563eb', fontWeight: 700, textDecoration: 'none' }}>#{h.taskId}</a>
{' · '}{h.summary || '—'}
</div>
<div style={{ fontSize: 11, color: '#94a3b8', whiteSpace: 'nowrap' }}>{formatDateShort(h.at)}</div>
</div>
))}
</div>
</div>
<div style={{ height: 40 }} />
</div>
</div>
</div>
);
}
function SchedulesPage({ schedules, activeId, setActiveId, onPatch, onTrigger, onDelete, onOpenCreate }) {
const [filter, setFilter] = React.useState('all');
const [search, setSearch] = React.useState('');
const active = schedules.find(s => s.id === activeId) || schedules[0];
return (
<div style={{
flex: 1, minHeight: 0, display: 'grid',
gridTemplateColumns: '320px 1fr',
background: '#f1f5f9', gap: 1,
}}>
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<ScheduleListPane
items={schedules} activeId={active?.id} onSelect={setActiveId}
filter={filter} setFilter={setFilter} search={search} setSearch={setSearch}
onOpenCreate={onOpenCreate}
/>
</div>
<div style={{ background: '#fff', minWidth: 0 }}>
<ScheduleDetail sch={active} onPatch={onPatch} onTrigger={onTrigger} onDelete={onDelete} />
</div>
</div>
);
}
window.SchedulesPage = SchedulesPage;