Erste Version

main
Samuel Zielke 1 week ago
commit 0a7b27e4f2

14
.gitignore vendored

@ -0,0 +1,14 @@
# Geheimnisse und lokale Konfiguration
server/.env
*.pem
*.key
ZUGANGSDATEN.txt
# Abhängigkeiten und Laufzeitdaten
node_modules/
uploads/
# Gepackte Stände – werden bei Bedarf neu erzeugt
*.tar.gz
.DS_Store

@ -0,0 +1,377 @@
# Monitor-Check
**Bildschirme, Lautsprecher und Übersetzungsanlagen vor Ort per Handy erfassen und prüfen.**
Bei Veranstaltungen mit vielen Monitoren, z. B. in Foyers, Nebenräumen oder
Übertragungssälen, muss vorher jedes Gerät einmal geprüft werden: Kommt Ton?
Kommt Bild? Läuft die richtige Sprache? Monitor-Check macht daraus eine kleine
Web-App fürs Handy. Ihr geht von Gerät zu Gerät, macht ein Foto, tippt drei
Mal auf JA / NEIN / n.A., und die Übersicht zeigt sofort, was funktioniert und
wo jemand nachsehen muss.
```
Handy (Browser)
├── Foto ──────────────► dein Server (Node) ──► /uploads/…jpg
└── Daten ──────────────► Apps-Script Web-App ──► Google Tabelle
```
Die **Daten liegen in einer Google Tabelle**, die ihr selbst besitzt. Dort
könnt ihr filtern, drucken oder teilen. Die **Oberfläche und die Fotos liegen
auf eurem eigenen Server**. Im Repo sind keine Zugangsdaten enthalten: Token,
Tabellen-URL und Domain richtet ihr bei der Installation selbst ein.
### Was das Tool kann
- **Erfassen in Sekunden:** Bezeichnung, Standort, Foto direkt aus der Kamera, Status
- **Automatischer Status** aus den drei Prüfpunkten *Audio*, *Bild* und *Sprache*
- **Übersicht** mit Kacheln, Suche und Statusfilter
- **Fortlaufende IDs** (`MON-001`, `MON-002` …), die nie doppelt vergeben werden
- **Protokoll** jeder Änderung mit Zeit und Person im Blatt `Protokoll`
- **Offline-Warteschlange:** Änderungen ohne Netz werden nachgereicht
- **Als App installierbar** über „Zum Home-Bildschirm“
### So kommt ihr zum Laufen
1. **Google Tabelle** anlegen und das Apps Script einfügen ([Teil 1](#teil-1--google-tabelle-einrichten))
2. **Server** mit `install.sh` aufsetzen ([Teil 2](#teil-2--server-aufsetzen))
3. **Am Handy** die Seite öffnen und im Setup-Tab URL und Token eintragen ([Teil 3](#teil-3--oberfläche-verbinden))
Dafür braucht ihr ein Google-Konto, einen Linux-Server oder LXC-Container
(Ubuntu 22.04/24.04) und eine Domain mit HTTPS. Ohne HTTPS gibt das Handy die
Kamera nicht frei.
### Anpassen
| Was | Wo |
|---|---|
| ID-Präfix (`MON`) und Stellenzahl | `apps-script/Code.gs` → `CONFIG.ID_PREFIX`, `CONFIG.ID_PAD` |
| Namen der Tabellenblätter | `apps-script/Code.gs` → `CONFIG.SHEET_NAME`, `CONFIG.LOG_SHEET_NAME` |
| Statusbezeichnungen | `apps-script/Code.gs` → `STATUS` und gleichlautend in `web/index.html` |
| Zugangsdaten am Gerät vorbelegen | `web/index.html` → `DEFAULTS` (nicht committen, wenn echte Werte drinstehen) |
| Bildgröße, Upload-Limit, Port | `server/.env` (Vorlage: `server/.env.example`) |
## Statusregeln
Jeder Eintrag hat drei Prüfpunkte: **Audio**, **Bild**, **Sprache** – jeweils `JA`, `NEIN` oder `n.A.`
| Eingabe | Ergebnis |
|---|---|
| alle bewerteten Punkte `JA` | **Funktionsfähig** |
| teilweise `NEIN` | **Teilweise funktionsfähig** |
| alle bewerteten Punkte `NEIN` | **Fehlerhaft** |
| alle drei `n.A.` | **Deaktiviert** |
| nichts erfasst | **Nicht geprüft** |
Ein einzelnes `n.A.` schließt nur diesen Punkt aus der Bewertung aus und zählt nie als Fehler. Beispiel: `n.A. / NEIN / NEIN` → **Fehlerhaft** (die zwei bewerteten Punkte sind beide nein), `n.A. / JA / NEIN` → **Teilweise funktionsfähig**.
Die Regeln stehen identisch in `Code.gs` (`berechneStatus`) und in `index.html` – das Sheet bleibt die maßgebliche Quelle, das Frontend zeigt die Vorschau sofort an.
---
## Teil 1 – Google Tabelle einrichten
1. Neue Tabelle auf [sheets.new](https://sheets.new) anlegen, z. B. „Monitore Veranstaltung 2026".
2. **Erweiterungen → Apps Script**. Den Standardinhalt von `Code.gs` löschen und den Inhalt aus `apps-script/Code.gs` einfügen.
3. Ganz oben `CONFIG.API_TOKEN` durch ein langes Zufallstoken ersetzen. Erzeugen z. B. mit:
```bash
openssl rand -hex 24
```
Dieses Token brauchst du gleich noch zweimal (Server und Oberfläche).
4. Speichern. Im Editor die Funktion `testStatusLogik` auswählen und **Ausführen** – beim ersten Mal Zugriff freigeben. Im Ausführungsprotokoll muss „Alle 9 Testfälle bestanden" stehen.
5. Zurück in der Tabelle: Seite neu laden, dann Menü **Monitor-Check → Tabelle einrichten**. Damit entstehen die Blätter `Monitore` und `Protokoll` samt Spaltenbreiten und Dropdowns.
6. Im Apps-Script-Editor: **Bereitstellen → Neue Bereitstellung → Typ: Web-App**
- Beschreibung: `Monitor-Check API`
- **Ausführen als: Ich**
- **Zugriff: Jeder** ← zwingend, sonst antwortet die API mit einer HTML-Anmeldeseite
- Bereitstellen, Berechtigungen bestätigen, die `…/exec`-URL kopieren.
> Nach jeder Änderung an `Code.gs`: **Bereitstellen → Bereitstellungen verwalten → Bearbeiten → Version: Neu → Bereitstellen**. Die URL bleibt dabei gleich.
---
## Teil 2 – Server aufsetzen
**Der schnelle Weg:** Projektordner auf den Server kopieren und `install.sh` laufen lassen – das erledigt alles ab Abschnitt 2.2.
```bash
# auf deinem Rechner (IP anpassen)
scp -r . root@192.168.1.50:/root/monitor-check-quelle
# auf dem Server
cd /root/monitor-check-quelle
sudo bash install.sh
```
Das Skript fragt nach Domain und E-Mail, erzeugt ein API-Token, installiert Node 20, nginx, den Dienst und holt das Let's-Encrypt-Zertifikat. Am Ende stehen alle Zugangsdaten auf dem Bildschirm und in `/opt/monitor-check/ZUGANGSDATEN.txt`.
Ohne Rückfragen:
```bash
sudo bash install.sh --domain monitore.deine-domain.de --email du@deine-domain.de --yes
```
Nützliche Schalter: `--dry-run` zeigt nur, was passieren würde. `--update` spielt nach Änderungen an `web/` oder `server/` die neuen Dateien ein und startet den Dienst neu. `--no-tls` und `--no-firewall` überspringen Zertifikat bzw. ufw. `--help` listet alles auf.
### Hinter einem vorgeschalteten Reverse-Proxy
Wenn TLS bereits auf einem vorgelagerten nginx liegt, übernimmt der Container nur HTTP:
```bash
sudo bash install.sh --behind-proxy --public-url https://monitore.deine-domain.de --yes
```
Das überspringt certbot, lässt den Container auf Port 80 lauschen und setzt `PUBLIC_URL` trotzdem auf **https** – wichtig, denn diese Adresse landet als Bild-Link in der Tabelle. Bei `http://` blockiert der Browser die Bilder später als Mixed Content.
Am Ende schreibt das Skript eine passende Vorlage nach `/opt/monitor-check/PROXY-SNIPPET.conf`, die du auf dem vorgelagerten Proxy einsetzen kannst. Ein Punkt daraus entscheidet über Erfolg oder Frust:
```nginx
client_max_body_size 20m;
```
Fehlt die Zeile im **äußeren** Proxy, brechen Foto-Uploads mit `413 Request Entity Too Large` ab – der Container selbst ist dann gar nicht schuld.
Der lokale nginx im Container darf auch ganz entfallen; dann hört Node direkt auf `0.0.0.0:8080` und der Proxy zielt dorthin:
```bash
sudo bash install.sh --behind-proxy --no-nginx --public-url https://monitore.deine-domain.de --yes
```
Der Unterschied ist klein: Mit lokalem nginx werden die Fotos direkt von der Platte ausgeliefert statt durch Node. Bei ein paar hundert Bildern spielt das keine Rolle – wähle, was besser in deine Landschaft passt.
Läuft das Skript durch, geht es direkt bei **Teil 3** weiter. Die Abschnitte 2.2 bis 2.7 beschreiben dieselben Schritte von Hand – nützlich zum Nachvollziehen oder Reparieren.
### 2.1 Container erstellen (auf dem Proxmox-Host)
Getestet mit Ubuntu 22.04/24.04 in einem unprivilegierten LXC-Container (Proxmox). Alle Befehle als `root` im Container.
```bash
pct create 120 local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \
--hostname monitor-check \
--cores 2 --memory 1024 --swap 512 \
--rootfs local-lvm:8 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--features nesting=1 \
--unprivileged 1 \
--onboot 1
pct start 120
pct enter 120
```
Ein bestehender Container tut es genauso – dann direkt bei 2.2 weiter.
### 2.2 Grundpakete
```bash
apt update && apt -y upgrade
apt -y install curl git nginx ca-certificates
# Node.js 20 LTS
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt -y install nodejs
node -v # sollte v20.x zeigen
```
### 2.3 Benutzer und Verzeichnisse
```bash
adduser --system --group --home /opt/monitor-check monitor
mkdir -p /opt/monitor-check /var/lib/monitor-check/uploads
```
### 2.4 Dateien einspielen
Ordner `server/` und `web/` aus diesem Projekt nach `/opt/monitor-check/` kopieren – z. B. vom Arbeitsrechner aus:
```bash
# auf deinem Rechner, IP anpassen
scp -r server web root@192.168.1.50:/opt/monitor-check/
```
Dann im Container:
```bash
cd /opt/monitor-check/server
npm install --omit=dev
```
`sharp` bringt fertige Binärpakete mit; sollte der Build doch starten, hilft `apt -y install build-essential python3`.
### 2.5 Konfiguration
```bash
cd /opt/monitor-check/server
cp .env.example .env
nano .env
```
Anzupassen:
```ini
PORT=8080
HOST=127.0.0.1
API_TOKEN=<dasselbe Token wie in Code.gs>
PUBLIC_URL=https://monitore.deine-domain.de
UPLOAD_DIR=/var/lib/monitor-check/uploads
WEB_DIR=/opt/monitor-check/web
ALLOWED_ORIGINS=https://monitore.deine-domain.de
```
`PUBLIC_URL` ist wichtig: genau diese Adresse landet als Bild-Link in der Tabelle.
```bash
chown -R monitor:monitor /opt/monitor-check /var/lib/monitor-check
chmod 600 /opt/monitor-check/server/.env
```
### 2.6 Als Dienst starten
```bash
cp /opt/monitor-check/server/monitor-check.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now monitor-check
systemctl status monitor-check --no-pager
curl -s localhost:8080/gesundheit # {"ok":true,…}
```
Logs mitlesen: `journalctl -u monitor-check -f`
### 2.7 nginx und HTTPS
```bash
cp /opt/monitor-check/server/nginx-monitor-check.conf /etc/nginx/sites-available/monitor-check
sed -i 's/monitore.example.org/monitore.deine-domain.de/' /etc/nginx/sites-available/monitor-check
ln -sf ../sites-available/monitor-check /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
# Zertifikat (DNS muss vorher auf den Server zeigen)
apt -y install certbot python3-certbot-nginx
certbot --nginx -d monitore.deine-domain.de
```
**HTTPS ist Pflicht.** Ohne TLS gibt die Handy-Kamera im Browser kein Bild frei und `fetch` zu Google wird von der Seite aus blockiert.
Optional Firewall:
```bash
apt -y install ufw
ufw allow 22,80,443/tcp && ufw --force enable
```
---
## Teil 3 – Oberfläche verbinden
`https://monitore.deine-domain.de` am Handy öffnen. Beim ersten Start landest du im Tab **Setup**:
| Feld | Wert |
|---|---|
| Apps-Script Web-App URL | die `…/exec`-URL aus Schritt 1.6 |
| API-Token | dasselbe Token wie in `Code.gs` und `.env` |
| Upload-Endpoint | `https://monitore.deine-domain.de/api/upload` |
| Dein Name | wird je Prüfung als „Prüfer" gespeichert |
**Speichern**, dann **Verbindung testen** – die Meldung „Verbindung ok" bestätigt Schritt 1 und 2.
Die Werte liegen im `localStorage` des Browsers, also pro Gerät einmal eintragen. Willst du das vorbelegen, trage sie in `web/index.html` oben im Block `DEFAULTS` ein und lade die Datei neu auf den Server.
Auf dem iPhone: Teilen → **Zum Home-Bildschirm**. Danach startet das Tool wie eine App im Vollbild.
---
## Bedienung
**Erfassen** – Bezeichnung eingeben, optional Standort, aufs Fotofeld tippen (öffnet direkt die Kamera). Der Status lässt sich sofort mitsetzen oder später nachtragen. Die ID vergibt das Skript automatisch: `MON-001`, `MON-002`, …
**Übersicht** – Kacheln zeigen die Verteilung, darunter Suche und Statusfilter. Ein Tippen auf einen Eintrag öffnet die Detailansicht mit den drei Schaltern. Erneutes Tippen auf einen bereits gewählten Wert hebt die Auswahl wieder auf.
**Löschen** – In der Detailansicht ganz unten „Eintrag löschen", nach Rückfrage verschwindet die Zeile aus der Tabelle. Alternativ die Zeile direkt im Sheet löschen. Vergebene IDs werden dabei nie erneut vergeben, auch nicht die des zuletzt angelegten Eintrags – der Zähler steht in den Skript-Eigenschaften. Das zugehörige Foto bleibt auf dem Server liegen; siehe „Wartung" zum Aufräumen.
**Offline** – Bei Verbindungsabbruch wandern Neuanlagen und Statusmeldungen in eine lokale Warteschlange (Anzeige oben rechts) und gehen automatisch raus, sobald das Netz zurück ist. Manuell auslösen im Setup-Tab. Fotos brauchen dagegen eine Verbindung – ohne Netz den Eintrag ohne Bild anlegen und das Foto später über die Tabelle nachtragen.
---
## Tabellenstruktur
Blatt `Monitore`:
| Spalte | Inhalt |
|---|---|
| A ID | `MON-001` fortlaufend |
| B Bezeichnung | Freitext |
| C Standort | Freitext |
| D Bild-URL | Link auf dein `/uploads/…` |
| E Audio / F Bild / G Sprache | `JA` / `NEIN` / `n.A.` |
| H Status | automatisch berechnet, Zeile wird eingefärbt |
| I Notiz | Freitext |
| J Erfasst am, K Geprüft am, L Geprüft von | Zeitstempel und Person |
Blatt `Protokoll` schreibt jede Änderung mit – Zeitpunkt, ID, Aktion, Status, Person.
Direkt in der Tabelle bearbeitete Werte werden nicht automatisch neu bewertet. Dafür gibt es **Monitor-Check → Alle Status neu berechnen**.
Bilder als Vorschau in der Tabelle: eine leere Spalte ergänzen und dort
`=BILD(D2)` eintragen.
---
## Wartung
```bash
# Dienst neu starten (nach Änderungen an server.js oder .env)
systemctl restart monitor-check
# Oberfläche und Server aktualisieren – neue Dateien hochladen, dann
sudo bash install.sh --update
# Browser-Cache: Seite mit gedrücktem Reload neu laden
# Platzbedarf der Fotos
du -sh /var/lib/monitor-check/uploads
# Sicherung der Bilder
tar czf uploads-$(date +%F).tar.gz -C /var/lib/monitor-check uploads
```
Verwaiste Fotos gelöschter Einträge aufräumen: Spalte D des Sheets als CSV exportieren, auf den Server legen und abgleichen.
```bash
# behaelt nur Dateien, die noch in der Tabelle verlinkt sind
cd /var/lib/monitor-check/uploads
grep -o '[^/]*\.jpg' /root/bild-urls.csv | sort -u > /tmp/benutzt.txt
ls *.jpg | sort > /tmp/vorhanden.txt
comm -23 /tmp/vorhanden.txt /tmp/benutzt.txt # erst anschauen
comm -23 /tmp/vorhanden.txt /tmp/benutzt.txt | xargs -r rm -- # dann loeschen
```
Bilder werden auf max. 1600 px verkleinert und als JPEG (Qualität 78) gespeichert – rund 150–400 kB pro Foto. 1000 Einträge belegen also etwa 300 MB. Anpassbar über `MAX_IMAGE_PX` und `JPEG_QUALITY` in der `.env`.
## Fehlersuche
| Symptom | Ursache |
|---|---|
| „Unerwartete Antwort – Web-App-Zugriff auf ‚Jeder' gesetzt?" | Bereitstellung steht auf „Nur ich". In den Bereitstellungen auf **Jeder** ändern. |
| „Nicht autorisiert" | Token stimmt nicht überein. Muss in `Code.gs`, `.env` und im Setup-Tab identisch sein. |
| Änderungen an `Code.gs` wirken nicht | Neue **Version** bereitstellen, nicht nur speichern. |
| Kamera öffnet nicht | Seite läuft über `http` statt `https`. |
| Upload bricht bei großen Fotos ab | `client_max_body_size` in **jedem** nginx davor und `MAX_UPLOAD_MB` in der `.env` erhöhen. |
| Bilder erscheinen nicht, Konsole meldet Mixed Content | `PUBLIC_URL` steht auf `http://`, die Seite läuft über `https://`. In der `.env` korrigieren, Dienst neu starten. Bereits gespeicherte Links im Sheet per Suchen-und-Ersetzen anpassen. |
| Bild-Link im Sheet zeigt `127.0.0.1` | `PUBLIC_URL` in der `.env` setzen und Dienst neu starten. |
| Nichts geht mehr | `journalctl -u monitor-check -n 50 --no-pager` |
## Sicherheit
Das Token schützt die Schnittstelle gegen zufällige Zugriffe, ist aber kein Login – wer die URL und das Token hat, kann schreiben. Für eine interne Veranstaltung reicht das. Soll es enger sein, lege in nginx zusätzlich eine Basic-Auth über `location /` (nicht über `/api/upload`, sonst scheitert der Upload aus der Seite heraus) oder beschränke den Vhost auf das Veranstaltungs-Netz per `allow`/`deny`.
---
## Dateien
| Datei | Zweck |
|---|---|
| `install.sh` | richtet den Server komplett ein (Ubuntu 22.04/24.04) |
| `apps-script/Code.gs` | Backend an der Google Tabelle, JSON-API, Statusberechnung |
| `web/index.html` | die komplette Oberfläche in einer Datei |
| `server/server.js` | Foto-Upload und Auslieferung der Oberfläche |
| `server/.env.example` | Konfigurationsvorlage, wird zur `.env` (nicht im Repo) |
| `server/monitor-check.service` | systemd-Unit |
| `server/nginx-monitor-check.conf` | nginx-Vhost mit TLS-Vorbereitung |

@ -0,0 +1,452 @@
/**
* Monitor-Check – Google Apps Script Backend
* ------------------------------------------
* Stellt eine JSON-API fuer das Web-Tool bereit.
* Deployment: Bereitstellen > Neue Bereitstellung > Web-App
* Ausfuehren als: Ich
* Zugriff: Jeder
*
* WICHTIG: API_TOKEN unten aendern und identisch im Frontend eintragen.
*/
var CONFIG = {
SHEET_NAME: 'Monitore',
LOG_SHEET_NAME: 'Protokoll',
API_TOKEN: 'BITTE-AENDERN-langes-zufaelliges-token',
ID_PREFIX: 'MON',
ID_PAD: 3
};
var HEADERS = [
'ID',
'Bezeichnung',
'Standort',
'Bild-URL',
'Audio',
'Bild',
'Sprache',
'Status',
'Notiz',
'Erfasst am',
'Geprueft am',
'Geprueft von'
];
var COL = {
ID: 0, BEZEICHNUNG: 1, STANDORT: 2, BILD_URL: 3,
AUDIO: 4, BILD: 5, SPRACHE: 6, STATUS: 7,
NOTIZ: 8, ERFASST: 9, GEPRUEFT: 10, PRUEFER: 11
};
/* ------------------------------------------------------------------ */
/* Status-Logik */
/* ------------------------------------------------------------------ */
var STATUS = {
OK: 'Funktionsfähig',
TEIL: 'Teilweise funktionsfähig',
FEHLER: 'Fehlerhaft',
AUS: 'Deaktiviert',
OFFEN: 'Nicht geprüft'
};
/**
* Regeln (audio/bild/sprache jeweils 'JA' | 'NEIN' | 'n.A.' | '')
* - Felder mit 'n.A.' werden aus der Bewertung ausgeschlossen.
* - Alle drei 'n.A.' -> Deaktiviert
* - Kein Feld gesetzt -> Nicht geprueft
* - Alle bewerteten Felder JA -> Funktionsfaehig
* - Alle bewerteten Felder NEIN -> Fehlerhaft
* - Gemischt -> Teilweise funktionsfaehig
*/
function berechneStatus(audio, bild, sprache) {
var werte = [norm_(audio), norm_(bild), norm_(sprache)];
var gesetzt = werte.filter(function (w) { return w !== ''; });
if (gesetzt.length === 0) return STATUS.OFFEN;
var na = werte.filter(function (w) { return w === 'n.A.'; }).length;
if (na === 3) return STATUS.AUS;
var bewertet = werte.filter(function (w) { return w === 'JA' || w === 'NEIN'; });
if (bewertet.length === 0) return STATUS.AUS;
var ja = bewertet.filter(function (w) { return w === 'JA'; }).length;
var nein = bewertet.filter(function (w) { return w === 'NEIN'; }).length;
if (nein === 0) return STATUS.OK;
if (ja === 0) return STATUS.FEHLER;
return STATUS.TEIL;
}
/**
* Vereinheitlicht Statustexte. Aeltere Zeilen wurden ohne Umlaute geschrieben
* ("Funktionsfaehig"); beide Schreibweisen ergeben denselben Status.
*/
function statusKanon_(v) {
var k = String(v == null ? '' : v).toLowerCase().trim()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss');
if (k === 'funktionsfaehig') return STATUS.OK;
if (k === 'teilweise funktionsfaehig') return STATUS.TEIL;
if (k === 'fehlerhaft') return STATUS.FEHLER;
if (k === 'deaktiviert') return STATUS.AUS;
return STATUS.OFFEN;
}
function norm_(v) {
if (v === null || v === undefined) return '';
var s = String(v).trim().toUpperCase();
if (s === '') return '';
if (s === 'JA' || s === 'J' || s === 'YES' || s === 'TRUE') return 'JA';
if (s === 'NEIN' || s === 'N' || s === 'NO' || s === 'FALSE') return 'NEIN';
if (s === 'N.A.' || s === 'NA' || s === 'N/A' || s === 'N.A') return 'n.A.';
return '';
}
/* ------------------------------------------------------------------ */
/* HTTP-Einstieg */
/* ------------------------------------------------------------------ */
function doGet(e) {
return handle_(e, (e && e.parameter) ? e.parameter : {});
}
function doPost(e) {
var payload = {};
try {
if (e && e.postData && e.postData.contents) {
payload = JSON.parse(e.postData.contents);
}
} catch (err) {
return json_({ ok: false, error: 'Ungueltiges JSON' });
}
return handle_(e, payload);
}
function handle_(e, p) {
try {
if (String(p.token || '') !== CONFIG.API_TOKEN) {
return json_({ ok: false, error: 'Nicht autorisiert' });
}
switch (String(p.action || '')) {
case 'ping': return json_({ ok: true, version: 1, time: new Date().toISOString() });
case 'list': return json_({ ok: true, items: listeEintraege_() });
case 'create': return json_(erstelleEintrag_(p));
case 'status': return json_(setzeStatus_(p));
case 'update': return json_(aktualisiereEintrag_(p));
case 'archive': return json_(archiviereEintrag_(p));
case 'delete': return json_(loescheEintrag_(p));
default: return json_({ ok: false, error: 'Unbekannte Aktion' });
}
} catch (err) {
return json_({ ok: false, error: String(err && err.message ? err.message : err) });
}
}
function json_(obj) {
return ContentService
.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
/* ------------------------------------------------------------------ */
/* Sheet-Zugriff */
/* ------------------------------------------------------------------ */
function sheet_() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sh) {
sh = ss.insertSheet(CONFIG.SHEET_NAME);
}
if (sh.getLastRow() === 0 || String(sh.getRange(1, 1).getValue()).trim() === '') {
sh.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold');
sh.setFrozenRows(1);
}
return sh;
}
function daten_() {
var sh = sheet_();
var last = sh.getLastRow();
if (last < 2) return [];
return sh.getRange(2, 1, last - 1, HEADERS.length).getValues();
}
function zeileFuerId_(id) {
var rows = daten_();
for (var i = 0; i < rows.length; i++) {
if (String(rows[i][COL.ID]).trim() === String(id).trim()) {
return { row: i + 2, values: rows[i] };
}
}
return null;
}
function listeEintraege_() {
return daten_()
.filter(function (r) { return String(r[COL.ID]).trim() !== ''; })
.map(function (r) {
return {
id: String(r[COL.ID]),
bezeichnung: String(r[COL.BEZEICHNUNG] || ''),
standort: String(r[COL.STANDORT] || ''),
bildUrl: String(r[COL.BILD_URL] || ''),
audio: norm_(r[COL.AUDIO]),
bild: norm_(r[COL.BILD]),
sprache: norm_(r[COL.SPRACHE]),
status: statusKanon_(r[COL.STATUS]),
notiz: String(r[COL.NOTIZ] || ''),
erfasstAm: iso_(r[COL.ERFASST]),
geprueftAm: iso_(r[COL.GEPRUEFT]),
pruefer: String(r[COL.PRUEFER] || '')
};
});
}
function iso_(v) {
if (!v) return '';
if (v instanceof Date) return v.toISOString();
return String(v);
}
/* ------------------------------------------------------------------ */
/* Aktionen */
/* ------------------------------------------------------------------ */
function erstelleEintrag_(p) {
var bez = String(p.bezeichnung || '').trim();
if (!bez) return { ok: false, error: 'Bezeichnung fehlt' };
var lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
var sh = sheet_();
var id = naechsteId_(sh);
var jetzt = new Date();
var audio = norm_(p.audio);
var bild = norm_(p.bild);
var sprache = norm_(p.sprache);
var status = berechneStatus(audio, bild, sprache);
var geprueft = status === STATUS.OFFEN ? '' : jetzt;
var zeile = [];
zeile[COL.ID] = id;
zeile[COL.BEZEICHNUNG] = bez;
zeile[COL.STANDORT] = String(p.standort || '').trim();
zeile[COL.BILD_URL] = String(p.bildUrl || '').trim();
zeile[COL.AUDIO] = audio;
zeile[COL.BILD] = bild;
zeile[COL.SPRACHE] = sprache;
zeile[COL.STATUS] = status;
zeile[COL.NOTIZ] = String(p.notiz || '').trim();
zeile[COL.ERFASST] = jetzt;
zeile[COL.GEPRUEFT] = geprueft;
zeile[COL.PRUEFER] = String(p.pruefer || '').trim();
sh.appendRow(zeile);
faerbeZeile_(sh, sh.getLastRow(), status);
protokolliere_(id, 'angelegt', status, p.pruefer);
return { ok: true, id: id, status: status };
} finally {
lock.releaseLock();
}
}
/**
* Naechste freie ID. Der hoechste je vergebene Zaehler wird zusaetzlich in den
* Skript-Eigenschaften gemerkt, damit IDs geloeschter Eintraege nie erneut
* vergeben werden.
*/
function naechsteId_(sh) {
var props = PropertiesService.getScriptProperties();
var max = parseInt(props.getProperty('lastId') || '0', 10);
if (isNaN(max)) max = 0;
var rows = daten_();
var re = new RegExp('^' + CONFIG.ID_PREFIX + '-(\\d+)$');
for (var i = 0; i < rows.length; i++) {
var m = re.exec(String(rows[i][COL.ID]).trim());
if (m) {
var n = parseInt(m[1], 10);
if (n > max) max = n;
}
}
var nummer = max + 1;
props.setProperty('lastId', String(nummer));
var next = String(nummer);
while (next.length < CONFIG.ID_PAD) next = '0' + next;
return CONFIG.ID_PREFIX + '-' + next;
}
function setzeStatus_(p) {
var treffer = zeileFuerId_(p.id);
if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id };
var sh = sheet_();
var audio = norm_(p.audio);
var bild = norm_(p.bild);
var sprache = norm_(p.sprache);
var status = berechneStatus(audio, bild, sprache);
var jetzt = new Date();
sh.getRange(treffer.row, COL.AUDIO + 1, 1, 4)
.setValues([[audio, bild, sprache, status]]);
if (p.notiz !== undefined) {
sh.getRange(treffer.row, COL.NOTIZ + 1).setValue(String(p.notiz || ''));
}
sh.getRange(treffer.row, COL.GEPRUEFT + 1, 1, 2)
.setValues([[jetzt, String(p.pruefer || '')]]);
faerbeZeile_(sh, treffer.row, status);
protokolliere_(p.id, 'Status gesetzt', status, p.pruefer);
return { ok: true, id: String(p.id), status: status, geprueftAm: jetzt.toISOString() };
}
function aktualisiereEintrag_(p) {
var treffer = zeileFuerId_(p.id);
if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id };
var sh = sheet_();
if (p.bezeichnung !== undefined) sh.getRange(treffer.row, COL.BEZEICHNUNG + 1).setValue(String(p.bezeichnung));
if (p.standort !== undefined) sh.getRange(treffer.row, COL.STANDORT + 1).setValue(String(p.standort));
if (p.bildUrl !== undefined) sh.getRange(treffer.row, COL.BILD_URL + 1).setValue(String(p.bildUrl));
if (p.notiz !== undefined) sh.getRange(treffer.row, COL.NOTIZ + 1).setValue(String(p.notiz));
protokolliere_(p.id, 'bearbeitet', '', p.pruefer);
return { ok: true, id: String(p.id) };
}
function archiviereEintrag_(p) {
var treffer = zeileFuerId_(p.id);
if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id };
var sh = sheet_();
sh.getRange(treffer.row, COL.AUDIO + 1, 1, 4).setValues([['n.A.', 'n.A.', 'n.A.', STATUS.AUS]]);
faerbeZeile_(sh, treffer.row, STATUS.AUS);
protokolliere_(p.id, 'deaktiviert', STATUS.AUS, p.pruefer);
return { ok: true, id: String(p.id), status: STATUS.AUS };
}
function loescheEintrag_(p) {
var treffer = zeileFuerId_(p.id);
if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id };
var lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
// Zeile koennte sich zwischenzeitlich verschoben haben – erneut suchen.
treffer = zeileFuerId_(p.id);
if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id };
var bez = String(treffer.values[COL.BEZEICHNUNG] || '');
var bild = String(treffer.values[COL.BILD_URL] || '');
sheet_().deleteRow(treffer.row);
protokolliere_(p.id, 'geloescht (' + bez + ')', bild, p.pruefer);
return { ok: true, id: String(p.id) };
} finally {
lock.releaseLock();
}
}
/* ------------------------------------------------------------------ */
/* Darstellung + Protokoll */
/* ------------------------------------------------------------------ */
function faerbeZeile_(sh, row, status) {
var farben = {};
farben[STATUS.OK] = '#e6f4ea';
farben[STATUS.TEIL] = '#fef7e0';
farben[STATUS.FEHLER] = '#fce8e6';
farben[STATUS.AUS] = '#f1f3f4';
farben[STATUS.OFFEN] = '#ffffff';
sh.getRange(row, 1, 1, HEADERS.length).setBackground(farben[status] || '#ffffff');
}
function protokolliere_(id, aktion, status, pruefer) {
try {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var log = ss.getSheetByName(CONFIG.LOG_SHEET_NAME);
if (!log) {
log = ss.insertSheet(CONFIG.LOG_SHEET_NAME);
log.appendRow(['Zeitpunkt', 'ID', 'Aktion', 'Status', 'Person']);
log.getRange(1, 1, 1, 5).setFontWeight('bold');
log.setFrozenRows(1);
}
log.appendRow([new Date(), String(id || ''), String(aktion || ''), String(status || ''), String(pruefer || '')]);
} catch (err) {
// Protokoll ist optional – Fehler hier duerfen die Aktion nicht abbrechen.
}
}
/* ------------------------------------------------------------------ */
/* Einmalige Einrichtung / Menue */
/* ------------------------------------------------------------------ */
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Monitor-Check')
.addItem('Tabelle einrichten', 'einrichten')
.addItem('Alle Status neu berechnen', 'statusNeuBerechnen')
.addToUi();
}
function einrichten() {
var sh = sheet_();
sh.setColumnWidth(COL.ID + 1, 90);
sh.setColumnWidth(COL.BEZEICHNUNG + 1, 200);
sh.setColumnWidth(COL.STANDORT + 1, 160);
sh.setColumnWidth(COL.BILD_URL + 1, 260);
sh.setColumnWidth(COL.STATUS + 1, 190);
sh.setColumnWidth(COL.NOTIZ + 1, 260);
var jn = SpreadsheetApp.newDataValidation()
.requireValueInList(['JA', 'NEIN', 'n.A.'], true)
.setAllowInvalid(false)
.build();
sh.getRange(2, COL.AUDIO + 1, 1000, 3).setDataValidation(jn);
protokolliere_('', 'Tabelle eingerichtet', '', Session.getActiveUser().getEmail());
SpreadsheetApp.getUi().alert('Tabelle "' + CONFIG.SHEET_NAME + '" ist eingerichtet.');
}
function statusNeuBerechnen() {
var sh = sheet_();
var rows = daten_();
for (var i = 0; i < rows.length; i++) {
if (String(rows[i][COL.ID]).trim() === '') continue;
var s = berechneStatus(rows[i][COL.AUDIO], rows[i][COL.BILD], rows[i][COL.SPRACHE]);
sh.getRange(i + 2, COL.STATUS + 1).setValue(s);
faerbeZeile_(sh, i + 2, s);
}
}
/* ------------------------------------------------------------------ */
/* Selbsttest (im Editor ausfuehrbar) */
/* ------------------------------------------------------------------ */
function testStatusLogik() {
var faelle = [
[['JA', 'JA', 'JA'], STATUS.OK],
[['NEIN', 'NEIN', 'NEIN'], STATUS.FEHLER],
[['JA', 'NEIN', 'JA'], STATUS.TEIL],
[['JA', 'NEIN', 'NEIN'], STATUS.TEIL],
[['n.A.', 'n.A.', 'n.A.'], STATUS.AUS],
[['n.A.', 'JA', 'JA'], STATUS.OK],
[['n.A.', 'NEIN', 'NEIN'], STATUS.FEHLER],
[['n.A.', 'JA', 'NEIN'], STATUS.TEIL],
[['', '', ''], STATUS.OFFEN]
];
var fehler = [];
faelle.forEach(function (f) {
var ist = berechneStatus(f[0][0], f[0][1], f[0][2]);
if (ist !== f[1]) fehler.push(f[0].join('/') + ' -> ' + ist + ' (erwartet ' + f[1] + ')');
});
if (fehler.length) throw new Error('Fehlgeschlagen:\n' + fehler.join('\n'));
Logger.log('Alle ' + faelle.length + ' Testfaelle bestanden.');
}

