From f0211e0e5c113218a925ba897e79eaed372badf3 Mon Sep 17 00:00:00 2001 From: OliverGiertz Date: Mon, 6 Apr 2026 17:15:55 +0000 Subject: [PATCH] feat: initial VanityOnTour status page - HTML dashboard with auto-refresh (5min countdown) - Python checker: HTTP status, SSL expiry, App Store data - GitHub Actions: runs every 5 min, deploys via FTP to Hostinger - Monitors 13 services + iOS app + 6 SSL certs Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/update-status.yml | 45 +++ README.md | 35 ++ public/index.html | 507 ++++++++++++++++++++++++++++ public/status.json | 232 +++++++++++++ scripts/check_status.py | 160 +++++++++ 5 files changed, 979 insertions(+) create mode 100644 .github/workflows/update-status.yml create mode 100644 README.md create mode 100644 public/index.html create mode 100644 public/status.json create mode 100644 scripts/check_status.py diff --git a/.github/workflows/update-status.yml b/.github/workflows/update-status.yml new file mode 100644 index 0000000..f7284e1 --- /dev/null +++ b/.github/workflows/update-status.yml @@ -0,0 +1,45 @@ +name: Update Status Page + +on: + schedule: + - cron: "*/5 * * * *" # every 5 minutes + workflow_dispatch: # manual trigger + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run status checks + run: python3 scripts/check_status.py + + - name: Commit updated status.json + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add public/status.json + git diff --cached --quiet || git commit -m "chore: update status $(date -u '+%Y-%m-%d %H:%M UTC')" + git push + + - name: Deploy to Hostinger via FTP + uses: SamKirkland/FTP-Deploy-Action@v4.3.5 + with: + server: ${{ secrets.FTP_SERVER }} + username: ${{ secrets.FTP_USERNAME }} + password: ${{ secrets.FTP_PASSWORD }} + local-dir: ./public/ + server-dir: /public_html/ + dangerous-clean-slate: false + exclude: | + **/.git* + **/.git*/** diff --git a/README.md b/README.md new file mode 100644 index 0000000..83c3b34 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# VanityOnTour Status Page + +Automated status dashboard for all VanityOnTour services, hosted on Hostinger at `status.vanityontour.de`. + +## 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 + +## How it works + +GitHub Actions runs every 5 minutes: +1. `scripts/check_status.py` checks all services and writes `public/status.json` +2. Commits the updated `status.json` to the repo +3. Deploys `public/` to Hostinger via FTP + +## Setup: GitHub Secrets required + +Go to **Settings → Secrets → Actions** and add: + +| Secret | Value | +|--------|-------| +| `FTP_SERVER` | FTP hostname from Hostinger hPanel | +| `FTP_USERNAME` | `u982551092` | +| `FTP_PASSWORD` | FTP password from Hostinger hPanel | + +## Local test + +```bash +python3 scripts/check_status.py +# → writes public/status.json +``` diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6d1ca6e --- /dev/null +++ b/public/index.html @@ -0,0 +1,507 @@ + + + + + + VanityOnTour Status + + + + +
+ +
+ +
+
Zuletzt geprüft:
+
Nächste Aktualisierung in
+
+
+ +
+
+ Lade Status… +
+ +
+ +
+ +
+

© 2026 VanityOnTour · Oliver Giertz  ·  + Uptime Kuma +  ·  Daten werden alle 5 Minuten aktualisiert +

