Compare commits
103 Commits
doot
...
bbbf9795bc
| Author | SHA1 | Date | |
|---|---|---|---|
| bbbf9795bc | |||
| 8353ecc38c | |||
| d73ed21719 | |||
| c6fa2c41a7 | |||
| 8432cc0bf6 | |||
| 21ebd13a07 | |||
| 78aa49c0f3 | |||
| 9fc5bfe9c7 | |||
| 7a585d79ed | |||
| 605252f812 | |||
| db6229e955 | |||
| afce828d6a | |||
| 6fb6fbd828 | |||
| c21f8089d4 | |||
| a478f708a2 | |||
| d21a8ec278 | |||
| 7232d1f8de | |||
| 3bd27f59d7 | |||
| 44948a6e9f | |||
| 3f0f9907ed | |||
| 7b969ea6c2 | |||
| 085ade75bf | |||
| dae0882e0f | |||
| d0f50141c7 | |||
| b59842899b | |||
| 9553e77b2f | |||
| 2ae714db48 | |||
| efaf2fb169 | |||
| dd6eeb6e40 | |||
| 00f8d707d8 | |||
| cf0f66452d | |||
| 5e2acb3ae9 | |||
|
|
e66b675979 | ||
|
|
9847d6422e | ||
|
|
c8b1f124f2 | ||
| 9b6e29a15c | |||
| b55a96c51c | |||
| 800f42c9bb | |||
| a7635c62d3 | |||
| 99bf0f6c5f | |||
| b1e75bd91f | |||
| 46fc66971d | |||
| d54aa6009a | |||
| 68e9facdc7 | |||
| 0b311678c6 | |||
| 652ac886a4 | |||
| be055ae98c | |||
| b1f94b990c | |||
| 2f90b03e6b | |||
| e9abe472ff | |||
| 18c291d37a | |||
| a6aa73bb21 | |||
| fba1eab648 | |||
| c142b4da3d | |||
| fdc5b12a61 | |||
| 34e5512a8d | |||
| 9bc0eb1ce5 | |||
| 1721a2d885 | |||
| 850e27a8e2 | |||
| f437dd4271 | |||
| ea918ff778 | |||
| 986a30735a | |||
| 1b17f0bf60 | |||
| ed0d715b18 | |||
| f18b57bc0d | |||
| a2c05e5c97 | |||
| a6fb8ab43a | |||
| 2bf7c6837b | |||
| c19496f3da | |||
| 80bfaca041 | |||
| 0da1c405d1 | |||
| 45e1967b74 | |||
| 13c1eac9b7 | |||
| c023481303 | |||
| 53956a0be1 | |||
| 6b274dea52 | |||
| 3e0b269f57 | |||
| 1113658d9b | |||
| 11c96416e8 | |||
| 1e69486ae5 | |||
| 7b0e58d8bd | |||
| 55d8f22816 | |||
| 912ed8fae2 | |||
| 4f833202d1 | |||
| 8b8163399c | |||
| 3f47565426 | |||
| abd414f692 | |||
| 11c884be63 | |||
| 719d99e2c0 | |||
| 06e072b050 | |||
| 689c523e0f | |||
| 7486949ff9 | |||
| 1bfa26a7d6 | |||
| 233208c910 | |||
| cb27948649 | |||
| 2981d4ccc2 | |||
| d27f874483 | |||
| 2cb1368440 | |||
| 7744786431 | |||
| f679f970f7 | |||
| 391d5a1768 | |||
| c92cbf8abc | |||
| 44f9b4eb74 |
17
.dockerignore
Normal file → Executable file
@@ -1,6 +1,15 @@
|
|||||||
react_OLD
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
.venv
|
.venv
|
||||||
.vscode
|
.vscode
|
||||||
.git
|
.claude
|
||||||
.git*
|
CLAUDE.md
|
||||||
__pycache__
|
README.md
|
||||||
|
STATUS_MONITOR_README.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
notes.txt
|
||||||
|
react_OLD
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
|||||||
0
.gitattributes
vendored
Normal file → Executable file
2
.gitconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[core]
|
||||||
|
filemode = false
|
||||||
8
.gitignore
vendored
Normal file → Executable file
@@ -2,4 +2,10 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
notes.txt
|
notes.txt
|
||||||
react_OLD
|
react_OLD
|
||||||
envs.py
|
envs.py
|
||||||
|
.env
|
||||||
|
status_history.json
|
||||||
|
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
|
.aider*
|
||||||
|
|||||||
0
.vscode/launch.json
vendored
Normal file → Executable file
0
.vscode/settings.json
vendored
Normal file → Executable file
50
Dockerfile
Normal file → Executable file
@@ -1,34 +1,34 @@
|
|||||||
FROM ubuntu:lunar
|
# 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>"
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND noninteractive
|
# Set environment variables for better Python performance in Docker
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
RUN apt-get update
|
WORKDIR /app
|
||||||
RUN apt-get install -y python3-pip nginx gunicorn supervisor
|
|
||||||
|
|
||||||
# Setup flask application
|
# Create a non-root user for security
|
||||||
RUN mkdir -p /deploy/app
|
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||||
COPY src /deploy/app
|
|
||||||
RUN pip install -r /deploy/app/requirements.txt --break-system-packages
|
|
||||||
|
|
||||||
# Setup nginx
|
# Copy only the requirements file first to leverage Docker layer caching
|
||||||
RUN rm /etc/nginx/sites-enabled/default
|
COPY src/requirements.txt .
|
||||||
COPY flask.conf /etc/nginx/sites-available/
|
|
||||||
RUN ln -s /etc/nginx/sites-available/flask.conf /etc/nginx/sites-enabled/flask.conf && \
|
|
||||||
echo "daemon off;" >> /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Setup supervisord
|
# Install dependencies as root, but then switch to the non-root user
|
||||||
RUN mkdir -p /var/log/supervisor
|
RUN pip install -r requirements.txt
|
||||||
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
COPY gunicorn.conf /etc/supervisor/conf.d/gunicorn.conf
|
|
||||||
|
|
||||||
# Permissions
|
# Copy the rest of the source code
|
||||||
# RUN adduser --disabled-password --gecos '' supervisor && \
|
COPY src/ .
|
||||||
RUN chmod -R 777 /var/* && \
|
|
||||||
chown -R root /var/*
|
|
||||||
|
|
||||||
# Entrypoint
|
# Ensure the appuser owns the app directory
|
||||||
USER root
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
# Start processes
|
# Switch to the non-root user for better security
|
||||||
CMD ["/usr/bin/supervisord"]
|
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
Normal file → Executable file
@@ -1,9 +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
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|||||||
138
STATUS_MONITOR_README.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Service Status Monitor
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Server-side monitoring system that checks the availability of asimonson.com services every 2 hours and provides uptime statistics.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend Components
|
||||||
|
|
||||||
|
#### 1. `monitor.py` - Service Monitoring Module
|
||||||
|
- **Purpose**: Performs automated health checks on all services
|
||||||
|
- **Check Interval**: Every 2 hours (7200 seconds)
|
||||||
|
- **Services Monitored**:
|
||||||
|
- asimonson.com
|
||||||
|
- files.asimonson.com
|
||||||
|
- git.asimonson.com
|
||||||
|
- pass.asimonson.com
|
||||||
|
- ssh.asimonson.com
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Tracks response times and HTTP status codes
|
||||||
|
- Calculates uptime percentages for multiple time periods (24h, 7d, 30d, all-time)
|
||||||
|
- Persists data to PostgreSQL (`service_checks` table) via `DATABASE_URL` env var
|
||||||
|
- Gracefully degrades when no database is configured (local dev)
|
||||||
|
- Runs in a background thread
|
||||||
|
|
||||||
|
#### 2. `app.py` - Flask Integration
|
||||||
|
- **New API Endpoint**: `/api/status`
|
||||||
|
- Returns current status for all services
|
||||||
|
- Includes uptime statistics
|
||||||
|
- Provides last check and next check times
|
||||||
|
- **Auto-start**: Monitoring begins when the Flask app starts
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
#### 1. `templates/status.html` - Status Page Template
|
||||||
|
- Displays real-time service status
|
||||||
|
- Shows uptime percentages (24h, 7d, 30d, all-time)
|
||||||
|
- Displays response times and status codes
|
||||||
|
- Shows total number of checks performed
|
||||||
|
- Manual refresh button
|
||||||
|
- Auto-refreshes every 5 minutes
|
||||||
|
|
||||||
|
#### 2. `static/js/status.js` - Frontend Logic
|
||||||
|
- Fetches status data from `/api/status` API
|
||||||
|
- Updates UI with service status and uptime
|
||||||
|
- Handles error states gracefully
|
||||||
|
- Auto-refresh every 5 minutes
|
||||||
|
|
||||||
|
#### 3. `static/css/App.css` - Styling
|
||||||
|
- Color-coded status indicators:
|
||||||
|
- Green: Operational
|
||||||
|
- Yellow: Degraded/Timeout
|
||||||
|
- Red: Offline
|
||||||
|
- Responsive grid layout
|
||||||
|
- Dark theme matching existing site design
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
Check history is stored in a PostgreSQL `service_checks` table. The connection is configured via the `DATABASE_URL` environment variable (e.g. `postgresql://user:pass@host:5432/dbname`).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE service_checks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
service_id VARCHAR(50) NOT NULL,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
response_time INTEGER,
|
||||||
|
status_code INTEGER,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The table and index are created automatically on startup. If `DATABASE_URL` is not set, the monitor runs without persistence (useful for local development).
|
||||||
|
|
||||||
|
## Status Types
|
||||||
|
|
||||||
|
- **online**: HTTP status 2xx-4xx, service responding
|
||||||
|
- **degraded**: HTTP status 5xx or slow response
|
||||||
|
- **timeout**: Request exceeded timeout limit (10 seconds)
|
||||||
|
- **offline**: Unable to reach service
|
||||||
|
- **unknown**: No checks performed yet
|
||||||
|
|
||||||
|
## Uptime Calculation
|
||||||
|
|
||||||
|
Uptime percentage = (number of online checks / total checks) × 100
|
||||||
|
|
||||||
|
Calculated for:
|
||||||
|
- Last 24 hours
|
||||||
|
- Last 7 days
|
||||||
|
- Last 30 days
|
||||||
|
- All-time (since monitoring began)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Starting the Server
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The monitoring will start automatically and perform an initial check immediately, then every 2 hours thereafter.
|
||||||
|
|
||||||
|
### Accessing the Status Page
|
||||||
|
Navigate to: `https://asimonson.com/status`
|
||||||
|
|
||||||
|
### API Access
|
||||||
|
Direct API access: `https://asimonson.com/api/status`
|
||||||
|
|
||||||
|
Returns JSON with current status and uptime statistics for all services.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
To modify monitoring behavior, edit `src/monitor.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Change check interval (in seconds)
|
||||||
|
CHECK_INTERVAL = 7200 # 2 hours
|
||||||
|
|
||||||
|
# Modify service list
|
||||||
|
SERVICES = [
|
||||||
|
{
|
||||||
|
'id': 'main',
|
||||||
|
'name': 'asimonson.com',
|
||||||
|
'url': 'https://asimonson.com',
|
||||||
|
'timeout': 10 # seconds
|
||||||
|
},
|
||||||
|
# Add more services here
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- First deployment will show limited uptime data until enough checks accumulate
|
||||||
|
- Historical data is preserved across server restarts (stored in PostgreSQL)
|
||||||
|
- Page auto-refreshes every 5 minutes to show latest server data
|
||||||
|
- Manual refresh button available for immediate updates
|
||||||
|
- All checks performed server-side (no client-side CORS issues)
|
||||||
32
docker-compose.yml
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
portfolio:
|
||||||
|
image: 'asimonson1125/portfolio'
|
||||||
|
build:
|
||||||
|
context: ./
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: 'no'
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://portfolio:portfolio@db:5432/portfolio
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: 'no'
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: portfolio
|
||||||
|
POSTGRES_PASSWORD: portfolio
|
||||||
|
POSTGRES_DB: portfolio
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U portfolio"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
24
flask.conf
@@ -1,24 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_name www.asimonson.com;
|
|
||||||
return 301 https://asimonson.com$request_uri;
|
|
||||||
}
|
|
||||||
server {
|
|
||||||
listen 8080;
|
|
||||||
server_name asimonson.com;
|
|
||||||
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain text/javascript text/css;
|
|
||||||
gunzip on;
|
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
||||||
add_header X-Content-Type-Options 'nosniff';
|
|
||||||
add_header X-Frame-Options 'SAMEORIGIN';
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:5000/;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[program:gunicorn]
|
|
||||||
command=/usr/bin/gunicorn -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker app:app -b localhost:5000
|
|
||||||
directory=/deploy/app
|
|
||||||
227
src/app.py
Normal file → Executable file
@@ -1,102 +1,171 @@
|
|||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
import flask
|
import flask
|
||||||
from flask_minify import Minify
|
from flask_minify import Minify
|
||||||
import json
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
import werkzeug.exceptions as HTTPerror
|
import werkzeug.exceptions as HTTPerror
|
||||||
import requests
|
|
||||||
from config import *
|
|
||||||
|
|
||||||
proj = json.load(open("./static/json/projects.json", "r"))
|
import config # noqa: F401 — side-effect: loads dev env vars
|
||||||
books = json.load(open("./static/json/books.json", "r"))
|
from monitor import monitor, SERVICES
|
||||||
skillList = json.load(open("./static/json/skills.json", "r"))
|
|
||||||
timeline = json.load(open("./static/json/timeline.json", "r"))
|
|
||||||
pages = json.load(open("./static/json/pages.json", "r"))
|
|
||||||
pages['about']['skillList'] = skillList
|
|
||||||
pages['about']['timeline'] = timeline
|
|
||||||
pages['projects']['projects'] = proj
|
|
||||||
pages['home']['books'] = books
|
|
||||||
pages['books']['books'] = books
|
|
||||||
|
|
||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
|
# Trust the scheme/host Cloudflare sets, so redirects Werkzeug builds itself
|
||||||
|
# (e.g. the trailing-slash redirect on /api/goto) use https instead of the
|
||||||
|
# plaintext scheme it sees on its own socket.
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||||
|
|
||||||
|
# ── Static file fingerprinting ────────────────────────────────────────
|
||||||
|
|
||||||
|
static_file_hashes = {}
|
||||||
|
for dirpath, _, filenames in os.walk(app.static_folder):
|
||||||
|
for filename in filenames:
|
||||||
|
filepath = os.path.join(dirpath, filename)
|
||||||
|
relative = os.path.relpath(filepath, app.static_folder).replace(os.sep, '/')
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
static_file_hashes[relative] = hashlib.md5(f.read()).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def override_url_for():
|
||||||
|
def versioned_url_for(endpoint, **values):
|
||||||
|
if endpoint == 'static':
|
||||||
|
filename = values.get('filename')
|
||||||
|
if filename and filename in static_file_hashes:
|
||||||
|
values['v'] = static_file_hashes[filename]
|
||||||
|
return flask.url_for(endpoint, **values)
|
||||||
|
return dict(url_for=versioned_url_for)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Security and caching headers ──────────────────────────────────────
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def add_headers(response):
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
|
|
||||||
|
if flask.request.is_secure:
|
||||||
|
response.headers['Strict-Transport-Security'] = 'max-age=31536000'
|
||||||
|
|
||||||
|
if flask.request.path.startswith('/static/'):
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||||
|
elif flask.request.path in ['/sitemap.xml', '/robots.txt']:
|
||||||
|
response.headers['Cache-Control'] = 'public, max-age=86400'
|
||||||
|
else:
|
||||||
|
response.headers['Cache-Control'] = 'no-cache, must-revalidate'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ── Load page data ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_json(path):
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
projects = load_json("./static/json/projects.json")
|
||||||
|
books = load_json("./static/json/books.json")
|
||||||
|
skills = load_json("./static/json/skills.json")
|
||||||
|
pages = load_json("./static/json/pages.json")
|
||||||
|
features = load_json("./static/json/features.json")
|
||||||
|
|
||||||
|
pages['about']['skillList'] = skills
|
||||||
|
pages['projects']['projects'] = projects
|
||||||
|
pages['projects']['features'] = features
|
||||||
|
pages['home']['features'] = features
|
||||||
|
pages['home']['books'] = books
|
||||||
|
pages['home']['projects'] = projects
|
||||||
|
pages['books']['books'] = books
|
||||||
|
pages['status']['services'] = SERVICES
|
||||||
|
|
||||||
|
for _name, _page in pages.items():
|
||||||
|
_page['id'] = _name
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter('slug')
|
||||||
|
def slug(value):
|
||||||
|
value = re.sub(r'[^a-z0-9]+', '-', str(value).lower())
|
||||||
|
return value.strip('-')
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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')
|
||||||
|
def api_status():
|
||||||
|
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:
|
||||||
|
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 as e:
|
except Exception:
|
||||||
# raise e
|
page = render_error(500, "Internal Server Error")
|
||||||
e = HTTPerror.InternalServerError(None, e)
|
|
||||||
page = page404(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")
|
||||||
return page404(e)
|
return handler
|
||||||
return dynamicRule
|
|
||||||
|
|
||||||
for i in pages:
|
|
||||||
func = funcGen(i, pages)
|
|
||||||
app.add_url_rule(pages[i]['canonical'], i, func)
|
|
||||||
|
|
||||||
|
|
||||||
# for i in pages:
|
for name in pages:
|
||||||
# exec(f"@app.route(pages['{i}']['canonical'])\ndef {i}(): return flask.render_template('header.html', var=pages['{i}'])")
|
app.add_url_rule(pages[name]['canonical'], name, make_page_handler(name))
|
||||||
|
|
||||||
@app.route("/huge")
|
|
||||||
def huge():
|
# ── Static file routes ───────────────────────────────────────────────
|
||||||
return flask.render_template('huge.html')
|
|
||||||
|
|
||||||
@app.route("/resume")
|
@app.route("/resume")
|
||||||
@app.route("/Resume.pdf")
|
@app.route("/Resume.pdf")
|
||||||
|
@app.route("/Resume_Simonson_Andrew.pdf")
|
||||||
def resume():
|
def resume():
|
||||||
return flask.send_file("./static/Resume.pdf")
|
return flask.send_file("./static/Resume_Simonson_Andrew.pdf")
|
||||||
|
|
||||||
@app.route("/hotspots")
|
|
||||||
def hotspotsRIT():
|
|
||||||
url = HotspotsURL
|
|
||||||
if flask.request.args.get("legend") == "false":
|
|
||||||
url += "?legend=false"
|
|
||||||
pagevars = {
|
|
||||||
"template": "iframe.html",
|
|
||||||
"title": f"Hotspots @ RIT",
|
|
||||||
"description": "Hotspots @ RIT by Andrew Simonson",
|
|
||||||
"canonical": "/hotspots",
|
|
||||||
}
|
|
||||||
return flask.render_template("iframe.html", url=url, var=pagevars)
|
|
||||||
|
|
||||||
@app.route("/hotspots/<path>")
|
|
||||||
def hotspotsProxy(path):
|
|
||||||
return requests.get(f"{HotspotsURL}/{path}").content
|
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
|
||||||
def page404(e):
|
|
||||||
eCode = e.code
|
|
||||||
message = e.description
|
|
||||||
try:
|
|
||||||
message = e.length
|
|
||||||
finally:
|
|
||||||
pagevars = {
|
|
||||||
"template": "error.html",
|
|
||||||
"title": f"{eCode} - Simonson",
|
|
||||||
"description": "Error on Andrew Simonson's Digital Portfolio",
|
|
||||||
"canonical": "404",
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
flask.render_template(
|
|
||||||
"header.html",
|
|
||||||
var=pagevars,
|
|
||||||
error=eCode,
|
|
||||||
message=message,
|
|
||||||
title=f"{eCode} - Simonson Portfolio",
|
|
||||||
),
|
|
||||||
eCode,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/sitemap.xml")
|
@app.route("/sitemap.xml")
|
||||||
@@ -105,10 +174,10 @@ 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()
|
app.run(debug=False)
|
||||||
else:
|
else:
|
||||||
Minify(app=app, html=True, js=True, cssless=True)
|
Minify(app=app, html=True, js=True, cssless=True)
|
||||||
|
monitor.start_monitoring()
|
||||||
|
|||||||
4
src/config.py
Normal file → Executable file
@@ -1,8 +1,6 @@
|
|||||||
from os import environ as env
|
from os import environ as env
|
||||||
# automatically updates some dev envs. need to remove for production.
|
# automatically updates some dev envs. need to remove for production.
|
||||||
try:
|
try:
|
||||||
__import__('envs.py')
|
__import__('envs')
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
HotspotsURL = env.get('HotspotsURL', 'https://asimonson.com/hotspots')
|
|
||||||
377
src/monitor.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"""
|
||||||
|
Service monitoring module.
|
||||||
|
Checks service availability and tracks uptime statistics in PostgreSQL.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from threading import Thread, Lock
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import requests
|
||||||
|
|
||||||
|
SERVICES = [
|
||||||
|
{'id': 'main', 'name': 'asimonson.com', 'url': 'https://asimonson.com', 'timeout': 10},
|
||||||
|
# {'id': 'EternalRelays', 'name': 'eternalrelays.com', 'url': 'https://eternalrelays.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},
|
||||||
|
# {'id': 'cascadalyst', 'name': 'cascadalyst.com', 'url': 'https://cascadalyst.com', 'timeout': 10},
|
||||||
|
]
|
||||||
|
|
||||||
|
CHECK_INTERVAL = 60 # seconds between checks
|
||||||
|
RETENTION_DAYS = 90 # how long to keep records
|
||||||
|
CLEANUP_INTERVAL = 86400 # seconds between purge runs
|
||||||
|
|
||||||
|
DATABASE_URL = os.environ.get('DATABASE_URL')
|
||||||
|
|
||||||
|
# Expected columns (besides id) -- name: SQL type
|
||||||
|
_EXPECTED_COLUMNS = {
|
||||||
|
'service_id': 'VARCHAR(50) NOT NULL',
|
||||||
|
'timestamp': 'TIMESTAMPTZ NOT NULL DEFAULT NOW()',
|
||||||
|
'status': 'VARCHAR(20) NOT NULL',
|
||||||
|
'response_time': 'INTEGER',
|
||||||
|
'status_code': 'INTEGER',
|
||||||
|
'error': 'TEXT',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceMonitor:
|
||||||
|
def __init__(self):
|
||||||
|
self.lock = Lock()
|
||||||
|
self._current = {
|
||||||
|
svc['id']: {
|
||||||
|
'name': svc['name'],
|
||||||
|
'url': svc['url'],
|
||||||
|
'status': 'unknown',
|
||||||
|
'response_time': None,
|
||||||
|
'status_code': None,
|
||||||
|
'last_online': None,
|
||||||
|
}
|
||||||
|
for svc in SERVICES
|
||||||
|
}
|
||||||
|
self._last_check = None
|
||||||
|
# Cheap placeholder (no DB access) until the first check_all_services()
|
||||||
|
# run replaces it with the real, uptime-backed summary.
|
||||||
|
self._cached_summary = {
|
||||||
|
'last_check': None,
|
||||||
|
'next_check': None,
|
||||||
|
'services': [
|
||||||
|
{
|
||||||
|
'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': None, '7d': None, '30d': None, 'all_time': None},
|
||||||
|
'total_checks': 0,
|
||||||
|
}
|
||||||
|
for service_id, cached in self._current.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self._ensure_schema()
|
||||||
|
|
||||||
|
# ── Database helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_conn():
|
||||||
|
"""Return a new psycopg2 connection, or None if DATABASE_URL is unset."""
|
||||||
|
if not DATABASE_URL:
|
||||||
|
return None
|
||||||
|
return psycopg2.connect(DATABASE_URL)
|
||||||
|
|
||||||
|
def _ensure_schema(self):
|
||||||
|
"""Create or migrate the service_checks table to match _EXPECTED_COLUMNS."""
|
||||||
|
if not DATABASE_URL:
|
||||||
|
print("DATABASE_URL not set -- running without persistence")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
conn = psycopg2.connect(DATABASE_URL)
|
||||||
|
break
|
||||||
|
except psycopg2.OperationalError:
|
||||||
|
if attempt < 4:
|
||||||
|
print(f"Database not ready, retrying in 2s (attempt {attempt + 1}/5)...")
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
print("Could not connect to database -- running without persistence")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn, conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS service_checks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
service_id VARCHAR(50) NOT NULL,
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
response_time INTEGER,
|
||||||
|
status_code INTEGER,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_checks_service_timestamp
|
||||||
|
ON service_checks (service_id, timestamp DESC);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Introspect existing columns
|
||||||
|
cur.execute("""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'service_checks'
|
||||||
|
""")
|
||||||
|
existing = {row[0] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for col, col_type in _EXPECTED_COLUMNS.items():
|
||||||
|
if col not in existing:
|
||||||
|
bare_type = col_type.split('NOT NULL')[0].split('DEFAULT')[0].strip()
|
||||||
|
cur.execute(f'ALTER TABLE service_checks ADD COLUMN {col} {bare_type}')
|
||||||
|
print(f"Added column {col} to service_checks")
|
||||||
|
|
||||||
|
expected_names = set(_EXPECTED_COLUMNS) | {'id'}
|
||||||
|
for col in existing - expected_names:
|
||||||
|
cur.execute(f'ALTER TABLE service_checks DROP COLUMN {col}')
|
||||||
|
print(f"Dropped column {col} from service_checks")
|
||||||
|
|
||||||
|
print("Database schema OK")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _insert_check(self, service_id, result):
|
||||||
|
"""Persist a single check result to the database."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO service_checks
|
||||||
|
(service_id, timestamp, status, response_time, status_code, error)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||||
|
(
|
||||||
|
service_id,
|
||||||
|
result['timestamp'],
|
||||||
|
result['status'],
|
||||||
|
result.get('response_time'),
|
||||||
|
result.get('status_code'),
|
||||||
|
result.get('error'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ── Service checks ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def check_service(self, service):
|
||||||
|
"""Perform an HTTP HEAD against a service and return a status dict."""
|
||||||
|
start_time = time.time()
|
||||||
|
result = {
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'status': 'offline',
|
||||||
|
'response_time': None,
|
||||||
|
'status_code': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.head(
|
||||||
|
service['url'],
|
||||||
|
timeout=service['timeout'],
|
||||||
|
allow_redirects=True,
|
||||||
|
)
|
||||||
|
result['response_time'] = int((time.time() - start_time) * 1000)
|
||||||
|
result['status_code'] = response.status_code
|
||||||
|
|
||||||
|
if response.status_code < 500:
|
||||||
|
result['status'] = 'online'
|
||||||
|
else:
|
||||||
|
result['status'] = 'degraded'
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
result['status'] = 'timeout'
|
||||||
|
result['response_time'] = service['timeout'] * 1000
|
||||||
|
except Exception as e:
|
||||||
|
result['status'] = 'offline'
|
||||||
|
result['error'] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def check_all_services(self):
|
||||||
|
"""Check every service concurrently, persist results, and update cache."""
|
||||||
|
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Checking all services...")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=len(SERVICES)) as executor:
|
||||||
|
futures = {executor.submit(self.check_service, s): s for s in SERVICES}
|
||||||
|
for future in futures:
|
||||||
|
service = futures[future]
|
||||||
|
result = future.result()
|
||||||
|
results[service['id']] = result
|
||||||
|
print(f" {service['name']}: {result['status']} ({result['response_time']}ms)")
|
||||||
|
|
||||||
|
for service_id, result in results.items():
|
||||||
|
self._insert_check(service_id, result)
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
for service in SERVICES:
|
||||||
|
result = results[service['id']]
|
||||||
|
cached = self._current[service['id']]
|
||||||
|
cached['status'] = result['status']
|
||||||
|
cached['response_time'] = result['response_time']
|
||||||
|
cached['status_code'] = result['status_code']
|
||||||
|
if result['status'] == 'online':
|
||||||
|
cached['last_online'] = result['timestamp']
|
||||||
|
self._last_check = datetime.now().isoformat()
|
||||||
|
self._cached_summary = self._build_summary_locked()
|
||||||
|
|
||||||
|
# ── Uptime calculations ───────────────────────────────────────
|
||||||
|
|
||||||
|
def _calculate_uptime(self, service_id, hours=None):
|
||||||
|
"""Return uptime percentage for a service, or None if insufficient data."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
if conn is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if hours:
|
||||||
|
cutoff = datetime.now() - timedelta(hours=hours)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'online'),
|
||||||
|
COUNT(*)
|
||||||
|
FROM service_checks
|
||||||
|
WHERE service_id = %s AND timestamp > %s""",
|
||||||
|
(service_id, cutoff),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status = 'online'),
|
||||||
|
COUNT(*)
|
||||||
|
FROM service_checks
|
||||||
|
WHERE service_id = %s""",
|
||||||
|
(service_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
online_count, total_count = cur.fetchone()
|
||||||
|
if total_count == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Only report a time-windowed uptime if data exists beyond the window
|
||||||
|
if hours:
|
||||||
|
cur.execute(
|
||||||
|
'SELECT EXISTS(SELECT 1 FROM service_checks WHERE service_id = %s AND timestamp <= %s)',
|
||||||
|
(service_id, cutoff),
|
||||||
|
)
|
||||||
|
if not cur.fetchone()[0]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return round((online_count / total_count) * 100, 3)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _get_total_checks(self, service_id):
|
||||||
|
"""Return the total number of recorded checks for a service."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
if conn is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
'SELECT COUNT(*) FROM service_checks WHERE service_id = %s',
|
||||||
|
(service_id,),
|
||||||
|
)
|
||||||
|
return cur.fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ── Status summary ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_summary_locked(self):
|
||||||
|
"""Build a JSON-serializable status summary with uptime statistics.
|
||||||
|
|
||||||
|
Hits the database (multiple queries per service) -- only call this
|
||||||
|
from within check_all_services() while self.lock is held, so it runs
|
||||||
|
once per check cycle rather than once per /api/status request.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
def get_status_summary(self):
|
||||||
|
"""Return the cached status summary, refreshed once per check cycle
|
||||||
|
(see check_all_services), so this never itself touches the database."""
|
||||||
|
with self.lock:
|
||||||
|
return self._cached_summary
|
||||||
|
|
||||||
|
# ── Background loop ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _purge_old_records(self):
|
||||||
|
"""Delete check records older than RETENTION_DAYS."""
|
||||||
|
conn = self._get_conn()
|
||||||
|
if conn is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cutoff = datetime.now() - timedelta(days=RETENTION_DAYS)
|
||||||
|
with conn, conn.cursor() as cur:
|
||||||
|
cur.execute('DELETE FROM service_checks WHERE timestamp < %s', (cutoff,))
|
||||||
|
deleted = cur.rowcount
|
||||||
|
if deleted:
|
||||||
|
print(f"Purged {deleted} records older than {RETENTION_DAYS} days")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def start_monitoring(self):
|
||||||
|
"""Start the background daemon thread for periodic checks and cleanup."""
|
||||||
|
def monitor_loop():
|
||||||
|
self.check_all_services()
|
||||||
|
self._purge_old_records()
|
||||||
|
|
||||||
|
checks_since_cleanup = 0
|
||||||
|
checks_per_cleanup = CLEANUP_INTERVAL // CHECK_INTERVAL
|
||||||
|
|
||||||
|
while True:
|
||||||
|
time.sleep(CHECK_INTERVAL)
|
||||||
|
self.check_all_services()
|
||||||
|
checks_since_cleanup += 1
|
||||||
|
if checks_since_cleanup >= checks_per_cleanup:
|
||||||
|
self._purge_old_records()
|
||||||
|
checks_since_cleanup = 0
|
||||||
|
|
||||||
|
thread = Thread(target=monitor_loop, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
print(f"Service monitoring started (checks every {CHECK_INTERVAL}s)")
|
||||||
|
|
||||||
|
|
||||||
|
monitor = ServiceMonitor()
|
||||||
@@ -1,43 +1,22 @@
|
|||||||
bidict==0.22.1
|
blinker==1.9.0
|
||||||
black==22.12.0
|
certifi==2026.5.20
|
||||||
blinker==1.5
|
charset-normalizer==3.4.7
|
||||||
certifi==2023.7.22
|
click==8.4.1
|
||||||
cffi==1.15.1
|
Flask==3.1.3
|
||||||
charset-normalizer==3.3.1
|
Flask-Minify==0.50
|
||||||
click==8.1.3
|
gunicorn==26.0.0
|
||||||
colorama==0.4.6
|
idna==3.16
|
||||||
Flask==2.2.2
|
itsdangerous==2.2.0
|
||||||
Flask-Minify==0.41
|
Jinja2==3.1.6
|
||||||
Flask-SocketIO==5.3.2
|
|
||||||
gevent==22.10.2
|
|
||||||
gevent-websocket==0.10.1
|
|
||||||
greenlet==2.0.2
|
|
||||||
gunicorn==20.1.0
|
|
||||||
htmlmin==0.1.12
|
|
||||||
idna==3.4
|
|
||||||
importlib-metadata==6.0.0
|
|
||||||
itsdangerous==2.1.2
|
|
||||||
Jinja2==3.1.2
|
|
||||||
jsmin==3.0.1
|
jsmin==3.0.1
|
||||||
lesscpy==0.15.1
|
lesscpy==0.15.1
|
||||||
libsass==0.22.0
|
MarkupSafe==3.0.3
|
||||||
MarkupSafe==2.1.2
|
packaging==26.2
|
||||||
mypy-extensions==0.4.3
|
|
||||||
pathspec==0.11.0
|
|
||||||
platformdirs==2.6.2
|
|
||||||
ply==3.11
|
ply==3.11
|
||||||
pycparser==2.21
|
psycopg2-binary==2.9.12
|
||||||
pyScss==1.4.0
|
rcssmin==1.2.2
|
||||||
python-engineio==4.3.4
|
requests==2.34.2
|
||||||
python-socketio==5.7.2
|
six==1.17.0
|
||||||
rcssmin==1.1.1
|
urllib3==2.7.0
|
||||||
requests==2.31.0
|
Werkzeug==3.1.8
|
||||||
six==1.16.0
|
xxhash==3.7.0
|
||||||
tomli==2.0.1
|
|
||||||
typing_extensions==4.4.0
|
|
||||||
urllib3==2.0.7
|
|
||||||
Werkzeug==2.2.2
|
|
||||||
xxhash==3.2.0
|
|
||||||
zipp==3.11.0
|
|
||||||
zope.event==4.6
|
|
||||||
zope.interface==5.5.2
|
|
||||||
|
|||||||
BIN
src/static/Resume_Simonson_Andrew.pdf
Executable file
0
src/static/chesscom-embed/default.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
0
src/static/chesscom-embed/diamonds.png
Normal file → Executable file
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
2219
src/static/css/App.css
Normal file → Executable 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;
|
|
||||||
}
|
|
||||||
0
src/static/css/head.css
Normal file → Executable file
0
src/static/favicon.ico
Normal file → Executable file
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
0
src/static/fonts/NeonFuture.ttf
Normal file → Executable file
0
src/static/fonts/RobotoCondensed-Regular.ttf
Normal file → Executable file
0
src/static/fonts/SHUTTLE-X.ttf
Normal file → Executable file
0
src/static/fonts/SunsetClub.otf
Normal file → Executable file
BIN
src/static/fonts/chessglyph-new.0cc8115c.woff2
Normal file
0
src/static/icons/email.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
0
src/static/icons/github.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 759 B After Width: | Height: | Size: 759 B |
0
src/static/icons/globe.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
0
src/static/icons/instagram.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 683 B After Width: | Height: | Size: 683 B |
0
src/static/icons/linkedin.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
0
src/static/icons/log.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
0
src/static/icons/menu.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 764 B After Width: | Height: | Size: 764 B |
BIN
src/static/icons/min.rasterLogo.avif
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
0
src/static/icons/neonfinal3.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
0
src/static/icons/rasterLogo.png
Normal file → Executable file
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 382 KiB |
0
src/static/icons/rasterLogoCircle.png
Normal file → Executable file
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
0
src/static/icons/withBackground.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,41 +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) {
|
|
||||||
if (obj.parentElement.classList.contains("activeSkill")) {
|
|
||||||
obj.parentElement.classList.remove("activeSkill");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// document.querySelectorAll(".skill").forEach((x) => {
|
|
||||||
// x.classList.remove("activeSkill");
|
|
||||||
// });
|
|
||||||
while (obj.parentElement.classList.contains("skill")) {
|
|
||||||
obj = obj.parentElement;
|
|
||||||
obj.classList.add("activeSkill");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
34
src/static/js/chessbed.js
Normal file → Executable 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();
|
||||||
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)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/static/js/idler.js
Normal file → Executable file
@@ -1,8 +1,11 @@
|
|||||||
const balls = [];
|
const balls = [];
|
||||||
const density = 0.00003;
|
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,18 @@ class Ball {
|
|||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
frameRate(15);
|
frameRate(15);
|
||||||
const pix = screenHeight * screenWidth;
|
const pixels = screenHeight * screenWidth;
|
||||||
createCanvas(screenWidth, screenHeight);
|
const canvas = createCanvas(screenWidth, screenHeight);
|
||||||
for (let i = 0; i < pix * density; i++) {
|
canvas.parent('bg');
|
||||||
let thisBall = new Ball(
|
for (let i = 0; i < pixels * density; i++) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,21 +75,31 @@ function draw() {
|
|||||||
for (let i = 0; i < balls.length; i++) {
|
for (let i = 0; i < balls.length; i++) {
|
||||||
balls[i].update();
|
balls[i].update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw connection lines with additive blending so overlaps brighten
|
||||||
|
blendMode(ADD);
|
||||||
|
strokeWeight(2);
|
||||||
|
|
||||||
for (let i = 0; i < balls.length - 1; i++) {
|
for (let i = 0; i < balls.length - 1; i++) {
|
||||||
|
const a = balls[i];
|
||||||
for (let j = i + 1; j < balls.length; j++) {
|
for (let j = i + 1; j < balls.length; j++) {
|
||||||
let distance = dist(balls[i].x, balls[i].y, balls[j].x, balls[j].y);
|
const b = balls[j];
|
||||||
if (distance < 100){
|
const dx = b.x - a.x;
|
||||||
stroke(150);
|
const dy = b.y - a.y;
|
||||||
line(balls[i].x, balls[i].y, balls[j].x, balls[j].y);
|
const distSquared = dx * dx + dy * dy;
|
||||||
}
|
|
||||||
else if (distance < 150) {
|
if (distSquared < MAX_DIST_SQUARED) {
|
||||||
stroke(100);
|
const distance = Math.sqrt(distSquared);
|
||||||
let chance = 0.3 ** (((random(0.2) + 0.8) * distance) / 150);
|
if (distance < 75) {
|
||||||
if (chance < 0.5) {
|
stroke(255, 85);
|
||||||
stroke(50);
|
} else {
|
||||||
|
const chance = 0.3 ** (((random(0.2) + 0.8) * distance) / MAX_DIST);
|
||||||
|
stroke(255, chance < 0.5 ? 40 : 75);
|
||||||
}
|
}
|
||||||
line(balls[i].x, balls[i].y, balls[j].x, balls[j].y);
|
line(a.x, a.y, b.x, b.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blendMode(BLEND);
|
||||||
}
|
}
|
||||||
|
|||||||
217
src/static/js/responsive.js
Normal file → Executable file
@@ -1,71 +1,172 @@
|
|||||||
window.onload = function () {
|
function toggleMenu(collapse) {
|
||||||
onLoaded();
|
const header = document.getElementById("siteHeader");
|
||||||
};
|
const btn = document.getElementById("menu");
|
||||||
function onLoaded() {
|
if (!header) return;
|
||||||
window.onresize = function () {
|
const open = collapse ? false : !header.classList.contains("open");
|
||||||
resizer();
|
header.classList.toggle("open", open);
|
||||||
};
|
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
resizer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizer() {
|
function markCurrentPage(location) {
|
||||||
const e = document.querySelector(".navControl");
|
document.querySelectorAll(".site-nav a[data-page]").forEach(function (link) {
|
||||||
if (window.innerWidth > 1400) {
|
if (link.dataset.page === location) {
|
||||||
// desktop view
|
link.setAttribute("aria-current", "page");
|
||||||
e.style.maxHeight = `${e.scrollHeight + 10}px`;
|
|
||||||
} else {
|
|
||||||
// mobile view
|
|
||||||
document.querySelector(".header").style.borderBottomWidth = "3px";
|
|
||||||
e.style.maxHeight = "0px";
|
|
||||||
document.querySelectorAll(".navElement *").forEach((x) => {
|
|
||||||
x.style.paddingTop = ".3rem";
|
|
||||||
x.style.paddingBottom = ".3rem";
|
|
||||||
x.style.fontSize = "1rem";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMenu() {
|
|
||||||
if (window.innerWidth < 1400) {
|
|
||||||
const e = document.querySelector(".navControl");
|
|
||||||
const bar = document.querySelector(".header");
|
|
||||||
if (e.style.maxHeight === "0px") {
|
|
||||||
e.style.maxHeight = `${e.scrollHeight + 10}px`;
|
|
||||||
bar.style.borderBottomWidth = "0px";
|
|
||||||
} else {
|
} else {
|
||||||
e.style.maxHeight = "0px";
|
link.removeAttribute("aria-current");
|
||||||
bar.style.borderBottomWidth = "3px";
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goto(location, { push = true, hash = "" } = {}) {
|
||||||
|
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 {
|
||||||
|
const response = await fetch("/api/goto/" + location, {
|
||||||
|
credentials: "include",
|
||||||
|
method: "GET",
|
||||||
|
mode: "cors",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the full body to download - this is usually the slow part
|
||||||
|
const [metadata, content] = await response.json();
|
||||||
|
|
||||||
|
document.dispatchEvent(new Event('beforenavigate'));
|
||||||
|
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
root.innerHTML = content;
|
||||||
|
|
||||||
|
// Re-execute scripts
|
||||||
|
root.querySelectorAll("script").forEach(function(oldScript) {
|
||||||
|
const newScript = document.createElement("script");
|
||||||
|
Array.from(oldScript.attributes).forEach(function(attr) {
|
||||||
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
|
});
|
||||||
|
newScript.textContent = oldScript.textContent;
|
||||||
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = hash || (push ? "" : decodeURIComponent(window.location.hash.substring(1)));
|
||||||
|
const targetEl = target ? document.getElementById(target) : null;
|
||||||
|
if (targetEl) {
|
||||||
|
revealTarget(targetEl);
|
||||||
|
targetEl.scrollIntoView({ behavior: "instant", block: "start" });
|
||||||
|
} else {
|
||||||
|
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu(true);
|
||||||
|
markCurrentPage(location);
|
||||||
|
document.querySelector("title").textContent = metadata["title"];
|
||||||
|
if (push) {
|
||||||
|
history.pushState(null, null, metadata["canonical"] + (hash ? "#" + hash : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goto(location, { push = true, toggle = true } = {}) {
|
// Featured project panels: collapsed to a short peek until expanded.
|
||||||
let a = await fetch("/api/goto/" + location, {
|
function setFeatureExpanded(feature, expanded) {
|
||||||
credentials: "include",
|
const clips = feature.querySelectorAll(".feature-clip");
|
||||||
method: "GET",
|
const btn = feature.querySelector(".feature-expand");
|
||||||
mode: "cors",
|
if (expanded) {
|
||||||
});
|
feature.classList.add("expanded");
|
||||||
const response = await a.json();
|
clips.forEach(function (el) {
|
||||||
if (!location.includes("#")) {
|
el.style.maxHeight = el.scrollHeight + "px";
|
||||||
window.scrollTo({top: 0, left: 0, behavior:"instant"});
|
});
|
||||||
|
// Once open, let the regions size themselves so late-loading content is never clipped.
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!feature.classList.contains("expanded")) return;
|
||||||
|
clips.forEach(function (el) {
|
||||||
|
el.style.maxHeight = "none";
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
} else {
|
||||||
|
// Pin the current heights, force a reflow, then let CSS animate down to the peek.
|
||||||
|
clips.forEach(function (el) {
|
||||||
|
el.style.maxHeight = el.scrollHeight + "px";
|
||||||
|
});
|
||||||
|
void feature.offsetHeight;
|
||||||
|
feature.classList.remove("expanded");
|
||||||
|
clips.forEach(function (el) {
|
||||||
|
el.style.maxHeight = "";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const metadata = response[0];
|
if (btn) btn.setAttribute("aria-expanded", expanded ? "true" : "false");
|
||||||
const content = response[1];
|
}
|
||||||
let root = document.getElementById("root");
|
|
||||||
root.innerHTML = content;
|
function toggleFeature(btn) {
|
||||||
root.querySelectorAll("script").forEach((x) => {
|
const feature = btn.closest(".feature");
|
||||||
eval(x.innerHTML);
|
if (feature) setFeatureExpanded(feature, !feature.classList.contains("expanded"));
|
||||||
});
|
}
|
||||||
if (toggle) {
|
|
||||||
toggleMenu();
|
// Expand a collapsed panel (or the one containing the target) before jumping to it.
|
||||||
}
|
function revealTarget(el) {
|
||||||
document.querySelector("title").textContent = metadata["title"];
|
const feature = el.closest(".feature");
|
||||||
if (push) {
|
if (feature && !feature.classList.contains("expanded")) {
|
||||||
history.pushState(null, null, metadata["canonical"]);
|
setFeatureExpanded(feature, true);
|
||||||
|
feature.querySelectorAll(".feature-clip").forEach(function (el) {
|
||||||
|
el.style.maxHeight = "none";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
if (!window.location.hash) return;
|
||||||
|
const el = document.getElementById(decodeURIComponent(window.location.hash.substring(1)));
|
||||||
|
if (el) {
|
||||||
|
revealTarget(el);
|
||||||
|
el.scrollIntoView({ behavior: "instant", block: "start" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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) || "home", { push: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeSkill(obj) {
|
||||||
|
let skill = obj.closest(".skill");
|
||||||
|
if (skill.classList.contains("activeSkill")) {
|
||||||
|
skill.classList.remove("activeSkill");
|
||||||
|
const btn = obj.closest('.skillname') || obj.querySelector?.('.skillname') || obj;
|
||||||
|
if (btn && btn.setAttribute) btn.setAttribute("aria-expanded", "false");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (skill) {
|
||||||
|
skill.classList.add("activeSkill");
|
||||||
|
const nameEl = skill.querySelector?.(':scope > .skillname') || skill.querySelector('.skillname');
|
||||||
|
if (nameEl) nameEl.setAttribute("aria-expanded", "true");
|
||||||
|
skill = skill.parentElement.closest(".skill");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
229
src/static/js/status.js
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
// Use a global to track the interval and ensure we don't stack listeners
|
||||||
|
if (window.statusIntervalId) {
|
||||||
|
clearInterval(window.statusIntervalId);
|
||||||
|
window.statusIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
updateStatusDisplay(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching status:', error);
|
||||||
|
showError('Failed to fetch service status. Please try again later.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusDisplay(data) {
|
||||||
|
if (data.last_check) {
|
||||||
|
const lastCheck = new Date(data.last_check);
|
||||||
|
const lastUpdateEl = document.getElementById('lastUpdate');
|
||||||
|
if (lastUpdateEl) lastUpdateEl.textContent = lastCheck.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.next_check) {
|
||||||
|
const nextCheckEl = document.getElementById('nextUpdate');
|
||||||
|
if (nextCheckEl) {
|
||||||
|
const nextCheck = new Date(data.next_check);
|
||||||
|
nextCheckEl.textContent = nextCheck.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.services) {
|
||||||
|
data.services.forEach(function(service) {
|
||||||
|
updateServiceCard(service);
|
||||||
|
});
|
||||||
|
updateOverallStatus(data.services);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshBtn = document.getElementById('refreshBtn');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
refreshBtn.textContent = 'Refresh now';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUptimeClass(value) {
|
||||||
|
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) {
|
||||||
|
const card = document.getElementById(`status-${service.id}`);
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const stateDot = card.querySelector('.state-dot');
|
||||||
|
const stateText = card.querySelector('.state-text');
|
||||||
|
const timeDisplay = document.getElementById(`time-${service.id}`);
|
||||||
|
const codeDisplay = document.getElementById(`code-${service.id}`);
|
||||||
|
const uptimeDisplay = document.getElementById(`uptime-${service.id}`);
|
||||||
|
const checksDisplay = document.getElementById(`checks-${service.id}`);
|
||||||
|
|
||||||
|
if (timeDisplay) timeDisplay.textContent = service.response_time !== null ? `${service.response_time}ms` : '--';
|
||||||
|
|
||||||
|
if (codeDisplay) {
|
||||||
|
if (service.status_code !== null) {
|
||||||
|
codeDisplay.textContent = service.status_code;
|
||||||
|
} else {
|
||||||
|
codeDisplay.textContent = service.status === 'unknown' ? 'Unknown' : 'Error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.classList.remove('online', 'degraded', 'offline', 'unknown');
|
||||||
|
|
||||||
|
switch (service.status) {
|
||||||
|
case 'online':
|
||||||
|
if (stateDot) stateDot.className = 'state-dot online';
|
||||||
|
if (stateText) stateText.textContent = 'Operational';
|
||||||
|
card.classList.add('online');
|
||||||
|
break;
|
||||||
|
case 'degraded':
|
||||||
|
case 'timeout':
|
||||||
|
if (stateDot) stateDot.className = 'state-dot degraded';
|
||||||
|
if (stateText) stateText.textContent = service.status === 'timeout' ? 'Timeout' : 'Degraded';
|
||||||
|
card.classList.add('degraded');
|
||||||
|
break;
|
||||||
|
case 'offline':
|
||||||
|
if (stateDot) stateDot.className = 'state-dot offline';
|
||||||
|
if (stateText) stateText.textContent = 'Offline';
|
||||||
|
card.classList.add('offline');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (stateDot) stateDot.className = 'state-dot loading';
|
||||||
|
if (stateText) stateText.textContent = 'Unknown';
|
||||||
|
card.classList.add('unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uptimeDisplay && service.uptime) {
|
||||||
|
uptimeDisplay.innerHTML = [
|
||||||
|
formatUptime(service.uptime['24h'], '24h'),
|
||||||
|
formatUptime(service.uptime['7d'], '7d'),
|
||||||
|
formatUptime(service.uptime['30d'], '30d'),
|
||||||
|
formatUptime(service.uptime.all_time, 'All'),
|
||||||
|
].join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checksDisplay && service.total_checks !== undefined) {
|
||||||
|
checksDisplay.textContent = service.total_checks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverallStatus(services) {
|
||||||
|
const overallBar = document.getElementById('overallStatus');
|
||||||
|
if (!overallBar) return;
|
||||||
|
|
||||||
|
const icon = overallBar.querySelector('.summary-icon');
|
||||||
|
const title = overallBar.querySelector('.summary-title');
|
||||||
|
const subtitle = document.getElementById('summary-subtitle');
|
||||||
|
const onlineCount = document.getElementById('onlineCount');
|
||||||
|
const totalCount = document.getElementById('totalCount');
|
||||||
|
|
||||||
|
const total = services.length;
|
||||||
|
const online = services.filter(function(s) { return s.status === 'online'; }).length;
|
||||||
|
const degraded = services.filter(function(s) { return s.status === 'degraded' || s.status === 'timeout'; }).length;
|
||||||
|
const offline = services.filter(function(s) { return s.status === 'offline'; }).length;
|
||||||
|
|
||||||
|
if (onlineCount) onlineCount.textContent = online;
|
||||||
|
if (totalCount) totalCount.textContent = total;
|
||||||
|
|
||||||
|
overallBar.classList.remove('online', 'degraded', 'offline');
|
||||||
|
if (icon) icon.classList.remove('operational', 'partial', 'major', 'loading');
|
||||||
|
|
||||||
|
// Determine overall status
|
||||||
|
if (online === total) {
|
||||||
|
overallBar.classList.add('online');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add('operational');
|
||||||
|
icon.textContent = '\u2713';
|
||||||
|
}
|
||||||
|
if (title) title.textContent = 'All Systems Operational';
|
||||||
|
if (subtitle) subtitle.textContent = `All ${total} services are running normally`;
|
||||||
|
} else if (offline >= Math.ceil(total / 2)) {
|
||||||
|
overallBar.classList.add('offline');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add('major');
|
||||||
|
icon.textContent = '\u2715';
|
||||||
|
}
|
||||||
|
if (title) title.textContent = 'Major Outage';
|
||||||
|
if (subtitle) subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline, ${degraded} degraded`;
|
||||||
|
} else if (offline > 0 || degraded > 0) {
|
||||||
|
overallBar.classList.add('degraded');
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add('partial');
|
||||||
|
icon.textContent = '\u26A0';
|
||||||
|
}
|
||||||
|
if (title) title.textContent = 'Partial Outage';
|
||||||
|
if (subtitle) {
|
||||||
|
if (offline > 0 && degraded > 0) {
|
||||||
|
subtitle.textContent = `${offline} offline, ${degraded} degraded`;
|
||||||
|
} else if (offline > 0) {
|
||||||
|
subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline`;
|
||||||
|
} else {
|
||||||
|
subtitle.textContent = `${degraded} service${degraded !== 1 ? 's' : ''} degraded`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.add('loading');
|
||||||
|
icon.textContent = '\u25D0';
|
||||||
|
}
|
||||||
|
if (title) title.textContent = 'Status Unknown';
|
||||||
|
if (subtitle) subtitle.textContent = 'Waiting for service data';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'status-error';
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
|
||||||
|
|
||||||
|
const container = document.querySelector('.page');
|
||||||
|
if (container) {
|
||||||
|
container.insertBefore(errorDiv, container.firstChild);
|
||||||
|
setTimeout(function() { errorDiv.remove(); }, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStatus() {
|
||||||
|
const refreshBtn = document.getElementById('refreshBtn');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
refreshBtn.textContent = 'Checking';
|
||||||
|
}
|
||||||
|
fetchStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initStatusPage() {
|
||||||
|
if (window.statusIntervalId) {
|
||||||
|
clearInterval(window.statusIntervalId);
|
||||||
|
}
|
||||||
|
fetchStatus();
|
||||||
|
window.statusIntervalId = setInterval(fetchStatus, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupStatusPage() {
|
||||||
|
if (window.statusIntervalId) {
|
||||||
|
clearInterval(window.statusIntervalId);
|
||||||
|
window.statusIntervalId = null;
|
||||||
|
}
|
||||||
|
document.removeEventListener('beforenavigate', cleanupStatusPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('beforenavigate', cleanupStatusPage);
|
||||||
|
|
||||||
|
if (document.getElementById('overallStatus')) {
|
||||||
|
initStatusPage();
|
||||||
|
}
|
||||||
60
src/static/json/books.json
Normal file → Executable file
@@ -1,11 +1,22 @@
|
|||||||
{
|
{
|
||||||
"selection": [
|
"selection": [
|
||||||
"The Rational Optimist",
|
"The Rational Optimist",
|
||||||
"The Accidental Superpower",
|
"Project Hail Mary",
|
||||||
"Freakonomics",
|
"Freakonomics",
|
||||||
"Zero To One"
|
"The Accidental Superpower",
|
||||||
|
"The Martian"
|
||||||
],
|
],
|
||||||
"books": {
|
"books": {
|
||||||
|
"Project Hail Mary": {
|
||||||
|
"filename": "hailmary.jpg",
|
||||||
|
"link": "https://www.amazon.com/Project-Hail-Mary-Andy-Weir/dp/0593135202",
|
||||||
|
"review": "This must be the first hard-sci-fi book I have read and I absolutely loved it. When I watched the movie I concluded that both executed on an inspiring story with an endearing tone but only the book held the creative practicality that really made it a favorite."
|
||||||
|
},
|
||||||
|
"The Martian": {
|
||||||
|
"filename": "themartian.jpg",
|
||||||
|
"link": "https://www.amazon.com/Martian-Andy-Weir/dp/0553418025",
|
||||||
|
"review": "I picked this up after Project Hail Mary looking for more creative uses of hard scifi. On that account The Martian surpassed expectations - partially because the plot didn't have to contend with fantasy elements. I aspire to one day use a radioactive space heater as a hot tub power source, just as my hero, Mark Watney, once did."
|
||||||
|
},
|
||||||
"The Rational Optimist": {
|
"The Rational Optimist": {
|
||||||
"filename": "ratOpt.jpg",
|
"filename": "ratOpt.jpg",
|
||||||
"link": "https://www.amazon.com/Rational-Optimist-Prosperity-Evolves-P-s/dp/0061452068",
|
"link": "https://www.amazon.com/Rational-Optimist-Prosperity-Evolves-P-s/dp/0061452068",
|
||||||
@@ -31,6 +42,16 @@
|
|||||||
"link": "https://freakonomics.com/books/",
|
"link": "https://freakonomics.com/books/",
|
||||||
"review": "More like the other Freakonomics books than I expected (cracked storytelling), which is still excellent, but I wished there was greater insights into seeing past conventional wisdom, which is what thinking like a freak means. Still a great book."
|
"review": "More like the other Freakonomics books than I expected (cracked storytelling), which is still excellent, but I wished there was greater insights into seeing past conventional wisdom, which is what thinking like a freak means. Still a great book."
|
||||||
},
|
},
|
||||||
|
"Fooled By Randomness": {
|
||||||
|
"filename": "fooledbyrandomness.jpg",
|
||||||
|
"link": "https://www.amazon.com/Fooled-Randomness-Hidden-Chance-Markets-dp-B006Q7VYC4/dp/B006Q7VYC4/ref=dp_ob_title_bk",
|
||||||
|
"review": "A lengthy compendium on probabilistic reasoning that helped kick off a curiosity of indefinite computation. There's more ancient philosophy than a book like this really needs but the occasional brazen punchline from the contemporary anecdotes make it bearable."
|
||||||
|
},
|
||||||
|
"The Tyranny of Metrics": {
|
||||||
|
"filename": "TyrannyOfMetrics.jpg",
|
||||||
|
"link": "https://www.amazon.com/Tyranny-Metrics-Jerry-Z-Muller/dp/0691174954",
|
||||||
|
"review": "Library find. Very appreciated read given my field of study. Adds a new lens on the cost of information and how it impacts us from the cube office to the oval office."
|
||||||
|
},
|
||||||
"The Accidental Superpower": {
|
"The Accidental Superpower": {
|
||||||
"filename": "theAccidentalSuperpower.jpeg",
|
"filename": "theAccidentalSuperpower.jpeg",
|
||||||
"link": "https://zeihan.com/",
|
"link": "https://zeihan.com/",
|
||||||
@@ -66,6 +87,11 @@
|
|||||||
"link": "https://www.amazon.com/Give-Me-Break-Exposed-Hucksters-ebook/dp/B000FC2NF8/",
|
"link": "https://www.amazon.com/Give-Me-Break-Exposed-Hucksters-ebook/dp/B000FC2NF8/",
|
||||||
"review": "I expected a boring autobiography-type book, but instead is a glimpse inside Stossel's work that transformed itself as it transformed his view. Was very happy to see a figure of similar personal ideology. Probably made it a little too easy to swallow that pill."
|
"review": "I expected a boring autobiography-type book, but instead is a glimpse inside Stossel's work that transformed itself as it transformed his view. Was very happy to see a figure of similar personal ideology. Probably made it a little too easy to swallow that pill."
|
||||||
},
|
},
|
||||||
|
"Reign of Terror": {
|
||||||
|
"filename": "reignofterror.jpg",
|
||||||
|
"link": "https://www.amazon.com/Reign-Terror-Destabilized-America-Produced/dp/1984879774",
|
||||||
|
"review": "Packed with real news events and first person accounts, Reign of Terror chronicles the story of politics and intelligence agencies during the War on Terror. In typical journalist fashion, the populist (read: racist) cause for the events is mostly conjecture to fit a progressive narrative. Nonetheless, a comprehensive history of malpractice in public office."
|
||||||
|
},
|
||||||
"Zero To One": {
|
"Zero To One": {
|
||||||
"filename": "zeroToOne.jpeg",
|
"filename": "zeroToOne.jpeg",
|
||||||
"link": "https://www.amazon.com/Zero-One-Notes-Startups-Future/dp/0804139296",
|
"link": "https://www.amazon.com/Zero-One-Notes-Startups-Future/dp/0804139296",
|
||||||
@@ -81,21 +107,26 @@
|
|||||||
"link": "https://www.amazon.com/Discipline-Destiny-Power-Self-Control-Virtues/dp/0593191692",
|
"link": "https://www.amazon.com/Discipline-Destiny-Power-Self-Control-Virtues/dp/0593191692",
|
||||||
"review": "Much like the first in its series - small chapters (very helpful) each with inspiring insider stories of figures of history. Anyone capable of learning from these figures would benefit greatly from implementing the virtues in this series."
|
"review": "Much like the first in its series - small chapters (very helpful) each with inspiring insider stories of figures of history. Anyone capable of learning from these figures would benefit greatly from implementing the virtues in this series."
|
||||||
},
|
},
|
||||||
|
"Right Thing, Right Now": {
|
||||||
|
"filename": "rightthingrightnow.png",
|
||||||
|
"link": "https://www.amazon.com/Right-Thing-Now-Values-Character/dp/0593191714",
|
||||||
|
"review": "As the third in its series, the virtue of justice derives a large portion of its meaning from the previous two. While still an good read with a valuable influence for personal growth, it lacks a distinction between justice as a virtue and fighting for the right cause. Some sections preach for ideological purity while others insist on pragmatism which is a pretty important detail regarding justice."
|
||||||
|
},
|
||||||
"On Grand Strategy": {
|
"On Grand Strategy": {
|
||||||
"filename": "onGrandStrategy.jpeg",
|
"filename": "onGrandStrategy.jpeg",
|
||||||
"link": "https://www.amazon.com/Grand-Strategy-John-Lewis-Gaddis/dp/1594203512",
|
"link": "https://www.amazon.com/Grand-Strategy-John-Lewis-Gaddis/dp/1594203512",
|
||||||
"review": "Book for the academically-inclined. Not fun to read. Big words scary. It's insightful to be sure but I wouldn't read it again. The message on conceptual contradictions has stuck with me. Quite the brain food."
|
"review": "Book for the academically-inclined. Not fun to read. Big words scary. It's insightful to be sure but I wouldn't read it again. The message on conceptual contradictions has stuck with me. Quite the brain food."
|
||||||
},
|
},
|
||||||
"The Parasitic Mind": {
|
|
||||||
"filename": "theParasiticMind.jpeg",
|
|
||||||
"link": "https://www.amazon.com/Parasitic-Mind-Infectious-Killing-Common/dp/1684512298/",
|
|
||||||
"review": "The humor is the most memorable part but the concepts are no slouches. The contemporary culture war basis makes it tricky to talk about, but it absolutely should be discussed."
|
|
||||||
},
|
|
||||||
"David and Goliath": {
|
"David and Goliath": {
|
||||||
"filename": "davidAndGoliath.png",
|
"filename": "davidAndGoliath.png",
|
||||||
"link": "https://www.amazon.com/David-Goliath-Underdogs-Misfits-Battling/dp/0316239852/",
|
"link": "https://www.amazon.com/David-Goliath-Underdogs-Misfits-Battling/dp/0316239852/",
|
||||||
"review": "Book contains takes that may not be hot, but *are* incredibly based. In a sentence: Goliath is only the giant from the wrong perspectives. The only reason it's not one of my favorites is that it's tamer than the aggressively standoffish and hilarious."
|
"review": "Book contains takes that may not be hot, but *are* incredibly based. In a sentence: Goliath is only the giant from the wrong perspectives. The only reason it's not one of my favorites is that it's tamer than the aggressively standoffish and hilarious."
|
||||||
},
|
},
|
||||||
|
"The Scout Mindset": {
|
||||||
|
"filename": "scoutMindset.png",
|
||||||
|
"link": "https://www.amazon.com/Scout-Mindset-People-Things-Clearly-ebook/dp/B07L2HQ26K/",
|
||||||
|
"review": "Felt like a list of things that I already do that I should be more mindful of. Maybe that's just me. There was some interesting mental probablism sprinkled in the first half but the second half did not have much new to say. Good but not eye-opening."
|
||||||
|
},
|
||||||
"Verbal Judo": {
|
"Verbal Judo": {
|
||||||
"filename": "verbalJudo.png",
|
"filename": "verbalJudo.png",
|
||||||
"link": "https://www.amazon.com/Verbal-Judo-Second-Gentle-Persuasion-ebook/dp/B00FJ3CMI6/",
|
"link": "https://www.amazon.com/Verbal-Judo-Second-Gentle-Persuasion-ebook/dp/B00FJ3CMI6/",
|
||||||
@@ -106,6 +137,11 @@
|
|||||||
"link": "https://www.amazon.com/YOU-READ-ANYONE-David-Lieberman-ebook/dp/B001J6OV0Y",
|
"link": "https://www.amazon.com/YOU-READ-ANYONE-David-Lieberman-ebook/dp/B001J6OV0Y",
|
||||||
"review": "Not as page-turning as many of the others and clearly not as memorable. The techniques pique curiosity but are difficult to use without practice."
|
"review": "Not as page-turning as many of the others and clearly not as memorable. The techniques pique curiosity but are difficult to use without practice."
|
||||||
},
|
},
|
||||||
|
"The Parasitic Mind": {
|
||||||
|
"filename": "theParasiticMind.jpeg",
|
||||||
|
"link": "https://www.amazon.com/Parasitic-Mind-Infectious-Killing-Common/dp/1684512298/",
|
||||||
|
"review": "The humor is the most memorable part but the concepts are no slouches. The contemporary culture war basis makes it tricky to talk about, but it absolutely should be discussed."
|
||||||
|
},
|
||||||
"Profiles in Courage": {
|
"Profiles in Courage": {
|
||||||
"filename": "profilesInCourage.jpeg",
|
"filename": "profilesInCourage.jpeg",
|
||||||
"link": "https://www.amazon.com/Profiles-Courage-John-F-Kennedy/dp/0060854936",
|
"link": "https://www.amazon.com/Profiles-Courage-John-F-Kennedy/dp/0060854936",
|
||||||
@@ -116,11 +152,6 @@
|
|||||||
"link": "https://www.goodreads.com/book/show/8034188-where-good-ideas-come-from",
|
"link": "https://www.goodreads.com/book/show/8034188-where-good-ideas-come-from",
|
||||||
"review": "I got this book at a recycling center. I didn't want to read it or like it. Unfortnuately, it's pretty good. 200 pages of considerate review of how innovation comes to be + suggestions to expand the utility of your ideas (I've adopted several!)"
|
"review": "I got this book at a recycling center. I didn't want to read it or like it. Unfortnuately, it's pretty good. 200 pages of considerate review of how innovation comes to be + suggestions to expand the utility of your ideas (I've adopted several!)"
|
||||||
},
|
},
|
||||||
"Make Your Bed": {
|
|
||||||
"filename": "makeYourBed.jpg",
|
|
||||||
"link": "https://www.amazon.com/Make-Your-Bed-Little-Things/dp/1455570249",
|
|
||||||
"review": "Something small to read on a rainy day or flight. Valuable advice condensed into personal stories that stretch beyond anecdotes."
|
|
||||||
},
|
|
||||||
"12 Rules for Life": {
|
"12 Rules for Life": {
|
||||||
"filename": "12RulesForLife.jpg",
|
"filename": "12RulesForLife.jpg",
|
||||||
"link": "https://www.amazon.com/12-Rules-Life-Antidote-Chaos/dp/0345816021/",
|
"link": "https://www.amazon.com/12-Rules-Life-Antidote-Chaos/dp/0345816021/",
|
||||||
@@ -135,6 +166,11 @@
|
|||||||
"filename": "HitchhikersGuideToTheGalaxy.jpeg",
|
"filename": "HitchhikersGuideToTheGalaxy.jpeg",
|
||||||
"link": "https://www.amazon.com/Hitchhikers-Guide-Galaxy-Douglas-Adams/dp/0345418913",
|
"link": "https://www.amazon.com/Hitchhikers-Guide-Galaxy-Douglas-Adams/dp/0345418913",
|
||||||
"review": "It's alright. It felt like an aimless journey without defined boundaries that reveled in that fact for irony and wit points."
|
"review": "It's alright. It felt like an aimless journey without defined boundaries that reveled in that fact for irony and wit points."
|
||||||
|
},
|
||||||
|
"The Adventures of Sherlock Holmes": {
|
||||||
|
"filename": "sherlockholmes.jpg",
|
||||||
|
"link": "https://www.amazon.com/Adventures-Sherlock-Holmes-Arthur-Conan/dp/0141034332",
|
||||||
|
"review": "Had there not been half a dozen TV shows and movies about Sherlock Holmes all pulling from the same source material, I might have been intrigued. Instead, I felt like I was proofreading an old English fan rewrite of a book that I had already read. By today's standards, the outcomes were very telegraphed. Still enjoyed thinking through the deductions, though."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/static/json/features.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"homelab": {
|
||||||
|
"title": "AI Homelab and Self Hosted Web Tooling",
|
||||||
|
"tagline": "A self-hosted cluster serving Mixture-of-Experts models beyond their rated VRAM and independent web services",
|
||||||
|
"body": "<p>I refined a method to pool GPUs from different vendors and generations into a single inference backend of roughly 20 GB over Vulkan. FreeToken, a bleeding-edge MoE inference server, fronts the cluster and handles the elastic inference, running Mixture-of-Experts models with far more total parameters than the cluster's VRAM would normally allow. My own scripts handle KV caching and system-prompt compaction, which is what makes the setup harness-capable.</p><p>The same rig trains as well as it serves, having refined Qwen SLMs and diffusion image models. All this is hosted alongside my personal web assets, like this website. I voluntarily subject myself to the maintenance of production-grade systems on every level of the tech stack to ensure that I survive the Big Tech apocalypse.</p>",
|
||||||
|
"stack": ["FreeToken", "ComfyUI", "PEFT / TRL", "Docker", "Cloudflare", "Reverse Proxy"],
|
||||||
|
"image": "photos/deepdives/homelab-dashboard.png",
|
||||||
|
"caption": "Cluster dashboard during a long inference run."
|
||||||
|
},
|
||||||
|
"capstone": {
|
||||||
|
"title": "Subatomic Particle Tracing and Anomaly Control",
|
||||||
|
"tagline": "My Master's Capstone: Building a Diffusion Cloud Chamber and using stereo reconstruction to classify Muons and Alpha/Beta particles.",
|
||||||
|
"body": "<p>My ongoing Data Science Capstone Project combines existing utilities for cheap quantum demonstrations and real world measuring techniques to create effective sensing of subatomic phenomena without breaking the bank or into the IAEA</p>",
|
||||||
|
"stack": ["Ego"]
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/static/json/pages.json
Normal file → Executable file
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"home": {
|
"home": {
|
||||||
"template": "home.html",
|
"template": "home.html",
|
||||||
"title": "Andrew Simonson - Portfolio Home",
|
"title": "Andrew Simonson - Data Scientist & Researcher",
|
||||||
"description": "Andrew Simonson's Digital Portfolio home",
|
"description": "Andrew Simonson - Data Scientist at Ecolab, graduate student at RIT. Projects, research papers, and more.",
|
||||||
"canonical": "/"
|
"canonical": "/"
|
||||||
},
|
},
|
||||||
|
"status": {
|
||||||
|
"template": "status.html",
|
||||||
|
"title": "Andrew Simonson - Status Page",
|
||||||
|
"description": "Status page for my services",
|
||||||
|
"canonical": "/status"
|
||||||
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"template": "projects.html",
|
"template": "projects.html",
|
||||||
"title": "Andrew Simonson - Projects",
|
"title": "Projects & Research - Andrew Simonson",
|
||||||
"description": "Recent projects by Andrew Simonson on his lovely portfolio website :)",
|
"description": "Data science projects, geospatial analysis, research papers, and software experiments by Andrew Simonson.",
|
||||||
"canonical": "/projects"
|
"canonical": "/projects"
|
||||||
},
|
},
|
||||||
"about": {
|
|
||||||
"template": "about.html",
|
|
||||||
"title": "Andrew Simonson - About Me",
|
|
||||||
"description": "About Andrew Simonson",
|
|
||||||
"canonical": "/about"
|
|
||||||
},
|
|
||||||
"books": {
|
"books": {
|
||||||
"template": "books.html",
|
"template": "books.html",
|
||||||
"title": "Andrew Simonson - Bookshelf",
|
"title": "Andrew Simonson - Bookshelf",
|
||||||
@@ -25,8 +25,46 @@
|
|||||||
},
|
},
|
||||||
"duck": {
|
"duck": {
|
||||||
"template": "duck.html",
|
"template": "duck.html",
|
||||||
"title":"You've been ducked!",
|
"title": "You've been ducked!",
|
||||||
"description": "Face it, you've been ducked",
|
"description": "Face it, you've been ducked",
|
||||||
"canonical": "/duck"
|
"canonical": "/duck"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"template": "about.html",
|
||||||
|
"title": "About, Timeline & Certifications - Andrew Simonson",
|
||||||
|
"description": "Bio, career/education timeline, and certifications for Andrew Simonson - Data Scientist at Ecolab.",
|
||||||
|
"canonical": "/about",
|
||||||
|
"timeline": {
|
||||||
|
"ecolab": {
|
||||||
|
"title": "Data Scientist / Data Engineer — Ecolab",
|
||||||
|
"date": "January 2024 – Present",
|
||||||
|
"content": "<p class=\"timeitem-org\">Ecolab · Saint Paul, MN</p><ul><li>Primary model developer of RushReady from inception through multimillion-dollar product commercialization</li><li>Engineered near-real-time data platform with Delta Live Tables and automated MLOps lifecycle across QSR brands</li><li>Built explainable anomaly detection and brand-configurable recommendations, increasing speed of service by 20%</li><li>Represented Ecolab as liaison in Microsoft's 100-member AI accelerator program</li></ul>",
|
||||||
|
"classes": "experience"
|
||||||
|
},
|
||||||
|
"rit_ms": {
|
||||||
|
"title": "M.S. Data Science — RIT",
|
||||||
|
"date": "Dec 2026 (expected)",
|
||||||
|
"content": "<p class=\"timeitem-org\">Rochester Institute of Technology · Rochester, NY</p><ul><li>Focus on probability theory, statistical learning, and Bayesian methods</li><li>Capstone: Fall 2026</li></ul>",
|
||||||
|
"classes": "education"
|
||||||
|
},
|
||||||
|
"dow_chemical": {
|
||||||
|
"title": "Data Engineer — Dow Chemical",
|
||||||
|
"date": "January 2023 – May 2023",
|
||||||
|
"content": "<p class=\"timeitem-org\">Dow Chemical · Freeport, TX</p><ul><li>Independently built reactive chemistry analysis Flask app encoding adiabatic correction and exotherm-detection logic across 9 test types</li><li>Architected object-model abstraction, decoupling test-type parsers from report generation and replacing 4 legacy VBA tools</li><li>Product became the interdepartmental data standard, saving >1,020 hours (~0.65 FTE) annually</li></ul>",
|
||||||
|
"classes": "experience"
|
||||||
|
},
|
||||||
|
"rit_bs": {
|
||||||
|
"title": "B.S. Computer Science & Data Science — RIT",
|
||||||
|
"date": "Dec 2024",
|
||||||
|
"content": "<p class=\"timeitem-org\">Rochester Institute of Technology · Rochester, NY</p><ul><li>GPA 3.63 · Dean's List</li><li>Minor in International Relations</li><li>MicroMasters in Data Science – UC San Diego (edX)</li></ul>",
|
||||||
|
"classes": "education"
|
||||||
|
},
|
||||||
|
"csh": {
|
||||||
|
"title": "E-Board Member & Presenter — CSH",
|
||||||
|
"date": "Aug 2021 – Present",
|
||||||
|
"content": "<p class=\"timeitem-org\">Computer Science House · Rochester, NY</p><ul><li>Presented seminars on web scraping, analytics, and GIS</li><li>Built and maintained web services for 80+ members</li></ul>",
|
||||||
|
"classes": "experience technical"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
190
src/static/json/projects.json
Normal file → Executable file
@@ -1,81 +1,96 @@
|
|||||||
{
|
{
|
||||||
"RIT Hotspots": {
|
"Human vs AI Code Smell Study": {
|
||||||
"status": "WIP",
|
|
||||||
"classes": "pinned geospacial programming",
|
|
||||||
"bgi": "hotspotsrit.png",
|
|
||||||
"content": "Live crowd migration map using RIT occupancy data",
|
|
||||||
"links": [
|
|
||||||
[
|
|
||||||
"github", "https://github.com/asimonson1125/hotspotsrit", "git repo"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"globe", "https://asimonson.com/hotspots", "demo"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"LogicFlow": {
|
|
||||||
"status": "incomplete",
|
|
||||||
"classes": "programming",
|
|
||||||
"bgi": "logicflow.jpg",
|
|
||||||
"content": "Translate paragraphs to logical flowcharts, powered by ChatGPT Winner of CSHacks' Best Use of AI by Paychex",
|
|
||||||
"links": [
|
|
||||||
[
|
|
||||||
"github", "https://github.com/asimonson1125/LogicFlow", "git repo"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"globe", "https://devpost.com/software/logicflow", "Hackathon listing"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Alternative Energy Map": {
|
|
||||||
"status": "complete",
|
"status": "complete",
|
||||||
"classes": "pinned geospacial",
|
"classes": "programming",
|
||||||
"bgi": "geovisF.png",
|
"bgi": "aiCodeGroups.png",
|
||||||
"content": "ArcGIS Map of the most effective alternative energy sources in the continental United States",
|
"content": "Research paper comparing human vs AI code maintainability based on mature code smell detection algorithms.",
|
||||||
|
"links": [
|
||||||
|
[
|
||||||
|
"globe",
|
||||||
|
"http://files.asimonson.com/u/AIcodeSmells.pdf",
|
||||||
|
"Paper"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Generalized Trust Review": {
|
||||||
|
"status": "complete",
|
||||||
|
"classes":"programming",
|
||||||
|
"bgi": "blanketTrust.png",
|
||||||
|
"content": "Short analysis correlating societal blanket trust of press and scientific institutions.",
|
||||||
|
"links": [
|
||||||
|
[
|
||||||
|
"globe",
|
||||||
|
"http://files.asimonson.com/u/blanketTrust.pdf",
|
||||||
|
"Paper"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PhysCom - Physical Combinatorics": {
|
||||||
|
"status": "WIP",
|
||||||
|
"classes": "programming",
|
||||||
|
"content": "Experimental innovation engine operating on physical attributes and limitations of proven existing technologies with further AI review."
|
||||||
|
},
|
||||||
|
"Antietam-Conococheague Watershed Monitoring": {
|
||||||
|
"status": "complete",
|
||||||
|
"classes": "geospatial",
|
||||||
|
"bgi": "watershedTemps.png",
|
||||||
|
"content": "Live geospatial 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": {
|
||||||
|
"status": "complete",
|
||||||
|
"classes": "programming",
|
||||||
|
"bgi": "automotiveBrandAnalysis.png",
|
||||||
|
"content": "Brand valuation analysis of the used car market, measuring value decay by mileage to extrapolate qualities such as perceived reliability and persistent value of luxury features."
|
||||||
|
},
|
||||||
|
"RIT Hotspots": {
|
||||||
|
"status": "incomplete",
|
||||||
|
"classes": "pinned geospatial programming",
|
||||||
|
"bgi": "hotspotsrit.png",
|
||||||
|
"content": "Live crowd migration map using RIT occupancy data. It seems RIT didn't like me exposing their surveillance state but since they didn't want to talk to me about it they instead changed the service response schema a few times. When that didn't stop me they just shut down the whole service. Nerds.",
|
||||||
|
"links": [
|
||||||
|
["github", "https://github.com/asimonson1125/hotspotsrit", "git repo"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Calorimetry Analysis Engineering": {
|
||||||
|
"status": "complete",
|
||||||
|
"classes": "pinned programming",
|
||||||
|
"bgi": "calorimeterAnalysis.png",
|
||||||
|
"content": "An analytical toolkit designed for reactive chemistry analysis, especially calorimetry. Works include automatic analysis, alerting unusual and dangerous results derived from a wide range of testing environments and equipment",
|
||||||
|
"links": []
|
||||||
|
},
|
||||||
|
"Geography of Alternative Energy": {
|
||||||
|
"status": "complete",
|
||||||
|
"classes": "pinned geospatial",
|
||||||
|
"bgi": "energyGeography.png",
|
||||||
|
"content": "An ArcGIS geospatial analysis comparing the difference in effectiveness of wind, solar, and geothermal energy across the continental 48 United States.",
|
||||||
"links": [
|
"links": [
|
||||||
[
|
[
|
||||||
"globe",
|
"globe",
|
||||||
"https://ritarcgis.maps.arcgis.com/apps/dashboards/17d5bda01edc4a2eb6205a4922d889c9",
|
"https://ritarcgis.maps.arcgis.com/apps/dashboards/17d5bda01edc4a2eb6205a4922d889c9",
|
||||||
"ArcGIS"
|
"Dashboard"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"OccupyRIT": {
|
"OccupyRIT": {
|
||||||
"status": "WIP",
|
"status": "complete",
|
||||||
"classes": "programming",
|
"classes": "pinned programming",
|
||||||
"bgi": "occupyRIT.png",
|
"bgi": "occupyRIT.png",
|
||||||
"content": "Collects RIT Gym Occupancy data, determining busiest workout times",
|
"content": "Collects RIT Gym Occupancy data, determining busiest workout times",
|
||||||
"links": [
|
"links": [
|
||||||
["github", "https://github.com/asimonson1125/Occupy-RIT", "git repo"]
|
["github", "https://github.com/asimonson1125/Occupy-RIT", "git repo"]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Chesscom Embeds": {
|
"Portfolio Website": {
|
||||||
"status": "complete",
|
"status": "complete",
|
||||||
"classes": "programming",
|
"classes": "programming",
|
||||||
"bgi": "chessbed.png",
|
"content": "This website is my personal sandbox where I've integrated some of my data projects via docker cluster. It is self hosted and zero-trust secure while remaining dynamic and free of the tech debt that comes with pre-designed sites and excessive framework application. Yeah, I can do E2E.",
|
||||||
"content": "A template for creating Chess.com user profile embeds",
|
|
||||||
"links": [
|
"links": [
|
||||||
["github", "https://github.com/asimonson1125/chesscom-embed", "git repo"]
|
["globe", "https://asimonson.com", "Homepage"],
|
||||||
]
|
[
|
||||||
},
|
"github",
|
||||||
"Resume": {
|
"https://github.com/asimonson1125/asimonson1125.github.io",
|
||||||
"status": "WIP",
|
"git repo"
|
||||||
"classes": "programming",
|
]
|
||||||
"bgi": "resume.png",
|
|
||||||
"content": "My Resume, made in LaTeX with a custom design derived by the AltaCV template on OverLeaf",
|
|
||||||
"links": [
|
|
||||||
["github", "https://github.com/asimonson1125/Resume", "git repo"],
|
|
||||||
["globe", "https://asimonson.com/Resume.pdf", "Resume"]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Digital Portfolio": {
|
|
||||||
"status": "WIP",
|
|
||||||
"classes": "programming",
|
|
||||||
"bgi": "website.png",
|
|
||||||
"content": "My personal portfolio website (you're on it now!)",
|
|
||||||
"links": [
|
|
||||||
["github", "https://github.com/asimonson1125/asimonson1125.github.io", "git repo"],
|
|
||||||
["globe", "https://asimonson.com", "site link"]
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Slate": {
|
"Slate": {
|
||||||
@@ -84,62 +99,21 @@
|
|||||||
"bgi": "slate.png",
|
"bgi": "slate.png",
|
||||||
"content": "Slate is a web app designed to help event coordinators schedule events by congregating participant calendar data. Includes Computer Science House account integration",
|
"content": "Slate is a web app designed to help event coordinators schedule events by congregating participant calendar data. Includes Computer Science House account integration",
|
||||||
"links": [
|
"links": [
|
||||||
["github", "https://github.com/asimonson1125/Slate", "git repo"],
|
["globe", "https://slate.csh.rit.edu", "site link"],
|
||||||
["globe", "https://slate.csh.rit.edu", "site link"]
|
["github", "https://github.com/asimonson1125/Slate", "git repo"]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"HvZ Bot": {
|
"Monte Carlo Engine for NationsGame": {
|
||||||
"status": "complete",
|
|
||||||
"classes": "programming",
|
|
||||||
"bgi": "",
|
|
||||||
"content": "A Discord bot to handle role management and statistics for RIT's Humans vs. Zombies games",
|
|
||||||
"links": [
|
|
||||||
["github", "https://github.com/asimonson1125/HvZ-bot", "git repo"]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"FinTech": {
|
|
||||||
"status": "WIP",
|
|
||||||
"classes": "pinned programming",
|
|
||||||
"bgi": "",
|
|
||||||
"content": "A team derived from the RIT Financial Management Association dedicated to learning about financial management of equities using automated solutions developed by students",
|
|
||||||
"links": [
|
|
||||||
["github", "https://github.com/LukeHorigan/Financial-Management-Assocation-", "git repo"]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Browser Trivia Bot": {
|
|
||||||
"status": "complete",
|
|
||||||
"classes": "programming",
|
|
||||||
"bgi": "",
|
|
||||||
"content": "A tampermonkey tool used to automatically answer and submit online trivia forms, which can be tailored to different site layouts. Source currently private.",
|
|
||||||
"links": [
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"NationsGame Rolls Sim": {
|
|
||||||
"status": "complete",
|
"status": "complete",
|
||||||
"classes": "programming",
|
"classes": "programming",
|
||||||
"bgi": "ceoOfYugo.png",
|
"bgi": "ceoOfYugo.png",
|
||||||
"content": "A simulator for the browser game, NationsGame, to analyze unit composition and predict in-game victors and unit statistics. Unfortunately, NationsGame is now defunct. Limited screenshots of functionality.",
|
"content": "A simulator for the browser game, NationsGame, to analyze unit composition and predict in-game victors and unit statistics. Unfortunately, NationsGame is now defunct. Limited screenshots of functionality.",
|
||||||
"links": [
|
"links": [
|
||||||
["github", "https://github.com/asimonson1125/NG-Rolls-Simulator", "git repo"]
|
[
|
||||||
]
|
"github",
|
||||||
},
|
"https://github.com/asimonson1125/NG-Rolls-Simulator",
|
||||||
"VEXcode Button Engine": {
|
"git repo"
|
||||||
"status": "complete",
|
]
|
||||||
"classes": "programming",
|
|
||||||
"bgi": "vexcodeButtons.jpeg",
|
|
||||||
"content": "VEXcode button library + examples and template for the VEX V5 brain",
|
|
||||||
"links": [
|
|
||||||
["github", "https://github.com/asimonson1125/VEXcode-Button-Generator", "git repo"],
|
|
||||||
["globe", "https://www.vexforum.com/t/vexcode-button-generator/72450", "Forum post"]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"WinKeylogger": {
|
|
||||||
"status": "complete",
|
|
||||||
"classes": "programming",
|
|
||||||
"bgi": "",
|
|
||||||
"content": "A C++ keylogger for windows based off a Udemy course with my custom modifications and powershell script",
|
|
||||||
"links": [
|
|
||||||
["github", "https://github.com/asimonson1125/WinKeylogger", "git repo"]
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/static/json/skills.json
Normal file → Executable file
@@ -1,26 +1,35 @@
|
|||||||
{
|
{
|
||||||
"Data and AI": {
|
"Data and AI": {
|
||||||
"Python": {
|
"ML": {
|
||||||
"PyTorch/TensorFlow": {},
|
"PySpark ML": {},
|
||||||
"Numpy/Pandas": {},
|
"Numpy/Pandas/Polars": {},
|
||||||
"Selenium/BS4": {}
|
"TensorFlow": {},
|
||||||
|
"Scikit": {}
|
||||||
},
|
},
|
||||||
"R": {},
|
"PySpark": {},
|
||||||
"SQL": {}
|
"Selenium/BS4 Web Hacking": {},
|
||||||
|
"SQL": {},
|
||||||
|
"Declarative Pipelines": {},
|
||||||
|
"ArcGIS": {}
|
||||||
|
},
|
||||||
|
"DevOps": {
|
||||||
|
"Docker": {},
|
||||||
|
"Microsoft Azure": {},
|
||||||
|
"Databricks": {},
|
||||||
|
"Kubernetes/Openshift": {},
|
||||||
|
"Cloudflare": {},
|
||||||
|
"Bash": {}
|
||||||
},
|
},
|
||||||
"Frontend": {
|
"Frontend": {
|
||||||
"Flask (Python)": {},
|
"Flask (Python)": {},
|
||||||
"React (Javascript)": {},
|
"REST APIs": {},
|
||||||
"Angular (Typescript)": {}
|
"Web Scraping": {}
|
||||||
},
|
},
|
||||||
"Backend & DevOps": {
|
"Offline Skills": {
|
||||||
"DevOps": {
|
"Circuitry": {},
|
||||||
"Docker": {},
|
"Skiing": {},
|
||||||
"Microsoft Azure": {},
|
"Chess": {},
|
||||||
"Kubernetes/Openshift": {},
|
"Plinking": {},
|
||||||
"Bash": {}
|
"Building something with trash that solves my problems": {}
|
||||||
},
|
|
||||||
"C#": {},
|
|
||||||
"C++": {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/static/json/timeline.json
Normal file → Executable file
@@ -1,18 +1,53 @@
|
|||||||
{
|
{
|
||||||
"Co-op @ Dow Chemical": {
|
"Data Scientist @ Ecolab": {
|
||||||
|
"classes": "pinned experience technical",
|
||||||
|
"date": "01/2024 - Present",
|
||||||
|
"content": "Primary model developer of RushReady from inception through multimillion-dollar product commercialization. Engineered near-real-time data platform with Delta Live Tables and automated MLOps lifecycle. Built explainable anomaly detection, increasing speed of service by 20%. Represented Ecolab in Microsoft's 100-member AI accelerator program."
|
||||||
|
},
|
||||||
|
"Data Scientist / Data Engineer": {
|
||||||
"classes": "pinned experience technical",
|
"classes": "pinned experience technical",
|
||||||
"date": "01/2023 - 05/2023",
|
"date": "01/2023 - 05/2023",
|
||||||
"content": "Spring 2023 Semester Co-op under Dow Chemical's Global Reactive Chemicals team in Analytical Sciences. Responsibilities included management of chemical compatability data and tool creation for parsing, generating, and submitting reports."
|
"content": "Co-op under Dow Chemical's Global Reactive Chemicals team in Analytical Sciences. Independently built reactive chemistry analysis Flask app encoding adiabatic correction and exotherm-detection logic. Replaced 4 legacy VBA tools with an object-model abstraction architecture. Product became the interdepartmental data standard, saving >1,020 hours (~0.65 FTE) annually."
|
||||||
|
},
|
||||||
|
"Rochester Institute of Technology — M.S.": {
|
||||||
|
"classes": "pinned education technical",
|
||||||
|
"date": "01/2023 - 12/2026",
|
||||||
|
"content": "Data Science M.S. with dual cluster in Data Science and AI. Capstone Fall 2026. Certified by Databricks in ML Model Deployment, Retrieval Agents, and MLOps. Certified by Microsoft in Data Analysis Career Essentials and Docker Foundations."
|
||||||
|
},
|
||||||
|
"Rochester Institute of Technology — B.S.": {
|
||||||
|
"classes": "pinned education technical",
|
||||||
|
"date": "08/2021 - 12/2024",
|
||||||
|
"content": "Computer Science B.S. (GPA 3.63, Dean's List) with a minor in International Relations. Active member of Computer Science House — built and maintained web services for 80+ members, presented seminars on web scraping, analytics, and GIS."
|
||||||
|
},
|
||||||
|
"UCSanDiegoX Data Science MicroMasters": {
|
||||||
|
"classes": "education technical",
|
||||||
|
"date": "2024",
|
||||||
|
"content": "Completed MicroMasters program in Data Science through UC San Diego on edX."
|
||||||
},
|
},
|
||||||
"Started Portfolio": {
|
"Started Portfolio": {
|
||||||
"classes": "",
|
"classes": "",
|
||||||
"date": "08/26/2021",
|
"date": "08/26/2021",
|
||||||
"content": "I started building this website on this day. I wish I could say I was farther along than I am."
|
"content": "I started building this website on this day. I wish I could say I was farther along than I am."
|
||||||
},
|
},
|
||||||
"Rochester Institute of Technology": {
|
"Human vs AI Code Smell Study": {
|
||||||
"classes": "pinned education technical",
|
"classes": "technical",
|
||||||
"date": "08/2021 - 12/2024",
|
"date": "2024 - 2025",
|
||||||
"content": "Studying in Rochester Institute of Technology's Computer Science BS program with a minor in International Relations."
|
"content": "Analyzed 85K+ code definitions comparing AI vs human code maintainability via static analysis and non-parametric statistics. Found AI code carries 3-4x more structural debt invisible to reviewers."
|
||||||
|
},
|
||||||
|
"Generalized Trust Review": {
|
||||||
|
"classes": "technical",
|
||||||
|
"date": "2023 - 2024",
|
||||||
|
"content": "Cross-national correlation analysis of media and science trust across 128 countries with subgroup OLS regression. Found trust transfer attenuates 5.7x in low press-freedom regimes."
|
||||||
|
},
|
||||||
|
"Antietam Watershed Monitoring": {
|
||||||
|
"classes": "technical",
|
||||||
|
"date": "2023",
|
||||||
|
"content": "ArcGIS choropleth dashboard monitoring stream temps and biotic indices across Upper Potomac sub-watersheds. Flags exceedances of healthy stream conditions for state environmental review."
|
||||||
|
},
|
||||||
|
"Hackathon Awards": {
|
||||||
|
"classes": "technical",
|
||||||
|
"date": "2023 - 2024",
|
||||||
|
"content": "CSHacks - Best Use of AI (Paychex). Tiger Games - Social Impact Winner. HACK.COMS '24 - Accessibility Winner."
|
||||||
},
|
},
|
||||||
"Pretzel & Pizza Creations": {
|
"Pretzel & Pizza Creations": {
|
||||||
"classes": "experience",
|
"classes": "experience",
|
||||||
@@ -27,11 +62,11 @@
|
|||||||
"Boonsboro High School": {
|
"Boonsboro High School": {
|
||||||
"classes": "education",
|
"classes": "education",
|
||||||
"date": "09/2016 - 06/2021",
|
"date": "09/2016 - 06/2021",
|
||||||
"content": "Graduated high school with highest honors.\nMember of National Honor Society, Academic Team County Champions. Participated in Physics Olympics, Robotics Club, and scored at state championships in Cross Country and Track and Field (4x800, 800)."
|
"content": "Graduated high school with highest honors. Member of National Honor Society, Academic Team County Champions. Participated in Physics Olympics, Robotics Club, and scored at state championships in Cross Country and Track and Field (4x800, 800)."
|
||||||
},
|
},
|
||||||
"Vex Robotics Team Lead/Club Preisdent": {
|
"Vex Robotics Team Lead/Club President": {
|
||||||
"classes": "technical",
|
"classes": "technical",
|
||||||
"date": "10/2015 - 04/2021",
|
"date": "10/2015 - 04/2021",
|
||||||
"content": "Led 5 teams through middle and high school to VEX Robotics Competitions, elevating Boonsboro from county group-stage elimination to its first state championship participation. Reorganized club and set up its first interface with the community + sponsors"
|
"content": "Led 5 teams through middle and high school to VEX Robotics Competitions, elevating Boonsboro from county group-stage elimination to its first state championship participation. Reorganized club and set up its first interface with the community + sponsors."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 898 KiB |
|
Before Width: | Height: | Size: 463 KiB |
BIN
src/static/photos/blinkies/010.gif
Normal file
|
After Width: | Height: | Size: 987 B |
BIN
src/static/photos/blinkies/bobthebuilder.gif
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/static/photos/blinkies/brainglow.gif
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/photos/blinkies/fearnobeer.gif
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src/static/photos/blinkies/pepsiaddict.gif
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/static/photos/blinkies/tooclose.gif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/static/photos/blinkies/usa.gif
Normal file
|
After Width: | Height: | Size: 620 B |
0
src/static/photos/books/12RulesForLife.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
0
src/static/photos/books/BeyondOrder.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
0
src/static/photos/books/HitchhikersGuideToTheGalaxy.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 320 KiB |
BIN
src/static/photos/books/TyrannyOfMetrics.jpg
Executable file
|
After Width: | Height: | Size: 110 KiB |
0
src/static/photos/books/autobioOfJefferson.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
0
src/static/photos/books/courageIsCalling.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
0
src/static/photos/books/davidAndGoliath.png
Normal file → Executable file
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
0
src/static/photos/books/disciplineIsDestiny.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
0
src/static/photos/books/disunitedNations.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
BIN
src/static/photos/books/fooledbyrandomness.jpg
Executable file
|
After Width: | Height: | Size: 17 KiB |
0
src/static/photos/books/freakonomics.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
0
src/static/photos/books/giveMeABreak.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
BIN
src/static/photos/books/hailmary.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
0
src/static/photos/books/makeYourBed.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
0
src/static/photos/books/no-they-cant.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 308 KiB |
0
src/static/photos/books/onGrandStrategy.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
0
src/static/photos/books/profilesInCourage.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
0
src/static/photos/books/ratOpt.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
BIN
src/static/photos/books/reignofterror.jpg
Executable file
|
After Width: | Height: | Size: 126 KiB |
BIN
src/static/photos/books/rightthingrightnow.png
Executable file
|
After Width: | Height: | Size: 204 KiB |
BIN
src/static/photos/books/scoutMindset.png
Executable file
|
After Width: | Height: | Size: 253 KiB |
BIN
src/static/photos/books/sherlockholmes.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
0
src/static/photos/books/superfreakonomics.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 235 KiB |
0
src/static/photos/books/theAbsentSuperpower.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
0
src/static/photos/books/theAccidentalSuperpower.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
0
src/static/photos/books/theEndOfTheWorldIsJustTheBeginning.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
0
src/static/photos/books/theParasiticMind.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
0
src/static/photos/books/theStormBeforeTheCalm.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
BIN
src/static/photos/books/themartian.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
0
src/static/photos/books/thinkLikeAFreak.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
0
src/static/photos/books/verbalJudo.png
Normal file → Executable file
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
0
src/static/photos/books/whenToRobABank.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
0
src/static/photos/books/where-good-ideas-come-from.png
Normal file → Executable file
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
0
src/static/photos/books/youCanReadAnyone.jpg
Normal file → Executable file
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
0
src/static/photos/books/zeroToOne.jpeg
Normal file → Executable file
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 54 KiB |
BIN
src/static/photos/deepdives/homelab-dashboard.png
Normal file
|
After Width: | Height: | Size: 121 KiB |