@ -0,0 +1,519 @@
#!/usr/bin/env bash
#
# Monitor-Check – Installationsskript fuer Ubuntu 24.04 (funktioniert auch auf 22.04)
#
# sudo bash install.sh # gefuehrte Installation
# sudo bash install.sh --domain m.example.de --email ich@example.de --yes
# sudo bash install.sh --update # nur Dateien aktualisieren + Neustart
# sudo bash install.sh --dry-run # nur zeigen, was passieren wuerde
#
# Das Skript erwartet die Ordner server/ und web/ neben sich.
set -euo pipefail
# ------------------------------------------------------------------ Konstanten
APP_NAME="monitor-check"
APP_USER="monitor"
APP_DIR="/opt/${APP_NAME}"
DATA_DIR="/var/lib/${APP_NAME}"
UPLOAD_DIR="${DATA_DIR}/uploads"
SERVICE_FILE="/etc/systemd/system/${APP_NAME}.service"
NGINX_FILE="/etc/nginx/sites-available/${APP_NAME}"
NODE_MAJOR=20
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ------------------------------------------------------------------ Parameter
DOMAIN=""
EMAIL=""
TOKEN=""
PORT="8080"
MAX_UPLOAD_MB="15"
ASSUME_YES=0
DRY_RUN=0
MODE="install"
USE_TLS=1
USE_FIREWALL=1
USE_NGINX=1
PUBLIC_URL_OVERRIDE=""
BEHIND_PROXY=0
# ------------------------------------------------------------------ Ausgabe
if [[ -t 1 ]]; then
B="\033[1m"; DIM="\033[2m"; GRN="\033[32m"; YLW="\033[33m"; RED="\033[31m"; RST="\033[0m"
else
B=""; DIM=""; GRN=""; YLW=""; RED=""; RST=""
fi
schritt() { printf "\n${B}==> %s${RST}\n" "$*"; }
info() { printf " %s\n" "$*"; }
ok() { printf " ${GRN}✓${RST} %s\n" "$*"; }
warn() { printf " ${YLW}!${RST} %s\n" "$*"; }
fehler() { printf "\n${RED}Fehler:${RST} %s\n\n" "$*" >&2; exit 1; }
run() {
if [[ $DRY_RUN -eq 1 ]]; then
printf " ${DIM}[dry-run] %s${RST}\n" "$*"
else
"$@"
fi
}
# schreibt stdin nach $1
# Im Testlauf: mit MC_DRY_OUT=/pfad werden die erzeugten Dateien dort abgelegt.
schreibe() {
local ziel="$1"
if [[ $DRY_RUN -eq 1 ]]; then
printf " ${DIM}[dry-run] schreibe %s${RST}\n" "$ziel"
if [[ -n "${MC_DRY_OUT:-}" ]]; then
mkdir -p "${MC_DRY_OUT}$(dirname "$ziel")"
cat > "${MC_DRY_OUT}${ziel}"
else
cat >/dev/null
fi
else
cat > "$ziel"
fi
}
hilfe() {
sed -n '3,10p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
cat <<'EOF'
Optionen:
--domain <name> Domain, unter der das Tool erreichbar ist
--email <adresse> E-Mail fuer Let's Encrypt
--token <token> API-Token (sonst wird eins erzeugt)
--port <nummer> interner Node-Port (Standard 8080)
--max-upload <mb> maximale Fotogroesse in MB (Standard 15)
--behind-proxy hinter vorgeschaltetem Reverse-Proxy (kein certbot),
zusammen mit --public-url verwenden
--public-url <url> oeffentliche Adresse, z. B. https://monitore.example.de
(landet als Bild-Link in der Tabelle)
--no-nginx kein nginx im Container, Node direkt auf 0.0.0.0:<port>
--no-tls kein Zertifikat anfordern
--no-firewall ufw nicht konfigurieren
--update nur Anwendungsdateien erneuern und Dienst neu starten
--yes keine Rueckfragen
--dry-run nichts veraendern, nur anzeigen
--help diese Hilfe
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="${2:-}"; shift 2 ;;
--email) EMAIL="${2:-}"; shift 2 ;;
--token) TOKEN="${2:-}"; shift 2 ;;
--port) PORT="${2:-}"; shift 2 ;;
--max-upload) MAX_UPLOAD_MB="${2:-}"; shift 2 ;;
--public-url) PUBLIC_URL_OVERRIDE="${2:-}"; shift 2 ;;
--behind-proxy) BEHIND_PROXY=1; USE_TLS=0; shift ;;
--no-nginx) USE_NGINX=0; shift ;;
--no-tls) USE_TLS=0; shift ;;
--no-firewall) USE_FIREWALL=0; shift ;;
--update) MODE="update"; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--help|-h) hilfe ;;
*) fehler "Unbekannte Option: $1 (--help zeigt die Uebersicht)" ;;
esac
done
# ------------------------------------------------------------------ Vorpruefung
[[ $EUID -eq 0 || $DRY_RUN -eq 1 ]] || fehler "Bitte als root ausfuehren: sudo bash install.sh"
[[ -d "${SRC_DIR}/server" && -d "${SRC_DIR}/web" ]] || \
fehler "Ordner server/ und web/ nicht gefunden neben ${BASH_SOURCE[0]}"
[[ -f "${SRC_DIR}/server/server.js" ]] || fehler "server/server.js fehlt."
[[ -f "${SRC_DIR}/web/index.html" ]] || fehler "web/index.html fehlt."
if [[ -r /etc/os-release ]]; then
. /etc/os-release
[[ "${ID:-}" == "ubuntu" || "${ID_LIKE:-}" == *debian* ]] || \
warn "Nicht getestetes System (${PRETTY_NAME:-unbekannt}) – weiter auf eigenes Risiko."
fi
printf "\n${B}Monitor-Check – Installation${RST}\n"
[[ $DRY_RUN -eq 1 ]] && printf "${YLW}Testlauf: es wird nichts veraendert.${RST}\n" || true
# ------------------------------------------------------------------ Update-Modus
if [[ "$MODE" == "update" ]]; then
[[ -d "$APP_DIR" ]] || fehler "${APP_DIR} existiert nicht – zuerst normal installieren."
schritt "Anwendungsdateien erneuern"
run rsync -a --delete --exclude node_modules --exclude .env \
"${SRC_DIR}/server/" "${APP_DIR}/server/" 2>/dev/null || \
run cp -r "${SRC_DIR}/server/." "${APP_DIR}/server/"
run cp -r "${SRC_DIR}/web/." "${APP_DIR}/web/"
run chown -R "${APP_USER}:${APP_USER}" "$APP_DIR"
run bash -c "cd '${APP_DIR}/server' && npm install --omit=dev --no-audit --no-fund"
run systemctl restart "$APP_NAME"
ok "Aktualisiert. Status: systemctl status ${APP_NAME}"
exit 0
fi
# ------------------------------------------------------------------ Abfragen
frage() { # frage <Variable> <Text> <Standard>
local __var="$1" __text="$2" __default="${3:-}" __eingabe=""
local __aktuell="${!__var}"
if [[ -n "$__aktuell" ]]; then return; fi
if [[ $ASSUME_YES -eq 1 || ! -t 0 ]]; then
printf -v "$__var" '%s' "$__default"; return
fi
if [[ -n "$__default" ]]; then
read -r -p " ${__text} [${__default}]: " __eingabe || true
else
read -r -p " ${__text}: " __eingabe || true
fi
printf -v "$__var" '%s' "${__eingabe:-$__default}"
}
schritt "Angaben"
# Token aus bestehender Installation weiterverwenden
if [[ -z "$TOKEN" && -f "${APP_DIR}/server/.env" ]]; then
TOKEN="$(grep -E '^API_TOKEN=' "${APP_DIR}/server/.env" | cut -d= -f2- || true)"
[[ -n "$TOKEN" ]] && ok "Vorhandenes API-Token wird weiterverwendet." || true
fi
if [[ -z "$TOKEN" ]]; then
TOKEN="$(openssl rand -hex 24 2>/dev/null || head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
ok "Neues API-Token erzeugt."
fi
# Hinter einem vorgeschalteten Proxy uebernimmt dieser TLS und Zertifikat.
[[ $USE_NGINX -eq 0 ]] && USE_TLS=0 || true
[[ $BEHIND_PROXY -eq 1 ]] && USE_TLS=0 || true
if [[ -z "$PUBLIC_URL_OVERRIDE" ]]; then
frage DOMAIN "Domain (leer lassen fuer Zugriff nur ueber IP)" ""
fi
if [[ -n "$DOMAIN" && $USE_TLS -eq 1 ]]; then
frage EMAIL "E-Mail fuer Let's Encrypt (leer = kein Zertifikat)" ""
[[ -z "$EMAIL" ]] && USE_TLS=0 || true
else
USE_TLS=0
fi
if [[ -n "$PUBLIC_URL_OVERRIDE" ]]; then
PUBLIC_URL="${PUBLIC_URL_OVERRIDE%/}"
[[ "$PUBLIC_URL" =~ ^https?:// ]] || fehler "--public-url muss mit http:// oder https:// beginnen."
elif [[ -n "$DOMAIN" ]]; then
PUBLIC_URL="$([[ $USE_TLS -eq 1 ]] && echo "https://${DOMAIN}" || echo "http://${DOMAIN}")"
else
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -z "$IP" ]] && IP="127.0.0.1" || true
PUBLIC_URL="http://${IP}"
warn "Ohne Domain laeuft alles ueber http – die Handy-Kamera bleibt dann gesperrt."
fi
# Ohne nginx im Container muss Node von aussen erreichbar sein.
if [[ $USE_NGINX -eq 1 ]]; then BIND_HOST="127.0.0.1"; else BIND_HOST="0.0.0.0"; fi
# Hostname aus der oeffentlichen URL – fuer die Proxy-Vorlage
PUBLIC_HOST="${PUBLIC_URL#*://}"; PUBLIC_HOST="${PUBLIC_HOST%%/*}"
CONTAINER_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -z "$CONTAINER_IP" ]] && CONTAINER_IP="<container-ip>" || true
UPSTREAM="$([[ $USE_NGINX -eq 1 ]] && echo "http://${CONTAINER_IP}:80" || echo "http://${CONTAINER_IP}:${PORT}")"
cat <<EOF
Domain : ${DOMAIN:-<nur IP>}
Oeffentliche URL: ${PUBLIC_URL}
TLS : $([[ $USE_TLS -eq 1 ]] && echo "Let's Encrypt im Container" || echo "nicht im Container (Proxy oder http)")
nginx lokal : $([[ $USE_NGINX -eq 1 ]] && echo "ja, Port 80" || echo "nein, Node direkt")
Node hoert auf: ${BIND_HOST}:${PORT}
Verzeichnisse : ${APP_DIR} , ${DATA_DIR}
API-Token : ${TOKEN}
EOF
if [[ $ASSUME_YES -eq 0 && $DRY_RUN -eq 0 && -t 0 ]]; then
read -r -p " Fortfahren? [J/n] " a || true
[[ "${a:-J}" =~ ^([JjYy]|)$ ]] || { echo " Abgebrochen."; exit 0; }
fi
# ------------------------------------------------------------------ Pakete
schritt "Systempakete installieren"
export DEBIAN_FRONTEND=noninteractive
run apt-get update -qq
if [[ $USE_NGINX -eq 1 ]]; then
run apt-get install -y -qq curl ca-certificates gnupg nginx openssl rsync
else
run apt-get install -y -qq curl ca-certificates gnupg openssl rsync
fi
ok "Basispakete vorhanden."
# Node.js
NODE_OK=0
if command -v node >/dev/null 2>&1; then
NODE_VER="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
[[ "$NODE_VER" -ge 18 ]] && NODE_OK=1 && ok "Node.js $(node -v) bereits installiert." || true
fi
if [[ $NODE_OK -eq 0 ]]; then
info "Node.js ${NODE_MAJOR} LTS wird eingerichtet …"
run bash -c "curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash -"
run apt-get install -y -qq nodejs
ok "Node.js installiert."
fi
# ------------------------------------------------------------------ Benutzer
schritt "Benutzer und Verzeichnisse"
if id "$APP_USER" >/dev/null 2>&1; then
ok "Benutzer ${APP_USER} existiert."
else
run adduser --system --group --home "$APP_DIR" --no-create-home "$APP_USER"
ok "Benutzer ${APP_USER} angelegt."
fi
run mkdir -p "${APP_DIR}/server" "${APP_DIR}/web" "$UPLOAD_DIR"
# ------------------------------------------------------------------ Dateien
schritt "Anwendung kopieren"
run rsync -a --exclude node_modules --exclude .env --exclude uploads \
"${SRC_DIR}/server/" "${APP_DIR}/server/"
run rsync -a "${SRC_DIR}/web/" "${APP_DIR}/web/"
ok "Dateien liegen in ${APP_DIR}."
schritt "Node-Abhaengigkeiten installieren"
info "Das dauert beim ersten Mal ein bis zwei Minuten (sharp)."
run bash -c "cd '${APP_DIR}/server' && npm install --omit=dev --no-audit --no-fund"
ok "Abhaengigkeiten installiert."
# ------------------------------------------------------------------ Konfiguration
schritt "Konfiguration schreiben"
if [[ -f "${APP_DIR}/server/.env" && $DRY_RUN -eq 0 ]]; then
cp "${APP_DIR}/server/.env" "${APP_DIR}/server/.env.bak.$(date +%s)"
info "Bisherige .env gesichert."
fi
schreibe "${APP_DIR}/server/.env" <<EOF
# erzeugt von install.sh am $(date -Is)
PORT=${PORT}
HOST=${BIND_HOST}
API_TOKEN=${TOKEN}
PUBLIC_URL=${PUBLIC_URL}
UPLOAD_DIR=${UPLOAD_DIR}
WEB_DIR=${APP_DIR}/web
MAX_UPLOAD_MB=${MAX_UPLOAD_MB}
MAX_IMAGE_PX=1600
JPEG_QUALITY=78
ALLOWED_ORIGINS=${PUBLIC_URL}
TRUST_PROXY=$([[ $USE_TLS -eq 0 && $USE_NGINX -eq 1 ]] && echo 2 || echo 1)
RATE_LIMIT=300
EOF
run chown -R "${APP_USER}:${APP_USER}" "$APP_DIR" "$DATA_DIR"
run chmod 600 "${APP_DIR}/server/.env"
ok ".env geschrieben."
# ------------------------------------------------------------------ systemd
schritt "Dienst einrichten"
schreibe "$SERVICE_FILE" <<EOF
[Unit]
Description=Monitor-Check Upload- und Web-Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}/server
EnvironmentFile=${APP_DIR}/server/.env
ExecStart=$(command -v node || echo /usr/bin/node) ${APP_DIR}/server/server.js
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=${DATA_DIR}
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${APP_NAME}
[Install]
WantedBy=multi-user.target
EOF
run systemctl daemon-reload
run systemctl enable --now "$APP_NAME"
run systemctl restart "$APP_NAME"
ok "Dienst ${APP_NAME} laeuft."
# ------------------------------------------------------------------ nginx
if [[ $USE_NGINX -eq 1 ]]; then
schritt "nginx einrichten"
SERVER_NAME="${DOMAIN:-_}"
schreibe "$NGINX_FILE" <<EOF
# erzeugt von install.sh
server {
listen 80;
listen [::]:80;
server_name ${SERVER_NAME};
client_max_body_size $((MAX_UPLOAD_MB + 5))m;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:${PORT};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 120s;
}
location /uploads/ {
alias ${UPLOAD_DIR}/;
expires 30d;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
access_log off;
location ~* \\.(php|pl|py|cgi|sh)\$ { deny all; }
}
}
EOF
run ln -sfn "$NGINX_FILE" "/etc/nginx/sites-enabled/${APP_NAME}"
[[ -e /etc/nginx/sites-enabled/default ]] && run rm -f /etc/nginx/sites-enabled/default || true
run nginx -t
run systemctl reload nginx
ok "nginx liefert ${SERVER_NAME} aus."
else
schritt "nginx"
info "uebersprungen – Node bedient ${BIND_HOST}:${PORT} direkt."
fi
# ------------------------------------------------------------------ TLS
if [[ $USE_TLS -eq 1 ]]; then
schritt "Zertifikat anfordern"
run apt-get install -y -qq certbot python3-certbot-nginx
if run certbot --nginx -d "$DOMAIN" --agree-tos -m "$EMAIL" --redirect --non-interactive; then
ok "HTTPS aktiv fuer ${DOMAIN}."
else
warn "certbot ist fehlgeschlagen – zeigt der DNS-Eintrag von ${DOMAIN} schon hierher?"
warn "Spaeter nachholen: certbot --nginx -d ${DOMAIN}"
fi
fi
# ------------------------------------------------------------------ Firewall
if [[ $USE_FIREWALL -eq 1 ]]; then
schritt "Firewall"
run apt-get install -y -qq ufw
run ufw allow 22/tcp
if [[ $USE_NGINX -eq 1 ]]; then
run ufw allow 80/tcp
run ufw allow 443/tcp
PORTS="22, 80, 443"
else
run ufw allow "${PORT}/tcp"
PORTS="22, ${PORT}"
fi
run bash -c "ufw --force enable"
ok "ufw aktiv (${PORTS})."
fi
# ------------------------------------------------------------------ Test
schritt "Funktionstest"
if [[ $DRY_RUN -eq 0 ]]; then
sleep 2
if curl -fsS "http://127.0.0.1:${PORT}/gesundheit" >/dev/null 2>&1; then
ok "Node-Dienst antwortet."
else
warn "Node-Dienst antwortet nicht – pruefen mit: journalctl -u ${APP_NAME} -n 40 --no-pager"
fi
if [[ $USE_NGINX -eq 1 ]]; then
if curl -fsS -o /dev/null "http://127.0.0.1/" 2>/dev/null; then
ok "nginx liefert die Oberflaeche aus."
else
warn "nginx antwortet lokal nicht wie erwartet."
fi
fi
fi
# ------------------------------------------------------------------ Proxy-Vorlage
PROXY_DATEI="${APP_DIR}/PROXY-SNIPPET.conf"
if [[ $USE_TLS -eq 0 ]]; then
schritt "Vorlage fuer den vorgeschalteten Proxy"
schreibe "$PROXY_DATEI" <<EOF
# Fuer den vorgeschalteten Reverse-Proxy (TLS liegt dort).
# Wichtig ist client_max_body_size – sonst brechen Foto-Uploads mit 413 ab.
server {
listen 443 ssl http2;
server_name ${PUBLIC_HOST};
# ssl_certificate ... ;
# ssl_certificate_key ... ;
client_max_body_size $((MAX_UPLOAD_MB + 5))m;
location / {
proxy_pass ${UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 120s;
proxy_request_buffering off;
}
}
EOF
ok "Vorlage liegt in ${PROXY_DATEI} (Upstream: ${UPSTREAM})."
fi
# ------------------------------------------------------------------ Abschluss
ZUSAMMENFASSUNG="${APP_DIR}/ZUGANGSDATEN.txt"
if [[ $DRY_RUN -eq 0 ]]; then
cat > "$ZUSAMMENFASSUNG" <<EOF
Monitor-Check – Zugangsdaten (erzeugt $(date -Is))
Oberflaeche : ${PUBLIC_URL}
Upload-Endpoint : ${PUBLIC_URL}/api/upload
API-Token : ${TOKEN}
Dieses Token muss identisch in apps-script/Code.gs stehen (CONFIG.API_TOKEN)
und im Setup-Tab der Oberflaeche eingetragen werden.
EOF
chmod 600 "$ZUSAMMENFASSUNG"
fi
cat <<EOF
${GRN}${B}Fertig.${RST}
Oberflaeche ${B}${PUBLIC_URL}${RST}
Upload-Endpoint ${PUBLIC_URL}/api/upload
API-Token ${B}${TOKEN}${RST}
$([[ $DRY_RUN -eq 0 ]] && echo "notiert in ${ZUSAMMENFASSUNG}")
$([[ $USE_TLS -eq 0 ]] && cat <<PROXY
${B}Vorgeschalteter Proxy:${RST} Ziel ist ${B}${UPSTREAM}${RST}
Dort unbedingt setzen: client_max_body_size $((MAX_UPLOAD_MB + 5))m;
Fertige Vorlage: ${PROXY_DATEI}
PROXY
)
${B}Noch zu tun:${RST}
1. In der Google Tabelle: Erweiterungen > Apps Script, Inhalt von
apps-script/Code.gs einfuegen und CONFIG.API_TOKEN auf das Token oben setzen.
2. Bereitstellen > Neue Bereitstellung > Web-App
Ausfuehren als: Ich | Zugriff: ${B}Jeder${RST}
Die .../exec-URL kopieren.
3. ${PUBLIC_URL} am Handy oeffnen, Tab "Setup":
Apps-Script-URL, Token und Upload-Endpoint eintragen, dann
"Verbindung testen".
${B}Nuetzlich:${RST}
systemctl status ${APP_NAME}
journalctl -u ${APP_NAME} -f
sudo bash install.sh --update # nach Aenderungen an web/ oder server/
EOF

@ -0,0 +1,26 @@
# Kopieren nach .env und anpassen
PORT=8080
HOST=127.0.0.1
# Muss identisch sein mit CONFIG.API_TOKEN in apps-script/Code.gs
API_TOKEN=BITTE-AENDERN-langes-zufaelliges-token
# Oeffentliche Basis-URL (ohne Slash am Ende) – landet als Bild-URL im Sheet
PUBLIC_URL=https://monitore.example.org
# Pfade
UPLOAD_DIR=/var/lib/monitor-check/uploads
WEB_DIR=/opt/monitor-check/web
# Grenzen
MAX_UPLOAD_MB=15
MAX_IMAGE_PX=1600
JPEG_QUALITY=78
# Kommagetrennt, oder * fuer alle
ALLOWED_ORIGINS=*
# Anzahl vorgeschalteter Proxys (eigener nginx = 1, plus externer Proxy = 2)
TRUST_PROXY=1
# Uploads pro Minute und IP
RATE_LIMIT=300

@ -0,0 +1,32 @@
[Unit]
Description=Monitor-Check Upload- und Web-Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=monitor
Group=monitor
WorkingDirectory=/opt/monitor-check/server
EnvironmentFile=/opt/monitor-check/server/.env
ExecStart=/usr/bin/node /opt/monitor-check/server/server.js
Restart=always
RestartSec=3
# Haertung
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/monitor-check
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
StandardOutput=journal
StandardError=journal
SyslogIdentifier=monitor-check
[Install]
WantedBy=multi-user.target

@ -0,0 +1,39 @@
# /etc/nginx/sites-available/monitor-check
# Danach: ln -s ../sites-available/monitor-check /etc/nginx/sites-enabled/
# nginx -t && systemctl reload nginx
# certbot --nginx -d monitore.example.org
server {
listen 80;
listen [::]:80;
server_name monitore.example.org;
# Uploads vom Handy koennen gross sein
client_max_body_size 20m;
# Sicherheits-Header
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Hochgeladene Bilder direkt von der Platte ausliefern (schneller als durch Node)
location /uploads/ {
alias /var/lib/monitor-check/uploads/;
expires 30d;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
access_log off;
# keine Ausfuehrung von hochgeladenen Dateien
location ~* \.(php|pl|py|cgi|sh)$ { deny all; }
}
}

@ -0,0 +1,19 @@
{
"name": "monitor-check-server",
"version": "1.0.0",
"description": "Upload- und Hosting-Server fuer das Monitor-Check-Tool",
"main": "server.js",
"private": true,
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"sharp": "^0.33.4"
}
}

