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 후속 노트
397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Canvas — 4-모드 중앙 캔버스 (v3 V3Canvas / V3ViewCanvas / V3EditCanvas / V3RunCanvas / V3HistoryCanvas)
|
|
*
|
|
* view : 관계 트리 (listRelations API)
|
|
* edit : 룰 에디터 (기존 RuleBuilder 호출, 단계 6 에서 PanZoomStage 베이스로 갈아끼움)
|
|
* run : 단계별 실행 시각화 (mock 진행)
|
|
* history : 실행 이력 테이블 (listExecutionHistory API)
|
|
*/
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Table2, History as HistoryIcon, Play, Pause, SkipBack, SkipForward,
|
|
ChevronLeft, ChevronRight, Check,
|
|
} from 'lucide-react';
|
|
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
|
|
import { PanZoomStage } from './PanZoomStage';
|
|
import { RuleBuilder } from '../RuleBuilder';
|
|
import {
|
|
listRelations, listExecutionHistory,
|
|
type TableRelation, type ExecutionRecord,
|
|
} from '@/lib/api/control';
|
|
|
|
interface CanvasProps {
|
|
card: Record<string, any>;
|
|
/** DashboardCanvas ref (호환용, IDE EditCanvas 는 자체 ref 사용) */
|
|
canvasRef: React.RefObject<HTMLDivElement | null>;
|
|
dashboardId: string;
|
|
}
|
|
|
|
export function Canvas({ card, dashboardId }: CanvasProps) {
|
|
const mode = useControlMode((s) => s.mode);
|
|
|
|
return (
|
|
<div className="ctrl-ide-canvas-inner">
|
|
{mode === 'view' && <ViewCanvas card={card} dashboardId={dashboardId} />}
|
|
{mode === 'edit' && <EditCanvas />}
|
|
{mode === 'run' && <RunCanvas />}
|
|
{mode === 'history' && <HistoryCanvas card={card} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── VIEW — 관계 트리 ─── */
|
|
function ViewCanvas({ card }: { card: Record<string, any>; dashboardId: string }) {
|
|
const tableName = card.primary_table ?? card.PRIMARY_TABLE ?? '';
|
|
const cardTitle = card.title ?? card.TITLE ?? '카드';
|
|
const [rels, setRels] = useState<TableRelation[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!tableName) return;
|
|
let alive = true;
|
|
listRelations(tableName).then((r) => { if (alive) setRels(r); });
|
|
return () => { alive = false; };
|
|
}, [tableName]);
|
|
|
|
const W = 1000, H = 540;
|
|
const targets = useMemo(() => {
|
|
if (rels.length === 0) return [];
|
|
return rels.map((r, i) => {
|
|
const t = rels.length === 1 ? 0.5 : i / (rels.length - 1);
|
|
return { x: 750, y: 80 + t * 380, name: r.to, type: r.type, edgeLabel: r.label };
|
|
});
|
|
}, [rels]);
|
|
|
|
return (
|
|
<PanZoomStage width={W} height={H}>
|
|
<svg width={W} height={H} style={{ position: 'absolute', inset: 0 }}>
|
|
<defs>
|
|
<pattern id="v3-dots" width={16} height={16} patternUnits="userSpaceOnUse">
|
|
<circle cx={1} cy={1} r={0.7} fill="rgba(var(--v5-cyan-rgb), .16)" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width={W} height={H} fill="url(#v3-dots)" />
|
|
|
|
{/* edges */}
|
|
{targets.map((t, i) => {
|
|
const isAuto = t.type === 'auto';
|
|
const rgb = isAuto ? '108,92,231' : '0,154,150';
|
|
const x1 = 250, y1 = H / 2, x2 = t.x - 100, y2 = t.y;
|
|
const mx = (x1 + x2) / 2;
|
|
return (
|
|
<g key={i}>
|
|
<path
|
|
d={`M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`}
|
|
stroke={`rgb(${rgb})`} strokeWidth={2} opacity={0.5}
|
|
fill="none" strokeDasharray={isAuto ? '0' : '6 4'}
|
|
/>
|
|
<g transform={`translate(${mx}, ${(y1 + y2) / 2 - 10})`}>
|
|
<rect x={-40} y={-10} width={80} height={20} rx={10}
|
|
fill="var(--v5-surface-solid)" stroke={`rgba(${rgb}, .45)`} strokeWidth={1.2} />
|
|
<text y={4} textAnchor="middle" fontSize={10} fontWeight={700} fill={`rgb(${rgb})`}>
|
|
{t.edgeLabel}
|
|
</text>
|
|
</g>
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* source highlight */}
|
|
<rect x={50} y={H / 2 - 56} width={200} height={112} rx={12}
|
|
fill="rgba(var(--v5-cyan-rgb), .05)" stroke="rgb(var(--v5-cyan-rgb))" strokeWidth={2} />
|
|
</svg>
|
|
|
|
{/* source label */}
|
|
<div style={{
|
|
position: 'absolute', left: 50, top: H / 2 - 56, width: 200, height: 112,
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
|
|
}}>
|
|
<div className="ctrl-canvas-tag" style={{ color: 'rgb(0, 154, 150)' }}>SOURCE</div>
|
|
<div style={{ fontSize: '.85rem', fontWeight: 800, letterSpacing: '-.01em' }}>{cardTitle}</div>
|
|
<div className="ctrl-canvas-mono">{tableName || '—'}</div>
|
|
</div>
|
|
|
|
{/* target nodes */}
|
|
{targets.map((t) => (
|
|
<div key={t.name} className="ctrl-canvas-relnode" style={{
|
|
position: 'absolute', left: t.x - 100, top: t.y - 36, width: 200,
|
|
borderColor: t.type === 'auto'
|
|
? 'rgba(var(--v5-primary-rgb), .5)'
|
|
: 'rgba(var(--v5-cyan-rgb), .5)',
|
|
}}>
|
|
<div className="ctrl-canvas-tag" style={{
|
|
color: t.type === 'auto' ? 'rgb(var(--v5-primary-rgb))' : 'rgb(0, 154, 150)',
|
|
marginBottom: 5, display: 'flex', alignItems: 'center', gap: 5,
|
|
}}>
|
|
<Table2 size={10} />
|
|
{t.type === 'auto' ? 'AUTO' : 'FK'}
|
|
</div>
|
|
<div style={{ fontSize: '.78rem', fontWeight: 700, marginBottom: 4 }}>{t.name}</div>
|
|
<div className="ctrl-canvas-mono">
|
|
{t.type === 'auto' ? '동기화' : '참조'}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{targets.length === 0 && (
|
|
<div className="ctrl-canvas-empty">
|
|
이 테이블의 관계 정보가 없습니다
|
|
<small>API: GET /api/control/tables/{tableName}/relations</small>
|
|
</div>
|
|
)}
|
|
</PanZoomStage>
|
|
);
|
|
}
|
|
|
|
/* ─── EDIT — RuleBuilder 위임 (컬럼별 마우스 연결 + 노드 드래그 + 팔레트 드롭) ─── */
|
|
function EditCanvas() {
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
return (
|
|
<div ref={canvasRef} className="ctrl-edit-canvas-host">
|
|
<RuleBuilder canvasRef={canvasRef} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── RUN — 단계별 실행 시각화 (mock 진행) ─── */
|
|
function RunCanvas() {
|
|
const ruleNodes = useControlMode((s) => s.ruleNodes);
|
|
const [playState, setPlayState] = useState<'paused' | 'playing'>('paused');
|
|
const [playStep, setPlayStep] = useState(0);
|
|
|
|
const totalSteps = Math.max(ruleNodes.length, 1);
|
|
const current = Math.min(playStep, totalSteps);
|
|
|
|
useEffect(() => {
|
|
if (playState !== 'playing') return;
|
|
if (current >= totalSteps) return;
|
|
const t = setTimeout(() => setPlayStep((s) => s + 1), 700);
|
|
return () => clearTimeout(t);
|
|
}, [playState, current, totalSteps]);
|
|
|
|
return (
|
|
<div className="ctrl-run-shell">
|
|
{/* top — playback controls */}
|
|
<div className="ctrl-run-top">
|
|
<div className={`ctrl-run-state ${playState}`}>
|
|
{playState === 'playing' ? <Play size={16} /> : <Pause size={16} />}
|
|
</div>
|
|
<div>
|
|
<div className={`ctrl-canvas-tag ${playState === 'playing' ? 'is-play' : 'is-pause'}`}>
|
|
{playState === 'playing' ? 'LIVE TRACE · 재생 중' : 'LIVE TRACE · 일시정지'}
|
|
</div>
|
|
<div style={{ fontSize: '.92rem', fontWeight: 700, marginTop: 2 }}>
|
|
{ruleNodes.length === 0 ? '룰 없음' : `노드 ${ruleNodes.length}개`}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
<div className="ctrl-run-btns">
|
|
<PlayBtn Ic={SkipBack} onClick={() => setPlayStep(0)} title="처음" />
|
|
<PlayBtn Ic={ChevronLeft} onClick={() => setPlayStep((s) => Math.max(0, s - 1))} title="이전" />
|
|
<PlayBtn
|
|
Ic={playState === 'playing' ? Pause : Play}
|
|
primary
|
|
onClick={() => setPlayState((p) => (p === 'playing' ? 'paused' : 'playing'))}
|
|
title={playState === 'playing' ? '일시정지' : '재생'}
|
|
/>
|
|
<PlayBtn Ic={ChevronRight} onClick={() => setPlayStep((s) => Math.min(totalSteps, s + 1))} title="다음" />
|
|
<PlayBtn Ic={SkipForward} onClick={() => setPlayStep(totalSteps)} title="끝" />
|
|
</div>
|
|
|
|
<div className="ctrl-run-counter">
|
|
<div className="ctrl-run-counter-num">{current}/{totalSteps}</div>
|
|
<div className="ctrl-canvas-mono">{Math.round((current / totalSteps) * 100)}%</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* progress */}
|
|
<div className="ctrl-run-progress">
|
|
<div className="ctrl-run-progress-bar" style={{ width: `${(current / totalSteps) * 100}%` }} />
|
|
</div>
|
|
|
|
{/* steps */}
|
|
<div className="ctrl-run-steps">
|
|
{ruleNodes.length === 0 && (
|
|
<div className="ctrl-canvas-empty">
|
|
실행할 룰이 없습니다 — EDIT 모드에서 노드를 추가하세요
|
|
</div>
|
|
)}
|
|
{ruleNodes.map((n, i) => {
|
|
const def = CTRL_NODE_TYPES[n.type];
|
|
const rgb = def?.rgb ?? '108,92,231';
|
|
const done = i < current;
|
|
const active = i === current - 1 && playState === 'playing';
|
|
const pending = i >= current;
|
|
return (
|
|
<div
|
|
key={n.id}
|
|
className={`ctrl-run-step ${active ? 'is-active' : ''} ${done ? 'is-done' : ''} ${pending ? 'is-pending' : ''}`}
|
|
>
|
|
<div
|
|
className="ctrl-run-step-num"
|
|
style={{
|
|
background: done ? 'var(--v5-green)' : active ? `rgb(${rgb})` : 'var(--v5-bg-subtle)',
|
|
color: done || active ? '#fff' : 'var(--v5-text-muted)',
|
|
boxShadow: active ? `0 0 12px rgba(${rgb}, .5)` : 'none',
|
|
}}
|
|
>
|
|
{done ? <Check size={10} /> : i + 1}
|
|
</div>
|
|
<div
|
|
className="ctrl-run-step-ico"
|
|
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}
|
|
>
|
|
{def?.icon ?? '?'}
|
|
</div>
|
|
<div>
|
|
<div style={{ fontSize: '.73rem', fontWeight: 700 }}>{n.label ?? def?.label ?? n.type}</div>
|
|
<div className="ctrl-canvas-mono">{n.summary?.[0] ?? ''}</div>
|
|
</div>
|
|
<LatencyBar ms={Math.round(20 + Math.random() * 60)} max={100} />
|
|
<span className={`ctrl-run-step-status ${active ? 'is-active' : ''}`}>
|
|
{done ? '완료' : active ? '진행 중…' : '대기'}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlayBtn({
|
|
Ic, onClick, primary, title,
|
|
}: { Ic: any; onClick: () => void; primary?: boolean; title: string }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
title={title}
|
|
className={`ctrl-run-btn${primary ? ' primary' : ''}`}
|
|
>
|
|
<Ic size={11} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function LatencyBar({ ms, max }: { ms: number; max: number }) {
|
|
const pct = Math.min(100, (ms / max) * 100);
|
|
const color = pct < 50 ? 'var(--v5-green)' : pct < 80 ? 'var(--v5-amber)' : 'var(--v5-red)';
|
|
return (
|
|
<div className="ctrl-latency-bar" title={`${ms}ms`}>
|
|
<div style={{ width: `${pct}%`, background: color }} />
|
|
<span>{ms}ms</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── HISTORY — 실행 이력 테이블 ─── */
|
|
function HistoryCanvas({ card }: { card: Record<string, any> }) {
|
|
const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? '';
|
|
const [items, setItems] = useState<ExecutionRecord[]>([]);
|
|
const [filter, setFilter] = useState<'all' | 'ok' | 'fail'>('all');
|
|
|
|
useEffect(() => {
|
|
if (!cardId) return;
|
|
let alive = true;
|
|
listExecutionHistory(cardId, { limit: 50 }).then((r) => { if (alive) setItems(r); });
|
|
return () => { alive = false; };
|
|
}, [cardId]);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (filter === 'all') return items;
|
|
if (filter === 'ok') return items.filter((i) => i.ok);
|
|
return items.filter((i) => !i.ok);
|
|
}, [items, filter]);
|
|
|
|
const okCount = items.filter((i) => i.ok).length;
|
|
const failCount = items.length - okCount;
|
|
|
|
return (
|
|
<div className="ctrl-history-shell">
|
|
<div className="ctrl-history-top">
|
|
<div className="ctrl-history-tag">
|
|
<HistoryIcon size={11} />
|
|
EXECUTION HISTORY
|
|
</div>
|
|
<div className="ctrl-canvas-mono">
|
|
최근 <b>{items.length}</b>회 · 24h
|
|
</div>
|
|
<div style={{ flex: 1 }} />
|
|
<select
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value as 'all' | 'ok' | 'fail')}
|
|
className="ctrl-history-select"
|
|
>
|
|
<option value="all">전체 ({items.length})</option>
|
|
<option value="ok">성공 ({okCount})</option>
|
|
<option value="fail">실패 ({failCount})</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="ctrl-history-body">
|
|
<table className="ctrl-history-table">
|
|
<thead>
|
|
<tr>
|
|
<th></th>
|
|
<th>TS</th>
|
|
<th>TRIGGER</th>
|
|
<th>WHO</th>
|
|
<th style={{ textAlign: 'right' }}>STEPS</th>
|
|
<th style={{ textAlign: 'right' }}>LATENCY</th>
|
|
<th>RESULT</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map((ex) => (
|
|
<tr key={ex.id}>
|
|
<td>
|
|
<span
|
|
className="ctrl-history-dot"
|
|
style={{
|
|
background: ex.ok ? 'var(--v5-green)' : 'var(--v5-red)',
|
|
boxShadow: ex.ok
|
|
? '0 0 6px var(--v5-green)'
|
|
: '0 0 6px var(--v5-red)',
|
|
}}
|
|
/>
|
|
</td>
|
|
<td className="ctrl-history-mono">{ex.ts}</td>
|
|
<td className="ctrl-history-mono">{ex.trig}</td>
|
|
<td className="ctrl-history-mono ctrl-history-sec">{ex.who}</td>
|
|
<td className="ctrl-history-mono" style={{ textAlign: 'right' }}>{ex.steps}/8</td>
|
|
<td style={{ textAlign: 'right' }}>
|
|
<LatencyBar ms={ex.ms} max={400} />
|
|
</td>
|
|
<td>
|
|
<span className={`ctrl-history-result ${ex.ok ? 'ok' : 'fail'}`}>
|
|
{ex.ok ? 'OK' : 'FAIL'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<button className="ctrl-history-more">
|
|
<ChevronRight size={11} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<tr>
|
|
<td colSpan={8}>
|
|
<div className="ctrl-canvas-empty" style={{ position: 'static' }}>
|
|
실행 이력 없음
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|