diff --git a/README.md b/README.md index 2f43568..1fe3775 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,19 @@ Automated status dashboard for all VanityOnTour services, hosted on Hostinger at ## What it monitors -- **Websites**: vanityontour.de, news, wiki, staysense, landing -- **Tools**: N8N, Nginx Proxy Manager, Uptime Kuma, Stats, App Backend, CloudPanel -- **APIs**: RSS News API, StaySense API -- **iOS App**: Vanity Expense Logbook (version, rating, last update) -- **SSL**: Certificate expiry for all main domains +- **Websites**: vanityontour.de, News, Wiki, StaySense, StaySense Landing, + VanityCast Landing, Kurzlinks (`go.`) +- **Tools**: N8N, beide Nginx Proxy Manager, Uptime Kuma, Grafana, App Backend, + CloudPanel, Forgejo, Postiz, Shlink Admin +- **APIs**: RSS News API, StaySense API, Shlink API +- **iOS Apps**: Vanity Expense Logbook und VanityCast (Version, Bewertung, + letztes Update) +- **SSL**: Zertifikatslaufzeit aller Hauptdomains + +`Shlink Admin` ist bewusst ein schwaches Signal: Traefik beantwortet die +BasicAuth **vor** dem Proxy, die erwartete `401` belegt also nur, dass Traefik +läuft und der Schutz noch greift — nicht, dass der Container dahinter lebt. Dass +Shlink selbst antwortet, zeigt `Shlink API` (`go.vanityontour.de/rest/health`). ## How it works @@ -39,6 +47,21 @@ scp scripts/check_status.py hetzner:/opt/check_status.py ssh hetzner '/opt/run_status_check.sh' # einmal testweise ausführen ``` +### Deployment einer Änderung an `public/` + +`index.html`, CSS und Icons werden vom Cron **nicht** mit ausgerollt — der +kopiert nur `status.json`. Der Deploy-Key für Hostinger liegt ausschließlich auf +hetzner (`/root/.ssh/hostinger_ed25519`), der Weg geht deshalb über hetzner: + +```bash +scp public/index.html hetzner:/opt/public/index.html +ssh hetzner 'scp -P 65002 -i /root/.ssh/hostinger_ed25519 /opt/public/index.html \ + u982551092@82.198.228.98:/home/u982551092/domains/status.vanityontour.de/public_html/index.html' +``` + +Vorher prüfen, ob die Live-Datei noch dem Repo-Stand entspricht — sie wird sonst +stillschweigend überschrieben. + ## Dienste hinter dem Management-VPN Seit der VPN-Absicherung antworten die Admin-Oberflächen öffentlich nur noch mit diff --git a/public/index.html b/public/index.html index 95c8d60..53bb894 100644 --- a/public/index.html +++ b/public/index.html @@ -169,6 +169,9 @@ gap: 16px; align-items: flex-start; } + /* Mehrere Apps stehen untereinander in einer Karte — Trennlinie zwischen + ihnen, aber nicht unter der letzten. */ + .app-card + .app-card { border-top: 1px solid var(--border); } .app-icon { width: 72px; height: 72px; @@ -384,14 +387,31 @@ function renderSection(title, services, sslData) { `; } -function renderApp(app) { - if (!app || app.error) { +function renderApps(data) { + // Neuere status.json liefert "apps" als Liste; das alte Einzelfeld "app" + // bleibt als Rückfallebene, falls die Seite mal vor dem Skript aktualisiert wird. + const apps = (data.apps && data.apps.length) ? data.apps : (data.app ? [data.app] : []); + const usable = apps.filter(a => a && !a.error); + + if (!usable.length) { return `
-
iOS App
+
iOS Apps
App Store Daten nicht verfügbar
`; } + + return ` +
+
+ iOS Apps + ${usable.length} im App Store +
+ ${usable.map(appCard).join("")} +
`; +} + +function appCard(app) { const rating = app.rating ? app.rating.toFixed(1) : "—"; const storeUrl = app.store_url || "#"; const iconHtml = app.icon_url @@ -401,12 +421,13 @@ function renderApp(app) { ? new Date(app.last_update).toLocaleDateString("de-DE", { year:"numeric", month:"long", day:"numeric" }) : "—"; + // Eine frisch veröffentlichte App hat noch keine Bewertungen — dann "☆ — (0)" + // zu zeigen sähe nach einem Fehler aus. + const ratingTag = app.rating + ? `${renderStars(app.rating)} ${rating} (${app.rating_count || 0})` + : `Noch keine Bewertungen`; + return ` -
-
- iOS App - App Store -
${iconHtml}
@@ -414,7 +435,7 @@ function renderApp(app) {
${app.seller || ""}
v${app.version || "—"} - ${renderStars(app.rating)} ${rating} (${app.rating_count || 0}) + ${ratingTag} ${app.price || "—"} ${app.category || "—"} iOS ${app.min_ios || "—"}+ @@ -424,8 +445,7 @@ function renderApp(app) { Im App Store ansehen ↗
-
-
`; +
`; } async function loadStatus() { @@ -466,7 +486,7 @@ function render(data) { renderSection("Websites", websites, ssl) + renderSection("Tools & Automation", tools, ssl) + renderSection("APIs", apis, ssl) + - renderApp(data.app) + + renderApps(data) + `
Uptime Kuma diff --git a/scripts/check_status.py b/scripts/check_status.py index be1ada2..137a679 100644 --- a/scripts/check_status.py +++ b/scripts/check_status.py @@ -41,6 +41,8 @@ WEBSITES = [ {"name": "Wiki", "url": "https://wiki.vanityontour.de", "group": "websites", "expect": [200, 301, 302]}, {"name": "StaySense", "url": "https://staysense.vanityontour.de", "group": "websites", "expect": [200, 301, 302]}, {"name": "StaySense Landing", "url": "https://landing.staysense.vanityontour.de", "group": "websites", "expect": [200, 301, 302]}, + {"name": "VanityCast Landing", "url": "https://vanitycast.vanityontour.de", "group": "websites", "expect": [200, 301, 302]}, + {"name": "Kurzlinks", "url": "https://go.vanityontour.de", "group": "websites", "expect": [200, 301, 302]}, {"name": "N8N Automation", "url": "https://n8n.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "http://10.10.0.13:5678"}, {"name": "Nginx Proxy Manager", "url": "https://nginx.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "http://10.10.0.13:81"}, {"name": "Nginx Proxy Mgr (VoT)", "url": "https://ng.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "http://10.10.0.12:81"}, @@ -52,8 +54,18 @@ WEBSITES = [ # Grafana und CloudPanel laufen auf hetzner selbst — localhost statt 10.10.0.14, # damit die Prüfung auch bei liegendem Tunnel noch stimmt. {"name": "CloudPanel", "url": "https://cp.blog.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "https://127.0.0.1:8443"}, + # Dienste auf dem Hostinger VPS (Traefik). Forgejo und Postiz haben bewusst + # noch öffentliche Logins und werden deshalb ganz normal von außen geprüft. + {"name": "Forgejo", "url": "https://git.giertz.biz", "group": "tools", "expect": [200, 301, 302]}, + {"name": "Postiz", "url": "https://social.vanityontour.de", "group": "tools", "expect": [200, 301, 302, 307]}, + # Achtung, schwaches Signal: Traefik beantwortet die BasicAuth vor dem Proxy. + # Die 401 belegt also nur, dass Traefik läuft und der Auth-Schutz noch greift — + # nicht, dass der Container dahinter lebt. Dass Shlink selbst antwortet, zeigt + # der Health-Endpunkt weiter unten. Eine 200 hier hiesse: Schutz ist weg. + {"name": "Shlink Admin", "url": "https://shlink.vanityontour.de", "group": "tools", "expect": [401]}, {"name": "RSS News API", "url": "https://news.vanityontour.de/health", "group": "apis", "expect": [200]}, {"name": "StaySense API", "url": "https://staysense.vanityontour.de/api/health", "group": "apis", "expect": [200]}, + {"name": "Shlink API", "url": "https://go.vanityontour.de/rest/health", "group": "apis", "expect": [200]}, ] SSL_DOMAINS = [ @@ -63,10 +75,16 @@ SSL_DOMAINS = [ "n8n.vanityontour.de", "staysense.vanityontour.de", "server.vanityontour.de", + "vanitycast.vanityontour.de", + "go.vanityontour.de", + "git.giertz.biz", ] -APP_STORE_ID = "6742772476" APP_STORE_COUNTRY = "de" +APP_STORE_IDS = [ + "6742772476", # Vanity Expense Logbook + "6781152682", # VanityCast +] def check_http(url: str, expected: list[int]) -> dict: @@ -109,17 +127,18 @@ def check_ssl(domain: str) -> dict: return {"valid": False, "expires_in_days": None, "expires_at": None, "error": str(e)[:60]} -def fetch_app_store() -> dict: - url = f"https://itunes.apple.com/lookup?id={APP_STORE_ID}&country={APP_STORE_COUNTRY}" +def fetch_app_store(app_id: str) -> dict: + url = f"https://itunes.apple.com/lookup?id={app_id}&country={APP_STORE_COUNTRY}" try: with urllib.request.urlopen(url, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) if not data.get("results"): - return {"error": "No results"} + return {"id": app_id, "error": "No results"} r = data["results"][0] release_raw = r.get("currentVersionReleaseDate", "") release_fmt = release_raw[:10] if release_raw else None return { + "id": app_id, "name": r.get("trackName"), "version": r.get("version"), "rating": r.get("averageUserRating"), @@ -136,7 +155,7 @@ def fetch_app_store() -> dict: "error": None, } except Exception as e: - return {"error": str(e)[:80]} + return {"id": app_id, "error": str(e)[:80]} def main(): @@ -162,8 +181,12 @@ def main(): print(f" {domain}: {d.get('expires_in_days', '?')} days") print("Fetching App Store data...") - app = fetch_app_store() - print(f" {app.get('name', 'ERROR')} v{app.get('version', '?')} ⭐{app.get('rating', '?')}") + apps = [fetch_app_store(app_id) for app_id in APP_STORE_IDS] + for a in apps: + if a.get("error"): + print(f" {a.get('id')}: FEHLER {a['error']}") + else: + print(f" {a.get('name')} v{a.get('version', '?')} ⭐{a.get('rating') or '—'}") # Overall status downs = [r for r in results if r["status"] == "down"] @@ -180,7 +203,10 @@ def main(): "overall": overall, "services": results, "ssl": ssl_results, - "app": app, + # "app" bleibt als Einzelfeld erhalten, weil index.html nur manuell auf + # Hostinger aktualisiert wird — eine ältere Seite würde sonst leer laufen. + "app": apps[0] if apps else {"error": "keine App konfiguriert"}, + "apps": apps, } with open(OUTPUT_FILE, "w", encoding="utf-8") as f: