first
This commit is contained in:
600
web/app.js
Normal file
600
web/app.js
Normal file
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* Arnold — Terminator I/O Web Interface
|
||||
*
|
||||
* Vanilla JS, no build tools. Polls the REST API at ~20 Hz for I/O state
|
||||
* and ~2 Hz for status/sequences. Renders into the DOM panels defined in
|
||||
* index.html.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const state = {
|
||||
signals: [], // from GET /config/signals (bootstrap)
|
||||
ioSnapshot: {}, // from GET /io
|
||||
status: null, // from GET /status
|
||||
sequences: [], // from GET /sequences
|
||||
sequenceCache: {}, // name -> full detail from GET /sequences/{name}
|
||||
|
||||
// Output shadow state (what we last wrote)
|
||||
outputState: {}, // signal_name -> bool|int
|
||||
|
||||
// Analog pending edits
|
||||
analogPending: {}, // signal_name -> int (before committed)
|
||||
|
||||
// Active sequence run
|
||||
activeRunId: null,
|
||||
activeRun: null, // RunResult from GET /runs/{run_id}
|
||||
lastResult: null, // last completed RunResult
|
||||
|
||||
// Flash message
|
||||
flashMsg: "",
|
||||
flashTimer: null,
|
||||
};
|
||||
|
||||
// Derived signal lists (populated after bootstrap)
|
||||
let digitalInputs = [];
|
||||
let analogInputs = [];
|
||||
let digitalOutputs = [];
|
||||
let analogOutputs = [];
|
||||
let hasAnalogInputs = false;
|
||||
let hasDigitalInputs = false;
|
||||
let hasAnalogOutputs = false;
|
||||
let hasDigitalOutputs = false;
|
||||
|
||||
// ── API helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const API = ""; // same origin
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(API + path, opts);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status} ${res.statusText}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Bootstrap ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
// Load signal config + sequences in parallel
|
||||
const [signals, sequences] = await Promise.all([
|
||||
api("GET", "/config/signals"),
|
||||
api("GET", "/sequences"),
|
||||
]);
|
||||
|
||||
state.signals = signals;
|
||||
state.sequences = sequences;
|
||||
|
||||
// Partition signals
|
||||
digitalInputs = signals.filter(s => s.direction === "input" && s.value_type === "bool");
|
||||
analogInputs = signals.filter(s => s.direction === "input" && s.value_type === "int");
|
||||
digitalOutputs = signals.filter(s => s.direction === "output" && s.value_type === "bool");
|
||||
analogOutputs = signals.filter(s => s.direction === "output" && s.value_type === "int");
|
||||
|
||||
hasDigitalInputs = digitalInputs.length > 0;
|
||||
hasAnalogInputs = analogInputs.length > 0;
|
||||
hasDigitalOutputs = digitalOutputs.length > 0;
|
||||
hasAnalogOutputs = analogOutputs.length > 0;
|
||||
|
||||
// Init output shadow state
|
||||
for (const s of digitalOutputs) {
|
||||
state.outputState[s.name] = s.default_state ?? false;
|
||||
}
|
||||
for (const s of analogOutputs) {
|
||||
state.outputState[s.name] = s.default_value ?? 0;
|
||||
}
|
||||
|
||||
// Pre-fetch full sequence details
|
||||
for (const seq of sequences) {
|
||||
api("GET", `/sequences/${encodeURIComponent(seq.name)}`).then(detail => {
|
||||
state.sequenceCache[seq.name] = detail;
|
||||
});
|
||||
}
|
||||
|
||||
buildInputPanel();
|
||||
buildOutputPanel();
|
||||
buildSequencePanel();
|
||||
|
||||
// Start poll loops
|
||||
pollIO();
|
||||
pollStatus();
|
||||
pollRun();
|
||||
|
||||
} catch (err) {
|
||||
flash("Bootstrap failed: " + err.message);
|
||||
console.error("Bootstrap error:", err);
|
||||
// Retry in 3 seconds
|
||||
setTimeout(bootstrap, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Polling loops ──────────────────────────────────────────────────────────
|
||||
|
||||
function pollIO() {
|
||||
api("GET", "/io")
|
||||
.then(data => {
|
||||
state.ioSnapshot = data;
|
||||
renderInputs();
|
||||
renderOutputValues();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setTimeout(pollIO, 50)); // ~20 Hz
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
api("GET", "/status")
|
||||
.then(data => {
|
||||
state.status = data;
|
||||
renderStatusBar();
|
||||
|
||||
// Track active run
|
||||
if (data.active_run && !state.activeRunId) {
|
||||
state.activeRunId = data.active_run;
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setTimeout(pollStatus, 500)); // 2 Hz
|
||||
}
|
||||
|
||||
function pollRun() {
|
||||
if (state.activeRunId) {
|
||||
api("GET", `/runs/${state.activeRunId}`)
|
||||
.then(run => {
|
||||
state.activeRun = run;
|
||||
|
||||
if (run.status !== "pending" && run.status !== "running") {
|
||||
// Run completed
|
||||
state.lastResult = run;
|
||||
state.activeRunId = null;
|
||||
state.activeRun = null;
|
||||
renderSequenceIdle();
|
||||
} else {
|
||||
renderSequenceRunning();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setTimeout(pollRun, 100);
|
||||
}
|
||||
|
||||
// ── Status bar ─────────────────────────────────────────────────────────────
|
||||
|
||||
function renderStatusBar() {
|
||||
const el = document.getElementById("status-devices");
|
||||
if (!state.status) { el.innerHTML = "<span style='color:var(--text-dim)'>connecting...</span>"; return; }
|
||||
|
||||
const devs = state.status.devices || [];
|
||||
const polls = state.status.poll_stats || [];
|
||||
const pollMap = {};
|
||||
for (const p of polls) pollMap[p.device_id] = p;
|
||||
|
||||
el.innerHTML = devs.map(d => {
|
||||
const p = pollMap[d.device_id] || {};
|
||||
const dot = d.connected ? "connected" : "disconnected";
|
||||
const hz = (p.achieved_hz || 0).toFixed(0);
|
||||
const err = p.error_count || 0;
|
||||
return `<div class="device-status">
|
||||
<span class="device-dot ${dot}"></span>
|
||||
<span>${d.device_id}</span>
|
||||
<span style="color:var(--text-dim)">${hz} Hz</span>
|
||||
<span style="color:var(--text-dim)">err=${err}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// ── Input panel ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildInputPanel() {
|
||||
const diHeader = document.querySelector("#digital-inputs .sub-header");
|
||||
const aiHeader = document.querySelector("#analog-inputs .sub-header");
|
||||
const noInputs = document.getElementById("no-inputs");
|
||||
|
||||
if (!hasDigitalInputs && !hasAnalogInputs) {
|
||||
noInputs.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
noInputs.classList.add("hidden");
|
||||
|
||||
// Show sub-headers only when both types present
|
||||
if (hasDigitalInputs && hasAnalogInputs) {
|
||||
diHeader.classList.remove("hidden");
|
||||
aiHeader.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Build digital input rows
|
||||
const diList = document.getElementById("digital-input-list");
|
||||
diList.innerHTML = digitalInputs.map(s => `
|
||||
<div class="signal-row" id="di-${s.name}">
|
||||
<span class="indicator off"></span>
|
||||
<span class="signal-name">${s.name}</span>
|
||||
<span class="signal-value off">OFF</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// Build analog input rows
|
||||
const aiList = document.getElementById("analog-input-list");
|
||||
aiList.innerHTML = analogInputs.map(s => `
|
||||
<div class="signal-row" id="ai-${s.name}">
|
||||
<span class="signal-name">${s.name}</span>
|
||||
<span class="signal-value analog">0</span>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderInputs() {
|
||||
// Digital inputs
|
||||
for (const s of digitalInputs) {
|
||||
const row = document.getElementById(`di-${s.name}`);
|
||||
if (!row) continue;
|
||||
const io = state.ioSnapshot[s.name];
|
||||
const dot = row.querySelector(".indicator");
|
||||
const val = row.querySelector(".signal-value");
|
||||
|
||||
if (!io || io.stale) {
|
||||
dot.className = "indicator stale";
|
||||
val.className = "signal-value stale";
|
||||
val.textContent = "?";
|
||||
} else if (io.value) {
|
||||
dot.className = "indicator on";
|
||||
val.className = "signal-value on";
|
||||
val.textContent = "ON";
|
||||
} else {
|
||||
dot.className = "indicator off";
|
||||
val.className = "signal-value off";
|
||||
val.textContent = "OFF";
|
||||
}
|
||||
}
|
||||
|
||||
// Analog inputs
|
||||
for (const s of analogInputs) {
|
||||
const row = document.getElementById(`ai-${s.name}`);
|
||||
if (!row) continue;
|
||||
const io = state.ioSnapshot[s.name];
|
||||
const val = row.querySelector(".signal-value");
|
||||
|
||||
if (!io || io.stale) {
|
||||
val.className = "signal-value stale";
|
||||
val.textContent = "?";
|
||||
} else {
|
||||
val.className = "signal-value analog";
|
||||
val.textContent = String(io.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output panel ───────────────────────────────────────────────────────────
|
||||
|
||||
function buildOutputPanel() {
|
||||
const doHeader = document.querySelector("#digital-outputs .sub-header");
|
||||
const aoHeader = document.querySelector("#analog-outputs .sub-header");
|
||||
const noOutputs = document.getElementById("no-outputs");
|
||||
const bulkDiv = document.getElementById("digital-bulk");
|
||||
|
||||
if (!hasDigitalOutputs && !hasAnalogOutputs) {
|
||||
noOutputs.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
noOutputs.classList.add("hidden");
|
||||
|
||||
// Show sub-headers only when both types present
|
||||
if (hasDigitalOutputs && hasAnalogOutputs) {
|
||||
doHeader.classList.remove("hidden");
|
||||
aoHeader.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Bulk buttons
|
||||
if (hasDigitalOutputs) {
|
||||
bulkDiv.classList.remove("hidden");
|
||||
document.getElementById("btn-all-off").addEventListener("click", () => allDigitalOutputs(false));
|
||||
document.getElementById("btn-all-on").addEventListener("click", () => allDigitalOutputs(true));
|
||||
}
|
||||
|
||||
// Digital output rows
|
||||
const doList = document.getElementById("digital-output-list");
|
||||
doList.innerHTML = digitalOutputs.map(s => `
|
||||
<div class="signal-row output-row" id="do-${s.name}" data-signal="${s.name}">
|
||||
<span class="indicator off"></span>
|
||||
<span class="signal-name">${s.name}</span>
|
||||
<span class="signal-value off">OFF</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// Click to toggle
|
||||
for (const s of digitalOutputs) {
|
||||
document.getElementById(`do-${s.name}`).addEventListener("click", () => {
|
||||
const cur = state.outputState[s.name];
|
||||
writeOutput(s.name, !cur);
|
||||
});
|
||||
}
|
||||
|
||||
// Analog output rows
|
||||
const aoList = document.getElementById("analog-output-list");
|
||||
aoList.innerHTML = analogOutputs.map(s => `
|
||||
<div class="signal-row" id="ao-${s.name}">
|
||||
<span class="signal-name">${s.name}</span>
|
||||
<div class="analog-controls">
|
||||
<button class="ao-minus" data-signal="${s.name}" title="Decrease">−</button>
|
||||
<input type="number" class="ao-input" data-signal="${s.name}"
|
||||
min="0" max="65535" value="${state.outputState[s.name] || 0}">
|
||||
<button class="ao-plus" data-signal="${s.name}" title="Increase">+</button>
|
||||
<button class="write-btn ao-write" data-signal="${s.name}">Write</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// Analog control events
|
||||
for (const s of analogOutputs) {
|
||||
const input = aoList.querySelector(`input[data-signal="${s.name}"]`);
|
||||
const minus = aoList.querySelector(`.ao-minus[data-signal="${s.name}"]`);
|
||||
const plus = aoList.querySelector(`.ao-plus[data-signal="${s.name}"]`);
|
||||
const write = aoList.querySelector(`.ao-write[data-signal="${s.name}"]`);
|
||||
|
||||
minus.addEventListener("click", () => {
|
||||
const cur = parseInt(input.value) || 0;
|
||||
const nv = Math.max(0, cur - 100);
|
||||
input.value = nv;
|
||||
markAnalogPending(s.name, nv, input);
|
||||
});
|
||||
|
||||
plus.addEventListener("click", () => {
|
||||
const cur = parseInt(input.value) || 0;
|
||||
const nv = Math.min(65535, cur + 100);
|
||||
input.value = nv;
|
||||
markAnalogPending(s.name, nv, input);
|
||||
});
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
let v = parseInt(input.value);
|
||||
if (isNaN(v)) v = 0;
|
||||
v = Math.max(0, Math.min(65535, v));
|
||||
markAnalogPending(s.name, v, input);
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
const v = parseInt(input.value) || 0;
|
||||
writeOutput(s.name, Math.max(0, Math.min(65535, v)));
|
||||
}
|
||||
});
|
||||
|
||||
write.addEventListener("click", () => {
|
||||
const v = parseInt(input.value) || 0;
|
||||
writeOutput(s.name, Math.max(0, Math.min(65535, v)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function markAnalogPending(name, value, inputEl) {
|
||||
state.analogPending[name] = value;
|
||||
if (value !== state.outputState[name]) {
|
||||
inputEl.classList.add("pending");
|
||||
} else {
|
||||
inputEl.classList.remove("pending");
|
||||
}
|
||||
}
|
||||
|
||||
function renderOutputValues() {
|
||||
// Digital outputs — use shadow state, NOT polled IO
|
||||
for (const s of digitalOutputs) {
|
||||
const row = document.getElementById(`do-${s.name}`);
|
||||
if (!row) continue;
|
||||
const val = state.outputState[s.name];
|
||||
const dot = row.querySelector(".indicator");
|
||||
const txt = row.querySelector(".signal-value");
|
||||
|
||||
if (val) {
|
||||
dot.className = "indicator on";
|
||||
txt.className = "signal-value on";
|
||||
txt.textContent = "ON";
|
||||
} else {
|
||||
dot.className = "indicator off";
|
||||
txt.className = "signal-value off";
|
||||
txt.textContent = "OFF";
|
||||
}
|
||||
}
|
||||
|
||||
// Analog outputs — update input value only if not pending
|
||||
for (const s of analogOutputs) {
|
||||
const input = document.querySelector(`input.ao-input[data-signal="${s.name}"]`);
|
||||
if (!input) continue;
|
||||
if (!(s.name in state.analogPending)) {
|
||||
input.value = state.outputState[s.name] || 0;
|
||||
input.classList.remove("pending");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output writes ──────────────────────────────────────────────────────────
|
||||
|
||||
async function writeOutput(name, value) {
|
||||
try {
|
||||
await api("POST", `/io/${encodeURIComponent(name)}/write`, { value });
|
||||
state.outputState[name] = value;
|
||||
delete state.analogPending[name];
|
||||
|
||||
// Clear pending style on analog input
|
||||
const input = document.querySelector(`input.ao-input[data-signal="${name}"]`);
|
||||
if (input) {
|
||||
input.value = value;
|
||||
input.classList.remove("pending");
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
flash(`${name} \u2192 ${value ? "ON" : "OFF"}`);
|
||||
} else {
|
||||
flash(`${name} \u2192 ${value}`);
|
||||
}
|
||||
} catch (err) {
|
||||
flash(`WRITE FAILED: ${name} \u2014 ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function allDigitalOutputs(value) {
|
||||
for (const s of digitalOutputs) {
|
||||
writeOutput(s.name, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sequence panel ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildSequencePanel() {
|
||||
renderSequenceIdle();
|
||||
}
|
||||
|
||||
function renderSequenceIdle() {
|
||||
const idle = document.getElementById("sequence-idle");
|
||||
const running = document.getElementById("sequence-running");
|
||||
idle.classList.remove("hidden");
|
||||
running.classList.add("hidden");
|
||||
|
||||
const list = document.getElementById("sequence-list");
|
||||
list.innerHTML = state.sequences.map(seq => `
|
||||
<div class="seq-item">
|
||||
<div style="flex:1">
|
||||
<div class="seq-name">${esc(seq.name)}</div>
|
||||
${seq.description ? `<div class="seq-desc">${esc(seq.description)}</div>` : ""}
|
||||
</div>
|
||||
<span class="seq-meta">${seq.steps} steps</span>
|
||||
<button class="seq-run-btn" data-seq="${esc(seq.name)}"
|
||||
${state.activeRunId ? "disabled" : ""}>Run</button>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
// Bind run buttons
|
||||
for (const btn of list.querySelectorAll(".seq-run-btn")) {
|
||||
btn.addEventListener("click", () => runSequence(btn.dataset.seq));
|
||||
}
|
||||
|
||||
// Last run summary
|
||||
const summaryEl = document.getElementById("last-run-summary");
|
||||
if (state.lastResult) {
|
||||
const r = state.lastResult;
|
||||
const cls = r.status === "success" ? "run-success" : "run-failed";
|
||||
let html = `<span class="${cls}">${esc(r.sequence_name)} \u2192 ${r.status.toUpperCase()}</span>`;
|
||||
html += ` <span style="color:var(--text-dim)">${r.steps_completed}/${r.total_steps} steps · ${r.duration_ms} ms</span>`;
|
||||
if (r.failed_step) {
|
||||
html += `<br><span class="run-failed">\u2717 step ${r.failed_step.step_index} (${r.failed_step.t_ms} ms): ${esc(r.failed_step.detail)}</span>`;
|
||||
}
|
||||
summaryEl.innerHTML = html;
|
||||
summaryEl.classList.remove("hidden");
|
||||
} else {
|
||||
summaryEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function renderSequenceRunning() {
|
||||
const idle = document.getElementById("sequence-idle");
|
||||
const running = document.getElementById("sequence-running");
|
||||
idle.classList.add("hidden");
|
||||
running.classList.remove("hidden");
|
||||
|
||||
const run = state.activeRun;
|
||||
if (!run) return;
|
||||
|
||||
const detail = state.sequenceCache[run.sequence_name];
|
||||
|
||||
// Header
|
||||
document.getElementById("run-header").textContent =
|
||||
`\u25B6 ${run.sequence_name} ${run.steps_completed}/${run.total_steps}`;
|
||||
|
||||
// Progress bar
|
||||
const pct = run.total_steps ? (run.steps_completed / run.total_steps * 100) : 0;
|
||||
document.getElementById("run-progress-fill").style.width = pct + "%";
|
||||
|
||||
// Step list
|
||||
const stepList = document.getElementById("run-step-list");
|
||||
if (!detail) {
|
||||
stepList.innerHTML = "<div style='color:var(--text-dim)'>(loading steps...)</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
stepList.innerHTML = detail.steps.map((step, i) => {
|
||||
let cls, icon;
|
||||
if (i === run.current_step_index) {
|
||||
cls = "current"; icon = "\u25B6";
|
||||
} else if (i < run.steps_completed) {
|
||||
cls = "completed"; icon = "\u2713";
|
||||
} else {
|
||||
cls = "pending-step"; icon = "\u00B7";
|
||||
}
|
||||
|
||||
const tStr = (step.t_ms / 1000).toFixed(1) + "s";
|
||||
const action = formatStep(step);
|
||||
|
||||
return `<div class="step-row ${cls}">
|
||||
<span class="step-icon">${icon}</span>
|
||||
<span class="step-time">${tStr}</span>
|
||||
<span class="step-action">${esc(action)}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function formatStep(step) {
|
||||
if (step.action === "set_output") {
|
||||
if (step.value !== null && step.value !== undefined) {
|
||||
return `set ${step.signal} \u2192 ${step.value}`;
|
||||
}
|
||||
return `set ${step.signal} \u2192 ${step.state ? "ON" : "OFF"}`;
|
||||
}
|
||||
if (step.action === "check_input") {
|
||||
if (step.expected_value !== null && step.expected_value !== undefined) {
|
||||
const tol = step.tolerance ? `\u00B1${step.tolerance}` : "";
|
||||
return `chk ${step.signal} == ${step.expected_value}${tol}`;
|
||||
}
|
||||
return `chk ${step.signal} == ${step.expected ? "ON" : "OFF"}`;
|
||||
}
|
||||
if (step.action === "wait_input") {
|
||||
const tout = step.timeout_ms ? `${step.timeout_ms} ms` : "?";
|
||||
if (step.expected_value !== null && step.expected_value !== undefined) {
|
||||
const tol = step.tolerance ? `\u00B1${step.tolerance}` : "";
|
||||
return `wait ${step.signal} == ${step.expected_value}${tol} (timeout ${tout})`;
|
||||
}
|
||||
return `wait ${step.signal} == ${step.expected ? "ON" : "OFF"} (timeout ${tout})`;
|
||||
}
|
||||
return `${step.action} ${step.signal}`;
|
||||
}
|
||||
|
||||
async function runSequence(name) {
|
||||
if (state.activeRunId) {
|
||||
flash("Busy: sequence already running");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api("POST", `/sequences/${encodeURIComponent(name)}/run`);
|
||||
state.activeRunId = res.run_id;
|
||||
flash(`Started: ${name}`);
|
||||
renderSequenceRunning();
|
||||
} catch (err) {
|
||||
flash(`Failed to start: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flash message ──────────────────────────────────────────────────────────
|
||||
|
||||
function flash(msg, duration = 4000) {
|
||||
const el = document.getElementById("status-message");
|
||||
el.textContent = msg;
|
||||
if (state.flashTimer) clearTimeout(state.flashTimer);
|
||||
state.flashTimer = setTimeout(() => { el.textContent = ""; }, duration);
|
||||
}
|
||||
|
||||
// ── Utilities ──────────────────────────────────────────────────────────────
|
||||
|
||||
function esc(str) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = str;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener("DOMContentLoaded", bootstrap);
|
||||
71
web/index.html
Normal file
71
web/index.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Arnold — Terminator I/O</title>
|
||||
<link rel="stylesheet" href="/web/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Status bar -->
|
||||
<header id="status-bar">
|
||||
<div id="status-devices"></div>
|
||||
<div id="status-message"></div>
|
||||
</header>
|
||||
|
||||
<!-- Main 3-panel layout -->
|
||||
<main id="panels">
|
||||
<!-- Inputs panel -->
|
||||
<section id="input-panel" class="panel">
|
||||
<h2>Inputs</h2>
|
||||
<div id="digital-inputs">
|
||||
<h3 class="sub-header hidden">Digital</h3>
|
||||
<div id="digital-input-list" class="signal-list"></div>
|
||||
</div>
|
||||
<div id="analog-inputs">
|
||||
<h3 class="sub-header hidden">Analog</h3>
|
||||
<div id="analog-input-list" class="signal-list"></div>
|
||||
</div>
|
||||
<div id="no-inputs" class="empty-msg">(none)</div>
|
||||
</section>
|
||||
|
||||
<!-- Outputs panel -->
|
||||
<section id="output-panel" class="panel">
|
||||
<h2>Outputs</h2>
|
||||
<div id="digital-outputs">
|
||||
<h3 class="sub-header hidden">Digital</h3>
|
||||
<div class="bulk-actions hidden" id="digital-bulk">
|
||||
<button id="btn-all-off" title="All digital outputs OFF">All OFF</button>
|
||||
<button id="btn-all-on" title="All digital outputs ON">All ON</button>
|
||||
</div>
|
||||
<div id="digital-output-list" class="signal-list"></div>
|
||||
</div>
|
||||
<div id="analog-outputs">
|
||||
<h3 class="sub-header hidden">Analog</h3>
|
||||
<div id="analog-output-list" class="signal-list"></div>
|
||||
</div>
|
||||
<div id="no-outputs" class="empty-msg">(none)</div>
|
||||
</section>
|
||||
|
||||
<!-- Sequences panel -->
|
||||
<section id="sequence-panel" class="panel panel-wide">
|
||||
<h2>Sequences</h2>
|
||||
<div id="sequence-idle">
|
||||
<div id="sequence-list"></div>
|
||||
<div id="last-run-summary" class="hidden"></div>
|
||||
</div>
|
||||
<div id="sequence-running" class="hidden">
|
||||
<div id="run-header"></div>
|
||||
<div id="run-progress-bar"><div id="run-progress-fill"></div></div>
|
||||
<div id="run-step-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer id="footer">
|
||||
Arnold — Terminator I/O Server
|
||||
</footer>
|
||||
|
||||
<script src="/web/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
463
web/style.css
Normal file
463
web/style.css
Normal file
@@ -0,0 +1,463 @@
|
||||
/* Arnold — Terminator I/O Web Interface
|
||||
Dark theme, terminal-inspired, responsive. */
|
||||
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--bg-panel: #16213e;
|
||||
--bg-hover: #1e2d4a;
|
||||
--border: #2a3a5c;
|
||||
--text: #c8d6e5;
|
||||
--text-dim: #6b7b8d;
|
||||
--green: #2ecc71;
|
||||
--green-dim: #1a7a42;
|
||||
--red: #e74c3c;
|
||||
--yellow: #f39c12;
|
||||
--cyan: #00cec9;
|
||||
--accent: #6c5ce7;
|
||||
--accent-light:#a29bfe;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Status bar ─────────────────────────────────────────────── */
|
||||
|
||||
#status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
background: #0f1527;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#status-devices {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.device-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.device-dot.connected { background: var(--green); }
|
||||
.device-dot.disconnected { background: var(--red); }
|
||||
|
||||
#status-message {
|
||||
color: var(--yellow);
|
||||
font-size: 12px;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
/* ── Main panels ────────────────────────────────────────────── */
|
||||
|
||||
#panels {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.panel-wide {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sub-header {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 10px 0 6px 0;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ── Signal rows ────────────────────────────────────────────── */
|
||||
|
||||
.signal-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.signal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.signal-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Indicator dot for digital signals */
|
||||
.indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.indicator.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.indicator.off { background: #3a4a5c; border: 1px solid #4a5a6c; }
|
||||
.indicator.stale { background: #5a5a5a; }
|
||||
|
||||
.signal-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.signal-value {
|
||||
font-weight: 600;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
.signal-value.on { color: var(--green); }
|
||||
.signal-value.off { color: var(--text-dim); }
|
||||
.signal-value.stale { color: var(--text-dim); }
|
||||
.signal-value.analog { color: var(--cyan); }
|
||||
|
||||
/* ── Output controls ────────────────────────────────────────── */
|
||||
|
||||
.output-row {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.output-row:active {
|
||||
background: #2a3d5c;
|
||||
}
|
||||
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bulk-actions button, button.seq-run-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bulk-actions button:hover, button.seq-run-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.bulk-actions button:active, button.seq-run-btn:active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Analog output controls */
|
||||
.analog-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.analog-controls button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.analog-controls button:hover {
|
||||
border-color: var(--cyan);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.analog-controls button:active {
|
||||
background: var(--cyan);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.analog-controls input {
|
||||
width: 70px;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--cyan);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.analog-controls input:focus {
|
||||
border-color: var(--cyan);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.analog-controls .write-btn {
|
||||
font-size: 12px;
|
||||
width: auto;
|
||||
padding: 0 8px;
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.analog-controls .write-btn:hover {
|
||||
background: var(--cyan);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.analog-controls .pending {
|
||||
border-color: var(--yellow);
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
/* ── Sequence panel ─────────────────────────────────────────── */
|
||||
|
||||
.seq-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.seq-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.seq-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.seq-meta {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.seq-desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
padding: 0 10px 6px 10px;
|
||||
}
|
||||
|
||||
button.seq-run-btn {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
button.seq-run-btn:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.seq-run-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Run progress */
|
||||
#run-header {
|
||||
font-weight: 600;
|
||||
color: var(--yellow);
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#run-progress-bar {
|
||||
height: 6px;
|
||||
background: #2a3a5c;
|
||||
border-radius: 3px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#run-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--yellow);
|
||||
border-radius: 3px;
|
||||
transition: width 0.2s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.step-row.current {
|
||||
background: rgba(243, 156, 18, 0.15);
|
||||
color: var(--yellow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-row.completed {
|
||||
color: var(--green-dim);
|
||||
}
|
||||
|
||||
.step-row.pending-step {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-time {
|
||||
width: 55px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-action {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Last run summary */
|
||||
#last-run-summary {
|
||||
margin-top: 16px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.run-success { color: var(--green); }
|
||||
.run-failed { color: var(--red); }
|
||||
.run-error { color: var(--red); }
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────── */
|
||||
|
||||
#footer {
|
||||
padding: 4px 16px;
|
||||
background: #0f1527;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────── */
|
||||
|
||||
/* Tablet: stack outputs below inputs, sequences full width below */
|
||||
@media (max-width: 1024px) {
|
||||
#panels {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel {
|
||||
flex: 1 1 45%;
|
||||
min-width: 280px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
max-height: 50vh;
|
||||
}
|
||||
.panel-wide {
|
||||
flex: 1 1 100%;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phone: single column */
|
||||
@media (max-width: 640px) {
|
||||
#panels {
|
||||
flex-direction: column;
|
||||
}
|
||||
.panel {
|
||||
flex: none;
|
||||
max-height: none;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.panel-wide {
|
||||
flex: none;
|
||||
}
|
||||
#status-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
.signal-row {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user