Compare commits

4 Commits

15 changed files with 551 additions and 588 deletions

View File

@@ -1,10 +1,34 @@
FROM python:3.10-bullseye # Use a slimmer base image to reduce image size and pull times
FROM python:3.10-slim-bullseye
LABEL maintainer="Andrew Simonson <asimonson1125@gmail.com>" LABEL maintainer="Andrew Simonson <asimonson1125@gmail.com>"
# Set environment variables for better Python performance in Docker
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app WORKDIR /app
# Create a non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Copy only the requirements file first to leverage Docker layer caching
COPY src/requirements.txt .
# Install dependencies as root, but then switch to the non-root user
RUN pip install -r requirements.txt
# Copy the rest of the source code
COPY src/ . COPY src/ .
RUN pip install --no-cache-dir -r requirements.txt # Ensure the appuser owns the app directory
RUN chown -R appuser:appuser /app
# Switch to the non-root user for better security
USER appuser
# Expose the port (Gunicorn's default or specified in CMD)
EXPOSE 8080
# Start Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"] CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"]

View File

@@ -1,11 +1,92 @@
# I made a uhh website # Personal Portfolio & Service Monitor
So people can see how excellent my coding standards are.
* Style: 5/10 A Flask-based website for my personal portfolio and a service monitoring dashboard. This project handles dynamic project showcases, automated service health tracking, and production-ready optimizations.
* Originality: 3/10
* Security: Yes*
* Viruses: not included
You gotta uhh `pip3 install -r requirements.txt` and `python3 app.py` that thing ## Features
Docker compose configured to expose at `localhost:8080` - **Content Management**: Pages like projects, books, and skills are managed via JSON files in the `static` directory.
- **Service Monitoring**: Background health checks for external services with uptime statistics stored in PostgreSQL.
- **Optimizations**:
- HTML, CSS, and JS minification via `Flask-Minify`.
- MD5-based cache busting for static assets.
- Configurable cache-control headers.
- **Security**: Pre-configured headers for XSS protection and frame security.
- **Deployment**: Ready for containerized deployment with Docker and Gunicorn.
## Tech Stack
- **Backend**: Python 3.12, Flask
- **Frontend**: Vanilla CSS/JS, Jinja2
- **Database**: PostgreSQL (optional, for monitoring history)
- **Infrastructure**: Docker, docker-compose
## Project Structure
```text
.
├── src/
│ ├── app.py # Application entry point
│ ├── monitor.py # Service monitoring logic
│ ├── config.py # Environment configuration
│ ├── templates/ # HTML templates
│ ├── static/ # CSS, JS, and JSON data
│ └── requirements.txt # Python dependencies
├── Dockerfile # Container definition
├── docker-compose.yml # Local stack orchestration
└── STATUS_MONITOR_README.md # Monitoring system documentation
```
## Getting Started
### Using Docker
To run the full stack (App + PostgreSQL):
1. **Clone the repository**:
```bash
git clone https://github.com/asimonson1125/asimonson1125.github.io.git
cd asimonson1125.github.io
```
2. **Start services**:
```bash
docker-compose up --build
```
3. **Access the site**:
Visit [http://localhost:8080](http://localhost:8080).
### Local Development
To run the Flask app without Docker:
1. **Set up a virtual environment**:
```bash
python3 -m venv .venv
source .venv/bin/activate
```
2. **Install dependencies**:
```bash
pip install -r src/requirements.txt
```
3. **Run the application**:
```bash
cd src
python3 app.py
```
*Note: status monitor is by default disabled outside of its container cluster*
## Service Monitoring
The monitoring system in `src/monitor.py` tracks service availability. It:
- Runs concurrent health checks every hour.
- Calculates uptime for various windows (24h, 7d, 30d).
- Provides a status UI at `/status` and a JSON API at `/api/status`.
See [STATUS_MONITOR_README.md](./STATUS_MONITOR_README.md) for more details.
## License
This project is personal property. All rights reserved.

View File

@@ -1,15 +1,18 @@
import flask import hashlib
from flask_minify import Minify
import json import json
import os import os
import hashlib
import flask
from flask_minify import Minify
import werkzeug.exceptions as HTTPerror import werkzeug.exceptions as HTTPerror
from config import *
import config # noqa: F401 — side-effect: loads dev env vars
from monitor import monitor, SERVICES from monitor import monitor, SERVICES
app = flask.Flask(__name__) app = flask.Flask(__name__)
# Compute content hashes for static file fingerprinting # ── Static file fingerprinting ────────────────────────────────────────
static_file_hashes = {} static_file_hashes = {}
for dirpath, _, filenames in os.walk(app.static_folder): for dirpath, _, filenames in os.walk(app.static_folder):
for filename in filenames: for filename in filenames:
@@ -18,6 +21,7 @@ for dirpath, _, filenames in os.walk(app.static_folder):
with open(filepath, 'rb') as f: with open(filepath, 'rb') as f:
static_file_hashes[relative] = hashlib.md5(f.read()).hexdigest()[:8] static_file_hashes[relative] = hashlib.md5(f.read()).hexdigest()[:8]
@app.context_processor @app.context_processor
def override_url_for(): def override_url_for():
def versioned_url_for(endpoint, **values): def versioned_url_for(endpoint, **values):
@@ -28,17 +32,16 @@ def override_url_for():
return flask.url_for(endpoint, **values) return flask.url_for(endpoint, **values)
return dict(url_for=versioned_url_for) return dict(url_for=versioned_url_for)
# Add security and caching headers
# ── Security and caching headers ──────────────────────────────────────
@app.after_request @app.after_request
def add_security_headers(response): def add_headers(response):
"""Add security and performance headers to all responses"""
# Security headers
response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'SAMEORIGIN' response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Cache control for static assets
if flask.request.path.startswith('/static/'): if flask.request.path.startswith('/static/'):
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
elif flask.request.path in ['/sitemap.xml', '/robots.txt']: elif flask.request.path in ['/sitemap.xml', '/robots.txt']:
@@ -49,56 +52,93 @@ def add_security_headers(response):
return response return response
# ── Load page data ────────────────────────────────────────────────────
def load_json(path): def load_json(path):
with open(path, "r") as f: with open(path, "r") as f:
return json.load(f) return json.load(f)
proj = load_json("./static/json/projects.json")
projects = load_json("./static/json/projects.json")
books = load_json("./static/json/books.json") books = load_json("./static/json/books.json")
skillList = load_json("./static/json/skills.json") skills = load_json("./static/json/skills.json")
timeline = load_json("./static/json/timeline.json") timeline = load_json("./static/json/timeline.json")
pages = load_json("./static/json/pages.json") pages = load_json("./static/json/pages.json")
pages['projects']['skillList'] = skillList pages['projects']['skillList'] = skills
# pages['about']['timeline'] = timeline pages['projects']['projects'] = projects
pages['projects']['projects'] = proj
pages['home']['books'] = books pages['home']['books'] = books
pages['books']['books'] = books pages['books']['books'] = books
pages['status']['services'] = SERVICES pages['status']['services'] = SERVICES
# ── Error rendering ──────────────────────────────────────────────────
def render_error(code, message):
pagevars = {
"template": "error.html",
"title": f"{code} - Simonson",
"description": "Error on Andrew Simonson's Digital Portfolio",
"canonical": f"/{code}",
}
return (
flask.render_template(
"header.html",
var=pagevars,
error=code,
message=message,
title=f"{code} - Simonson Portfolio",
),
code,
)
@app.errorhandler(HTTPerror.HTTPException)
def handle_http_error(e):
return render_error(e.code, e.description)
@app.errorhandler(Exception)
def handle_generic_error(e):
return render_error(500, "Internal Server Error")
# ── API routes ────────────────────────────────────────────────────────
@app.route('/api/status') @app.route('/api/status')
def api_status(): def api_status():
"""API endpoint for service status"""
return flask.jsonify(monitor.get_status_summary()) return flask.jsonify(monitor.get_status_summary())
@app.route('/api/goto/') @app.route('/api/goto/')
@app.route('/api/goto/<location>') @app.route('/api/goto/<location>')
def goto(location='home'): def api_goto(location='home'):
if location not in pages: if location not in pages:
flask.abort(404) flask.abort(404)
pagevars = pages[location] pagevars = pages[location]
page = None
try: try:
page = flask.render_template(pagevars["template"], var=pagevars) page = flask.render_template(pagevars["template"], var=pagevars)
except Exception: except Exception:
e = HTTPerror.InternalServerError() page = render_error(500, "Internal Server Error")
page = handle_http_error(e)
return [pagevars, page] return [pagevars, page]
def funcGen(pagename, pages):
def dynamicRule(): # ── Dynamic page routes ──────────────────────────────────────────────
def make_page_handler(pagename):
def handler():
try: try:
return flask.render_template('header.html', var=pages[pagename]) return flask.render_template('header.html', var=pages[pagename])
except Exception: except Exception:
e = HTTPerror.InternalServerError() return render_error(500, "Internal Server Error")
print(e) return handler
return handle_http_error(e)
return dynamicRule
for i in pages:
func = funcGen(i, pages) for name in pages:
app.add_url_rule(pages[i]['canonical'], i, func) app.add_url_rule(pages[name]['canonical'], name, make_page_handler(name))
# ── Static file routes ───────────────────────────────────────────────
@app.route("/resume") @app.route("/resume")
@app.route("/Resume.pdf") @app.route("/Resume.pdf")
@@ -106,46 +146,6 @@ for i in pages:
def resume(): def resume():
return flask.send_file("./static/Resume_Simonson_Andrew.pdf") return flask.send_file("./static/Resume_Simonson_Andrew.pdf")
@app.errorhandler(HTTPerror.HTTPException)
def handle_http_error(e):
eCode = e.code
message = e.description
pagevars = {
"template": "error.html",
"title": f"{eCode} - Simonson",
"description": "Error on Andrew Simonson's Digital Portfolio",
"canonical": f"/{eCode}",
}
return (
flask.render_template(
"header.html",
var=pagevars,
error=eCode,
message=message,
title=f"{eCode} - Simonson Portfolio",
),
eCode,
)
@app.errorhandler(Exception)
def handle_generic_error(e):
pagevars = {
"template": "error.html",
"title": "500 - Simonson",
"description": "Error on Andrew Simonson's Digital Portfolio",
"canonical": "/500",
}
return (
flask.render_template(
"header.html",
var=pagevars,
error=500,
message="Internal Server Error",
title="500 - Simonson Portfolio",
),
500,
)
@app.route("/sitemap.xml") @app.route("/sitemap.xml")
@app.route("/robots.txt") @app.route("/robots.txt")
@@ -153,10 +153,9 @@ def static_from_root():
return flask.send_from_directory(app.static_folder, flask.request.path[1:]) return flask.send_from_directory(app.static_folder, flask.request.path[1:])
if __name__ == "__main__": # ── Startup ───────────────────────────────────────────────────────────
# import sass
# sass.compile(dirname=("static/scss", "static/css"), output_style="compressed") if __name__ == "__main__":
app.run(debug=False) app.run(debug=False)
else: else:
Minify(app=app, html=True, js=True, cssless=True) Minify(app=app, html=True, js=True, cssless=True)

View File

@@ -1,48 +1,29 @@
""" """
Service monitoring module Service monitoring module.
Checks service availability and tracks uptime statistics Checks service availability and tracks uptime statistics in PostgreSQL.
""" """
import os import os
import requests
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta from datetime import datetime, timedelta
from threading import Thread, Lock from threading import Thread, Lock
import psycopg2 import psycopg2
import requests
# Service configuration
SERVICES = [ SERVICES = [
{ {'id': 'main', 'name': 'asimonson.com', 'url': 'https://asimonson.com', 'timeout': 10},
'id': 'main', {'id': 'files', 'name': 'files.asimonson.com', 'url': 'https://files.asimonson.com', 'timeout': 10},
'name': 'asimonson.com', {'id': 'git', 'name': 'git.asimonson.com', 'url': 'https://git.asimonson.com', 'timeout': 10},
'url': 'https://asimonson.com',
'timeout': 10
},
{
'id': 'files',
'name': 'files.asimonson.com',
'url': 'https://files.asimonson.com',
'timeout': 10
},
{
'id': 'git',
'name': 'git.asimonson.com',
'url': 'https://git.asimonson.com',
'timeout': 10
}
] ]
# Check interval: 1 min CHECK_INTERVAL = 60 # seconds between checks
CHECK_INTERVAL = 60 RETENTION_DAYS = 90 # how long to keep records
CLEANUP_INTERVAL = 86400 # seconds between purge runs
# Retention: 90 days (quarter year)
RETENTION_DAYS = 90
CLEANUP_INTERVAL = 86400 # 24 hours
DATABASE_URL = os.environ.get('DATABASE_URL') DATABASE_URL = os.environ.get('DATABASE_URL')
# Expected columns (besides id) name: SQL type # Expected columns (besides id) -- name: SQL type
_EXPECTED_COLUMNS = { _EXPECTED_COLUMNS = {
'service_id': 'VARCHAR(50) NOT NULL', 'service_id': 'VARCHAR(50) NOT NULL',
'timestamp': 'TIMESTAMPTZ NOT NULL DEFAULT NOW()', 'timestamp': 'TIMESTAMPTZ NOT NULL DEFAULT NOW()',
@@ -56,21 +37,21 @@ _EXPECTED_COLUMNS = {
class ServiceMonitor: class ServiceMonitor:
def __init__(self): def __init__(self):
self.lock = Lock() self.lock = Lock()
# Lightweight in-memory cache of latest status per service self._current = {
self._current = {} svc['id']: {
for service in SERVICES: 'name': svc['name'],
self._current[service['id']] = { 'url': svc['url'],
'name': service['name'],
'url': service['url'],
'status': 'unknown', 'status': 'unknown',
'response_time': None, 'response_time': None,
'status_code': None, 'status_code': None,
'last_online': None, 'last_online': None,
} }
for svc in SERVICES
}
self._last_check = None self._last_check = None
self._ensure_schema() self._ensure_schema()
# ── database helpers ────────────────────────────────────────── # ── Database helpers ──────────────────────────────────────────
@staticmethod @staticmethod
def _get_conn(): def _get_conn():
@@ -80,13 +61,11 @@ class ServiceMonitor:
return psycopg2.connect(DATABASE_URL) return psycopg2.connect(DATABASE_URL)
def _ensure_schema(self): def _ensure_schema(self):
"""Create the service_checks table (and index) if needed, then """Create or migrate the service_checks table to match _EXPECTED_COLUMNS."""
reconcile columns with _EXPECTED_COLUMNS."""
if not DATABASE_URL: if not DATABASE_URL:
print("DATABASE_URL not set running without persistence") print("DATABASE_URL not set -- running without persistence")
return return
# Retry connection in case DB is still starting (e.g. Docker)
conn = None conn = None
for attempt in range(5): for attempt in range(5):
try: try:
@@ -97,8 +76,9 @@ class ServiceMonitor:
print(f"Database not ready, retrying in 2s (attempt {attempt + 1}/5)...") print(f"Database not ready, retrying in 2s (attempt {attempt + 1}/5)...")
time.sleep(2) time.sleep(2)
else: else:
print("Could not connect to database running without persistence") print("Could not connect to database -- running without persistence")
return return
try: try:
with conn, conn.cursor() as cur: with conn, conn.cursor() as cur:
cur.execute(""" cur.execute("""
@@ -125,23 +105,15 @@ class ServiceMonitor:
""") """)
existing = {row[0] for row in cur.fetchall()} existing = {row[0] for row in cur.fetchall()}
# Add missing columns
for col, col_type in _EXPECTED_COLUMNS.items(): for col, col_type in _EXPECTED_COLUMNS.items():
if col not in existing: if col not in existing:
# Strip NOT NULL / DEFAULT for ALTER ADD (can't enforce
# NOT NULL on existing rows without a default)
bare_type = col_type.split('NOT NULL')[0].split('DEFAULT')[0].strip() bare_type = col_type.split('NOT NULL')[0].split('DEFAULT')[0].strip()
cur.execute( cur.execute(f'ALTER TABLE service_checks ADD COLUMN {col} {bare_type}')
f'ALTER TABLE service_checks ADD COLUMN {col} {bare_type}'
)
print(f"Added column {col} to service_checks") print(f"Added column {col} to service_checks")
# Drop unexpected columns (besides 'id')
expected_names = set(_EXPECTED_COLUMNS) | {'id'} expected_names = set(_EXPECTED_COLUMNS) | {'id'}
for col in existing - expected_names: for col in existing - expected_names:
cur.execute( cur.execute(f'ALTER TABLE service_checks DROP COLUMN {col}')
f'ALTER TABLE service_checks DROP COLUMN {col}'
)
print(f"Dropped column {col} from service_checks") print(f"Dropped column {col} from service_checks")
print("Database schema OK") print("Database schema OK")
@@ -149,7 +121,7 @@ class ServiceMonitor:
conn.close() conn.close()
def _insert_check(self, service_id, result): def _insert_check(self, service_id, result):
"""Insert a single check result into the database.""" """Persist a single check result to the database."""
conn = self._get_conn() conn = self._get_conn()
if conn is None: if conn is None:
return return
@@ -171,35 +143,28 @@ class ServiceMonitor:
finally: finally:
conn.close() conn.close()
# ── service checks ──────────────────────────────────────────── # ── Service checks ────────────────────────────────────────────
def check_service(self, service): def check_service(self, service):
"""Check a single service and return status""" """Perform an HTTP HEAD against a service and return a status dict."""
start_time = time.time() start_time = time.time()
result = { result = {
'timestamp': datetime.now().isoformat(), 'timestamp': datetime.now().isoformat(),
'status': 'offline', 'status': 'offline',
'response_time': None, 'response_time': None,
'status_code': None 'status_code': None,
} }
try: try:
response = requests.head( response = requests.head(
service['url'], service['url'],
timeout=service['timeout'], timeout=service['timeout'],
allow_redirects=True allow_redirects=True,
) )
result['response_time'] = int((time.time() - start_time) * 1000)
elapsed = int((time.time() - start_time) * 1000) # ms
result['response_time'] = elapsed
result['status_code'] = response.status_code result['status_code'] = response.status_code
# Consider 2xx and 3xx as online if response.status_code < 500:
if 200 <= response.status_code < 400:
result['status'] = 'online'
elif 400 <= response.status_code < 500:
# Client errors might still mean service is up
result['status'] = 'online' result['status'] = 'online'
else: else:
result['status'] = 'degraded' result['status'] = 'degraded'
@@ -214,10 +179,9 @@ class ServiceMonitor:
return result return result
def check_all_services(self): def check_all_services(self):
"""Check all services and update status data""" """Check every service concurrently, persist results, and update cache."""
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Checking all services...") print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Checking all services...")
# Perform all network checks concurrently and OUTSIDE the lock
results = {} results = {}
with ThreadPoolExecutor(max_workers=len(SERVICES)) as executor: with ThreadPoolExecutor(max_workers=len(SERVICES)) as executor:
futures = {executor.submit(self.check_service, s): s for s in SERVICES} futures = {executor.submit(self.check_service, s): s for s in SERVICES}
@@ -227,11 +191,9 @@ class ServiceMonitor:
results[service['id']] = result results[service['id']] = result
print(f" {service['name']}: {result['status']} ({result['response_time']}ms)") print(f" {service['name']}: {result['status']} ({result['response_time']}ms)")
# Persist to database (outside lock — DB has its own concurrency)
for service_id, result in results.items(): for service_id, result in results.items():
self._insert_check(service_id, result) self._insert_check(service_id, result)
# Update lightweight in-memory cache under lock
with self.lock: with self.lock:
for service in SERVICES: for service in SERVICES:
result = results[service['id']] result = results[service['id']]
@@ -243,13 +205,14 @@ class ServiceMonitor:
cached['last_online'] = result['timestamp'] cached['last_online'] = result['timestamp']
self._last_check = datetime.now().isoformat() self._last_check = datetime.now().isoformat()
# ── uptime calculations ─────────────────────────────────────── # ── Uptime calculations ───────────────────────────────────────
def _calculate_uptime_unlocked(self, service_id, hours=None): def _calculate_uptime(self, service_id, hours=None):
"""Calculate uptime percentage for a service by querying the DB.""" """Return uptime percentage for a service, or None if insufficient data."""
conn = self._get_conn() conn = self._get_conn()
if conn is None: if conn is None:
return None return None
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
if hours: if hours:
@@ -273,11 +236,10 @@ class ServiceMonitor:
) )
online_count, total_count = cur.fetchone() online_count, total_count = cur.fetchone()
if total_count == 0: if total_count == 0:
return None return None
# Only show uptime for a window if we have data older than it # Only report a time-windowed uptime if data exists beyond the window
if hours: if hours:
cur.execute( cur.execute(
'SELECT EXISTS(SELECT 1 FROM service_checks WHERE service_id = %s AND timestamp <= %s)', 'SELECT EXISTS(SELECT 1 FROM service_checks WHERE service_id = %s AND timestamp <= %s)',
@@ -290,47 +252,8 @@ class ServiceMonitor:
finally: finally:
conn.close() conn.close()
def calculate_uptime(self, service_id, hours=None):
"""Calculate uptime percentage for a service"""
return self._calculate_uptime_unlocked(service_id, hours)
def get_status_summary(self):
"""Get current status summary with uptime statistics"""
with self.lock:
summary = {
'last_check': self._last_check,
'next_check': None,
'services': []
}
if self._last_check:
last_check = datetime.fromisoformat(self._last_check)
next_check = last_check + timedelta(seconds=CHECK_INTERVAL)
summary['next_check'] = next_check.isoformat()
for service_id, cached in self._current.items():
service_summary = {
'id': service_id,
'name': cached['name'],
'url': cached['url'],
'status': cached['status'],
'response_time': cached['response_time'],
'status_code': cached['status_code'],
'last_online': cached['last_online'],
'uptime': {
'24h': self._calculate_uptime_unlocked(service_id, 24),
'7d': self._calculate_uptime_unlocked(service_id, 24 * 7),
'30d': self._calculate_uptime_unlocked(service_id, 24 * 30),
'all_time': self._calculate_uptime_unlocked(service_id)
},
'total_checks': self._get_total_checks(service_id),
}
summary['services'].append(service_summary)
return summary
def _get_total_checks(self, service_id): def _get_total_checks(self, service_id):
"""Return the total number of checks for a service.""" """Return the total number of recorded checks for a service."""
conn = self._get_conn() conn = self._get_conn()
if conn is None: if conn is None:
return 0 return 0
@@ -344,6 +267,43 @@ class ServiceMonitor:
finally: finally:
conn.close() conn.close()
# ── Status summary ────────────────────────────────────────────
def get_status_summary(self):
"""Build a JSON-serializable status summary with uptime statistics."""
with self.lock:
summary = {
'last_check': self._last_check,
'next_check': None,
'services': [],
}
if self._last_check:
last_check = datetime.fromisoformat(self._last_check)
summary['next_check'] = (last_check + timedelta(seconds=CHECK_INTERVAL)).isoformat()
for service_id, cached in self._current.items():
summary['services'].append({
'id': service_id,
'name': cached['name'],
'url': cached['url'],
'status': cached['status'],
'response_time': cached['response_time'],
'status_code': cached['status_code'],
'last_online': cached['last_online'],
'uptime': {
'24h': self._calculate_uptime(service_id, 24),
'7d': self._calculate_uptime(service_id, 24 * 7),
'30d': self._calculate_uptime(service_id, 24 * 30),
'all_time': self._calculate_uptime(service_id),
},
'total_checks': self._get_total_checks(service_id),
})
return summary
# ── Background loop ───────────────────────────────────────────
def _purge_old_records(self): def _purge_old_records(self):
"""Delete check records older than RETENTION_DAYS.""" """Delete check records older than RETENTION_DAYS."""
conn = self._get_conn() conn = self._get_conn()
@@ -352,10 +312,7 @@ class ServiceMonitor:
try: try:
cutoff = datetime.now() - timedelta(days=RETENTION_DAYS) cutoff = datetime.now() - timedelta(days=RETENTION_DAYS)
with conn, conn.cursor() as cur: with conn, conn.cursor() as cur:
cur.execute( cur.execute('DELETE FROM service_checks WHERE timestamp < %s', (cutoff,))
'DELETE FROM service_checks WHERE timestamp < %s',
(cutoff,),
)
deleted = cur.rowcount deleted = cur.rowcount
if deleted: if deleted:
print(f"Purged {deleted} records older than {RETENTION_DAYS} days") print(f"Purged {deleted} records older than {RETENTION_DAYS} days")
@@ -363,7 +320,7 @@ class ServiceMonitor:
conn.close() conn.close()
def start_monitoring(self): def start_monitoring(self):
"""Start background monitoring thread""" """Start the background daemon thread for periodic checks and cleanup."""
def monitor_loop(): def monitor_loop():
self.check_all_services() self.check_all_services()
self._purge_old_records() self._purge_old_records()
@@ -381,7 +338,7 @@ class ServiceMonitor:
thread = Thread(target=monitor_loop, daemon=True) thread = Thread(target=monitor_loop, daemon=True)
thread.start() thread.start()
print(f"Service monitoring started (checks every {CHECK_INTERVAL} seconds)") print(f"Service monitoring started (checks every {CHECK_INTERVAL}s)")
# Global monitor instance
monitor = ServiceMonitor() monitor = ServiceMonitor()

View File

@@ -1,23 +1,23 @@
blinker==1.8.2 blinker==1.9.0
certifi==2024.7.4 certifi==2026.1.4
charset-normalizer==3.3.2 charset-normalizer==3.4.4
click==8.1.7 click==8.3.1
Flask==3.0.3 Flask==3.1.3
Flask-Minify==0.48 Flask-Minify==0.50
gunicorn==22.0.0 gunicorn==25.1.0
htmlminf==0.1.13 htmlminf==0.1.13
idna==3.7 idna==3.11
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.4 Jinja2==3.1.6
jsmin==3.0.1 jsmin==3.0.1
lesscpy==0.15.1 lesscpy==0.15.1
MarkupSafe==2.1.5 MarkupSafe==3.0.3
packaging==24.1 packaging==26.0
ply==3.11 ply==3.11
rcssmin==1.1.2 psycopg2-binary==2.9.11
requests==2.32.3 rcssmin==1.2.2
six==1.16.0 requests==2.32.5
urllib3==2.2.2 six==1.17.0
Werkzeug==3.0.3 urllib3==2.6.3
xxhash==3.4.1 Werkzeug==3.1.6
psycopg2-binary==2.9.9 xxhash==3.6.0

View File

@@ -271,6 +271,10 @@ tr {
gap: 0; gap: 0;
} }
.navElement + .navElement {
border-left: 1px solid rgba(var(--accent-rgb), 0.4);
}
.navElement { .navElement {
display: inline-block; display: inline-block;
text-align: center; text-align: center;
@@ -786,6 +790,7 @@ tr {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
color: inherit;
background: currentColor; background: currentColor;
box-shadow: 0 0 6px currentColor; box-shadow: 0 0 6px currentColor;
flex-shrink: 0; flex-shrink: 0;
@@ -1402,7 +1407,8 @@ tr {
} }
.navElement + .navElement { .navElement + .navElement {
border-top: 1px solid rgba(var(--accent-rgb), 0.1); border-top: 1px solid rgba(var(--accent-rgb), 0.25);
border-left: none;
} }
.navElement * { .navElement * {
@@ -1834,3 +1840,38 @@ tr {
.text-muted { .text-muted {
color: #888 !important; color: #888 !important;
} }
/* SPA Loading Bar */
#loading-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent) 0%, #fff 50%, var(--accent) 100%);
background-size: 200% 100%;
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.6);
z-index: 2000;
width: 0%;
opacity: 0;
transition: width 0.4s cubic-bezier(0.1, 0.05, 0.1, 1), opacity 0.3s ease;
pointer-events: none;
}
#loading-bar.visible {
opacity: 1;
}
#loading-bar.active {
width: 70%;
animation: loading-shimmer 2s infinite linear;
}
#loading-bar.finish {
opacity: 0;
width: 100%;
transition: width 0.2s ease, opacity 0.4s ease 0.1s;
}
@keyframes loading-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}

