first commit

This commit is contained in:
2025-11-11 10:36:31 +01:00
commit 80eb037b56
25 changed files with 4509 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}

7
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

4
extension/icons/icon.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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: "SystemRAM",
runningContainers: "Laufend",
cpuCores: "CPUKerne",
totalCpu: "GesamtCPU",
totalMem: "ContainerRAM gesamt",
totalMemPct: "ContainerRAM %",
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
View 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
View 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
View 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
View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// 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
View 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
View 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%;
}
}