Explorer
/opt/struktur/portal/app.py
← Zurück ↓ Download
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 &middot; 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">&#9881;&#65039;</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">&#128640;</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">&#128421;&#65039;</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">&#129302;</div>
      <div class="card-title">KI &amp; 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">&#128640;</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">&#128218;</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[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)