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 <noreply@anthropic.com>
This commit is contained in:
OliverGiertz 2026-04-06 17:15:55 +00:00
commit f0211e0e5c
5 changed files with 979 additions and 0 deletions

45
.github/workflows/update-status.yml vendored Normal file
View file

@ -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*/**

35
README.md Normal file
View file

@ -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
```

507
public/index.html Normal file
View file

@ -0,0 +1,507 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VanityOnTour Status</title>
<style>
:root {
--bg: #0f0f13;
--surface: #18181f;
--border: #27272f;
--purple: #7c3aed;
--purple-l: #a78bfa;
--green: #22c55e;
--yellow: #eab308;
--red: #ef4444;
--text: #e5e5ef;
--muted: #71717a;
--radius: 10px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
min-height: 100vh;
padding: 24px 16px 40px;
}
/* ── Header ── */
.header {
max-width: 900px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-hex {
width: 36px; height: 36px;
background: var(--purple);
clip-path: polygon(50% 0%,100% 25%,100% 75%,50% 100%,0% 75%,0% 25%);
display: flex; align-items: center; justify-content: center;
font-weight: 900; font-size: 16px; color: #fff;
}
.logo-text { font-size: 18px; font-weight: 700; letter-spacing: -.3px; }
.logo-sub { font-size: 11px; color: var(--muted); margin-top: 1px; }
.header-meta { text-align: right; font-size: 12px; color: var(--muted); line-height: 1.6; }
.header-meta strong { color: var(--purple-l); }
/* ── Overall Banner ── */
.overall {
max-width: 900px;
margin: 0 auto 20px;
padding: 12px 18px;
border-radius: var(--radius);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 14px;
}
.overall.up { background: #052e16; border-color: #166534; color: #86efac; }
.overall.degraded{ background: #1c1708; border-color: #854d0e; color: #fde047; }
.overall.down { background: #1f0707; border-color: #991b1b; color: #fca5a5; }
.overall.loading { background: var(--surface); color: var(--muted); }
.overall-dot {
width: 10px; height: 10px; border-radius: 50%;
flex-shrink: 0;
}
.up .overall-dot { background: var(--green); box-shadow: 0 0 8px var(--green); }
.degraded .overall-dot{ background: var(--yellow); box-shadow: 0 0 8px var(--yellow); }
.down .overall-dot { background: var(--red); box-shadow: 0 0 8px var(--red); }
.loading .overall-dot { background: var(--muted); }
/* ── Layout ── */
.main { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
/* ── Card ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.card-header {
padding: 10px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 10px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.card-count {
font-size: 11px;
color: var(--muted);
}
/* ── Service Rows ── */
.service-row {
display: grid;
grid-template-columns: 1fr 100px 80px 70px;
align-items: center;
padding: 9px 16px;
border-bottom: 1px solid var(--border);
gap: 8px;
}
.service-row:last-child { border-bottom: none; }
.service-name { font-weight: 500; font-size: 13px; }
.service-url { font-size: 11px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.badge.up { background: #052e16; color: #86efac; border: 1px solid #166534; }
.badge.degraded { background: #1c1708; color: #fde047; border: 1px solid #854d0e; }
.badge.down { background: #1f0707; color: #fca5a5; border: 1px solid #991b1b; }
.badge.loading { background: var(--border); color: var(--muted); border: 1px solid var(--border); }
.badge-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.response-time { font-size: 12px; color: var(--muted); text-align: right; }
.response-time.fast { color: var(--green); }
.response-time.medium { color: var(--yellow); }
.response-time.slow { color: var(--red); }
.ssl-pill {
font-size: 11px;
padding: 2px 7px;
border-radius: 20px;
text-align: center;
}
.ssl-pill.ok { background: #052e16; color: #86efac; }
.ssl-pill.warn { background: #1c1708; color: #fde047; }
.ssl-pill.bad { background: #1f0707; color: #fca5a5; }
/* ── iOS App Card ── */
.app-card {
padding: 16px;
display: flex;
gap: 16px;
align-items: flex-start;
}
.app-icon {
width: 72px;
height: 72px;
border-radius: 16px;
flex-shrink: 0;
background: var(--border);
overflow: hidden;
}
.app-icon img { width: 100%; height: 100%; object-fit: cover; }
.app-info { flex: 1; min-width: 0; }
.app-name { font-size: 16px; font-weight: 700; }
.app-seller { font-size: 11px; color: var(--muted); margin-top: 2px; }
.app-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.app-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
background: rgba(124,58,237,.15);
border: 1px solid rgba(124,58,237,.35);
border-radius: 20px;
font-size: 11px;
color: var(--purple-l);
font-weight: 500;
}
.stars { color: #facc15; letter-spacing: 1px; }
.app-store-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 12px;
padding: 6px 14px;
background: var(--purple);
color: #fff;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
text-decoration: none;
transition: background .15s;
}
.app-store-btn:hover { background: #6d28d9; }
/* ── Uptime Kuma Link ── */
.uptime-link {
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.uptime-link a {
color: var(--purple-l);
text-decoration: none;
font-size: 13px;
font-weight: 500;
}
.uptime-link a:hover { text-decoration: underline; }
/* ── Footer ── */
.footer {
max-width: 900px;
margin: 32px auto 0;
text-align: center;
font-size: 11px;
color: var(--muted);
}
.footer a { color: var(--muted); }
/* ── Refresh Bar ── */
.refresh-bar {
height: 2px;
background: var(--border);
position: fixed;
top: 0; left: 0;
border-radius: 0 2px 2px 0;
background: var(--purple);
transition: width .5s linear;
}
/* ── Skeleton loader ── */
@keyframes shimmer {
0% { opacity: .4; }
50% { opacity: .8; }
100% { opacity: .4; }
}
.skeleton {
background: var(--border);
border-radius: 4px;
animation: shimmer 1.5s infinite;
}
@media (max-width: 600px) {
.service-row { grid-template-columns: 1fr 90px 60px; }
.service-row .ssl-pill { display: none; }
.app-meta { gap: 6px; }
}
</style>
</head>
<body>
<div class="refresh-bar" id="refreshBar"></div>
<header class="header">
<div class="logo">
<div class="logo-hex">V</div>
<div>
<div class="logo-text">VanityOnTour</div>
<div class="logo-sub">status.vanityontour.de</div>
</div>
</div>
<div class="header-meta">
<div>Zuletzt geprüft: <strong id="lastCheck"></strong></div>
<div>Nächste Aktualisierung in <strong id="countdown"></strong></div>
</div>
</header>
<div class="overall loading" id="overallBanner">
<div class="overall-dot"></div>
<span id="overallText">Lade Status…</span>
</div>
<main class="main" id="main">
<!-- injected by JS -->
</main>
<footer class="footer">
<p>© 2026 VanityOnTour · Oliver Giertz &nbsp;·&nbsp;
<a href="https://server.vanityontour.de/status/vanity" target="_blank">Uptime Kuma</a>
&nbsp;·&nbsp; Daten werden alle 5 Minuten aktualisiert
</p>
</footer>
<script>
const REFRESH_MS = 5 * 60 * 1000; // 5 Minuten
let nextRefresh;
function statusBadge(status) {
const labels = { up: "Online", degraded: "Beeinträchtigt", down: "Offline", unknown: "Unbekannt" };
const s = status || "unknown";
return `<span class="badge ${s}"><span class="badge-dot"></span>${labels[s] || s}</span>`;
}
function rtClass(ms) {
if (!ms) return "";
if (ms < 400) return "fast";
if (ms < 1000) return "medium";
return "slow";
}
function sslPill(info) {
if (!info || info.error) return `<span class="ssl-pill bad">SSL ✗</span>`;
const d = info.expires_in_days;
if (d === null || d === undefined) return "";
const cls = d > 30 ? "ok" : d > 7 ? "warn" : "bad";
return `<span class="ssl-pill ${cls}">${d}d SSL</span>`;
}
function renderStars(rating) {
if (!rating) return "";
const full = Math.round(rating);
return "★".repeat(full) + "☆".repeat(5 - full);
}
function serviceRow(s) {
const rt = s.response_time_ms ? `${s.response_time_ms}ms` : "—";
const rtC = rtClass(s.response_time_ms);
const hostname = new URL(s.url).hostname;
return `
<div class="service-row">
<div>
<div class="service-name">${s.name}</div>
<div class="service-url">${hostname}</div>
</div>
${statusBadge(s.status)}
<div class="response-time ${rtC}">${rt}</div>
${sslPill(null)}
</div>`;
}
function renderSection(title, services, sslData) {
if (!services.length) return "";
const rows = services.map(s => {
const rt = s.response_time_ms ? `${s.response_time_ms}ms` : "—";
const rtC = rtClass(s.response_time_ms);
const hostname = new URL(s.url).hostname;
const ssl = sslData && sslData[hostname] ? sslPill(sslData[hostname]) : "";
return `
<div class="service-row">
<div>
<div class="service-name">${s.name}</div>
<div class="service-url">${hostname}</div>
</div>
${statusBadge(s.status)}
<div class="response-time ${rtC}">${rt}</div>
${ssl}
</div>`;
}).join("");
const downs = services.filter(s => s.status === "down").length;
const countLabel = downs ? `<span style="color:var(--red)">${downs} down</span>` : `${services.length} online`;
return `
<div class="card">
<div class="card-header">
<span class="card-title">${title}</span>
<span class="card-count">${countLabel}</span>
</div>
${rows}
</div>`;
}
function renderApp(app) {
if (!app || app.error) {
return `
<div class="card">
<div class="card-header"><span class="card-title">iOS App</span></div>
<div style="padding:16px;color:var(--muted)">App Store Daten nicht verfügbar</div>
</div>`;
}
const rating = app.rating ? app.rating.toFixed(1) : "—";
const storeUrl = app.store_url || "#";
const iconHtml = app.icon_url
? `<img src="${app.icon_url}" alt="${app.name}" loading="lazy">`
: "";
const lastUpdate = app.last_update
? new Date(app.last_update).toLocaleDateString("de-DE", { year:"numeric", month:"long", day:"numeric" })
: "—";
return `
<div class="card">
<div class="card-header">
<span class="card-title">iOS App</span>
<span class="card-count">App Store</span>
</div>
<div class="app-card">
<div class="app-icon">${iconHtml}</div>
<div class="app-info">
<div class="app-name">${app.name || "—"}</div>
<div class="app-seller">${app.seller || ""}</div>
<div class="app-meta">
<span class="app-tag">v${app.version || "—"}</span>
<span class="app-tag"><span class="stars">${renderStars(app.rating)}</span>&nbsp;${rating} (${app.rating_count || 0})</span>
<span class="app-tag">${app.price || "—"}</span>
<span class="app-tag">${app.category || "—"}</span>
<span class="app-tag">iOS ${app.min_ios || "—"}+</span>
<span class="app-tag">Aktualisiert ${lastUpdate}</span>
</div>
<a class="app-store-btn" href="${storeUrl}" target="_blank" rel="noopener">
Im App Store ansehen ↗
</a>
</div>
</div>
</div>`;
}
async function loadStatus() {
try {
const res = await fetch(`status.json?t=${Date.now()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
render(data);
} catch (e) {
document.getElementById("overallText").textContent = "Fehler beim Laden der Statusdaten";
}
}
function render(data) {
// Overall banner
const banner = document.getElementById("overallBanner");
const texts = { up: "Alle Systeme betriebsbereit", degraded: "Einige Dienste beeinträchtigt", down: "Kritische Ausfälle erkannt" };
banner.className = `overall ${data.overall || "loading"}`;
document.getElementById("overallText").textContent = texts[data.overall] || "Status unbekannt";
// Timestamp
if (data.generated_at) {
const d = new Date(data.generated_at);
document.getElementById("lastCheck").textContent =
d.toLocaleString("de-DE", { timeZone:"UTC", hour12:false,
day:"2-digit", month:"2-digit", year:"numeric",
hour:"2-digit", minute:"2-digit" }) + " UTC";
}
const services = data.services || [];
const ssl = data.ssl || {};
const websites = services.filter(s => s.group === "websites");
const tools = services.filter(s => s.group === "tools");
const apis = services.filter(s => s.group === "apis");
document.getElementById("main").innerHTML =
renderSection("Websites", websites, ssl) +
renderSection("Tools & Automation", tools, ssl) +
renderSection("APIs", apis, ssl) +
renderApp(data.app) +
`<div class="card">
<div class="card-header">
<span class="card-title">Uptime Kuma</span>
<span class="card-count">Monitoring</span>
</div>
<div class="uptime-link">
<span style="color:var(--muted)">Detaillierte Uptime-Statistiken und Heartbeat-Verlauf</span>
<a href="https://server.vanityontour.de/status/vanity" target="_blank" rel="noopener">
Öffnen ↗
</a>
</div>
</div>`;
}
// ── Countdown & Auto-Refresh ──────────────────────────────────
function startCountdown() {
nextRefresh = Date.now() + REFRESH_MS;
const bar = document.getElementById("refreshBar");
const tick = () => {
const remaining = Math.max(0, nextRefresh - Date.now());
const secs = Math.ceil(remaining / 1000);
const mins = Math.floor(secs / 60);
const s = secs % 60;
document.getElementById("countdown").textContent =
`${mins}:${String(s).padStart(2, "0")}`;
bar.style.width = `${(1 - remaining / REFRESH_MS) * 100}%`;
if (remaining <= 0) {
loadStatus();
nextRefresh = Date.now() + REFRESH_MS;
}
};
tick();
setInterval(tick, 1000);
}
// ── Init ─────────────────────────────────────────────────────
loadStatus();
startCountdown();
</script>
</body>
</html>

232
public/status.json Normal file
View file

@ -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
}
}

160
scripts/check_status.py Normal file
View file

@ -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()