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.
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user