Initial commit
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# build output
|
||||
dist/
|
||||
build/
|
||||
.cache/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env.*.local
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
21
Dockerfile.ssh
Normal file
21
Dockerfile.ssh
Normal file
@@ -0,0 +1,21 @@
|
||||
# Small base with package manager; Debian is straightforward
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install OpenSSH client and basic tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openssh-client ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Wrapper: copy key to an SSH location with 0600 perms, then exec ssh with args
|
||||
RUN printf '%s\n' \
|
||||
'#!/bin/sh' \
|
||||
'set -eu' \
|
||||
'mkdir -p /root/.ssh' \
|
||||
'# If a key is mounted, install it with strict permissions' \
|
||||
'if [ -f /mnt/keys/tunnel_ed25519 ]; then' \
|
||||
' install -m 600 /mnt/keys/tunnel_ed25519 /root/.ssh/id_ed25519' \
|
||||
'fi' \
|
||||
'exec ssh "$@"' \
|
||||
> /usr/local/bin/run-ssh && chmod +x /usr/local/bin/run-ssh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/run-ssh"]
|
||||
65
app/data/configRobot.yml
Normal file
65
app/data/configRobot.yml
Normal 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: "...."
|
||||
|
||||
|
||||
8
app/programs/listDockerContainer.py
Executable file
8
app/programs/listDockerContainer.py
Executable 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
119
app/public/index.html
Executable 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
115
app/public/js/status.js
Executable 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
216
app/server.js
Executable 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);
|
||||
});
|
||||
7
client/ssh/id_ed25519
Executable file
7
client/ssh/id_ed25519
Executable file
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAmxhQB5nYS00+6YwVXLYMiKQZFQMGiQrKwxJ00n5y7tQAAAJjUYyKK1GMi
|
||||
igAAAAtzc2gtZWQyNTUxOQAAACAmxhQB5nYS00+6YwVXLYMiKQZFQMGiQrKwxJ00n5y7tQ
|
||||
AAAECtQAl35IoNbezyw7NsF+8Pnvaa2wGW4jrB8t/ulIr4tCbGFAHmdhLTT7pjBVctgyIp
|
||||
BkVAwaJCsrDEnTSfnLu1AAAAFWNsMDFca2VjaEAwMS1LVy1TMDIwNg==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
1
client/ssh/id_ed25519.pub
Executable file
1
client/ssh/id_ed25519.pub
Executable file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICbGFAHmdhLTT7pjBVctgyIpBkVAwaJCsrDEnTSfnLu1 cl01\kech@01-KW-S0206
|
||||
32
client/ssh/known_hosts
Normal file
32
client/ssh/known_hosts
Normal file
@@ -0,0 +1,32 @@
|
||||
10.98.32.183 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILDO853Xe2NzMQ67BvD5k9WSGgu38sxcFKIasuul1JVi
|
||||
10.98.32.183 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCNLMjrdXqJUCjBH2i76DoBjbCxW7W9L/mIOWU+/9D8oZRBUwZZiPlzgTH5eTLvdVT7L9Qr2UCUCn8UmYf1MLPNKn6pILgbpMxK4dVp2W7sAkqTBf6Lc84OvXjLD7kg6hrwayU3NJKmbvegCMxqnT1UPFvIeOgnCw9u9/SID5jgX8htxR4MAMyR24RSKOeQoyIR2prywYsVTEKQor8PT3U4AEt7umRhVYkgGJo586NIH5hV+VEFmW6G0R8rx5+OWLGqFG7WpkUx02z6FYqrGTf4JnmjAruJIL+adhfH0TVghEc+o/UzhBr/XlgVx/W2pBZq1DzL7f5dcxptN2pQVxdEiGob0IS5bzkBXoUI9KwaFrCwemGOYlMmsDi5i5UoIMbBgD2aTsphljrJaY+RDv6Qh9K4jxFoImhPg80KBJg6Oz2vlX3U0kq4Kgb5HnAmytKFZMerJdmloSVyXf127QL/6T9y0BXmuiNLhKTk6txPxLIVQ6ZrpT0jWPSL4dQ6D98=
|
||||
10.98.32.183 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNXjSomDO/196azExlsXsgth7h3kCpdzi1Cfh1Ctb92Qa0STpED7PLV96cBQFAzF2GdlYl3gh87EhjoGAuFqWEI=
|
||||
raspberrypi ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMMbpIITUewDZlzZitJu1bc+PyFu824ASOry6kps9Tb
|
||||
raspberrypi ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBK6ubNzIfRSmtxFLB0kHiwDkGr26nLNk8I2jkBIvZva69VykPJyQSoxGXOoQQ1zWUv+l4r+EZ0cSX4sX+LmJZUo=
|
||||
raspberrypi.local ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPMMbpIITUewDZlzZitJu1bc+PyFu824ASOry6kps9Tb
|
||||
10.0.0.56 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxjOLjcjSIbKBDrJykrEIiiuDby6NwJLixaLx1ME5Xj
|
||||
10.0.0.56 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCdpkkXZSlDdZ/LIzAzN+6nQE2YkaSeNgkfsfzvVzkC9IRepljrmMP2Y4rkiTtlTt+EQUsLXW0oscbJtCWgcCq2dAcLmHZq1tNbvDcI7uHjIPiAs5OtleaZjFV+9CjDr9VhjJNLr0ByBeHlx1MhiYAY6lU3HBfW1E5zyLS5xWPYvhMKeHIssWfVgFWTv1PJdW3sAehY0R6vEh2Tj6R5FD93AUZ0YF9d0XnFMntwp/ExDaVzo/GUE64XT2qLYugt+xQVdDtdFBtbHO0Lf6Q7V5OH+MfH4rVIu0o47Aqg4kCiaqM+cjjIhORJg/FzfNzzcKgLHSA8RckdzUAkJeIL+16q0lsCWn+Lv87HOiObMrfguj3BBeAzEG5ZcC42enfWfcJqBy+YiHZzJhjjNZUWQin34nkYW75appQH+jXQDzNJO7tA4RHfOMoM+L2NgMdlASiX7kkMxdq8N7KJIRTgCK44RlqEXn7qB88QGZ36VFcJexQi3aJj4JMoMIPASXdV6n8=
|
||||
10.0.0.56 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBDMAMqU35mfy8kSCWPw4ESOI0fW8y2TtkpeyVMHUbg8Top8TrpiD42fjMklRbnkkexIuKO0VDUZeJjkTXBMguro=
|
||||
raspberrypi5 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIII3R2w4Ijc82I5d8Wt/kcI5WqLiRn2QIZ/TexGeFUyk
|
||||
raspberrypi5 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCrx9F+bihWAje9U4wxueqaB0No+8HyvvoC9Ymlf5Ijb7drVHYcgdOSVe93SJlJ23sQ7kdK8UfYbTf9W/Pr07QZ2RSkkRrRA6G5cw/RZw/4pKSRIr1FzHvfwGrJ4O/kzKzU6LdtS7FF3CD4pm9BiHy1jkSkuiZuOncIrWuwjfjE3Fgxh7J+O4ocNicy4XJl926cd4oy5P16HZtZcAZuBiPDgvBXaNU2giHYazL6NRC6pj+EWO0XllnNw8/PM4JnuvM7VtKUgZzDg81Lf+o+OSS5uirJOj5Z1c6dri0fRuT9JzOwYyf84wStf1EuRhW94ppOshQucrOkz72toNxUlNNX9xLfrckyQC3zlp1cXi2hcPlQPrDqnPTZKB8BORhVgePekOrZPITiCV5QC5UuzqecqjtVQze1dakJFMAy/GN6PuiaVIAE5EJ7Ty2soAc5/sCFanvUhd8WhMcqr52MaD9x5hQI5yxZpwnJJ1acMUiu/sgifkRppoUxZ1a4eXECjXk=
|
||||
raspberrypi5 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBKtmHpfOwvcqWE0HJ/uvmA8jKzccdcB0nTJSr2WBK8Bs+65V2UijhNx8rzL1RmQaBShZtVlTvHNAlCbGDttpcwM=
|
||||
raspberrypi5.local ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIII3R2w4Ijc82I5d8Wt/kcI5WqLiRn2QIZ/TexGeFUyk
|
||||
ksw3dserver.local ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBmV41Tq7zrNdyowjRv/rvghSsSMpCwTjw9/fXObcACW
|
||||
ksw3dserver.local ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDdwKVFJWcGZPSmKJZ1bobwKLvP55lUacG6GYafnP4rbH/KST7uWSOqLxpmDtCfL8g0QzsXdLO0tEDK93OFQIkDzSeXNR9lFGqlV2WGILuOMkek8FIDPA7aODd39BvYz5JeFM0rfyNbR8uVUuwPuHoaMTunjSCTu2/sAa/8S4c3yBJ7D9mEYn9AL/RJV47qnLqX9gtsTbemwWQ3Ij94N6yhdRY/WDuZ6pFnj3rpCo66fhNAisnUve/4QHWDswQ5phIIDktWtDwvJ6N1VNmwInubmBvR60n25V4gI9z1CxRLkipChvB+iX3EFoqcdCIO7L6YqglMKn1oYcgTEfi1IzM82bjWbZq7yXJCGNHlI941/09AB315MipFGuzMIAQ0cJRiDJ58AgFBfyTt6GJYfapc3J3Ja3pwmtnX9yo4JG9ukY1siIb0C5ejrj90idEjVGeyoClMpRA3JNHQljg1g6a/Kbg+DpIznh+7SlmvesJFfqvrHMfh7LTQEyNpqkm9lec=
|
||||
ksw3dserver.local ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLJrDES36rr4MxeZV0up6ZN2DIJSJ1eDceom2AarwkIHjnZp67GFQE9pGz0A4ewLzogL0co7xJx4JFIPDfaZXeQ=
|
||||
192.168.8.203 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIII3R2w4Ijc82I5d8Wt/kcI5WqLiRn2QIZ/TexGeFUyk
|
||||
10.0.0.57 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIII3R2w4Ijc82I5d8Wt/kcI5WqLiRn2QIZ/TexGeFUyk
|
||||
192.168.8.201 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBRr5lKH7Oz5eQMQHvhhuRn6inltN3dt03zK8lNRtIcJ
|
||||
192.168.8.201 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDZ+xZmcQI9PUYnHT5CCDWxmkUuoBwqCowKueYTTyrqCxrK2dt4TrlQOvAtNiTD0RFTZGOTIUvIzzYv+D8zgrWH068hzcjw9fFYmKnwi8gc27CDTZqP2rnejhYKpXsgkSVzEIUuuiuvMU4xwXtiZ9M/yKCvKh6KJZ6mR+e8/26MzNVCb4ZaAEDg3n7dZyDdI2m4Bx26J1I9uTzKQP0XSwp/M0N/G93xr4FN00W4NjBx2zC4NRfcjcPv6sus/EDuCDIb/ICBBPF2BDt0m5EVn+1IO+KpALGd1bwR+toPzWmqkyu0e2QlhL0C5oP+LAPi8A7aXH+VZyxf3zdQ+cmS5IVQB1vnRb17dEU7sKZDIxKUCaXrzhzckp4vaBh6RrPNTJ5Y3j9quKPYiYniYDMP2d+agqwm0FliHP1XZg5ikJYGNleDMhRgnV8roPfEZ2ea1pQ9dl61LE83nfyGMyfi+X/gUSMTz1chjcWIlUM7GNvJ9UEsbjp0QbEZn43jb99Gbws=
|
||||
192.168.8.201 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIz48oXT1K3fYfpUpv4wUlLKlhQu5x1kKm2SkYu136BKJ9yFCYUKn+uOpOX1SILdo+z8tumhyb388lFSZ94Rxsg=
|
||||
[synologynas.local]:885 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID5nNPeTdrdu0SBm7GDoMQX6lEi/QKws3c+uJO3jMlEU
|
||||
[synologynas.local]:885 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHMfMPh3FVD9YWMsukPDtPTF/fLKKiDCmkg06a/Xv9jQEPmiMsexqoxcMFCRJ2qmuSRqH9fnyjR7L+JhJ1FFFXM=
|
||||
thinkcentre.local ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV1bzcwR2FwtByf4M82Jjqlut8dUjtlnT6PD9IK9DZN
|
||||
thinkcentre.local ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC2wQ331FiD/0TgP7LwfHFeDdaOjCUez2YucVpFaIbO/zvcUHF93DTwnUrUgKs2HSFVJw8D/shhnfmGNiMzFmqMCVAoCpKrt16eGtDNRvjuRTlbTMT0oaIvNkS095nbDddXBjbjh4nK4Jjiyh56UT2uFGY+TlfM30bi3G1GkWOfzoB36fyMRFgNW9Zja3RZL78j6dbL9BBpUAJGuJfrEmFck7188oGcx/CkeaX+fuButrM+wbSycv6qiRQOXeTYUcsJiIxGQhMl7t1WfiP3PYEpE+DUNFMkugXnINvovMTLn8sOmOBSkV7NYei0IEljPAQ7MWhoGIU5rl0LuLq+zwmlSC1A9LbxvNQzb4jUYJUukd19HCXwi+8bk+Du4xKY7AGvrJtdho+lg5yPLOqbpne3l2jB6bP7UsISC+Rta8JeRXmBvcjN9n/Tx4cHsQNsf4PVHLs3TzL5cqnBdvStEHt87pyvOQwByNw7RODk3QQyh4yTA2sr3UrgW5f7zvMKeNU=
|
||||
thinkcentre.local ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJFp9Ba4xQBCdG4UcbALUxUAPIiJwY+byAGVlif9c6n8f2NVeDgDUs2+3YSmmfPCTNK84oxbL/hDUNEpL96wZz8=
|
||||
[albulastrasse.diskstation.me]:885 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID5nNPeTdrdu0SBm7GDoMQX6lEi/QKws3c+uJO3jMlEU
|
||||
[thinkcentre.local]:3222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDO2kSX8pD+VUMnZEhrOxnjyDk/4U7+h+X5bOOvTz3f5AO4s+LWIrAU5wDP66ibn2LqRrvaxrksLTOUDhKU/jLhDjlqfP5pnI6b2voZHk03pZ9Q9N0A+VJ4GOAiy/Q354CQpUER9WJq1GbhfDS325HUVCM8jHnIrtHEEBsoN4lFIsCL+bie4Gygfk6+Hvq4kljAXj043Tjyvw+sJ+N48C3RBFRBxB/LdP8CuKFurilfhQPnGk7xIuPn+8cGY9VInrX9ZdrVWnzPi/na1CoFAhatmpsqRIi+C26/U0o+lrQFEn8Nf5Dyrkv4fVYQWcJJsHLs00BKac32WBDJbThZSLCssAMUWiWydwk+SgJiYEmcbSgb+i+huKY1/6pPfGFrIVoUO469r7u02Cdb3I1/Py+0ZxXyvtd4bVfTy3hzwz3ouWcDadlmpHn8OOqHzvAVoma/hcNQ8VJv6zs6aiw9S9AdND938XMky2a1dfTM5cRY2vYyWun8DUM50804rK5bigs8w6LCKuQTPv04hkMBiH6cybFbGKBfeuyBfPiObgYMvatf2q1JVdIedPANgXP3BGMTz40I6/U1dQOxw740kwZX1QFon1KPKgRYfrxMSsmQh43NHaHHHlGdXsl6LsvwTUOMbARxpy/gwvBsVBhxOEKRtyUVMXzteSeDuy08AGwhfQ==
|
||||
thinkcentre ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICV1bzcwR2FwtByf4M82Jjqlut8dUjtlnT6PD9IK9DZN
|
||||
[thinkcentre.local]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL7mpGrP8jGvgVHD9rUiojaRdZ3N+NgDrmnM5yDurz2O
|
||||
[thinkcentre.local]:2222 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJmjp9u71R3nzHn1mDcBkpv8+TFY2nJzRZ00kG2dfsKJ4/TCyIRrBLQNOZ/pwY2l3WrItmyxavSCqcxQ7gWZqxc=
|
||||
[thinkcentre.local]:2222 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCNK5wArMICfo2GXJUt27MNofmAOpZd53pYu1PtlXyEnAC8MJl8XgyioLAG0z8gjhLE70+wfFJsCI6+Awhc3ahxRGCmyz45mON3mQwoDChe5OFyx/dLi5wgXzX1sFVNEA3ziDmMP/8mu/zxVy26+s10KdIKnND0ASka/qjfu3lRQ2BoYohOA3AIdpu0NlXAL+hoghqLG6S8tXJ2p3mPoOVdDHur4xgcMHcFnJXyWkKG+UafjA9Ve04aiVmid+biAREGaOsddJutjGvMnng6D+T5HtOlbqX3iZ1ew8jrzdcyTiN4uuVyNObXYaKbD5MX5HOO/xPZqMzgrcQ7tURyo2gLHJBplrB8akXE/ZpZ53nN65jEuGd5msgRSu8Wy3NbOoQPCsapQqzKjs3rZJSRkb2QLUwq2hPuIUBgyMUoBlwAqooirBcHNBwtsLGJsErKHi5nAXRnvZF7A/ApVaE5JbUeWATjABS8BbjEifsEwYhCnIZlnXP3a2YkaWOndN+YIJ8=
|
||||
7
client/ssh/tunnel_ed25519
Normal file
7
client/ssh/tunnel_ed25519
Normal file
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACCz+U/1x5we7BEIL68hS2yNuj1j11OMqIH1RcQhsHjbXAAAAJBVUf5tVVH+
|
||||
bQAAAAtzc2gtZWQyNTUxOQAAACCz+U/1x5we7BEIL68hS2yNuj1j11OMqIH1RcQhsHjbXA
|
||||
AAAEBYr54zv/c7r1IWm+khOsd8luvaeHx1ZkEWn7U7fMkZMLP5T/XHnB7sEQgvryFLbI26
|
||||
PWPXU4yogfVFxCGweNtcAAAABnR1bm5lbAECAwQFBgc=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
1
client/ssh/tunnel_ed25519.pub
Normal file
1
client/ssh/tunnel_ed25519.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILP5T/XHnB7sEQgvryFLbI26PWPXU4yogfVFxCGweNtc tunnel
|
||||
2
createTunnel.sh.txt
Normal file
2
createTunnel.sh.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
install -m 600 ./client/ssh/tunnel_ed25519 ~/.ssh/tunnel_ed25519
|
||||
ssh -p 2222 -v -N -T -R 0.0.0.0:9000:localhost:8080 -i ~/.ssh/tunnel_ed25519 -o ExitOnForwardFailure=yes tunnel@thinkcentre.local
|
||||
65
data/configRobot.yml
Executable file
65
data/configRobot.yml
Executable 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: "...."
|
||||
|
||||
|
||||
242
data/status.json
Normal file
242
data/status.json
Normal file
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"timestamp": "2025-12-27T13:47:09.882287+00:00",
|
||||
"system": {
|
||||
"cpu": {
|
||||
"percent": 19.7,
|
||||
"load_avg": {
|
||||
"1m": 0.51220703125,
|
||||
"5m": 0.27783203125,
|
||||
"15m": 0.3212890625
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"total_mb": 7775.16,
|
||||
"used_mb": 2945.71,
|
||||
"percent": 42.5
|
||||
}
|
||||
},
|
||||
"containers": [
|
||||
{
|
||||
"id": "d1758990b9",
|
||||
"name": "reverse-tunnel",
|
||||
"image": "alpine:latest",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 8.95,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 0.12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b00c1a2969",
|
||||
"name": "AppRobotConfig",
|
||||
"image": "node:20-alpine",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [
|
||||
{
|
||||
"container_port": "80/tcp",
|
||||
"host_ip": "0.0.0.0",
|
||||
"host_port": "8080"
|
||||
},
|
||||
{
|
||||
"container_port": "80/tcp",
|
||||
"host_ip": "::",
|
||||
"host_port": "8080"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 11.66,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 0.15
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c94cc1e834",
|
||||
"name": "tunnelConnector",
|
||||
"image": "ghcr.io/linuxserver/openssh-server:latest",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 5.53,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 0.07
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "333745700e",
|
||||
"name": "nginxAuth",
|
||||
"image": "nginx:latest",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [
|
||||
{
|
||||
"container_port": "443/tcp",
|
||||
"host_ip": "0.0.0.0",
|
||||
"host_port": "8082"
|
||||
},
|
||||
{
|
||||
"container_port": "443/tcp",
|
||||
"host_ip": "::",
|
||||
"host_port": "8082"
|
||||
},
|
||||
{
|
||||
"container_port": "444/tcp",
|
||||
"host_ip": "0.0.0.0",
|
||||
"host_port": "444"
|
||||
},
|
||||
{
|
||||
"container_port": "444/tcp",
|
||||
"host_ip": "::",
|
||||
"host_port": "444"
|
||||
},
|
||||
{
|
||||
"container_port": "80/tcp",
|
||||
"host_ip": null,
|
||||
"host_port": null
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 6.59,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 0.08
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "16675455a9",
|
||||
"name": "appvideoserver",
|
||||
"image": "appvideoserver:latest",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [
|
||||
{
|
||||
"container_port": "8443/tcp",
|
||||
"host_ip": "0.0.0.0",
|
||||
"host_port": "8448"
|
||||
},
|
||||
{
|
||||
"container_port": "8443/tcp",
|
||||
"host_ip": "::",
|
||||
"host_port": "8448"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 327.17,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 4.21
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "88e8a24141",
|
||||
"name": "guacamoleTunneled",
|
||||
"image": "abesnier/guacamole:latest",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [
|
||||
{
|
||||
"container_port": "8080/tcp",
|
||||
"host_ip": "0.0.0.0",
|
||||
"host_port": "8081"
|
||||
},
|
||||
{
|
||||
"container_port": "8080/tcp",
|
||||
"host_ip": "::",
|
||||
"host_port": "8081"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 402.19,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 5.17
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "73fd1ba6b3",
|
||||
"name": "RoboticsDriver",
|
||||
"image": "node:18",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [
|
||||
{
|
||||
"container_port": "2095/tcp",
|
||||
"host_ip": "0.0.0.0",
|
||||
"host_port": "2095"
|
||||
},
|
||||
{
|
||||
"container_port": "2095/tcp",
|
||||
"host_ip": "::",
|
||||
"host_port": "2095"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 46.09,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 0.59
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "9e8af64136",
|
||||
"name": "gitea",
|
||||
"image": "gitea/gitea:latest",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [
|
||||
{
|
||||
"container_port": "22/tcp",
|
||||
"host_ip": null,
|
||||
"host_port": null
|
||||
},
|
||||
{
|
||||
"container_port": "2222/tcp",
|
||||
"host_ip": "0.0.0.0",
|
||||
"host_port": "3222"
|
||||
},
|
||||
{
|
||||
"container_port": "2222/tcp",
|
||||
"host_ip": "::",
|
||||
"host_port": "3222"
|
||||
},
|
||||
{
|
||||
"container_port": "3000/tcp",
|
||||
"host_ip": "0.0.0.0",
|
||||
"host_port": "3000"
|
||||
},
|
||||
{
|
||||
"container_port": "3000/tcp",
|
||||
"host_ip": "::",
|
||||
"host_port": "3000"
|
||||
}
|
||||
],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 164.53,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 2.12
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "845c4fc6bf",
|
||||
"name": "cloudflared",
|
||||
"image": "cloudflare/cloudflared:latest",
|
||||
"status": "running",
|
||||
"state": "running",
|
||||
"openPorts": [],
|
||||
"resources": {
|
||||
"cpu_percent": 0.0,
|
||||
"memory_mb": 32.57,
|
||||
"memory_limit_mb": 7775.16,
|
||||
"memory_percent": 0.42
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
44
docker-compose.yaml
Executable file
44
docker-compose.yaml
Executable file
@@ -0,0 +1,44 @@
|
||||
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
node-app:
|
||||
image: node:20-alpine
|
||||
container_name: AppRobotConfig
|
||||
user: "root" # allow binding privileged port 80
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./app:/app # mount your Node.js app code here
|
||||
- ./public:/app/public
|
||||
- ./data:/app/public/data
|
||||
command: ["node", "server.js"]
|
||||
|
||||
expose:
|
||||
- "80" # optional, nur Doku innerhalb des Netzwerks
|
||||
ports:
|
||||
- "8080:80" # <-- Host-Port 8080 auf Container-Port 8
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
reverse-tunnel:
|
||||
image: alpine:latest
|
||||
container_name: reverse-tunnel
|
||||
depends_on:
|
||||
- node-app
|
||||
volumes:
|
||||
- ./client/ssh/tunnel_ed25519:/tmp/tunnel_ed25519:ro
|
||||
- ./client/ssh/known_hosts:/root/.ssh/known_hosts:ro
|
||||
command: >
|
||||
sh -c "apk add --no-cache openssh &&
|
||||
mkdir -p /root/.ssh &&
|
||||
install -m 600 /tmp/tunnel_ed25519 /root/.ssh/id_ed25519 &&
|
||||
exec ssh -p 2222 -N -T
|
||||
-R 0.0.0.0:9000:node-app:80
|
||||
-i /root/.ssh/id_ed25519
|
||||
-o ExitOnForwardFailure=yes
|
||||
-o ServerAliveInterval=30
|
||||
-o ServerAliveCountMax=3
|
||||
tunnel@thinkcentre.local"
|
||||
|
||||
restart: unless-stopped
|
||||
7
generate.txt
Executable file
7
generate.txt
Executable file
@@ -0,0 +1,7 @@
|
||||
i love the docker container of cloudflare that creates a tunnel.
|
||||
|
||||
I want to use something simulilar. Give me two docker-compose.yaml
|
||||
|
||||
1) Server where a Port is availiable (that is availiable outside of a firewall). It has the port 4080 that should be routed to the clients :80. and it has a server:4040 for ssh secured access by the client.
|
||||
|
||||
2) Client that lives behind a firewall and connects to that server. That client receives the traffic and hosts not only the tunnel, but also a node.js server.
|
||||
119
public/index.html
Executable file
119
public/index.html
Executable 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
public/js/status.js
Executable file
115
public/js/status.js
Executable 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();
|
||||
});
|
||||
127
scripts/getStats.py
Executable file
127
scripts/getStats.py
Executable file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import time
|
||||
import psutil
|
||||
import docker
|
||||
import os
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
OUTPUT_FILE_NAME = "~/Documents/AppRobotConfig/data/status.json"
|
||||
OUTPUT_FILE = os.path.expanduser(OUTPUT_FILE_NAME)
|
||||
|
||||
client = docker.from_env()
|
||||
|
||||
def get_system_stats():
|
||||
vm = psutil.virtual_memory()
|
||||
cpu = psutil.cpu_percent(interval=None)
|
||||
load1, load5, load15 = psutil.getloadavg()
|
||||
|
||||
return {
|
||||
"cpu": {
|
||||
"percent": cpu,
|
||||
"load_avg": {
|
||||
"1m": load1,
|
||||
"5m": load5,
|
||||
"15m": load15
|
||||
}
|
||||
},
|
||||
"memory": {
|
||||
"total_mb": round(vm.total / 1024 / 1024, 2),
|
||||
"used_mb": round(vm.used / 1024 / 1024, 2),
|
||||
"percent": vm.percent
|
||||
}
|
||||
}
|
||||
|
||||
def calculate_cpu_percent(stats):
|
||||
print("Calculate Percentage")
|
||||
cpu_delta = (
|
||||
stats["cpu_stats"]["cpu_usage"]["total_usage"]
|
||||
- stats["precpu_stats"]["cpu_usage"]["total_usage"]
|
||||
)
|
||||
system_delta = (
|
||||
stats["cpu_stats"]["system_cpu_usage"]
|
||||
- stats["precpu_stats"]["system_cpu_usage"]
|
||||
)
|
||||
|
||||
if system_delta > 0 and cpu_delta > 0:
|
||||
cpu_count = len(stats["cpu_stats"]["cpu_usage"].get("percpu_usage", []))
|
||||
return round((cpu_delta / system_delta) * cpu_count * 100, 2)
|
||||
|
||||
return 0.0
|
||||
|
||||
def get_container_stats():
|
||||
containers_data = []
|
||||
|
||||
for container in client.containers.list(all=True):
|
||||
info = {
|
||||
"id": container.short_id,
|
||||
"name": container.name,
|
||||
"image": container.image.tags[0] if container.image.tags else container.image.short_id,
|
||||
"status": container.status,
|
||||
"state": container.attrs["State"]["Status"],
|
||||
}
|
||||
|
||||
# Add openPorts info
|
||||
open_ports = []
|
||||
network_settings = container.attrs.get("NetworkSettings", {})
|
||||
port_bindings = network_settings.get("Ports", {})
|
||||
|
||||
for container_port, host_bindings in port_bindings.items():
|
||||
if host_bindings:
|
||||
for binding in host_bindings:
|
||||
open_ports.append({
|
||||
"container_port": container_port,
|
||||
"host_ip": binding.get("HostIp"),
|
||||
"host_port": binding.get("HostPort")
|
||||
})
|
||||
else:
|
||||
# Port exposed but not published
|
||||
open_ports.append({
|
||||
"container_port": container_port,
|
||||
"host_ip": None,
|
||||
"host_port": None
|
||||
})
|
||||
|
||||
info["openPorts"] = open_ports
|
||||
|
||||
|
||||
if container.status == "running":
|
||||
stats = container.stats(stream=False)
|
||||
|
||||
cpu_percent = calculate_cpu_percent(stats)
|
||||
|
||||
mem_usage = stats["memory_stats"]["usage"]
|
||||
mem_limit = stats["memory_stats"]["limit"]
|
||||
mem_percent = (mem_usage / mem_limit) * 100 if mem_limit > 0 else 0
|
||||
|
||||
info["resources"] = {
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_mb": round(mem_usage / 1024 / 1024, 2),
|
||||
"memory_limit_mb": round(mem_limit / 1024 / 1024, 2),
|
||||
"memory_percent": round(mem_percent, 2)
|
||||
}
|
||||
else:
|
||||
info["resources"] = None
|
||||
|
||||
containers_data.append(info)
|
||||
|
||||
return containers_data
|
||||
|
||||
def main():
|
||||
# while True:
|
||||
data = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"system": get_system_stats(),
|
||||
"containers": get_container_stats()
|
||||
}
|
||||
|
||||
print("output written")
|
||||
with open(OUTPUT_FILE, "w+") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
#time.sleep(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
scripts/install.sh
Executable file
4
scripts/install.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-full
|
||||
sudo apt install python3-psutil python3-docker
|
||||
sudo usermod -aG docker $USER
|
||||
Reference in New Issue
Block a user