mirror of
https://github.com/asimonson1125/asimonson1125.github.io.git
synced 2026-02-25 05:09:49 -06:00
Compare commits
4 Commits
3f0f9907ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d21a8ec278 | |||
| 7232d1f8de | |||
| 3bd27f59d7 | |||
| 44948a6e9f |
30
Dockerfile
30
Dockerfile
@@ -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
|
||||||
|
|
||||||
CMD [ "gunicorn", "--bind", "0.0.0.0:8080", "app: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"]
|
||||||
|
|||||||
97
README.md
97
README.md
@@ -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.
|
||||||
|
|||||||
143
src/app.py
143
src/app.py
@@ -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)
|
||||||
|
|||||||
209
src/monitor.py
209
src/monitor.py
@@ -1,76 +1,57 @@
|
|||||||
"""
|
"""
|
||||||
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()',
|
||||||
'status': 'VARCHAR(20) NOT NULL',
|
'status': 'VARCHAR(20) NOT NULL',
|
||||||
'response_time': 'INTEGER',
|
'response_time': 'INTEGER',
|
||||||
'status_code': 'INTEGER',
|
'status_code': 'INTEGER',
|
||||||
'error': 'TEXT',
|
'error': 'TEXT',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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({
|
||||||
rapid: stats.chess_rapid.last.rating,
|
cName: user["username"],
|
||||||
blitz: stats.chess_blitz.last.rating,
|
pic: user.avatar,
|
||||||
bullet: stats.chess_bullet.last.rating,
|
ratings: {
|
||||||
tactics: stats.tactics.highest.rating,
|
rapid: stats.chess_rapid.last.rating,
|
||||||
};
|
blitz: stats.chess_blitz.last.rating,
|
||||||
setChess({ cName: user["username"], pic: user.avatar, ratings: ratings });
|
bullet: stats.chess_bullet.last.rating,
|
||||||
} else if (user === null || user.status === 403 || user.status === null) {
|
tactics: stats.tactics.highest.rating,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.dispatchEvent(new Event('beforenavigate'));
|
// Wait for the full body to download - this is usually the slow part
|
||||||
|
const [metadata, content] = await response.json();
|
||||||
|
|
||||||
const response = await a.json();
|
document.dispatchEvent(new Event('beforenavigate'));
|
||||||
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
|
||||||
const newScript = document.createElement("script");
|
root.querySelectorAll("script").forEach(function(oldScript) {
|
||||||
Array.from(oldScript.attributes).forEach(attr => {
|
const newScript = document.createElement("script");
|
||||||
newScript.setAttribute(attr.name, attr.value);
|
Array.from(oldScript.attributes).forEach(function(attr) {
|
||||||
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
|
});
|
||||||
|
newScript.textContent = oldScript.textContent;
|
||||||
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||||
});
|
});
|
||||||
newScript.textContent = oldScript.textContent;
|
|
||||||
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));
|
if (el) el.scrollIntoView();
|
||||||
const el = document.getElementById(eid);
|
} else {
|
||||||
if (el) el.scrollIntoView();
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
updateOverallStatus(data.services);
|
||||||
|
}
|
||||||
|
|
||||||
// Update overall status
|
|
||||||
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,146 +71,119 @@ 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`;
|
if (codeDisplay) {
|
||||||
} else {
|
if (service.status_code !== null) {
|
||||||
timeDisplay.textContent = '--';
|
codeDisplay.textContent = service.status_code;
|
||||||
|
} else {
|
||||||
|
codeDisplay.textContent = service.status === 'unknown' ? 'Unknown' : 'Error';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update status code
|
|
||||||
if (service.status_code !== null) {
|
|
||||||
codeDisplay.textContent = service.status_code;
|
|
||||||
} else {
|
|
||||||
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');
|
||||||
icon.classList.add('operational');
|
if (icon) {
|
||||||
icon.textContent = '\u2713';
|
icon.classList.add('operational');
|
||||||
title.textContent = 'All Systems Operational';
|
icon.textContent = '\u2713';
|
||||||
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');
|
||||||
icon.classList.add('major');
|
if (icon) {
|
||||||
icon.textContent = '\u2715';
|
icon.classList.add('major');
|
||||||
title.textContent = 'Major Outage';
|
icon.textContent = '\u2715';
|
||||||
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');
|
||||||
icon.classList.add('partial');
|
if (icon) {
|
||||||
icon.textContent = '\u26A0';
|
icon.classList.add('partial');
|
||||||
title.textContent = 'Partial Outage';
|
icon.textContent = '\u26A0';
|
||||||
if (offline > 0 && degraded > 0) {
|
}
|
||||||
subtitle.textContent = `${offline} offline, ${degraded} degraded`;
|
if (title) title.textContent = 'Partial Outage';
|
||||||
} else if (offline > 0) {
|
if (subtitle) {
|
||||||
subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline`;
|
if (offline > 0 && degraded > 0) {
|
||||||
} else {
|
subtitle.textContent = `${offline} offline, ${degraded} degraded`;
|
||||||
subtitle.textContent = `${degraded} service${degraded !== 1 ? 's' : ''} degraded`;
|
} else if (offline > 0) {
|
||||||
|
subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline`;
|
||||||
|
} else {
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user