289 lines
9.7 KiB
TypeScript
289 lines
9.7 KiB
TypeScript
import { h } from 'preact';
|
|
import { useState, useMemo, useEffect } from 'preact/hooks';
|
|
import InterfaceList from './InterfaceList';
|
|
import Canvas from './Canvas';
|
|
import { wsClient } from './lib/ws';
|
|
import { parseInterface } from './lib/xml';
|
|
import { newPlotPanel } from './lib/templates';
|
|
import { useAuth, canWrite } from './lib/auth';
|
|
import type { Interface } from './lib/types';
|
|
import HelpModal from './HelpModal';
|
|
import ZoomControl from './ZoomControl';
|
|
import ControlLogicEditor from './ControlLogicEditor';
|
|
import AuditViewer from './AuditViewer';
|
|
import ConfigManager from './ConfigManager';
|
|
|
|
interface Props {
|
|
onEdit?: (iface?: Interface) => void;
|
|
initialInterface?: Interface | null;
|
|
/** Notifies the parent which interface is currently shown (for edit return). */
|
|
onView?: (iface: Interface | null) => void;
|
|
}
|
|
|
|
/** Format a Date as a datetime-local input value (YYYY-MM-DDTHH:MM) */
|
|
function toLocalInput(d: Date): string {
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
|
const [currentInterface, setCurrentInterface] = useState<Interface | null>(initialInterface ?? null);
|
|
const [parseError, setParseError] = useState<string | null>(null);
|
|
const [wsStatus, setWsStatus] = useState('connecting');
|
|
const [showHelp, setShowHelp] = useState(false);
|
|
const [helpSection, setHelpSection] = useState('start');
|
|
const [showTimeNav, setShowTimeNav] = useState(false);
|
|
const [showControlLogic, setShowControlLogic] = useState(false);
|
|
const [showAudit, setShowAudit] = useState(false);
|
|
const [showConfig, setShowConfig] = useState(false);
|
|
const [leftW, setLeftW] = useState(220);
|
|
const [listCollapsed, setListCollapsed] = useState(false);
|
|
const me = useAuth();
|
|
const writable = canWrite(me.level);
|
|
|
|
function startResize(e: MouseEvent) {
|
|
e.preventDefault();
|
|
const startX = e.clientX;
|
|
const startW = leftW;
|
|
function onMove(mv: MouseEvent) {
|
|
setLeftW(Math.max(140, startW + mv.clientX - startX));
|
|
}
|
|
function onUp() {
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseup', onUp);
|
|
}
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
function openHelp(section = 'start') { setHelpSection(section); setShowHelp(true); }
|
|
|
|
// Historical time range — null means live
|
|
const now = useMemo(() => new Date(), []);
|
|
const [startInput, setStartInput] = useState(() => toLocalInput(new Date(now.getTime() - 3600_000)));
|
|
const [endInput, setEndInput] = useState(() => toLocalInput(now));
|
|
const [timeRange, setTimeRange] = useState<{ start: string; end: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
const unsub = wsClient.status.subscribe(setWsStatus);
|
|
return unsub;
|
|
}, []);
|
|
|
|
// When returning from edit mode, show the edited interface
|
|
useEffect(() => {
|
|
if (initialInterface) setCurrentInterface(initialInterface);
|
|
}, [initialInterface]);
|
|
|
|
// Auto-hide the interface-selection pane whenever a panel is opened, and keep
|
|
// the parent informed of what's shown (so closing the editor can return here).
|
|
useEffect(() => {
|
|
if (currentInterface) setListCollapsed(true);
|
|
onView?.(currentInterface);
|
|
}, [currentInterface]);
|
|
|
|
function handleLoad(xml: string) {
|
|
try {
|
|
setParseError(null);
|
|
setCurrentInterface(parseInterface(xml));
|
|
} catch (err) {
|
|
setParseError(err instanceof Error ? err.message : 'Failed to parse interface file.');
|
|
}
|
|
}
|
|
|
|
async function handleNavigate(interfaceId: string) {
|
|
try {
|
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(interfaceId)}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
handleLoad(await res.text());
|
|
} catch (err) {
|
|
setParseError(`Failed to load interface "${interfaceId}": ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
/** Load interface by ID and pass parsed object to onEdit */
|
|
async function handleEditById(id: string) {
|
|
try {
|
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const iface = parseInterface(await res.text());
|
|
onEdit?.(iface);
|
|
} catch (err) {
|
|
setParseError(`Failed to load interface for editing: ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
function handleLoadHistory() {
|
|
if (!startInput || !endInput) return;
|
|
const start = new Date(startInput).toISOString();
|
|
const end = new Date(endInput).toISOString();
|
|
setTimeRange({ start, end });
|
|
}
|
|
|
|
function handleGoLive() {
|
|
setTimeRange(null);
|
|
}
|
|
|
|
const isLive = timeRange === null;
|
|
|
|
return (
|
|
<div class="view-mode">
|
|
<header class="toolbar">
|
|
<div class="toolbar-left">
|
|
<span class="app-name">uopi</span>
|
|
{currentInterface && (
|
|
<span class="iface-name">{currentInterface.name}</span>
|
|
)}
|
|
</div>
|
|
<div class="toolbar-center">
|
|
{parseError && (
|
|
<span class="parse-error" title={parseError}>Parse error — check console</span>
|
|
)}
|
|
</div>
|
|
<div class="toolbar-right">
|
|
<div
|
|
class={`status-chip ${wsStatus}`}
|
|
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
|
|
>
|
|
<span class="status-dot"></span>
|
|
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
|
{me.user && !writable && ' (read-only)'}
|
|
</div>
|
|
<button
|
|
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
|
title="Toggle historical time navigation"
|
|
onClick={() => setShowTimeNav(v => !v)}
|
|
>
|
|
⏱ History
|
|
</button>
|
|
<button
|
|
class="icon-btn help-manual-btn"
|
|
onClick={() => openHelp('start')}
|
|
title="Open user manual"
|
|
>
|
|
📖
|
|
</button>
|
|
<ZoomControl />
|
|
{writable && me.canEditLogic && (
|
|
<button
|
|
class="toolbar-btn"
|
|
onClick={() => setShowControlLogic(true)}
|
|
title="Open server-side control logic"
|
|
>
|
|
⚙ Control logic
|
|
</button>
|
|
)}
|
|
{me.canViewAudit && (
|
|
<button
|
|
class="toolbar-btn"
|
|
onClick={() => setShowAudit(true)}
|
|
title="View the audit log"
|
|
>
|
|
🛡 Audit
|
|
</button>
|
|
)}
|
|
{writable && (
|
|
<button
|
|
class="toolbar-btn"
|
|
onClick={() => setShowConfig(true)}
|
|
title="Open configuration manager"
|
|
>
|
|
🗂 Config
|
|
</button>
|
|
)}
|
|
{writable && (
|
|
<button
|
|
class="btn-edit"
|
|
onClick={() => onEdit?.(currentInterface ?? undefined)}
|
|
title="Switch to Edit mode"
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Historical time range bar — hidden by default */}
|
|
{showTimeNav && (
|
|
<div class="time-nav-bar">
|
|
<button
|
|
class={`toolbar-btn${isLive ? ' toolbar-btn-active' : ''}`}
|
|
title="Switch to live data"
|
|
onClick={handleGoLive}
|
|
>
|
|
● Live
|
|
</button>
|
|
<input
|
|
class="time-input"
|
|
type="datetime-local"
|
|
value={startInput}
|
|
onInput={(e) => setStartInput((e.target as HTMLInputElement).value)}
|
|
title="History start"
|
|
/>
|
|
<span class="time-sep">→</span>
|
|
<input
|
|
class="time-input"
|
|
type="datetime-local"
|
|
value={endInput}
|
|
onInput={(e) => setEndInput((e.target as HTMLInputElement).value)}
|
|
title="History end"
|
|
/>
|
|
<button
|
|
class="toolbar-btn"
|
|
title="Load historical data for this time range"
|
|
onClick={handleLoadHistory}
|
|
>
|
|
Load
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div class="content">
|
|
<InterfaceList
|
|
onLoad={handleLoad}
|
|
onEdit={onEdit}
|
|
onNewPlot={() => onEdit?.(newPlotPanel())}
|
|
onEditId={handleEditById}
|
|
width={leftW}
|
|
collapsed={listCollapsed}
|
|
onToggleCollapse={() => setListCollapsed(c => !c)}
|
|
onSelect={async (id) => {
|
|
try {
|
|
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(id)}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
handleLoad(await res.text());
|
|
} catch (err) {
|
|
setParseError(`Failed to load interface "${id}": ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}}
|
|
/>
|
|
<div class="panel-resize-handle" onMouseDown={startResize} />
|
|
|
|
<div class="view-content-area">
|
|
<div class="view-panel-container active">
|
|
<Canvas
|
|
iface={currentInterface}
|
|
onNavigate={handleNavigate}
|
|
timeRange={timeRange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showHelp && (
|
|
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
|
)}
|
|
|
|
{showControlLogic && (
|
|
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
|
|
)}
|
|
|
|
{showAudit && (
|
|
<AuditViewer onClose={() => setShowAudit(false)} />
|
|
)}
|
|
{showConfig && (
|
|
<ConfigManager onClose={() => setShowConfig(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|