Initial commit

This commit is contained in:
Kendel Christoph
2025-12-27 20:13:26 +01:00
commit 90d55cad7e
24 changed files with 1355 additions and 0 deletions

65
app/data/configRobot.yml Normal file
View File

@@ -0,0 +1,65 @@
Mechanics:
Robot:
Name: "Arm7m"
id: "a0f57d44-1bc8-4d0e-a682-316cf538370a" # UUID of the Robot
Access:
type: "Base, Ellbow, LowerArm" # three controller Boards
adressBase: ""
Position:
Base: # Position of different Arucos on the Base
freedom: "x" # which degree of freedom does apply
Aruco210: "{Nr: '210', x: '+23', y: '34'}"
Aruco222: "{Nr: '222', x: '+23', y: '34'}"
UpperArm:
freedom: "x, alpha" # which degree of freedom does apply
Aruco233: "{Nr: '233', x: '+23', y: '34'}"
LowerArm:
freedom: "x, alpha, beta"
Rail:
Name: "Basis 1"
id: "1d447f8a-275a-4a24-8ddd-ec7f598511af"
Markers:
VideoSetup:
Cam1: "localhost:8080/StreamX"
Cam2: "localhost:8080/StreamY"
CSV: "localhost:8080/Video.csv"
Apps:
AppVideoServer:
Name: "Video Server und Steuerung"
Docker: "AppVideoServer"
localAdress: "localhost:1010"
robots: ["Robot"]
visiblePort: 8090
RoboticsDriver:
Name: "Robotics Driver"
Docker: "RoboticsDriver"
localAdress: "localhost: 2920"
AppFindZero:
Name: "Set to Zero"
Network:
Myself:
MyID: "RobotUser Max" # My ID so the Server knows, who I am
MyPublicKey: " ... ... "
MyPrivateKeyPath: "..." # To identify at Server, who saves the public Key
MyLocalIP: "192.168.2.12" # in case the WebBrowser is in the same Network
Server:
Adress: "robotics.schooltech.ch"
ServerPublicKey: "...."
LastServerInfo: "20250101"
ThisFile:
CheckSum: "...."
LastWorkedOnBy: "...."

View File

@@ -0,0 +1,8 @@
#
# All other Apps on the Robot (should be) installed as Docker containers. So a simple
# List of all Dockers should reveal which Apps are installed (with each Entry-Port)
#
print('{"Apps": [{"Robot": {"Container":"Robot", "port":"8808"}}]}')

119
app/public/index.html Executable file
View File

