feat: initial public release (MAESTRO v0.1.0)
Open-source release of MAESTRO, an agent orchestration platform that runs LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0. See README.md and docs/ (getting-started, configuration, architecture).
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
// ChatPane — mirrors ui/src/components/chat/* with user/ask/result/progress bubbles
|
||||
function Bubble({ role, children, footer }) {
|
||||
const isUser = role === 'user';
|
||||
const style = {
|
||||
maxWidth: '85%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 16,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.55,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
};
|
||||
if (isUser) {
|
||||
Object.assign(style, { background: '#f1f5f9', color: '#0f172a', borderBottomRightRadius: 4, alignSelf: 'flex-end' });
|
||||
} else if (role === 'ask') {
|
||||
Object.assign(style, { background: '#fef9c3', color: '#854d0e', border: '1px solid #fde68a', borderBottomLeftRadius: 4 });
|
||||
} else if (role === 'result') {
|
||||
Object.assign(style, { background: '#ecfdf5', color: '#065f46', border: '1px solid #a7f3d0', borderBottomLeftRadius: 4 });
|
||||
} else {
|
||||
Object.assign(style, { background: '#fff', color: '#0f172a', border: '1px solid #e2e8f0', borderBottomLeftRadius: 4 });
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: isUser ? 'flex-end' : 'flex-start', gap: 4 }}>
|
||||
<div style={style}>{children}</div>
|
||||
{footer && <div style={{ fontSize: 10, color: '#94a3b8' }}>{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBubble({ text }) {
|
||||
return (
|
||||
<div style={{
|
||||
alignSelf: 'flex-start', background: '#f1f5f9', color: '#475569',
|
||||
padding: '8px 12px', borderRadius: 12, fontSize: 12,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<Spinner />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatHeader({ task, onOpenDetail, detailOpen }) {
|
||||
return (
|
||||
<div style={{
|
||||
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>
|
||||
TASK #{task.id}
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.title}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<StatusBadge status={task.status} />
|
||||
<button onClick={onOpenDetail} style={{
|
||||
padding: '6px 10px', borderRadius: 8, border: '1px solid #e2e8f0',
|
||||
background: detailOpen ? '#eff6ff' : '#fff',
|
||||
color: detailOpen ? '#1d4ed8' : '#475569',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>詳細</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Composer({ onSend, disabled }) {
|
||||
const [text, setText] = React.useState('');
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
const send = async () => {
|
||||
if (!text.trim() || disabled || sending) return;
|
||||
setError(null); setSending(true);
|
||||
try {
|
||||
await Promise.resolve(onSend(text.trim()));
|
||||
setText('');
|
||||
} catch (e) {
|
||||
setError(e?.message || '送信に失敗しました');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div style={{ flexShrink: 0, borderTop: '1px solid #e2e8f0', background: '#fff', padding: 12 }}>
|
||||
{error && (
|
||||
<div style={{
|
||||
marginBottom: 8, padding: '8px 10px', background: '#fef2f2', border: '1px solid #fecaca',
|
||||
color: '#b91c1c', borderRadius: 8, fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
}}>
|
||||
<span>⚠ {error}</span>
|
||||
<button onClick={send} style={{
|
||||
padding: '2px 8px', borderRadius: 6, border: '1px solid #fecaca',
|
||||
background: '#fff', color: '#b91c1c', fontSize: 11, fontWeight: 700,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>再送信</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-end', gap: 8, background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0', borderRadius: 12, padding: 8,
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}>
|
||||
<button style={{ padding: 6, background: 'transparent', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
|
||||
<IconAttach width={16} height={16} />
|
||||
</button>
|
||||
<textarea
|
||||
value={text}
|
||||
disabled={disabled || sending}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); send(); } }}
|
||||
rows={2}
|
||||
placeholder={disabled ? '送信できません' : 'メッセージを入力 (⌘+Enter で送信)'}
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: 'none', outline: 'none', background: 'transparent',
|
||||
fontFamily: 'inherit', fontSize: 13, color: '#0f172a', lineHeight: 1.5, minHeight: 32,
|
||||
}}
|
||||
/>
|
||||
<button onClick={send} disabled={disabled || sending || !text.trim()} style={{
|
||||
padding: '6px 14px', background: '#2563eb', color: '#fff', borderRadius: 8,
|
||||
fontSize: 12, fontWeight: 700, border: 'none',
|
||||
cursor: (disabled || sending || !text.trim()) ? 'not-allowed' : 'pointer',
|
||||
opacity: (disabled || sending || !text.trim()) ? 0.5 : 1, fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
{sending && <Spinner />}
|
||||
{sending ? '送信中' : '送信'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 10, color: '#94a3b8', paddingLeft: 4 }}>エージェントは常に /brainstorm → /plan → /implement のパイプラインで動作します。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPane({ task, messages, onSend, onOpenDetail, detailOpen, loading, onOpenCreate }) {
|
||||
const scrollRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}, [messages.length, task && task.id]);
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fff', justifyContent: 'center' }}>
|
||||
<EmptyState
|
||||
icon={<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>}
|
||||
title="タスクを選択してください"
|
||||
hint="左のリストから会話を開くか、新しい依頼を作成できます。"
|
||||
action={onOpenCreate && (
|
||||
<button onClick={onOpenCreate} style={{
|
||||
padding: '8px 14px', borderRadius: 10, fontSize: 12, fontWeight: 700,
|
||||
background: '#2563eb', color: '#fff', border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#fff' }}>
|
||||
<ChatHeader task={task} onOpenDetail={onOpenDetail} detailOpen={detailOpen} />
|
||||
<div ref={scrollRef} style={{
|
||||
flex: 1, overflowY: 'auto', padding: '16px 20px',
|
||||
display: 'flex', flexDirection: 'column', gap: 12, minHeight: 0,
|
||||
}}>
|
||||
{loading && (
|
||||
<>
|
||||
<div style={{ alignSelf: 'flex-end', width: '60%' }}><SkeletonLine height={40} style={{ borderRadius: 16 }} /></div>
|
||||
<div style={{ alignSelf: 'flex-start', width: '70%' }}><SkeletonLine height={56} style={{ borderRadius: 16 }} /></div>
|
||||
<div style={{ alignSelf: 'flex-start', width: '40%' }}><SkeletonLine height={32} style={{ borderRadius: 12 }} /></div>
|
||||
</>
|
||||
)}
|
||||
{!loading && messages.length === 0 && (
|
||||
<EmptyState
|
||||
compact
|
||||
title="まだメッセージがありません"
|
||||
hint="下の入力欄から依頼の詳細を送信してください。エージェントが /brainstorm から開始します。"
|
||||
/>
|
||||
)}
|
||||
{!loading && messages.map((m, i) => (
|
||||
m.role === 'progress'
|
||||
? <ProgressBubble key={i} text={m.content} />
|
||||
: <Bubble key={i} role={m.role} footer={m.footer}>{m.content}</Bubble>
|
||||
))}
|
||||
</div>
|
||||
<Composer onSend={onSend} disabled={task.status === 'cancelled' || task.status === 'succeeded'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.ChatPane = ChatPane;
|
||||
@@ -0,0 +1,134 @@
|
||||
// DetailPanel — tabs: overview, progress (activity + log surface)
|
||||
function Tabs({ tab, onTab }) {
|
||||
const items = [
|
||||
{ id: 'overview', label: '概要' },
|
||||
{ id: 'progress', label: '進捗' },
|
||||
{ id: 'subtasks', label: 'サブタスク' },
|
||||
];
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid #e2e8f0', background: '#fff' }}>
|
||||
{items.map(it => (
|
||||
<button key={it.id} onClick={() => onTab(it.id)} style={{
|
||||
padding: '6px 12px', borderRadius: 8, fontSize: 12, fontWeight: 600,
|
||||
border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: tab === it.id ? '#eff6ff' : 'transparent',
|
||||
color: tab === it.id ? '#1d4ed8' : '#64748b',
|
||||
}}>{it.label}</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ task }) {
|
||||
const Row = ({ label, value }) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '8px 0', borderBottom: '1px solid #f1f5f9', fontSize: 12 }}>
|
||||
<span style={{ color: '#64748b' }}>{label}</span>
|
||||
<span style={{ color: '#0f172a', fontWeight: 600, textAlign: 'right' }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ padding: 16, overflowY: 'auto', fontSize: 13, color: '#0f172a' }}>
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DESCRIPTION</div>
|
||||
<div style={{ marginTop: 6, color: '#334155', lineHeight: 1.6, fontSize: 13 }}>{task.body}</div>
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<StatChip label="試行" value={`${task.attempts}/3`} />
|
||||
<StatChip label="ピース" value={task.piece} color="#2563eb" />
|
||||
<StatChip label="ワーカー" value={task.worker || '—'} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Row label="リポジトリ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.repo}</span>} />
|
||||
<Row label="ブランチ" value={<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 11 }}>{task.branch}</span>} />
|
||||
<Row label="作成日時" value={new Date(task.createdAt).toLocaleString('ja-JP')} />
|
||||
<Row label="更新日時" value={relativeTime(task.updatedAt)} />
|
||||
<Row label="担当" value={task.assignee} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||||
<button style={{
|
||||
padding: '8px 14px', borderRadius: 10, border: '1px solid #e2e8f0',
|
||||
background: '#fff', color: '#475569', fontSize: 12, fontWeight: 600,
|
||||
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
|
||||
}}>再試行</button>
|
||||
<button style={{
|
||||
padding: '8px 14px', borderRadius: 10, border: '1px solid #fecaca',
|
||||
background: '#fff', color: '#b91c1c', fontSize: 12, fontWeight: 600,
|
||||
cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap',
|
||||
}}>キャンセル</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressTab({ task }) {
|
||||
const events = task.events || [];
|
||||
return (
|
||||
<div style={{ padding: 16, overflowY: 'auto' }}>
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 8 }}>ACTIVITY</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 20 }}>
|
||||
{events.map((e, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
|
||||
<div style={{ flexShrink: 0, marginTop: 3 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: 9999, background: e.kind === 'error' ? '#dc2626' : e.kind === 'ok' ? '#16a34a' : '#3b82f6' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#0f172a', fontWeight: 600 }}>{e.label}</div>
|
||||
<div style={{ color: '#64748b', fontSize: 11 }}>{e.meta}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#94a3b8', fontFamily: 'IBM Plex Mono, monospace' }}>{e.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em', marginBottom: 6 }}>ACTIVITY.LOG</div>
|
||||
<div style={{
|
||||
background: '#0f172a', color: '#e2e8f0',
|
||||
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, lineHeight: 1.6,
|
||||
padding: 12, borderRadius: 8, whiteSpace: 'pre', overflowX: 'auto',
|
||||
}}>
|
||||
{`[10:42:18] ` + String.fromCharCode(9432) + ` starting worker for task #` + task.id + `
|
||||
[10:42:19] ` + String.fromCharCode(9432) + ` branch: ` + task.branch + `
|
||||
[10:42:21] ` + String.fromCharCode(9432) + ` piece: ` + task.piece + `
|
||||
[10:42:22] ` + String.fromCharCode(9655) + ` /brainstorm
|
||||
[10:43:04] ` + String.fromCharCode(10003) + ` /plan (12 steps)
|
||||
[10:43:05] ` + String.fromCharCode(9655) + ` /implement
|
||||
[10:44:58] ` + String.fromCharCode(10003) + ` tests passed`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailPanel({ task, onClose }) {
|
||||
const [tab, setTab] = React.useState('overview');
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: '#f8fafc', borderLeft: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
flexShrink: 0, padding: '12px 16px', borderBottom: '1px solid #e2e8f0', background: '#fff',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
|
||||
}}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 10, fontFamily: 'IBM Plex Mono, monospace', color: '#94a3b8', letterSpacing: '.08em' }}>DETAIL</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>#{task.id} {task.title}</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={{
|
||||
width: 28, height: 28, borderRadius: 8, border: '1px solid #e2e8f0',
|
||||
background: '#fff', color: '#64748b', cursor: 'pointer', display: 'inline-flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}><IconClose width={12} height={12} /></button>
|
||||
</div>
|
||||
<Tabs tab={tab} onTab={setTab} />
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
{tab === 'overview' && <OverviewTab task={task} />}
|
||||
{tab === 'progress' && <ProgressTab task={task} />}
|
||||
{tab === 'subtasks' && (
|
||||
<div style={{ padding: 16, fontSize: 13, color: '#64748b' }}>
|
||||
サブタスクはこのタスクにありません。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.DetailPanel = DetailPanel;
|
||||
@@ -0,0 +1,169 @@
|
||||
// Shared small primitives for the admin UI kit.
|
||||
// Status tone + tiny SVG icons + labels matching the codebase.
|
||||
|
||||
const STATUS_LABELS = {
|
||||
queued: 'Inbox', running: 'Running', waiting_human: 'Waiting',
|
||||
waiting_subtasks: 'Subtasks', retry: 'Retry', succeeded: 'Done',
|
||||
failed: 'Failed', cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
const STATUS_TONE = {
|
||||
running: { bg: '#dcfce7', fg: '#166534' },
|
||||
waiting_human: { bg: '#fef9c3', fg: '#854d0e' },
|
||||
waiting_subtasks: { bg: '#e0e7ff', fg: '#3730a3' },
|
||||
failed: { bg: '#fee2e2', fg: '#b91c1c' },
|
||||
succeeded: { bg: '#dbeafe', fg: '#1e40af' },
|
||||
retry: { bg: '#fef3c7', fg: '#92400e' },
|
||||
queued: { bg: '#e2e8f0', fg: '#475569' },
|
||||
cancelled: { bg: '#e2e8f0', fg: '#475569' },
|
||||
};
|
||||
|
||||
function StatusBadge({ status, small }) {
|
||||
const tone = STATUS_TONE[status] || STATUS_TONE.queued;
|
||||
const label = STATUS_LABELS[status] || status;
|
||||
const style = {
|
||||
background: tone.bg, color: tone.fg,
|
||||
fontSize: small ? 10 : 11, fontWeight: 700,
|
||||
padding: small ? '1px 8px' : '2px 10px', borderRadius: 9999,
|
||||
display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap',
|
||||
};
|
||||
return <span style={style}>{label}</span>;
|
||||
}
|
||||
|
||||
function StatChip({ label, value, color }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||
padding: '8px 12px', boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
minWidth: 0, flex: '1 1 0', minWidth: 80,
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.06em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>{label}</div>
|
||||
<div style={{
|
||||
fontSize: 15, fontWeight: 800, color: color || '#0f172a', marginTop: 2,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return <div style={{
|
||||
width: 16, height: 16, border: '2px solid #e2e8f0', borderTopColor: '#2563eb',
|
||||
borderRadius: '9999px', animation: 'ao-spin 1s linear infinite', display: 'inline-block',
|
||||
}} />;
|
||||
}
|
||||
|
||||
function PulseDot() {
|
||||
return <span style={{
|
||||
display: 'inline-block', width: 8, height: 8, background: '#3b82f6',
|
||||
borderRadius: 9999, animation: 'ao-pulse 1.2s ease-in-out infinite',
|
||||
}} />;
|
||||
}
|
||||
|
||||
function IconSearch(props) {
|
||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>;
|
||||
}
|
||||
function IconAttach(props) {
|
||||
return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>;
|
||||
}
|
||||
function IconClose(props) {
|
||||
return <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" {...props}><path d="M4 4l8 8M12 4l-8 8"/></svg>;
|
||||
}
|
||||
|
||||
// ---- State primitives: loading / empty / error ----
|
||||
|
||||
function SkeletonLine({ width = '100%', height = 10, style }) {
|
||||
return <div style={{
|
||||
width, height, borderRadius: 6,
|
||||
background: 'linear-gradient(90deg, #f1f5f9 0%, #e2e8f0 50%, #f1f5f9 100%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'ao-shimmer 1.4s ease-in-out infinite',
|
||||
...style,
|
||||
}} />;
|
||||
}
|
||||
|
||||
function SkeletonCard({ lines = 2 }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '10px 12px', borderRadius: 12, border: '1px solid #e2e8f0',
|
||||
background: '#fff', display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<SkeletonLine width="60%" height={12} />
|
||||
<SkeletonLine width={44} height={14} style={{ borderRadius: 9999 }} />
|
||||
</div>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<SkeletonLine key={i} width={i === lines - 1 ? '40%' : '90%'} height={9} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonList({ count = 5, lines = 2 }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} lines={lines} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ icon, title, hint, action, compact }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
textAlign: 'center', padding: compact ? '24px 16px' : '48px 24px', gap: 8,
|
||||
color: '#64748b',
|
||||
}}>
|
||||
{icon && <div style={{
|
||||
width: 40, height: 40, borderRadius: 9999, background: '#f1f5f9',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#94a3b8', marginBottom: 4,
|
||||
}}>{icon}</div>}
|
||||
{title && <div style={{ fontSize: 13, fontWeight: 700, color: '#334155' }}>{title}</div>}
|
||||
{hint && <div style={{ fontSize: 12, color: '#64748b', maxWidth: 280, lineHeight: 1.5 }}>{hint}</div>}
|
||||
{action && <div style={{ marginTop: 8 }}>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ title = '読み込みに失敗しました', hint, onRetry, compact }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
textAlign: 'center', padding: compact ? '24px 16px' : '40px 24px', gap: 8,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 9999, background: '#fee2e2',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#b91c1c', marginBottom: 4,
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#b91c1c' }}>{title}</div>
|
||||
{hint && <div style={{ fontSize: 12, color: '#64748b', maxWidth: 320, lineHeight: 1.5 }}>{hint}</div>}
|
||||
{onRetry && (
|
||||
<button onClick={onRetry} style={{
|
||||
marginTop: 4, padding: '6px 14px', borderRadius: 8, fontSize: 12, fontWeight: 700,
|
||||
background: '#fff', border: '1px solid #e2e8f0', color: '#334155',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>再試行</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function relativeTime(ms) {
|
||||
const mins = Math.floor((Date.now() - ms) / 60000);
|
||||
if (mins < 1) return 'たった今';
|
||||
if (mins < 60) return `${mins}分前`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}時間前`;
|
||||
return `${Math.floor(hrs / 24)}日前`;
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
STATUS_LABELS, STATUS_TONE,
|
||||
StatusBadge, StatChip, Spinner, PulseDot,
|
||||
SkeletonLine, SkeletonCard, SkeletonList, EmptyState, ErrorState,
|
||||
IconSearch, IconAttach, IconClose, relativeTime,
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
# Admin UI Kit
|
||||
|
||||
Agent Orchestrator 管理画面のハイファイ UI キット。`ui/src/` の実装に対応します。
|
||||
|
||||
## エントリ
|
||||
|
||||
- `index.html` — 4画面シェル(Tasks / Schedules / Users / Settings)。TopBar のナビで切替。
|
||||
|
||||
## コンポーネント
|
||||
|
||||
| ファイル | 役割 |
|
||||
|---|---|
|
||||
| `TopBar.jsx` | ロゴ + ワードマーク + セクションナビ(下線インジケータ)+ ユーザーアバター |
|
||||
| `TaskList.jsx` | 左パネル最上部の「新しい依頼」ボタン、カウント行、検索バー内にソートアイコン統合、ステータスフィルタ chip、タスクリスト |
|
||||
| `ChatPane.jsx` | タスク詳細のチャット UI(user / assistant / ask / result / progress バブル) |
|
||||
| `DetailPanel.jsx` | 右側の詳細パネル(Overview / Progress タブ) |
|
||||
| `SchedulesPage.jsx` | スケジュール一覧 + 詳細(Cron / Event トリガー、実行履歴) |
|
||||
| `UsersPage.jsx` | ユーザー一覧 + ロール設定 / プロフィール |
|
||||
| `SettingsPage.jsx` | 左サイドバー + 中央フォーム(Provider, Pieces, Workers ほか) |
|
||||
| `Primitives.jsx` | StatusBadge, StatChip, SVG アイコン、スピナー、pulse dot |
|
||||
|
||||
## 設計上の決定(v2)
|
||||
|
||||
- **プライマリアクションは左パネル最上部** — 「新しい依頼」は TopBar 右上ではなく、タスクリスト文脈内の青いフル幅ボタン。
|
||||
- **カウントはプライマリアクション直下に統合** — 「合計 / 実行中 / 待機 / 失敗」は TaskList の冒頭に置き、TopBar 右側はアバターのみでスッキリ。
|
||||
- **ソートは検索バー内にアイコン化** — `select` を外し、検索バー右端の小さなアイコンボタンをクリックすると 3 つの並び順からドロップダウンで選べる。リストの縦領域が約 40px 広がる。
|
||||
- **4画面で同じシェルを共有** — Tasks / Schedules / Users は「左リスト + 中央詳細」、Settings のみ「左サイドバー + 中央フォーム」。ヘッダー → サマリ chip → カードフォーム のリズムで操作導線を揃える。
|
||||
- **ナビは下線インジケータ** — タブ的な視覚。アクティブ以外はコントラストを落とす。
|
||||
|
||||
## 実装との対応
|
||||
|
||||
`ui/src/components/` 以下の同名コンポーネントにそのまま対応させる想定です。UI キットはあくまで意思決定のモック — 実 API は未接続、サンプルデータは `index.html` 内にインライン。
|
||||
|
||||
旧バージョン(移植前)は `../admin-legacy/` にあります。
|
||||
@@ -0,0 +1,427 @@
|
||||
// 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;
|
||||
@@ -0,0 +1,318 @@
|
||||
// SettingsPage — sidebar groups + scrollable form (matches existing SettingsSidebar structure)
|
||||
|
||||
const SETTINGS_GROUPS = [
|
||||
{
|
||||
label: '基本設定',
|
||||
sections: [
|
||||
{ id: 'general', label: 'General', desc: 'タイムゾーン・言語' },
|
||||
{ id: 'provider', label: 'Provider', desc: 'LLM API キー・デフォルトモデル' },
|
||||
{ id: 'workers', label: 'Workers', desc: '並列数・タイムアウト・リトライ' },
|
||||
{ id: 'workspace', label: 'Workspace', desc: '作業ディレクトリ・クリーンアップ' },
|
||||
{ id: 'progress', label: 'Progress', desc: '進捗報告の頻度' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'セキュリティ・アクセス制御',
|
||||
sections: [
|
||||
{ id: 'repos', label: 'Repos', desc: 'Gitea 接続・許可リポジトリ' },
|
||||
{ id: 'access-control', label: 'Access Control', desc: 'ロール・権限マトリクス' },
|
||||
{ id: 'search-filter', label: 'Search Filter', desc: 'NGワード・ドメイン制限' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'ツール設定',
|
||||
sections: [
|
||||
{ id: 'tools', label: 'Tools', desc: '利用可能なツールの有効化' },
|
||||
{ id: 'browser-settings', label: 'Browser', desc: 'noVNC・セッション' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'エージェント制御',
|
||||
sections: [
|
||||
{ id: 'ask-subtasks', label: 'Ask / Subtasks', desc: 'ASK・サブタスクの挙動' },
|
||||
{ id: 'context', label: 'Context', desc: 'コンテキスト長・注入ルール' },
|
||||
{ id: 'memory-safety', label: 'Memory / Safety', desc: 'メモリ制限・安全装置' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const PIECES = ['auto', 'chat', 'research', 'general', 'x-ai-digest', 'brainstorming', 'data-process'];
|
||||
|
||||
function SettingsSidebar({ section, onSelect, piece, onSelectPiece }) {
|
||||
const itemStyle = (active) => ({
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, fontFamily: 'inherit', marginBottom: 1,
|
||||
background: active ? '#eff6ff' : 'transparent',
|
||||
color: active ? '#1d4ed8' : '#475569',
|
||||
fontWeight: active ? 700 : 500,
|
||||
});
|
||||
return (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '12px 10px', background: '#fff', borderRight: '1px solid #e2e8f0' }}>
|
||||
{SETTINGS_GROUPS.map(g => (
|
||||
<div key={g.label} style={{ marginBottom: 12 }}>
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
|
||||
textTransform: 'uppercase', padding: '4px 10px 4px', marginBottom: 2,
|
||||
}}>{g.label}</div>
|
||||
{g.sections.map(s => (
|
||||
<button key={s.id} style={itemStyle(section === s.id && !piece)} onClick={() => onSelect(s.id)}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
|
||||
textTransform: 'uppercase', padding: '4px 10px 4px', marginBottom: 2,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>Pieces</span>
|
||||
<button title="Piece を追加" style={{
|
||||
border: 'none', background: 'transparent', color: '#2563eb',
|
||||
cursor: 'pointer', fontSize: 14, fontWeight: 700, padding: 0, lineHeight: 1,
|
||||
}}>+</button>
|
||||
</div>
|
||||
{PIECES.map(p => (
|
||||
<button key={p} style={itemStyle(piece === p)} onClick={() => onSelectPiece(p)}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveBar({ onDiscard }) {
|
||||
// 3 states: idle / saving / saved / error — demonstrated via toggle
|
||||
const [state, setState] = React.useState('idle');
|
||||
const [dirty, setDirty] = React.useState(false);
|
||||
// mark dirty when any descendant input changes
|
||||
const onInput = React.useCallback(() => setDirty(true), []);
|
||||
React.useEffect(() => {
|
||||
const h = () => setDirty(true);
|
||||
document.addEventListener('input', h);
|
||||
return () => document.removeEventListener('input', h);
|
||||
}, []);
|
||||
const save = async () => {
|
||||
setState('saving');
|
||||
// mock latency; in 1/5 odds surface an error to showcase failure UI
|
||||
await new Promise(r => setTimeout(r, 700));
|
||||
if (Math.random() < 0.2) {
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
setState('saved'); setDirty(false);
|
||||
setTimeout(() => setState('idle'), 1500);
|
||||
};
|
||||
const discard = () => { setDirty(false); setState('idle'); onDiscard?.(); };
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{state === 'saved' && (
|
||||
<span style={{
|
||||
fontSize: 11, color: '#166534', background: '#dcfce7', padding: '3px 8px',
|
||||
borderRadius: 9999, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
}}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>
|
||||
保存しました
|
||||
</span>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<span style={{
|
||||
fontSize: 11, color: '#b91c1c', background: '#fee2e2', padding: '3px 8px',
|
||||
borderRadius: 9999, fontWeight: 700, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
}}>⚠ 保存に失敗</span>
|
||||
)}
|
||||
<button onClick={discard} disabled={!dirty || state === 'saving'} style={{
|
||||
padding: '6px 12px', background: '#fff', border: '1px solid #e2e8f0', color: '#475569',
|
||||
borderRadius: 8, fontSize: 12, fontWeight: 700,
|
||||
cursor: (!dirty || state === 'saving') ? 'not-allowed' : 'pointer',
|
||||
opacity: (!dirty || state === 'saving') ? 0.5 : 1,
|
||||
fontFamily: 'inherit',
|
||||
}}>Discard</button>
|
||||
<button onClick={save} disabled={state === 'saving'} style={{
|
||||
padding: '6px 14px', background: '#2563eb', border: 'none', color: '#fff',
|
||||
borderRadius: 8, fontSize: 12, fontWeight: 700,
|
||||
cursor: state === 'saving' ? 'wait' : 'pointer',
|
||||
opacity: state === 'saving' ? 0.7 : 1,
|
||||
fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
{state === 'saving' && <Spinner />}
|
||||
{state === 'saving' ? '保存中…' : 'Save & Apply'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple mock form surface — shows that the detail pane follows the exact same "card with form" rhythm as Schedules/Users.
|
||||
function SettingsForm({ section, piece }) {
|
||||
const meta = (() => {
|
||||
for (const g of SETTINGS_GROUPS) for (const s of g.sections) if (s.id === section) return s;
|
||||
return null;
|
||||
})();
|
||||
const title = piece ? `Piece: ${piece}` : (meta?.label || section);
|
||||
const desc = piece ? `${piece} ピースの定義・ムーブメント・ツール設定` : (meta?.desc || '');
|
||||
|
||||
// Sample fields per-section (placeholder — the real forms are in gitea-agent-orchestrator/ui)
|
||||
const fields = piece ? [
|
||||
{ label: 'Description', kind: 'text', value: piece === 'auto' ? '入力内容から最適なピースを自動選択' : `${piece} 用の定義` },
|
||||
{ label: 'Max movements', kind: 'number', value: 25 },
|
||||
{ label: 'Initial movement', kind: 'select', value: 'execute', options: ['execute', 'plan', 'research'] },
|
||||
] : ({
|
||||
provider: [
|
||||
{ label: 'Default provider', kind: 'select', value: 'anthropic', options: ['anthropic', 'openai', 'google', 'bedrock'] },
|
||||
{ label: 'Model', kind: 'select', value: 'claude-sonnet-4.5', options: ['claude-sonnet-4.5', 'claude-opus-4', 'gpt-4.1'] },
|
||||
{ label: 'API key', kind: 'password', value: 'sk-ant-•••••••••••••••••••••••', env: true },
|
||||
{ label: 'Max tokens', kind: 'number', value: 8192 },
|
||||
{ label: 'Temperature', kind: 'number', value: 0.7, step: 0.1 },
|
||||
],
|
||||
workers: [
|
||||
{ label: 'Parallel workers', kind: 'number', value: 6 },
|
||||
{ label: 'Per-task timeout (sec)', kind: 'number', value: 900 },
|
||||
{ label: 'Max retries', kind: 'number', value: 3 },
|
||||
{ label: 'Retry backoff (sec)', kind: 'number', value: 60 },
|
||||
],
|
||||
general: [
|
||||
{ label: 'System timezone', kind: 'select', value: 'Asia/Tokyo', options: ['Asia/Tokyo', 'UTC', 'America/Los_Angeles'] },
|
||||
{ label: 'Language', kind: 'select', value: 'ja', options: ['ja', 'en'] },
|
||||
{ label: 'Allow anonymous task creation', kind: 'toggle', value: false },
|
||||
],
|
||||
repos: [
|
||||
{ label: 'Gitea URL', kind: 'text', value: 'https://gitea.internal' },
|
||||
{ label: 'Access token', kind: 'password', value: 'gitea_•••••••••••', env: true },
|
||||
{ label: 'Allowed repos', kind: 'text', value: 'daichi/*, corp/*', help: 'カンマ区切り・グロブ可' },
|
||||
],
|
||||
'access-control': [
|
||||
{ label: 'Admin ロール allow', kind: 'text', value: '*' },
|
||||
{ label: 'Operator ロール allow', kind: 'text', value: 'tasks.*, schedules.*' },
|
||||
{ label: 'Viewer ロール allow', kind: 'text', value: 'tasks.read, schedules.read' },
|
||||
],
|
||||
tools: [
|
||||
{ label: 'Read / Write / Edit', kind: 'toggle', value: true },
|
||||
{ label: 'Bash', kind: 'toggle', value: true },
|
||||
{ label: 'Browser (noVNC)', kind: 'toggle', value: true },
|
||||
{ label: 'WebSearch', kind: 'toggle', value: false },
|
||||
],
|
||||
'browser-settings': [
|
||||
{ label: 'noVNC endpoint', kind: 'text', value: 'https://novnc.internal' },
|
||||
{ label: 'Session timeout (min)', kind: 'number', value: 30 },
|
||||
{ label: 'Allow CAPTCHA fallback to human', kind: 'toggle', value: true },
|
||||
],
|
||||
'ask-subtasks': [
|
||||
{ label: 'Max ASK depth', kind: 'number', value: 3 },
|
||||
{ label: 'Auto-resume after ASK timeout (min)', kind: 'number', value: 60 },
|
||||
{ label: 'Allow parallel subtasks', kind: 'toggle', value: true },
|
||||
],
|
||||
context: [
|
||||
{ label: 'Context window (tokens)', kind: 'number', value: 200000 },
|
||||
{ label: 'Auto-compact threshold', kind: 'number', value: 80, help: '% で指定' },
|
||||
],
|
||||
'memory-safety': [
|
||||
{ label: 'Memory limit per worker (MB)', kind: 'number', value: 2048 },
|
||||
{ label: 'Kill on OOM', kind: 'toggle', value: true },
|
||||
{ label: 'Safe-mode Bash commands only', kind: 'toggle', value: false },
|
||||
],
|
||||
progress: [
|
||||
{ label: 'Progress update interval (sec)', kind: 'number', value: 15 },
|
||||
{ label: 'Show subtask progress', kind: 'toggle', value: true },
|
||||
],
|
||||
workspace: [
|
||||
{ label: 'Workspace root', kind: 'text', value: '/var/lib/agent/workspace' },
|
||||
{ label: 'Clean after task completion', kind: 'toggle', value: false },
|
||||
],
|
||||
'search-filter': [
|
||||
{ label: 'Blocked domains', kind: 'text', value: 'example-bad.com, *.malicious.example' },
|
||||
{ label: 'NG words', kind: 'text', value: '', help: 'カンマ区切り' },
|
||||
],
|
||||
}[section] || []);
|
||||
|
||||
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={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#64748b', letterSpacing: '.08em', textTransform: 'uppercase' }}>
|
||||
{piece ? 'PIECE' : 'SETTINGS'}
|
||||
</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>{title}</div>
|
||||
{desc && <div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{desc}</div>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<SaveBar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px', background: '#f8fafc' }}>
|
||||
<div style={{ maxWidth: 640, margin: '0 auto' }}>
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 16, padding: 20,
|
||||
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
}}>
|
||||
{fields.map((f, i) => (
|
||||
<FormRow key={i} label={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{f.label}
|
||||
{f.env && <span style={{ fontSize: 9, fontWeight: 700, color: '#92400e', background: '#fef3c7', padding: '1px 5px', borderRadius: 3 }}>ENV</span>}
|
||||
</span>
|
||||
} help={f.help}>
|
||||
{f.kind === 'toggle' ? (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', width: 44, height: 24, borderRadius: 9999,
|
||||
background: f.value ? '#2563eb' : '#cbd5e1', padding: 2,
|
||||
justifyContent: f.value ? 'flex-end' : 'flex-start', cursor: 'pointer',
|
||||
}}>
|
||||
<div style={{ width: 20, height: 20, borderRadius: 9999, background: '#fff', boxShadow: '0 1px 2px rgb(0 0 0 / .2)' }} />
|
||||
</div>
|
||||
) : f.kind === 'select' ? (
|
||||
<SelectInput value={f.value} onChange={() => {}}>
|
||||
{f.options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</SelectInput>
|
||||
) : f.kind === 'password' ? (
|
||||
<TextInput type="password" value={f.value} readOnly={!!f.env}
|
||||
style={{ fontFamily: 'IBM Plex Mono, monospace', background: f.env ? '#f8fafc' : '#fff' }} />
|
||||
) : f.kind === 'number' ? (
|
||||
<TextInput type="number" value={f.value} step={f.step || 1} onChange={() => {}} />
|
||||
) : (
|
||||
<TextInput value={f.value} onChange={() => {}} />
|
||||
)}
|
||||
</FormRow>
|
||||
))}
|
||||
{fields.length === 0 && (
|
||||
<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 8v4M12 16h.01"/></svg>}
|
||||
title="このセクションは未実装です"
|
||||
hint="`gitea-agent-orchestrator/ui` 側で設定項目を追加するとここに表示されます。"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height: 40 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage({ section, setSection, piece, setPiece }) {
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, minHeight: 0, display: 'grid',
|
||||
gridTemplateColumns: '240px 1fr',
|
||||
background: '#f1f5f9', gap: 1,
|
||||
}}>
|
||||
<SettingsSidebar
|
||||
section={section} onSelect={(s) => { setSection(s); setPiece(null); }}
|
||||
piece={piece} onSelectPiece={(p) => setPiece(p)}
|
||||
/>
|
||||
<div style={{ background: '#fff', minWidth: 0 }}>
|
||||
<SettingsForm section={section} piece={piece} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.SettingsPage = SettingsPage;
|
||||
@@ -0,0 +1,199 @@
|
||||
// TaskList — FilterBar + LocalTaskListItem recreation
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'updated', label: '新しい順' },
|
||||
{ value: 'status', label: 'ステータス順' },
|
||||
{ value: 'title', label: 'タイトル順' },
|
||||
];
|
||||
|
||||
function SortMenu({ sort, onSort }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h);
|
||||
}, []);
|
||||
const current = SORT_OPTIONS.find(o => o.value === sort) || SORT_OPTIONS[0];
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
title={`並び順: ${current.label}`}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 28, height: 28, border: 'none', background: open ? '#eff6ff' : 'transparent',
|
||||
color: open ? '#1d4ed8' : '#64748b', borderRadius: 8, cursor: 'pointer',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3"/></svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 10,
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
minWidth: 160, padding: 4,
|
||||
}}>
|
||||
{SORT_OPTIONS.map(o => (
|
||||
<button key={o.value} onClick={() => { onSort(o.value); setOpen(false); }} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
width: '100%', padding: '6px 10px', borderRadius: 8, border: 'none',
|
||||
background: sort === o.value ? '#eff6ff' : 'transparent',
|
||||
color: sort === o.value ? '#1d4ed8' : '#334155',
|
||||
fontSize: 12, fontWeight: sort === o.value ? 700 : 500, cursor: 'pointer',
|
||||
fontFamily: 'inherit', textAlign: 'left',
|
||||
}}>
|
||||
{o.label}
|
||||
{sort === o.value && <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterBar({ status, onStatus, search, onSearch, sort, onSort, counts, total }) {
|
||||
const columns = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled'];
|
||||
const chipStyle = (active) => ({
|
||||
flexShrink: 0, padding: '6px 10px', borderRadius: 9999,
|
||||
fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', cursor: 'pointer',
|
||||
border: '1px solid ' + (active ? '#2563eb' : '#e2e8f0'),
|
||||
background: active ? '#eff6ff' : '#fff',
|
||||
color: active ? '#1d4ed8' : '#64748b',
|
||||
fontFamily: 'inherit',
|
||||
});
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingBottom: 12, borderBottom: '1px solid #e2e8f0' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, background: '#fff', border: '1px solid #e2e8f0',
|
||||
borderRadius: 12, padding: '4px 6px 4px 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) => onSearch(e.target.value)} placeholder="検索..."
|
||||
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: '#0f172a', minWidth: 0, padding: '4px 0' }} />
|
||||
<div style={{ width: 1, height: 18, background: '#e2e8f0', flexShrink: 0 }} />
|
||||
<SortMenu sort={sort} onSort={onSort} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
|
||||
<button style={chipStyle(status === 'all')} onClick={() => onStatus('all')}>
|
||||
All <span style={{ color: '#94a3b8', marginLeft: 2 }}>{total}</span>
|
||||
</button>
|
||||
{columns.map(s => (
|
||||
<button key={s} style={chipStyle(status === s)} onClick={() => onStatus(s)}>
|
||||
{STATUS_LABELS[s]} <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts[s] || 0}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskItem({ task, 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', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
#{task.id} {task.title}
|
||||
</div>
|
||||
<StatusBadge status={task.status} small />
|
||||
</div>
|
||||
<div style={{ marginTop: 2, fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.body.length > 60 ? task.body.slice(0, 60) + '…' : task.body}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, fontSize: 10, color: '#94a3b8' }}>{relativeTime(task.updatedAt)}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskList({ tasks, activeId, onSelect, filters, setFilters, onOpenCreate, loading, error, onRetry }) {
|
||||
const counts = {};
|
||||
for (const s of ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retry', 'succeeded', 'failed', 'cancelled']) {
|
||||
counts[s] = tasks.filter(t => t.status === s).length;
|
||||
}
|
||||
const running = counts.running || 0;
|
||||
const waiting = (counts.waiting_human || 0) + (counts.waiting_subtasks || 0);
|
||||
const failed = counts.failed || 0;
|
||||
const filtered = tasks
|
||||
.filter(t => filters.status === 'all' || t.status === filters.status)
|
||||
.filter(t => !filters.search || (t.title + t.body).toLowerCase().includes(filters.search.toLowerCase()))
|
||||
.sort((a, b) => filters.sort === 'title' ? a.title.localeCompare(b.title) : b.updatedAt - a.updatedAt);
|
||||
|
||||
const hasSearch = !!filters.search || filters.status !== 'all';
|
||||
|
||||
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)',
|
||||
transition: 'background .15s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#1d4ed8'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = '#2563eb'}
|
||||
>
|
||||
<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 }}>{tasks.length}</b> 件</span>
|
||||
<span style={{ color: '#cbd5e1' }}>·</span>
|
||||
<span><b style={{ color: '#16a34a', fontWeight: 700 }}>{running}</b> 実行中</span>
|
||||
<span><b style={{ color: '#d97706', fontWeight: 700 }}>{waiting}</b> 待機</span>
|
||||
{failed > 0 && <span><b style={{ color: '#dc2626', fontWeight: 700 }}>{failed}</b> 失敗</span>}
|
||||
</div>
|
||||
<FilterBar
|
||||
status={filters.status} onStatus={(s) => setFilters(f => ({ ...f, status: s }))}
|
||||
search={filters.search} onSearch={(q) => setFilters(f => ({ ...f, search: q }))}
|
||||
sort={filters.sort} onSort={(s) => setFilters(f => ({ ...f, sort: s }))}
|
||||
counts={counts} total={tasks.length}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||
{loading && <SkeletonList count={6} />}
|
||||
{!loading && error && (
|
||||
<ErrorState
|
||||
title="タスクの読み込みに失敗"
|
||||
hint={error}
|
||||
onRetry={onRetry}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
{!loading && !error && filtered.map(t => <TaskItem key={t.id} task={t} active={activeId === t.id} onClick={() => onSelect(t.id)} />)}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
hasSearch ? (
|
||||
<EmptyState
|
||||
compact
|
||||
icon={<IconSearch width={18} height={18} />}
|
||||
title="該当するタスクはありません"
|
||||
hint="検索ワードやステータスフィルタを変えてみてください。"
|
||||
action={
|
||||
<button onClick={() => setFilters(f => ({ ...f, search: '', status: '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"><path d="M9 12h6M9 16h6M9 8h6M5 21h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>}
|
||||
title="まだ依頼がありません"
|
||||
hint="左上の「新しい依頼」から最初のタスクを作成できます。"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.TaskList = TaskList;
|
||||
@@ -0,0 +1,57 @@
|
||||
// TopBar — mirrors ui/src/components/layout/TopBar.tsx
|
||||
function TopBar({ page, onNavigate, counts, onOpenCreate, user }) {
|
||||
const navItem = (id, label) => {
|
||||
const active = page === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => onNavigate(id)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '10px 4px', marginBottom: -13,
|
||||
fontSize: 12, fontWeight: active ? 700 : 500,
|
||||
border: 'none', background: 'transparent', cursor: 'pointer', fontFamily: 'inherit',
|
||||
color: active ? '#0f172a' : '#64748b',
|
||||
borderBottom: '2px solid ' + (active ? '#2563eb' : 'transparent'),
|
||||
transition: 'color .15s, border-color .15s',
|
||||
}}
|
||||
>{label}</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
flexShrink: 0, background: '#fff', borderBottom: '1px solid #e2e8f0',
|
||||
padding: '12px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<img src="../../assets/logo.svg" width="22" height="22" alt="" />
|
||||
<span style={{
|
||||
fontFamily: 'IBM Plex Mono, monospace', fontSize: 11, fontWeight: 700,
|
||||
color: '#2563eb', textTransform: 'uppercase', letterSpacing: '.16em',
|
||||
}}>Agent Orchestrator</span>
|
||||
<span style={{ fontFamily: 'IBM Plex Mono, monospace', fontSize: 10, color: '#94a3b8' }}>v1.14.0</span>
|
||||
<div style={{ display: 'flex', gap: 14, alignItems: 'stretch' }}>
|
||||
{navItem('tasks', 'Tasks')}
|
||||
{navItem('schedules', 'Schedules')}
|
||||
{navItem('settings', 'Settings')}
|
||||
{navItem('users', 'Users')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{user && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: 9999, background: '#dbeafe', color: '#1d4ed8',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700,
|
||||
}}>{user.name.charAt(0)}</div>
|
||||
<span style={{ fontSize: 12, color: '#475569' }}>{user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.TopBar = TopBar;
|
||||
@@ -0,0 +1,313 @@
|
||||
// UsersPage — left list of users + center profile/role editor
|
||||
// Data model derives from ui/src/pages/UsersPage.tsx.
|
||||
|
||||
const ROLE_TONE = {
|
||||
admin: { bg: '#ede9fe', fg: '#5b21b6' },
|
||||
operator: { bg: '#dbeafe', fg: '#1d4ed8' },
|
||||
viewer: { bg: '#e2e8f0', fg: '#475569' },
|
||||
};
|
||||
|
||||
const USER_STATUS_TONE = {
|
||||
pending: { bg: '#fef9c3', fg: '#854d0e', label: '承認待ち' },
|
||||
active: { bg: '#dcfce7', fg: '#166534', label: 'アクティブ' },
|
||||
disabled: { bg: '#e2e8f0', fg: '#475569', label: '無効' },
|
||||
};
|
||||
|
||||
function UserAvatar({ name, size = 32 }) {
|
||||
const initial = (name || '?').charAt(0).toUpperCase();
|
||||
// simple deterministic hue from name
|
||||
let hue = 0; for (const c of (name || '')) hue = (hue * 31 + c.charCodeAt(0)) % 360;
|
||||
return (
|
||||
<div style={{
|
||||
width: size, height: size, borderRadius: 9999,
|
||||
background: `hsl(${hue} 60% 92%)`, color: `hsl(${hue} 50% 35%)`,
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.45, fontWeight: 800, flexShrink: 0,
|
||||
}}>{initial}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserListItem({ user, 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',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<UserAvatar name={user.name || user.email} size={36} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||
<div style={{
|
||||
flex: 1, minWidth: 0,
|
||||
fontSize: 13, fontWeight: 700, color: '#0f172a',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{user.name || '(未設定)'}</div>
|
||||
{user.status === 'pending' && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, color: '#854d0e', background: '#fef9c3',
|
||||
padding: '1px 6px', borderRadius: 4, flexShrink: 0,
|
||||
}}>承認</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{user.email}
|
||||
</div>
|
||||
<div style={{ marginTop: 2, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 4,
|
||||
background: ROLE_TONE[user.role]?.bg, color: ROLE_TONE[user.role]?.fg,
|
||||
}}>{user.role}</span>
|
||||
<span style={{ fontSize: 10, color: '#94a3b8' }}>· {user.taskCount} タスク</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UserListPane({ users, activeId, onSelect, filter, setFilter, search, setSearch, onOpenInvite }) {
|
||||
const filtered = users.filter(u => {
|
||||
if (filter !== 'all' && u.role !== filter && u.status !== filter) return false;
|
||||
if (search && !((u.name || '') + u.email).toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
const counts = {
|
||||
all: users.length,
|
||||
admin: users.filter(u => u.role === 'admin').length,
|
||||
operator: users.filter(u => u.role === 'operator').length,
|
||||
viewer: users.filter(u => u.role === 'viewer').length,
|
||||
pending: users.filter(u => u.status === 'pending').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={onOpenInvite} 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>
|
||||
{counts.pending > 0 && <><span style={{ color: '#cbd5e1' }}>·</span>
|
||||
<span><b style={{ color: '#d97706', fontWeight: 700 }}>{counts.pending}</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 === 'admin')} onClick={() => setFilter('admin')}>Admin <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.admin}</span></button>
|
||||
<button style={chipStyle(filter === 'operator')} onClick={() => setFilter('operator')}>Operator <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.operator}</span></button>
|
||||
<button style={chipStyle(filter === 'viewer')} onClick={() => setFilter('viewer')}>Viewer <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.viewer}</span></button>
|
||||
{counts.pending > 0 && <button style={chipStyle(filter === 'pending')} onClick={() => setFilter('pending')}>承認待ち <span style={{ color: '#94a3b8', marginLeft: 2 }}>{counts.pending}</span></button>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||
{filtered.map(u => <UserListItem key={u.id} user={u} active={activeId === u.id} onClick={() => onSelect(u.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"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/></svg>}
|
||||
title="ユーザーがいません"
|
||||
hint="右上の「ユーザーを招待」から追加できます。"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetail({ user, onPatch, onDelete, onApprove }) {
|
||||
if (!user) {
|
||||
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="8" r="4"/><path d="M4 21v-1a8 8 0 0116 0v1"/></svg>}
|
||||
title="ユーザーを選択してください"
|
||||
hint="左のリストから表示・編集したいユーザーを開きます。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const statusTone = USER_STATUS_TONE[user.status] || USER_STATUS_TONE.active;
|
||||
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: 12, minWidth: 0 }}>
|
||||
<UserAvatar name={user.name || user.email} size={40} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>{user.name || '(未設定)'}</div>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 9999,
|
||||
background: statusTone.bg, color: statusTone.fg,
|
||||
}}>{statusTone.label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
{user.status === 'pending' && (
|
||||
<button onClick={() => onApprove(user.id)} style={{
|
||||
padding: '6px 12px', background: '#16a34a', border: 'none', color: '#fff',
|
||||
borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>承認</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(user.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={user.taskCount} />
|
||||
<StatChip label="最終ログイン" value={user.lastLogin || '—'} />
|
||||
<StatChip label="登録日" value={user.createdAt || '—'} />
|
||||
</div>
|
||||
|
||||
{/* Role & permissions */}
|
||||
<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>
|
||||
{[
|
||||
{ id: 'admin', label: 'Admin', desc: '全ての設定変更・ユーザー管理・システム操作' },
|
||||
{ id: 'operator', label: 'Operator', desc: 'タスク作成・実行・スケジュール管理' },
|
||||
{ id: 'viewer', label: 'Viewer', desc: '閲覧のみ。タスクの作成・変更不可' },
|
||||
].map(r => (
|
||||
<button key={r.id} onClick={() => onPatch(user.id, { role: r.id })} style={{
|
||||
width: '100%', textAlign: 'left', padding: '12px 14px', borderRadius: 10,
|
||||
border: '1px solid ' + (user.role === r.id ? '#2563eb' : '#e2e8f0'),
|
||||
background: user.role === r.id ? '#eff6ff' : '#fff',
|
||||
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 8,
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<span style={{
|
||||
width: 18, height: 18, borderRadius: 9999, flexShrink: 0,
|
||||
border: '2px solid ' + (user.role === r.id ? '#2563eb' : '#cbd5e1'),
|
||||
background: user.role === r.id ? '#2563eb' : '#fff',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{user.role === r.id && <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M5 13l4 4L19 7"/></svg>}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#0f172a' }}>{r.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 2 }}>{r.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Profile */}
|
||||
<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>
|
||||
<FormRow label="表示名">
|
||||
<TextInput value={user.name || ''} onChange={e => onPatch(user.id, { name: e.target.value })} />
|
||||
</FormRow>
|
||||
<FormRow label="メールアドレス">
|
||||
<TextInput value={user.email} readOnly style={{ background: '#f8fafc', color: '#64748b' }} />
|
||||
</FormRow>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<FormRow label="タイムゾーン">
|
||||
<SelectInput value={user.timezone || 'Asia/Tokyo'} onChange={e => onPatch(user.id, { timezone: e.target.value })}>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
</SelectInput>
|
||||
</FormRow>
|
||||
<FormRow label="デフォルトピース">
|
||||
<SelectInput value={user.defaultPiece || 'auto'} onChange={e => onPatch(user.id, { defaultPiece: e.target.value })}>
|
||||
<option value="auto">auto</option>
|
||||
<option value="chat">chat</option>
|
||||
<option value="research">research</option>
|
||||
</SelectInput>
|
||||
</FormRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 40 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPage({ users, activeId, setActiveId, onPatch, onDelete, onApprove, onOpenInvite }) {
|
||||
const [filter, setFilter] = React.useState('all');
|
||||
const [search, setSearch] = React.useState('');
|
||||
const active = users.find(u => u.id === activeId) || users[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' }}>
|
||||
<UserListPane
|
||||
users={users} activeId={active?.id} onSelect={setActiveId}
|
||||
filter={filter} setFilter={setFilter} search={search} setSearch={setSearch}
|
||||
onOpenInvite={onOpenInvite}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ background: '#fff', minWidth: 0 }}>
|
||||
<UserDetail user={active} onPatch={onPatch} onDelete={onDelete} onApprove={onApprove} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.UsersPage = UsersPage;
|
||||
@@ -0,0 +1,399 @@
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Agent Orchestrator — Admin</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-app, #f8fafc);
|
||||
color: var(--fg1, #0f172a);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@keyframes ao-spin { from { transform: rotate(0) } to { transform: rotate(360deg) } }
|
||||
@keyframes ao-pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.35 } }
|
||||
@keyframes ao-shimmer { 0% { background-position: 200% 0 } 100% { background-position: -200% 0 } }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border: 2px solid #f8fafc; border-radius: 10px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/[email protected]/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/[email protected]/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="./Primitives.jsx"></script>
|
||||
<script type="text/babel" src="./TopBar.jsx"></script>
|
||||
<script type="text/babel" src="./TaskList.jsx"></script>
|
||||
<script type="text/babel" src="./ChatPane.jsx"></script>
|
||||
<script type="text/babel" src="./DetailPanel.jsx"></script>
|
||||
<script type="text/babel" src="./SchedulesPage.jsx"></script>
|
||||
<script type="text/babel" src="./UsersPage.jsx"></script>
|
||||
<script type="text/babel" src="./SettingsPage.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
const MIN = 60 * 1000;
|
||||
const H = 60 * MIN;
|
||||
const now = Date.now();
|
||||
|
||||
const SAMPLE_TASKS = [
|
||||
{
|
||||
id: 412, title: 'Xの朝のAIダイジェスト生成',
|
||||
body: '毎朝 7:00 JST にフォロー中のAI関連アカウントの過去24hをサマリし、DMで送信する。Twitter CLIを使用し、1スレッドにまとめること。',
|
||||
status: 'running', piece: 'x-ai-digest', worker: 'worker-03', attempts: 1,
|
||||
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/412-morning-digest',
|
||||
createdAt: now - 2*H, updatedAt: now - 4*MIN,
|
||||
events: [
|
||||
{ kind: 'info', label: '/brainstorm 完了', meta: '12個のアイデアを生成', time: '10:42' },
|
||||
{ kind: 'info', label: '/plan 完了', meta: '12ステップ · 推定 4分', time: '10:43' },
|
||||
{ kind: 'info', label: '/implement 実行中', meta: 'ステップ 8 / 12', time: '10:45' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 411, title: 'Brave Search の CAPTCHA 回避',
|
||||
body: 'noVNC経由でBraveに繰り返しCAPTCHAが発生。ユーザーの介入が必要。',
|
||||
status: 'waiting_human', piece: 'general', worker: 'worker-01', attempts: 2,
|
||||
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/411-brave-captcha',
|
||||
createdAt: now - 3*H, updatedAt: now - 18*MIN,
|
||||
events: [
|
||||
{ kind: 'info', label: '/brainstorm 完了', meta: '', time: '08:10' },
|
||||
{ kind: 'error', label: 'ASK が発行されました', meta: 'CAPTCHAの解決を依頼', time: '08:22' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 410, title: 'GitHub Issue #284 の対応',
|
||||
body: 'scheduler.ts のタイムアウト処理リファクタ。worker-manager.test.ts を更新。',
|
||||
status: 'succeeded', piece: 'general', worker: 'worker-02', attempts: 1,
|
||||
assignee: '@daichi', repo: 'gitea:daichi/agent-orchestrator', branch: 'task/410-sched-timeout',
|
||||
createdAt: now - 8*H, updatedAt: now - 2*H,
|
||||
events: [
|
||||
{ kind: 'info', label: '/plan 完了', meta: '7ステップ', time: '02:11' },
|
||||
{ kind: 'ok', label: 'PR 作成', meta: '#284 テスト通過', time: '04:08' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 409, title: 'ブレスト: 社内AIエージェント活用事例',
|
||||
body: '営業部向け、週次の活用アイデアを10件ブレストし、優先順位をつけて提出。',
|
||||
status: 'queued', piece: 'brainstorming', worker: null, attempts: 0,
|
||||
assignee: '@tomoko', repo: 'gitea:corp/ops', branch: '—',
|
||||
createdAt: now - 30*MIN, updatedAt: now - 25*MIN,
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
id: 408, title: '四半期データの集計とグラフ化',
|
||||
body: 'Q3のSNSエンゲージメントを集計し、CSVとPNGで出力。',
|
||||
status: 'waiting_subtasks', piece: 'data-process', worker: 'worker-05', attempts: 1,
|
||||
assignee: '@kenta', repo: 'gitea:corp/analytics', branch: 'task/408-q3-roundup',
|
||||
createdAt: now - 5*H, updatedAt: now - 45*MIN,
|
||||
events: [
|
||||
{ kind: 'info', label: 'サブタスク3件を発行', meta: '#408-1, #408-2, #408-3', time: '09:30' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 407, title: '競合サービスのリサーチ',
|
||||
body: 'エージェント型ワーカー系SaaSを3社分析し、比較表を作成する。',
|
||||
status: 'failed', piece: 'research', worker: 'worker-04', attempts: 3,
|
||||
assignee: '@tomoko', repo: 'gitea:corp/research', branch: 'task/407-competitors',
|
||||
createdAt: now - 26*H, updatedAt: now - 10*H,
|
||||
events: [
|
||||
{ kind: 'error', label: 'タイムアウト', meta: '3回連続で失敗', time: 'yesterday' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 406, title: 'ゲーム実況の告知ツイート生成',
|
||||
body: '今夜のストリーム用の告知ツイートを3案作成。ハッシュタグ付き。',
|
||||
status: 'retry', piece: 'game-tweet-generator', worker: 'worker-06', attempts: 2,
|
||||
assignee: '@daichi', repo: 'gitea:daichi/stream', branch: 'task/406-tweet-gen',
|
||||
createdAt: now - 40*MIN, updatedAt: now - 8*MIN,
|
||||
events: [
|
||||
{ kind: 'error', label: 'レート制限', meta: '60秒後に再試行', time: '10:35' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 405, title: '経費申請書のOCRと仕分け',
|
||||
body: '添付PDFをOCRし、勘定科目ごとに仕分け。',
|
||||
status: 'cancelled', piece: 'office-process', worker: null, attempts: 1,
|
||||
assignee: '@kenta', repo: 'gitea:corp/ops', branch: '—',
|
||||
createdAt: now - 48*H, updatedAt: now - 20*H,
|
||||
events: [],
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_MESSAGES = {
|
||||
412: [
|
||||
{ role: 'user', content: '毎朝7:00にAI関連のXアカウントの過去24hをまとめて、DMで送ってほしい。1スレッドで。', footer: '10:41 · @daichi' },
|
||||
{ role: 'assistant', content: '了解。x-ai-digest ピースを使用します。対象アカウント、まとめる観点、文字数制限を確認させてください。' },
|
||||
{ role: 'ask', content: '❓ 以下を確認させてください:\n\n1. 対象アカウントリストはこのリポジトリの accounts.txt で良いですか?\n2. 1ツイートあたりの上限文字数は280でOK?\n3. 日本語メインの要約で良いですか?' },
|
||||
{ role: 'user', content: '1. OK\n2. OK\n3. 日本語で。でも原文が英語なら簡潔な英語の引用も残して。' },
|
||||
{ role: 'progress', content: '/implement — ステップ 8 / 12 (Twitter CLIでタイムライン取得中)' },
|
||||
],
|
||||
411: [
|
||||
{ role: 'user', content: 'Brave Searchで検索結果が取れない。何度もCAPTCHAが出てるっぽい。' },
|
||||
{ role: 'progress', content: 'noVNCセッションを開いて確認中...' },
|
||||
{ role: 'ask', content: '❓ CAPTCHAの解決が必要です。noVNCで手動で解決していただけますか?\n\nsession: https://novnc.internal/412\n\n解決後 `/resume 411` と返信してください。' },
|
||||
],
|
||||
410: [
|
||||
{ role: 'user', content: 'Issue #284 の対応お願い。scheduler.tsのタイムアウト処理が不安定。' },
|
||||
{ role: 'assistant', content: '了解。/brainstorm から始めます。' },
|
||||
{ role: 'result', content: '✅ 完了しました。\n\n- PR: gitea:daichi/agent-orchestrator#291\n- 変更: scheduler.ts, worker-manager.test.ts, worker.test.ts\n- テスト: 42 passed\n\nレビューお願いします。' },
|
||||
],
|
||||
409: [
|
||||
{ role: 'user', content: '営業部向けに今週のエージェント活用ネタを10個ブレストしてほしい。' },
|
||||
{ role: 'assistant', content: '了解。キューに入りました。ワーカーの空きが出次第処理します。' },
|
||||
],
|
||||
408: [
|
||||
{ role: 'user', content: 'Q3のSNSエンゲージメントまとめて、折れ線グラフと棒グラフのPNGで。' },
|
||||
{ role: 'progress', content: 'サブタスク3件の完了を待っています (#408-1, #408-2, #408-3)' },
|
||||
],
|
||||
407: [
|
||||
{ role: 'user', content: '競合3社のリサーチと比較表を作成。' },
|
||||
{ role: 'assistant', content: '3回試行しましたが、外部サイトの読み込みタイムアウトが続いています。' },
|
||||
],
|
||||
406: [
|
||||
{ role: 'user', content: '今夜のストリーム告知を3案、ハッシュタグ付きで。' },
|
||||
{ role: 'progress', content: 'レート制限中 — 60秒後に再試行します' },
|
||||
],
|
||||
405: [
|
||||
{ role: 'user', content: '経費PDFのOCRと仕分け。' },
|
||||
{ role: 'assistant', content: 'キャンセルされました。' },
|
||||
],
|
||||
};
|
||||
|
||||
const SAMPLE_SCHEDULES = [
|
||||
{
|
||||
id: 1, title: '毎朝のAIダイジェスト', body: 'フォロー中のAI関連アカウントの過去24hをサマリし、DMで送る。',
|
||||
pieceName: 'x-ai-digest', outputFormat: 'markdown',
|
||||
triggerKind: 'cron', cronExpression: '0 7 * * *',
|
||||
nextRunAt: new Date(now + 8*H).toISOString(),
|
||||
lastRunAt: new Date(now - 16*H).toISOString(),
|
||||
isActive: true,
|
||||
history: [
|
||||
{ taskId: 412, status: 'running', summary: '実行中', at: new Date(now - 4*MIN).toISOString() },
|
||||
{ taskId: 398, status: 'succeeded', summary: '12アカウント ・ 8件のハイライト', at: new Date(now - 16*H).toISOString() },
|
||||
{ taskId: 385, status: 'succeeded', summary: '9アカウント ・ 5件のハイライト', at: new Date(now - 40*H).toISOString() },
|
||||
{ taskId: 373, status: 'failed', summary: 'Twitter API レート制限', at: new Date(now - 64*H).toISOString() },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, title: '週次ニュースまとめ (月曜09:00)', body: '先週の業界ニュースを5本まとめ、社内Slackに投稿。',
|
||||
pieceName: 'research', outputFormat: 'markdown',
|
||||
triggerKind: 'cron', cronExpression: '0 9 * * 1',
|
||||
nextRunAt: new Date(now + 4*24*H).toISOString(),
|
||||
lastRunAt: new Date(now - 3*24*H).toISOString(),
|
||||
isActive: true,
|
||||
history: [
|
||||
{ taskId: 340, status: 'succeeded', summary: '5本投稿', at: new Date(now - 3*24*H).toISOString() },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3, title: 'GitHub Issue 自動トリアージ', body: '新規 Issue にラベル付けし、優先度を判定してコメント。',
|
||||
pieceName: 'general', outputFormat: 'json',
|
||||
triggerKind: 'event', eventSource: 'github.issue.opened', eventFilter: 'repo == "agent-orchestrator"',
|
||||
lastRunAt: new Date(now - 2*H).toISOString(),
|
||||
isActive: true,
|
||||
history: [
|
||||
{ taskId: 408, status: 'succeeded', summary: '#294 に bugラベルを付与', at: new Date(now - 2*H).toISOString() },
|
||||
{ taskId: 402, status: 'succeeded', summary: '#293 に enhancementラベル', at: new Date(now - 6*H).toISOString() },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4, title: '月次レポート生成', body: '月末にKPIサマリを作成し、経営会議用PDFを出力。',
|
||||
pieceName: 'data-process', outputFormat: 'markdown',
|
||||
triggerKind: 'cron', cronExpression: '0 18 28 * *',
|
||||
nextRunAt: new Date(now + 10*24*H).toISOString(),
|
||||
lastRunAt: null,
|
||||
isActive: false,
|
||||
history: [],
|
||||
},
|
||||
];
|
||||
|
||||
const SAMPLE_USERS = [
|
||||
{ id: 'u1', name: 'Daichi', email: '[email protected]', role: 'admin', status: 'active', taskCount: 142, lastLogin: 'たった今', createdAt: '2025-11-02', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
|
||||
{ id: 'u2', name: 'Tomoko', email: '[email protected]', role: 'operator', status: 'active', taskCount: 38, lastLogin: '2時間前', createdAt: '2025-12-14', timezone: 'Asia/Tokyo', defaultPiece: 'chat' },
|
||||
{ id: 'u3', name: 'Kenta', email: '[email protected]', role: 'operator', status: 'active', taskCount: 21, lastLogin: '昨日', createdAt: '2026-01-20', timezone: 'Asia/Tokyo', defaultPiece: 'research' },
|
||||
{ id: 'u4', name: 'Aya', email: '[email protected]', role: 'viewer', status: 'active', taskCount: 4, lastLogin: '3日前', createdAt: '2026-02-09', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
|
||||
{ id: 'u5', name: null, email: '[email protected]', role: 'viewer', status: 'pending', taskCount: 0, lastLogin: '—', createdAt: '2026-04-17', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
|
||||
{ id: 'u6', name: 'Hiro', email: '[email protected]', role: 'viewer', status: 'disabled', taskCount: 2, lastLogin: '1ヶ月前', createdAt: '2026-01-03', timezone: 'Asia/Tokyo', defaultPiece: 'auto' },
|
||||
];
|
||||
|
||||
function DemoStateFloater({ state, setState }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const STATES = [
|
||||
{ id: 'normal', label: '通常', hint: 'サンプルデータを表示' },
|
||||
{ id: 'loading', label: 'Loading', hint: 'スケルトンを表示' },
|
||||
{ id: 'error', label: 'Error', hint: 'エラー状態と再試行ボタン' },
|
||||
{ id: 'empty', label: 'Empty', hint: 'データなし・初回利用' },
|
||||
];
|
||||
const current = STATES.find(s => s.id === state) || STATES[0];
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', right: 16, bottom: 16, zIndex: 50,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
|
||||
}}>
|
||||
{open && (
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #e2e8f0', borderRadius: 12,
|
||||
boxShadow: '0 10px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
padding: 8, minWidth: 220,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, color: '#94a3b8', letterSpacing: '.08em',
|
||||
textTransform: 'uppercase', padding: '4px 10px 6px',
|
||||
}}>デモ状態</div>
|
||||
{STATES.map(s => (
|
||||
<button key={s.id} onClick={() => setState(s.id)} style={{
|
||||
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px',
|
||||
border: 'none', borderRadius: 8, fontFamily: 'inherit', cursor: 'pointer',
|
||||
background: state === s.id ? '#eff6ff' : 'transparent',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: state === s.id ? '#1d4ed8' : '#334155' }}>{s.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#64748b', marginTop: 1 }}>{s.hint}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => setOpen(v => !v)} title="デモ状態を切り替え" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 14px', borderRadius: 9999,
|
||||
background: '#0f172a', color: '#fff', border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 700, fontFamily: 'inherit',
|
||||
boxShadow: '0 4px 12px rgb(0 0 0 / 0.25)',
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 9999,
|
||||
background: state === 'normal' ? '#22c55e' : state === 'loading' ? '#3b82f6' : state === 'error' ? '#ef4444' : '#94a3b8' }} />
|
||||
状態: {current.label}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [tasks, setTasks] = React.useState(SAMPLE_TASKS);
|
||||
const [activeId, setActiveId] = React.useState(412);
|
||||
const [detailOpen, setDetailOpen] = React.useState(true);
|
||||
const [messages, setMessages] = React.useState(INITIAL_MESSAGES);
|
||||
const [filters, setFilters] = React.useState({ status: 'all', search: '', sort: 'updated' });
|
||||
const [page, setPage] = React.useState('tasks');
|
||||
|
||||
const [schedules, setSchedules] = React.useState(SAMPLE_SCHEDULES);
|
||||
const [activeScheduleId, setActiveScheduleId] = React.useState(1);
|
||||
const patchSchedule = (id, patch) => setSchedules(xs => xs.map(s => s.id === id ? { ...s, ...patch } : s));
|
||||
const triggerSchedule = (id) => alert('#' + id + ' を今すぐ実行 (モック)');
|
||||
const deleteSchedule = (id) => setSchedules(xs => xs.filter(s => s.id !== id));
|
||||
|
||||
const [users, setUsers] = React.useState(SAMPLE_USERS);
|
||||
const [activeUserId, setActiveUserId] = React.useState('u1');
|
||||
const patchUser = (id, patch) => setUsers(xs => xs.map(u => u.id === id ? { ...u, ...patch } : u));
|
||||
const deleteUser = (id) => setUsers(xs => xs.filter(u => u.id !== id));
|
||||
const approveUser = (id) => patchUser(id, { status: 'active' });
|
||||
|
||||
const [settingsSection, setSettingsSection] = React.useState('provider');
|
||||
const [settingsPiece, setSettingsPiece] = React.useState(null);
|
||||
|
||||
// ── Demo state switch (loading / error / empty) — just a small floater, not part of the product ──
|
||||
const [demoState, setDemoState] = React.useState(() => localStorage.getItem('admin-demo-state') || 'normal');
|
||||
React.useEffect(() => { localStorage.setItem('admin-demo-state', demoState); }, [demoState]);
|
||||
const isLoading = demoState === 'loading';
|
||||
const hasError = demoState === 'error';
|
||||
const isEmpty = demoState === 'empty';
|
||||
|
||||
const viewTasks = isLoading || hasError ? [] : (isEmpty ? [] : tasks);
|
||||
const viewActiveId = isEmpty ? null : activeId;
|
||||
const viewSchedules = isLoading || isEmpty ? [] : schedules;
|
||||
const viewUsers = isLoading || isEmpty ? [] : users;
|
||||
|
||||
const active = tasks.find(t => t.id === activeId) || tasks[0];
|
||||
|
||||
const counts = {
|
||||
total: tasks.length,
|
||||
running: tasks.filter(t => t.status === 'running').length,
|
||||
waiting: tasks.filter(t => t.status === 'waiting_human' || t.status === 'waiting_subtasks').length,
|
||||
failed: tasks.filter(t => t.status === 'failed').length,
|
||||
};
|
||||
|
||||
const onSend = (text) => {
|
||||
setMessages(m => ({
|
||||
...m,
|
||||
[activeId]: [...(m[activeId] || []), { role: 'user', content: text, footer: 'たった今 · @daichi' }],
|
||||
}));
|
||||
// fake echo after small delay
|
||||
setTimeout(() => {
|
||||
setMessages(m => ({
|
||||
...m,
|
||||
[activeId]: [...(m[activeId] || []), { role: 'progress', content: 'エージェントが応答を生成中...' }],
|
||||
}));
|
||||
}, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||
<TopBar
|
||||
page={page} onNavigate={setPage}
|
||||
counts={counts}
|
||||
onOpenCreate={() => alert('新しい依頼 (モック)')}
|
||||
user={{ name: 'Daichi' }}
|
||||
/>
|
||||
{page === 'tasks' && (
|
||||
<div style={{
|
||||
flex: 1, minHeight: 0, display: 'grid',
|
||||
gridTemplateColumns: detailOpen ? '320px 1fr 380px' : '320px 1fr',
|
||||
background: '#f1f5f9', gap: 1,
|
||||
}}>
|
||||
<div style={{ background: '#fff', padding: 12, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<TaskList
|
||||
tasks={viewTasks} activeId={viewActiveId} onSelect={setActiveId}
|
||||
filters={filters} setFilters={setFilters}
|
||||
onOpenCreate={() => alert('新しい依頼 (モック)')}
|
||||
loading={isLoading}
|
||||
error={hasError ? 'ネットワークエラー: 接続を確認してください' : null}
|
||||
onRetry={() => setDemoState('normal')}
|
||||
/>
|
||||
</div>
|
||||
<ChatPane
|
||||
task={isLoading || hasError || isEmpty ? null : active}
|
||||
messages={!active ? [] : (messages[active.id] || [])}
|
||||
onSend={onSend}
|
||||
onOpenDetail={() => setDetailOpen(v => !v)}
|
||||
detailOpen={detailOpen}
|
||||
loading={isLoading}
|
||||
onOpenCreate={() => alert('新しい依頼 (モック)')}
|
||||
/>
|
||||
{detailOpen && !isLoading && !hasError && !isEmpty && <DetailPanel task={active} onClose={() => setDetailOpen(false)} />}
|
||||
</div>
|
||||
)}
|
||||
{page === 'schedules' && (
|
||||
<SchedulesPage
|
||||
schedules={viewSchedules} activeId={isEmpty || isLoading ? null : activeScheduleId} setActiveId={setActiveScheduleId}
|
||||
onPatch={patchSchedule} onTrigger={triggerSchedule} onDelete={deleteSchedule}
|
||||
onOpenCreate={() => alert('新しいスケジュール (モック)')}
|
||||
/>
|
||||
)}
|
||||
{page === 'users' && (
|
||||
<UsersPage
|
||||
users={viewUsers} activeId={isEmpty || isLoading ? null : activeUserId} setActiveId={setActiveUserId}
|
||||
onPatch={patchUser} onDelete={deleteUser} onApprove={approveUser}
|
||||
onOpenInvite={() => alert('ユーザーを招待 (モック)')}
|
||||
/>
|
||||
)}
|
||||
{page === 'settings' && (
|
||||
<SettingsPage
|
||||
section={settingsSection} setSection={setSettingsSection}
|
||||
piece={settingsPiece} setPiece={setSettingsPiece}
|
||||
/>
|
||||
)}
|
||||
<DemoStateFloater state={demoState} setState={setDemoState} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user