first commit
This commit is contained in:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mkdir:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
backend/data/*.db
|
||||||
|
backend/data/*.db-shm
|
||||||
|
backend/data/*.db-wal
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
9
Dockerfile
Normal file
9
Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci --only=production || npm i --only=production
|
||||||
|
COPY server.js ./
|
||||||
|
COPY public ./public
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV PORT=8080
|
||||||
|
CMD ["node", "server.js"]
|
||||||
68
README.md
Normal file
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Docker Stack Stats UI (v5)
|
||||||
|
|
||||||
|
Ein leichtgewichtiges Dashboard für die Übersicht über Docker-Stacks und deren Container-Ressourcen (CPU, RAM, Container).
|
||||||
|
Die Node.js-Serverkomponente wertet Docker-Statistiken aus, stellt sie über eine kleine REST-API bereit und liefert eine statische Weboberfläche aus `public/`.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
- Gruppierte Ansicht nach Stack/Compose-Projekt mit persistierbarem Toggle für Stapel-/Flat-Ansicht
|
||||||
|
- Sortierbare Spaltenköpfe und kompakteres UI für doppelt so viele Zeilen auf einen Blick
|
||||||
|
- System- und Container-RAM-Anzeige inklusive prozentualer Nutzung des Hosts
|
||||||
|
- Flat-View mit Spalten-filtern, Stack-Spalte und adaptiver Detailsicht
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
- `server.js`: Express-Server, Basis-Auth, Docker-Stats-Erfassung und API-Endpunkte
|
||||||
|
- `public/`: Gebündelte UI für das Dashboard
|
||||||
|
- `web/`: Alternative Version der Oberfläche (für Referenz oder eigenes Frontend)
|
||||||
|
- `docker-compose.yml` & `Dockerfile`: Containerisierung
|
||||||
|
- `extension/`: eigenständige Browser-Erweiterung (nicht Teil der Runtime, liegt hier als Referenz)
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
- Docker Engine (Abfrage über Socket `/var/run/docker.sock`)
|
||||||
|
- Node.js 18+ (für lokale Entwicklung)
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
1. Repository klonen und Abhängigkeiten installieren:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
2. Server starten (liefert UI unter http://localhost:8080):
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
3. Alternativ mit einem Live-Proxy bzw. lokalen Backend an `server.js` binden und `public/` separat hosten.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
1. Image bauen:
|
||||||
|
```bash
|
||||||
|
docker build -t docker-stack-stats-ui .
|
||||||
|
```
|
||||||
|
2. Beispiel-Container starten (Docker-Socket mounten, optional Basic Auth):
|
||||||
|
```bash
|
||||||
|
docker run -d --name stack-stats -p 8080:8080 \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
||||||
|
-e BASIC_AUTH_USER=me -e BASIC_AUTH_PASS=secret \
|
||||||
|
--restart unless-stopped \
|
||||||
|
docker-stack-stats-ui
|
||||||
|
```
|
||||||
|
3. Oder mit Docker Compose:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Konfiguration (Umgebungsvariablen)
|
||||||
|
- `PORT` (Default: `8080`)
|
||||||
|
- `BASIC_AUTH_USER` + `BASIC_AUTH_PASS`: Aktivieren Basic Auth; die Werte werden für den Zugriff auf das Dashboard geprüft.
|
||||||
|
- `MEM_SUBTRACT_CACHE` (`true`/`false`, Default `true`): Zieht Caches aus dem Statistik-Usage, um die sichtbare RAM-Nutzung auf aktiv verwendete Speicher zu begrenzen.
|
||||||
|
|
||||||
|
## API-Endpunkte
|
||||||
|
- `GET /api/summary`: Gesamtdaten pro Stack inklusive CPU/MEM, Host-Systeminformationen.
|
||||||
|
- `GET /api/group/:name`: Detailinformationen zu einem Stack (Container-Liste, Summen).
|
||||||
|
- `GET /api/containers`: Liste aller Container (Name, Stack, CPU %, RAM MiB, Host-%).
|
||||||
|
- `GET /health`: Prüft Docker-Ping und Anzahl der Container; nützlich für Monitoring.
|
||||||
|
|
||||||
|
Alle Antworten sind JSON; Fehler mit HTTP 500 liefern ein `error`-Feld.
|
||||||
|
|
||||||
|
## Sicherheit & Betrieb
|
||||||
|
- Der Zugriff erfolgt über die statischen Dateien in `public/`, daher sollte der Container nur intern erreichbar gemacht oder Basic Auth aktiviert werden.
|
||||||
|
- Der Server benötigt nur Leserechte auf `/var/run/docker.sock`, daher ist das Mounten im `:ro`-Modus empfehlenswert.
|
||||||
|
- Neustart-policy (`unless-stopped`) sichert den Betrieb über Host-Reboots.
|
||||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
19
backend/package.json
Normal file
19
backend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "fb-tracker-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend for Facebook post tracker",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"better-sqlite3": "^9.2.2",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
616
backend/server.js
Normal file
616
backend/server.js
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
const MAX_PROFILES = 5;
|
||||||
|
const DEFAULT_PROFILE_NAMES = {
|
||||||
|
1: 'Profil 1',
|
||||||
|
2: 'Profil 2',
|
||||||
|
3: 'Profil 3',
|
||||||
|
4: 'Profil 4',
|
||||||
|
5: 'Profil 5'
|
||||||
|
};
|
||||||
|
|
||||||
|
const screenshotDir = path.join(__dirname, 'data', 'screenshots');
|
||||||
|
if (!fs.existsSync(screenshotDir)) {
|
||||||
|
fs.mkdirSync(screenshotDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware - Enhanced CORS for extension
|
||||||
|
app.use(cors({
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
// Allow larger payloads because screenshots from high-res monitors can easily exceed 10 MB
|
||||||
|
app.use(express.json({ limit: '30mb' }));
|
||||||
|
|
||||||
|
// Additional CORS headers for extension compatibility
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Database setup
|
||||||
|
const dbPath = path.join(__dirname, 'data', 'tracker.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
function clampTargetCount(value) {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Math.min(MAX_PROFILES, Math.max(1, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTargetCount(value) {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeProfileNumber(value) {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileName(profileNumber) {
|
||||||
|
return DEFAULT_PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredProfiles(targetCount) {
|
||||||
|
const count = clampTargetCount(targetCount);
|
||||||
|
return Array.from({ length: count }, (_, index) => index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProfileStatuses(requiredProfiles, checks) {
|
||||||
|
const validChecks = checks
|
||||||
|
.map(check => {
|
||||||
|
const profileNumber = sanitizeProfileNumber(check.profile_number);
|
||||||
|
if (!profileNumber) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...check,
|
||||||
|
profile_number: profileNumber,
|
||||||
|
profile_name: getProfileName(profileNumber)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const completedSet = new Set(validChecks.map(check => check.profile_number));
|
||||||
|
const checkByProfile = new Map(validChecks.map(check => [check.profile_number, check]));
|
||||||
|
|
||||||
|
const statuses = requiredProfiles.map((profileNumber, index) => {
|
||||||
|
const prerequisites = requiredProfiles.slice(0, index);
|
||||||
|
const prerequisitesMet = prerequisites.every(num => completedSet.has(num));
|
||||||
|
const isChecked = completedSet.has(profileNumber);
|
||||||
|
return {
|
||||||
|
profile_number: profileNumber,
|
||||||
|
profile_name: getProfileName(profileNumber),
|
||||||
|
status: isChecked ? 'done' : (prerequisitesMet ? 'available' : 'locked'),
|
||||||
|
checked_at: isChecked && checkByProfile.get(profileNumber)
|
||||||
|
? checkByProfile.get(profileNumber).checked_at
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
statuses,
|
||||||
|
completedChecks: validChecks,
|
||||||
|
completedSet
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcCheckedCount(postId) {
|
||||||
|
const post = db.prepare('SELECT id, target_count FROM posts WHERE id = ?').get(postId);
|
||||||
|
if (!post) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId);
|
||||||
|
const requiredProfiles = getRequiredProfiles(post.target_count);
|
||||||
|
const { statuses } = buildProfileStatuses(requiredProfiles, checks);
|
||||||
|
const checkedCount = statuses.filter(status => status.status === 'done').length;
|
||||||
|
|
||||||
|
db.prepare('UPDATE posts SET checked_count = ? WHERE id = ?').run(checkedCount, postId);
|
||||||
|
if (post.target_count !== requiredProfiles.length) {
|
||||||
|
db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database tables
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT,
|
||||||
|
target_count INTEGER NOT NULL,
|
||||||
|
checked_count INTEGER DEFAULT 0,
|
||||||
|
screenshot_path TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS checks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
post_id TEXT NOT NULL,
|
||||||
|
profile_number INTEGER,
|
||||||
|
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (post_id) REFERENCES posts(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
profile_number INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const ensureColumn = (table, column, definition) => {
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||||
|
if (!columns.some(col => col.name === column)) {
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0');
|
||||||
|
ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT');
|
||||||
|
|
||||||
|
const checkIndexes = db.prepare("PRAGMA index_list('checks')").all();
|
||||||
|
for (const idx of checkIndexes) {
|
||||||
|
if (idx.unique) {
|
||||||
|
// Skip auto indexes created from PRIMARY KEY/UNIQUE constraints; SQLite refuses to drop them
|
||||||
|
if (idx.origin !== 'c' || (idx.name && idx.name.startsWith('sqlite_autoindex'))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = db.prepare(`PRAGMA index_info('${idx.name}')`).all();
|
||||||
|
const columns = info.map(i => i.name).join(',');
|
||||||
|
if (columns === 'post_id,profile_number' || columns === 'profile_number,post_id') {
|
||||||
|
db.exec(`DROP INDEX IF EXISTS "${idx.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileStateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get();
|
||||||
|
if (!profileStateRow) {
|
||||||
|
db.prepare('INSERT INTO profile_state (id, profile_number) VALUES (1, 1)').run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPostRow(post) {
|
||||||
|
if (!post) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id);
|
||||||
|
const requiredProfiles = getRequiredProfiles(post.target_count);
|
||||||
|
const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks);
|
||||||
|
const checkedCount = statuses.filter(status => status.status === 'done').length;
|
||||||
|
const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null;
|
||||||
|
const screenshotPath = screenshotFile && fs.existsSync(screenshotFile)
|
||||||
|
? `/api/posts/${post.id}/screenshot`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (post.checked_count !== checkedCount) {
|
||||||
|
db.prepare('UPDATE posts SET checked_count = ? WHERE id = ?').run(checkedCount, post.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.target_count !== requiredProfiles.length) {
|
||||||
|
db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, post.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRequired = statuses.find(status => status.status === 'available');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
target_count: requiredProfiles.length,
|
||||||
|
checked_count: checkedCount,
|
||||||
|
checks: completedChecks,
|
||||||
|
is_complete: checkedCount >= requiredProfiles.length,
|
||||||
|
screenshot_path: screenshotPath,
|
||||||
|
required_profiles: requiredProfiles,
|
||||||
|
profile_statuses: statuses,
|
||||||
|
next_required_profile: nextRequired ? nextRequired.profile_number : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all posts
|
||||||
|
app.get('/api/posts', (req, res) => {
|
||||||
|
try {
|
||||||
|
const posts = db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM posts
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json(posts.map(mapPostRow));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get post by URL
|
||||||
|
app.get('/api/posts/by-url', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.query;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL parameter required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(url);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return res.json(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(mapPostRow(post));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/profile-state', (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get();
|
||||||
|
res.json({ profile_number: row ? row.profile_number : 1 });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/profile-state', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { profile_number } = req.body;
|
||||||
|
if (typeof profile_number === 'undefined') {
|
||||||
|
return res.status(400).json({ error: 'profile_number is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInt(profile_number, 10);
|
||||||
|
if (Number.isNaN(parsed) || parsed < 1 || parsed > 5) {
|
||||||
|
return res.status(400).json({ error: 'profile_number must be between 1 and 5' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('UPDATE profile_state SET profile_number = ? WHERE id = 1').run(parsed);
|
||||||
|
|
||||||
|
res.json({ profile_number: parsed });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/posts/:postId/screenshot', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { postId } = req.params;
|
||||||
|
const { imageData } = req.body;
|
||||||
|
|
||||||
|
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageData || typeof imageData !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'imageData is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return res.status(400).json({ error: 'Invalid image data format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = match[1];
|
||||||
|
const base64 = match[2];
|
||||||
|
const extension = mimeType === 'image/jpeg' ? 'jpg' : 'png';
|
||||||
|
const buffer = Buffer.from(base64, 'base64');
|
||||||
|
const fileName = `${postId}.${extension}`;
|
||||||
|
const filePath = path.join(screenshotDir, fileName);
|
||||||
|
|
||||||
|
if (post.screenshot_path && post.screenshot_path !== fileName) {
|
||||||
|
const existingPath = path.join(screenshotDir, post.screenshot_path);
|
||||||
|
if (fs.existsSync(existingPath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(existingPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to remove previous screenshot:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
db.prepare('UPDATE posts SET screenshot_path = ? WHERE id = ?').run(fileName, postId);
|
||||||
|
|
||||||
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||||
|
res.json(mapPostRow(updatedPost));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/posts/:postId/screenshot', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { postId } = req.params;
|
||||||
|
const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId);
|
||||||
|
|
||||||
|
if (!post || !post.screenshot_path) {
|
||||||
|
return res.status(404).json({ error: 'Screenshot not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(screenshotDir, post.screenshot_path);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'Screenshot not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.sendFile(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new post
|
||||||
|
app.post('/api/posts', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { url, title, target_count } = req.body;
|
||||||
|
|
||||||
|
const validatedTargetCount = validateTargetCount(target_count);
|
||||||
|
|
||||||
|
if (!url || !validatedTargetCount) {
|
||||||
|
return res.status(400).json({ error: 'URL and target_count are required (1-5)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
|
||||||
|
const stmt = db.prepare('INSERT INTO posts (id, url, title, target_count, checked_count, screenshot_path) VALUES (?, ?, ?, ?, 0, NULL)');
|
||||||
|
stmt.run(id, url, title || '', validatedTargetCount);
|
||||||
|
|
||||||
|
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id);
|
||||||
|
|
||||||
|
res.json(mapPostRow(post));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('UNIQUE constraint failed')) {
|
||||||
|
res.status(409).json({ error: 'Post with this URL already exists' });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/posts/:postId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { postId } = req.params;
|
||||||
|
const { target_count, title } = req.body || {};
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (typeof target_count !== 'undefined') {
|
||||||
|
const validatedTargetCount = validateTargetCount(target_count);
|
||||||
|
if (!validatedTargetCount) {
|
||||||
|
return res.status(400).json({ error: 'target_count must be between 1 and 5' });
|
||||||
|
}
|
||||||
|
updates.push('target_count = ?');
|
||||||
|
params.push(validatedTargetCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof title !== 'undefined') {
|
||||||
|
updates.push('title = ?');
|
||||||
|
params.push(title || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updates.length) {
|
||||||
|
return res.status(400).json({ error: 'No valid fields to update' });
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(postId);
|
||||||
|
|
||||||
|
const stmt = db.prepare(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`);
|
||||||
|
const result = stmt.run(...params);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
recalcCheckedCount(postId);
|
||||||
|
|
||||||
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||||
|
res.json(mapPostRow(updatedPost));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check a post for a profile
|
||||||
|
app.post('/api/posts/:postId/check', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { postId } = req.params;
|
||||||
|
const { profile_number } = req.body;
|
||||||
|
|
||||||
|
const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredProfiles = getRequiredProfiles(post.target_count);
|
||||||
|
if (post.target_count !== requiredProfiles.length) {
|
||||||
|
db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, postId);
|
||||||
|
post.target_count = requiredProfiles.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileValue = sanitizeProfileNumber(profile_number);
|
||||||
|
if (!profileValue) {
|
||||||
|
const stateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get();
|
||||||
|
profileValue = sanitizeProfileNumber(stateRow ? stateRow.profile_number : null) || requiredProfiles[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiredProfiles.includes(profileValue)) {
|
||||||
|
return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCheck = db.prepare(
|
||||||
|
'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?'
|
||||||
|
).get(postId, profileValue);
|
||||||
|
|
||||||
|
if (existingCheck) {
|
||||||
|
const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||||
|
return res.json(mapPostRow(existingPost));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredProfiles.length > 0) {
|
||||||
|
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
|
||||||
|
if (prerequisiteProfiles.length) {
|
||||||
|
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId);
|
||||||
|
const completedSet = new Set(
|
||||||
|
completedRows
|
||||||
|
.map(row => sanitizeProfileNumber(row.profile_number))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num));
|
||||||
|
if (missingPrerequisites.length) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Vorherige Profile müssen zuerst bestätigen.',
|
||||||
|
missing_profiles: missingPrerequisites
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertStmt = db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)');
|
||||||
|
insertStmt.run(postId, profileValue);
|
||||||
|
recalcCheckedCount(postId);
|
||||||
|
|
||||||
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId);
|
||||||
|
|
||||||
|
res.json(mapPostRow(updatedPost));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check by URL (for web interface auto-check)
|
||||||
|
app.post('/api/check-by-url', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { url, profile_number } = req.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = db.prepare('SELECT * FROM posts WHERE url = ?').get(url);
|
||||||
|
if (!post) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredProfiles = getRequiredProfiles(post.target_count);
|
||||||
|
if (post.target_count !== requiredProfiles.length) {
|
||||||
|
db.prepare('UPDATE posts SET target_count = ? WHERE id = ?').run(requiredProfiles.length, post.id);
|
||||||
|
post.target_count = requiredProfiles.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileValue = sanitizeProfileNumber(profile_number);
|
||||||
|
if (!profileValue) {
|
||||||
|
const stateRow = db.prepare('SELECT profile_number FROM profile_state WHERE id = 1').get();
|
||||||
|
profileValue = sanitizeProfileNumber(stateRow ? stateRow.profile_number : null) || requiredProfiles[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requiredProfiles.includes(profileValue)) {
|
||||||
|
return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCheck = db.prepare(
|
||||||
|
'SELECT id FROM checks WHERE post_id = ? AND profile_number = ?'
|
||||||
|
).get(post.id, profileValue);
|
||||||
|
|
||||||
|
if (existingCheck) {
|
||||||
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id);
|
||||||
|
return res.json(mapPostRow(updatedPost));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredProfiles.length > 0) {
|
||||||
|
const prerequisiteProfiles = requiredProfiles.slice(0, requiredProfiles.indexOf(profileValue));
|
||||||
|
if (prerequisiteProfiles.length) {
|
||||||
|
const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id);
|
||||||
|
const completedSet = new Set(
|
||||||
|
completedRows
|
||||||
|
.map(row => sanitizeProfileNumber(row.profile_number))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingPrerequisites = prerequisiteProfiles.filter(num => !completedSet.has(num));
|
||||||
|
if (missingPrerequisites.length) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Vorherige Profile müssen zuerst bestätigen.',
|
||||||
|
missing_profiles: missingPrerequisites
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('INSERT INTO checks (post_id, profile_number) VALUES (?, ?)').run(post.id, profileValue);
|
||||||
|
recalcCheckedCount(post.id);
|
||||||
|
|
||||||
|
const updatedPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(post.id);
|
||||||
|
|
||||||
|
res.json(mapPostRow(updatedPost));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete post
|
||||||
|
app.delete('/api/posts/:postId', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { postId } = req.params;
|
||||||
|
|
||||||
|
const post = db.prepare('SELECT screenshot_path FROM posts WHERE id = ?').get(postId);
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM checks WHERE post_id = ?').run(postId);
|
||||||
|
const result = db.prepare('DELETE FROM posts WHERE id = ?').run(postId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Post not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post && post.screenshot_path) {
|
||||||
|
const filePath = path.join(screenshotDir, post.screenshot_path);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to remove screenshot:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
stack-stats:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
# Optional auth:
|
||||||
|
# - BASIC_AUTH_USER=me
|
||||||
|
# - BASIC_AUTH_PASS=secret
|
||||||
|
# Optional memory behavior:
|
||||||
|
# - MEM_SUBTRACT_CACHE=true
|
||||||
|
restart: unless-stopped
|
||||||
30
extension/background.js
Normal file
30
extension/background.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// Background script for service worker
|
||||||
|
// Currently minimal, can be extended for additional functionality
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
console.log('Facebook Post Tracker extension installed');
|
||||||
|
|
||||||
|
// Set default profile if not set
|
||||||
|
chrome.storage.sync.get(['profileNumber'], (result) => {
|
||||||
|
if (!result.profileNumber) {
|
||||||
|
chrome.storage.sync.set({ profileNumber: 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
if (message && message.type === 'captureScreenshot') {
|
||||||
|
const windowId = sender && sender.tab ? sender.tab.windowId : chrome.windows.WINDOW_ID_CURRENT;
|
||||||
|
|
||||||
|
chrome.tabs.captureVisibleTab(windowId, { format: 'jpeg', quality: 80 }, (imageData) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
sendResponse({ error: chrome.runtime.lastError.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendResponse({ imageData });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
3
extension/config.js
Normal file
3
extension/config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// API Configuration
|
||||||
|
// Passe die URL an deine Deployment-Domain an
|
||||||
|
const API_BASE_URL = 'https://fb.srv.medeba-media.de';
|
||||||
64
extension/content.css
Normal file
64
extension/content.css
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
.fb-tracker-ui {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-status,
|
||||||
|
.fb-tracker-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #050505;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-status.complete .fb-tracker-text {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-count {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #ccd0d5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-add-btn,
|
||||||
|
.fb-tracker-check-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #1877f2;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-add-btn:hover,
|
||||||
|
.fb-tracker-check-btn:hover {
|
||||||
|
background: #166fe5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-check-btn {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-tracker-check-btn:hover {
|
||||||
|
background: #047857;
|
||||||
|
}
|
||||||
1128
extension/content.js
Normal file
1128
extension/content.js
Normal file
File diff suppressed because it is too large
Load Diff
4
extension/icons/icon.svg
Normal file
4
extension/icons/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<rect width="128" height="128" rx="20" fill="#1877f2"/>
|
||||||
|
<text x="64" y="90" font-size="80" text-anchor="middle" fill="white">📋</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 210 B |
35
extension/manifest.json
Normal file
35
extension/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Facebook Post Tracker",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "Track Facebook posts across multiple profiles",
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"activeTab",
|
||||||
|
"tabs"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"<all_urls>",
|
||||||
|
"https://www.facebook.com/*",
|
||||||
|
"https://facebook.com/*",
|
||||||
|
"http://localhost:3001/*",
|
||||||
|
"https://fb.srv.medeba-media.de/*"
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://www.facebook.com/*",
|
||||||
|
"https://facebook.com/*"
|
||||||
|
],
|
||||||
|
"js": ["config.js", "content.js"],
|
||||||
|
"css": ["content.css"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
119
extension/popup.html
Normal file
119
extension/popup.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Facebook Post Tracker</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
width: 300px;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ccd0d5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 12px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #65676b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.saved {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
background: #1877f2;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #166fe5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: #e4e6eb;
|
||||||
|
color: #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: #d8dadf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #65676b;
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📋 Facebook Post Tracker</h1>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<label for="profileSelect">Aktuelles Profil:</label>
|
||||||
|
<select id="profileSelect">
|
||||||
|
<option value="1">Profil 1</option>
|
||||||
|
<option value="2">Profil 2</option>
|
||||||
|
<option value="3">Profil 3</option>
|
||||||
|
<option value="4">Profil 4</option>
|
||||||
|
<option value="5">Profil 5</option>
|
||||||
|
</select>
|
||||||
|
<div class="info">Wähle das Browser-Profil aus, mit dem du aktuell arbeitest.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<button id="saveBtn">Speichern</button>
|
||||||
|
<button id="webInterfaceBtn" class="secondary">Web-Interface</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="config.js"></script>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
86
extension/popup.js
Normal file
86
extension/popup.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
const profileSelect = document.getElementById('profileSelect');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
async function fetchProfileState() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/profile-state`);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && typeof data.profile_number !== 'undefined') {
|
||||||
|
const parsed = parseInt(data.profile_number, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Profile state fetch failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfileState(profileNumber) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/profile-state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ profile_number: profileNumber })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update profile state:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(message, saved = false) {
|
||||||
|
statusEl.textContent = message;
|
||||||
|
statusEl.className = saved ? 'status saved' : 'status';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initProfileSelect() {
|
||||||
|
const backendProfile = await fetchProfileState();
|
||||||
|
if (backendProfile) {
|
||||||
|
profileSelect.value = String(backendProfile);
|
||||||
|
chrome.storage.sync.set({ profileNumber: backendProfile });
|
||||||
|
updateStatus(`Profil ${backendProfile} ausgewählt`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.storage.sync.get(['profileNumber'], (result) => {
|
||||||
|
const profileNumber = result.profileNumber || 1;
|
||||||
|
profileSelect.value = String(profileNumber);
|
||||||
|
updateStatus(`Profil ${profileNumber} ausgewählt (lokal)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initProfileSelect();
|
||||||
|
|
||||||
|
function reloadFacebookTabs() {
|
||||||
|
chrome.tabs.query({ url: ['https://www.facebook.com/*', 'https://facebook.com/*'] }, (tabs) => {
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
chrome.tabs.reload(tab.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('saveBtn').addEventListener('click', async () => {
|
||||||
|
const profileNumber = parseInt(profileSelect.value, 10);
|
||||||
|
|
||||||
|
chrome.storage.sync.set({ profileNumber }, async () => {
|
||||||
|
updateStatus(`Profil ${profileNumber} gespeichert!`, true);
|
||||||
|
await updateProfileState(profileNumber);
|
||||||
|
reloadFacebookTabs();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('webInterfaceBtn').addEventListener('click', () => {
|
||||||
|
chrome.tabs.create({ url: API_BASE_URL });
|
||||||
|
});
|
||||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "docker-stack-stats-ui",
|
||||||
|
"version": "5.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dockerode": "^4.0.4",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
458
public/app.js
Normal file
458
public/app.js
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
|
||||||
|
// i18n
|
||||||
|
const I18N = {
|
||||||
|
en: {
|
||||||
|
title: "Docker Stack Stats",
|
||||||
|
autoRefresh: "Auto-refresh",
|
||||||
|
refresh: "Refresh",
|
||||||
|
stackProject: "Stack / Project",
|
||||||
|
containers: "Containers",
|
||||||
|
cpuSum: "CPU Sum",
|
||||||
|
memSum: "Mem Sum",
|
||||||
|
memPct: "Mem %",
|
||||||
|
container: "Container",
|
||||||
|
cpu: "CPU %",
|
||||||
|
memMiB: "Mem (MiB)",
|
||||||
|
memPctSys: "Mem %",
|
||||||
|
loading: "Loading…",
|
||||||
|
system: "System",
|
||||||
|
serverVersion: "Docker",
|
||||||
|
systemRam: "System RAM",
|
||||||
|
runningContainers: "Running",
|
||||||
|
cpuCores: "CPU Cores",
|
||||||
|
totalCpu: "Total CPU",
|
||||||
|
totalMem: "Total Mem (containers)",
|
||||||
|
totalMemPct: "Total Mem %",
|
||||||
|
groupByStack: "Group by stack",
|
||||||
|
healthError: "Error loading data: "
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
title: "Docker-Stack-Statistiken",
|
||||||
|
autoRefresh: "Auto-Aktualisierung",
|
||||||
|
refresh: "Aktualisieren",
|
||||||
|
stackProject: "Stack / Projekt",
|
||||||
|
containers: "Container",
|
||||||
|
cpuSum: "CPU Summe",
|
||||||
|
memSum: "RAM Summe",
|
||||||
|
memPct: "RAM %",
|
||||||
|
container: "Container",
|
||||||
|
cpu: "CPU %",
|
||||||
|
memMiB: "RAM (MiB)",
|
||||||
|
memPctSys: "RAM %",
|
||||||
|
loading: "Lade…",
|
||||||
|
system: "System",
|
||||||
|
serverVersion: "Docker",
|
||||||
|
systemRam: "System‑RAM",
|
||||||
|
runningContainers: "Laufend",
|
||||||
|
cpuCores: "CPU‑Kerne",
|
||||||
|
totalCpu: "Gesamt‑CPU",
|
||||||
|
totalMem: "Container‑RAM gesamt",
|
||||||
|
totalMemPct: "Container‑RAM %",
|
||||||
|
groupByStack: "Nach Stack gruppieren",
|
||||||
|
healthError: "Fehler beim Laden der Daten: "
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let lang = localStorage.getItem('lang') || (navigator.language || 'en').slice(0,2);
|
||||||
|
if (!I18N[lang]) lang = 'en';
|
||||||
|
|
||||||
|
function t(key) { return (I18N[lang] && I18N[lang][key]) || I18N['en'][key] || key; }
|
||||||
|
|
||||||
|
function updateSortIndicators() {
|
||||||
|
// Remove old indicators
|
||||||
|
document.querySelectorAll('.header-row .sort .sort-ind').forEach(s => s.remove());
|
||||||
|
// Add new indicator to active key in BOTH headers (only one visible, but safe)
|
||||||
|
const addInd = (rootSel) => {
|
||||||
|
document.querySelectorAll(rootSel + ' .sort').forEach(btn => {
|
||||||
|
if (btn.dataset.sort === sort.key) {
|
||||||
|
const ind = document.createElement('span');
|
||||||
|
ind.className = 'sort-ind';
|
||||||
|
ind.textContent = sort.dir === 'asc' ? ' ▲' : ' ▼';
|
||||||
|
btn.appendChild(ind);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
addInd('#header-grouped');
|
||||||
|
addInd('#header-flat');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyI18N() {
|
||||||
|
document.getElementById('title').textContent = t('title');
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.getAttribute('data-i18n')); });
|
||||||
|
const placeholders = {
|
||||||
|
'search': lang === 'de' ? 'Suche (alle Spalten)' : 'Search (any column)',
|
||||||
|
'f-stack': lang === 'de' ? 'filtern' : 'filter',
|
||||||
|
'f-count': lang === 'de' ? '≥, ≤, = oder Text' : '≥, ≤, = or text',
|
||||||
|
'f-cpu': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =',
|
||||||
|
'f-mem': lang === 'de' ? '≥, ≤, = (MiB)' : '≥, ≤, = (MiB)',
|
||||||
|
'f-memPct': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =',
|
||||||
|
'f-container': lang === 'de' ? 'filtern' : 'filter',
|
||||||
|
'f-stack-flat': lang === 'de' ? 'filtern' : 'filter',
|
||||||
|
'f-cpu-flat': lang === 'de' ? '≥, ≤, =' : '≥, ≤, =',
|
||||||
|
'f-mem-flat': lang === 'de' ? '≥, ≤, = (MiB)' : '≥, ≤, = (MiB)',
|
||||||
|
'f-memPct-flat': lang === 'de' ? '≥, ≤, =' : '≥, ≤, ='
|
||||||
|
};
|
||||||
|
for (const [id, ph] of Object.entries(placeholders)) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.setAttribute('placeholder', ph);
|
||||||
|
}
|
||||||
|
const tg = document.querySelector('#toggleGroup .label');
|
||||||
|
if (tg) tg.textContent = t('groupByStack');
|
||||||
|
|
||||||
|
// Re-apply indicator after texts may have changed
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
// number formatting
|
||||||
|
function nf(opts={}) { return new Intl.NumberFormat(lang, opts); }
|
||||||
|
function fmtPct(v) { return nf({minimumFractionDigits: 2, maximumFractionDigits: 2}).format(v) + ' %'; }
|
||||||
|
function fmtMiB(v) { return nf({minimumFractionDigits: 2, maximumFractionDigits: 2}).format(v) + ' MiB'; }
|
||||||
|
|
||||||
|
async function fetchJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(await r.text()); return r.json(); }
|
||||||
|
|
||||||
|
function el(tag, attrs = {}, ...children) {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === 'class') e.className = v;
|
||||||
|
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
|
||||||
|
else e.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) if (c != null) e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumericFilter(input) {
|
||||||
|
const s = input.trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const m = /^(>=|<=|=|>|<)?\s*([0-9]+(?:\.[0-9]+)?)$/.exec(s);
|
||||||
|
if (!m) return (x) => String(x).toLowerCase().includes(s.toLowerCase());
|
||||||
|
const op = m[1] || '=';
|
||||||
|
const val = Number(m[2]);
|
||||||
|
return (x) => {
|
||||||
|
const n = Number(x);
|
||||||
|
if (op === '>=') return n >= val;
|
||||||
|
if (op === '<=') return n <= val;
|
||||||
|
if (op === '>') return n > val;
|
||||||
|
if (op === '<') return n < val;
|
||||||
|
return n === val;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
let current = { groups: [], total: { cpuSum: 0, memSumMiB: 0, memPctSys: 0 }, sys: {}, containers: [] };
|
||||||
|
let sort = { key: 'group', dir: 'asc' };
|
||||||
|
const openGroups = new Set(JSON.parse(localStorage.getItem('openGroups') || '[]'));
|
||||||
|
const rowMap = new Map(); // grouped rows
|
||||||
|
const contRowMap = new Map(); // flat rows
|
||||||
|
let groupByStack = (localStorage.getItem('groupByStack') ?? '1') === '1';
|
||||||
|
|
||||||
|
function applyGroupFilters(groups) {
|
||||||
|
const q = document.getElementById('search').value.trim().toLowerCase();
|
||||||
|
const fStack = document.getElementById('f-stack').value.trim().toLowerCase();
|
||||||
|
const countF = parseNumericFilter(document.getElementById('f-count').value);
|
||||||
|
const cpuF = parseNumericFilter(document.getElementById('f-cpu').value);
|
||||||
|
const memF = parseNumericFilter(document.getElementById('f-mem').value);
|
||||||
|
const memPctF= parseNumericFilter(document.getElementById('f-memPct').value);
|
||||||
|
|
||||||
|
return groups.filter(g => {
|
||||||
|
const blob = `${g.group} ${g.count} ${g.cpuSum} ${g.memSumMiB} ${g.memPctSys}`.toLowerCase();
|
||||||
|
if (q && !blob.includes(q)) return false;
|
||||||
|
if (fStack && !g.group.toLowerCase().includes(fStack)) return false;
|
||||||
|
if (countF && !countF(g.count)) return false;
|
||||||
|
if (cpuF && !cpuF(g.cpuSum)) return false;
|
||||||
|
if (memF && !memF(g.memSumMiB)) return false;
|
||||||
|
if (memPctF && !memPctF(g.memPctSys)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyContainerFilters(conts) {
|
||||||
|
const q = document.getElementById('search').value.trim().toLowerCase();
|
||||||
|
const fName = document.getElementById('f-container').value.trim().toLowerCase();
|
||||||
|
const fStack = document.getElementById('f-stack-flat').value.trim().toLowerCase();
|
||||||
|
const cpuF = parseNumericFilter(document.getElementById('f-cpu-flat').value);
|
||||||
|
const memF = parseNumericFilter(document.getElementById('f-mem-flat').value);
|
||||||
|
const memPctF= parseNumericFilter(document.getElementById('f-memPct-flat').value);
|
||||||
|
|
||||||
|
return conts.filter(c => {
|
||||||
|
const blob = `${c.name} ${c.group} ${c.cpu} ${c.memMiB} ${c.memPctSys}`.toLowerCase();
|
||||||
|
if (q && !blob.includes(q)) return false;
|
||||||
|
if (fName && !c.name.toLowerCase().includes(fName)) return false;
|
||||||
|
if (fStack && !c.group.toLowerCase().includes(fStack)) return false;
|
||||||
|
if (cpuF && !cpuF(c.cpu)) return false;
|
||||||
|
if (memF && !memF(c.memMiB)) return false;
|
||||||
|
if (memPctF && !memPctF(c.memPctSys)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySort(list) {
|
||||||
|
const arr = list.slice();
|
||||||
|
const k = sort.key, dir = sort.dir;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
let av = a[k], bv = b[k];
|
||||||
|
if (typeof av === 'string') { av = av.toLowerCase(); bv = bv.toLowerCase(); }
|
||||||
|
if (av < bv) return dir === 'asc' ? -1 : 1;
|
||||||
|
if (av > bv) return dir === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSort(key) {
|
||||||
|
if (sort.key === key) sort.dir = (sort.dir === 'asc' ? 'desc' : 'asc');
|
||||||
|
else { sort.key = key; sort.dir = 'asc'; }
|
||||||
|
updateSortIndicators();
|
||||||
|
if (groupByStack) renderGrouped(); else renderFlat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeaderClicks() {
|
||||||
|
document.querySelectorAll('.header-row .sort').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => setSort(btn.dataset.sort));
|
||||||
|
});
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshData() {
|
||||||
|
const err = document.getElementById('error');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
const summary = await fetchJSON('/api/summary');
|
||||||
|
current.total = summary.total;
|
||||||
|
current.groups = summary.groups;
|
||||||
|
current.sys = summary.sys || {};
|
||||||
|
const cont = await fetchJSON('/api/containers');
|
||||||
|
current.containers = cont.containers || [];
|
||||||
|
|
||||||
|
renderSystemAndTotals();
|
||||||
|
if (groupByStack) renderGrouped(); else renderFlat();
|
||||||
|
|
||||||
|
if (groupByStack) {
|
||||||
|
for (const g of openGroups) await refreshGroupDetail(g, true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = t('healthError') + e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystemAndTotals() {
|
||||||
|
const s = current.sys || {};
|
||||||
|
const total = current.total || {};
|
||||||
|
const box = document.getElementById('sys');
|
||||||
|
box.classList.remove('hidden');
|
||||||
|
box.innerHTML = '';
|
||||||
|
const totalMemPct = (s.memTotalMiB > 0) ? (100 * (total.memSumMiB || 0) / s.memTotalMiB) : 0;
|
||||||
|
|
||||||
|
box.appendChild(el('div', { class: 'row sys-row' },
|
||||||
|
el('div', {}, el('strong', {}, t('system'))),
|
||||||
|
el('div', { class: 'col-num' }, `${t('serverVersion')}: ${s.serverVersion || '-'}`),
|
||||||
|
el('div', { class: 'col-num' }, `${t('cpuCores')}: ${s.ncpu || '-'}`),
|
||||||
|
el('div', { class: 'col-num' }, `${t('systemRam')}: ${fmtMiB(s.memTotalMiB || 0)}`)
|
||||||
|
));
|
||||||
|
box.appendChild(el('div', { class: 'row sys-row' },
|
||||||
|
el('div', {}, el('strong', {}, t('totalCpu'))),
|
||||||
|
el('div', { class: 'col-num' }, fmtPct(total.cpuSum || 0)),
|
||||||
|
el('div', {}, el('strong', {}, t('totalMem'))),
|
||||||
|
el('div', { class: 'col-num' }, `${fmtMiB(total.memSumMiB || 0)} · ${t('totalMemPct')}: ${fmtPct(totalMemPct)}`)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGroupDetail(groupName, preserve=false) {
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON(`/api/group/${encodeURIComponent(groupName)}`);
|
||||||
|
let details = rowMap.get(groupName)?.details;
|
||||||
|
if (!details) return;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const c of data.containers) {
|
||||||
|
frag.appendChild(el('div', { class: 'row container-row grid-containers' },
|
||||||
|
el('div', { class: 'container-name col-name' }, c.name),
|
||||||
|
el('div', { class: 'col-num spacer' }, ''),
|
||||||
|
el('div', { class: 'col-num' }, fmtPct(c.cpu)),
|
||||||
|
el('div', { class: 'col-num' }, fmtMiB(c.memMiB)),
|
||||||
|
el('div', { class: 'col-num' }, fmtPct(c.memPctSys))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
details.innerHTML = '';
|
||||||
|
details.appendChild(frag);
|
||||||
|
details.dataset.loaded = '1';
|
||||||
|
} catch (e) {
|
||||||
|
let details = rowMap.get(groupName)?.details;
|
||||||
|
if (details) details.textContent = (t('healthError') + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGroupRow(g) {
|
||||||
|
const root = document.getElementById('stacks');
|
||||||
|
let entry = rowMap.get(g.group);
|
||||||
|
if (entry) return entry;
|
||||||
|
const row = el('div', { class: 'card stack' });
|
||||||
|
const header = el('div', { class: 'row grid-stacks clickable' });
|
||||||
|
const nameEl = el('div', { class: 'stack-name col-name', 'data-field': 'name' }, g.group);
|
||||||
|
const countEl= el('div', { class: 'col-num', 'data-field': 'count' }, String(g.count));
|
||||||
|
const cpuEl = el('div', { class: 'col-num', 'data-field': 'cpu' }, fmtPct(g.cpuSum));
|
||||||
|
const memEl = el('div', { class: 'col-num', 'data-field': 'mem' }, fmtMiB(g.memSumMiB));
|
||||||
|
const pctEl = el('div', { class: 'col-num', 'data-field': 'pct' }, fmtPct(g.memPctSys));
|
||||||
|
header.appendChild(nameEl); header.appendChild(countEl); header.appendChild(cpuEl); header.appendChild(memEl); header.appendChild(pctEl);
|
||||||
|
|
||||||
|
const details = el('div', { class: 'details hidden', 'data-group': g.group }, el('div', { class: 'hint' }, t('loading')));
|
||||||
|
|
||||||
|
header.addEventListener('click', async () => {
|
||||||
|
const isOpen = !details.classList.contains('hidden');
|
||||||
|
if (isOpen) { details.classList.add('hidden'); openGroups.delete(g.group); }
|
||||||
|
else { details.classList.remove('hidden'); openGroups.add(g.group); await refreshGroupDetail(g.group, true); }
|
||||||
|
localStorage.setItem('openGroups', JSON.stringify([...openGroups]));
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(header); row.appendChild(details);
|
||||||
|
root.appendChild(row);
|
||||||
|
entry = { row, header, nameEl, countEl, cpuEl, memEl, pctEl, details };
|
||||||
|
rowMap.set(g.group, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrouped() {
|
||||||
|
document.getElementById('header-grouped').classList.remove('hidden');
|
||||||
|
document.getElementById('stacks').classList.remove('hidden');
|
||||||
|
document.getElementById('header-flat').classList.add('hidden');
|
||||||
|
document.getElementById('containers').classList.add('hidden');
|
||||||
|
|
||||||
|
const filtered = applyGroupFilters(current.groups);
|
||||||
|
const sorted = applySort(filtered);
|
||||||
|
const root = document.getElementById('stacks');
|
||||||
|
const existing = new Set(rowMap.keys());
|
||||||
|
|
||||||
|
for (const g of sorted) {
|
||||||
|
const entry = ensureGroupRow(g);
|
||||||
|
entry.nameEl.textContent = g.group;
|
||||||
|
entry.countEl.textContent = String(g.count);
|
||||||
|
entry.cpuEl.textContent = fmtPct(g.cpuSum);
|
||||||
|
entry.memEl.textContent = fmtMiB(g.memSumMiB);
|
||||||
|
entry.pctEl.textContent = fmtPct(g.memPctSys);
|
||||||
|
existing.delete(g.group);
|
||||||
|
root.appendChild(entry.row);
|
||||||
|
}
|
||||||
|
for (const gone of existing) {
|
||||||
|
const entry = rowMap.get(gone);
|
||||||
|
if (entry) entry.row.remove();
|
||||||
|
rowMap.delete(gone);
|
||||||
|
openGroups.delete(gone);
|
||||||
|
}
|
||||||
|
localStorage.setItem('openGroups', JSON.stringify([...openGroups]));
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureContainerRow(key) {
|
||||||
|
const root = document.getElementById('containers');
|
||||||
|
let entry = contRowMap.get(key);
|
||||||
|
if (entry) return entry;
|
||||||
|
const row = el('div', { class: 'card cont' });
|
||||||
|
const header = el('div', { class: 'row grid-flat' });
|
||||||
|
const nameEl = el('div', { class: 'col-name', 'data-field': 'name' });
|
||||||
|
const groupEl= el('div', { class: 'col-name', 'data-field': 'group' });
|
||||||
|
const cpuEl = el('div', { class: 'col-num', 'data-field': 'cpu' });
|
||||||
|
const memEl = el('div', { class: 'col-num', 'data-field': 'mem' });
|
||||||
|
const pctEl = el('div', { class: 'col-num', 'data-field': 'pct' });
|
||||||
|
header.appendChild(nameEl); header.appendChild(groupEl); header.appendChild(cpuEl); header.appendChild(memEl); header.appendChild(pctEl);
|
||||||
|
row.appendChild(header);
|
||||||
|
root.appendChild(row);
|
||||||
|
entry = { row, nameEl, groupEl, cpuEl, memEl, pctEl };
|
||||||
|
contRowMap.set(key, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlat() {
|
||||||
|
document.getElementById('header-grouped').classList.add('hidden');
|
||||||
|
document.getElementById('stacks').classList.add('hidden');
|
||||||
|
document.getElementById('header-flat').classList.remove('hidden');
|
||||||
|
document.getElementById('containers').classList.remove('hidden');
|
||||||
|
|
||||||
|
const filtered = applyContainerFilters(current.containers);
|
||||||
|
const sorted = applySort(filtered);
|
||||||
|
const root = document.getElementById('containers');
|
||||||
|
const existing = new Set(contRowMap.keys());
|
||||||
|
|
||||||
|
for (const c of sorted) {
|
||||||
|
const key = c.id;
|
||||||
|
const entry = ensureContainerRow(key);
|
||||||
|
entry.nameEl.textContent = c.name;
|
||||||
|
entry.groupEl.textContent = c.group;
|
||||||
|
entry.cpuEl.textContent = fmtPct(c.cpu);
|
||||||
|
entry.memEl.textContent = fmtMiB(c.memMiB);
|
||||||
|
entry.pctEl.textContent = fmtPct(c.memPctSys);
|
||||||
|
existing.delete(key);
|
||||||
|
root.appendChild(entry.row);
|
||||||
|
}
|
||||||
|
for (const gone of existing) {
|
||||||
|
const entry = contRowMap.get(gone);
|
||||||
|
if (entry) entry.row.remove();
|
||||||
|
contRowMap.delete(gone);
|
||||||
|
}
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAutoRefresh() {
|
||||||
|
const chk = document.getElementById('autorefresh');
|
||||||
|
const intervalInput = document.getElementById('interval');
|
||||||
|
function schedule() {
|
||||||
|
if (timer) { clearInterval(timer); timer = null; }
|
||||||
|
if (chk.checked) {
|
||||||
|
const sec = Math.max(2, Number(intervalInput.value) || 5);
|
||||||
|
timer = setInterval(refreshData, sec * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chk.addEventListener('change', () => { localStorage.setItem('autorefresh', chk.checked ? '1' : '0'); schedule(); });
|
||||||
|
intervalInput.addEventListener('change', () => { localStorage.setItem('interval', intervalInput.value); schedule(); });
|
||||||
|
const ar = localStorage.getItem('autorefresh'); const iv = localStorage.getItem('interval');
|
||||||
|
if (ar !== null) chk.checked = ar === '1'; if (iv !== null) intervalInput.value = iv; schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupTheme() {
|
||||||
|
const btn = document.getElementById('theme');
|
||||||
|
const root = document.documentElement;
|
||||||
|
const saved = localStorage.getItem('theme') || 'dark';
|
||||||
|
root.setAttribute('data-theme', saved);
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const cur = root.getAttribute('data-theme') || 'dark';
|
||||||
|
const next = cur === 'dark' ? 'light' : 'dark';
|
||||||
|
root.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupFilters() {
|
||||||
|
const ids = ['search','f-stack','f-count','f-cpu','f-mem','f-memPct','f-container','f-stack-flat','f-cpu-flat','f-mem-flat','f-memPct-flat','lang'];
|
||||||
|
for (const id of ids) {
|
||||||
|
const elmt = document.getElementById(id);
|
||||||
|
if (!elmt) continue;
|
||||||
|
if (id === 'lang') {
|
||||||
|
elmt.value = lang;
|
||||||
|
elmt.addEventListener('change', () => { lang = elmt.value; localStorage.setItem('lang', lang); applyI18N(); renderSystemAndTotals(); if (groupByStack) renderGrouped(); else renderFlat(); });
|
||||||
|
} else {
|
||||||
|
elmt.addEventListener('input', () => { if (groupByStack) renderGrouped(); else renderFlat(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderHeaderClicks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGroupingToggle() {
|
||||||
|
const tgl = document.getElementById('toggleGroup');
|
||||||
|
tgl.checked = groupByStack;
|
||||||
|
tgl.addEventListener('change', () => {
|
||||||
|
groupByStack = tgl.checked;
|
||||||
|
localStorage.setItem('groupByStack', groupByStack ? '1' : '0');
|
||||||
|
if (groupByStack) { renderGrouped(); for (const g of openGroups) refreshGroupDetail(g, true); }
|
||||||
|
else { renderFlat(); }
|
||||||
|
updateSortIndicators();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('refresh').addEventListener('click', refreshData);
|
||||||
|
setupGroupingToggle();
|
||||||
|
setupAutoRefresh();
|
||||||
|
setupTheme();
|
||||||
|
setupFilters();
|
||||||
|
applyI18N();
|
||||||
|
updateSortIndicators();
|
||||||
|
refreshData();
|
||||||
|
});
|
||||||
62
public/index.html
Normal file
62
public/index.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Docker Stack Stats</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1 id="title">Docker Stack Stats</h1>
|
||||||
|
<div class="controls">
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="toggleGroup" checked />
|
||||||
|
<span class="slider"></span>
|
||||||
|
<span class="label" data-i18n="groupByStack">Group by stack</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" id="search" placeholder="Search (any column)">
|
||||||
|
<label><input type="checkbox" id="autorefresh" checked /> <span data-i18n="autoRefresh">Auto-refresh</span></label>
|
||||||
|
<input type="number" id="interval" min="2" value="5" />s
|
||||||
|
<button id="refresh" data-i18n="refresh">Refresh</button>
|
||||||
|
<button id="theme">Light/Dark</button>
|
||||||
|
<select id="lang">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="error" class="error hidden"></div>
|
||||||
|
|
||||||
|
<section id="sys" class="sys card"></section>
|
||||||
|
|
||||||
|
<!-- GROUPED HEADER -->
|
||||||
|
<section id="header-grouped">
|
||||||
|
<div class="row header-row grid-stacks">
|
||||||
|
<div class="col-name"><button class="sort" data-sort="group"><span data-i18n="stackProject">Stack / Project</span></button><br><input id="f-stack" class="filter" placeholder="filter"></div>
|
||||||
|
<div class="col-num"><button class="sort" data-sort="count"><span data-i18n="containers">Containers</span></button><br><input id="f-count" class="filter" placeholder="≥, ≤, = or text"></div>
|
||||||
|
<div class="col-num"><button class="sort" data-sort="cpuSum"><span data-i18n="cpuSum">CPU Sum</span></button><br><input id="f-cpu" class="filter" placeholder="≥, ≤, ="></div>
|
||||||
|
<div class="col-num"><button class="sort" data-sort="memSumMiB"><span data-i18n="memSum">Mem Sum</span></button><br><input id="f-mem" class="filter" placeholder="≥, ≤, = (MiB)"></div>
|
||||||
|
<div class="col-num"><button class="sort" data-sort="memPctSys"><span data-i18n="memPct">Mem %</span></button><br><input id="f-memPct" class="filter" placeholder="≥, ≤, ="></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FLAT HEADER -->
|
||||||
|
<section id="header-flat" class="hidden">
|
||||||
|
<div class="row header-row grid-flat">
|
||||||
|
<div class="col-name"><button class="sort" data-sort="name"><span data-i18n="container">Container</span></button><br><input id="f-container" class="filter" placeholder="filter"></div>
|
||||||
|
<div class="col-name"><button class="sort" data-sort="group"><span data-i18n="stackProject">Stack / Project</span></button><br><input id="f-stack-flat" class="filter" placeholder="filter"></div>
|
||||||
|
<div class="col-num"><button class="sort" data-sort="cpu"><span data-i18n="cpu">CPU %</span></button><br><input id="f-cpu-flat" class="filter" placeholder="≥, ≤, ="></div>
|
||||||
|
<div class="col-num"><button class="sort" data-sort="memMiB"><span data-i18n="memMiB">Mem (MiB)</span></button><br><input id="f-mem-flat" class="filter" placeholder="≥, ≤, = (MiB)"></div>
|
||||||
|
<div class="col-num"><button class="sort" data-sort="memPctSys"><span data-i18n="memPctSys">Mem %</span></button><br><input id="f-memPct-flat" class="filter" placeholder="≥, ≤, ="></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="stacks" class="stacks"></section>
|
||||||
|
<section id="containers" class="containers hidden"></section>
|
||||||
|
</div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
66
public/styles.css
Normal file
66
public/styles.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--card: #111833;
|
||||||
|
--text: #ebefff;
|
||||||
|
--muted: #a7b0d0;
|
||||||
|
--accent: #8cb0ff;
|
||||||
|
--border: #243056;
|
||||||
|
|
||||||
|
--grid-stacks: 1.4fr 90px 110px 120px 100px;
|
||||||
|
--grid-containers: 1.2fr 1fr 110px 120px 100px;
|
||||||
|
|
||||||
|
--pad-card: 6px;
|
||||||
|
--pad-row: 4px;
|
||||||
|
--gap-row: 8px;
|
||||||
|
--radius: 12px;
|
||||||
|
--fs: 14px;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f7f8ff;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #07102a;
|
||||||
|
--muted: #5a6a8a;
|
||||||
|
--accent: #345dff;
|
||||||
|
--border: #dfe6fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background: var(--bg); color: var(--text); font-size: var(--fs); }
|
||||||
|
.container { max-width: 1100px; margin: 18px auto; padding: 0 12px; }
|
||||||
|
header { display: flex; flex-wrap: wrap; gap: 6px; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||||
|
h1 { font-size: 18px; margin: 0; }
|
||||||
|
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||||
|
input[type="number"], input[type="text"], select { padding: 4px 6px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--text); height: 28px; }
|
||||||
|
input[type="number"] { width: 70px; }
|
||||||
|
input[type="text"] { width: 200px; }
|
||||||
|
button { background: var(--accent); color: #fff; border: none; padding: 6px 10px; border-radius: 10px; cursor: pointer; font-weight: 600; height: 28px; }
|
||||||
|
button:hover { filter: brightness(1.05); }
|
||||||
|
|
||||||
|
.toggle { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.toggle input { display: none; }
|
||||||
|
.toggle .slider { position: relative; width: 42px; height: 22px; background: var(--border); border-radius: 999px; cursor: pointer; }
|
||||||
|
.toggle .slider::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: transform .15s ease-in-out; }
|
||||||
|
.toggle input:checked + .slider::after { transform: translateX(20px); }
|
||||||
|
.toggle .label { user-select: none; }
|
||||||
|
|
||||||
|
.error { background: #c62828; color: #fff; padding: 6px 10px; border-radius: 10px; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--pad-card); margin-bottom: 8px; }
|
||||||
|
|
||||||
|
.row { display: grid; gap: var(--gap-row); align-items: center; padding: var(--pad-row) 4px; }
|
||||||
|
.grid-stacks { grid-template-columns: var(--grid-stacks); }
|
||||||
|
.grid-containers, .grid-flat { grid-template-columns: var(--grid-containers); }
|
||||||
|
|
||||||
|
.header-row { font-weight: 700; margin: 6px 0; padding: 6px; opacity: .95; background: color-mix(in oklab, var(--card) 88%, var(--text) 12%); border-radius: var(--radius); }
|
||||||
|
.header-row input { width: 100%; margin-top: 4px; height: 26px; }
|
||||||
|
.header-row .sort { background: transparent; color: var(--text); border: none; padding: 0; cursor: pointer; font-weight: 700; }
|
||||||
|
|
||||||
|
.stack-name, .container-name, .col-name { text-align: left; }
|
||||||
|
.col-num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.clickable { cursor: pointer; }
|
||||||
|
.details { margin-top: 4px; padding-top: 2px; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
.hint { color: var(--muted); font-style: italic; }
|
||||||
|
|
||||||
|
.sys-row { grid-template-columns: 1fr auto auto auto; }
|
||||||
208
server.js
Normal file
208
server.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import Docker from 'dockerode';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
|
||||||
|
const ENABLE_AUTH = !!(process.env.BASIC_AUTH_USER || process.env.BASIC_AUTH_PASS);
|
||||||
|
const AUTH_USER = process.env.BASIC_AUTH_USER || '';
|
||||||
|
const AUTH_PASS = process.env.BASIC_AUTH_PASS || '';
|
||||||
|
const MEM_SUBTRACT_CACHE = String(process.env.MEM_SUBTRACT_CACHE || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
function constantTimeEqual(a, b) {
|
||||||
|
const aa = Buffer.from(a || '');
|
||||||
|
const bb = Buffer.from(b || '');
|
||||||
|
if (aa.length !== bb.length) return false;
|
||||||
|
return crypto.timingSafeEqual(aa, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicAuth(req, res, next) {
|
||||||
|
if (!ENABLE_AUTH) return next();
|
||||||
|
const hdr = req.headers['authorization'] || '';
|
||||||
|
const m = /^Basic\s+([A-Za-z0-9+/=]+)$/.exec(hdr);
|
||||||
|
if (!m) return unauthorized(res);
|
||||||
|
const dec = Buffer.from(m[1], 'base64').toString('utf8');
|
||||||
|
const idx = dec.indexOf(':');
|
||||||
|
if (idx < 0) return unauthorized(res);
|
||||||
|
const u = dec.slice(0, idx);
|
||||||
|
const p = dec.slice(idx + 1);
|
||||||
|
if (constantTimeEqual(u, AUTH_USER) && constantTimeEqual(p, AUTH_PASS)) return next();
|
||||||
|
return unauthorized(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorized(res) {
|
||||||
|
res.set('WWW-Authenticate', 'Basic realm="Stack Stats"');
|
||||||
|
return res.status(401).send('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(basicAuth);
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
function calcCpuPercentUnix(_, current) {
|
||||||
|
try {
|
||||||
|
if (!current || !current.cpu_stats || !current.precpu_stats) return 0;
|
||||||
|
const cpuDelta = (current.cpu_stats.cpu_usage?.total_usage ?? 0) - (current.precpu_stats.cpu_usage?.total_usage ?? 0);
|
||||||
|
const systemDelta = (current.cpu_stats.system_cpu_usage ?? 0) - (current.precpu_stats.system_cpu_usage ?? 0);
|
||||||
|
const onlineCPUs = current.cpu_stats.online_cpus || (current.cpu_stats.cpu_usage?.percpu_usage?.length ?? 1) || 1;
|
||||||
|
if (systemDelta <= 0 || cpuDelta <= 0) return 0;
|
||||||
|
return (cpuDelta / systemDelta) * onlineCPUs * 100;
|
||||||
|
} catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToMiB(bytes) { return (bytes || 0) / (1024 * 1024); }
|
||||||
|
|
||||||
|
function stackOrProject(labels = {}) {
|
||||||
|
const stack = labels['com.docker.stack.namespace'];
|
||||||
|
const proj = labels['com.docker.compose.project'];
|
||||||
|
if (stack && stack !== '(unknown)') return stack;
|
||||||
|
if (proj && proj !== '(unknown)') return proj;
|
||||||
|
return '__no_stack__';
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryMiBFromStats(stats) {
|
||||||
|
try {
|
||||||
|
if (!stats || !stats.memory_stats) return 0;
|
||||||
|
let usage = stats.memory_stats.usage || 0;
|
||||||
|
if (String(process.env.MEM_SUBTRACT_CACHE || 'true').toLowerCase() !== 'false') {
|
||||||
|
const s = stats.memory_stats.stats || {};
|
||||||
|
const cacheLike = (typeof s.cache === 'number' ? s.cache : 0) || (typeof s.inactive_file === 'number' ? s.inactive_file : 0);
|
||||||
|
if (cacheLike > 0 && usage > cacheLike) usage -= cacheLike;
|
||||||
|
}
|
||||||
|
return bytesToMiB(Math.max(0, usage));
|
||||||
|
} catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dockerInfo() {
|
||||||
|
try {
|
||||||
|
const info = await docker.info();
|
||||||
|
return {
|
||||||
|
serverVersion: info.ServerVersion,
|
||||||
|
ncpu: info.NCPU,
|
||||||
|
memTotalMiB: Math.round((info.MemTotal || 0) / (1024*1024)),
|
||||||
|
os: info.OperatingSystem,
|
||||||
|
kernel: info.KernelVersion,
|
||||||
|
architecture: info.Architecture,
|
||||||
|
containers: { total: info.Containers, running: info.ContainersRunning, paused: info.ContainersPaused, stopped: info.ContainersStopped }
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { error: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllStats() {
|
||||||
|
const containers = await docker.listContainers({ all: false });
|
||||||
|
const info = await dockerInfo();
|
||||||
|
const sysMemMiB = info.memTotalMiB || 0;
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(containers.map(async (c) => {
|
||||||
|
const id = c.Id;
|
||||||
|
const name = (c.Names?.[0] || '').replace(/^\//, '') || c.Names?.[0] || id.slice(0,12);
|
||||||
|
const group = stackOrProject(c.Labels || {});
|
||||||
|
const container = docker.getContainer(id);
|
||||||
|
let statsRaw;
|
||||||
|
try {
|
||||||
|
statsRaw = await container.stats({ stream: false });
|
||||||
|
} catch (e) {
|
||||||
|
return { id, name, group, cpu: 0, memMiB: 0, memPctSys: 0 };
|
||||||
|
}
|
||||||
|
const cpu = calcCpuPercentUnix(null, statsRaw);
|
||||||
|
const memMiB = memoryMiBFromStats(statsRaw);
|
||||||
|
const memPctSys = sysMemMiB > 0 ? (memMiB / sysMemMiB) * 100 : 0;
|
||||||
|
return { id, name, group, cpu, memMiB, memPctSys };
|
||||||
|
}));
|
||||||
|
|
||||||
|
const data = results.filter(r => r.status === 'fulfilled').map(r => r.value);
|
||||||
|
|
||||||
|
const groups = {};
|
||||||
|
for (const row of data) {
|
||||||
|
if (!groups[row.group]) groups[row.group] = { group: row.group, cpuSum: 0, memSumMiB: 0, memPctSys: 0, containers: [] };
|
||||||
|
groups[row.group].cpuSum += row.cpu;
|
||||||
|
groups[row.group].memSumMiB += row.memMiB;
|
||||||
|
groups[row.group].containers.push(row);
|
||||||
|
}
|
||||||
|
const infoMem = info.memTotalMiB || 0;
|
||||||
|
if (infoMem > 0) {
|
||||||
|
for (const g of Object.values(groups)) {
|
||||||
|
g.memPctSys = (g.memSumMiB / infoMem) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupList = Object.values(groups)
|
||||||
|
.sort((a, b) => b.memSumMiB - a.memSumMiB)
|
||||||
|
.map(g => ({
|
||||||
|
group: g.group,
|
||||||
|
cpuSum: Number(g.cpuSum.toFixed(2)),
|
||||||
|
memSumMiB: Number(g.memSumMiB.toFixed(2)),
|
||||||
|
memPctSys: Number(g.memPctSys.toFixed(2)),
|
||||||
|
count: g.containers.length
|
||||||
|
}));
|
||||||
|
|
||||||
|
const total = groupList.reduce((acc, g) => ({
|
||||||
|
cpuSum: Number((acc.cpuSum + g.cpuSum).toFixed(2)),
|
||||||
|
memSumMiB: Number((acc.memSumMiB + g.memSumMiB).toFixed(2)),
|
||||||
|
memPctSys: Number((acc.memPctSys + g.memPctSys).toFixed(2))
|
||||||
|
}), { cpuSum: 0, memSumMiB: 0, memPctSys: 0 });
|
||||||
|
|
||||||
|
for (const g of Object.values(groups)) {
|
||||||
|
g.containers.sort((a, b) => b.cpu - a.cpu);
|
||||||
|
g.containers = g.containers.map(c => ({
|
||||||
|
id: c.id, name: c.name, group: c.group,
|
||||||
|
cpu: Number(c.cpu.toFixed(2)),
|
||||||
|
memMiB: Number(c.memMiB.toFixed(2)),
|
||||||
|
memPctSys: Number((c.memPctSys || 0).toFixed(2))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, groups, groupList, sys: info, containers: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpoints
|
||||||
|
app.get('/api/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { total, groupList, sys } = await fetchAllStats();
|
||||||
|
res.json({ total, groups: groupList, sys });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/group/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groups } = await fetchAllStats();
|
||||||
|
const g = groups[req.params.name];
|
||||||
|
if (!g) return res.json({ group: req.params.name, containers: [], cpuSum: 0, memSumMiB: 0, memPctSys: 0 });
|
||||||
|
res.json({ group: g.group, containers: g.containers, cpuSum: g.cpuSum, memSumMiB: g.memSumMiB, memPctSys: g.memPctSys });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/containers', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { containers, sys } = await fetchAllStats();
|
||||||
|
res.json({ containers, sys });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message || String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await docker.ping();
|
||||||
|
const cont = await docker.listContainers({ all: false });
|
||||||
|
res.json({ ok: true, containers: cont.length });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ ok: false, error: String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`docker-stack-stats-ui listening on :${PORT}`);
|
||||||
|
});
|
||||||
9
web/Dockerfile
Normal file
9
web/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY index.html /usr/share/nginx/html/
|
||||||
|
COPY style.css /usr/share/nginx/html/
|
||||||
|
COPY app.js /usr/share/nginx/html/
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
887
web/app.js
Normal file
887
web/app.js
Normal file
@@ -0,0 +1,887 @@
|
|||||||
|
const API_URL = 'https://fb.srv.medeba-media.de/api';
|
||||||
|
|
||||||
|
let currentProfile = 1;
|
||||||
|
let currentTab = 'pending';
|
||||||
|
let posts = [];
|
||||||
|
let profilePollTimer = null;
|
||||||
|
|
||||||
|
const MAX_PROFILES = 5;
|
||||||
|
const PROFILE_NAMES = {
|
||||||
|
1: 'Profil 1',
|
||||||
|
2: 'Profil 2',
|
||||||
|
3: 'Profil 3',
|
||||||
|
4: 'Profil 4',
|
||||||
|
5: 'Profil 5'
|
||||||
|
};
|
||||||
|
|
||||||
|
const screenshotModal = document.getElementById('screenshotModal');
|
||||||
|
const screenshotModalContent = document.getElementById('screenshotModalContent');
|
||||||
|
const screenshotModalImage = document.getElementById('screenshotModalImage');
|
||||||
|
const screenshotModalClose = document.getElementById('screenshotModalClose');
|
||||||
|
const screenshotModalBackdrop = document.getElementById('screenshotModalBackdrop');
|
||||||
|
let screenshotModalLastFocus = null;
|
||||||
|
let screenshotModalPreviousOverflow = '';
|
||||||
|
let screenshotModalZoomed = false;
|
||||||
|
|
||||||
|
function getProfileName(profileNumber) {
|
||||||
|
if (!profileNumber) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return PROFILE_NAMES[profileNumber] || `Profil ${profileNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return date.toLocaleString('de-DE');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Ungültiges Datum:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUrlForDisplay(url) {
|
||||||
|
if (!url) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const pathname = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||||
|
const search = parsed.search || '';
|
||||||
|
return `${parsed.hostname}${pathname}${search}`;
|
||||||
|
} catch (error) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequiredProfiles(post) {
|
||||||
|
if (Array.isArray(post.required_profiles) && post.required_profiles.length) {
|
||||||
|
return post.required_profiles
|
||||||
|
.map((value) => {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.min(MAX_PROFILES, Math.max(1, parsed));
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedTarget = parseInt(post.target_count, 10);
|
||||||
|
const count = Number.isNaN(parsedTarget)
|
||||||
|
? 1
|
||||||
|
: Math.min(MAX_PROFILES, Math.max(1, parsedTarget));
|
||||||
|
|
||||||
|
return Array.from({ length: count }, (_, index) => index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChecks(checks) {
|
||||||
|
if (!Array.isArray(checks)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks
|
||||||
|
.map((check) => {
|
||||||
|
if (!check) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInt(check.profile_number, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileNumber = Math.min(MAX_PROFILES, Math.max(1, parsed));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...check,
|
||||||
|
profile_number: profileNumber,
|
||||||
|
profile_name: check.profile_name || getProfileName(profileNumber),
|
||||||
|
checked_at: check.checked_at || null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTime = a.checked_at ? new Date(a.checked_at).getTime() : 0;
|
||||||
|
const bTime = b.checked_at ? new Date(b.checked_at).getTime() : 0;
|
||||||
|
if (aTime === bTime) {
|
||||||
|
return a.profile_number - b.profile_number;
|
||||||
|
}
|
||||||
|
return aTime - bTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePostStatus(post, profileNumber = currentProfile) {
|
||||||
|
const requiredProfiles = normalizeRequiredProfiles(post);
|
||||||
|
const checks = normalizeChecks(post.checks);
|
||||||
|
|
||||||
|
const backendStatuses = Array.isArray(post.profile_statuses) ? post.profile_statuses : [];
|
||||||
|
let profileStatuses = backendStatuses
|
||||||
|
.map((status) => {
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = parseInt(status.profile_number, 10);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profileNumberValue = Math.min(MAX_PROFILES, Math.max(1, parsed));
|
||||||
|
let normalizedStatus = status.status;
|
||||||
|
if (normalizedStatus !== 'done' && normalizedStatus !== 'available') {
|
||||||
|
normalizedStatus = 'locked';
|
||||||
|
}
|
||||||
|
const check = checks.find((item) => item.profile_number === profileNumberValue) || null;
|
||||||
|
return {
|
||||||
|
profile_number: profileNumberValue,
|
||||||
|
profile_name: status.profile_name || getProfileName(profileNumberValue),
|
||||||
|
status: normalizedStatus,
|
||||||
|
checked_at: status.checked_at || (check ? check.checked_at : null) || null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (profileStatuses.length !== requiredProfiles.length) {
|
||||||
|
const checksByProfile = new Map(checks.map((check) => [check.profile_number, check]));
|
||||||
|
const completedSet = new Set(checks.map((check) => check.profile_number));
|
||||||
|
|
||||||
|
profileStatuses = requiredProfiles.map((value, index) => {
|
||||||
|
const prerequisites = requiredProfiles.slice(0, index);
|
||||||
|
const prerequisitesMet = prerequisites.every((profile) => completedSet.has(profile));
|
||||||
|
const check = checksByProfile.get(value) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile_number: value,
|
||||||
|
profile_name: getProfileName(value),
|
||||||
|
status: check ? 'done' : (prerequisitesMet ? 'available' : 'locked'),
|
||||||
|
checked_at: check ? check.checked_at : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const checksByProfile = new Map(checks.map((check) => [check.profile_number, check]));
|
||||||
|
profileStatuses = requiredProfiles.map((value) => {
|
||||||
|
const status = profileStatuses.find((item) => item.profile_number === value);
|
||||||
|
if (!status) {
|
||||||
|
const check = checksByProfile.get(value) || null;
|
||||||
|
return {
|
||||||
|
profile_number: value,
|
||||||
|
profile_name: getProfileName(value),
|
||||||
|
status: check ? 'done' : 'locked',
|
||||||
|
checked_at: check ? check.checked_at : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'done') {
|
||||||
|
const check = checksByProfile.get(value) || null;
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
checked_at: status.checked_at || (check ? check.checked_at : null) || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'available') {
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
checked_at: status.checked_at || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
status: 'locked',
|
||||||
|
checked_at: status.checked_at || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedProfilesSet = new Set(
|
||||||
|
profileStatuses
|
||||||
|
.filter((status) => status.status === 'done')
|
||||||
|
.map((status) => status.profile_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkedCount = profileStatuses.filter((status) => status.status === 'done').length;
|
||||||
|
const targetCount = profileStatuses.length;
|
||||||
|
const isComplete = profileStatuses.every((status) => status.status === 'done');
|
||||||
|
const nextRequiredProfile = profileStatuses.find((status) => status.status === 'available') || null;
|
||||||
|
const isCurrentProfileRequired = requiredProfiles.includes(profileNumber);
|
||||||
|
const isCurrentProfileDone = profileStatuses.some((status) => status.profile_number === profileNumber && status.status === 'done');
|
||||||
|
const canCurrentProfileCheck = profileStatuses.some((status) => status.profile_number === profileNumber && status.status === 'available');
|
||||||
|
|
||||||
|
const waitingForStatuses = profileStatuses.filter((status) => status.profile_number < profileNumber && status.status !== 'done');
|
||||||
|
const waitingForProfiles = waitingForStatuses.map((status) => status.profile_number);
|
||||||
|
const waitingForNames = waitingForStatuses.map((status) => status.profile_name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
requiredProfiles,
|
||||||
|
profileStatuses,
|
||||||
|
checks,
|
||||||
|
completedProfilesSet,
|
||||||
|
checkedCount,
|
||||||
|
targetCount,
|
||||||
|
isComplete,
|
||||||
|
isCurrentProfileRequired,
|
||||||
|
isCurrentProfileDone,
|
||||||
|
canCurrentProfileCheck,
|
||||||
|
waitingForProfiles,
|
||||||
|
waitingForNames,
|
||||||
|
nextRequiredProfile,
|
||||||
|
profileNumber
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScreenshotModalSize() {
|
||||||
|
if (!screenshotModalContent || !screenshotModalImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenshotModalZoomed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!screenshotModalImage.src) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const padding = 48;
|
||||||
|
const viewportWidth = Math.max(320, window.innerWidth * 0.95);
|
||||||
|
const viewportHeight = Math.max(280, window.innerHeight * 0.92);
|
||||||
|
const naturalWidth = screenshotModalImage.naturalWidth || screenshotModalImage.width || 0;
|
||||||
|
const naturalHeight = screenshotModalImage.naturalHeight || screenshotModalImage.height || 0;
|
||||||
|
|
||||||
|
const targetWidth = Math.min(Math.max(320, naturalWidth + padding), viewportWidth);
|
||||||
|
const targetHeight = Math.min(Math.max(260, naturalHeight + padding), viewportHeight);
|
||||||
|
|
||||||
|
screenshotModalContent.style.width = `${targetWidth}px`;
|
||||||
|
screenshotModalContent.style.height = `${targetHeight}px`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProfileState() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/profile-state`);
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && typeof data.profile_number !== 'undefined') {
|
||||||
|
const parsed = parseInt(data.profile_number, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Profilstatus konnte nicht geladen werden:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushProfileState(profileNumber) {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/profile-state`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ profile_number: profileNumber })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Profilstatus konnte nicht gespeichert werden:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProfileNumber(profileNumber, { fromBackend = false } = {}) {
|
||||||
|
if (!profileNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('profileSelect').value = String(profileNumber);
|
||||||
|
|
||||||
|
if (currentProfile === profileNumber) {
|
||||||
|
if (!fromBackend) {
|
||||||
|
pushProfileState(profileNumber);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentProfile = profileNumber;
|
||||||
|
localStorage.setItem('profileNumber', currentProfile);
|
||||||
|
|
||||||
|
if (!fromBackend) {
|
||||||
|
pushProfileState(currentProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load profile from localStorage
|
||||||
|
function loadProfile() {
|
||||||
|
fetchProfileState().then((backendProfile) => {
|
||||||
|
if (backendProfile) {
|
||||||
|
applyProfileNumber(backendProfile, { fromBackend: true });
|
||||||
|
} else {
|
||||||
|
const saved = localStorage.getItem('profileNumber');
|
||||||
|
if (saved) {
|
||||||
|
applyProfileNumber(parseInt(saved, 10) || 1, { fromBackend: true });
|
||||||
|
} else {
|
||||||
|
applyProfileNumber(1, { fromBackend: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save profile to localStorage
|
||||||
|
function saveProfile(profileNumber) {
|
||||||
|
applyProfileNumber(profileNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProfilePolling() {
|
||||||
|
if (profilePollTimer) {
|
||||||
|
clearInterval(profilePollTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
profilePollTimer = setInterval(async () => {
|
||||||
|
const backendProfile = await fetchProfileState();
|
||||||
|
if (backendProfile && backendProfile !== currentProfile) {
|
||||||
|
applyProfileNumber(backendProfile, { fromBackend: true });
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile selector change handler
|
||||||
|
document.getElementById('profileSelect').addEventListener('change', (e) => {
|
||||||
|
saveProfile(parseInt(e.target.value, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentTab = btn.dataset.tab;
|
||||||
|
renderPosts();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch all posts
|
||||||
|
async function fetchPosts() {
|
||||||
|
try {
|
||||||
|
showLoading();
|
||||||
|
const response = await fetch(`${API_URL}/posts`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch posts');
|
||||||
|
}
|
||||||
|
|
||||||
|
posts = await response.json();
|
||||||
|
renderPosts();
|
||||||
|
} catch (error) {
|
||||||
|
showError('Fehler beim Laden der Beiträge. Stelle sicher, dass das Backend läuft.');
|
||||||
|
console.error('Error fetching posts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render posts
|
||||||
|
function renderPosts() {
|
||||||
|
hideLoading();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
const container = document.getElementById('postsContainer');
|
||||||
|
|
||||||
|
const postItems = posts.map((post) => ({
|
||||||
|
post,
|
||||||
|
status: computePostStatus(post)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let filteredItems = postItems;
|
||||||
|
|
||||||
|
if (currentTab === 'pending') {
|
||||||
|
filteredItems = postItems.filter((item) => item.status.canCurrentProfileCheck && !item.status.isComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredItems.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">🎉</div>
|
||||||
|
<div class="empty-state-text">
|
||||||
|
${currentTab === 'pending' ? 'Keine offenen Beiträge!' : 'Noch keine Beiträge erfasst.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filteredItems
|
||||||
|
.map(({ post, status }) => createPostCard(post, status))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
filteredItems.forEach(({ post, status }) => {
|
||||||
|
const card = document.getElementById(`post-${post.id}`);
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBtn = card.querySelector('.btn-open');
|
||||||
|
if (openBtn) {
|
||||||
|
openBtn.addEventListener('click', () => openPost(post.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const editBtn = card.querySelector('.btn-edit-target');
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.addEventListener('click', () => promptEditTarget(post.id, status.targetCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBtn = card.querySelector('.btn-delete');
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.addEventListener('click', () => deletePost(post.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenshotEl = card.querySelector('.post-screenshot');
|
||||||
|
if (screenshotEl && screenshotEl.dataset.screenshot) {
|
||||||
|
const url = screenshotEl.dataset.screenshot;
|
||||||
|
const openHandler = () => openScreenshotModal(url);
|
||||||
|
screenshotEl.addEventListener('click', openHandler);
|
||||||
|
screenshotEl.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openScreenshotModal(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openScreenshotModal(url) {
|
||||||
|
if (!screenshotModal || !url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotModalLastFocus = document.activeElement;
|
||||||
|
screenshotModalImage.src = url;
|
||||||
|
resetScreenshotZoom();
|
||||||
|
screenshotModalPreviousOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
screenshotModal.removeAttribute('hidden');
|
||||||
|
screenshotModal.classList.add('open');
|
||||||
|
|
||||||
|
if (screenshotModalClose) {
|
||||||
|
screenshotModalClose.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenshotModalImage.complete) {
|
||||||
|
applyScreenshotModalSize();
|
||||||
|
} else {
|
||||||
|
const handleLoad = () => {
|
||||||
|
applyScreenshotModalSize();
|
||||||
|
};
|
||||||
|
screenshotModalImage.addEventListener('load', handleLoad, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScreenshotModal() {
|
||||||
|
if (!screenshotModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!screenshotModal.classList.contains('open')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetScreenshotZoom();
|
||||||
|
screenshotModal.classList.remove('open');
|
||||||
|
screenshotModal.setAttribute('hidden', '');
|
||||||
|
screenshotModalImage.src = '';
|
||||||
|
document.body.style.overflow = screenshotModalPreviousOverflow;
|
||||||
|
|
||||||
|
if (screenshotModalLastFocus && typeof screenshotModalLastFocus.focus === 'function') {
|
||||||
|
screenshotModalLastFocus.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetScreenshotZoom() {
|
||||||
|
screenshotModalZoomed = false;
|
||||||
|
if (screenshotModalContent) {
|
||||||
|
screenshotModalContent.classList.remove('zoomed');
|
||||||
|
screenshotModalContent.style.width = '';
|
||||||
|
screenshotModalContent.style.height = '';
|
||||||
|
screenshotModalContent.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
|
}
|
||||||
|
if (screenshotModalImage) {
|
||||||
|
screenshotModalImage.classList.remove('zoomed');
|
||||||
|
}
|
||||||
|
applyScreenshotModalSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScreenshotZoom() {
|
||||||
|
if (!screenshotModalContent || !screenshotModalImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
screenshotModalZoomed = !screenshotModalZoomed;
|
||||||
|
screenshotModalContent.classList.toggle('zoomed', screenshotModalZoomed);
|
||||||
|
screenshotModalImage.classList.toggle('zoomed', screenshotModalZoomed);
|
||||||
|
|
||||||
|
if (screenshotModalZoomed) {
|
||||||
|
screenshotModalContent.style.width = 'min(95vw, 1300px)';
|
||||||
|
screenshotModalContent.style.height = '92vh';
|
||||||
|
screenshotModalContent.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||||
|
} else {
|
||||||
|
screenshotModalContent.style.width = '';
|
||||||
|
screenshotModalContent.style.height = '';
|
||||||
|
applyScreenshotModalSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create post card HTML
|
||||||
|
function createPostCard(post, status) {
|
||||||
|
const createdDate = formatDateTime(post.created_at) || '—';
|
||||||
|
|
||||||
|
const resolvedScreenshotPath = post.screenshot_path
|
||||||
|
? (post.screenshot_path.startsWith('http')
|
||||||
|
? post.screenshot_path
|
||||||
|
: `${API_URL.replace(/\/api$/, '')}${post.screenshot_path}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const screenshotHtml = resolvedScreenshotPath
|
||||||
|
? `
|
||||||
|
<div class="post-screenshot" data-screenshot="${escapeHtml(resolvedScreenshotPath)}" role="button" tabindex="0" aria-label="Screenshot anzeigen">
|
||||||
|
<img src="${escapeHtml(resolvedScreenshotPath)}" alt="Screenshot zum Beitrag" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const profileRowsHtml = status.profileStatuses.map((profileStatus) => {
|
||||||
|
const classes = ['profile-line', `profile-line--${profileStatus.status}`];
|
||||||
|
let label = 'Wartet';
|
||||||
|
if (profileStatus.status === 'done') {
|
||||||
|
const doneDate = formatDateTime(profileStatus.checked_at);
|
||||||
|
label = doneDate ? `Erledigt (${doneDate})` : 'Erledigt';
|
||||||
|
} else if (profileStatus.status === 'available') {
|
||||||
|
label = 'Bereit';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="${classes.join(' ')}">
|
||||||
|
<span class="profile-line__name">${escapeHtml(profileStatus.profile_name)}</span>
|
||||||
|
<span class="profile-line__status">${escapeHtml(label)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const infoMessages = [];
|
||||||
|
if (!status.isCurrentProfileRequired) {
|
||||||
|
infoMessages.push('Dieses Profil muss den Beitrag nicht bestätigen.');
|
||||||
|
} else if (status.isCurrentProfileDone) {
|
||||||
|
infoMessages.push('Für dein Profil erledigt.');
|
||||||
|
} else if (status.waitingForNames.length) {
|
||||||
|
infoMessages.push(`Wartet auf: ${status.waitingForNames.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoHtml = infoMessages.length
|
||||||
|
? `
|
||||||
|
<div class="post-hints">
|
||||||
|
${infoMessages.map((message) => `
|
||||||
|
<div class="post-hint${message.includes('erledigt') ? ' post-hint--success' : ''}">
|
||||||
|
${escapeHtml(message)}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const directLinkHtml = post.url
|
||||||
|
? `
|
||||||
|
<div class="post-link">
|
||||||
|
<span class="post-link__label">Direktlink:</span>
|
||||||
|
<a href="${escapeHtml(post.url)}" target="_blank" rel="noopener noreferrer" class="post-link__anchor">
|
||||||
|
${escapeHtml(formatUrlForDisplay(post.url))}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const openButtonHtml = status.canCurrentProfileCheck
|
||||||
|
? `
|
||||||
|
<button class="btn btn-success btn-open">Beitrag öffnen & abhaken</button>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="post-card ${status.isComplete ? 'complete' : ''}" id="post-${post.id}">
|
||||||
|
<div class="post-header">
|
||||||
|
<div class="post-title">${escapeHtml(post.title || '')}</div>
|
||||||
|
<div class="post-status ${status.isComplete ? 'complete' : ''}">
|
||||||
|
${status.checkedCount}/${status.targetCount} ${status.isComplete ? '✓' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-meta">
|
||||||
|
<div class="post-info">Erstellt: ${escapeHtml(createdDate)}</div>
|
||||||
|
<div class="post-target">
|
||||||
|
<span>Benötigte Profile: ${status.targetCount}</span>
|
||||||
|
<button type="button" class="btn btn-inline btn-edit-target">Anzahl ändern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${directLinkHtml}
|
||||||
|
|
||||||
|
<div class="post-profiles">
|
||||||
|
${profileRowsHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${infoHtml}
|
||||||
|
|
||||||
|
${screenshotHtml}
|
||||||
|
|
||||||
|
<div class="post-actions">
|
||||||
|
${openButtonHtml}
|
||||||
|
${post.url ? `
|
||||||
|
<a href="${escapeHtml(post.url)}" target="_blank" rel="noopener noreferrer" class="btn btn-secondary btn-direct-link">
|
||||||
|
Direkt öffnen
|
||||||
|
</a>
|
||||||
|
` : ''}
|
||||||
|
<button class="btn btn-danger btn-delete">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open post and auto-check
|
||||||
|
async function openPost(postId) {
|
||||||
|
const post = posts.find((item) => item.id === postId);
|
||||||
|
if (!post) {
|
||||||
|
alert('Beitrag konnte nicht gefunden werden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post.url) {
|
||||||
|
alert('Für diesen Beitrag ist kein Direktlink vorhanden.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = computePostStatus(post);
|
||||||
|
|
||||||
|
if (!status.isCurrentProfileRequired) {
|
||||||
|
alert('Dieses Profil muss den Beitrag nicht bestätigen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.isCurrentProfileDone) {
|
||||||
|
window.open(post.url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.canCurrentProfileCheck) {
|
||||||
|
if (status.waitingForNames.length) {
|
||||||
|
alert(`Wartet auf: ${status.waitingForNames.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
alert('Der Beitrag kann aktuell nicht abgehakt werden.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/check-by-url`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: post.url,
|
||||||
|
profile_number: currentProfile
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 409) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (data && data.error) {
|
||||||
|
alert(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Failed to check post');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(post.url, '_blank');
|
||||||
|
|
||||||
|
await fetchPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Abhaken des Beitrags');
|
||||||
|
console.error('Error checking post:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptEditTarget(postId, currentTarget) {
|
||||||
|
const defaultValue = Number.isFinite(currentTarget) ? String(currentTarget) : '';
|
||||||
|
const newValue = prompt(`Neue Anzahl (1-${MAX_PROFILES}):`, defaultValue);
|
||||||
|
|
||||||
|
if (newValue === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInt(newValue, 10);
|
||||||
|
if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) {
|
||||||
|
alert(`Bitte gib eine Zahl zwischen 1 und ${MAX_PROFILES} ein.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/posts/${postId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_count: parsed })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
const message = data && data.error ? data.error : 'Fehler beim Aktualisieren der Anzahl.';
|
||||||
|
alert(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Aktualisieren der Anzahl.');
|
||||||
|
console.error('Error updating target count:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete post
|
||||||
|
async function deletePost(postId) {
|
||||||
|
if (!confirm('Beitrag wirklich löschen?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/posts/${postId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete post');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchPosts();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Fehler beim Löschen des Beitrags');
|
||||||
|
console.error('Error deleting post:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function showLoading() {
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
document.getElementById('postsContainer').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoading() {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
document.getElementById('postsContainer').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const errorEl = document.getElementById('error');
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
document.getElementById('error').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
if (unsafe === null || unsafe === undefined) {
|
||||||
|
unsafe = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof unsafe !== 'string') {
|
||||||
|
unsafe = String(unsafe);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-check on page load if URL parameter is present
|
||||||
|
function checkAutoCheck() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const autoCheckUrl = urlParams.get('check');
|
||||||
|
|
||||||
|
if (autoCheckUrl) {
|
||||||
|
// Try to check this URL automatically
|
||||||
|
fetch(`${API_URL}/check-by-url`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: decodeURIComponent(autoCheckUrl),
|
||||||
|
profile_number: currentProfile
|
||||||
|
})
|
||||||
|
}).then(() => {
|
||||||
|
// Remove the parameter from URL
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
fetchPosts();
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenshotModalClose) {
|
||||||
|
screenshotModalClose.addEventListener('click', closeScreenshotModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenshotModalBackdrop) {
|
||||||
|
screenshotModalBackdrop.addEventListener('click', closeScreenshotModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenshotModalImage) {
|
||||||
|
screenshotModalImage.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
toggleScreenshotZoom();
|
||||||
|
});
|
||||||
|
|
||||||
|
screenshotModalImage.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleScreenshotZoom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
screenshotModalImage.setAttribute('tabindex', '0');
|
||||||
|
screenshotModalImage.setAttribute('role', 'button');
|
||||||
|
screenshotModalImage.setAttribute('aria-label', 'Screenshot vergrößern oder verkleinern');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (screenshotModalZoomed) {
|
||||||
|
resetScreenshotZoom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeScreenshotModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (screenshotModal && screenshotModal.classList.contains('open') && !screenshotModalZoomed) {
|
||||||
|
applyScreenshotModalSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
loadProfile();
|
||||||
|
startProfilePolling();
|
||||||
|
fetchPosts();
|
||||||
|
checkAutoCheck();
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
setInterval(fetchPosts, 30000);
|
||||||
46
web/index.html
Normal file
46
web/index.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Facebook Post Tracker - Web Interface</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>📋 Facebook Post Tracker</h1>
|
||||||
|
<div class="profile-selector">
|
||||||
|
<label for="profileSelect">Dein Profil:</label>
|
||||||
|
<select id="profileSelect">
|
||||||
|
<option value="1">Profil 1</option>
|
||||||
|
<option value="2">Profil 2</option>
|
||||||
|
<option value="3">Profil 3</option>
|
||||||
|
<option value="4">Profil 4</option>
|
||||||
|
<option value="5">Profil 5</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="pending">Offene Beiträge</button>
|
||||||
|
<button class="tab-btn" data-tab="all">Alle Beiträge</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loading" class="loading">Lade Beiträge...</div>
|
||||||
|
<div id="error" class="error" style="display: none;"></div>
|
||||||
|
|
||||||
|
<div id="postsContainer" class="posts-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="screenshotModal" class="screenshot-modal" hidden>
|
||||||
|
<div id="screenshotModalBackdrop" class="screenshot-modal__backdrop" aria-hidden="true"></div>
|
||||||
|
<div id="screenshotModalContent" class="screenshot-modal__content" role="dialog" aria-modal="true">
|
||||||
|
<button type="button" id="screenshotModalClose" class="screenshot-modal__close" aria-label="Schließen">×</button>
|
||||||
|
<img id="screenshotModalImage" alt="Screenshot zum Beitrag" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
534
web/style.css
Normal file
534
web/style.css
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
color: #050505;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-selector label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-selector select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ccd0d5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: #1877f2;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 20px;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card.complete {
|
||||||
|
opacity: 0.7;
|
||||||
|
border-left: 4px solid #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #050505;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-status.complete {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #65676b;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-target {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #2563eb;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-inline:hover,
|
||||||
|
.btn-inline:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-link__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-link__anchor {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-link__anchor:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-profiles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-line__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-line__status {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-line--done {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
border-left: 3px solid #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-line--available {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-left: 3px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-line--locked {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
border-left: 3px solid #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-hints {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #92400e;
|
||||||
|
background: #fff7ed;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-hint--success {
|
||||||
|
color: #065f46;
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-screenshot {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: clamp(180px, 24vw, 240px);
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-screenshot:hover,
|
||||||
|
.post-screenshot:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-screenshot img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #e4e6eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-badge:not(.checked) {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-badge.checked {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-badge.current {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
border: 2px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #1877f2;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #166fe5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #059669;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e4e6eb;
|
||||||
|
color: #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d8dadf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__content {
|
||||||
|
position: relative;
|
||||||
|
width: min(95vw, 1300px);
|
||||||
|
max-height: 92vh;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 30px 60px rgba(15, 23, 42, 0.45);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__content.zoomed {
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__content img {
|
||||||
|
max-width: calc(95vw - 96px);
|
||||||
|
max-height: calc(92vh - 120px);
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 44px rgba(8, 15, 35, 0.45);
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__content.zoomed {
|
||||||
|
cursor: zoom-out;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__content.zoomed img {
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: #111827;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: background 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__close:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-modal__close:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-text {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #65676b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user