Fix route diagram open issues: bugs, visual polish, interactive features
Batch 1 — Bug fixes: - #51: Pass group+routeId to stats/timeseries API for route-scoped data - #55: Propagate processor FAILED status to diagram error node highlighting Batch 2 — Visual polish: - #56: Brighter canvas background with amber/cyan radial gradients - #57: Stronger glow filters (stdDeviation 3→6, opacity 0.4→0.6) - #58: Uniform 200×40px leaf nodes with label truncation at 22 chars - #59: Diagram legend (node types, edge types, overlay indicators) - #64: SVG <title> tooltips on all nodes showing type, status, duration Batch 3 — Interactive features: - #60: Draggable minimap viewport (click-to-center, drag-to-pan) - #62: CSS View Transitions slide animation, back arrow, Backspace key Batch 4 — Advanced features: - #50: Execution picker dropdown scoped to group+routeId - #49: Iteration count badge (×N) on compound nodes - #63: Route header stats (Executions Today, Success Rate, Avg, P99) Closes #49 #50 #51 #55 #56 #57 #58 #59 #60 #62 #63 #64 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,22 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { api } from '../client';
|
import { api } from '../client';
|
||||||
import type { SearchRequest } from '../types';
|
import type { SearchRequest } from '../types';
|
||||||
|
|
||||||
export function useExecutionStats(timeFrom: string | undefined, timeTo: string | undefined) {
|
export function useExecutionStats(
|
||||||
|
timeFrom: string | undefined,
|
||||||
|
timeTo: string | undefined,
|
||||||
|
routeId?: string,
|
||||||
|
group?: string,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['executions', 'stats', timeFrom, timeTo],
|
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, group],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/search/stats', {
|
const { data, error } = await api.GET('/search/stats', {
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
from: timeFrom!,
|
from: timeFrom!,
|
||||||
to: timeTo || undefined,
|
to: timeTo || undefined,
|
||||||
|
routeId: routeId || undefined,
|
||||||
|
group: group || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -38,9 +45,14 @@ export function useSearchExecutions(filters: SearchRequest, live = false) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string | undefined) {
|
export function useStatsTimeseries(
|
||||||
|
timeFrom: string | undefined,
|
||||||
|
timeTo: string | undefined,
|
||||||
|
routeId?: string,
|
||||||
|
group?: string,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['executions', 'timeseries', timeFrom, timeTo],
|
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, group],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/search/stats/timeseries', {
|
const { data, error } = await api.GET('/search/stats/timeseries', {
|
||||||
params: {
|
params: {
|
||||||
@@ -48,6 +60,8 @@ export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string
|
|||||||
from: timeFrom!,
|
from: timeFrom!,
|
||||||
to: timeTo || undefined,
|
to: timeTo || undefined,
|
||||||
buckets: 24,
|
buckets: 24,
|
||||||
|
routeId: routeId || undefined,
|
||||||
|
group: group || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface OverlayState {
|
|||||||
executedEdges: Set<string>;
|
executedEdges: Set<string>;
|
||||||
durations: Map<string, number>;
|
durations: Map<string, number>;
|
||||||
sequences: Map<string, number>;
|
sequences: Map<string, number>;
|
||||||
|
statuses: Map<string, string>;
|
||||||
iterationData: Map<string, IterationData>;
|
iterationData: Map<string, IterationData>;
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
selectNode: (nodeId: string | null) => void;
|
selectNode: (nodeId: string | null) => void;
|
||||||
@@ -25,6 +26,7 @@ function collectProcessorData(
|
|||||||
executedNodes: Set<string>,
|
executedNodes: Set<string>,
|
||||||
durations: Map<string, number>,
|
durations: Map<string, number>,
|
||||||
sequences: Map<string, number>,
|
sequences: Map<string, number>,
|
||||||
|
statuses: Map<string, string>,
|
||||||
counter: { seq: number },
|
counter: { seq: number },
|
||||||
) {
|
) {
|
||||||
for (const proc of processors) {
|
for (const proc of processors) {
|
||||||
@@ -33,9 +35,10 @@ function collectProcessorData(
|
|||||||
executedNodes.add(nodeId);
|
executedNodes.add(nodeId);
|
||||||
durations.set(nodeId, proc.durationMs ?? 0);
|
durations.set(nodeId, proc.durationMs ?? 0);
|
||||||
sequences.set(nodeId, ++counter.seq);
|
sequences.set(nodeId, ++counter.seq);
|
||||||
|
if (proc.status) statuses.set(nodeId, proc.status);
|
||||||
}
|
}
|
||||||
if (proc.children && proc.children.length > 0) {
|
if (proc.children && proc.children.length > 0) {
|
||||||
collectProcessorData(proc.children, executedNodes, durations, sequences, counter);
|
collectProcessorData(proc.children, executedNodes, durations, sequences, statuses, counter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,19 +71,20 @@ export function useExecutionOverlay(
|
|||||||
if (execution) setIsActive(true);
|
if (execution) setIsActive(true);
|
||||||
}, [execution]);
|
}, [execution]);
|
||||||
|
|
||||||
const { executedNodes, durations, sequences, iterationData } = useMemo(() => {
|
const { executedNodes, durations, sequences, statuses, iterationData } = useMemo(() => {
|
||||||
const en = new Set<string>();
|
const en = new Set<string>();
|
||||||
const dur = new Map<string, number>();
|
const dur = new Map<string, number>();
|
||||||
const seq = new Map<string, number>();
|
const seq = new Map<string, number>();
|
||||||
|
const st = new Map<string, string>();
|
||||||
const iter = new Map<string, IterationData>();
|
const iter = new Map<string, IterationData>();
|
||||||
|
|
||||||
if (!execution?.processors) {
|
if (!execution?.processors) {
|
||||||
return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
|
return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter };
|
||||||
}
|
}
|
||||||
|
|
||||||
collectProcessorData(execution.processors, en, dur, seq, { seq: 0 });
|
collectProcessorData(execution.processors, en, dur, seq, st, { seq: 0 });
|
||||||
|
|
||||||
return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
|
return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter };
|
||||||
}, [execution]);
|
}, [execution]);
|
||||||
|
|
||||||
const executedEdges = useMemo(
|
const executedEdges = useMemo(
|
||||||
@@ -119,6 +123,7 @@ export function useExecutionOverlay(
|
|||||||
executedEdges,
|
executedEdges,
|
||||||
durations,
|
durations,
|
||||||
sequences,
|
sequences,
|
||||||
|
statuses,
|
||||||
iterationData: new Map([...iterationData].map(([k, v]) => {
|
iterationData: new Map([...iterationData].map(([k, v]) => {
|
||||||
const current = iterations.get(k) ?? v.current;
|
const current = iterations.get(k) ?? v.current;
|
||||||
return [k, { ...v, current }];
|
return [k, { ...v, current }];
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export function PerformanceTab({ group, routeId }: PerformanceTabProps) {
|
|||||||
const timeTo = new Date().toISOString();
|
const timeTo = new Date().toISOString();
|
||||||
|
|
||||||
// Use scoped stats/timeseries via group+routeId query params
|
// Use scoped stats/timeseries via group+routeId query params
|
||||||
const { data: stats } = useExecutionStats(timeFrom, timeTo);
|
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, group);
|
||||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, group);
|
||||||
|
|
||||||
const buckets = timeseries?.buckets ?? [];
|
const buckets = timeseries?.buckets ?? [];
|
||||||
const sparkTotal = buckets.map((b) => b.totalCount ?? 0);
|
const sparkTotal = buckets.map((b) => b.totalCount ?? 0);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DiagramLayout } from '../../api/types';
|
import type { DiagramLayout } from '../../api/types';
|
||||||
|
import { useExecutionStats } from '../../api/queries/executions';
|
||||||
import styles from './RoutePage.module.css';
|
import styles from './RoutePage.module.css';
|
||||||
|
|
||||||
interface RouteHeaderProps {
|
interface RouteHeaderProps {
|
||||||
@@ -9,6 +10,12 @@ interface RouteHeaderProps {
|
|||||||
|
|
||||||
export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
|
export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
|
||||||
const nodeCount = layout?.nodes?.length ?? 0;
|
const nodeCount = layout?.nodes?.length ?? 0;
|
||||||
|
const timeFrom = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const { data: stats } = useExecutionStats(timeFrom, undefined, routeId, group);
|
||||||
|
|
||||||
|
const successRate = stats && stats.totalCount > 0
|
||||||
|
? ((1 - stats.failedCount / stats.totalCount) * 100).toFixed(1)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.routeHeader}>
|
<div className={styles.routeHeader}>
|
||||||
@@ -24,6 +31,32 @@ export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{stats && (
|
||||||
|
<div className={styles.headerStatsRow}>
|
||||||
|
<div className={styles.headerStat}>
|
||||||
|
<span className={styles.headerStatValue}>{stats.totalToday.toLocaleString()}</span>
|
||||||
|
<span className={styles.headerStatLabel}>Executions Today</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerStat}>
|
||||||
|
<span className={`${styles.headerStatValue} ${styles.headerStatGreen}`}>
|
||||||
|
{successRate ? `${successRate}%` : '--'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.headerStatLabel}>Success Rate</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerStat}>
|
||||||
|
<span className={`${styles.headerStatValue} ${styles.headerStatCyan}`}>
|
||||||
|
{stats.avgDurationMs != null ? `${stats.avgDurationMs}ms` : '--'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.headerStatLabel}>Avg Duration</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerStat}>
|
||||||
|
<span className={`${styles.headerStatValue} ${styles.headerStatAmber}`}>
|
||||||
|
{stats.p99LatencyMs != null ? `${stats.p99LatencyMs}ms` : '--'}
|
||||||
|
</span>
|
||||||
|
<span className={styles.headerStatLabel}>P99 Latency</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,28 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumbLink {
|
.breadcrumbLink {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -89,6 +111,48 @@
|
|||||||
background: var(--green);
|
background: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerStatsRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStatValue {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStatGreen {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStatCyan {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStatAmber {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerStatLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Toolbar & Tabs ─── */
|
/* ─── Toolbar & Tabs ─── */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useSearchParams, NavLink } from 'react-router';
|
import { useParams, useSearchParams, NavLink, useNavigate } from 'react-router';
|
||||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||||
import { useExecutionDetail } from '../../api/queries/executions';
|
import { useExecutionDetail } from '../../api/queries/executions';
|
||||||
import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
|
import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
|
||||||
@@ -7,6 +7,7 @@ import { RouteHeader } from './RouteHeader';
|
|||||||
import { DiagramTab } from './DiagramTab';
|
import { DiagramTab } from './DiagramTab';
|
||||||
import { PerformanceTab } from './PerformanceTab';
|
import { PerformanceTab } from './PerformanceTab';
|
||||||
import { ProcessorTree } from '../executions/ProcessorTree';
|
import { ProcessorTree } from '../executions/ProcessorTree';
|
||||||
|
import { ExecutionPicker } from './diagram/ExecutionPicker';
|
||||||
import styles from './RoutePage.module.css';
|
import styles from './RoutePage.module.css';
|
||||||
|
|
||||||
type Tab = 'diagram' | 'performance' | 'processors';
|
type Tab = 'diagram' | 'performance' | 'processors';
|
||||||
@@ -16,6 +17,29 @@ export function RoutePage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const execId = searchParams.get('exec');
|
const execId = searchParams.get('exec');
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('diagram');
|
const [activeTab, setActiveTab] = useState<Tab>('diagram');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
const doc = document as Document & { startViewTransition?: (cb: () => void) => void };
|
||||||
|
if (doc.startViewTransition) {
|
||||||
|
doc.startViewTransition(() => navigate(-1));
|
||||||
|
} else {
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// Backspace navigates back (unless user is in an input)
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'Backspace') return;
|
||||||
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
|
e.preventDefault();
|
||||||
|
goBack();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKey);
|
||||||
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
|
}, [goBack]);
|
||||||
|
|
||||||
const { data: layout, isLoading: layoutLoading } = useDiagramByRoute(group, routeId);
|
const { data: layout, isLoading: layoutLoading } = useDiagramByRoute(group, routeId);
|
||||||
const { data: execution } = useExecutionDetail(execId);
|
const { data: execution } = useExecutionDetail(execId);
|
||||||
@@ -33,6 +57,7 @@ export function RoutePage() {
|
|||||||
<>
|
<>
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className={styles.breadcrumb}>
|
<nav className={styles.breadcrumb}>
|
||||||
|
<button className={styles.backBtn} onClick={goBack} title="Back (Backspace)">←</button>
|
||||||
<NavLink to="/executions" className={styles.breadcrumbLink}>Transactions</NavLink>
|
<NavLink to="/executions" className={styles.breadcrumbLink}>Transactions</NavLink>
|
||||||
<span className={styles.breadcrumbSep}>/</span>
|
<span className={styles.breadcrumbSep}>/</span>
|
||||||
<span className={styles.breadcrumbText}>{group}</span>
|
<span className={styles.breadcrumbText}>{group}</span>
|
||||||
@@ -68,6 +93,7 @@ export function RoutePage() {
|
|||||||
|
|
||||||
{activeTab === 'diagram' && (
|
{activeTab === 'diagram' && (
|
||||||
<div className={styles.toolbarRight}>
|
<div className={styles.toolbarRight}>
|
||||||
|
<ExecutionPicker group={group} routeId={routeId} />
|
||||||
<button
|
<button
|
||||||
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
|
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
|
||||||
onClick={overlay.toggle}
|
onClick={overlay.toggle}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { DiagramLayout } from '../../../api/types';
|
|||||||
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
|
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
|
||||||
import { RouteDiagramSvg } from './RouteDiagramSvg';
|
import { RouteDiagramSvg } from './RouteDiagramSvg';
|
||||||
import { DiagramMinimap } from './DiagramMinimap';
|
import { DiagramMinimap } from './DiagramMinimap';
|
||||||
|
import { DiagramLegend } from './DiagramLegend';
|
||||||
import styles from './diagram.module.css';
|
import styles from './diagram.module.css';
|
||||||
|
|
||||||
interface DiagramCanvasProps {
|
interface DiagramCanvasProps {
|
||||||
@@ -98,12 +99,15 @@ export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DiagramLegend />
|
||||||
|
|
||||||
<DiagramMinimap
|
<DiagramMinimap
|
||||||
nodes={layout.nodes ?? []}
|
nodes={layout.nodes ?? []}
|
||||||
edges={layout.edges ?? []}
|
edges={layout.edges ?? []}
|
||||||
diagramWidth={layout.width ?? 600}
|
diagramWidth={layout.width ?? 600}
|
||||||
diagramHeight={layout.height ?? 400}
|
diagramHeight={layout.height ?? 400}
|
||||||
viewBox={viewBox}
|
viewBox={viewBox}
|
||||||
|
panzoomRef={panzoomRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
73
ui/src/pages/routes/diagram/DiagramLegend.tsx
Normal file
73
ui/src/pages/routes/diagram/DiagramLegend.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import styles from './diagram.module.css';
|
||||||
|
|
||||||
|
interface LegendItem {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
dashed?: boolean;
|
||||||
|
shape?: 'circle' | 'line';
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_TYPES: LegendItem[] = [
|
||||||
|
{ label: 'Endpoint', color: '#58a6ff', shape: 'circle' },
|
||||||
|
{ label: 'EIP Pattern', color: '#b87aff', shape: 'circle' },
|
||||||
|
{ label: 'Processor', color: '#3fb950', shape: 'circle' },
|
||||||
|
{ label: 'Error Handler', color: '#f85149', shape: 'circle' },
|
||||||
|
{ label: 'Cross-route', color: '#39d2e0', shape: 'circle', dashed: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EDGE_TYPES: LegendItem[] = [
|
||||||
|
{ label: 'Flow', color: '#4a5e7a', shape: 'line' },
|
||||||
|
{ label: 'Error', color: '#f85149', shape: 'line', dashed: true },
|
||||||
|
{ label: 'Cross-route', color: '#39d2e0', shape: 'line', dashed: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OVERLAY_TYPES: LegendItem[] = [
|
||||||
|
{ label: 'Executed', color: '#3fb950', shape: 'circle' },
|
||||||
|
{ label: 'Execution path', color: '#3fb950', shape: 'line' },
|
||||||
|
{ label: 'Not executed', color: '#4a5e7a', shape: 'circle' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function LegendRow({ item }: { item: LegendItem }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.legendRow}>
|
||||||
|
{item.shape === 'circle' ? (
|
||||||
|
<span
|
||||||
|
className={styles.legendDot}
|
||||||
|
style={{
|
||||||
|
background: item.color,
|
||||||
|
border: item.dashed ? `1px dashed ${item.color}` : undefined,
|
||||||
|
opacity: item.label === 'Not executed' ? 0.3 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={styles.legendLine}
|
||||||
|
style={{
|
||||||
|
background: item.color,
|
||||||
|
borderStyle: item.dashed ? 'dashed' : 'solid',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className={styles.legendLabel}>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiagramLegend() {
|
||||||
|
return (
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<div className={styles.legendSection}>
|
||||||
|
<span className={styles.legendTitle}>Nodes</span>
|
||||||
|
{NODE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendSection}>
|
||||||
|
<span className={styles.legendTitle}>Edges</span>
|
||||||
|
{EDGE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendSection}>
|
||||||
|
<span className={styles.legendTitle}>Overlay</span>
|
||||||
|
{OVERLAY_TYPES.map((t) => <LegendRow key={t.label} item={t} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useCallback, useRef, type MutableRefObject } from 'react';
|
||||||
|
import type { PanZoom } from 'panzoom';
|
||||||
import type { PositionedNode, PositionedEdge } from '../../../api/types';
|
import type { PositionedNode, PositionedEdge } from '../../../api/types';
|
||||||
import { getNodeStyle } from './nodeStyles';
|
import { getNodeStyle } from './nodeStyles';
|
||||||
import styles from './diagram.module.css';
|
import styles from './diagram.module.css';
|
||||||
@@ -9,12 +10,15 @@ interface DiagramMinimapProps {
|
|||||||
diagramWidth: number;
|
diagramWidth: number;
|
||||||
diagramHeight: number;
|
diagramHeight: number;
|
||||||
viewBox: { x: number; y: number; w: number; h: number };
|
viewBox: { x: number; y: number; w: number; h: number };
|
||||||
|
panzoomRef: MutableRefObject<PanZoom | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MINIMAP_W = 160;
|
const MINIMAP_W = 160;
|
||||||
const MINIMAP_H = 100;
|
const MINIMAP_H = 100;
|
||||||
|
|
||||||
export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, viewBox }: DiagramMinimapProps) {
|
export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, viewBox, panzoomRef }: DiagramMinimapProps) {
|
||||||
|
const dragging = useRef(false);
|
||||||
|
|
||||||
const scale = useMemo(() => {
|
const scale = useMemo(() => {
|
||||||
if (diagramWidth === 0 || diagramHeight === 0) return 1;
|
if (diagramWidth === 0 || diagramHeight === 0) return 1;
|
||||||
return Math.min(MINIMAP_W / diagramWidth, MINIMAP_H / diagramHeight);
|
return Math.min(MINIMAP_W / diagramWidth, MINIMAP_H / diagramHeight);
|
||||||
@@ -27,9 +31,48 @@ export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, view
|
|||||||
h: viewBox.h * scale,
|
h: viewBox.h * scale,
|
||||||
}), [viewBox, scale]);
|
}), [viewBox, scale]);
|
||||||
|
|
||||||
|
const panTo = useCallback((clientX: number, clientY: number, svg: SVGSVGElement) => {
|
||||||
|
const pz = panzoomRef.current;
|
||||||
|
if (!pz) return;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
// Convert minimap mouse coords to diagram coords
|
||||||
|
const mx = (clientX - rect.left) / scale;
|
||||||
|
const my = (clientY - rect.top) / scale;
|
||||||
|
// Center viewport on clicked point
|
||||||
|
const t = pz.getTransform();
|
||||||
|
const targetX = -(mx - viewBox.w / 2) * t.scale;
|
||||||
|
const targetY = -(my - viewBox.h / 2) * t.scale;
|
||||||
|
pz.moveTo(targetX, targetY);
|
||||||
|
}, [panzoomRef, scale, viewBox.w, viewBox.h]);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging.current = true;
|
||||||
|
panTo(e.clientX, e.clientY, e.currentTarget);
|
||||||
|
}, [panTo]);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
panTo(e.clientX, e.clientY, e.currentTarget);
|
||||||
|
}, [panTo]);
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
dragging.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.minimap}>
|
<div className={styles.minimap}>
|
||||||
<svg width={MINIMAP_W} height={MINIMAP_H} viewBox={`0 0 ${MINIMAP_W} ${MINIMAP_H}`}>
|
<svg
|
||||||
|
width={MINIMAP_W}
|
||||||
|
height={MINIMAP_H}
|
||||||
|
viewBox={`0 0 ${MINIMAP_W} ${MINIMAP_H}`}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
>
|
||||||
<rect width={MINIMAP_W} height={MINIMAP_H} fill="#0d1117" rx={4} />
|
<rect width={MINIMAP_W} height={MINIMAP_H} fill="#0d1117" rx={4} />
|
||||||
{/* Edges */}
|
{/* Edges */}
|
||||||
{edges.map((e) => {
|
{edges.map((e) => {
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import type { PositionedNode } from '../../../api/types';
|
|||||||
import { getNodeStyle, isCompoundType } from './nodeStyles';
|
import { getNodeStyle, isCompoundType } from './nodeStyles';
|
||||||
import styles from './diagram.module.css';
|
import styles from './diagram.module.css';
|
||||||
|
|
||||||
|
const FIXED_W = 200;
|
||||||
|
const FIXED_H = 40;
|
||||||
|
const MAX_LABEL = 22;
|
||||||
|
|
||||||
|
function truncateLabel(label: string | undefined): string {
|
||||||
|
if (!label) return '';
|
||||||
|
return label.length > MAX_LABEL ? label.slice(0, MAX_LABEL - 1) + '\u2026' : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTooltip(node: PositionedNode, isOverlayActive: boolean, isExecuted: boolean, isError: boolean, duration?: number): string {
|
||||||
|
const parts = [`${node.type ?? 'PROCESSOR'}: ${node.label ?? ''}`];
|
||||||
|
if (isOverlayActive && isExecuted) {
|
||||||
|
parts.push(`Status: ${isError ? 'FAILED' : 'OK'}`);
|
||||||
|
if (duration != null) parts.push(`Duration: ${duration}ms`);
|
||||||
|
}
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
interface DiagramNodeProps {
|
interface DiagramNodeProps {
|
||||||
node: PositionedNode;
|
node: PositionedNode;
|
||||||
isExecuted: boolean;
|
isExecuted: boolean;
|
||||||
@@ -35,6 +53,8 @@ export function DiagramNode({
|
|||||||
? (isError ? '#f85149' : '#3fb950')
|
? (isError ? '#f85149' : '#3fb950')
|
||||||
: style.border;
|
: style.border;
|
||||||
|
|
||||||
|
const tooltip = buildTooltip(node, isOverlayActive, isExecuted, isError, duration);
|
||||||
|
|
||||||
if (isCompound) {
|
if (isCompound) {
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
@@ -43,6 +63,7 @@ export function DiagramNode({
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label={`${node.type} container: ${node.label}`}
|
aria-label={`${node.type} container: ${node.label}`}
|
||||||
>
|
>
|
||||||
|
<title>{tooltip}</title>
|
||||||
<rect
|
<rect
|
||||||
x={node.x}
|
x={node.x}
|
||||||
y={node.y}
|
y={node.y}
|
||||||
@@ -71,6 +92,12 @@ export function DiagramNode({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uniform dimensions for leaf nodes — use fixed size, centered on ELK position
|
||||||
|
const cx = (node.x ?? 0) + (node.width ?? FIXED_W) / 2;
|
||||||
|
const cy = (node.y ?? 0) + (node.height ?? FIXED_H) / 2;
|
||||||
|
const rx = cx - FIXED_W / 2;
|
||||||
|
const ry = cy - FIXED_H / 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`}
|
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`}
|
||||||
@@ -81,11 +108,12 @@ export function DiagramNode({
|
|||||||
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
|
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
|
<title>{tooltip}</title>
|
||||||
<rect
|
<rect
|
||||||
x={node.x}
|
x={rx}
|
||||||
y={node.y}
|
y={ry}
|
||||||
width={node.width}
|
width={FIXED_W}
|
||||||
height={node.height}
|
height={FIXED_H}
|
||||||
rx={8}
|
rx={8}
|
||||||
fill={style.bg}
|
fill={style.bg}
|
||||||
stroke={isSelected ? '#f0b429' : borderColor}
|
stroke={isSelected ? '#f0b429' : borderColor}
|
||||||
@@ -94,23 +122,23 @@ export function DiagramNode({
|
|||||||
filter={glowFilter}
|
filter={glowFilter}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={(node.x ?? 0) + (node.width ?? 0) / 2}
|
x={cx}
|
||||||
y={(node.y ?? 0) + (node.height ?? 0) / 2 + 4}
|
y={cy + 4}
|
||||||
fill="#fff"
|
fill="#fff"
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
fontFamily="JetBrains Mono, monospace"
|
fontFamily="JetBrains Mono, monospace"
|
||||||
fontWeight={500}
|
fontWeight={500}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
>
|
>
|
||||||
{node.label}
|
{truncateLabel(node.label)}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* Duration badge */}
|
{/* Duration badge */}
|
||||||
{isOverlayActive && isExecuted && duration != null && (
|
{isOverlayActive && isExecuted && duration != null && (
|
||||||
<g>
|
<g>
|
||||||
<rect
|
<rect
|
||||||
x={(node.x ?? 0) + (node.width ?? 0) - 28}
|
x={rx + FIXED_W - 28}
|
||||||
y={(node.y ?? 0) - 8}
|
y={ry - 8}
|
||||||
width={36}
|
width={36}
|
||||||
height={16}
|
height={16}
|
||||||
rx={8}
|
rx={8}
|
||||||
@@ -118,8 +146,8 @@ export function DiagramNode({
|
|||||||
opacity={0.9}
|
opacity={0.9}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={(node.x ?? 0) + (node.width ?? 0) - 10}
|
x={rx + FIXED_W - 10}
|
||||||
y={(node.y ?? 0) + 4}
|
y={ry + 4}
|
||||||
fill="#fff"
|
fill="#fff"
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fontFamily="JetBrains Mono, monospace"
|
fontFamily="JetBrains Mono, monospace"
|
||||||
@@ -135,16 +163,16 @@ export function DiagramNode({
|
|||||||
{isOverlayActive && isExecuted && sequence != null && (
|
{isOverlayActive && isExecuted && sequence != null && (
|
||||||
<g>
|
<g>
|
||||||
<circle
|
<circle
|
||||||
cx={(node.x ?? 0) + 8}
|
cx={rx + 8}
|
||||||
cy={(node.y ?? 0) - 4}
|
cy={ry - 4}
|
||||||
r={8}
|
r={8}
|
||||||
fill="#21262d"
|
fill="#21262d"
|
||||||
stroke={isError ? '#f85149' : '#3fb950'}
|
stroke={isError ? '#f85149' : '#3fb950'}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
/>
|
/>
|
||||||
<text
|
<text
|
||||||
x={(node.x ?? 0) + 8}
|
x={rx + 8}
|
||||||
y={(node.y ?? 0) - 1}
|
y={ry - 1}
|
||||||
fill="#fff"
|
fill="#fff"
|
||||||
fontSize={8}
|
fontSize={8}
|
||||||
fontFamily="JetBrains Mono, monospace"
|
fontFamily="JetBrains Mono, monospace"
|
||||||
|
|||||||
75
ui/src/pages/routes/diagram/ExecutionPicker.tsx
Normal file
75
ui/src/pages/routes/diagram/ExecutionPicker.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { useSearchExecutions } from '../../../api/queries/executions';
|
||||||
|
import styles from './diagram.module.css';
|
||||||
|
|
||||||
|
interface ExecutionPickerProps {
|
||||||
|
group: string;
|
||||||
|
routeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionPicker({ group, routeId }: ExecutionPickerProps) {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentExecId = searchParams.get('exec');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data } = useSearchExecutions({
|
||||||
|
group,
|
||||||
|
routeId,
|
||||||
|
sortField: 'startTime',
|
||||||
|
sortDir: 'DESC',
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const executions = data?.data ?? [];
|
||||||
|
|
||||||
|
const select = (execId: string) => {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
next.set('exec', execId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.execPicker} ref={ref}>
|
||||||
|
<button className={styles.execPickerBtn} onClick={() => setOpen(!open)}>
|
||||||
|
{currentExecId ? `Exec: ${currentExecId.slice(0, 8)}...` : 'Select Execution'}
|
||||||
|
<span className={styles.execPickerChevron}>{open ? '\u25B4' : '\u25BE'}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={styles.execPickerDropdown}>
|
||||||
|
{executions.length === 0 && (
|
||||||
|
<div className={styles.execPickerEmpty}>No recent executions</div>
|
||||||
|
)}
|
||||||
|
{executions.map((ex) => (
|
||||||
|
<button
|
||||||
|
key={ex.executionId}
|
||||||
|
className={`${styles.execPickerItem} ${ex.executionId === currentExecId ? styles.execPickerItemActive : ''}`}
|
||||||
|
onClick={() => select(ex.executionId)}
|
||||||
|
>
|
||||||
|
<span className={`${styles.execPickerStatus} ${ex.status === 'FAILED' ? styles.execPickerFailed : styles.execPickerOk}`} />
|
||||||
|
<span className={styles.execPickerTime}>
|
||||||
|
{new Date(ex.startTime).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.execPickerDuration}>{ex.durationMs}ms</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,19 +45,48 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
|
|||||||
<SvgDefs />
|
<SvgDefs />
|
||||||
|
|
||||||
{/* Compound container nodes (background) */}
|
{/* Compound container nodes (background) */}
|
||||||
{compoundNodes.map((node) => (
|
{compoundNodes.map((node) => {
|
||||||
|
const iterData = node.id ? overlay.iterationData.get(node.id) : undefined;
|
||||||
|
return (
|
||||||
|
<g key={node.id}>
|
||||||
<DiagramNode
|
<DiagramNode
|
||||||
key={node.id}
|
|
||||||
node={node}
|
node={node}
|
||||||
isExecuted={!!node.id && overlay.executedNodes.has(node.id)}
|
isExecuted={!!node.id && overlay.executedNodes.has(node.id)}
|
||||||
isError={false}
|
isError={!!node.id && overlay.statuses.get(node.id) === 'FAILED'}
|
||||||
isOverlayActive={overlay.isActive}
|
isOverlayActive={overlay.isActive}
|
||||||
duration={node.id ? overlay.durations.get(node.id) : undefined}
|
duration={node.id ? overlay.durations.get(node.id) : undefined}
|
||||||
sequence={undefined}
|
sequence={undefined}
|
||||||
isSelected={overlay.selectedNodeId === node.id}
|
isSelected={overlay.selectedNodeId === node.id}
|
||||||
onClick={overlay.selectNode}
|
onClick={overlay.selectNode}
|
||||||
/>
|
/>
|
||||||
))}
|
{/* Iteration count badge */}
|
||||||
|
{overlay.isActive && iterData && iterData.count > 1 && (
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
x={(node.x ?? 0) + (node.width ?? 0) - 32}
|
||||||
|
y={(node.y ?? 0) + 2}
|
||||||
|
width={28}
|
||||||
|
height={16}
|
||||||
|
rx={8}
|
||||||
|
fill="#b87aff"
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={(node.x ?? 0) + (node.width ?? 0) - 18}
|
||||||
|
y={(node.y ?? 0) + 13}
|
||||||
|
fill="#fff"
|
||||||
|
fontSize={9}
|
||||||
|
fontFamily="JetBrains Mono, monospace"
|
||||||
|
fontWeight={600}
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{'\u00D7'}{iterData.count}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Edges */}
|
{/* Edges */}
|
||||||
<EdgeLayer
|
<EdgeLayer
|
||||||
@@ -81,7 +110,7 @@ export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
|
|||||||
key={nodeId}
|
key={nodeId}
|
||||||
node={node}
|
node={node}
|
||||||
isExecuted={overlay.executedNodes.has(nodeId)}
|
isExecuted={overlay.executedNodes.has(nodeId)}
|
||||||
isError={false}
|
isError={overlay.statuses.get(nodeId) === 'FAILED'}
|
||||||
isOverlayActive={overlay.isActive}
|
isOverlayActive={overlay.isActive}
|
||||||
duration={overlay.durations.get(nodeId)}
|
duration={overlay.durations.get(nodeId)}
|
||||||
sequence={overlay.sequences.get(nodeId)}
|
sequence={overlay.sequences.get(nodeId)}
|
||||||
|
|||||||
@@ -17,36 +17,36 @@ export function SvgDefs() {
|
|||||||
</marker>
|
</marker>
|
||||||
|
|
||||||
{/* Glow filters */}
|
{/* Glow filters */}
|
||||||
<filter id="glow-green" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="glow-green" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
<feGaussianBlur stdDeviation="6" result="blur" />
|
||||||
<feFlood floodColor="#3fb950" floodOpacity="0.4" result="color" />
|
<feFlood floodColor="#3fb950" floodOpacity="0.6" result="color" />
|
||||||
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
||||||
<feMerge>
|
<feMerge>
|
||||||
<feMergeNode in="shadow" />
|
<feMergeNode in="shadow" />
|
||||||
<feMergeNode in="SourceGraphic" />
|
<feMergeNode in="SourceGraphic" />
|
||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
<filter id="glow-red" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="glow-red" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
<feGaussianBlur stdDeviation="6" result="blur" />
|
||||||
<feFlood floodColor="#f85149" floodOpacity="0.4" result="color" />
|
<feFlood floodColor="#f85149" floodOpacity="0.6" result="color" />
|
||||||
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
||||||
<feMerge>
|
<feMerge>
|
||||||
<feMergeNode in="shadow" />
|
<feMergeNode in="shadow" />
|
||||||
<feMergeNode in="SourceGraphic" />
|
<feMergeNode in="SourceGraphic" />
|
||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
<filter id="glow-blue" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="glow-blue" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
<feGaussianBlur stdDeviation="6" result="blur" />
|
||||||
<feFlood floodColor="#58a6ff" floodOpacity="0.4" result="color" />
|
<feFlood floodColor="#58a6ff" floodOpacity="0.6" result="color" />
|
||||||
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
||||||
<feMerge>
|
<feMerge>
|
||||||
<feMergeNode in="shadow" />
|
<feMergeNode in="shadow" />
|
||||||
<feMergeNode in="SourceGraphic" />
|
<feMergeNode in="SourceGraphic" />
|
||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
<filter id="glow-purple" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="glow-purple" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
<feGaussianBlur stdDeviation="6" result="blur" />
|
||||||
<feFlood floodColor="#b87aff" floodOpacity="0.4" result="color" />
|
<feFlood floodColor="#b87aff" floodOpacity="0.6" result="color" />
|
||||||
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
||||||
<feMerge>
|
<feMerge>
|
||||||
<feMergeNode in="shadow" />
|
<feMergeNode in="shadow" />
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background: var(--bg-deep);
|
background:
|
||||||
|
radial-gradient(ellipse at 20% 50%, rgba(240, 180, 41, 0.04) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse at 80% 50%, rgba(34, 211, 238, 0.04) 0%, transparent 60%),
|
||||||
|
var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -306,6 +309,162 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Execution Picker ─── */
|
||||||
|
.execPicker {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerChevron {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerDropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
width: 260px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerEmpty {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerItemActive {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerStatus {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerOk {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerFailed {
|
||||||
|
background: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerTime {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execPickerDuration {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Legend ─── */
|
||||||
|
.legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
background: rgba(13, 17, 23, 0.85);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 14px;
|
||||||
|
z-index: 10;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendTitle {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendDot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendLine {
|
||||||
|
width: 16px;
|
||||||
|
height: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Responsive ─── */
|
/* ─── Responsive ─── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.splitLayout {
|
.splitLayout {
|
||||||
@@ -322,4 +481,8 @@
|
|||||||
.minimap {
|
.minimap {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,3 +141,31 @@ body::after {
|
|||||||
.mono { font-family: var(--font-mono); font-size: 12px; }
|
.mono { font-family: var(--font-mono); font-size: 12px; }
|
||||||
.text-muted { color: var(--text-muted); }
|
.text-muted { color: var(--text-muted); }
|
||||||
.text-secondary { color: var(--text-secondary); }
|
.text-secondary { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ─── View Transitions (progressive enhancement) ─── */
|
||||||
|
@keyframes slide-out-left {
|
||||||
|
to { opacity: 0; transform: translateX(-60px); }
|
||||||
|
}
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from { opacity: 0; transform: translateX(60px); }
|
||||||
|
}
|
||||||
|
@keyframes slide-out-right {
|
||||||
|
to { opacity: 0; transform: translateX(60px); }
|
||||||
|
}
|
||||||
|
@keyframes slide-in-left {
|
||||||
|
from { opacity: 0; transform: translateX(-60px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: slide-out-left 0.2s ease-in both;
|
||||||
|
}
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: slide-in-right 0.2s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-nav::view-transition-old(root) {
|
||||||
|
animation: slide-out-right 0.2s ease-in both;
|
||||||
|
}
|
||||||
|
.back-nav::view-transition-new(root) {
|
||||||
|
animation: slide-in-left 0.2s ease-out both;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user