Compare commits

3 Commits

7 changed files with 306 additions and 121 deletions

View File

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

View File

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

View File

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

View File

@@ -1839,4 +1839,39 @@ tr {
.text-muted { .text-muted {
color: #888 !important; color: #888 !important;
} }
/* SPA Loading Bar */
#loading-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent) 0%, #fff 50%, var(--accent) 100%);
background-size: 200% 100%;
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.6);
z-index: 2000;
width: 0%;
opacity: 0;
transition: width 0.4s cubic-bezier(0.1, 0.05, 0.1, 1), opacity 0.3s ease;
pointer-events: none;
}
#loading-bar.visible {
opacity: 1;
}
#loading-bar.active {
width: 70%;
animation: loading-shimmer 2s infinite linear;
}
#loading-bar.finish {
opacity: 0;
width: 100%;
transition: width 0.2s ease, opacity 0.4s ease 0.1s;
}
@keyframes loading-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}

View File

@@ -14,50 +14,78 @@ function toggleMenu(collapse) {
} }
async function goto(location, { push = true } = {}) { async function goto(location, { push = true } = {}) {
let response; const loadingBar = document.getElementById('loading-bar');
if (loadingBar) {
loadingBar.style.width = ''; // Clear inline style from previous run
}
let loadingTimeout = setTimeout(() => {
if (loadingBar) {
loadingBar.classList.remove('finish');
loadingBar.classList.add('active');
loadingBar.classList.add('visible');
}
}, 150);
try { try {
response = await fetch("/api/goto/" + location, { const response = await fetch("/api/goto/" + location, {
credentials: "include", credentials: "include",
method: "GET", method: "GET",
mode: "cors", mode: "cors",
}); });
if (!response.ok) { if (!response.ok) {
console.error(`Navigation failed: HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
return;
} }
} catch (err) {
console.error("Navigation fetch failed:", err);
return;
}
document.dispatchEvent(new Event('beforenavigate')); // Wait for the full body to download - this is usually the slow part
const [metadata, content] = await response.json();
document.dispatchEvent(new Event('beforenavigate'));
const [metadata, content] = await response.json(); const root = document.getElementById("root");
const root = document.getElementById("root"); root.innerHTML = content;
root.innerHTML = content;
// Re-execute scripts injected via innerHTML (browser ignores them otherwise) // Re-execute scripts
root.querySelectorAll("script").forEach(function(oldScript) { root.querySelectorAll("script").forEach(function(oldScript) {
const newScript = document.createElement("script"); const newScript = document.createElement("script");
Array.from(oldScript.attributes).forEach(function(attr) { Array.from(oldScript.attributes).forEach(function(attr) {
newScript.setAttribute(attr.name, attr.value); newScript.setAttribute(attr.name, attr.value);
});
newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript);
}); });
newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript);
});
if (window.location.href.includes("#")) { if (window.location.href.includes("#")) {
const id = decodeURIComponent(window.location.hash.substring(1)); const id = decodeURIComponent(window.location.hash.substring(1));
const el = document.getElementById(id); const el = document.getElementById(id);
if (el) el.scrollIntoView(); if (el) el.scrollIntoView();
} else { } else {
window.scrollTo({ top: 0, left: 0, behavior: "instant" }); window.scrollTo({ top: 0, left: 0, behavior: "instant" });
} }
toggleMenu(true); toggleMenu(true);
document.querySelector("title").textContent = metadata["title"]; document.querySelector("title").textContent = metadata["title"];
if (push) { if (push) {
history.pushState(null, null, metadata["canonical"]); history.pushState(null, null, metadata["canonical"]);
}
} catch (err) {
console.error("Navigation failed:", err);
} finally {
clearTimeout(loadingTimeout);
if (loadingBar && loadingBar.classList.contains('active')) {
loadingBar.classList.add('finish');
loadingBar.classList.remove('active');
setTimeout(() => {
if (!loadingBar.classList.contains('active')) {
loadingBar.style.width = '0%';
loadingBar.classList.remove('finish');
loadingBar.classList.remove('visible');
}
}, 500);
}
} }
} }

View File

@@ -1,4 +1,8 @@
let statusIntervalId = null; // 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() { async function fetchStatus() {
try { try {
@@ -17,7 +21,8 @@ async function fetchStatus() {
function updateStatusDisplay(data) { function updateStatusDisplay(data) {
if (data.last_check) { if (data.last_check) {
const lastCheck = new Date(data.last_check); const lastCheck = new Date(data.last_check);
document.getElementById('lastUpdate').textContent = `Last checked: ${lastCheck.toLocaleString()}`; const lastUpdateEl = document.getElementById('lastUpdate');
if (lastUpdateEl) lastUpdateEl.textContent = `Last checked: ${lastCheck.toLocaleString()}`;
} }
if (data.next_check) { if (data.next_check) {
@@ -28,11 +33,12 @@ function updateStatusDisplay(data) {
} }
} }
data.services.forEach(function(service) { if (data.services) {
updateServiceCard(service); data.services.forEach(function(service) {
}); updateServiceCard(service);
});
updateOverallStatus(data.services); updateOverallStatus(data.services);
}
const refreshBtn = document.getElementById('refreshBtn'); const refreshBtn = document.getElementById('refreshBtn');
if (refreshBtn) { if (refreshBtn) {
@@ -65,36 +71,38 @@ function updateServiceCard(service) {
const uptimeDisplay = document.getElementById(`uptime-${service.id}`); const uptimeDisplay = document.getElementById(`uptime-${service.id}`);
const checksDisplay = document.getElementById(`checks-${service.id}`); const checksDisplay = document.getElementById(`checks-${service.id}`);
timeDisplay.textContent = service.response_time !== null ? `${service.response_time}ms` : '--'; if (timeDisplay) timeDisplay.textContent = service.response_time !== null ? `${service.response_time}ms` : '--';
if (service.status_code !== null) { if (codeDisplay) {
codeDisplay.textContent = service.status_code; if (service.status_code !== null) {
} else { codeDisplay.textContent = service.status_code;
codeDisplay.textContent = service.status === 'unknown' ? 'Unknown' : 'Error'; } else {
codeDisplay.textContent = service.status === 'unknown' ? 'Unknown' : 'Error';
}
} }
card.classList.remove('online', 'degraded', 'offline', 'unknown'); card.classList.remove('online', 'degraded', 'offline', 'unknown');
switch (service.status) { switch (service.status) {
case 'online': case 'online':
stateDot.className = 'state-dot online'; if (stateDot) stateDot.className = 'state-dot online';
stateText.textContent = 'Operational'; if (stateText) stateText.textContent = 'Operational';
card.classList.add('online'); card.classList.add('online');
break; break;
case 'degraded': case 'degraded':
case 'timeout': case 'timeout':
stateDot.className = 'state-dot degraded'; if (stateDot) stateDot.className = 'state-dot degraded';
stateText.textContent = service.status === 'timeout' ? 'Timeout' : 'Degraded'; if (stateText) stateText.textContent = service.status === 'timeout' ? 'Timeout' : 'Degraded';
card.classList.add('degraded'); card.classList.add('degraded');
break; break;
case 'offline': case 'offline':
stateDot.className = 'state-dot offline'; if (stateDot) stateDot.className = 'state-dot offline';
stateText.textContent = 'Offline'; if (stateText) stateText.textContent = 'Offline';
card.classList.add('offline'); card.classList.add('offline');
break; break;
default: default:
stateDot.className = 'state-dot loading'; if (stateDot) stateDot.className = 'state-dot loading';
stateText.textContent = 'Unknown'; if (stateText) stateText.textContent = 'Unknown';
card.classList.add('unknown'); card.classList.add('unknown');
} }
@@ -114,6 +122,8 @@ function updateServiceCard(service) {
function updateOverallStatus(services) { function updateOverallStatus(services) {
const overallBar = document.getElementById('overallStatus'); const overallBar = document.getElementById('overallStatus');
if (!overallBar) return;
const icon = overallBar.querySelector('.summary-icon'); const icon = overallBar.querySelector('.summary-icon');
const title = overallBar.querySelector('.summary-title'); const title = overallBar.querySelector('.summary-title');
const subtitle = document.getElementById('summary-subtitle'); const subtitle = document.getElementById('summary-subtitle');
@@ -125,46 +135,52 @@ function updateOverallStatus(services) {
const degraded = services.filter(function(s) { return s.status === 'degraded' || s.status === 'timeout'; }).length; const degraded = services.filter(function(s) { return s.status === 'degraded' || s.status === 'timeout'; }).length;
const offline = services.filter(function(s) { return s.status === 'offline'; }).length; const offline = services.filter(function(s) { return s.status === 'offline'; }).length;
onlineCount.textContent = online; if (onlineCount) onlineCount.textContent = online;
totalCount.textContent = total; if (totalCount) totalCount.textContent = total;
overallBar.classList.remove('online', 'degraded', 'offline'); overallBar.classList.remove('online', 'degraded', 'offline');
icon.classList.remove('operational', 'partial', 'major', 'loading'); if (icon) icon.classList.remove('operational', 'partial', 'major', 'loading');
// Determine overall status // Determine overall status
if (online === total) { if (online === total) {
// All systems operational
overallBar.classList.add('online'); overallBar.classList.add('online');
icon.classList.add('operational'); if (icon) {
icon.textContent = '\u2713'; icon.classList.add('operational');
title.textContent = 'All Systems Operational'; icon.textContent = '\u2713';
subtitle.textContent = `All ${total} services are running normally`; }
if (title) title.textContent = 'All Systems Operational';
if (subtitle) subtitle.textContent = `All ${total} services are running normally`;
} else if (offline >= Math.ceil(total / 2)) { } else if (offline >= Math.ceil(total / 2)) {
// Major outage (50% or more offline)
overallBar.classList.add('offline'); overallBar.classList.add('offline');
icon.classList.add('major'); if (icon) {
icon.textContent = '\u2715'; icon.classList.add('major');
title.textContent = 'Major Outage'; icon.textContent = '\u2715';
subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline, ${degraded} degraded`; }
if (title) title.textContent = 'Major Outage';
if (subtitle) subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline, ${degraded} degraded`;
} else if (offline > 0 || degraded > 0) { } else if (offline > 0 || degraded > 0) {
// Partial outage
overallBar.classList.add('degraded'); overallBar.classList.add('degraded');
icon.classList.add('partial'); if (icon) {
icon.textContent = '\u26A0'; icon.classList.add('partial');
title.textContent = 'Partial Outage'; icon.textContent = '\u26A0';
if (offline > 0 && degraded > 0) { }
subtitle.textContent = `${offline} offline, ${degraded} degraded`; if (title) title.textContent = 'Partial Outage';
} else if (offline > 0) { if (subtitle) {
subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline`; if (offline > 0 && degraded > 0) {
} else { subtitle.textContent = `${offline} offline, ${degraded} degraded`;
subtitle.textContent = `${degraded} service${degraded !== 1 ? 's' : ''} degraded`; } else if (offline > 0) {
subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline`;
} else {
subtitle.textContent = `${degraded} service${degraded !== 1 ? 's' : ''} degraded`;
}
} }
} else { } else {
// Unknown state if (icon) {
icon.classList.add('loading'); icon.classList.add('loading');
icon.textContent = '\u25D0'; icon.textContent = '\u25D0';
title.textContent = 'Status Unknown'; }
subtitle.textContent = 'Waiting for service data'; if (title) title.textContent = 'Status Unknown';
if (subtitle) subtitle.textContent = 'Waiting for service data';
} }
} }
@@ -191,23 +207,23 @@ function refreshStatus() {
} }
function initStatusPage() { function initStatusPage() {
if (statusIntervalId !== null) { if (window.statusIntervalId) {
clearInterval(statusIntervalId); clearInterval(window.statusIntervalId);
} }
fetchStatus(); fetchStatus();
statusIntervalId = setInterval(fetchStatus, 60000); window.statusIntervalId = setInterval(fetchStatus, 60000);
} }
// Clean up polling interval when navigating away via SPA function cleanupStatusPage() {
document.addEventListener('beforenavigate', function() { if (window.statusIntervalId) {
if (statusIntervalId !== null) { clearInterval(window.statusIntervalId);
clearInterval(statusIntervalId); window.statusIntervalId = null;
statusIntervalId = null;
} }
}); document.removeEventListener('beforenavigate', cleanupStatusPage);
}
if (document.readyState === 'loading') { document.addEventListener('beforenavigate', cleanupStatusPage);
document.addEventListener('DOMContentLoaded', initStatusPage);
} else { if (document.getElementById('overallStatus')) {
initStatusPage(); initStatusPage();
} }

View File

@@ -59,6 +59,7 @@
</head> </head>
{% block header %} {% block header %}
<body onpopstate="backButton()"> <body onpopstate="backButton()">
<div id="loading-bar"></div>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<main id='map'></main> <main id='map'></main>
<div id="contentStuffer"> <div id="contentStuffer">