59fb56558f
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.
361 lines
10 KiB
JavaScript
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);
|