Files
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

277 lines
10 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { Info, Database, ScrollText, Trash2, Activity, Wrench } from 'lucide-react';
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
import { NODE_TYPE_SCHEMAS, type NodeFieldSchema } from '../schemas';
import { getNodeStats, listNodeComments, type NodeStats, type NodeComment } from '@/lib/api/control';
interface RightRailProps {
selectedCard: Record<string, any>;
}
export function RightRail({ selectedCard }: RightRailProps) {
const configNodeId = useControlMode((s) => s.configNodeId);
const ruleNodes = useControlMode((s) => s.ruleNodes);
const updateRuleNode = useControlMode((s) => s.updateRuleNode);
const removeRuleNode = useControlMode((s) => s.removeRuleNode);
const setConfigNodeId = useControlMode((s) => s.setConfigNodeId);
const selectedNode = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
return (
<div className="ctrl-ide-rightrail">
{/* 섹션 1: 노드 설정 / 카드 정보 */}
<div className="ctrl-rail-sec">
<div className="ctrl-rail-sec-head">
{selectedNode ? <Wrench size={11} /> : <Info size={11} />}
<span className="ctrl-rail-sec-title">
{selectedNode ? '노드 설정' : '데이터 인스펙터'}
</span>
<span className="ctrl-rail-sec-count">
{selectedNode ? selectedNode.id : '—'}
</span>
</div>
<div className="ctrl-rail-sec-body">
{selectedNode ? (
<NodeInspector
node={selectedNode}
onChange={(patch) => updateRuleNode(selectedNode.id, patch)}
onDelete={() => { removeRuleNode(selectedNode.id); setConfigNodeId(null); }}
/>
) : (
<CardInfo card={selectedCard} />
)}
</div>
</div>
{/* 섹션 2: 실행 상태 (v3 V3LiveItem 4개 미러) — 실 데이터 없으면 '—' fallback */}
<ActivitySection />
</div>
);
}
function ActivitySection() {
// 실 데이터 연결 전: 모든 값 '—' (control.ts 에 getControlActivity API 추가 시 연결)
// TODO: API listControlActivity(cardId) 추가 후 useEffect 로 fetch
const items: Array<{ label: string; value: string; dot?: 'ok' | 'warn' | 'bad' }> = [
{ label: '최근 트리거', value: '—' },
{ label: '오늘 실행', value: '—' },
{ label: '평균 latency', value: '—' },
{ label: '대기 큐', value: '—' },
];
return (
<div className="ctrl-rail-sec">
<div className="ctrl-rail-sec-head">
<Activity size={11} />
<span className="ctrl-rail-sec-title"> </span>
<span className="ctrl-rail-sec-count">live</span>
</div>
<div className="ctrl-rail-sec-body">
<div className="ctrl-activity">
{items.map((it) => (
<div key={it.label} className="ctrl-activity-row">
<span className="ctrl-activity-label">{it.label}</span>
<span className="ctrl-activity-value">
{it.dot && <span className={`ctrl-activity-dot ${it.dot}`} />}
{it.value}
</span>
</div>
))}
</div>
</div>
</div>
);
}
function NodeInspector({
node, onChange, onDelete,
}: {
node: Record<string, any>;
onChange: (patch: Record<string, any>) => void;
onDelete: () => void;
}) {
const schema: NodeFieldSchema[] = NODE_TYPE_SCHEMAS[node.type] ?? [];
const config: Record<string, any> = node.config ?? {};
const def = CTRL_NODE_TYPES[node.type];
const [stats, setStats] = useState<NodeStats | null>(null);
const [comments, setComments] = useState<NodeComment[]>([]);
useEffect(() => {
let alive = true;
getNodeStats(node.id).then((s) => { if (alive) setStats(s); });
listNodeComments(node.id).then((c) => { if (alive) setComments(c); });
return () => { alive = false; };
}, [node.id]);
return (
<>
<div className="ctrl-sec-head">
<span className="ctrl-sec-ico"><Info size={11} /></span>
Inspector
<span className="ctrl-sec-count">{def?.label ?? node.type}</span>
<span className="ctrl-sec-right">
<button
type="button"
className="ctrl-ide-tool ctrl-ide-mini"
onClick={onDelete}
title="노드 삭제"
>
<Trash2 size={11} />
</button>
</span>
</div>
<div className="ctrl-ide-inspector">
{/* node 식별 */}
<div className="ctrl-ide-field ctrl-ide-field-meta">
<div>
<span className="ctrl-ide-field-k"> ID</span>
<code>{node.id}</code>
</div>
{def && (
<div>
<span className="ctrl-ide-field-k"></span>
<span style={{ color: `rgb(${def.rgb})`, fontWeight: 700 }}>
{def.icon} {def.label}
</span>
</div>
)}
</div>
{/* schema 기반 필드 */}
{schema.length === 0 && (
<div className="ctrl-ide-empty"> </div>
)}
{schema.map((f) => (
<div key={f.k} className="ctrl-ide-field">
<label className="ctrl-ide-field-label">
{f.l}
{f.locked && <span className="ctrl-ide-field-locked"> · </span>}
</label>
{f.select ? (
<select
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
>
{f.select.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
) : f.multiline ? (
<textarea
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
placeholder={f.hint}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
rows={3}
/>
) : (
<input
type="text"
value={config[f.k] ?? f.v ?? ''}
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
disabled={f.locked}
placeholder={f.hint}
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
/>
)}
{f.hint && !f.multiline && <div className="ctrl-ide-field-hint">{f.hint}</div>}
</div>
))}
{/* 통계 */}
{stats && (
<>
<div className="ctrl-sec-head" style={{ marginTop: 12 }}>
<span className="ctrl-sec-ico"><Activity size={11} /></span>
</div>
<div className="ctrl-ide-stats">
<div><span className="ctrl-ide-field-k"></span><code>{stats.runs}</code></div>
<div><span className="ctrl-ide-field-k"> ms</span><code>{stats.lastMs ?? '—'}</code></div>
<div>
<span className="ctrl-ide-field-k"></span>
<span className={`ctrl-validation-dot ${stats.valid ? 'ok' : 'bad'}`} />
</div>
{stats.alert && (
<div className="ctrl-ide-stat-alert">{stats.alert}</div>
)}
</div>
</>
)}
{/* 댓글 */}
{comments.length > 0 && (
<>
<div className="ctrl-sec-head" style={{ marginTop: 12 }}>
<span className="ctrl-sec-ico"><ScrollText size={11} /></span>
<span className="ctrl-sec-count">{comments.length}</span>
</div>
<div className="ctrl-ide-comments">
{comments.map((c, i) => (
<div key={i} className="ctrl-ide-comment">
<span
className="ctrl-ide-avatar"
style={{ background: `rgb(${c.color})` }}
title={c.who}
>
{c.short}
</span>
<div>
<div className="ctrl-ide-comment-meta">
<b>{c.who}</b><span> · {c.at}</span>
</div>
<div className="ctrl-ide-comment-text">{c.text}</div>
</div>
</div>
))}
</div>
</>
)}
</div>
</>
);
}
function CardInfo({ card }: { card: Record<string, any> }) {
const title = card.title ?? card.TITLE ?? '카드';
const table = card.primary_table ?? card.PRIMARY_TABLE ?? '';
const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? '';
return (
<>
<div className="ctrl-sec-head">
<span className="ctrl-sec-ico"><Database size={11} /></span>
</div>
<div className="ctrl-ide-card-info">
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k"></span>
<span>{title}</span>
</div>
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k"></span>
<code>{table || '—'}</code>
</div>
<div className="ctrl-ide-field-row">
<span className="ctrl-ide-field-k">ID</span>
<code>{cardId}</code>
</div>
</div>
<div className="ctrl-sec-head" style={{ marginTop: 16 }}>
<span className="ctrl-sec-ico"><ScrollText size={11} /></span>
</div>
<div className="ctrl-ide-help">
<p> .</p>
<p> .</p>
<p> <b>READ / EDIT / RUN / HISTORY</b> .</p>
</div>
</>
);
}