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:
commit
f0211e0e5c
5 changed files with 979 additions and 0 deletions
45
.github/workflows/update-status.yml
vendored
Normal file
45
.github/workflows/update-status.yml
vendored
Normal 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
35
README.md
Normal 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
507
public/index.html
Normal 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 ·
|
||||||
|
<a href="https://server.vanityontour.de/status/vanity" target="_blank">Uptime Kuma</a>
|
||||||
|
· 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> ${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
232
public/status.json
Normal 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
160
scripts/check_status.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue