2f398ae0b3
- 제어모드 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 후속 노트
277 lines
10 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|