/** * Mandu Kitchen DevTools - Guard Panel * @version 1.0.3 */ import React, { useMemo, useState, useCallback } from 'react'; import type { DevToolsGuardViolation } from '../../../types'; import { colors, typography, spacing, borderRadius, animation } from '../../../design-tokens'; // ============================================================================ // Styles // ============================================================================ const styles = { container: { padding: spacing.md, height: '100%', display: 'flex', flexDirection: 'column' as const, gap: spacing.md, }, header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', }, clearButton: { padding: `${spacing.xs} ${spacing.sm}`, borderRadius: borderRadius.sm, backgroundColor: colors.background.light, border: 'none', color: colors.text.secondary, fontSize: typography.fontSize.xs, cursor: 'pointer', transition: `all ${animation.duration.fast}`, }, list: { flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' as const, gap: spacing.sm, }, emptyState: { flex: 1, display: 'flex', flexDirection: 'column' as const, alignItems: 'center', justifyContent: 'center', gap: spacing.md, padding: spacing.xl, color: colors.text.muted, fontSize: typography.fontSize.sm, textAlign: 'center' as const, }, violationItem: { padding: spacing.md, borderRadius: borderRadius.md, backgroundColor: colors.background.medium, borderLeft: '3px solid', transition: `all ${animation.duration.fast}`, }, violationHeader: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: spacing.sm, marginBottom: spacing.xs, }, ruleName: { display: 'flex', alignItems: 'center', gap: spacing.xs, }, badge: { padding: `2px ${spacing.xs}`, borderRadius: borderRadius.sm, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.medium, }, message: { fontSize: typography.fontSize.sm, color: colors.text.primary, lineHeight: typography.lineHeight.normal, marginBottom: spacing.sm, }, location: { display: 'flex', alignItems: 'center', gap: spacing.sm, fontSize: typography.fontSize.xs, fontFamily: typography.fontFamily.mono, color: colors.text.muted, }, arrow: { color: colors.text.muted, }, suggestion: { marginTop: spacing.sm, padding: spacing.sm, borderRadius: borderRadius.sm, backgroundColor: `${colors.semantic.info}10`, borderLeft: `2px solid ${colors.semantic.info}`, fontSize: typography.fontSize.xs, color: colors.text.secondary, lineHeight: typography.lineHeight.normal, }, suggestionLabel: { fontWeight: typography.fontWeight.semibold, color: colors.semantic.info, marginBottom: '4px', }, }; const severityStyles: Record = { error: { border: colors.semantic.error, bg: `${colors.semantic.error}20`, color: colors.semantic.error, }, warning: { border: colors.semantic.warning, bg: `${colors.semantic.warning}20`, color: colors.semantic.warning, }, }; // ============================================================================ // Props // ============================================================================ export interface GuardDecisionState { violationKey: string; action: 'approve' | 'reject'; } export interface GuardPanelProps { violations: DevToolsGuardViolation[]; onClear: () => void; } // ============================================================================ // Component // ============================================================================ export function GuardPanel({ violations, onClear }: GuardPanelProps): React.ReactElement { const [scanning, setScanning] = useState(false); const [scanResult, setScanResult] = useState<{ total: number; errors: number; warnings: number } | null>(null); const [decisions, setDecisions] = useState>(new Map()); const [showApproved, setShowApproved] = useState(false); const handleDecision = useCallback(async (ruleId: string, filePath: string, action: 'approve' | 'reject') => { try { const endpoint = action === 'approve' ? '/__kitchen/api/guard/approve' : '/__kitchen/api/guard/reject'; const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ruleId, filePath }), }); if (res.ok) { setDecisions(prev => { const next = new Map(prev); next.set(`${ruleId}::${filePath}`, action); return next; }); } } catch { // Kitchen API not available } }, []); const handleScan = useCallback(async () => { setScanning(true); setScanResult(null); try { const res = await fetch('/__kitchen/api/guard/scan', { method: 'POST' }); if (res.ok) { const data = await res.json(); if (data.report) { setScanResult({ total: data.report.totalViolations, errors: data.report.bySeverity?.error ?? 0, warnings: data.report.bySeverity?.warn ?? data.report.bySeverity?.warning ?? 0, }); } } } catch { // Kitchen API not available (non-dev or older version) } finally { setScanning(false); } }, []); // Group by rule const groupedByRule = useMemo(() => { const groups = new Map(); for (const v of violations) { const existing = groups.get(v.ruleId) ?? []; groups.set(v.ruleId, [...existing, v]); } return groups; }, [violations]); if (violations.length === 0) { return (

현재 아키텍처 위반이 없습니다.
새 스캔 결과가 생기면 여기에 표시됩니다.

{scanResult && (

스캔 결과: {scanResult.total === 0 ? '위반 없음' : `${scanResult.total}개 위반 (에러 ${scanResult.errors}, 경고 ${scanResult.warnings})` }

)}
); } return (
{/* Header */}
{violations.length}개의 위반 ({groupedByRule.size}개 규칙)
{/* Violation List */}
{violations.map((violation) => { const severity = severityStyles[violation.severity] ?? severityStyles.warning; const decisionKey = `${violation.ruleId}::${violation.source.file}`; const decision = decisions.get(decisionKey); const isApproved = decision === 'approve'; if (isApproved && !showApproved) return null; return (
{violation.severity} {violation.ruleName} {isApproved && ( Approved )}

{violation.message}

{violation.source.file} {violation.source.line && `:${violation.source.line}`} {violation.target && ( <> {violation.target.file} {violation.target.line && `:${violation.target.line}`} )}
{violation.suggestion && (
제안
{violation.suggestion}
)}
); })}
); }