View File

@@ -1,70 +0,0 @@
.hidden {
display: none;
}
.hiddenup {
max-height: 0px !important;
}
.checkbox-wrapper > div {
display: inline-block;
margin-right: 1em;
margin-bottom: 1em;
}
.checkbox-wrapper > div:last-child {
margin-bottom: 0;;
}
.checkbox-wrapper .switch {
display: flex;
position: relative;
cursor: pointer;
}
.checkbox-wrapper .switch > * {
align-self: center;
}
.checkbox-wrapper .switch input {
display: none;
}
.checkbox-wrapper .slider {
background-color: #ccc;
transition: 0.4s;
height: 34px;
width: 60px;
}
.checkbox-wrapper .slider:before {
background-color: #fff;
bottom: 4px;
content: "";
height: 26px;
left: 4px;
position: absolute;
transition: 0.4s;
width: 26px;
}
.checkbox-wrapper input:checked+.slider {
background-color: #66bb6a;
}
.checkbox-wrapper input:checked+.slider:before {
transform: translateX(26px);
}
.checkbox-wrapper .slider.round {
border-radius: 34px;
}
.checkbox-wrapper .slider.round:before {
border-radius: 50%;
}
.checkbox-wrapper strong {
margin-left: .5em;
}

View File

@@ -1,39 +0,0 @@
function toggleCheckbox(dir) {
let toggles = document.querySelectorAll(
".checkbox-wrapper input[type=checkbox]"
);
let allow = [];
toggles.forEach(function (x) {
if (x.checked) {
allow.push(x.id);
}
});
let list = document.querySelectorAll(".checkbox-client > div");
if (allow.length === 0) {
for (let i = 0; i < list.length; i++) {
list[i].classList.remove("hidden" + dir);
}
} else {
for (let i = 0; i < list.length; i++) {
list[i].classList.remove("hidden" + dir);
for (let x = 0; x < allow.length; x++) {
if (!list[i].classList.contains(allow[x])) {
list[i].classList.add("hidden" + dir);
break;
}
}
}
}
}
function activeSkill(obj) {
let skill = obj.closest(".skill");
if (skill.classList.contains("activeSkill")) {
skill.classList.remove("activeSkill");
return;
}
while (skill) {
skill.classList.add("activeSkill");
skill = skill.parentElement.closest(".skill");
}
}

