Files
tvctl/examples/http-dashboard/app.js
T
44r0n7 000d97fdeb feat: ship HTTP dashboard and harden daemon/API flows
Add the static HTTP dashboard example and wire in the recent daemon/API polish:
CORS-aware API routing, service-install behavior cleanup, safer systemd unit
ExecStart quoting, and friendly-name validation for path-safe targeting.

Also refresh README/API/roadmap docs, remove the temporary claude observations
file, and include the related tests for API/status and daemon validation.
2026-04-18 16:45:12 -04:00

361 lines
10 KiB
JavaScript

const baseUrlInput = document.getElementById("baseUrl");
const targetOverrideInput = document.getElementById("targetOverride");
const deviceSelect = document.getElementById("deviceSelect");
const requestLog = document.getElementById("requestLog");
const responseView = document.getElementById("responseView");
const appLaunchValue = document.getElementById("appLaunchValue");
const refreshClear = document.getElementById("refreshClear");
const singleKey = document.getElementById("singleKey");
const sequenceKeys = document.getElementById("sequenceKeys");
const sequenceDelay = document.getElementById("sequenceDelay");
const devZip = document.getElementById("devZip");
const configKey = document.getElementById("configKey");
const configValue = document.getElementById("configValue");
function getBaseUrl() {
return baseUrlInput.value.replace(/\/+$/, "");
}
function getDeviceTarget() {
const override = targetOverrideInput.value.trim();
if (override.length > 0) return override;
const selected = deviceSelect.value.trim();
if (selected.length > 0) return selected;
throw new Error("Select a device or enter a manual override target first.");
}
function setResponse(value) {
responseView.value = value;
}
function appendLog(line) {
const timestamp = new Date().toISOString();
const current = requestLog.value || "";
requestLog.value = `[${timestamp}] ${line}\n${current}`.trim();
}
function safeParseJsonInput(input) {
try {
return JSON.parse(input);
} catch {
return input;
}
}
async function callJson(path, method = "GET", body = null, extraHeaders = {}) {
const url = `${getBaseUrl()}${path}`;
const headers = { ...extraHeaders };
const init = { method, headers };
if (body !== null) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
appendLog(`${method} ${url}`);
let response;
try {
response = await fetch(url, init);
} catch (error) {
const origin = window.location.origin;
setResponse(
JSON.stringify(
{
request: { method, url, body, origin },
error: {
kind: "network_or_cors",
message: String(error),
hint:
"If tvctl API is running, this is likely CORS. Enable daemon.cors_enabled and add this origin to daemon.cors_allowed_origins.",
},
},
null,
2
)
);
throw error;
}
const raw = await response.text();
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch {
parsed = { non_json_body: raw };
}
setResponse(
JSON.stringify(
{
request: { method, url, body },
response: {
status: response.status,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries()),
body: parsed,
},
},
null,
2
)
);
if (!response.ok) {
throw new Error(`${method} ${path} failed with status ${response.status}`);
}
return parsed;
}
async function callMultipart(path, file) {
const target = getDeviceTarget();
const url = `${getBaseUrl()}${path.replace("{id}", encodeURIComponent(target))}`;
const form = new FormData();
form.append("archive", file);
appendLog(`POST ${url} (multipart archive=${file.name})`);
let response;
try {
response = await fetch(url, { method: "POST", body: form });
} catch (error) {
const origin = window.location.origin;
setResponse(
JSON.stringify(
{
request: {
method: "POST",
url,
body: `multipart archive=${file.name}`,
origin,
},
error: {
kind: "network_or_cors",
message: String(error),
hint:
"If tvctl API is running, this is likely CORS. Enable daemon.cors_enabled and add this origin to daemon.cors_allowed_origins.",
},
},
null,
2
)
);
throw error;
}
const raw = await response.text();
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch {
parsed = { non_json_body: raw };
}
setResponse(
JSON.stringify(
{
request: {
method: "POST",
url,
body: `multipart archive=${file.name}`,
},
response: {
status: response.status,
ok: response.ok,
headers: Object.fromEntries(response.headers.entries()),
body: parsed,
},
},
null,
2
)
);
if (!response.ok) {
throw new Error(`POST ${path} failed with status ${response.status}`);
}
}
async function runAction(action) {
try {
await action();
} catch (error) {
appendLog(`ERROR: ${error}`);
}
}
function requireConfirm(message) {
return window.confirm(message);
}
async function refreshDeviceList() {
const json = await callJson("/devices");
const devices = json?.data ?? [];
deviceSelect.innerHTML = "";
if (devices.length === 0) {
deviceSelect.innerHTML = "<option value=''>no devices</option>";
return;
}
for (const device of devices) {
const value = device.id || device.name;
const option = document.createElement("option");
option.value = value;
option.textContent = `${device.name} (${device.id})`;
if (device.is_default) {
option.selected = true;
}
deviceSelect.appendChild(option);
}
}
document.getElementById("pingBtn").addEventListener("click", () =>
runAction(async () => {
await callJson("/daemon/status");
})
);
document.getElementById("listDevicesBtn").addEventListener("click", () =>
runAction(async () => {
await refreshDeviceList();
})
);
document.getElementById("discoverBtn").addEventListener("click", () =>
runAction(async () => {
await callJson("/devices/discover", "POST");
await refreshDeviceList();
})
);
document.getElementById("getDeviceBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
await callJson(`/devices/${encodeURIComponent(target)}`);
})
);
document.getElementById("deleteDeviceBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
if (!requireConfirm(`Delete device '${target}'?`)) return;
await callJson(`/devices/${encodeURIComponent(target)}`, "DELETE");
await refreshDeviceList();
})
);
document.getElementById("getStateBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
await callJson(`/devices/${encodeURIComponent(target)}/state`);
})
);
document.getElementById("listAppsBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
await callJson(`/devices/${encodeURIComponent(target)}/apps`);
})
);
document.getElementById("launchAppBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
const app = appLaunchValue.value.trim();
if (!app) throw new Error("Launch app value is required.");
if (!requireConfirm(`Launch '${app}' on '${target}'?`)) return;
await callJson(`/devices/${encodeURIComponent(target)}/apps/launch`, "POST", { app });
})
);
document.getElementById("stopAppBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
if (!requireConfirm(`Stop active app on '${target}'?`)) return;
await callJson(`/devices/${encodeURIComponent(target)}/apps/stop`, "POST");
})
);
document.getElementById("refreshAppsBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
await callJson(`/devices/${encodeURIComponent(target)}/apps/refresh`, "POST", {
clear: refreshClear.checked,
});
})
);
document.getElementById("sendKeyBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
const key = singleKey.value.trim();
if (!key) throw new Error("Single key is required.");
await callJson(`/devices/${encodeURIComponent(target)}/remote/key`, "POST", { key });
})
);
document.getElementById("sendSequenceBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
const keys = sequenceKeys.value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (keys.length === 0) throw new Error("At least one sequence key is required.");
await callJson(`/devices/${encodeURIComponent(target)}/remote/sequence`, "POST", {
keys,
delay_ms: Number(sequenceDelay.value || "0"),
});
})
);
document.getElementById("devInstallBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
if (!devZip.files || devZip.files.length === 0) {
throw new Error("Select a zip file before installing.");
}
if (!requireConfirm(`Install selected dev zip to '${target}'?`)) return;
await callMultipart(`/devices/{id}/dev/install`, devZip.files[0]);
})
);
document.getElementById("devReloadBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
await callJson(`/devices/${encodeURIComponent(target)}/dev/reload`, "POST");
})
);
document.getElementById("devLogsBtn").addEventListener("click", () =>
runAction(async () => {
const target = getDeviceTarget();
await callJson(`/devices/${encodeURIComponent(target)}/dev/logs`);
})
);
document.getElementById("getConfigBtn").addEventListener("click", () =>
runAction(async () => {
await callJson("/config");
})
);
document.getElementById("patchConfigBtn").addEventListener("click", () =>
runAction(async () => {
const key = configKey.value.trim();
if (!key) throw new Error("Config key is required.");
if (!requireConfirm(`Patch config key '${key}'?`)) return;
const value = safeParseJsonInput(configValue.value.trim());
await callJson("/config", "PATCH", { [key]: value });
})
);
document.getElementById("reloadConfigBtn").addEventListener("click", () =>
runAction(async () => {
if (!requireConfirm("Reload daemon config now?")) return;
await callJson("/config/reload", "POST");
})
);
window.addEventListener("error", (event) => {
appendLog(`ERROR: ${event.message}`);
});
window.addEventListener("unhandledrejection", (event) => {
appendLog(`ERROR: ${event.reason}`);
});
runAction(refreshDeviceList);