@ -0,0 +1,151 @@
/**
* Monitor-Check – Upload- und Hosting-Server
* ------------------------------------------
* - liefert die Weboberflaeche (web/index.html) aus
* - nimmt Fotos entgegen, verkleinert sie und legt sie unter /uploads ab
* - gibt die oeffentliche URL des Bildes zurueck
*
* Start: node server.js
* Konfig: .env (siehe .env.example)
*/
const express = require('express');
const multer = require('multer');
const sharp = require('sharp');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
require('dotenv').config();
const PORT = parseInt(process.env.PORT || '8080', 10);
const HOST = process.env.HOST || '0.0.0.0';
const TOKEN = process.env.API_TOKEN || '';
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/+$/, '');
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads'));
const WEB_DIR = path.resolve(process.env.WEB_DIR || path.join(__dirname, '..', 'web'));
const MAX_MB = parseInt(process.env.MAX_UPLOAD_MB || '15', 10);
const MAX_PX = parseInt(process.env.MAX_IMAGE_PX || '1600', 10);
const JPEG_Q = parseInt(process.env.JPEG_QUALITY || '78', 10);
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '*')
.split(',').map(s => s.trim()).filter(Boolean);
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const app = express();
app.disable('x-powered-by');
// Anzahl der vorgeschalteten Proxys. Hinter einem zusaetzlichen externen
// Reverse-Proxy auf 2 setzen, sonst landen alle Clients in einem Rate-Limit-Topf.
app.set('trust proxy', parseInt(process.env.TRUST_PROXY || '1', 10));
/* ---------- CORS ---------- */
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes('*')) {
res.setHeader('Access-Control-Allow-Origin', '*');
} else if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-Token');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
/* ---------- Einfache Rate-Begrenzung ---------- */
const treffer = new Map();
function rateLimit(max, fensterMs) {
return (req, res, next) => {
const key = req.ip;
const jetzt = Date.now();
const e = treffer.get(key) || { n: 0, bis: jetzt + fensterMs };
if (jetzt > e.bis) { e.n = 0; e.bis = jetzt + fensterMs; }
e.n++;
treffer.set(key, e);
if (e.n > max) return res.status(429).json({ ok: false, error: 'Zu viele Anfragen' });
next();
};
}
setInterval(() => {
const jetzt = Date.now();
for (const [k, v] of treffer) if (jetzt > v.bis) treffer.delete(k);
}, 60000).unref();
/* ---------- Token-Pruefung ---------- */
function pruefeToken(req, res, next) {
if (!TOKEN) return next(); // ohne Token konfiguriert = offen
const mit = req.get('X-Token') || req.query.token || '';
const a = Buffer.from(String(mit));
const b = Buffer.from(TOKEN);
const ok = a.length === b.length && crypto.timingSafeEqual(a, b);
if (!ok) return res.status(401).json({ ok: false, error: 'Nicht autorisiert' });
next();
}
/* ---------- Upload ---------- */
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: MAX_MB * 1024 * 1024, files: 1 },
fileFilter: (req, file, cb) => {
if (!/^image\//.test(file.mimetype)) return cb(new Error('Nur Bilddateien erlaubt'));
cb(null, true);
}
});
app.post('/api/upload', rateLimit(parseInt(process.env.RATE_LIMIT || '300', 10), 60000), pruefeToken, (req, res) => {
upload.single('datei')(req, res, async (err) => {
if (err) {
const code = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(code).json({
ok: false,
error: err.code === 'LIMIT_FILE_SIZE' ? `Datei groesser als ${MAX_MB} MB` : err.message
});
}
if (!req.file) return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' });
try {
const name = `${new Date().toISOString().slice(0, 10)}_${crypto.randomBytes(6).toString('hex')}.jpg`;
const ziel = path.join(UPLOAD_DIR, name);
await sharp(req.file.buffer, { failOn: 'none' })
.rotate() // EXIF-Ausrichtung anwenden
.resize({ width: MAX_PX, height: MAX_PX, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: JPEG_Q, mozjpeg: true })
.toFile(ziel);
const basis = PUBLIC_URL || `${req.protocol}://${req.get('host')}`;
const url = `${basis}/uploads/${name}`;
const { size } = fs.statSync(ziel);
console.log(`[upload] ${name} ${(size / 1024).toFixed(0)} kB von ${req.ip}`);
res.json({ ok: true, url, datei: name, bytes: size });
} catch (e) {
console.error('[upload] Fehler:', e.message);
res.status(500).json({ ok: false, error: 'Bild konnte nicht verarbeitet werden' });
}
});
});
/* ---------- Statisches Ausliefern ---------- */
app.use('/uploads', express.static(UPLOAD_DIR, {
maxAge: '30d',
immutable: true,
index: false,
setHeaders: (res) => res.setHeader('X-Content-Type-Options', 'nosniff')
}));
app.use('/', express.static(WEB_DIR, { extensions: ['html'], maxAge: '5m' }));
app.get('/gesundheit', (req, res) => {
res.json({ ok: true, zeit: new Date().toISOString(), uploads: fs.readdirSync(UPLOAD_DIR).length });
});
app.use((req, res) => res.status(404).json({ ok: false, error: 'Nicht gefunden' }));
app.listen(PORT, HOST, () => {
console.log(`Monitor-Check laeuft auf http://${HOST}:${PORT}`);
console.log(` Weboberflaeche: ${WEB_DIR}`);
console.log(` Uploads: ${UPLOAD_DIR}`);
console.log(` Token: ${TOKEN ? 'aktiv' : 'NICHT gesetzt (offen!)'}`);
});