View File

@@ -7,17 +7,21 @@ async function addChessEmbed(username) {
setChess({ cName: "Chess.com request failed" }); setChess({ cName: "Chess.com request failed" });
return; return;
} }
if (user.status === 200) { if (user.status === 200) {
user = await user.json(); user = await user.json();
stats = await stats.json(); stats = await stats.json();
const ratings = { setChess({
cName: user["username"],
pic: user.avatar,
ratings: {
rapid: stats.chess_rapid.last.rating, rapid: stats.chess_rapid.last.rating,
blitz: stats.chess_blitz.last.rating, blitz: stats.chess_blitz.last.rating,
bullet: stats.chess_bullet.last.rating, bullet: stats.chess_bullet.last.rating,
tactics: stats.tactics.highest.rating, tactics: stats.tactics.highest.rating,
}; },
setChess({ cName: user["username"], pic: user.avatar, ratings: ratings }); });
} else if (user === null || user.status === 403 || user.status === null) { } else if (user.status === 403) {
setChess({ cName: "Chess.com request failed" }); setChess({ cName: "Chess.com request failed" });
} else { } else {
setChess({ cName: "User Not Found" }); setChess({ cName: "User Not Found" });
@@ -33,16 +37,12 @@ function setChess({ cName = null, pic = null, ratings = null }) {
document.querySelector(".chessImage").src = pic; document.querySelector(".chessImage").src = pic;
} }
if (ratings) { if (ratings) {
document.querySelector(".chessRapid .chessStat").textContent = document.querySelector(".chessRapid .chessStat").textContent = ratings.rapid;
ratings.rapid; document.querySelector(".chessBlitz .chessStat").textContent = ratings.blitz;
document.querySelector(".chessBlitz .chessStat").textContent = document.querySelector(".chessBullet .chessStat").textContent = ratings.bullet;
ratings.blitz; document.querySelector(".chessPuzzles .chessStat").textContent = ratings.tactics;
document.querySelector(".chessBullet .chessStat").textContent =
ratings.bullet;
document.querySelector(".chessPuzzles .chessStat").textContent =
ratings.tactics;
} }
} catch { } catch {
console.log("fucker clicking so fast the internet can't even keep up"); console.warn("Chess DOM elements not available (navigated away during fetch)");
} }
} }

