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