Files
invyone/frontend/components/control/ide/Canvas.tsx
T
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

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>
);
}