@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>System & Docker Status</title>
<style>
:root {
--bg: #0f172a; /* slate-900 */
--card: #111827; /* gray-900 */
--muted: #94a3b8; /* slate-400 */
--text: #e5e7eb; /* gray-200 */
--accent: #22d3ee; /* cyan-400 */
--accent2: #34d399; /* green-400 */
--danger: #f87171; /* red-400 */
}
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji','Segoe UI Emoji'; background: var(--bg); color: var(--text); }
header { padding: 24px; border-bottom: 1px solid #1f2937; position: sticky; top: 0; background: linear-gradient(180deg, rgba(15,23,42,0.9), rgba(15,23,42,0.9)); backdrop-filter: blur(6px); z-index: 10; }
h1 { margin: 0 0 8px; font-size: 1.4rem; }
.sub { color: var(--muted); font-size: .9rem; }
.wrap { max-width: 1200px; margin: 0 auto; padding: 24px; }
/* Top grid for system */
.grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; }
.card { background: var(--card); border: 1px solid #1f2937; border-radius: 12px; padding: 16px; }
.card h2 { margin: 0 0 8px; font-size: 1.1rem; }
.kpi { display:flex; align-items:center; gap: 16px; }
.ring { --size: 84px; width: var(--size); height: var(--size); border-radius: 50%; position: relative; display:grid; place-items:center; background: conic-gradient(var(--accent) var(--deg), #334155 var(--deg)); }
.ring::before { content:""; width: calc(var(--size) - 14px); height: calc(var(--size) - 14px); border-radius: 50%; background: var(--card); position: absolute; }
.ring span { position: relative; font-weight: 700; }
.legend { color: var(--muted); font-size: .9rem; }
.bar { height: 10px; background: #334155; border-radius: 999px; overflow: hidden; }
.bar > i { display:block; height: 100%; background: var(--accent2); width: 0; }
/* Container list */
.list { display:flex; flex-direction: column; gap: 12px; }
.row { display:grid; grid-template-columns: 2fr 1fr 2fr 1fr 120px; gap: 12px; align-items:center; padding: 12px; border-radius: 10px; background: #0b1220; border: 1px solid #1f2937; }
.row .name { font-weight: 600; }
.row .muted { color: var(--muted); font-size: .9rem; }
.status { padding: 2px 8px; border-radius: 999px; font-size: .8rem; display:inline-block; background: #064e3b; color: #a7f3d0; }
.status.stopped { background:#3f0a0a; color:#fecaca; }
.ports { display:flex; flex-wrap: wrap; gap: 6px; }
.port { padding: 4px 8px; border-radius: 999px; font-size: .8rem; background:#1f2937; color:#cbd5e1; border: 1px solid #334155; }
.badge { padding: 2px 6px; border-radius: 6px; font-size: .8rem; background:#1f2937; border: 1px solid #334155; }
.toolbar { display:flex; gap: 12px; align-items:center; margin-bottom: 12px; }
.toolbar input { background:#0b1220; border:1px solid #1f2937; color:var(--text); border-radius:8px; padding:8px 10px; }
.toolbar button { background:#111827; border:1px solid #1f2937; color:var(--text); border-radius:8px; padding:8px 12px; cursor:pointer; }
.toolbar button:hover { border-color:#334155; }
footer { color: var(--muted); padding: 24px; text-align:center; }
a { color: var(--accent); text-decoration: none; }
.small { font-size: .85rem; }
</style>
</head>
<body>
<header>
<h1>System & Docker Status</h1>
<div class="sub">Last update: <span id="last-update"></span> · Auto-refresh <span id="refresh-state">on</span></div>
</header>
<main class="wrap">
<section class="grid" id="system-grid">
<div class="card" style="grid-column: span 4;">
<h2>CPU</h2>
<div class="kpi">
<div class="ring" id="cpu-ring"><span id="cpu-percent">0%</span></div>
<div>
<div class="legend small">Load averages</div>
<div class="small">1m: <span id="load-1m"></span></div>
<div class="small">5m: <span id="load-5m"></span></div>
<div class="small">15m: <span id="load-15m"></span></div>
</div>
</div>
</div>
<div class="card" style="grid-column: span 8;">
<h2>Memory</h2>
<div class="kpi" style="gap: 24px;">
<div style="min-width:220px">
<div class="legend">Used</div>
<div class="bar"><i id="mem-bar"></i></div>
<div class="small"><span id="mem-used"></span> / <span id="mem-total"></span> (<span id="mem-percent"></span>)</div>
</div>
<div>
<div class="legend">Tip</div>
<div class="small">Keep memory usage under 80% to avoid OOM kills.</div>
</div>
</div>
</div>
</section>
<section style="margin-top: 24px;">
<div class="card">
<div class="toolbar">
<input id="search" type="search" placeholder="Filter containers by name or image…" />
<button id="toggle-refresh">Pause</button>
<span class="small" id="container-count"></span>
</div>
<div class="list" id="container-list"></div>
</div>
</section>
</main>
<footer>
<div class="small">This page reads <code>data/status.json</code>. Place your status file under <code>./data</code> (mounted to <code>/app/public/data</code>).</div>
</footer>
<script src="js/status.js"></script>
</body>
</html>
``

115
app/public/js/status.js Executable file
View File

@@ -0,0 +1,115 @@
// Simple status dashboard
const DATA_URL = 'data/status.json';
let autoRefresh = true;
let refreshTimer = null;
function formatMB(mb) {
return `${mb.toFixed(2)} MB`;
}
function formatPercent(p) {
return `${p.toFixed(2)}%`;
}
function setRing(el, percent) {
const deg = Math.min(100, Math.max(0, percent)) * 3.6; // 100% -> 360deg
el.style.setProperty('--deg', `${deg}deg`);
}
function renderSystem(system) {
const cpuPercent = system?.cpu?.percent ?? 0;
const ring = document.getElementById('cpu-ring');
setRing(ring, cpuPercent);
document.getElementById('cpu-percent').textContent = formatPercent(cpuPercent);
const la = system?.cpu?.load_avg ?? {};
document.getElementById('load-1m').textContent = (la['1m'] ?? '');
document.getElementById('load-5m').textContent = (la['5m'] ?? '');
document.getElementById('load-15m').textContent = (la['15m'] ?? '');
const mem = system?.memory ?? {};
const pct = mem.percent ?? 0;
document.getElementById('mem-bar').style.width = `${pct}%`;
document.getElementById('mem-percent').textContent = formatPercent(pct);
document.getElementById('mem-used').textContent = formatMB(mem.used_mb ?? 0);
document.getElementById('mem-total').textContent = formatMB(mem.total_mb ?? 0);
}
function portBadge(p) {
const host = p.host_ip ? `${p.host_ip}` : '';
const hostPort = p.host_port ? `:${p.host_port}` : '';
const container = p.container_port || '';
const text = `${host}${hostPort}${container}`.trim();
return `<span class="port">${text || '—'}</span>`;
}
function renderContainers(list) {
const q = document.getElementById('search').value.toLowerCase();
const root = document.getElementById('container-list');
root.innerHTML = '';
const filtered = list.filter(c => {
const s = `${c.name} ${c.image}`.toLowerCase();
return s.includes(q);
});
document.getElementById('container-count').textContent = `${filtered.length} / ${list.length} shown`;
filtered.forEach(c => {
const statusClass = (c.status === 'running') ? 'status' : 'status stopped';
const ports = (c.openPorts && c.openPorts.length) ? c.openPorts.map(portBadge).join('') : '<span class="muted">No published ports</span>';
const cpu = c.resources?.cpu_percent ?? 0;
const memMb = c.resources?.memory_mb ?? 0;
const memPct = c.resources?.memory_percent ?? 0;
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<div class="name">${c.name}<div class="muted">${c.image}</div></div>
<div><span class="${statusClass}">${c.status}</span></div>
<div class="ports">${ports}</div>
<div>
<span class="badge">CPU: ${formatPercent(cpu)}</span>
<span class="badge">Mem: ${formatMB(memMb)} (${formatPercent(memPct)})</span>
</div>
<div class="muted small">${c.id.slice(0,12)}</div>
`;
root.appendChild(row);
});
}
async function loadStatus() {
try {
const res = await fetch(`${DATA_URL}?t=${Date.now()}`, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
document.getElementById('last-update').textContent = new Date(data.timestamp).toLocaleString();
renderSystem(data.system);
renderContainers(data.containers || []);
} catch (err) {
console.error('Failed to load status:', err);
document.getElementById('last-update').textContent = 'failed';
}
}
function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
if (autoRefresh) loadStatus();
}, 5000);
}
// Events
window.addEventListener('DOMContentLoaded', () => {
document.getElementById('toggle-refresh').addEventListener('click', () => {
autoRefresh = !autoRefresh;
document.getElementById('toggle-refresh').textContent = autoRefresh ? 'Pause' : 'Resume';
document.getElementById('refresh-state').textContent = autoRefresh ? 'on' : 'paused';
if (autoRefresh) loadStatus();
});
document.getElementById('search').addEventListener('input', () => {
// Re-render list using current data in memory by fetching again (cheap)
loadStatus();
});
loadStatus();
startAutoRefresh();
});

216
app/server.js Executable file
View File

@@ -0,0 +1,216 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const zlib = require('zlib');
const PORT = 80;
const HOST = '0.0.0.0'; // Wichtig: IPv4 binden
const PUBLIC_DIR = path.join(__dirname, 'public'); // ./app/public
const STATUS_FILE = path.join(PUBLIC_DIR, 'data', 'status.json');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.mjs': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.txt': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8'
};
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'Referrer-Policy': 'strict-origin-when-cross-origin',
// Allow same-origin scripts and styles; adjust if you load external assets
'Content-Security-Policy': "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self';"
};
// Helper: safe join under PUBLIC_DIR (avoid path traversal)
function resolvePublicPath(requestPath) {
const clean = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, '');
const absolutePath = path.join(PUBLIC_DIR, clean);
if (!absolutePath.startsWith(PUBLIC_DIR)) return null;
return absolutePath;
}
// Helper: simple logger
function log(req, res, extra = '') {
const ts = new Date().toISOString();
console.log(`[${ts}] ${req.method} ${req.url} -> ${res.statusCode} ${extra}`);
}
// Helper: send JSON
function sendJSON(res, statusCode, obj) {
const buf = Buffer.from(JSON.stringify(obj, null, 2), 'utf-8');
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
...SECURITY_HEADERS
});
res.end(buf);
}
// Helper: gzip if accepted
function maybeCompress(req, res, contentType) {
const accept = req.headers['accept-encoding'] || '';
const supportsGzip = /\bgzip\b/.test(accept);
if (!supportsGzip) return null;
res.setHeader('Content-Encoding', 'gzip');
res.setHeader('Vary', 'Accept-Encoding');
return zlib.createGzip();
}
// Serve static files from /public
function serveStatic(req, res, pathname) {
// Default to index.html for "/" and for directory paths
let targetPath = pathname === '/' ? '/index.html' : pathname;
// Prevent directory traversal and map to filesystem
const abs = resolvePublicPath(targetPath);
if (!abs) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
res.end('Bad request');
return;
}
// If the path maps to a directory, try index.html inside it
let stat;
try {
stat = fs.statSync(abs);
if (stat.isDirectory()) {
targetPath = path.join(targetPath, 'index.html');
const absIndex = resolvePublicPath(targetPath);
if (!absIndex || !fs.existsSync(absIndex)) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
res.end('Forbidden');
return;
}
return streamFile(req, res, absIndex);
}
} catch {
// Fallback: try adding .html if no extension and file missing
const ext = path.extname(abs);
if (!ext) {
const htmlCandidate = `${abs}.html`;
if (fs.existsSync(htmlCandidate)) {
return streamFile(req, res, htmlCandidate);
}
}
// 404 if not found
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
res.end('Not found');
return;
}
// File exists—stream it
return streamFile(req, res, abs);
}
// Stream file with basic headers and optional gzip
function streamFile(req, res, absFile) {
const ext = path.extname(absFile).toLowerCase();
const contentType = MIME[ext] || 'application/octet-stream';
const stat = fs.statSync(absFile);
// Cache policy: static assets get short cache; HTML no-cache to help dev
const isHTML = contentType.startsWith('text/html');
const cacheControl = isHTML ? 'no-cache' : 'public, max-age=60';
// ETag (weak): size + mtime
const etag = `W/"${stat.size}-${stat.mtimeMs.toFixed(0)}"`;
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, { 'ETag': etag, 'Cache-Control': cacheControl, ...SECURITY_HEADERS });
res.end();
return;
}
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', stat.size);
res.setHeader('ETag', etag);
res.setHeader('Cache-Control', cacheControl);
Object.entries(SECURITY_HEADERS).forEach(([k, v]) => res.setHeader(k, v));
// Gzip if accepted (skip for already compressed types like images)
const maybeGzip = (/\btext\/|application\/(javascript|json|xml)/.test(contentType)) ? maybeCompress(req, res, contentType) : null;
const stream = fs.createReadStream(absFile);
stream.on('error', (err) => {
console.error('[static] stream error:', err);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8', ...SECURITY_HEADERS });
}
res.end('Internal server error');
});
if (maybeGzip) {
stream.pipe(maybeGzip).pipe(res);
} else {
stream.pipe(res);
}
}
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const pathname = parsed.pathname || '/';
// Minimal access log (only path, no query)
res.on('finish', () => log(req, res));
// CORS for API (read-only)
if (pathname.startsWith('/api/')) {
// Allow GET from any origin (adjust if needed)
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (pathname === '/api/status') {
fs.readFile(STATUS_FILE, 'utf-8', (err, json) => {
if (err) {
console.error('[api] status read error:', err);
sendJSON(res, 500, { error: 'status_unavailable' });
return;
}
// Validate JSON and return
try {
const data = JSON.parse(json);
sendJSON(res, 200, data);
} catch (e) {
console.error('[api] status parse error:', e);
sendJSON(res, 500, { error: 'status_invalid_json' });
}
});
return;
}
sendJSON(res, 404, { error: 'not_found' });
return;
}
// Serve static from /public
serveStatic(req, res, pathname);
});
server
.listen(PORT, HOST, () => {
console.log(`Node.js server listening on http://${HOST}:${PORT}`);
console.log(`Serving static files from: ${PUBLIC_DIR}`);
console.log(`Status file expected at: ${STATUS_FILE}`);
})
.on('error', (err) => {
console.error('[server] listen error:', err);
});