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,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.
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user