View File

@@ -3,6 +3,9 @@ const density = 0.00005;
let screenWidth = window.innerWidth + 10; let screenWidth = window.innerWidth + 10;
let screenHeight = window.innerHeight + 10; let screenHeight = window.innerHeight + 10;
const MAX_DIST = 150;
const MAX_DIST_SQUARED = MAX_DIST * MAX_DIST;
class Ball { class Ball {
constructor(x, y, size, speed, angle) { constructor(x, y, size, speed, angle) {
this.x = x; this.x = x;
@@ -14,8 +17,9 @@ class Ball {
} }
calcChange() { calcChange() {
this.xSpeed = this.speed * Math.sin((this.angle * Math.PI) / 180); const radians = (this.angle * Math.PI) / 180
this.ySpeed = this.speed * Math.cos((this.angle * Math.PI) / 180); this.xSpeed = this.speed * Math.sin(radians);
this.ySpeed = this.speed * Math.cos(radians);
} }
update() { update() {
@@ -44,19 +48,17 @@ class Ball {
function setup() { function setup() {
frameRate(15); frameRate(15);
const pix = screenHeight * screenWidth; const pixels = screenHeight * screenWidth;
createCanvas(screenWidth, screenHeight); createCanvas(screenWidth, screenHeight);
for (let i = 0; i < pix * density; i++) { for (let i = 0; i < pixels * density; i++) {
let thisBall = new Ball( balls.push(new Ball(
random(screenWidth), random(screenWidth),
random(screenHeight), random(screenHeight),
random(6) + 3, random(6) + 3,
Math.exp(random(4) + 3) / 1000 + 1, Math.exp(random(4) + 3) / 1000 + 1,
random(360) random(360)
); ));
balls.push(thisBall);
} }
stroke(255); stroke(255);
} }
@@ -69,42 +71,31 @@ function windowResized() {
function draw() { function draw() {
background(24); background(24);
// Update all balls
for (let i = 0; i < balls.length; i++) { for (let i = 0; i < balls.length; i++) {
balls[i].update(); balls[i].update();
} }
// Draw lines with additive blending so overlaps increase brightness // Draw connection lines with additive blending so overlaps brighten
blendMode(ADD); blendMode(ADD);
strokeWeight(2); strokeWeight(2);
const maxDist = 150;
const maxDistSquared = maxDist * maxDist;
for (let i = 0; i < balls.length - 1; i++) { for (let i = 0; i < balls.length - 1; i++) {
const ball1 = balls[i]; const a = balls[i];
for (let j = i + 1; j < balls.length; j++) { for (let j = i + 1; j < balls.length; j++) {
const ball2 = balls[j]; const b = balls[j];
const dx = b.x - a.x;
const dx = ball2.x - ball1.x; const dy = b.y - a.y;
const dy = ball2.y - ball1.y;
const distSquared = dx * dx + dy * dy; const distSquared = dx * dx + dy * dy;
if (distSquared < maxDistSquared) { if (distSquared < MAX_DIST_SQUARED) {
const distance = Math.sqrt(distSquared); const distance = Math.sqrt(distSquared);
if (distance < 75) { if (distance < 75) {
stroke(255, 85); stroke(255, 85);
line(ball1.x, ball1.y, ball2.x, ball2.y);
} else { } else {
const chance = 0.3 ** (((random(0.2) + 0.8) * distance) / 150); const chance = 0.3 ** (((random(0.2) + 0.8) * distance) / MAX_DIST);
if (chance < 0.5) { stroke(255, chance < 0.5 ? 40 : 75);
stroke(255, 40);
} else {
stroke(255, 75);
}
line(ball1.x, ball1.y, ball2.x, ball2.y);
} }
line(a.x, a.y, b.x, b.y);
} }
} }
} }

View File

@@ -1,67 +1,107 @@
function toggleMenu(collapse=false) { function toggleMenu(collapse) {
if (window.innerWidth < 1400) { if (window.innerWidth < 1400) {
const e = document.querySelector(".navControl"); const menu = document.querySelector(".navControl");
const bar = document.querySelector(".header"); const bar = document.querySelector(".header");
const isCollapsed = !e.style.maxHeight || e.style.maxHeight === "0px"; const isCollapsed = !menu.style.maxHeight || menu.style.maxHeight === "0px";
if (isCollapsed && !collapse) { if (isCollapsed && !collapse) {
e.style.maxHeight = `${e.scrollHeight + 10}px`; menu.style.maxHeight = `${menu.scrollHeight + 10}px`;
bar.style.borderBottomWidth = "0px"; bar.style.borderBottomWidth = "0px";
} else { } else {
e.style.maxHeight = "0px"; menu.style.maxHeight = "0px";
bar.style.borderBottomWidth = "3px"; bar.style.borderBottomWidth = "3px";
} }
} }
} }
async function goto(location, { push = true } = {}) { async function goto(location, { push = true } = {}) {
let a; const loadingBar = document.getElementById('loading-bar');
if (loadingBar) {
loadingBar.style.width = ''; // Clear inline style from previous run
}
let loadingTimeout = setTimeout(() => {
if (loadingBar) {
loadingBar.classList.remove('finish');
loadingBar.classList.add('active');
loadingBar.classList.add('visible');
}
}, 150);
try { try {
a = await fetch("/api/goto/" + location, { const response = await fetch("/api/goto/" + location, {
credentials: "include", credentials: "include",
method: "GET", method: "GET",
mode: "cors", mode: "cors",
}); });
if (!a.ok) {
console.error(`Navigation failed: HTTP ${a.status}`); if (!response.ok) {
return; throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
console.error("Navigation fetch failed:", err);
return;
} }
// Wait for the full body to download - this is usually the slow part
const [metadata, content] = await response.json();
document.dispatchEvent(new Event('beforenavigate')); document.dispatchEvent(new Event('beforenavigate'));
const response = await a.json();
const metadata = response[0];
const content = response[1];
const root = document.getElementById("root"); const root = document.getElementById("root");
root.innerHTML = content; root.innerHTML = content;
root.querySelectorAll("script").forEach((oldScript) => {
// Re-execute scripts
root.querySelectorAll("script").forEach(function(oldScript) {
const newScript = document.createElement("script"); const newScript = document.createElement("script");
Array.from(oldScript.attributes).forEach(attr => { Array.from(oldScript.attributes).forEach(function(attr) {
newScript.setAttribute(attr.name, attr.value); newScript.setAttribute(attr.name, attr.value);
}); });
newScript.textContent = oldScript.textContent; newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript); oldScript.parentNode.replaceChild(newScript, oldScript);
}); });
if (!window.location.href.includes("#")) { if (window.location.href.includes("#")) {
window.scrollTo({top: 0, left: 0, behavior:"instant"}); const id = decodeURIComponent(window.location.hash.substring(1));
} else { const el = document.getElementById(id);
const eid = decodeURIComponent(window.location.hash.substring(1));
const el = document.getElementById(eid);
if (el) el.scrollIntoView(); if (el) el.scrollIntoView();
} else {
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
} }
toggleMenu(collapse=true); toggleMenu(true);
document.querySelector("title").textContent = metadata["title"]; document.querySelector("title").textContent = metadata["title"];
if (push) { if (push) {
history.pushState(null, null, metadata["canonical"]); history.pushState(null, null, metadata["canonical"]);
} }
} catch (err) {
console.error("Navigation failed:", err);
} finally {
clearTimeout(loadingTimeout);
if (loadingBar && loadingBar.classList.contains('active')) {
loadingBar.classList.add('finish');
loadingBar.classList.remove('active');
setTimeout(() => {
if (!loadingBar.classList.contains('active')) {
loadingBar.style.width = '0%';
loadingBar.classList.remove('finish');
loadingBar.classList.remove('visible');
}
}, 500);
}
}
} }
function backButton() { function backButton() {
const location = window.location.pathname; const path = window.location.pathname;
goto(location.substring(1), { push: false }); // remove slash, goto already does that goto(path.substring(1), { push: false });
}
function activeSkill(obj) {
let skill = obj.closest(".skill");
if (skill.classList.contains("activeSkill")) {
skill.classList.remove("activeSkill");
return;
}
while (skill) {
skill.classList.add("activeSkill");
skill = skill.parentElement.closest(".skill");
}
} }

View File

@@ -1,8 +1,9 @@
// Fetch and display service status from API // Use a global to track the interval and ensure we don't stack listeners
if (window.statusIntervalId) {
clearInterval(window.statusIntervalId);
window.statusIntervalId = null;
}
/**
* Fetch status data from server
*/
async function fetchStatus() { async function fetchStatus() {
try { try {
const response = await fetch('/api/status'); const response = await fetch('/api/status');
@@ -17,36 +18,28 @@ async function fetchStatus() {
} }
} }
/**
* Update the status display with fetched data
*/
function updateStatusDisplay(data) { function updateStatusDisplay(data) {
// Update last check time
if (data.last_check) { if (data.last_check) {
const lastCheck = new Date(data.last_check); const lastCheck = new Date(data.last_check);
const timeString = lastCheck.toLocaleString(); const lastUpdateEl = document.getElementById('lastUpdate');
document.getElementById('lastUpdate').textContent = `Last checked: ${timeString}`; if (lastUpdateEl) lastUpdateEl.textContent = `Last checked: ${lastCheck.toLocaleString()}`;
} }
// Update next check time
if (data.next_check) { if (data.next_check) {
const nextCheck = new Date(data.next_check);
const timeString = nextCheck.toLocaleString();
const nextCheckEl = document.getElementById('nextUpdate'); const nextCheckEl = document.getElementById('nextUpdate');
if (nextCheckEl) { if (nextCheckEl) {
nextCheckEl.textContent = `Next check: ${timeString}`; const nextCheck = new Date(data.next_check);
nextCheckEl.textContent = `Next check: ${nextCheck.toLocaleString()}`;
} }
} }
// Update each service if (data.services) {
data.services.forEach(service => { data.services.forEach(function(service) {
updateServiceCard(service); updateServiceCard(service);
}); });
// Update overall status
updateOverallStatus(data.services); updateOverallStatus(data.services);
}
// Re-enable refresh button
const refreshBtn = document.getElementById('refreshBtn'); const refreshBtn = document.getElementById('refreshBtn');
if (refreshBtn) { if (refreshBtn) {
refreshBtn.disabled = false; refreshBtn.disabled = false;
@@ -54,9 +47,19 @@ function updateStatusDisplay(data) {
} }
} }
/** function getUptimeClass(value) {
* Update a single service card if (value === null) return 'text-muted';
*/ if (value >= 99) return 'text-excellent';
if (value >= 95) return 'text-good';
if (value >= 90) return 'text-fair';
return 'text-poor';
}
function formatUptime(value, label) {
const display = value !== null ? `${value}%` : '--';
return `${label}: <strong class="${getUptimeClass(value)}">${display}</strong>`;
}
function updateServiceCard(service) { function updateServiceCard(service) {
const card = document.getElementById(`status-${service.id}`); const card = document.getElementById(`status-${service.id}`);
if (!card) return; if (!card) return;
@@ -68,127 +71,101 @@ function updateServiceCard(service) {
const uptimeDisplay = document.getElementById(`uptime-${service.id}`); const uptimeDisplay = document.getElementById(`uptime-${service.id}`);
const checksDisplay = document.getElementById(`checks-${service.id}`); const checksDisplay = document.getElementById(`checks-${service.id}`);
// Update response time if (timeDisplay) timeDisplay.textContent = service.response_time !== null ? `${service.response_time}ms` : '--';
if (service.response_time !== null) {
timeDisplay.textContent = `${service.response_time}ms`;
} else {
timeDisplay.textContent = '--';
}
// Update status code if (codeDisplay) {
if (service.status_code !== null) { if (service.status_code !== null) {
codeDisplay.textContent = service.status_code; codeDisplay.textContent = service.status_code;
} else { } else {
codeDisplay.textContent = service.status === 'unknown' ? 'Unknown' : 'Error'; codeDisplay.textContent = service.status === 'unknown' ? 'Unknown' : 'Error';
} }
}
// Update status indicator
card.classList.remove('online', 'degraded', 'offline', 'unknown'); card.classList.remove('online', 'degraded', 'offline', 'unknown');
switch (service.status) { switch (service.status) {
case 'online': case 'online':
stateDot.className = 'state-dot online'; if (stateDot) stateDot.className = 'state-dot online';
stateText.textContent = 'Operational'; if (stateText) stateText.textContent = 'Operational';
card.classList.add('online'); card.classList.add('online');
break; break;
case 'degraded': case 'degraded':
case 'timeout': case 'timeout':
stateDot.className = 'state-dot degraded'; if (stateDot) stateDot.className = 'state-dot degraded';
stateText.textContent = service.status === 'timeout' ? 'Timeout' : 'Degraded'; if (stateText) stateText.textContent = service.status === 'timeout' ? 'Timeout' : 'Degraded';
card.classList.add('degraded'); card.classList.add('degraded');
break; break;
case 'offline': case 'offline':
stateDot.className = 'state-dot offline'; if (stateDot) stateDot.className = 'state-dot offline';
stateText.textContent = 'Offline'; if (stateText) stateText.textContent = 'Offline';
card.classList.add('offline'); card.classList.add('offline');
break; break;
default: default:
stateDot.className = 'state-dot loading'; if (stateDot) stateDot.className = 'state-dot loading';
stateText.textContent = 'Unknown'; if (stateText) stateText.textContent = 'Unknown';
card.classList.add('unknown'); card.classList.add('unknown');
} }
// Update uptime statistics
if (uptimeDisplay && service.uptime) { if (uptimeDisplay && service.uptime) {
const uptimeHTML = []; uptimeDisplay.innerHTML = [
formatUptime(service.uptime['24h'], '24h'),
// Helper function to get color class based on uptime percentage formatUptime(service.uptime['7d'], '7d'),
const getUptimeClass = (value) => { formatUptime(service.uptime['30d'], '30d'),
if (value === null) return 'text-muted'; formatUptime(service.uptime.all_time, 'All'),
if (value >= 99) return 'text-excellent'; ].join(' | ');
if (value >= 95) return 'text-good';
if (value >= 90) return 'text-fair';
return 'text-poor';
};
// Helper function to format uptime value
const formatUptime = (value, label) => {
const display = value !== null ? `${value}%` : '--';
const colorClass = getUptimeClass(value);
return `${label}: <strong class="${colorClass}">${display}</strong>`;
};
// Add all uptime metrics
uptimeHTML.push(formatUptime(service.uptime['24h'], '24h'));
uptimeHTML.push(formatUptime(service.uptime['7d'], '7d'));
uptimeHTML.push(formatUptime(service.uptime['30d'], '30d'));
uptimeHTML.push(formatUptime(service.uptime.all_time, 'All'));
uptimeDisplay.innerHTML = uptimeHTML.join(' | ');
} }
// Update total checks
if (checksDisplay && service.total_checks !== undefined) { if (checksDisplay && service.total_checks !== undefined) {
checksDisplay.textContent = service.total_checks; checksDisplay.textContent = service.total_checks;
} }
} }
/**
* Update overall status bar
*/
function updateOverallStatus(services) { function updateOverallStatus(services) {
const overallBar = document.getElementById('overallStatus'); const overallBar = document.getElementById('overallStatus');
if (!overallBar) return;
const icon = overallBar.querySelector('.summary-icon'); const icon = overallBar.querySelector('.summary-icon');
const title = overallBar.querySelector('.summary-title'); const title = overallBar.querySelector('.summary-title');
const subtitle = document.getElementById('summary-subtitle'); const subtitle = document.getElementById('summary-subtitle');
const onlineCount = document.getElementById('onlineCount'); const onlineCount = document.getElementById('onlineCount');
const totalCount = document.getElementById('totalCount'); const totalCount = document.getElementById('totalCount');
// Count service statuses
const total = services.length; const total = services.length;
const online = services.filter(s => s.status === 'online').length; const online = services.filter(function(s) { return s.status === 'online'; }).length;
const degraded = services.filter(s => s.status === 'degraded' || s.status === 'timeout').length; const degraded = services.filter(function(s) { return s.status === 'degraded' || s.status === 'timeout'; }).length;
const offline = services.filter(s => s.status === 'offline').length; const offline = services.filter(function(s) { return s.status === 'offline'; }).length;
// Update counts if (onlineCount) onlineCount.textContent = online;
onlineCount.textContent = online; if (totalCount) totalCount.textContent = total;
totalCount.textContent = total;
// Remove all status classes
overallBar.classList.remove('online', 'degraded', 'offline'); overallBar.classList.remove('online', 'degraded', 'offline');
icon.classList.remove('operational', 'partial', 'major', 'loading'); if (icon) icon.classList.remove('operational', 'partial', 'major', 'loading');
// Determine overall status // Determine overall status
if (online === total) { if (online === total) {
// All systems operational
overallBar.classList.add('online'); overallBar.classList.add('online');
if (icon) {
icon.classList.add('operational'); icon.classList.add('operational');
icon.textContent = '\u2713'; icon.textContent = '\u2713';
title.textContent = 'All Systems Operational'; }
subtitle.textContent = `All ${total} services are running normally`; if (title) title.textContent = 'All Systems Operational';
if (subtitle) subtitle.textContent = `All ${total} services are running normally`;
} else if (offline >= Math.ceil(total / 2)) { } else if (offline >= Math.ceil(total / 2)) {
// Major outage (50% or more offline)
overallBar.classList.add('offline'); overallBar.classList.add('offline');
if (icon) {
icon.classList.add('major'); icon.classList.add('major');
icon.textContent = '\u2715'; icon.textContent = '\u2715';
title.textContent = 'Major Outage'; }
subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline, ${degraded} degraded`; if (title) title.textContent = 'Major Outage';
if (subtitle) subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline, ${degraded} degraded`;
} else if (offline > 0 || degraded > 0) { } else if (offline > 0 || degraded > 0) {
// Partial outage
overallBar.classList.add('degraded'); overallBar.classList.add('degraded');
if (icon) {
icon.classList.add('partial'); icon.classList.add('partial');
icon.textContent = '\u26A0'; icon.textContent = '\u26A0';
title.textContent = 'Partial Outage'; }
if (title) title.textContent = 'Partial Outage';
if (subtitle) {
if (offline > 0 && degraded > 0) { if (offline > 0 && degraded > 0) {
subtitle.textContent = `${offline} offline, ${degraded} degraded`; subtitle.textContent = `${offline} offline, ${degraded} degraded`;
} else if (offline > 0) { } else if (offline > 0) {
@@ -196,18 +173,17 @@ function updateOverallStatus(services) {
} else { } else {
subtitle.textContent = `${degraded} service${degraded !== 1 ? 's' : ''} degraded`; subtitle.textContent = `${degraded} service${degraded !== 1 ? 's' : ''} degraded`;
} }
}
} else { } else {
// Unknown state if (icon) {
icon.classList.add('loading'); icon.classList.add('loading');
icon.textContent = '\u25D0'; icon.textContent = '\u25D0';
title.textContent = 'Status Unknown'; }
subtitle.textContent = 'Waiting for service data'; if (title) title.textContent = 'Status Unknown';
if (subtitle) subtitle.textContent = 'Waiting for service data';
} }
} }
/**
* Show error message
*/
function showError(message) { function showError(message) {
const errorDiv = document.createElement('div'); const errorDiv = document.createElement('div');
errorDiv.className = 'status-error'; errorDiv.className = 'status-error';
@@ -217,13 +193,10 @@ function showError(message) {
const container = document.querySelector('.foregroundContent'); const container = document.querySelector('.foregroundContent');
if (container) { if (container) {
container.insertBefore(errorDiv, container.firstChild); container.insertBefore(errorDiv, container.firstChild);
setTimeout(() => errorDiv.remove(), 5000); setTimeout(function() { errorDiv.remove(); }, 5000);
} }
} }
/**
* Manual refresh
*/
function refreshStatus() { function refreshStatus() {
const refreshBtn = document.getElementById('refreshBtn'); const refreshBtn = document.getElementById('refreshBtn');
if (refreshBtn) { if (refreshBtn) {
@@ -233,32 +206,24 @@ function refreshStatus() {
fetchStatus(); fetchStatus();
} }
/**
* Initialize on page load
*/
var statusIntervalId = null;
function initStatusPage() { function initStatusPage() {
// Clear any existing interval from a previous SPA navigation if (window.statusIntervalId) {
if (statusIntervalId !== null) { clearInterval(window.statusIntervalId);
clearInterval(statusIntervalId);
} }
fetchStatus(); fetchStatus();
// Auto-refresh every 1 minute to get latest data window.statusIntervalId = setInterval(fetchStatus, 60000);
statusIntervalId = setInterval(fetchStatus, 60000);
} }
// Clean up interval when navigating away via SPA function cleanupStatusPage() {
document.addEventListener('beforenavigate', () => { if (window.statusIntervalId) {
if (statusIntervalId !== null) { clearInterval(window.statusIntervalId);
clearInterval(statusIntervalId); window.statusIntervalId = null;
statusIntervalId = null; }
document.removeEventListener('beforenavigate', cleanupStatusPage);
} }
});
// Start when page loads document.addEventListener('beforenavigate', cleanupStatusPage);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initStatusPage); if (document.getElementById('overallStatus')) {
} else {
initStatusPage(); initStatusPage();
} }

View File

@@ -3,7 +3,7 @@
"status": "complete", "status": "complete",
"classes": "geospacial", "classes": "geospacial",
"bgi": "watershedTemps.png", "bgi": "watershedTemps.png",
"content": "Geospacial analysis of Maryland's Antietam and Conococheague sub-watersheds, monitoring water quality and temperatures through the summer months for reporting to governmental review boards for environmental protection" "content": "Live geospacial analysis of Maryland's Antietam and Conococheague sub-watersheds, monitoring water quality and temperatures through the summer months for governmental environment health review boards."
}, },
"Automotive Brand Valuation Analysis": { "Automotive Brand Valuation Analysis": {
"status": "complete", "status": "complete",

View File

@@ -51,7 +51,6 @@
href="{{ url_for('static', filename='css/App.css') }}" href="{{ url_for('static', filename='css/App.css') }}"
/> />
<link rel="canonical" href="{{ request.url_root | trim('/') }}{{ var['canonical'] }}" /> <link rel="canonical" href="{{ request.url_root | trim('/') }}{{ var['canonical'] }}" />
<script defer src="{{ url_for('static', filename='js/checkbox.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/responsive.js') }}"></script> <script defer src="{{ url_for('static', filename='js/responsive.js') }}"></script>
{# <script src="{{ url_for('static', filename='js/chessbed.js') }}"></script> #} {# <script src="{{ url_for('static', filename='js/chessbed.js') }}"></script> #}
<script defer src="{{ url_for('static', filename='js/idler.js') }}"></script> <script defer src="{{ url_for('static', filename='js/idler.js') }}"></script>
@@ -60,6 +59,7 @@
</head> </head>
{% block header %} {% block header %}
<body onpopstate="backButton()"> <body onpopstate="backButton()">
<div id="loading-bar"></div>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<main id='map'></main> <main id='map'></main>
<div id="contentStuffer"> <div id="contentStuffer">

View File

@@ -46,31 +46,6 @@
<br /> <br />
<h2 class="concentratedHead">Projects</h2> <h2 class="concentratedHead">Projects</h2>
<!-- >
<div class="checkbox-wrapper">
<div class="flex start">
<label class="switch" htmlFor="pinned">
<input type="checkbox" id="pinned" onClick="toggleCheckbox('')" checked/>
<div class="slider round"></div>
<strong>Pinned</strong>
</label>
</div>
<div class="flex start">
<label class="switch" htmlFor="programming">
<input type="checkbox" id="programming" onClick="toggleCheckbox('')" />
<div class="slider round"></div>
<strong>Programming</strong>
</label>
</div>
<div class="flex start">
<label class="switch" htmlFor="geospacial" onClick="toggleCheckbox('')">
<input type="checkbox" id="geospacial" />
<div class="slider round"></div>
<strong>Geospacial</strong>
</label>
</div>
</div>
</!-->
<div class="projectList"> <div class="projectList">
{% from 'partials/project.html' import project %} {% for i in {% from 'partials/project.html' import project %} {% for i in
var["projects"] %} {{ project(i, var["projects"][i]["classes"], var["projects"] %} {{ project(i, var["projects"][i]["classes"],
@@ -80,5 +55,4 @@
</div> </div>
</div> </div>
<!--><script>toggleCheckbox('')</script></!-->
{% endblock %} {% endblock %}