FileBrowser
This commit is contained in:
153
doc/fileBrowser.md
Normal file
153
doc/fileBrowser.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# ROADMAP — appRobotFileservice Web-UI (Datei-Browser)
|
||||
|
||||
## Ziel
|
||||
|
||||
Einfache Web-Oberfläche direkt auf Port 2100, die GCode-Programme verwaltet.
|
||||
Kein Framework, kein Build-Schritt — reines HTML/CSS/JS, serviert als static files.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Datei-Browser (aktuell implementiert)
|
||||
|
||||
**Dateien:** `public/index.html`, `public/index.css`
|
||||
|
||||
### Funktionen
|
||||
|
||||
| Feature | Status | Endpunkt |
|
||||
|---|---|---|
|
||||
| Programm-Liste laden | ✅ | `GET /api/programs` |
|
||||
| Aktives Programm anzeigen (Zeilenliste in Grad) | ✅ | `GET /api/active` |
|
||||
| Programm als aktiv setzen (FLoad) | ✅ | `PUT /api/active` |
|
||||
| Programm löschen | ✅ | `DELETE /api/programs/:id` |
|
||||
| Stepping: First / Prev / Next / Last | ✅ | `POST /api/active/first|prev|next|last` |
|
||||
| Programm leeren (FClear) | ✅ | `POST /api/active/clear` |
|
||||
| Cursor in Tabelle hervorheben + auto-scrollen | ✅ | — |
|
||||
| Auto-Refresh (5 s Polling) | ✅ | — |
|
||||
| Collapse-Karten (wie appRobotHoming) | ✅ | — |
|
||||
|
||||
### Was Phase 1 noch fehlt
|
||||
|
||||
- **Download `.gcode`** — Endpunkt `GET /api/programs/:id/download` muss im Server
|
||||
hinzugefügt werden (liefert die rohe Datei mit `Content-Disposition: attachment`).
|
||||
- **Upload `.gcode`** — Datei vom Desktop in den Service laden (Phase 2).
|
||||
- **Umbenennen** — `PUT /api/programs/:id` mit neuem `name`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Datei-Verwaltung
|
||||
|
||||
### Download-Endpunkt (kleines Backend-Feature für Phase 1.5)
|
||||
|
||||
```
|
||||
GET /api/programs/:id/download
|
||||
→ Content-Type: text/plain
|
||||
→ Content-Disposition: attachment; filename="<id>.gcode"
|
||||
→ Body: rohe .gcode-Datei
|
||||
```
|
||||
|
||||
Implementierung: 1 Route in `src/routes/programs.js` + `store.gcodePath(id)`.
|
||||
|
||||
### Upload
|
||||
|
||||
```
|
||||
POST /api/programs/upload
|
||||
Body: multipart/form-data { file: <gcode-Datei> }
|
||||
→ speichert unter slugify(filename) in GCodeFiles/
|
||||
```
|
||||
|
||||
Im Frontend: `<input type="file" accept=".gcode,.ngc">` + `FormData` fetch.
|
||||
|
||||
### Umbenennen / Metadaten bearbeiten
|
||||
|
||||
`PUT /api/programs/:id` ist bereits vorhanden — nur das Frontend-Formular fehlt.
|
||||
Inline-Eingabefeld in der Programm-Zeile, Enter → PUT.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Verzeichnis-Navigation
|
||||
|
||||
Aktuell: alles flach im `GCodeFiles/`-Ordner.
|
||||
Später: Unterordner für Projekte (z. B. `GCodeFiles/greifen/`, `GCodeFiles/ablegen/`).
|
||||
|
||||
### Backend
|
||||
|
||||
Neue Konfiguration: `storageDir` bleibt Wurzel; zusätzlicher optionaler `subDir`-Parameter.
|
||||
|
||||
```
|
||||
GET /api/programs?dir=greifen → listet GCodeFiles/greifen/
|
||||
PUT /api/active { id, dir } → lädt GCodeFiles/<dir>/<id>.gcode
|
||||
POST /api/dirs { name } → legt Unterordner an
|
||||
DELETE /api/dirs/:name → löscht (leer) Unterordner
|
||||
```
|
||||
|
||||
Wichtig: Pfad-Traversal verhindern — `assertValidId` auf jede Pfad-Komponente anwenden.
|
||||
|
||||
### Frontend
|
||||
|
||||
Breadcrumb-Leiste oberhalb der Programmliste:
|
||||
```
|
||||
GCodeFiles/ > greifen/ > [zurück]
|
||||
```
|
||||
|
||||
Doppelklick auf Ordner-Zeile → navigiert hinein.
|
||||
Klick auf Breadcrumb-Segment → navigiert zurück.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Live-Updates (WebSocket oder SSE)
|
||||
|
||||
Aktuell: Polling alle 5 s.
|
||||
Besser: Server-Sent Events (SSE) oder WebSocket, damit die UI sofort
|
||||
reagiert wenn der Driver per FPoint einen neuen Punkt schreibt.
|
||||
|
||||
### Option A — Server-Sent Events (einfach, read-only)
|
||||
|
||||
```
|
||||
GET /api/events
|
||||
→ text/event-stream
|
||||
→ event: active-changed\ndata: <ActiveState-JSON>\n\n
|
||||
```
|
||||
|
||||
Der Fileservice emittiert nach jedem `_persist()` ein SSE-Event.
|
||||
Frontend: `new EventSource('/api/events')` statt `setInterval`.
|
||||
|
||||
### Option B — WebSocket (bidirektional, für spätere Steuerung)
|
||||
|
||||
Erlaubt auch WS-basierte FCode-Befehle direkt von der Web-UI.
|
||||
Aufwändiger, aber konsistent mit dem Driver-Pattern.
|
||||
|
||||
**Empfehlung für Phase 4:** SSE — eine Zeile Mehraufwand gegenüber Polling,
|
||||
kein zusätzliches Protokoll.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Inline-Editor
|
||||
|
||||
Einzelne Zeilen direkt im Browser bearbeiten.
|
||||
|
||||
```
|
||||
PUT /api/active/lines/:index { line: "G90 G1 x0 y300 z0 a90.00 …" }
|
||||
DELETE /api/active/lines/:index
|
||||
POST /api/active/lines { line, atIndex }
|
||||
```
|
||||
|
||||
Alle drei Endpunkte sind bereits im Backend implementiert.
|
||||
Frontend: Klick auf Tabellenzeile → `<input>` erscheint inline; Escape abbricht, Enter speichert.
|
||||
|
||||
---
|
||||
|
||||
## Datei-Übersicht (nach Phase 1)
|
||||
|
||||
```
|
||||
appRobotFileservice/
|
||||
public/
|
||||
index.html HTML-Struktur + JavaScript (inline)
|
||||
index.css Design-System (identisch mit appRobotHoming/styles.css)
|
||||
doc/
|
||||
fileBrowser_ROADMAP.md diese Datei
|
||||
src/
|
||||
server.js +express.static('public/')
|
||||
routes/
|
||||
programs.js TODO Phase 2: /download-Endpunkt
|
||||
active.js komplett
|
||||
```
|
||||
246
public/index.css
Normal file
246
public/index.css
Normal file
@@ -0,0 +1,246 @@
|
||||
:root {
|
||||
--bg: #0b1220;
|
||||
--panel: #132c44;
|
||||
--border: #0e1822;
|
||||
--text: #e0e6ed;
|
||||
--muted: #9aa6b2;
|
||||
--accent: #a4bbd4;
|
||||
--active: #1e3a5f;
|
||||
--danger: #7f1d1d;
|
||||
--danger-border: #991b1b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
min-height: 100%;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(to bottom, #dddddd -20%, var(--bg) 130%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body { padding: 16px; }
|
||||
|
||||
/* ===== HEADER ===== */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.app-header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
.app-header .subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ===== GRID ===== */
|
||||
.sections {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.sections { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ===== CARD ===== */
|
||||
.section {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.section h2 .badge {
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
color: var(--muted);
|
||||
background: #1e293b;
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ===== PROGRAM LIST ===== */
|
||||
#program-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prog-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.prog-item:hover {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
.prog-item.is-active {
|
||||
background: var(--active);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.prog-item .prog-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.prog-item .prog-id {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.prog-item .prog-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prog-item .btn-del {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.prog-item:hover .btn-del,
|
||||
.prog-item.is-active .btn-del { opacity: 1; }
|
||||
.prog-item .btn-del:hover {
|
||||
color: #fca5a5;
|
||||
border-color: var(--danger-border);
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
padding: 8px 2px;
|
||||
}
|
||||
|
||||
/* ===== CONTROLS ===== */
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
button {
|
||||
background: #1e293b;
|
||||
color: var(--text);
|
||||
border: 1px solid #334155;
|
||||
padding: 6px 13px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
button:hover:not(:disabled) { border-color: var(--accent); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button.primary {
|
||||
background: var(--active);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== ACTIVE INFO BAR ===== */
|
||||
.active-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
background: #0b1220;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.active-bar .kv { display: flex; flex-direction: column; }
|
||||
.active-bar .kv .k { color: var(--muted); font-size: 11px; }
|
||||
.active-bar .kv .v { color: var(--accent); font-weight: 600; }
|
||||
.active-bar .sep { width: 1px; height: 30px; background: #1f2937; }
|
||||
|
||||
/* ===== GCODE TABLE ===== */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 6px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
table th {
|
||||
background: #1e293b;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 5px 8px;
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #0e1822;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table tbody tr:nth-child(even) { background: #0f172a; }
|
||||
table tbody tr:hover { background: #1e293b; }
|
||||
table tbody tr.cursor-row {
|
||||
background: var(--active);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
table td.idx { color: var(--muted); width: 40px; }
|
||||
table td.line { color: var(--text); }
|
||||
table td.ts { color: var(--muted); font-size: 11px; }
|
||||
|
||||
/* ===== STATUS / SPINNER ===== */
|
||||
.status-line {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
.status-line.err { color: #fca5a5; }
|
||||
|
||||
/* ===== DOWNLOAD LINK ===== */
|
||||
.dl-link {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
background: #1e293b;
|
||||
}
|
||||
.dl-link:hover { border-color: var(--accent); }
|
||||
324
public/index.html
Normal file
324
public/index.html
Normal file
@@ -0,0 +1,324 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>appRobotFileservice</title>
|
||||
<link rel="stylesheet" href="/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app-header">
|
||||
<h1>appRobotFileservice</h1>
|
||||
<span class="subtitle">Datei-Browser · GCodeFiles/</span>
|
||||
</div>
|
||||
|
||||
<div class="sections">
|
||||
|
||||
<!-- ===== LINKE SPALTE: Programm-Liste ===== -->
|
||||
<div class="section">
|
||||
<h2>
|
||||
Programme
|
||||
<span class="badge" id="prog-count">–</span>
|
||||
</h2>
|
||||
|
||||
<div class="controls">
|
||||
<button id="btn-refresh" title="Liste neu laden">⟳ Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<div class="status-line" id="list-status"></div>
|
||||
<div id="program-list"><span class="empty-hint">Lade…</span></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== RECHTE SPALTE: Aktives Programm + Inhalt ===== -->
|
||||
<div class="section">
|
||||
<h2>
|
||||
Aktives Programm
|
||||
<span class="badge" id="active-name">–</span>
|
||||
</h2>
|
||||
|
||||
<!-- Info-Zeile -->
|
||||
<div class="active-bar" id="active-bar" style="display:none">
|
||||
<div class="kv"><span class="k">ID</span><span class="v" id="ai-id">–</span></div>
|
||||
<div class="sep"></div>
|
||||
<div class="kv"><span class="k">Zeilen</span><span class="v" id="ai-count">–</span></div>
|
||||
<div class="sep"></div>
|
||||
<div class="kv"><span class="k">Cursor</span><span class="v" id="ai-cursor">–</span></div>
|
||||
<div class="sep"></div>
|
||||
<div class="kv"><span class="k">Version</span><span class="v" id="ai-version">–</span></div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="btn-first" disabled title="Erste Zeile">|◀ First</button>
|
||||
<button id="btn-prev" disabled title="Schritt zurück">◀ Prev</button>
|
||||
<button id="btn-next" disabled title="Schritt vor">Next ▶</button>
|
||||
<button id="btn-last" disabled title="Letzte Zeile">Last ▶|</button>
|
||||
<button id="btn-clear" disabled title="Programm leeren">✕ Clear</button>
|
||||
<a id="btn-download" class="dl-link" style="display:none" download>⬇ .gcode</a>
|
||||
</div>
|
||||
|
||||
<div class="status-line" id="active-status"></div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="lines-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="idx">#</th>
|
||||
<th class="line">G-Code (gespeichert in Grad)</th>
|
||||
<th class="ts">Zeitstempel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lines-body">
|
||||
<tr><td colspan="3" class="empty-hint" style="padding:10px">Kein aktives Programm</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- .sections -->
|
||||
|
||||
<script>
|
||||
// ─── Konfiguration ───────────────────────────────────────────────────────────
|
||||
const API = ''; // leer → gleicher Origin; überschreib mit 'http://…:2100'
|
||||
const REFRESH_MS = 5000; // Auto-Refresh-Intervall
|
||||
|
||||
// ─── Zustand ─────────────────────────────────────────────────────────────────
|
||||
let currentActiveId = null;
|
||||
|
||||
// ─── DOM-Referenzen ───────────────────────────────────────────────────────────
|
||||
const elProgCount = document.getElementById('prog-count');
|
||||
const elProgList = document.getElementById('program-list');
|
||||
const elListStatus = document.getElementById('list-status');
|
||||
const elActiveName = document.getElementById('active-name');
|
||||
const elActiveBar = document.getElementById('active-bar');
|
||||
const elAiId = document.getElementById('ai-id');
|
||||
const elAiCount = document.getElementById('ai-count');
|
||||
const elAiCursor = document.getElementById('ai-cursor');
|
||||
const elAiVersion = document.getElementById('ai-version');
|
||||
const elLinesBody = document.getElementById('lines-body');
|
||||
const elActStatus = document.getElementById('active-status');
|
||||
const elBtnFirst = document.getElementById('btn-first');
|
||||
const elBtnPrev = document.getElementById('btn-prev');
|
||||
const elBtnNext = document.getElementById('btn-next');
|
||||
const elBtnLast = document.getElementById('btn-last');
|
||||
const elBtnClear = document.getElementById('btn-clear');
|
||||
const elBtnDownload = document.getElementById('btn-download');
|
||||
|
||||
// ─── API-Helpers ─────────────────────────────────────────────────────────────
|
||||
async function apiFetch(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers['content-type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(API + path, opts);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw Object.assign(new Error(data.message || res.statusText), { code: data.code, status: res.status });
|
||||
return data;
|
||||
}
|
||||
|
||||
function setStatus(el, msg, isErr = false) {
|
||||
el.textContent = msg;
|
||||
el.className = 'status-line' + (isErr ? ' err' : '');
|
||||
}
|
||||
|
||||
// ─── Zeitstempel lesbar machen ───────────────────────────────────────────────
|
||||
function fmtTs(raw) {
|
||||
if (!raw) return '';
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 1e12) return raw;
|
||||
return new Date(n).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
}
|
||||
|
||||
// ─── Zeile in Code + Timestamp zerlegen ──────────────────────────────────────
|
||||
function parseLine(line) {
|
||||
const idx = line.indexOf(';');
|
||||
if (idx === -1) return { code: line.trim(), ts: '' };
|
||||
return { code: line.slice(0, idx).trim(), ts: fmtTs(line.slice(idx + 1).trim()) };
|
||||
}
|
||||
|
||||
// ─── Programmliste rendern ───────────────────────────────────────────────────
|
||||
function renderProgList(programs, activeId) {
|
||||
elProgCount.textContent = programs.length;
|
||||
if (!programs.length) {
|
||||
elProgList.innerHTML = '<span class="empty-hint">Keine Programme gefunden</span>';
|
||||
return;
|
||||
}
|
||||
elProgList.innerHTML = programs
|
||||
.map(p => {
|
||||
const isActive = p.id === activeId;
|
||||
return `<div class="prog-item${isActive ? ' is-active' : ''}" data-id="${esc(p.id)}">
|
||||
<span class="prog-name" title="${esc(p.name)}">${esc(p.name)}</span>
|
||||
<span class="prog-id">${esc(p.id)}</span>
|
||||
<span class="prog-count">${p.lineCount} Z.</span>
|
||||
<button class="btn-del" data-id="${esc(p.id)}" title="Löschen">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Klick auf Zeile → FLoad (Programm aktivieren)
|
||||
elProgList.querySelectorAll('.prog-item').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('btn-del')) return;
|
||||
loadProgram(el.dataset.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Klick auf Löschen-Button
|
||||
elProgList.querySelectorAll('.btn-del').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
deleteProgram(btn.dataset.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Zeilen-Tabelle rendern ──────────────────────────────────────────────────
|
||||
function renderLines(state) {
|
||||
const { programId, cursor, lineCount, lines = [], version } = state;
|
||||
currentActiveId = programId;
|
||||
|
||||
elActiveName.textContent = programId || '–';
|
||||
const hasProgram = !!programId;
|
||||
|
||||
// Info-Bar
|
||||
if (hasProgram) {
|
||||
elActiveBar.style.display = 'flex';
|
||||
elAiId.textContent = programId;
|
||||
elAiCount.textContent = lineCount;
|
||||
elAiCursor.textContent = cursor;
|
||||
elAiVersion.textContent = version;
|
||||
} else {
|
||||
elActiveBar.style.display = 'none';
|
||||
}
|
||||
|
||||
// Stepping-Buttons
|
||||
const hasLines = lineCount > 0;
|
||||
elBtnFirst.disabled = !hasProgram || !hasLines || cursor === 0;
|
||||
elBtnPrev.disabled = !hasProgram || !hasLines || cursor === 0;
|
||||
elBtnNext.disabled = !hasProgram || !hasLines || cursor >= lineCount - 1;
|
||||
elBtnLast.disabled = !hasProgram || !hasLines || cursor >= lineCount - 1;
|
||||
elBtnClear.disabled = !hasProgram;
|
||||
|
||||
// Download-Link
|
||||
if (programId) {
|
||||
elBtnDownload.href = `/api/programs/${encodeURIComponent(programId)}/download`;
|
||||
elBtnDownload.download = `${programId}.gcode`;
|
||||
elBtnDownload.style.display = '';
|
||||
} else {
|
||||
elBtnDownload.style.display = 'none';
|
||||
}
|
||||
|
||||
// Tabelle
|
||||
if (!lines.length) {
|
||||
elLinesBody.innerHTML = `<tr><td colspan="3" class="empty-hint" style="padding:10px">${
|
||||
hasProgram ? 'Programm ist leer' : 'Kein aktives Programm — links ein Programm anklicken'
|
||||
}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
elLinesBody.innerHTML = lines.map((line, i) => {
|
||||
const { code, ts } = parseLine(line);
|
||||
const isCursor = i === cursor;
|
||||
return `<tr class="${isCursor ? 'cursor-row' : ''}">
|
||||
<td class="idx">${i}</td>
|
||||
<td class="line">${isCursor ? '▶ ' : ''}${esc(code)}</td>
|
||||
<td class="ts">${esc(ts)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Zur Cursor-Zeile scrollen
|
||||
const cursorRow = elLinesBody.querySelector('.cursor-row');
|
||||
if (cursorRow) cursorRow.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// ─── Daten laden ─────────────────────────────────────────────────────────────
|
||||
async function refresh() {
|
||||
try {
|
||||
const [programs, active] = await Promise.all([
|
||||
apiFetch('GET', '/api/programs'),
|
||||
apiFetch('GET', '/api/active'),
|
||||
]);
|
||||
renderProgList(programs.programs || [], active.programId);
|
||||
renderLines(active);
|
||||
setStatus(elListStatus, '');
|
||||
setStatus(elActStatus, '');
|
||||
} catch (err) {
|
||||
setStatus(elListStatus, `Fehler: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Programm laden (FLoad-Äquivalent) ───────────────────────────────────────
|
||||
async function loadProgram(id) {
|
||||
setStatus(elActStatus, `Lade '${id}'…`);
|
||||
try {
|
||||
const state = await apiFetch('PUT', '/api/active', { id });
|
||||
renderLines(state);
|
||||
await refresh();
|
||||
setStatus(elActStatus, `'${id}' geladen`);
|
||||
} catch (err) {
|
||||
setStatus(elActStatus, `Fehler: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Programm löschen ────────────────────────────────────────────────────────
|
||||
async function deleteProgram(id) {
|
||||
if (!confirm(`Programm '${id}' wirklich löschen?`)) return;
|
||||
setStatus(elListStatus, `Lösche '${id}'…`);
|
||||
try {
|
||||
await apiFetch('DELETE', `/api/programs/${encodeURIComponent(id)}`);
|
||||
setStatus(elListStatus, `'${id}' gelöscht`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setStatus(elListStatus, `Fehler: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stepping ────────────────────────────────────────────────────────────────
|
||||
async function step(endpoint) {
|
||||
setStatus(elActStatus, '…');
|
||||
try {
|
||||
const r = await apiFetch('POST', `/api/active/${endpoint}`);
|
||||
setStatus(elActStatus, `Cursor → ${r.cursor}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setStatus(elActStatus, `Fehler: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Clear ───────────────────────────────────────────────────────────────────
|
||||
async function clearProgram() {
|
||||
if (!currentActiveId) return;
|
||||
if (!confirm(`Programm '${currentActiveId}' wirklich leeren?`)) return;
|
||||
try {
|
||||
const r = await apiFetch('POST', '/api/active/clear');
|
||||
renderLines(r);
|
||||
setStatus(elActStatus, 'Programm geleert');
|
||||
} catch (err) {
|
||||
setStatus(elActStatus, `Fehler: ${err.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event-Listener ──────────────────────────────────────────────────────────
|
||||
document.getElementById('btn-refresh').addEventListener('click', refresh);
|
||||
elBtnFirst.addEventListener('click', () => step('first'));
|
||||
elBtnPrev .addEventListener('click', () => step('prev'));
|
||||
elBtnNext .addEventListener('click', () => step('next'));
|
||||
elBtnLast .addEventListener('click', () => step('last'));
|
||||
elBtnClear.addEventListener('click', clearProgram);
|
||||
|
||||
// Collapse-Verhalten (wie im appRobotHoming)
|
||||
document.querySelectorAll('.section h2').forEach(h2 => {
|
||||
h2.addEventListener('click', () => h2.closest('.section').classList.toggle('collapsed'));
|
||||
});
|
||||
|
||||
// ─── HTML escapen ────────────────────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ─── Start ───────────────────────────────────────────────────────────────────
|
||||
refresh();
|
||||
setInterval(refresh, REFRESH_MS);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -30,6 +30,22 @@ router.get(
|
||||
})
|
||||
);
|
||||
|
||||
// GET /api/programs/:id/download — rohe .gcode-Datei als Download
|
||||
router.get(
|
||||
'/:id/download',
|
||||
asyncH(async (req, res) => {
|
||||
const id = req.params.id;
|
||||
const prog = await store.read(id); // wirft 404 wenn nicht vorhanden
|
||||
const filePath = store.gcodePath(id);
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${id}.gcode"`);
|
||||
// sendFile braucht absoluten Pfad — wir haben ihn direkt aus dem Store
|
||||
const fsp = require('fs/promises');
|
||||
const body = await fsp.readFile(filePath, 'utf8');
|
||||
res.send(body);
|
||||
})
|
||||
);
|
||||
|
||||
// POST /api/programs (FSave) — aus aktivem Puffer ODER expliziter Inhalt.
|
||||
router.post(
|
||||
'/',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Express-App der appRobotFileservice. createApp() ist test-freundlich (kein listen).
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const programsRouter = require('./routes/programs');
|
||||
const activeRouter = require('./routes/active');
|
||||
@@ -25,6 +26,9 @@ function createApp() {
|
||||
app.use('/api/programs', programsRouter);
|
||||
app.use('/api/active', activeRouter);
|
||||
|
||||
// Web-UI: statische Dateien aus public/ (index.html, index.css)
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// Unbekannter Pfad → 404-Envelope
|
||||
app.use((req, res) => res.status(404).json(envelope('NOT_FOUND', 'unknown endpoint', req.path)));
|
||||
app.use(errorMiddleware);
|
||||
|
||||
Reference in New Issue
Block a user