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:
44r0n7
2026-04-18 16:45:12 -04:00
parent 795aa2f713
commit 000d97fdeb
16 changed files with 998 additions and 726 deletions
+46
View File
@@ -0,0 +1,46 @@
# tvctl HTTP API Dashboard Example
This is a static browser dashboard for manual testing of every `tvctl` HTTP API endpoint.
## 1. Enable API + CORS
The API is already enabled by default. CORS is not.
Enable CORS for local browser usage:
```bash
tvctl config set daemon.cors_enabled true
tvctl config set daemon.cors_allowed_origins "http://127.0.0.1:8080,http://localhost:8080"
tvctl config reload
```
## 2. Serve the dashboard files
From the repository root:
```bash
python3 -m http.server 8080 -d examples/http-dashboard
```
Then open:
`http://127.0.0.1:8080`
Do not use the `http://0.0.0.0:8080` URL shown by Python's server output as the browser URL.
Use `http://127.0.0.1:8080` or `http://localhost:8080` so the request origin matches your
configured CORS allowlist.
## 3. Test flow
1. `GET /daemon/status`
2. `GET /devices`
3. `POST /devices/discover`
4. Select a target device and test device/apps/remote/dev sections
5. Use config section for `GET /config`, `PATCH /config`, `POST /config/reload`
## Notes
- Destructive/mutating actions prompt for confirmation.
- `dev/install` uses multipart upload (`archive` field) as required by the API.
- Device targets are URL-encoded automatically before calling `/v1/devices/{id}/...` routes.
- Requests and responses are shown in the response panel for debugging.
+360
View File
@@ -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);
+135
View File
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tvctl HTTP API Dashboard</title>
<link rel="stylesheet" href="./styles.css">
</head>
<body>
<header>
<h1>tvctl HTTP API Dashboard</h1>
<p>Local manual test dashboard for every <code>/v1</code> endpoint.</p>
</header>
<main>
<section class="panel">
<h2>Connection</h2>
<label>
API Base URL
<input id="baseUrl" value="http://127.0.0.1:7272/v1" />
</label>
<div class="row">
<button id="pingBtn" type="button">GET /daemon/status</button>
<button id="listDevicesBtn" type="button">GET /devices</button>
<button id="discoverBtn" type="button">POST /devices/discover</button>
</div>
<p class="hint">Tip: if browser requests fail, enable CORS in tvctl config first.</p>
</section>
<section class="panel">
<h2>Device Target</h2>
<label>
Known Devices
<select id="deviceSelect">
<option value="">(none loaded)</option>
</select>
</label>
<label>
Manual device UUID or name override
<input id="targetOverride" placeholder="optional" />
</label>
<p class="hint">Device endpoint actions use override when set, otherwise selected device.</p>
</section>
<section class="panel">
<h2>Device Endpoints</h2>
<div class="row">
<button id="getDeviceBtn" type="button">GET /devices/{id}</button>
<button id="deleteDeviceBtn" class="danger" type="button">DELETE /devices/{id}</button>
<button id="getStateBtn" type="button">GET /devices/{id}/state</button>
</div>
</section>
<section class="panel">
<h2>Apps Endpoints</h2>
<label>
Launch app name or id
<input id="appLaunchValue" placeholder="jellyfin" />
</label>
<label>
Refresh clear cache first
<input id="refreshClear" type="checkbox" />
</label>
<div class="row">
<button id="listAppsBtn" type="button">GET /devices/{id}/apps</button>
<button id="launchAppBtn" class="warn" type="button">POST /devices/{id}/apps/launch</button>
<button id="stopAppBtn" class="warn" type="button">POST /devices/{id}/apps/stop</button>
<button id="refreshAppsBtn" type="button">POST /devices/{id}/apps/refresh</button>
</div>
</section>
<section class="panel">
<h2>Remote Endpoints</h2>
<label>
Single key
<input id="singleKey" placeholder="home" />
</label>
<label>
Sequence keys (comma separated)
<input id="sequenceKeys" placeholder="home,down,select" />
</label>
<label>
Sequence delay_ms
<input id="sequenceDelay" type="number" value="200" min="0" />
</label>
<div class="row">
<button id="sendKeyBtn" type="button">POST /devices/{id}/remote/key</button>
<button id="sendSequenceBtn" type="button">POST /devices/{id}/remote/sequence</button>
</div>
</section>
<section class="panel">
<h2>Dev Endpoints</h2>
<label>
Zip file for sideload
<input id="devZip" type="file" accept=".zip,application/zip" />
</label>
<div class="row">
<button id="devInstallBtn" class="warn" type="button">POST /devices/{id}/dev/install</button>
<button id="devReloadBtn" type="button">POST /devices/{id}/dev/reload</button>
<button id="devLogsBtn" type="button">GET /devices/{id}/dev/logs</button>
</div>
</section>
<section class="panel">
<h2>Config Endpoints</h2>
<label>
Config patch key
<input id="configKey" placeholder="daemon.http_port" />
</label>
<label>
Config patch value (JSON literal, e.g. 7272, true, "text")
<input id="configValue" placeholder="7272" />
</label>
<div class="row">
<button id="getConfigBtn" type="button">GET /config</button>
<button id="patchConfigBtn" class="danger" type="button">PATCH /config</button>
<button id="reloadConfigBtn" class="warn" type="button">POST /config/reload</button>
</div>
</section>
<section class="panel panel-wide">
<h2>Request Log</h2>
<textarea id="requestLog" readonly></textarea>
</section>
<section class="panel panel-wide">
<h2>Response</h2>
<textarea id="responseView" readonly></textarea>
</section>
</main>
<script src="./app.js"></script>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: linear-gradient(180deg, #eef4ff 0%, #f8fbff 100%);
color: #1b2a41;
}
header {
padding: 1.2rem 1.4rem;
background: #0b3954;
color: #f3f7fb;
}
h1 {
margin: 0 0 0.4rem 0;
font-size: 1.4rem;
}
main {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.8rem;
padding: 0.8rem;
}
.panel {
background: #ffffff;
border: 1px solid #c6d6eb;
border-radius: 10px;
padding: 0.8rem;
box-shadow: 0 2px 8px rgba(15, 35, 55, 0.06);
}
.panel-wide {
grid-column: 1 / -1;
}
.panel-wide:nth-last-of-type(2) {
order: 100;
}
.panel-wide:last-of-type {
order: 101;
}
.panel h2 {
margin-top: 0;
font-size: 1rem;
}
label {
display: block;
margin: 0.4rem 0;
font-size: 0.9rem;
}
input,
select,
button {
width: 100%;
margin-top: 0.25rem;
border: 1px solid #9db3cf;
border-radius: 6px;
font-size: 0.9rem;
}
input,
select {
padding: 0.45rem;
background: #fbfdff;
}
button {
padding: 0.55rem;
cursor: pointer;
background: #1d6fa5;
color: #ffffff;
font-weight: 600;
}
button.warn {
background: #af6d1d;
}
button.danger {
background: #a12626;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.45rem;
margin-top: 0.5rem;
}
.hint {
font-size: 0.8rem;
color: #425b7a;
}
textarea[readonly] {
width: 100%;
min-height: 220px;
max-height: 70vh;
resize: both;
overflow: auto;
margin: 0;
padding: 0.6rem;
border: 1px solid #2f4b66;
border-radius: 6px;
background: #0f1720;
color: #d9ecff;
font-size: 0.8rem;
font-family: "IBM Plex Mono", "Cascadia Code", monospace;
white-space: pre;
}