+
+ + + + diff --git a/public/status.json b/public/status.json new file mode 100644 index 0000000..98526bf --- /dev/null +++ b/public/status.json @@ -0,0 +1,232 @@ +{ + "generated_at": "2026-04-06T17:15:32Z", + "overall": "up", + "services": [ + { + "name": "VanityOnTour", + "url": "https://vanityontour.de", + "group": "websites", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 326, + "error": null + }, + { + "name": "News Portal", + "url": "https://news.vanityontour.de", + "group": "websites", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 204, + "error": null + }, + { + "name": "Wiki", + "url": "https://wiki.vanityontour.de", + "group": "websites", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 505, + "error": null + }, + { + "name": "StaySense", + "url": "https://staysense.vanityontour.de", + "group": "websites", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 121, + "error": null + }, + { + "name": "StaySense Landing", + "url": "https://landing.staysense.vanityontour.de", + "group": "websites", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 130, + "error": null + }, + { + "name": "N8N Automation", + "url": "https://n8n.vanityontour.de", + "group": "tools", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 37, + "error": null + }, + { + "name": "Nginx Proxy Manager", + "url": "https://nginx.vanityontour.de", + "group": "tools", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 33, + "error": null + }, + { + "name": "Uptime Kuma", + "url": "https://server.vanityontour.de", + "group": "tools", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 122, + "error": null + }, + { + "name": "Statistiken", + "url": "https://stats.vanityontour.de", + "group": "tools", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 162, + "error": null + }, + { + "name": "App Backend", + "url": "https://app.vanityontour.de", + "group": "tools", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 84, + "error": null + }, + { + "name": "CloudPanel", + "url": "https://ng.vanityontour.de", + "group": "tools", + "expect": [ + 200, + 301, + 302 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 67, + "error": null + }, + { + "name": "RSS News API", + "url": "https://news.vanityontour.de/health", + "group": "apis", + "expect": [ + 200 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 93, + "error": null + }, + { + "name": "StaySense API", + "url": "https://staysense.vanityontour.de/api/health", + "group": "apis", + "expect": [ + 200 + ], + "status": "up", + "status_code": 200, + "response_time_ms": 91, + "error": null + } + ], + "ssl": { + "vanityontour.de": { + "valid": true, + "expires_in_days": 88, + "expires_at": "2026-07-04" + }, + "news.vanityontour.de": { + "valid": true, + "expires_in_days": 61, + "expires_at": "2026-06-07" + }, + "wiki.vanityontour.de": { + "valid": true, + "expires_in_days": 88, + "expires_at": "2026-07-04" + }, + "n8n.vanityontour.de": { + "valid": true, + "expires_in_days": 41, + "expires_at": "2026-05-18" + }, + "staysense.vanityontour.de": { + "valid": true, + "expires_in_days": 39, + "expires_at": "2026-05-16" + }, + "server.vanityontour.de": { + "valid": true, + "expires_in_days": 79, + "expires_at": "2026-06-24" + } + }, + "app": { + "name": "Vanity Expense Logbook", + "version": "3.0.12", + "rating": 4, + "rating_count": 1, + "rating_current_version": 4, + "rating_count_current_version": 1, + "price": "0,99 €", + "category": "Travel", + "last_update": "2026-03-16", + "min_ios": "18.2", + "store_url": "https://apps.apple.com/de/app/vanity-expense-logbook/id6742772476", + "icon_url": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/d9/da/6b/d9da6bc5-5acb-b038-c535-7901be47cb31/AppIcon-0-0-1x_U007emarketing-0-11-0-85-220.png/200x200bb.jpg", + "seller": "OLIVER GIERTZ", + "error": null + } +} \ No newline at end of file diff --git a/scripts/check_status.py b/scripts/check_status.py new file mode 100644 index 0000000..bb495f2 --- /dev/null +++ b/scripts/check_status.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +VanityOnTour Status Checker +Runs via GitHub Actions every 5 minutes, writes public/status.json +""" + +import json +import ssl +import socket +import time +import urllib.request +import urllib.error +from datetime import datetime, timezone + +OUTPUT_FILE = "public/status.json" + +WEBSITES = [ + {"name": "VanityOnTour", "url": "https://vanityontour.de", "group": "websites", "expect": [200, 301, 302]}, + {"name": "News Portal", "url": "https://news.vanityontour.de", "group": "websites", "expect": [200, 301, 302]}, + {"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": "N8N Automation", "url": "https://n8n.vanityontour.de", "group": "tools", "expect": [200, 301, 302]}, + {"name": "Nginx Proxy Manager", "url": "https://nginx.vanityontour.de", "group": "tools", "expect": [200, 301, 302]}, + {"name": "Uptime Kuma", "url": "https://server.vanityontour.de", "group": "tools", "expect": [200, 301, 302]}, + {"name": "Statistiken", "url": "https://stats.vanityontour.de", "group": "tools", "expect": [200, 301, 302]}, + {"name": "App Backend", "url": "https://app.vanityontour.de", "group": "tools", "expect": [200, 301, 302]}, + {"name": "CloudPanel", "url": "https://ng.vanityontour.de", "group": "tools", "expect": [200, 301, 302]}, + {"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]}, +] + +SSL_DOMAINS = [ + "vanityontour.de", + "news.vanityontour.de", + "wiki.vanityontour.de", + "n8n.vanityontour.de", + "staysense.vanityontour.de", + "server.vanityontour.de", +] + +APP_STORE_ID = "6742772476" +APP_STORE_COUNTRY = "de" + + +def check_http(url: str, expected: list[int]) -> dict: + start = time.time() + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + try: + req = urllib.request.Request( + url, + headers={"User-Agent": "VoT-StatusChecker/1.0"}, + ) + handler = urllib.request.HTTPSHandler(context=ctx) + opener = urllib.request.build_opener(handler) + opener.addheaders = [("User-Agent", "VoT-StatusChecker/1.0")] + with opener.open(req, timeout=10) as resp: + code = resp.status + except urllib.error.HTTPError as e: + code = e.code + except Exception as e: + return {"status": "down", "status_code": None, "response_time_ms": None, "error": str(e)[:80]} + ms = round((time.time() - start) * 1000) + up = code in expected + return {"status": "up" if up else "degraded", "status_code": code, "response_time_ms": ms, "error": None} + + +def check_ssl(domain: str) -> dict: + try: + ctx = ssl.create_default_context() + with socket.create_connection((domain, 443), timeout=8) as sock: + with ctx.wrap_socket(sock, server_hostname=domain) as ssock: + cert = ssock.getpeercert() + expires_str = cert.get("notAfter", "") + expires_dt = datetime.strptime(expires_str, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc) + days = (expires_dt - datetime.now(timezone.utc)).days + return {"valid": True, "expires_in_days": days, "expires_at": expires_dt.strftime("%Y-%m-%d")} + except Exception as e: + 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}" + 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"} + r = data["results"][0] + release_raw = r.get("currentVersionReleaseDate", "") + release_fmt = release_raw[:10] if release_raw else None + return { + "name": r.get("trackName"), + "version": r.get("version"), + "rating": r.get("averageUserRating"), + "rating_count": r.get("userRatingCount"), + "rating_current_version": r.get("averageUserRatingForCurrentVersion"), + "rating_count_current_version": r.get("userRatingCountForCurrentVersion"), + "price": r.get("formattedPrice"), + "category": r.get("primaryGenreName"), + "last_update": release_fmt, + "min_ios": r.get("minimumOsVersion"), + "store_url": r.get("trackViewUrl", "").split("?")[0], + "icon_url": r.get("artworkUrl100", "").replace("100x100bb", "200x200bb"), + "seller": r.get("sellerName"), + "error": None, + } + except Exception as e: + return {"error": str(e)[:80]} + + +def main(): + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + print(f"[{now}] Checking {len(WEBSITES)} services...") + + results = [] + for site in WEBSITES: + r = check_http(site["url"], site["expect"]) + results.append({**site, **r}) + sym = "✓" if r["status"] == "up" else "✗" + print(f" {sym} {site['name']:30s} {r['status']:8s} {r.get('status_code') or '---'} {r.get('response_time_ms') or '---'}ms") + + print("Checking SSL certificates...") + ssl_results = {} + for domain in SSL_DOMAINS: + ssl_results[domain] = check_ssl(domain) + d = ssl_results[domain] + 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', '?')}") + + # Overall status + downs = [r for r in results if r["status"] == "down"] + degraded = [r for r in results if r["status"] == "degraded"] + if downs: + overall = "degraded" if len(downs) <= 2 else "down" + elif degraded: + overall = "degraded" + else: + overall = "up" + + output = { + "generated_at": now, + "overall": overall, + "services": results, + "ssl": ssl_results, + "app": app, + } + + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + json.dump(output, f, ensure_ascii=False, indent=2) + print(f"Written to {OUTPUT_FILE} — overall: {overall}") + + +if __name__ == "__main__": + main()