@ -0,0 +1,885 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#111418">
<title>Monitor-Check</title>
<style>
:root{
--bg:#f5f6f7;
--surface:#ffffff;
--line:#d9dce0;
--line-strong:#b7bcc3;
--ink:#111418;
--ink-2:#5a616b;
--ink-3:#8b929b;
--accent:#1b57d6;
--ok:#1a7f45; --ok-bg:#e4f3ea;
--teil:#9a6400; --teil-bg:#fdf1d8;
--fehler:#b3261e; --fehler-bg:#fbe5e3;
--aus:#5a616b; --aus-bg:#eceef0;
--offen:#5a616b; --offen-bg:#ffffff;
--r:10px;
--tap:48px;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
/* muss vor den Layoutregeln stehen: display:block wuerde [hidden] sonst aushebeln */
[hidden]{display:none !important}
html,body{margin:0;padding:0}
body{
background:var(--bg);color:var(--ink);
font:16px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
padding-bottom:calc(72px + env(safe-area-inset-bottom));
}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
header{
position:sticky;top:0;z-index:30;background:var(--surface);
border-bottom:1px solid var(--line);
padding:calc(10px + env(safe-area-inset-top)) 14px 10px;
display:flex;align-items:center;gap:10px;
}
header h1{font-size:16px;font-weight:650;margin:0;letter-spacing:-.01em;flex:1}
.netstate{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--line);color:var(--ink-2);white-space:nowrap}
.netstate.off{background:var(--fehler-bg);border-color:#f0c4c0;color:var(--fehler)}
.netstate.queue{background:var(--teil-bg);border-color:#eddaa8;color:var(--teil)}
main{padding:14px;max-width:720px;margin:0 auto}
.view{display:none}
.view.on{display:block}
nav{
position:fixed;left:0;right:0;bottom:0;z-index:30;
background:var(--surface);border-top:1px solid var(--line);
display:grid;grid-template-columns:repeat(3,1fr);
padding-bottom:env(safe-area-inset-bottom);
}
nav button{
appearance:none;border:0;background:none;color:var(--ink-3);
padding:12px 4px;font:inherit;font-size:12px;font-weight:550;
display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;
min-height:var(--tap);
}
nav button svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
nav button.on{color:var(--accent)}
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:14px;margin-bottom:12px}
.card h2{font-size:13px;font-weight:650;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);margin:0 0 12px}
label{display:block;font-size:13px;font-weight:600;color:var(--ink-2);margin:0 0 6px}
input[type=text],input[type=url],input[type=password],textarea,select{
width:100%;padding:12px;font:inherit;color:var(--ink);
background:#fff;border:1px solid var(--line-strong);border-radius:8px;min-height:var(--tap);
}
input:focus,textarea:focus,select:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
textarea{min-height:76px;resize:vertical}
.field{margin-bottom:14px}
.hint{font-size:12px;color:var(--ink-3);margin-top:5px}
button.btn{
appearance:none;font:inherit;font-weight:600;cursor:pointer;
border:1px solid var(--line-strong);background:#fff;color:var(--ink);
border-radius:8px;padding:13px 16px;min-height:var(--tap);width:100%;
}
button.btn.primary{background:var(--ink);border-color:var(--ink);color:#fff}
button.btn.ghost{background:transparent}
button.btn.danger{color:var(--fehler);border-color:#f0c4c0}
button.btn.danger:active{background:var(--fehler-bg)}
button.btn:disabled{opacity:.5;cursor:default}
.btnrow{display:flex;gap:8px}
.btnrow .btn{width:auto;flex:1}
/* Status-Schalter */
.triple{display:flex;flex-direction:column;gap:10px}
.trow{display:grid;grid-template-columns:78px 1fr;align-items:center;gap:10px}
.trow > span{font-size:14px;font-weight:600}
.seg{display:grid;grid-template-columns:repeat(3,1fr);border:1px solid var(--line-strong);border-radius:8px;overflow:hidden}
.seg button{
appearance:none;border:0;border-right:1px solid var(--line-strong);background:#fff;
font:inherit;font-size:14px;font-weight:600;color:var(--ink-2);
padding:12px 4px;min-height:var(--tap);cursor:pointer;
}
.seg button:last-child{border-right:0}
.seg button[aria-pressed=true][data-v=JA]{background:var(--ok-bg);color:var(--ok);box-shadow:inset 0 0 0 2px var(--ok)}
.seg button[aria-pressed=true][data-v=NEIN]{background:var(--fehler-bg);color:var(--fehler);box-shadow:inset 0 0 0 2px var(--fehler)}
.seg button[aria-pressed=true][data-v="n.A."]{background:var(--aus-bg);color:var(--aus);box-shadow:inset 0 0 0 2px var(--aus)}
.chip{
display:inline-flex;align-items:center;gap:6px;
font-size:12px;font-weight:650;padding:4px 10px;border-radius:999px;border:1px solid transparent;white-space:nowrap;
}
.chip::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
.s-ok{background:var(--ok-bg);color:var(--ok)}
.s-teil{background:var(--teil-bg);color:var(--teil)}
.s-fehler{background:var(--fehler-bg);color:var(--fehler)}
.s-aus{background:var(--aus-bg);color:var(--aus)}
.s-offen{background:#fff;color:var(--offen);border-color:var(--line-strong)}
.preview{
display:flex;align-items:center;justify-content:space-between;gap:10px;
padding:12px;border:1px dashed var(--line-strong);border-radius:8px;margin-top:12px;
}
.preview small{color:var(--ink-3);font-size:12px}
/* Foto */
.photo{position:relative;border:1px dashed var(--line-strong);border-radius:8px;overflow:hidden;background:#fff}
.photo img{display:block;width:100%;max-height:260px;object-fit:cover}
.photo .ph{padding:26px 12px;text-align:center;color:var(--ink-3);font-size:14px}
.photo .rm{position:absolute;top:8px;right:8px;width:34px;height:34px;border-radius:50%;border:0;background:rgba(17,20,24,.78);color:#fff;font-size:18px;cursor:pointer;line-height:1}
input[type=file]{display:none}
/* Liste */
.toolbar{display:flex;gap:8px;margin-bottom:10px}
.toolbar input{flex:1}
.filters{display:flex;gap:6px;overflow-x:auto;padding-bottom:10px;margin-bottom:4px;-webkit-overflow-scrolling:touch}
.filters button{
appearance:none;font:inherit;font-size:13px;font-weight:600;white-space:nowrap;cursor:pointer;
border:1px solid var(--line-strong);background:#fff;color:var(--ink-2);border-radius:999px;padding:8px 13px;
}
.filters button.on{background:var(--ink);border-color:var(--ink);color:#fff}
.item{
display:flex;gap:12px;align-items:center;width:100%;text-align:left;cursor:pointer;
background:var(--surface);border:1px solid var(--line);border-radius:var(--r);
padding:10px;margin-bottom:8px;font:inherit;
}
.item .thumb{width:56px;height:56px;border-radius:6px;background:var(--aus-bg);flex:0 0 auto;object-fit:cover}
.item .thumb.empty{display:flex;align-items:center;justify-content:center;color:var(--ink-3);font-size:11px}
.item .meta{flex:1;min-width:0}
.item .idline{font-size:11px;color:var(--ink-3);letter-spacing:.04em}
.item .name{font-weight:600;font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .sub{font-size:12px;color:var(--ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin-bottom:12px}
.stat{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:8px 4px;text-align:center}
.stat b{display:block;font-size:18px;font-variant-numeric:tabular-nums}
.stat span{font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.04em}
/* Detail-Sheet */
.sheet{position:fixed;inset:0;z-index:50;display:none}
.sheet.on{display:block}
.sheet .bd{position:absolute;inset:0;background:rgba(17,20,24,.45)}
.sheet .pn{
position:absolute;left:0;right:0;bottom:0;max-height:92vh;overflow:auto;
background:var(--bg);border-radius:16px 16px 0 0;padding:16px 14px calc(20px + env(safe-area-inset-bottom));
}
.sheet .grip{width:38px;height:4px;border-radius:2px;background:var(--line-strong);margin:0 auto 14px}
@media(min-width:720px){.sheet .pn{max-width:640px;margin:0 auto;border-radius:16px;bottom:auto;top:50%;transform:translateY(-50%)}}
.toast{
position:fixed;left:50%;transform:translateX(-50%);bottom:calc(84px + env(safe-area-inset-bottom));
z-index:60;background:var(--ink);color:#fff;padding:11px 16px;border-radius:8px;
font-size:14px;font-weight:550;max-width:88vw;text-align:center;opacity:0;pointer-events:none;transition:opacity .18s;
}
.toast.on{opacity:1}
.toast.err{background:var(--fehler)}
.empty{text-align:center;color:var(--ink-3);padding:40px 20px;font-size:14px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
dl.kv{margin:0;font-size:13px}
dl.kv div{display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid var(--line)}
dl.kv dt{color:var(--ink-3)}
dl.kv dd{margin:0;text-align:right}
</style>
</head>
<body>
<header>
<h1>Monitor-Check</h1>
<span class="netstate" id="netstate">bereit</span>
</header>
<main>
<!-- ============ ERFASSEN ============ -->
<section class="view on" id="v-erfassen">
<div class="card">
<h2>Neuer Eintrag</h2>
<div class="field">
<label for="f-bez">Bezeichnung *</label>
<input type="text" id="f-bez" placeholder="z. B. Monitor Foyer Nord" autocomplete="off">
</div>
<div class="field">
<label for="f-ort">Standort</label>
<input type="text" id="f-ort" placeholder="z. B. Halle 2, Reihe A" autocomplete="off">
</div>
<div class="field">
<label>Foto</label>
<div class="photo" id="f-photo">
<div class="ph" id="f-photo-ph">Tippen zum Fotografieren</div>
<img id="f-photo-img" alt="" hidden>
<button class="rm" id="f-photo-rm" hidden aria-label="Foto entfernen">&times;</button>
</div>
<input type="file" id="f-file" accept="image/*" capture="environment">
<p class="hint" id="f-photo-hint">Wird auf deinen Server geladen, das Sheet speichert die URL.</p>
</div>
<div class="field">
<label>Status sofort erfassen (optional)</label>
<div class="triple" id="f-triple"></div>
<div class="preview">
<span class="chip s-offen" id="f-status">Nicht geprüft</span>
<small>wird automatisch berechnet</small>
</div>
</div>
<div class="field">
<label for="f-notiz">Notiz</label>
<textarea id="f-notiz" placeholder="Auffälligkeiten, Defekte, Zuständigkeit …"></textarea>
</div>
<button class="btn primary" id="f-save">Eintrag anlegen</button>
</div>
</section>
<!-- ============ ÜBERSICHT ============ -->
<section class="view" id="v-liste">
<div class="stats" id="stats"></div>
<div class="toolbar">
<input type="text" id="q" placeholder="ID, Bezeichnung, Standort …" autocomplete="off">
<button class="btn" id="reload" style="width:auto;padding:0 16px" aria-label="Neu laden">&#8635;</button>
</div>
<div class="filters" id="filters"></div>
<div id="list"></div>
</section>
<!-- ============ EINSTELLUNGEN ============ -->
<section class="view" id="v-setup">
<div class="card">
<h2>Verbindung</h2>
<div class="field">
<label for="s-api">Apps-Script Web-App URL</label>
<input type="url" id="s-api" placeholder="https://script.google.com/macros/s/…/exec" autocomplete="off">
</div>
<div class="field">
<label for="s-token">API-Token</label>
<input type="password" id="s-token" placeholder="Token aus Code.gs" autocomplete="off">
</div>
<div class="field">
<label for="s-upload">Upload-Endpoint</label>
<input type="url" id="s-upload" placeholder="https://dein-server/api/upload" autocomplete="off">
<p class="hint">Leer lassen, wenn ohne Foto gearbeitet wird.</p>
</div>
<div class="field">
<label for="s-user">Dein Name</label>
<input type="text" id="s-user" placeholder="wird als Prüfer gespeichert" autocomplete="off">
</div>
<div class="btnrow">
<button class="btn primary" id="s-save">Speichern</button>
<button class="btn" id="s-test">Verbindung testen</button>
</div>
</div>
<div class="card">
<h2>Statusregeln</h2>
<dl class="kv">
<div><dt>Audio / Bild / Sprache alle JA</dt><dd><span class="chip s-ok">Funktionsfähig</span></dd></div>
<div><dt>Teilweise NEIN</dt><dd><span class="chip s-teil">Teilweise</span></dd></div>
<div><dt>Alle bewerteten NEIN</dt><dd><span class="chip s-fehler">Fehlerhaft</span></dd></div>
<div><dt>Alle drei n.A.</dt><dd><span class="chip s-aus">Deaktiviert</span></dd></div>
<div style="border:0"><dt>Nichts erfasst</dt><dd><span class="chip s-offen">Nicht geprüft</span></dd></div>
</dl>
<p class="hint">Einzelne Felder mit „n.A.“ werden aus der Bewertung ausgeschlossen und zählen nicht als Fehler.</p>
</div>
<div class="card">
<h2>Offline-Warteschlange</h2>
<p class="hint" id="q-info" style="margin-top:0">–</p>
<div class="btnrow">
<button class="btn" id="q-flush">Jetzt senden</button>
<button class="btn ghost" id="q-clear">Verwerfen</button>
</div>
</div>
</section>
</main>
<nav>
<button class="on" data-view="erfassen">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>Erfassen
</button>
<button data-view="liste">
<svg viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>Übersicht
</button>
<button data-view="setup">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2v.1a2 2 0 1 1-4 0v-.2a1.7 1.7 0 0 0-3-1.1l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0-1.1-2.9H3a2 2 0 1 1 0-4h.2A1.7 1.7 0 0 0 4.3 6l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 2.9-1.1V2a2 2 0 1 1 4 0v.2a1.7 1.7 0 0 0 2.9 1.1l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0 1.1 2.9h.2a2 2 0 1 1 0 4h-.2a1.7 1.7 0 0 0-1.4 1z"/></svg>Setup
</button>
</nav>
<!-- Detail -->
<div class="sheet" id="detail">
<div class="bd" data-close></div>
<div class="pn" id="detail-pn"></div>
</div>
<div class="toast" id="toast"></div>
<script>
"use strict";
/* ================= Konfiguration ================= */
/* Optional hier fest eintragen – sonst über den Setup-Tab pflegen. */
var DEFAULTS = {
api: "",
token: "",
upload: "",
user: ""
};
var LS = "monitorcheck.v1";
var cfg = Object.assign({}, DEFAULTS, load(LS + ".cfg", {}));
var queue = load(LS + ".queue", []);
var items = load(LS + ".cache", []);
function load(k, fb){ try{ var v = localStorage.getItem(k); return v ? JSON.parse(v) : fb; }catch(e){ return fb; } }
function save(k, v){ try{ localStorage.setItem(k, JSON.stringify(v)); }catch(e){} }
/* ================= Statuslogik (identisch zu Code.gs) ================= */
var S = {
OK:"Funktionsfähig", TEIL:"Teilweise funktionsfähig",
FEHLER:"Fehlerhaft", AUS:"Deaktiviert", OFFEN:"Nicht geprüft"
};
var CLS = {};
CLS[S.OK]="s-ok"; CLS[S.TEIL]="s-teil"; CLS[S.FEHLER]="s-fehler";
CLS[S.AUS]="s-aus"; CLS[S.OFFEN]="s-offen";
function norm(v){
var s = String(v == null ? "" : v).trim().toUpperCase();
if(!s) return "";
if(s==="JA"||s==="J"||s==="TRUE") return "JA";
if(s==="NEIN"||s==="N"||s==="FALSE") return "NEIN";
if(s==="N.A."||s==="NA"||s==="N/A"||s==="N.A") return "n.A.";
return "";
}
function berechneStatus(a,b,c){
var w = [norm(a), norm(b), norm(c)];
var gesetzt = w.filter(function(x){ return x !== ""; });
if(!gesetzt.length) return S.OFFEN;
if(w.filter(function(x){ return x==="n.A."; }).length === 3) return S.AUS;
var bew = w.filter(function(x){ return x==="JA" || x==="NEIN"; });
if(!bew.length) return S.AUS;
var nein = bew.filter(function(x){ return x==="NEIN"; }).length;
if(nein === 0) return S.OK;
if(nein === bew.length) return S.FEHLER;
return S.TEIL;
}
/* Vereinheitlicht Statustexte aus der Tabelle – aeltere Zeilen stehen dort
ohne Umlaute ("Funktionsfaehig"). Ohne das greifen Filter und Zaehler nicht. */
function statusKanon(v){
var k = String(v == null ? "" : v).toLowerCase().trim()
.replace(/ä/g,"ae").replace(/ö/g,"oe").replace(/ü/g,"ue").replace(/ß/g,"ss");
if(k === "funktionsfaehig") return S.OK;
if(k === "teilweise funktionsfaehig") return S.TEIL;
if(k === "fehlerhaft") return S.FEHLER;
if(k === "deaktiviert") return S.AUS;
return S.OFFEN;
}
/* Kurzform für die Statistik-Kacheln */
function shortStatus(s){
if(s===S.OK) return "OK";
if(s===S.TEIL) return "Teil";
if(s===S.FEHLER) return "Fehler";
if(s===S.AUS) return "Aus";
return "Offen";
}
/* ================= Hilfen ================= */
var $ = function(sel, root){ return (root||document).querySelector(sel); };
var $$ = function(sel, root){ return Array.prototype.slice.call((root||document).querySelectorAll(sel)); };
function esc(s){
return String(s == null ? "" : s).replace(/[&<>"']/g, function(c){
return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c];
});
}
var toastTimer;
function toast(msg, isErr){
var t = $("#toast");
t.textContent = msg;
t.className = "toast on" + (isErr ? " err" : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(function(){ t.className = "toast"; }, 2800);
}
function chip(status){
return '<span class="chip ' + (CLS[status]||"s-offen") + '">' + esc(status) + "</span>";
}
function datum(iso){
if(!iso) return "–";
var d = new Date(iso);
if(isNaN(d)) return String(iso);
return d.toLocaleString("de-DE",{day:"2-digit",month:"2-digit",year:"2-digit",hour:"2-digit",minute:"2-digit"});
}
/* ================= API ================= */
/* text/plain vermeidet den CORS-Preflight, den Apps Script nicht beantwortet. */
function api(action, payload){
if(!cfg.api) return Promise.reject(new Error("Keine Apps-Script-URL hinterlegt (Setup)."));
var body = Object.assign({ action: action, token: cfg.token }, payload || {});
return fetch(cfg.api, {
method: "POST",
redirect: "follow",
headers: { "Content-Type": "text/plain;charset=utf-8" },
body: JSON.stringify(body)
})
.then(function(r){ return r.text(); })
.then(function(txt){
var data;
try{ data = JSON.parse(txt); }
catch(e){ throw new Error("Unerwartete Antwort – Web-App-Zugriff auf „Jeder“ gesetzt?"); }
if(!data.ok) throw new Error(data.error || "Fehler");
return data;
});
}
function uploadFoto(file){
if(!cfg.upload) return Promise.reject(new Error("Kein Upload-Endpoint hinterlegt (Setup)."));
var fd = new FormData();
fd.append("datei", file, file.name || "foto.jpg");
return fetch(cfg.upload, {
method: "POST",
headers: cfg.token ? { "X-Token": cfg.token } : {},
body: fd
})
.then(function(r){ return r.json().catch(function(){ throw new Error("Upload fehlgeschlagen (" + r.status + ")"); }); })
.then(function(d){
if(!d.ok) throw new Error(d.error || "Upload fehlgeschlagen");
return d.url;
});
}
/* ================= Warteschlange ================= */
function enqueue(action, payload){
queue.push({ action: action, payload: payload, ts: Date.now() });
save(LS + ".queue", queue);
renderNet();
window.dispatchEvent(new Event("mc:queue"));
}
function flushQueue(silent){
if(!queue.length){ if(!silent) toast("Warteschlange ist leer."); return Promise.resolve(); }
if(!navigator.onLine){ if(!silent) toast("Offline – später erneut.", true); return Promise.resolve(); }
var pending = queue.slice();
var ok = 0;
return pending.reduce(function(p, job){
return p.then(function(){
return api(job.action, job.payload).then(function(){
ok++;
queue = queue.filter(function(q){ return q !== job; });
save(LS + ".queue", queue);
});
});
}, Promise.resolve())
.then(function(){
if(ok) toast(ok + " Eintrag/Einträge nachgesendet.");
renderNet();
window.dispatchEvent(new Event("mc:queue"));
return ladeListe(true);
})
.catch(function(err){
renderNet();
if(!silent) toast(err.message, true);
});
}
function renderNet(){
var el = $("#netstate");
if(!navigator.onLine){ el.className = "netstate off"; el.textContent = "offline"; return; }
if(queue.length){ el.className = "netstate queue"; el.textContent = queue.length + " wartet"; return; }
el.className = "netstate"; el.textContent = "verbunden";
}
$("#q-info") && (function(){
var upd = function(){ $("#q-info").textContent = queue.length ? queue.length + " Vorgang/Vorgänge warten auf Übertragung." : "Keine offenen Vorgänge."; };
upd();
window.addEventListener("mc:queue", upd);
})();
/* ================= Navigation ================= */
$$("nav button").forEach(function(b){
b.addEventListener("click", function(){
$$("nav button").forEach(function(x){ x.classList.toggle("on", x === b); });
$$(".view").forEach(function(v){ v.classList.toggle("on", v.id === "v-" + b.dataset.view); });
window.scrollTo(0,0);
if(b.dataset.view === "liste") ladeListe(true);
});
});
/* ================= Status-Schalter ================= */
function buildTriple(host, werte, onChange){
host.__werte = werte;
host.__onChange = onChange;
var felder = [["audio","Audio"],["bild","Bild"],["sprache","Sprache"]];
host.innerHTML = felder.map(function(f){
return '<div class="trow"><span>' + f[1] + '</span><div class="seg" data-k="' + f[0] + '">' +
["JA","NEIN","n.A."].map(function(v){
var on = norm(werte[f[0]]) === v;
return '<button type="button" data-v="' + v + '" aria-pressed="' + on + '">' + v + "</button>";
}).join("") + "</div></div>";
}).join("");
// Listener nur einmal pro Host binden – buildTriple wird beim Zuruecksetzen erneut aufgerufen.
if(host.dataset.bound === "1") return;
host.dataset.bound = "1";
host.addEventListener("click", function(ev){
var btn = ev.target.closest("button[data-v]");
if(!btn) return;
var seg = btn.parentNode, k = seg.dataset.k;
var war = btn.getAttribute("aria-pressed") === "true";
$$("button", seg).forEach(function(x){ x.setAttribute("aria-pressed", "false"); });
if(!war){ btn.setAttribute("aria-pressed","true"); host.__werte[k] = btn.dataset.v; }
else { host.__werte[k] = ""; } // erneutes Tippen hebt die Auswahl auf
host.__onChange(host.__werte);
});
}
/* ================= Erfassen ================= */
var neu = { audio:"", bild:"", sprache:"" };
var neuFile = null;
buildTriple($("#f-triple"), neu, function(w){
var st = berechneStatus(w.audio, w.bild, w.sprache);
var el = $("#f-status");
el.className = "chip " + (CLS[st] || "s-offen");
el.textContent = st;
});
$("#f-photo").addEventListener("click", function(ev){
if(ev.target.id === "f-photo-rm") return;
$("#f-file").click();
});
var vorschauUrl = null;
$("#f-file").addEventListener("change", function(){
var f = this.files && this.files[0];
if(!f) return;
neuFile = f;
if(vorschauUrl) URL.revokeObjectURL(vorschauUrl);
vorschauUrl = URL.createObjectURL(f);
var img = $("#f-photo-img");
img.src = vorschauUrl;
img.hidden = false;
$("#f-photo-ph").hidden = true;
$("#f-photo-rm").hidden = false;
});
function fotoEntfernen(){
neuFile = null;
$("#f-file").value = "";
if(vorschauUrl){ URL.revokeObjectURL(vorschauUrl); vorschauUrl = null; }
var img = $("#f-photo-img");
img.removeAttribute("src");
img.hidden = true;
$("#f-photo-ph").hidden = false;
$("#f-photo-rm").hidden = true;
}
$("#f-photo-rm").addEventListener("click", function(ev){
ev.stopPropagation();
fotoEntfernen();
});
$("#f-save").addEventListener("click", function(){
var btn = this;
var bez = $("#f-bez").value.trim();
if(!bez){ toast("Bezeichnung fehlt.", true); $("#f-bez").focus(); return; }
btn.disabled = true;
btn.textContent = "Wird gespeichert …";
var schritt = neuFile
? uploadFoto(neuFile).catch(function(err){
toast("Foto: " + err.message, true);
return "";
})
: Promise.resolve("");
schritt.then(function(url){
var payload = {
bezeichnung: bez,
standort: $("#f-ort").value.trim(),
bildUrl: url,
audio: neu.audio, bild: neu.bild, sprache: neu.sprache,
notiz: $("#f-notiz").value.trim(),
pruefer: cfg.user || ""
};
return api("create", payload).then(function(res){
toast("Angelegt: " + res.id);
resetForm();
ladeListe(true);
}).catch(function(err){
if(!navigator.onLine || /Failed to fetch|NetworkError/i.test(err.message)){
enqueue("create", payload);
toast("Offline gespeichert – wird nachgesendet.");
resetForm();
} else {
toast(err.message, true);
}
});
}).then(function(){
btn.disabled = false;
btn.textContent = "Eintrag anlegen";
});
});
function resetForm(){
$("#f-bez").value = ""; $("#f-ort").value = ""; $("#f-notiz").value = "";
neu.audio = neu.bild = neu.sprache = "";
buildTriple($("#f-triple"), neu, function(w){
var st = berechneStatus(w.audio, w.bild, w.sprache);
var el = $("#f-status");
el.className = "chip " + (CLS[st] || "s-offen");
el.textContent = st;
});
$("#f-status").className = "chip s-offen";
$("#f-status").textContent = S.OFFEN;
fotoEntfernen();
window.scrollTo(0, 0);
}
/* ================= Übersicht ================= */
var filter = "alle";
var suche = "";
var FILTERS = [["alle","Alle"],[S.OFFEN,"Nicht geprüft"],[S.FEHLER,"Fehlerhaft"],[S.TEIL,"Teilweise"],[S.OK,"Funktionsfähig"],[S.AUS,"Deaktiviert"]];
$("#filters").innerHTML = FILTERS.map(function(f){
return '<button data-f="' + esc(f[0]) + '"' + (f[0]==="alle"?' class="on"':"") + ">" + esc(f[1]) + "</button>";
}).join("");
$("#filters").addEventListener("click", function(ev){
var b = ev.target.closest("button[data-f]");
if(!b) return;
filter = b.dataset.f;
$$("#filters button").forEach(function(x){ x.classList.toggle("on", x === b); });
renderListe();
});
$("#q").addEventListener("input", function(){ suche = this.value.trim().toLowerCase(); renderListe(); });
$("#reload").addEventListener("click", function(){ ladeListe(false); });
function setzeItems(arr){
items = (arr || []).map(function(i){
i.status = statusKanon(i.status);
return i;
});
save(LS + ".cache", items);
}
function ladeListe(silent){
if(!cfg.api){ if(!silent) toast("Bitte zuerst die Verbindung einrichten.", true); renderListe(); return Promise.resolve(); }
return api("list").then(function(res){
setzeItems(res.items || []);
renderListe();
if(!silent) toast(items.length + " Einträge geladen.");
}).catch(function(err){
renderListe();
if(!silent) toast(err.message, true);
});
}
function renderListe(){
var zahl = {};
zahl[S.OK]=0; zahl[S.TEIL]=0; zahl[S.FEHLER]=0; zahl[S.AUS]=0; zahl[S.OFFEN]=0;
items.forEach(function(i){ if(zahl[i.status] !== undefined) zahl[i.status]++; else zahl[S.OFFEN]++; });
$("#stats").innerHTML = [S.OK,S.TEIL,S.FEHLER,S.AUS,S.OFFEN].map(function(s){
return '<div class="stat"><b>' + zahl[s] + "</b><span>" + esc(shortStatus(s)) + "</span></div>";
}).join("");
var gefiltert = items.filter(function(i){
if(filter !== "alle" && i.status !== filter) return false;
if(!suche) return true;
return (i.id + " " + i.bezeichnung + " " + i.standort + " " + i.notiz).toLowerCase().indexOf(suche) >= 0;
});
if(!gefiltert.length){
$("#list").innerHTML = '<div class="empty">' +
(items.length ? "Keine Treffer." : "Noch keine Einträge – oben rechts neu laden oder im Tab „Erfassen“ anlegen.") +
"</div>";
return;
}
$("#list").innerHTML = gefiltert.map(function(i){
var thumb = i.bildUrl
? '<img class="thumb" src="' + esc(i.bildUrl) + '" alt="" loading="lazy">'
: '<div class="thumb empty">kein<br>Bild</div>';
return '<button class="item" data-id="' + esc(i.id) + '">' + thumb +
'<div class="meta">' +
'<div class="idline mono">' + esc(i.id) + "</div>" +
'<div class="name">' + esc(i.bezeichnung) + "</div>" +
'<div class="sub">' + esc(i.standort || "–") + "</div>" +
"</div>" + chip(i.status) + "</button>";
}).join("");
}
$("#list").addEventListener("click", function(ev){
var b = ev.target.closest(".item");
if(b) oeffneDetail(b.dataset.id);
});
/* ================= Detail / Statusmeldung ================= */
var aktiv = null;
var aktivWerte = null;
function oeffneDetail(id){
var it = items.filter(function(x){ return x.id === id; })[0];
if(!it) return;
aktiv = it;
aktivWerte = { audio: it.audio || "", bild: it.bild || "", sprache: it.sprache || "" };
$("#detail-pn").innerHTML =
'<div class="grip"></div>' +
'<div class="card">' +
'<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px">' +
"<div><div class='idline mono' style='font-size:12px;color:var(--ink-3)'>" + esc(it.id) + "</div>" +
"<div style='font-size:18px;font-weight:650'>" + esc(it.bezeichnung) + "</div>" +
"<div style='font-size:13px;color:var(--ink-3)'>" + esc(it.standort || "–") + "</div></div>" +
chip(it.status) +
"</div>" +
(it.bildUrl ? '<img src="' + esc(it.bildUrl) + '" alt="" style="width:100%;border-radius:8px;margin-bottom:12px">' : "") +
'<div class="triple" id="d-triple"></div>' +
'<div class="preview"><span class="chip ' + (CLS[it.status]||"s-offen") + '" id="d-preview">' + esc(it.status) + "</span><small>neuer Status</small></div>" +
'<div class="field" style="margin-top:14px"><label for="d-notiz">Notiz</label><textarea id="d-notiz">' + esc(it.notiz) + "</textarea></div>" +
'<div class="btnrow"><button class="btn primary" id="d-save">Status melden</button>' +
'<button class="btn ghost" data-close>Schließen</button></div>' +
'<button class="btn ghost danger" id="d-del" style="margin-top:8px">Eintrag löschen</button>' +
"</div>" +
'<div class="card"><h2>Verlauf</h2><dl class="kv">' +
"<div><dt>Erfasst</dt><dd>" + esc(datum(it.erfasstAm)) + "</dd></div>" +
"<div><dt>Zuletzt geprüft</dt><dd>" + esc(datum(it.geprueftAm)) + "</dd></div>" +
"<div style='border:0'><dt>Prüfer</dt><dd>" + esc(it.pruefer || "–") + "</dd></div>" +
"</dl></div>";
buildTriple($("#d-triple"), aktivWerte, function(w){
var st = berechneStatus(w.audio, w.bild, w.sprache);
var el = $("#d-preview");
el.className = "chip " + (CLS[st] || "s-offen");
el.textContent = st;
});
$("#d-save").addEventListener("click", speichereStatus);
$("#d-del").addEventListener("click", loescheEintrag);
$("#detail").classList.add("on");
document.body.style.overflow = "hidden";
}
function schliesseDetail(){
$("#detail").classList.remove("on");
document.body.style.overflow = "";
aktiv = null;
}
document.addEventListener("click", function(ev){
if(ev.target.matches("[data-close]")) schliesseDetail();
});
document.addEventListener("keydown", function(ev){
if(ev.key === "Escape" && $("#detail").classList.contains("on")) schliesseDetail();
});
function speichereStatus(){
if(!aktiv) return;
var btn = $("#d-save");
var payload = {
id: aktiv.id,
audio: aktivWerte.audio, bild: aktivWerte.bild, sprache: aktivWerte.sprache,
notiz: $("#d-notiz").value.trim(),
pruefer: cfg.user || ""
};
var neuStatus = berechneStatus(payload.audio, payload.bild, payload.sprache);
btn.disabled = true;
btn.textContent = "Wird gemeldet …";
api("status", payload).then(function(res){
lokalAktualisieren(payload, res.status || neuStatus, res.geprueftAm);
toast(aktiv.id + ": " + (res.status || neuStatus));
schliesseDetail();
}).catch(function(err){
if(!navigator.onLine || /Failed to fetch|NetworkError/i.test(err.message)){
enqueue("status", payload);
lokalAktualisieren(payload, neuStatus, new Date().toISOString());
toast("Offline gespeichert – wird nachgesendet.");
schliesseDetail();
} else {
toast(err.message, true);
btn.disabled = false;
btn.textContent = "Status melden";
}
});
}
function loescheEintrag(){
if(!aktiv) return;
var it = aktiv;
if(!confirm("„" + it.id + " – " + it.bezeichnung + "“ wirklich löschen?\n\nDie Zeile wird aus der Tabelle entfernt. Das lässt sich nicht rückgängig machen.")) return;
var btn = $("#d-del");
btn.disabled = true;
btn.textContent = "Wird gelöscht …";
api("delete", { id: it.id, pruefer: cfg.user || "" }).then(function(){
items = items.filter(function(x){ return x.id !== it.id; });
save(LS + ".cache", items);
renderListe();
toast(it.id + " gelöscht.");
schliesseDetail();
}).catch(function(err){
toast(err.message, true);
btn.disabled = false;
btn.textContent = "Eintrag löschen";
});
}
function lokalAktualisieren(p, status, zeit){
items = items.map(function(i){
if(i.id !== p.id) return i;
return Object.assign({}, i, {
audio: norm(p.audio), bild: norm(p.bild), sprache: norm(p.sprache),
status: statusKanon(status), notiz: p.notiz,
geprueftAm: zeit || i.geprueftAm, pruefer: p.pruefer || i.pruefer
});
});
save(LS + ".cache", items);
renderListe();
}
/* ================= Setup ================= */
function fuelleSetup(){
$("#s-api").value = cfg.api || "";
$("#s-token").value = cfg.token || "";
$("#s-upload").value = cfg.upload || "";
$("#s-user").value = cfg.user || "";
}
fuelleSetup();
$("#s-save").addEventListener("click", function(){
cfg.api = $("#s-api").value.trim();
cfg.token = $("#s-token").value.trim();
cfg.upload = $("#s-upload").value.trim();
cfg.user = $("#s-user").value.trim();
save(LS + ".cfg", cfg);
toast("Einstellungen gespeichert.");
ladeListe(true);
});
$("#s-test").addEventListener("click", function(){
var btn = this;
cfg.api = $("#s-api").value.trim();
cfg.token = $("#s-token").value.trim();
btn.disabled = true;
api("ping").then(function(r){ toast("Verbindung ok (" + datum(r.time) + ")"); })
.catch(function(e){ toast(e.message, true); })
.then(function(){ btn.disabled = false; });
});
$("#q-flush").addEventListener("click", function(){ flushQueue(false); });
$("#q-clear").addEventListener("click", function(){
if(!queue.length){ toast("Warteschlange ist leer."); return; }
if(confirm("Wirklich " + queue.length + " nicht gesendete Vorgänge verwerfen?")){
queue = []; save(LS + ".queue", queue); renderNet();
window.dispatchEvent(new Event("mc:queue"));
toast("Warteschlange geleert.");
}
});
/* ================= Start ================= */
window.addEventListener("online", function(){ renderNet(); flushQueue(true); });
window.addEventListener("offline", renderNet);
renderNet();
setzeItems(items); // Zwischenspeicher aus aelteren Versionen vereinheitlichen
renderListe();
if(cfg.api){ flushQueue(true).then(function(){ ladeListe(true); }); }
else { $$("nav button")[2].click(); }
</script>
</body>
</html>
Loading…
Cancel
Save

Powered by TurnKey Linux.