import json
import os
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from flask import Flask, jsonify, render_template_string
app = Flask(__name__)
WATCHER_SUMMARY_URL = os.environ.get(
"WORKER_WATCHER_SUMMARY_URL",
"http://127.0.0.1:8088/api/v1/alerts/summary",
)
@app.route('/api/worker-alerts')
def worker_alerts():
"""Server-side, read-only bridge to the Worker-Watcher summary."""
try:
request = Request(WATCHER_SUMMARY_URL, headers={"Accept": "application/json"})
with urlopen(request, timeout=4) as response: # noqa: S310 - URL is server configuration, never browser input
payload = json.loads(response.read().decode("utf-8"))
required = {"overall_status", "jobs_total", "active_alerts", "generated_at"}
if not isinstance(payload, dict) or not required.issubset(payload):
raise ValueError("invalid watcher summary")
return jsonify({"ok": True, "summary": payload})
except (HTTPError, URLError, TimeoutError, OSError, ValueError, TypeError, json.JSONDecodeError):
app.logger.warning("Worker-Watcher-Zusammenfassung nicht verfügbar")
return jsonify({"ok": False, "error": "watcher_unavailable"}), 503
HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Solutions</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0d14; color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 4rem 0;
}
.portal-header { width: 100%; max-width: 1000px; padding: 0 2rem; display: flex; justify-content: flex-start; align-items: flex-start; margin-bottom: 4rem; }
.logo { font-size: 2.5rem; font-weight: 700; color: #fff; margin-bottom: 0.5rem; letter-spacing: -1px; }
.logo span { color: #3b82f6; }
.subtitle { color: #475569; font-size: 0.95rem; }
.refresh-button { display: inline-flex; align-items: center; justify-content: center; padding: 0.45rem 1.1rem; background: rgba(59,130,246,0.12); border: 1px solid rgba(96,165,250,0.45); border-radius: 8px; color: #93c5fd; font-size: 0.82rem; font-weight: 600; cursor: pointer; }
.refresh-button:hover { background: rgba(59,130,246,0.22); border-color: rgba(96,165,250,0.7); }
.refresh-button:disabled { opacity: 0.7; cursor: wait; }
.spinner { display: inline-block; width: 12px; height: 12px; margin-right: 6px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; vertical-align: -2px; animation: portal-spin 0.7s linear infinite; }
.spinner[hidden] { display: none; }
@keyframes portal-spin { to { transform: rotate(360deg); } }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; width: 100%; max-width: 1000px; padding: 0 2rem; }
.card { background: #161b27; border: 1px solid #1e2330; border-radius: 16px; padding: 2.5rem 2rem; text-decoration: none; color: inherit; transition: all 0.2s; text-align: center; display: block; }
.card:hover { border-color: #3b82f6; transform: translateY(-4px); background: #1a2035; box-shadow: 0 8px 32px rgba(59,130,246,0.15); }
.card-icon { font-size: 3rem; margin-bottom: 1.2rem; }
.card-title { font-size: 1.3rem; font-weight: 600; color: #fff; margin-bottom: 0.5rem; }
.card-desc { font-size: 0.85rem; color: #fff; line-height: 1.6; }
.card-action-btn { display: inline-block; margin-top: 1.2rem; padding: 0.4rem 1.4rem; background: rgba(59,130,246,0.12); border: 1px solid rgba(96,165,250,0.45); border-radius: 8px; color: #93c5fd; font-size: 0.82rem; font-weight: 600; letter-spacing: 0.02em; transition: background 0.15s, border-color 0.15s; }
.card-btn { display: inline-block; margin-top: 1.2rem; padding: 0.35rem 1.1rem; background: rgba(59,130,246,0.12); border: 1px solid rgba(59,130,246,0.35); border-radius: 8px; color: #3b82f6; font-size: 0.82rem; font-weight: 500; }
.card-local:hover { border-color: #3b82f6; box-shadow: 0 8px 32px rgba(59,130,246,0.15); }
.card-root-btn { display: inline-block; margin-top: 1.2rem; padding: 0.35rem 1.1rem; background: rgba(22,163,74,0.12); border: 1px solid rgba(22,163,74,0.35); border-radius: 8px; color: #16a34a; font-size: 0.82rem; font-weight: 500; }
.card-root:hover { border-color: #16a34a; box-shadow: 0 8px 32px rgba(22,163,74,0.15); }
.card:hover .card-action-btn { background: rgba(59,130,246,0.22); border-color: rgba(96,165,250,0.7); }
.card-button-row { display: flex; justify-content: center; gap: 0.75rem; flex-wrap: wrap; margin-top: 1.2rem; }
.card-button-row .card-action-btn { margin-top: 0; text-decoration: none; cursor: pointer; }
.alert-card { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(250px, 0.85fr) minmax(300px, 1.7fr) minmax(190px, 0.75fr); align-items: center; column-gap: 1.35rem; text-align: left; padding: 1.35rem 1.5rem; min-height: 0; border-color: #334155; }
.alert-card .alert-primary { display: flex; align-items: center; gap: 1rem; min-width: 0; }
.alert-card .card-icon { flex: 0 0 auto; font-size: 2.25rem; margin-bottom: 0; }
.alert-card .card-title { margin-bottom: 0; }
.alert-card .alert-headline { margin-top: 0.35rem; color: #f8fafc; font-size: 1rem; font-weight: 700; line-height: 1.35; }
.alert-card .alert-content { min-width: 0; }
.alert-card .alert-counts { color: #fff; font-size: 0.83rem; line-height: 1.45; }
.alert-card .alert-problem { margin-top: 0.55rem; color: #f8fafc; font-size: 0.84rem; line-height: 1.4; overflow-wrap: anywhere; }
.alert-card .alert-meta { margin-top: 0.5rem; color: #fff; font-size: 0.78rem; line-height: 1.4; }
.alert-card .alert-action { display: flex; flex-direction: column; align-items: flex-end; justify-content: center; gap: 0.55rem; min-width: 0; padding-left: 1.1rem; border-left: 1px solid #334155; }
.alert-card .alert-action .refresh-button { margin-top: 0; min-width: 144px; }
.alert-card .alert-refresh-meta { max-width: 14rem; color: #fff; font-size: 0.75rem; line-height: 1.4; text-align: right; overflow-wrap: anywhere; }
.alert-card .alert-refresh-meta-label, .alert-card .alert-refresh-meta time, .alert-card .alert-refresh-meta time span { display: block; }
.alert-card .alert-refresh-message { min-height: 1.35em; color: #60a5fa; font-size: 0.75rem; line-height: 1.35; text-align: right; }
.alert-card .alert-refresh-message.error { color: #f87171; }
.alert-card .alert-action .card-action-btn { margin-top: 0.2rem; }
.alert-card .alert-stale { color: #fbbf24; font-weight: 600; }
.alert-card.status-healthy { border-color: #246b48; }
.alert-card.status-healthy .alert-state { color: #54d98a; }
.alert-card.status-warning { border-color: #725d2c; }
.alert-card.status-warning .alert-state { color: #f4c95d; }
.alert-card.status-critical { border-color: #713b3b; }
.alert-card.status-critical .alert-state { color: #ff7777; }
.alert-card.status-unavailable { border-color: #7f1d1d; }
.alert-card.status-unavailable .alert-state { color: #ff7777; }
.alert-card.status-unknown { border-color: #475569; }
.alert-card.status-unknown .alert-state { color: #fff; }
.section { width: 100%; max-width: 1000px; padding: 0 2rem; margin-top: 2.5rem; }
.section-title { font-size: 1rem; font-weight: 700; color: #fff; text-transform: uppercase; letter-spacing: 2px; margin-bottom: 1rem; border-left: 3px solid #a855f7; padding-left: 0.75rem; }
.sub-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.card-sub { padding: 1.5rem; border-radius: 12px; }
.card-sub .card-icon { font-size: 2rem; margin-bottom: 0.8rem; }
.card-sub .card-title { font-size: 1rem; }
.card-sub .card-action-btn { font-size: 0.78rem; padding: 0.3rem 1rem; margin-top: 0.8rem; }
footer { margin-top: 4rem; color: #1e2330; font-size: 0.75rem; }
@media (max-width: 900px) { .grid, .sub-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 900px) { .portal-header { margin-bottom: 2rem; } }
@media (max-width: 700px) { .portal-header { margin-bottom: 2rem; } .grid, .sub-grid { grid-template-columns: 1fr; } }
@media (max-width: 900px) { .alert-card { grid-template-columns: minmax(0, 1fr); row-gap: 0.85rem; } .alert-card .alert-primary, .alert-card .alert-content, .alert-card .alert-action { grid-column: 1; } .alert-card .alert-action { align-items: flex-start; width: 100%; padding: 0.85rem 0 0; border-left: 0; border-top: 1px solid #334155; } .alert-card .alert-refresh-meta, .alert-card .alert-refresh-message { max-width: 100%; text-align: left; } }
</style>
</head>
<body>
<div class="portal-header">
<div><div class="logo">Agent <span>Solutions</span></div><div class="subtitle">Zentrale Steuerung · VPS IONOS</div></div>
</div>
<div class="grid">
<div class="card alert-card status-unknown" id="worker-alert-card" aria-live="polite">
<div class="alert-primary">
<div class="card-icon" id="worker-alert-icon" aria-hidden="true">⚠</div>
<div class="alert-summary">
<div class="card-title">Worker-Warnungen</div>
<div id="worker-alert-headline" class="alert-headline">Wird geladen …</div>
</div>
</div>
<div id="worker-alert-content" class="alert-content">
<div class="alert-meta">Worker-Überwachung wird geprüft.</div>
</div>
<div class="alert-action">
<button id="worker-alert-refresh" class="refresh-button" type="button" aria-busy="false"><span id="worker-alert-refresh-spinner" class="spinner" aria-hidden="true" hidden></span><span id="worker-alert-refresh-label">Aktualisieren</span></button>
<div class="alert-refresh-meta"><span class="alert-refresh-meta-label">Zuletzt aktualisiert:</span><time id="worker-alert-last-updated"><span id="worker-alert-last-updated-at">Unbekannt</span><span id="worker-alert-last-updated-age"></span></time></div>
<div id="worker-alert-refresh-message" class="alert-refresh-message" aria-live="polite"></div>
<a class="card-action-btn" href="https://worker.agentsolutions-mallorca.com" target="_blank" rel="noopener noreferrer">Details öffnen</a>
</div>
</div>
<div class="card">
<div class="card-icon">⚙️</div>
<div class="card-title">Agents</div>
<div class="card-desc">Hermes, Worker, MiroFish und weitere Agenten-Dienste</div>
<div class="card-button-row">
<a class="card-action-btn" href="https://agents.agentsolutions-mallorca.com" target="_blank" rel="noopener noreferrer">Öffnen</a>
</div>
</div>
<a class="card" href="https://projects.agentsolutions-mallorca.com" target="_blank" rel="noopener noreferrer">
<div class="card-icon">🚀</div>
<div class="card-title">Projekte</div>
<div class="card-desc">YouTube Research, Lead Engine, Social Media Agent, Telefon Agent</div>
<div class="card-action-btn">Öffnen</div>
</a>
<a class="card" href="https://vpsverwaltung.agentsolutions-mallorca.com" target="_blank" rel="noopener noreferrer">
<div class="card-icon">🖥️</div>
<div class="card-title">VPS Verwaltung</div>
<div class="card-desc">Dateien, API-Keys, Logs, Sicherheit und Backups</div>
<div class="card-action-btn">Öffnen</div>
</a>
<a class="card" href="https://ki-automation.agentsolutions-mallorca.com" target="_blank" rel="noopener noreferrer">
<div class="card-icon">🤖</div>
<div class="card-title">KI & Automation</div>
<div class="card-desc">n8n, Paperclip, Portainer und KI-nahe Workflows</div>
<div class="card-action-btn">Öffnen</div>
</a>
<a class="card" href="https://projects.agentsolutions-mallorca.com/kundenprojekte" target="_blank" rel="noopener noreferrer">
<div class="card-icon">🚀</div>
<div class="card-title">Kundenprojekte</div>
<div class="card-desc">Fünf ausgearbeitete Lösungsangebote mit Details, Planwerten und verifizierten Projektständen.</div>
<div class="card-action-btn">Öffnen</div>
</a>
<a class="card" href="https://agents.agentsolutions-mallorca.com/wissen" target="_blank" rel="noopener noreferrer">
<div class="card-icon">📚</div>
<div class="card-title">Wissen</div>
<div class="card-desc">Wissensfragen, Wissenspipeline, Wissensbestand, Quellen und Prüfstatus.</div>
<div class="card-action-btn">Öffnen</div>
</a>
</div>
<footer>agentsolutions-mallorca.com</footer>
<script>
const watcherAlertState = { summary: null, lastSuccessAt: null, failures: 0, inFlight: null };
const watcherAlertCard = document.getElementById('worker-alert-card');
const watcherAlertIcon = document.getElementById('worker-alert-icon');
const watcherAlertHeadline = document.getElementById('worker-alert-headline');
const watcherAlertContent = document.getElementById('worker-alert-content');
const workerAlertRefreshButton = document.getElementById('worker-alert-refresh');
const workerAlertRefreshLabel = document.getElementById('worker-alert-refresh-label');
const workerAlertRefreshSpinner = document.getElementById('worker-alert-refresh-spinner');
const workerAlertRefreshMessage = document.getElementById('worker-alert-refresh-message');
const workerAlertLastUpdatedAt = document.getElementById('worker-alert-last-updated-at');
const workerAlertLastUpdatedAge = document.getElementById('worker-alert-last-updated-age');
const escapeHtml = value => String(value ?? '—').replace(/[&<>"']/g, char => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
const statusText = {healthy:'Keine Warnungen', warning:'Warnungen vorhanden', critical:'Warnungen vorhanden', unavailable:'Worker-Überwachung nicht erreichbar', unknown:'Zustand unbekannt'};
const statusIcon = {healthy:'✓', warning:'⚠', critical:'⚠', unavailable:'⚠', unknown:'?'};
const relativeAge = seconds => {
if (seconds === null || seconds === undefined || Number.isNaN(Number(seconds))) return 'Unbekannt';
const value = Math.max(0, Math.floor(Number(seconds)));
if (value < 60) return `vor ${value} ${value === 1 ? 'Sekunde' : 'Sekunden'}`;
const minutes = Math.floor(value / 60);
if (minutes < 60) return `vor ${minutes} ${minutes === 1 ? 'Minute' : 'Minuten'}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`;
const days = Math.floor(hours / 24);
return `vor ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
};
const relativeTime = value => {
if (!value) return 'Unbekannt';
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? 'Unbekannt' : relativeAge((Date.now() - timestamp) / 1000);
};
const formatLocalTimestamp = value => {
if (!value) return 'Unbekannt';
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) return 'Unbekannt';
return new Intl.DateTimeFormat('de-DE', {
day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit',
hourCycle:'h23', timeZone:'Europe/Madrid'
}).format(new Date(timestamp));
};
const updateWorkerAlertRefreshMeta = () => {
if (!watcherAlertState.lastSuccessAt) {
workerAlertLastUpdatedAt.textContent = 'Unbekannt';
workerAlertLastUpdatedAge.textContent = '';
return;
}
workerAlertLastUpdatedAt.textContent = formatLocalTimestamp(watcherAlertState.lastSuccessAt);
workerAlertLastUpdatedAge.textContent = relativeAge((Date.now() - watcherAlertState.lastSuccessAt) / 1000);
};
let workerAlertRefreshMessageTimer = null;
const setWorkerAlertRefreshMessage = (message, error = false, duration = 0) => {
if (workerAlertRefreshMessageTimer) window.clearTimeout(workerAlertRefreshMessageTimer);
workerAlertRefreshMessage.textContent = message;
workerAlertRefreshMessage.classList.toggle('error', error);
if (duration > 0) {
workerAlertRefreshMessageTimer = window.setTimeout(() => {
workerAlertRefreshMessage.textContent = '';
workerAlertRefreshMessage.classList.remove('error');
workerAlertRefreshMessageTimer = null;
}, duration);
}
};
const setWorkerAlertRefreshUi = loading => {
workerAlertRefreshButton.disabled = loading;
workerAlertRefreshButton.setAttribute('aria-busy', String(loading));
workerAlertRefreshSpinner.hidden = !loading;
workerAlertRefreshLabel.textContent = loading ? 'Wird aktualisiert …' : 'Aktualisieren';
watcherAlertCard.setAttribute('aria-busy', String(loading));
};
const problemAge = problem => problem && problem.age_seconds !== null && problem.age_seconds !== undefined
? relativeAge(problem.age_seconds).replace(/^vor /, 'seit ')
: 'seit Unbekannt';
const countLabel = (count, singular, plural = `${singular}e`) => Number(count) > 0 ? `${escapeHtml(count)} ${Number(count) === 1 ? singular : plural}` : '';
const hasSummaryShape = summary => summary && typeof summary === 'object'
&& ['overall_status','jobs_total','active_alerts','generated_at'].every(key => Object.prototype.hasOwnProperty.call(summary, key));
const updateCard = (summary, options = {}) => {
const requestedStatus = options.unavailable ? 'unavailable' : String(summary?.overall_status || 'unknown');
const status = Object.prototype.hasOwnProperty.call(statusText, requestedStatus) ? requestedStatus : 'unknown';
watcherAlertCard.className = `card alert-card status-${status}`;
watcherAlertIcon.textContent = statusIcon[status];
const staleNotice = options.stale ? '<div class="alert-stale">Letzter Stand – Worker-Überwachung nicht erreichbar</div>' : '';
const lastCheck = summary?.last_cycle_finished_at ? `Letzte Prüfung: ${escapeHtml(relativeTime(summary.last_cycle_finished_at))}` : 'Letzte Prüfung: Unbekannt';
const affected = Number(summary?.active_alerts || 0);
watcherAlertHeadline.textContent = status === 'healthy'
? 'Keine betroffenen Aufgaben'
: status === 'unavailable' && options.unavailable
? 'Worker-Überwachung nicht erreichbar'
: `${affected} betroffene Aufgaben`;
let content = `${staleNotice}`;
if (status === 'healthy') {
content += `<div class="alert-counts">${escapeHtml(summary.jobs_total)} Aufgaben überwacht</div>`;
} else if (status === 'unavailable' && options.unavailable) {
const successful = summary?.last_successful_cycle_at || summary?.last_cycle_finished_at;
content += `<div class="alert-counts">Letzter bekannter Stand: ${escapeHtml(affected)} betroffene Aufgaben</div>`;
content += `<div class="alert-meta">Letzte erfolgreiche Prüfung: ${escapeHtml(relativeTime(successful))}</div>`;
} else if (status === 'unknown') {
content += '<div class="alert-counts">Keine belastbare Warnbewertung verfügbar</div>';
} else {
const counts = [
countLabel(summary.failed, 'fehlgeschlagen', 'fehlgeschlagen'),
countLabel(summary.degraded, 'gestört', 'gestört'),
countLabel(summary.unavailable, 'nicht verfügbar', 'nicht verfügbar'),
summary.unavailable ? '' : countLabel(summary.unknown, 'unbekannt', 'unbekannt'),
countLabel(summary.stale, 'veraltet', 'veraltet'),
countLabel(summary.overdue, 'überfällig', 'überfällig')
].filter(Boolean).join(' · ');
content += `<div class="alert-counts">${counts || 'Keine aktiven Warnungen'}</div>`;
if (summary.oldest_problem) {
content += `<div class="alert-problem"><strong>Ältestes Problem:</strong> ${escapeHtml(summary.oldest_problem.display_name || summary.oldest_problem.job_key)} · ${escapeHtml(problemAge(summary.oldest_problem))}</div>`;
}
}
if (status !== 'unavailable' || !options.unavailable) content += `<div class="alert-meta">${escapeHtml(lastCheck)}</div>`;
watcherAlertContent.innerHTML = content;
};
const updateCardUnavailable = () => {
if (watcherAlertState.summary) {
const unavailable = watcherAlertState.failures >= 3;
updateCard(watcherAlertState.summary, {stale:true, unavailable});
}
else updateCard({}, {unavailable:true});
};
function refreshWorkerAlerts(manual = false) {
if (watcherAlertState.inFlight) return watcherAlertState.inFlight;
setWorkerAlertRefreshUi(true);
if (manual) setWorkerAlertRefreshMessage('Wird aktualisiert …');
watcherAlertState.inFlight = (async () => {
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('/api/worker-alerts', {cache:'no-store', signal:controller.signal});
if (!response.ok) throw new Error('watcher_unavailable');
const envelope = await response.json();
if (!envelope.ok || !hasSummaryShape(envelope.summary)) throw new Error('invalid_watcher_summary');
watcherAlertState.summary = envelope.summary;
watcherAlertState.lastSuccessAt = Date.now();
watcherAlertState.failures = 0;
updateCard(envelope.summary);
updateWorkerAlertRefreshMeta();
if (manual) setWorkerAlertRefreshMessage('Aktualisiert', false, 2000);
else setWorkerAlertRefreshMessage('');
} catch (error) {
watcherAlertState.failures += 1;
updateCardUnavailable();
setWorkerAlertRefreshMessage('Aktualisierung fehlgeschlagen', true, 2500);
} finally {
window.clearTimeout(timeout);
}
})().finally(() => {
setWorkerAlertRefreshUi(false);
watcherAlertState.inFlight = null;
});
return watcherAlertState.inFlight;
}
function launchLocalHermes(url) {
var frame = document.createElement('iframe');
frame.style.display = 'none';
frame.setAttribute('aria-hidden', 'true');
frame.src = url;
document.body.appendChild(frame);
window.setTimeout(function () {
if (frame.parentNode) {
frame.parentNode.removeChild(frame);
}
}, 1500);
}
workerAlertRefreshButton.addEventListener('click', () => refreshWorkerAlerts(true));
refreshWorkerAlerts();
window.setInterval(refreshWorkerAlerts, 60000);
window.setInterval(updateWorkerAlertRefreshMeta, 1000);
</script>
</body>
</html>"""
@app.route('/')
def index():
return render_template_string(HTML)
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5000, debug=False)