Initial commit
This commit is contained in:
+107
@@ -0,0 +1,107 @@
|
||||
const form = document.querySelector("[data-config-form]");
|
||||
const statusGrid = document.querySelector("[data-status-grid]");
|
||||
const message = document.querySelector("[data-message]");
|
||||
const refreshButton = document.querySelector("[data-refresh]");
|
||||
|
||||
function getPath(object, path) {
|
||||
return path.split(".").reduce((current, key) => current?.[key], object);
|
||||
}
|
||||
|
||||
function setPath(object, path, value) {
|
||||
const parts = path.split(".");
|
||||
let cursor = object;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
cursor[part] ??= {};
|
||||
cursor = cursor[part];
|
||||
}
|
||||
cursor[parts.at(-1)] = value;
|
||||
}
|
||||
|
||||
function coerceInputValue(input) {
|
||||
if (input.type === "checkbox") return input.checked;
|
||||
if (input.type === "number") return Number(input.value);
|
||||
return input.value.trim();
|
||||
}
|
||||
|
||||
function renderStatus(config, bridge, kasm, camera) {
|
||||
const items = [
|
||||
["Agent", config?.server?.publicBaseUrl ?? "online"],
|
||||
["Workspace", config?.workspace?.root ?? "-"],
|
||||
["ROS", `${config?.ros?.stack ?? "-"} · ${bridge?.stack?.stack ?? bridge?.health?.stack ?? "offline"}`],
|
||||
["KasmVNC", kasm?.running ? `running · ${kasm.url}` : kasm?.installed ? "stopped" : "not installed"],
|
||||
["Camera", camera?.enabled ? `${camera.videoDevice} · enabled` : "disabled"],
|
||||
];
|
||||
|
||||
statusGrid.innerHTML = items.map(([label, value]) => `
|
||||
<article>
|
||||
<span>${label}</span>
|
||||
<strong>${value}</strong>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function fillForm(config) {
|
||||
for (const input of form.elements) {
|
||||
if (!input.name) continue;
|
||||
const value = getPath(config, input.name);
|
||||
if (input.type === "checkbox") {
|
||||
input.checked = Boolean(value);
|
||||
} else if (value !== undefined) {
|
||||
input.value = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readFormPatch() {
|
||||
const patch = {};
|
||||
for (const input of form.elements) {
|
||||
if (!input.name) continue;
|
||||
setPath(patch, input.name, coerceInputValue(input));
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
async function json(path, init) {
|
||||
const response = await fetch(path, init);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.message || data.detail || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
message.textContent = "Loading...";
|
||||
const [config, bridge, kasm, camera] = await Promise.all([
|
||||
json("/api/config"),
|
||||
json("/api/bridge/status").catch((error) => ({ ok: false, health: { error: error.message } })),
|
||||
json("/api/kasmvnc/status").catch((error) => ({ ok: false, message: error.message })),
|
||||
json("/api/camera/status").catch((error) => ({ enabled: false, error: error.message })),
|
||||
]);
|
||||
fillForm(config);
|
||||
renderStatus(config, bridge, kasm, camera);
|
||||
message.textContent = "";
|
||||
}
|
||||
|
||||
form?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
message.textContent = "Saving...";
|
||||
try {
|
||||
const config = await json("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(readFormPatch()),
|
||||
});
|
||||
fillForm(config);
|
||||
await refresh();
|
||||
message.textContent = "Saved.";
|
||||
} catch (error) {
|
||||
message.textContent = error instanceof Error ? error.message : "Save failed.";
|
||||
}
|
||||
});
|
||||
|
||||
refreshButton?.addEventListener("click", () => refresh().catch((error) => {
|
||||
message.textContent = error instanceof Error ? error.message : "Refresh failed.";
|
||||
}));
|
||||
|
||||
refresh().catch((error) => {
|
||||
message.textContent = error instanceof Error ? error.message : "Load failed.";
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
const state = document.querySelector("[data-state]");
|
||||
const video = document.querySelector("[data-video]");
|
||||
const image = document.querySelector("[data-mjpeg]");
|
||||
const logs = document.querySelector("[data-logs]");
|
||||
const modeSelect = document.querySelector("[data-mode]");
|
||||
const startButton = document.querySelector("[data-start]");
|
||||
const stopButton = document.querySelector("[data-stop]");
|
||||
|
||||
let ws = null;
|
||||
let pc = null;
|
||||
let statsTimer = null;
|
||||
|
||||
function setState(message) {
|
||||
if (state) state.textContent = message;
|
||||
}
|
||||
|
||||
function renderLogs(items = []) {
|
||||
if (!logs) return;
|
||||
logs.textContent = items.slice(-30).join("\n");
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
}
|
||||
|
||||
async function getStatus() {
|
||||
const response = await fetch("/api/camera/status");
|
||||
const status = await response.json();
|
||||
renderLogs(status.logs);
|
||||
return status;
|
||||
}
|
||||
|
||||
function normalizeOfferDescription(description) {
|
||||
if (description.type !== "offer" || !description.sdp || description.sdp.includes("a=msid:")) return description;
|
||||
|
||||
const lines = description.sdp.replace(/\r\n/g, "\n").split("\n");
|
||||
const normalized = [];
|
||||
let hasMsidSemantic = false;
|
||||
let insertedVideoMsid = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("a=msid-semantic:")) hasMsidSemantic = true;
|
||||
if (line.startsWith("m=video") && !hasMsidSemantic) {
|
||||
normalized.push("a=msid-semantic: WMS jetson-camera");
|
||||
hasMsidSemantic = true;
|
||||
}
|
||||
normalized.push(line);
|
||||
if (!insertedVideoMsid && line.startsWith("a=mid:")) {
|
||||
normalized.push("a=msid:jetson-camera video0");
|
||||
insertedVideoMsid = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...description, sdp: normalized.join("\r\n") };
|
||||
}
|
||||
|
||||
function stopViewerOnly() {
|
||||
if (statsTimer) window.clearInterval(statsTimer);
|
||||
statsTimer = null;
|
||||
if (image) image.removeAttribute("src");
|
||||
if (video) video.srcObject = null;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
|
||||
if (ws && ws.readyState === WebSocket.CONNECTING) ws.onopen = () => ws.close();
|
||||
if (pc) pc.close();
|
||||
ws = null;
|
||||
pc = null;
|
||||
}
|
||||
|
||||
function showMjpeg(status) {
|
||||
if (!image || !video) return;
|
||||
video.style.display = "none";
|
||||
image.style.display = "block";
|
||||
const params = new URLSearchParams({
|
||||
device: status.videoDevice || "/dev/video0",
|
||||
width: String(status.width || 1920),
|
||||
height: String(status.height || 1080),
|
||||
framerate: String(status.framerate || 30),
|
||||
t: String(Date.now()),
|
||||
});
|
||||
image.src = `/api/camera/mjpeg?${params.toString()}`;
|
||||
image.onload = () => setState("MJPEG streaming");
|
||||
image.onerror = () => setState("MJPEG stream failed");
|
||||
}
|
||||
|
||||
async function startWebRtc(status) {
|
||||
if (!video || !image) return;
|
||||
image.style.display = "none";
|
||||
video.style.display = "block";
|
||||
|
||||
pc = new RTCPeerConnection();
|
||||
ws = new WebSocket(status.signalingUrl);
|
||||
|
||||
pc.ontrack = (event) => {
|
||||
const stream = event.streams[0];
|
||||
console.debug("[camera-web] track received", event.track.kind, event.track.readyState, event.track.muted);
|
||||
if (!stream) return;
|
||||
video.srcObject = stream;
|
||||
video.play().catch((error) => console.error("[camera-web] video play failed", error));
|
||||
setState("WebRTC streaming");
|
||||
};
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (!event.candidate || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({
|
||||
type: "candidate",
|
||||
roomId: status.roomId || "jetson-1",
|
||||
role: "viewer",
|
||||
candidate: event.candidate,
|
||||
}));
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.debug("[camera-web] connection state", pc.connectionState);
|
||||
if (pc.connectionState === "connected") setState("WebRTC connected");
|
||||
if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setState("WebRTC failed");
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "offer") {
|
||||
console.debug("[camera-web] offer received");
|
||||
await pc.setRemoteDescription(normalizeOfferDescription(data.offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: "answer",
|
||||
roomId: status.roomId || "jetson-1",
|
||||
role: "viewer",
|
||||
answer,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if (data.type === "candidate" && data.candidate) {
|
||||
await pc.addIceCandidate(data.candidate);
|
||||
}
|
||||
};
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "join", roomId: status.roomId || "jetson-1", role: "viewer" }));
|
||||
resolve();
|
||||
};
|
||||
ws.onerror = () => reject(new Error("signaling connection failed"));
|
||||
});
|
||||
|
||||
setState("starting WebRTC sender");
|
||||
await fetch("/api/camera/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roomId: status.roomId || "jetson-1" }),
|
||||
});
|
||||
setState("waiting for WebRTC offer");
|
||||
}
|
||||
|
||||
async function startCamera() {
|
||||
stopViewerOnly();
|
||||
setState("starting");
|
||||
await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined);
|
||||
const status = await getStatus();
|
||||
if (!status.enabled) {
|
||||
setState("camera disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = modeSelect?.value || "mjpeg";
|
||||
if (mode === "webrtc") {
|
||||
await startWebRtc(status);
|
||||
} else {
|
||||
setState("starting MJPEG stream");
|
||||
showMjpeg(status);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCamera() {
|
||||
stopViewerOnly();
|
||||
await fetch("/api/camera/stop", { method: "POST" }).catch(() => undefined);
|
||||
setState("stopped");
|
||||
await getStatus();
|
||||
}
|
||||
|
||||
startButton?.addEventListener("click", () => startCamera().catch((error) => setState(error.message)));
|
||||
stopButton?.addEventListener("click", () => stopCamera().catch((error) => setState(error.message)));
|
||||
modeSelect?.addEventListener("change", () => startCamera().catch((error) => setState(error.message)));
|
||||
|
||||
getStatus().then((status) => {
|
||||
if (!status.enabled) setState("camera disabled");
|
||||
else startCamera().catch((error) => setState(error.message));
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
getStatus().catch(() => undefined);
|
||||
}, 3000);
|
||||
@@ -0,0 +1,308 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #05070a;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #05070a;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
background: #05070a;
|
||||
}
|
||||
|
||||
body:has(.admin-shell) {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.shell {
|
||||
height: 100vh;
|
||||
min-width: 320px;
|
||||
background: #05070a;
|
||||
}
|
||||
|
||||
iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #05070a;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.setup {
|
||||
display: grid;
|
||||
min-height: 100%;
|
||||
place-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.setup h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.setup p {
|
||||
margin: 0;
|
||||
color: #cbd5e1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.setup a {
|
||||
justify-self: center;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.32);
|
||||
border-radius: 6px;
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.camera-shell {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
height: 100vh;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
.camera-shell video,
|
||||
.camera-shell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.camera-shell video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.camera-panel strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.camera-panel button {
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 6px;
|
||||
background: #1f2937;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.camera-panel pre {
|
||||
grid-column: 1 / -1;
|
||||
max-height: 96px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #0b0d10;
|
||||
color: #eef2f7;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.admin-header p {
|
||||
max-width: 760px;
|
||||
margin: 6px 0 0;
|
||||
color: rgba(238, 242, 247, 0.58);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-header nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-header nav a,
|
||||
.config-form button {
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 6px;
|
||||
background: #1a2029;
|
||||
color: #eef2f7;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.config-form {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.status-grid article,
|
||||
.config-form section {
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 6px;
|
||||
background: #13171e;
|
||||
}
|
||||
|
||||
.status-grid article {
|
||||
min-height: 70px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.status-grid span {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: rgba(238, 242, 247, 0.42);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-grid strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: rgba(238, 242, 247, 0.86);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
.config-form section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.config-form h2 {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0 0 2px;
|
||||
color: rgba(238, 242, 247, 0.84);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-form label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
color: rgba(238, 242, 247, 0.55);
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.config-form label.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-form input,
|
||||
.config-form select {
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 5px;
|
||||
background: #0d1117;
|
||||
color: rgba(238, 242, 247, 0.88);
|
||||
padding: 0 9px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.config-form input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.config-form footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 0 0;
|
||||
background: #0b0d10;
|
||||
}
|
||||
|
||||
.config-form footer span {
|
||||
min-width: 180px;
|
||||
color: rgba(238, 242, 247, 0.58);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.admin-shell {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-grid,
|
||||
.config-form,
|
||||
.config-form section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user