Initial site with CI/CD pipeline

This commit is contained in:
Brad Stein 2025-12-18 01:13:04 -03:00
commit f454df4f9c
36 changed files with 5382 additions and 0 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
.git
.gitignore
node_modules
frontend/node_modules
frontend/dist
frontend/.vite
media
docs
__pycache__
.venv
*.pyc
*.md

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
.venv/
__pycache__/
*.pyc
.DS_Store
.env
node_modules/
frontend/node_modules/
frontend/dist/
.pytest_cache/
.coverage
docs/*.md
AGENTS.md

18
Dockerfile.backend Normal file
View File

@ -0,0 +1,18 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ .
EXPOSE 8080
CMD ["gunicorn", "-b", "0.0.0.0:8080", "app:app"]

20
Dockerfile.frontend Normal file
View File

@ -0,0 +1,20 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci --ignore-scripts
COPY frontend/ ./
RUN npm run build
# Runtime stage
FROM nginx:1.27-alpine
WORKDIR /usr/share/nginx/html
# Minimal nginx config with SPA fallback.
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist ./
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

173
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,173 @@
pipeline {
agent {
kubernetes {
label 'bstein-dev-home'
defaultContainer 'builder'
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
app: bstein-dev-home
spec:
nodeSelector:
kubernetes.io/arch: arm64
node-role.kubernetes.io/worker: "true"
containers:
- name: dind
image: docker:27-dind
securityContext:
privileged: true
env:
- name: DOCKER_TLS_CERTDIR
value: ""
args:
- |
set -euo pipefail
exec dockerd-entrypoint.sh --mtu=1400
volumeMounts:
- name: dind-storage
mountPath: /var/lib/docker
- name: builder
image: docker:27
command: ["cat"]
tty: true
env:
- name: DOCKER_HOST
value: tcp://localhost:2375
- name: DOCKER_TLS_CERTDIR
value: ""
volumeMounts:
- name: workspace-volume
mountPath: /home/jenkins/agent
- name: docker-config-writable
mountPath: /root/.docker
- name: harbor-config
mountPath: /docker-config
volumes:
- name: workspace-volume
emptyDir: {}
- name: docker-config-writable
emptyDir: {}
- name: dind-storage
emptyDir: {}
- name: harbor-config
secret:
secretName: harbor-bstein-robot
items:
- key: .dockerconfigjson
path: config.json
"""
}
}
environment {
REGISTRY = 'registry.bstein.dev/bstein'
FRONT_IMAGE = "${REGISTRY}/bstein-dev-home-frontend"
BACK_IMAGE = "${REGISTRY}/bstein-dev-home-backend"
}
options {
disableConcurrentBuilds()
}
triggers {
// Webhook-friendly; Jenkins' /git/notifyCommit can trigger this without polling.
scm('')
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Prep toolchain') {
steps {
container('builder') {
sh '''
set -euo pipefail
apk add --no-cache bash git jq
mkdir -p /root/.docker
cp /docker-config/config.json /root/.docker/config.json
'''
}
}
}
stage('Compute version') {
steps {
container('builder') {
script {
sh '''
set -euo pipefail
SEMVER="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || jq -r '.version' frontend/package.json || echo '0.1.0')"
# Accept bare semver or leading v.
if ! echo "$SEMVER" | grep -Eq '^v?[0-9]+\\.[0-9]+\\.[0-9]+(\\-[0-9A-Za-z.-]+)?$'; then
SEMVER="0.1.0"
fi
echo "SEMVER=${SEMVER}" > build.env
'''
def props = readProperties file: 'build.env'
env.SEMVER = props['SEMVER']
env.VERSION_TAG = env.SEMVER
}
}
}
}
stage('Buildx setup') {
steps {
container('builder') {
sh '''
set -euo pipefail
for i in $(seq 1 10); do
if docker info >/dev/null 2>&1; then
break
fi
sleep 2
done
docker buildx create --name bstein-builder --driver docker-container --bootstrap --use || docker buildx use bstein-builder
'''
}
}
}
stage('Build & push frontend') {
steps {
container('builder') {
sh '''
set -euo pipefail
docker buildx build \
--platform linux/arm64 \
--tag "${FRONT_IMAGE}:${VERSION_TAG}" \
--tag "${FRONT_IMAGE}:latest" \
--file Dockerfile.frontend \
--push \
.
'''
}
}
}
stage('Build & push backend') {
steps {
container('builder') {
sh '''
set -euo pipefail
docker buildx build \
--platform linux/arm64 \
--tag "${BACK_IMAGE}:${VERSION_TAG}" \
--tag "${BACK_IMAGE}:latest" \
--file Dockerfile.backend \
--push \
.
'''
}
}
}
}
post {
always {
echo "Build complete for ${VERSION_TAG}"
}
}
}

6
README.md Normal file
View File

@ -0,0 +1,6 @@
# bstein-dev-home
Portfolio + lab status site with a Flask backend and Vue frontend.
- Jenkins pipeline builds arm64 Docker images (`bstein-dev-home-frontend`, `bstein-dev-home-backend`) and pushes to `registry.bstein.dev/bstein`.
- Flux deploys to `bstein.dev` with Traefik routing `/api` to the backend and the rest to the frontend.

161
backend/app.py Normal file
View File

@ -0,0 +1,161 @@
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any
from urllib.error import URLError
from urllib.parse import urlencode
from urllib.request import urlopen
from flask import Flask, jsonify, send_from_directory
from flask_cors import CORS
app = Flask(__name__, static_folder="../frontend/dist", static_url_path="")
CORS(app, resources={r"/api/*": {"origins": "*"}})
MONERO_GET_INFO_URL = os.getenv("MONERO_GET_INFO_URL", "http://monerod.crypto.svc.cluster.local:18081/get_info")
VM_BASE_URL = os.getenv(
"VM_BASE_URL",
"http://victoria-metrics-single-server.monitoring.svc.cluster.local:8428",
).rstrip("/")
VM_QUERY_TIMEOUT_SEC = float(os.getenv("VM_QUERY_TIMEOUT_SEC", "2"))
HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_TIMEOUT_SEC", "2"))
LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30"))
GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "https://metrics.bstein.dev/api/health")
OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics")
_LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
@app.route("/api/healthz")
def healthz() -> Any:
return jsonify({"ok": True})
@app.route("/api/monero/get_info")
def monero_get_info() -> Any:
try:
with urlopen(MONERO_GET_INFO_URL, timeout=2) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return jsonify(payload)
except (URLError, TimeoutError, ValueError) as exc:
return jsonify({"error": str(exc), "url": MONERO_GET_INFO_URL}), 503
def _vm_query(expr: str) -> float | None:
url = f"{VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}"
with urlopen(url, timeout=VM_QUERY_TIMEOUT_SEC) as resp:
payload = json.loads(resp.read().decode("utf-8"))
if payload.get("status") != "success":
return None
result = (payload.get("data") or {}).get("result") or []
if not result:
return None
values: list[float] = []
for item in result:
try:
values.append(float(item["value"][1]))
except (KeyError, IndexError, TypeError, ValueError):
continue
if not values:
return None
return max(values)
def _http_ok(url: str, expect_substring: str | None = None) -> bool:
try:
with urlopen(url, timeout=HTTP_CHECK_TIMEOUT_SEC) as resp:
if getattr(resp, "status", 200) != 200:
return False
if expect_substring:
chunk = resp.read(4096).decode("utf-8", errors="ignore")
return expect_substring in chunk
return True
except (URLError, TimeoutError, ValueError):
return False
@app.route("/api/lab/status")
def lab_status() -> Any:
now = time.time()
cached = _LAB_STATUS_CACHE.get("value")
if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < LAB_STATUS_CACHE_SEC):
return jsonify(cached)
connected = False
atlas_up = False
atlas_known = False
atlas_source = "unknown"
oceanus_up = False
oceanus_known = False
oceanus_source = "unknown"
try:
atlas_value = _vm_query('max(up{job="kubernetes-apiservers"})')
oceanus_value = _vm_query('max(up{instance=~"(titan-23|192.168.22.24)(:9100)?"})')
connected = True
atlas_known = atlas_value is not None
atlas_up = bool(atlas_value and atlas_value > 0.5)
atlas_source = "victoria-metrics"
oceanus_known = oceanus_value is not None
oceanus_up = bool(oceanus_value and oceanus_value > 0.5)
oceanus_source = "victoria-metrics"
except (URLError, TimeoutError, ValueError):
atlas_value = None
oceanus_value = None
if not atlas_known:
if _http_ok(GRAFANA_HEALTH_URL):
connected = True
atlas_known = True
atlas_up = True
atlas_source = "grafana-health"
if not oceanus_up:
if _http_ok(OCEANUS_NODE_EXPORTER_URL, expect_substring="node_exporter_build_info"):
connected = True
oceanus_known = True
oceanus_up = True
oceanus_source = "node-exporter"
payload = {
"connected": connected,
"atlas": {"up": atlas_up, "known": atlas_known, "source": atlas_source},
"oceanus": {"up": oceanus_up, "known": oceanus_known, "source": oceanus_source},
"checked_at": int(now),
}
_LAB_STATUS_CACHE["ts"] = now
_LAB_STATUS_CACHE["value"] = payload
return jsonify(payload)
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def serve_frontend(path: str) -> Any:
dist_path = Path(app.static_folder)
index_path = dist_path / "index.html"
if dist_path.exists() and index_path.exists():
target = dist_path / path
if path and target.exists():
return send_from_directory(app.static_folder, path)
return send_from_directory(app.static_folder, "index.html")
return jsonify(
{
"message": "Frontend not built yet. Run `npm install && npm run build` inside frontend/, then restart Flask.",
"available_endpoints": ["/api/healthz", "/api/monero/get_info"],
}
)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)

3
backend/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
flask==3.0.3
flask-cors==4.0.0
gunicorn==21.2.0

17
frontend/index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bstein.dev | Titan Lab Portfolio</title>
<meta
name="description"
content="Portfolio and live status for the Titan Lab clusters powering bstein.dev."
/>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

19
frontend/nginx.conf Normal file
View File

@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

2802
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
frontend/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "bstein-portfolio",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.7",
"mermaid": "^10.9.1",
"vue": "^3.4.21",
"vue-router": "^4.3.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.0"
}
}

59
frontend/src/App.vue Normal file
View File

@ -0,0 +1,59 @@
<template>
<div class="app-shell">
<TopBar />
<router-view
:lab-data="labData"
:lab-status="labStatus"
:service-data="serviceData"
:network-data="networkData"
:metrics-data="metricsData"
:loading="statusLoading"
:error="statusError"
/>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import TopBar from "./components/TopBar.vue";
import { fallbackHardware, fallbackServices, fallbackNetwork, fallbackMetrics } from "./data/sample.js";
const labData = ref(fallbackHardware());
const labStatus = ref(null);
const serviceData = ref(fallbackServices());
const networkData = ref(fallbackNetwork());
const metricsData = ref(fallbackMetrics());
const statusLoading = ref(true);
const statusError = ref("");
async function refreshLabStatus() {
try {
const resp = await fetch("/api/lab/status", { headers: { Accept: "application/json" } });
if (!resp.ok) throw new Error(`status ${resp.status}`);
labStatus.value = await resp.json();
statusError.value = "";
} catch (err) {
labStatus.value = null;
statusError.value = "Live data unavailable";
} finally {
statusLoading.value = false;
}
}
onMounted(() => {
refreshLabStatus();
window.setInterval(refreshLabStatus, 30000);
});
</script>
<style scoped>
.app-shell {
min-height: 100vh;
background: radial-gradient(circle at 8% 12%, rgba(55, 135, 255, 0.08), transparent 20%),
radial-gradient(circle at 90% 20%, rgba(0, 255, 182, 0.07), transparent 25%),
radial-gradient(circle at 18% 84%, rgba(255, 75, 135, 0.06), transparent 24%),
var(--bg-deep);
color: var(--text-primary);
}
</style>

View File

@ -0,0 +1,54 @@
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=JetBrains+Mono:wght@400;600&display=swap");
:root {
font-family: "Space Grotesk", "Inter", system-ui, -apple-system, sans-serif;
color: var(--text-primary);
background-color: var(--bg-deep);
text-rendering: optimizeLegibility;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at 10% 10%, rgba(55, 135, 255, 0.08), transparent 25%),
radial-gradient(circle at 90% 20%, rgba(0, 255, 182, 0.06), transparent 22%),
radial-gradient(circle at 20% 80%, rgba(255, 75, 135, 0.06), transparent 25%),
var(--bg-deep);
}
a {
color: var(--accent-cyan);
text-decoration: none;
}
a:hover {
color: var(--accent-rose);
}
h1,
h2,
h3,
h4 {
color: var(--text-strong);
letter-spacing: -0.01em;
}
p {
color: var(--text-muted);
}
.page {
max-width: 1200px;
margin: 0 auto;
padding: 48px 22px 72px;
}
section + section {
margin-top: 32px;
}

View File

@ -0,0 +1,136 @@
:root {
--bg-deep: #050914;
--bg-panel: rgba(17, 24, 39, 0.9);
--bg-glow: linear-gradient(135deg, rgba(0, 255, 182, 0.12), rgba(87, 111, 255, 0.18));
--border: rgba(255, 255, 255, 0.06);
--text-primary: #d7e5ff;
--text-strong: #f7fbff;
--text-muted: #9fb2d0;
--accent-cyan: #00e5c5;
--accent-rose: #ff4f93;
--accent-violet: #7f7cff;
--shadow-soft: 0 18px 40px rgba(0, 0, 0, 0.35);
--shadow-strong: 0 24px 64px rgba(0, 0, 0, 0.5);
--glow: 0 0 20px rgba(0, 229, 197, 0.25), 0 0 42px rgba(127, 124, 255, 0.2);
--radius: 18px;
--radius-sm: 12px;
--grid-gap: 18px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-muted);
font-size: 13px;
}
.card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 18px 16px;
box-shadow: var(--shadow-soft);
position: relative;
overflow: hidden;
}
.card::before {
content: "";
position: absolute;
inset: 0;
background: var(--bg-glow);
opacity: 0.15;
filter: blur(28px);
z-index: 0;
}
.card > * {
position: relative;
z-index: 1;
}
.grid {
display: grid;
gap: var(--grid-gap);
}
.grid.two {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.grid.three {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.glass {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(5, 9, 20, 0.9));
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-strong);
}
.tag {
padding: 4px 10px;
border-radius: 10px;
font-size: 12px;
letter-spacing: 0.01em;
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.accent {
color: var(--accent-cyan);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--border);
background: linear-gradient(135deg, rgba(0, 229, 197, 0.22), rgba(127, 124, 255, 0.18));
color: #02141d;
font-weight: 600;
text-decoration: none;
box-shadow: var(--glow);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.btn.secondary {
background: rgba(255, 255, 255, 0.04);
color: var(--text-strong);
}
.btn:hover {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 12px 36px rgba(0, 229, 197, 0.25);
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.panel-title h2 {
margin: 0;
}
.mono {
font-family: "JetBrains Mono", "Space Grotesk", monospace;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
margin: 16px 0;
}

View File

@ -0,0 +1,116 @@
<template>
<header class="hero card glass">
<div class="eyebrow">
<span class="pill">Portfolio + Titan Lab</span>
<span class="mono accent">atlas · oceanus · nextcloud-ready</span>
</div>
<h1>{{ title }}</h1>
<p class="lede">{{ subtitle }}</p>
<div class="cta">
<a v-for="link in links" :key="link.label" class="btn" :href="link.href" target="_blank" rel="noreferrer">
{{ link.label }}
</a>
</div>
<div class="inline-status">
<span v-if="loading" class="pill mono">Loading live data</span>
<span v-else-if="error" class="pill mono">{{ error }}</span>
<span v-else class="pill mono">Live data connected</span>
</div>
<div class="hero-grid">
<div class="mini-card">
<div class="label">Atlas</div>
<div class="value">pi + jetson + gpu mesh</div>
<small>Flux + Longhorn + Traefik</small>
</div>
<div class="mini-card">
<div class="label">Oceanus</div>
<div class="value">validator</div>
<small>Scraped via titan-24 into Grafana</small>
</div>
<div class="mini-card">
<div class="label">Ingress</div>
<div class="value">Traefik + oauth2-proxy</div>
<small>Keycloak SSO · secure headers</small>
</div>
</div>
</header>
</template>
<script setup>
defineProps({
title: String,
subtitle: String,
links: Array,
loading: Boolean,
error: String,
});
</script>
<style scoped>
.hero {
padding: 32px 28px;
background: linear-gradient(135deg, rgba(0, 229, 197, 0.12), rgba(127, 124, 255, 0.12));
border: 1px solid rgba(255, 255, 255, 0.08);
}
.eyebrow {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
flex-wrap: wrap;
}
h1 {
margin: 0 0 8px;
font-size: 32px;
}
.lede {
margin: 0 0 16px;
max-width: 720px;
color: var(--text-muted);
}
.cta {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin: 14px 0;
}
.inline-status {
margin: 12px 0 8px;
}
.hero-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-top: 16px;
}
.mini-card {
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
.label {
color: var(--text-muted);
font-size: 13px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.value {
color: var(--text-strong);
font-weight: 700;
margin: 4px 0;
}
small {
color: var(--text-muted);
}
</style>

View File

@ -0,0 +1,188 @@
<template>
<div class="card mermaid-card">
<div class="panel-title">
<h3>{{ title }}</h3>
<div class="actions">
<button class="pill mono action" type="button" @click="open">Full screen</button>
<span class="pill mono">Mermaid</span>
</div>
</div>
<p class="description">{{ description }}</p>
<div class="diagram" role="button" tabindex="0" @click="open" v-html="svgContent"></div>
<div v-if="isOpen" class="overlay" @click.self="close">
<div class="modal card glass">
<div class="modal-head">
<div>
<div class="modal-title">{{ title }}</div>
<div class="modal-sub mono">Click outside or press Esc to close</div>
</div>
<button class="close" type="button" @click="close">Close</button>
</div>
<div class="modal-body" v-html="svgContent"></div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from "vue";
import mermaid from "mermaid";
const props = defineProps({
title: String,
description: String,
diagram: String,
cardId: String,
});
const svgContent = ref("");
const renderKey = ref(props.cardId || `mermaid-${Math.random().toString(36).slice(2)}`);
const isOpen = ref(false);
let initialized = false;
const renderDiagram = async () => {
if (!props.diagram) return;
if (!initialized) {
mermaid.initialize({
startOnLoad: false,
theme: "dark",
securityLevel: "loose",
themeVariables: {
primaryColor: "#0b1228",
primaryTextColor: "#e8f3ff",
primaryBorderColor: "#00e5c5",
lineColor: "#7f7cff",
nodeTextColor: "#e8f3ff",
},
});
initialized = true;
}
try {
const { svg } = await mermaid.render(`${renderKey.value}-${Date.now()}`, props.diagram);
svgContent.value = svg;
} catch (err) {
svgContent.value = `<pre class="mono" style="color:#ff4f93">Mermaid render error: ${err}</pre>`;
}
};
onMounted(renderDiagram);
watch(
() => props.diagram,
() => renderDiagram()
);
const onKeyDown = (event) => {
if (event.key === "Escape") close();
};
const open = () => {
isOpen.value = true;
};
const close = () => {
isOpen.value = false;
};
watch(isOpen, (value) => {
if (value) {
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
} else {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKeyDown);
}
});
onUnmounted(() => {
document.body.style.overflow = "";
window.removeEventListener("keydown", onKeyDown);
});
</script>
<style scoped>
.mermaid-card {
min-height: 320px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
.action {
cursor: pointer;
}
.description {
color: var(--text-muted);
margin-top: 0;
}
.diagram {
margin-top: 12px;
padding: 10px;
border-radius: var(--radius-sm);
border: 1px dashed rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
overflow-x: auto;
cursor: zoom-in;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: grid;
place-items: center;
padding: 18px;
z-index: 50;
}
.modal {
width: min(1200px, 96vw);
max-height: 92vh;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.modal-title {
font-weight: 800;
color: var(--text-strong);
}
.modal-sub {
color: var(--text-muted);
font-size: 13px;
margin-top: 4px;
}
.close {
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
color: var(--text-strong);
padding: 8px 12px;
border-radius: 10px;
cursor: pointer;
}
.modal-body {
padding: 12px;
overflow: auto;
max-height: calc(92vh - 64px);
}
.modal-body :deep(svg) {
width: 100%;
height: auto;
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<div class="metrics">
<div v-for="item in items" :key="item.label" class="metric-card">
<div class="label">{{ item.label }}</div>
<div class="value">{{ item.value }}</div>
<div class="note">{{ item.note }}</div>
</div>
</div>
</template>
<script setup>
defineProps({
items: Array,
});
</script>
<style scoped>
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin: 18px 0;
}
.metric-card {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius);
padding: 14px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(0, 229, 197, 0.08));
box-shadow: var(--shadow-soft);
}
.label {
color: var(--text-muted);
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.value {
font-size: 26px;
font-weight: 800;
margin: 6px 0;
color: var(--text-strong);
}
.note {
color: var(--text-muted);
font-size: 13px;
white-space: pre-line;
}
</style>

View File

@ -0,0 +1,70 @@
<template>
<div class="card glass metrics">
<div class="panel-title">
<h2>Live Metrics</h2>
<span class="pill mono">metrics.bstein.dev</span>
</div>
<p>{{ metrics.description }}</p>
<div class="metrics-body">
<iframe
:src="metrics.dashboard"
title="Atlas + Oceanus metrics"
loading="lazy"
allowfullscreen
></iframe>
<div class="metrics-notes">
<div class="label">Highlights</div>
<ul>
<li>Atlas scraping for cluster + service SLOs.</li>
<li>Tethys (titan-24) pushes Oceanus validator stats.</li>
<li>Grafana ready for embedding into Nextcloud widgets.</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
metrics: {
type: Object,
default: () => ({ dashboard: "https://metrics.bstein.dev", description: "" }),
},
});
</script>
<style scoped>
.metrics iframe {
width: 100%;
min-height: 320px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm);
background: #0b1228;
}
.metrics-body {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 12px;
margin-top: 12px;
}
.metrics-notes {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm);
padding: 12px;
background: rgba(255, 255, 255, 0.03);
}
ul {
color: var(--text-muted);
padding-left: 16px;
margin: 8px 0 0;
}
@media (max-width: 900px) {
.metrics-body {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<div class="service-grid">
<component
v-for="svc in services"
:key="svc.name"
:is="isInternal(svc.link) ? RouterLink : 'a'"
class="service card"
:class="{ muted: svc.status === 'planned' || svc.status === 'degraded' }"
:to="isInternal(svc.link) ? svc.link : undefined"
:href="!isInternal(svc.link) ? svc.link || '#' : undefined"
:title="svc.link"
:target="isInternal(svc.link) ? undefined : '_blank'"
rel="noreferrer"
>
<div class="service-top">
<div class="icon">{{ svc.icon || "🛰️" }}</div>
<div>
<div class="name">{{ svc.name }}</div>
<div class="category">{{ svc.category }}</div>
</div>
</div>
<p class="summary">{{ svc.summary }}</p>
<div v-if="svc.link" class="link mono">{{ svc.host || hostFromLink(svc.link) }}</div>
</component>
</div>
</template>
<script setup>
import { RouterLink } from "vue-router";
const props = defineProps({
services: Array,
});
const hostFromLink = (link) => {
try {
const url = new URL(link);
return url.host;
} catch (e) {
return link;
}
};
const isInternal = (link) => typeof link === "string" && link.startsWith("/");
</script>
<style scoped>
.service-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.service {
text-decoration: none;
color: inherit;
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
border-color: rgba(255, 255, 255, 0.08);
}
.service:hover {
transform: translateY(-2px);
border-color: rgba(0, 229, 197, 0.5);
box-shadow: var(--glow);
}
.service.muted {
opacity: 0.5;
border-style: dashed;
}
.service.muted:hover {
transform: none;
border-color: rgba(255, 255, 255, 0.12);
box-shadow: none;
}
.service-top {
display: flex;
align-items: center;
gap: 10px;
}
.icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.06);
font-size: 18px;
}
.name {
font-weight: 700;
color: var(--text-strong);
}
.category {
color: var(--text-muted);
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.summary {
color: var(--text-muted);
margin: 8px 0 0;
}
.link {
margin-top: 6px;
color: var(--accent-cyan);
font-size: 13px;
}
@media (max-width: 1100px) {
.service-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 820px) {
.service-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.service-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<section>
<div class="panel-title">
<h2>Atlas Footprint</h2>
<span class="pill mono">{{ nodes.length }} nodes total</span>
</div>
<div class="grid three">
<div class="card stat">
<div class="label">Control plane</div>
<div class="value accent">{{ controlPlane }}</div>
<p>HA rpi5 set with dedicated db node (titan-db).</p>
</div>
<div class="card stat">
<div class="label">Pi workers</div>
<div class="value">{{ piWorkers }}</div>
<p>Pi5s handle most workloads; Pi4s mount astreae/asteria disks.</p>
</div>
<div class="card stat">
<div class="label">Accelerators</div>
<div class="value">{{ accelerators }}</div>
<p>Jetson pair for AI, GPU mini-pc titan-22 for Jellyfin.</p>
</div>
<div class="card stat">
<div class="label">Specialty nodes</div>
<div class="value">{{ specialty.length }}</div>
<p>Oceanus (validator), Tethys bridge, and Bastion.</p>
</div>
<div class="card stat">
<div class="label">Storage fabric</div>
<div class="value">Longhorn</div>
<p>Backs Jellyfin, Nextcloud, Harbor, and CI artifacts.</p>
</div>
<div class="card stat" v-if="offline.length">
<div class="label">Attention</div>
<div class="value danger">{{ offline.length }} offline</div>
<p class="mono">Impacted: {{ offlineNames }}</p>
</div>
</div>
<div class="divider"></div>
<div class="specialty">
<div v-for="node in specialty" :key="node.name" class="mini-card">
<div class="label">{{ node.alias || node.name }}</div>
<div class="value">{{ node.role }}</div>
<small>Status: {{ node.status }}</small>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
hardware: Object,
});
const nodes = computed(() => props.hardware?.clusters?.[0]?.nodes || []);
const specialty = computed(() => props.hardware?.specialty || []);
const controlPlane = computed(() => nodes.value.filter((n) => n.role.includes("control")).length);
const piWorkers = computed(() => nodes.value.filter((n) => n.role.includes("worker")).length);
const accelerators = computed(() => nodes.value.filter((n) => n.role.includes("jetson") || n.role.includes("gpu")).length);
const offline = computed(() => nodes.value.filter((n) => n.status === "offline"));
const offlineNames = computed(() => offline.value.map((n) => n.name).join(", "));
</script>
<style scoped>
.stat {
min-height: 150px;
}
.label {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
color: var(--text-muted);
}
.value {
font-size: 28px;
font-weight: 700;
margin: 8px 0;
color: var(--text-strong);
}
.danger {
color: var(--accent-rose);
}
.specialty {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-top: 12px;
}
.mini-card {
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<header class="topbar">
<div class="profile" @click="goAbout">
<div class="avatar">BS</div>
<div>
<div class="name">Brad Stein</div>
<div class="role">Software Development Engineer</div>
</div>
</div>
<nav class="links">
<RouterLink to="/" class="nav-link">Home</RouterLink>
<RouterLink to="/about" class="nav-link">About</RouterLink>
<a href="https://cloud.bstein.dev" class="nav-link strong" target="_blank" rel="noreferrer">Cloud</a>
<a href="https://sso.bstein.dev" class="nav-link" target="_blank" rel="noreferrer">Login</a>
<a href="https://sso.bstein.dev/realms/master/protocol/openid-connect/registrations" class="nav-link" target="_blank" rel="noreferrer">Sign Up</a>
<a href="https://sso.bstein.dev/realms/master/login-actions/reset-credentials" class="nav-link" target="_blank" rel="noreferrer">Reset</a>
</nav>
</header>
</template>
<script setup>
import { useRouter, RouterLink } from "vue-router";
const router = useRouter();
const goAbout = () => router.push("/about");
</script>
<style scoped>
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 28px;
background: rgba(5, 9, 20, 0.92);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
}
.profile {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(0, 229, 197, 0.5), rgba(127, 124, 255, 0.45));
display: grid;
place-items: center;
color: #010e17;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.14);
}
.name {
color: var(--text-strong);
font-weight: 700;
}
.role {
color: var(--text-muted);
font-size: 13px;
}
.links {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.nav-link {
color: var(--text-primary);
font-weight: 600;
text-decoration: none;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid transparent;
}
.nav-link.strong {
border-color: rgba(255, 255, 255, 0.14);
color: var(--accent-cyan);
}
.nav-link:hover {
border-color: rgba(255, 255, 255, 0.14);
color: var(--accent-cyan);
}
</style>

300
frontend/src/data/sample.js Normal file
View File

@ -0,0 +1,300 @@
export function fallbackHardware() {
return {
clusters: [
{
name: "atlas",
role: "Flux-managed Kubernetes for bstein.dev services",
nodes: [
{ name: "titan-0a", role: "control-plane (leader)", hardware: "rpi5", status: "ready" },
{ name: "titan-0b", role: "control-plane", hardware: "rpi5", status: "ready" },
{ name: "titan-0c", role: "control-plane", hardware: "rpi5", status: "ready" },
{ name: "titan-04", role: "worker", hardware: "rpi5", status: "ready" },
{ name: "titan-05", role: "worker", hardware: "rpi5", status: "ready" },
{ name: "titan-06", role: "worker", hardware: "rpi5", status: "ready" },
{ name: "titan-07", role: "worker", hardware: "rpi5", status: "ready" },
{ name: "titan-08", role: "worker", hardware: "rpi5", status: "ready" },
{ name: "titan-09", role: "worker", hardware: "rpi5", status: "ready" },
{ name: "titan-10", role: "worker", hardware: "rpi5", status: "ready" },
{ name: "titan-11", role: "worker", hardware: "rpi5", status: "ready" },
{ name: "titan-12", role: "worker (HDD astreae)", hardware: "rpi4", status: "ready" },
{ name: "titan-13", role: "worker (HDD astreae)", hardware: "rpi4", status: "ready" },
{ name: "titan-14", role: "worker (HDD astreae)", hardware: "rpi4", status: "ready" },
{ name: "titan-15", role: "worker (HDD astreae)", hardware: "rpi4", status: "ready" },
{ name: "titan-16", role: "worker", hardware: "rpi4", status: "offline" },
{ name: "titan-17", role: "worker (HDD asteria)", hardware: "rpi4", status: "ready" },
{ name: "titan-18", role: "worker (HDD asteria)", hardware: "rpi4", status: "ready" },
{ name: "titan-19", role: "worker (HDD asteria)", hardware: "rpi4", status: "ready" },
{ name: "titan-20", role: "jetson ai workload", hardware: "jetson xavier", status: "ready" },
{ name: "titan-21", role: "jetson ai workload", hardware: "jetson xavier", status: "ready" },
{ name: "titan-22", role: "gpu mini-pc (jellyfin)", hardware: "mini pc", status: "ready" },
],
},
],
specialty: [
{ name: "titan-db", alias: "atlas-db", role: "control-plane database (postgres)", hardware: "rpi5", status: "active" },
{ name: "titan-23", alias: "oceanus", role: "SUI validator (baremetal)", status: "active", hardware: "epyc-24c" },
{ name: "titan-24", alias: "tethys", role: "bridge node + scraper for oceanus metrics", status: "active", hardware: "ryzen-3900x" },
{ name: "titan-jh", alias: "theia", role: "bastion / KVM landing / lesavka", status: "active", hardware: "rpi5" },
],
};
}
export function fallbackServices() {
return {
services: [
{
name: "Nextcloud Hub",
category: "productivity",
summary: "Core user hub: storage, office, bstein.dev mail, & more apps.",
link: "https://cloud.bstein.dev",
},
{
name: "Jellyfin",
category: "media",
summary: "Family Movies hosted on titan-22 for GPU acceleration.",
link: "https://stream.bstein.dev",
},
{
name: "VaultWarden",
category: "security",
summary: "Open Source & private password manager.",
link: "https://vault.bstein.dev",
},
{
name: "Keycloak",
category: "identity",
summary: "Unified accounts for Single Sign-On.",
link: "https://sso.bstein.dev",
},
{
name: "Gitea",
category: "dev",
summary: "Source control for dev projects.",
link: "https://scm.bstein.dev",
},
{
name: "Jenkins",
category: "dev",
summary: "Continuous integration build pipelines.",
link: "https://ci.bstein.dev",
},
{
name: "Harbor",
category: "dev",
summary: "Artifact Registry for build artifacts.",
link: "https://registry.bstein.dev",
},
{
name: "Flux",
category: "dev",
summary: "GitOps UI for flux continuous deployment drift prevention.",
link: "https://cd.bstein.dev",
},
{
name: "Vault",
category: "dev",
summary: "Secrets for secure environment injection.",
link: "https://secret.bstein.dev",
},
{
name: "Grafana",
category: "observability",
summary: "Health metrics for atlas and eventually oceanus.",
link: "https://metrics.bstein.dev",
},
{
name: "Pegasus",
category: "media ingest",
summary: "Uploading service to inject jellyfin media.",
link: "https://pegasus.bstein.dev",
},
{
name: "Monero",
category: "crypto",
summary: "Private monero node for monero wallets",
link: "/monero",
host: "monerod.crypto.svc.cluster.local:18081",
},
{
name: "Jitsi",
category: "conferencing",
summary: "Video Conferencing - Planned",
link: "https://meet.bstein.dev",
status: "degraded",
},
{
name: "AI Chat",
category: "ai",
summary: "LLM Chat - Planned",
link: "/ai",
host: "chat.ai.bstein.dev",
status: "planned",
},
{
name: "AI Image",
category: "ai",
summary: "Visualization tool - Planned",
link: "/ai",
host: "draw.ai.bstein.dev",
status: "planned",
},
{
name: "AI Speech",
category: "ai",
summary: "Live Translation - Planned",
link: "/ai",
host: "talk.ai.bstein.dev",
status: "planned",
},
],
};
}
export function fallbackNetwork() {
return {
ingress: [
{
name: "dns_and_tls",
path: "DNS -> Traefik -> oauth2-proxy -> service",
notes: "TLS via cert-manager; Keycloak on the edge.",
},
{
name: "media",
path: "media.bstein.dev -> Traefik -> Pegasus -> Longhorn PVC -> Jellyfin",
notes: "Uploads and playback with storage on Longhorn.",
},
{
name: "registry",
path: "registry.bstein.dev -> Traefik -> Harbor -> FluxCD pull",
notes: "Harbor issues signed images for GitOps.",
},
],
egress: [
{
name: "ci_to_registry",
path: "Gitea webhook -> Jenkins -> Harbor push",
notes: "Builds signed before promotion.",
},
{
name: "metrics",
path: "Atlas scraping -> Prometheus -> Grafana -> metrics.bstein.dev",
notes: "titan-24 scrapes oceanus (titan-23).",
},
],
ingress_gateway: "Traefik with oauth2-proxy and Keycloak; Longhorn backs stateful ingress targets.",
};
}
export function fallbackMetrics() {
return {
dashboard: "https://metrics.bstein.dev",
description: "Atlas + Oceanus metrics.",
};
}
export function buildHardwareDiagram(data) {
return `
flowchart TB
subgraph TitanLab["Titan Lab (25 nodes)"]
subgraph Atlas["Atlas (k3s cluster)"]
subgraph CP["Control plane (rpi5)"]
titan0a["titan-0a<br/>rpi5 4c/8g"]
titan0b["titan-0b<br/>rpi5 4c/8g"]
titan0c["titan-0c<br/>rpi5 4c/8g"]
end
subgraph Pi5["Workers (rpi5)"]
titan04["titan-04"]
titan05["titan-05"]
titan06["titan-06"]
titan07["titan-07"]
titan08["titan-08"]
titan09["titan-09"]
titan10["titan-10"]
titan11["titan-11"]
end
subgraph Storage["Storage workers (rpi4 + disks)"]
titan12["titan-12<br/>8TB astreae + 12TB asteria"]
titan13["titan-13<br/>8TB astreae + 12TB asteria"]
titan14["titan-14<br/>8TB astreae + 12TB asteria"]
titan15["titan-15<br/>8TB astreae + 12TB asteria"]
titan16["titan-16<br/>offline"]:::down
titan17["titan-17"]
titan18["titan-18"]
titan19["titan-19"]
end
subgraph Accel["Accelerators + heavy nodes"]
titan20["titan-20<br/>Jetson Xavier 6c/16g"]
titan21["titan-21<br/>Jetson Xavier 6c/16g"]
titan22["titan-22<br/>10c/32g<br/>GPU streaming"]
titan24["titan-24 (tethys)<br/>12c/64g<br/>bridge + metrics"]
end
longhorn["Longhorn<br/>astreae: 4x8TB<br/>asteria: 4x12TB"]
traefik["Traefik ingress"]
keycloak["Keycloak SSO"]
services["Services<br/>cloud / stream / ci / registry / cd / secret"]
keycloak --> traefik
traefik --> services
services --> longhorn
longhorn --> Storage
end
subgraph Dedicated["Dedicated hosts (outside Atlas)"]
titanDb["titan-db<br/>Postgres for HA control plane<br/>rpi5 4c/8g"]
theia["titan-jh (theia)<br/>bastion<br/>rpi5 4c/8g"]
oceanus["titan-23 (oceanus)<br/>SUI validator<br/>24c/256g<br/>2.5GbE"]
end
titanDb -->|DB| titan0a
theia -->|ssh| titan0a
oceanus -->|metrics| titan24
end
classDef down fill:#311023,stroke:#ff4f93,color:#fff,stroke-width:2px;
`;
}
export function buildNetworkDiagram() {
return `
sequenceDiagram
participant U as User
participant DNS as DNS (*.bstein.dev)
participant T as Traefik (Atlas)
participant A as auth.bstein.dev (oauth2-proxy)
participant K as sso.bstein.dev (Keycloak)
participant S as Service (cloud/stream/ci/registry/cd/secret)
participant L as Longhorn PVC
U->>DNS: resolve host
DNS-->>U: Traefik VIP
U->>T: HTTPS request
T->>A: forwardAuth
A->>K: OIDC login/refresh
K-->>A: token
A-->>T: allow
T->>S: route to service
S-->>L: persistent storage operations
S-->>U: response / stream / artifact
`;
}
export function buildPipelineDiagram() {
return `
flowchart LR
dev[Developer] -->|push| gitea[scm.bstein.dev]
gitea -->|webhook| jenkins[ci.bstein.dev]
jenkins -->|build + push| harbor[registry.bstein.dev]
harbor -->|image update| flux[cd.bstein.dev]
flux -->|reconcile| atlas[Atlas]
atlas -->|deploy| svc[cloud / stream / secret / other apps]
keycloak[sso.bstein.dev] --> gitea
keycloak --> jenkins
keycloak --> harbor
keycloak --> flux
`;
}

9
frontend/src/main.js Normal file
View File

@ -0,0 +1,9 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./assets/base.css";
import "./assets/theme.css";
const app = createApp(App);
app.use(router);
app.mount("#app");

15
frontend/src/router.js Normal file
View File

@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "./views/HomeView.vue";
import AboutView from "./views/AboutView.vue";
import AiView from "./views/AiView.vue";
import MoneroView from "./views/MoneroView.vue";
export default createRouter({
history: createWebHistory(),
routes: [
{ path: "/", name: "home", component: HomeView },
{ path: "/about", name: "about", component: AboutView },
{ path: "/ai", name: "ai", component: AiView },
{ path: "/monero", name: "monero", component: MoneroView },
],
});

View File

@ -0,0 +1,341 @@
<template>
<div class="page">
<section class="card about">
<div class="left">
<div class="portrait">BS</div>
<div class="contact">
<div class="name">Brad Stein</div>
<div class="role">Senior Software Development Engineer </div>
<div class="role">SDET/ DevOps / Platform Tooling</div>
<div class="meta">US Citizen · Remote</div>
<div class="links">
<a href="https://www.linkedin.com/in/steinbradley/" target="_blank" rel="noreferrer">LinkedIn</a>
<a href="https://scm.bstein.dev/bstein" target="_blank" rel="noreferrer">Gitea</a>
<a href="https://metrics.bstein.dev" target="_blank" rel="noreferrer">Metrics</a>
<a href="mailto:brad@bstein.dev">brad@bstein.dev</a>
</div>
</div>
</div>
<div class="right">
<h1>About Me</h1>
<div class="copy">
<p>
Senior Backend &amp; DevOps engineer focused on making systems reliable. I build Python-driven tools on Linux, Kubernetes, and CI/CD
to keep distributed systems healthy.
</p>
<p>
I like simplifying environments and release pipelines so teams can ship and operate confidently. Recent work: platform tooling for a
Flux-managed Kubernetes microservice stack on the U.S. Space Forces PTES program.
</p>
<p>
My projects and dashboards run on my local <span class="mono">k3s</span>: see
<a href="https://scm.bstein.dev/bstein" target="_blank" rel="noreferrer">scm.bstein.dev</a> and
<a href="https://metrics.bstein.dev" target="_blank" rel="noreferrer">metrics.bstein.dev</a>.
</p>
</div>
<div class="highlights">
<div v-for="skill in skills" :key="skill" class="pill mono">{{ skill }}</div>
</div>
</div>
</section>
<section class="card">
<div class="section-head">
<h2>Experience</h2>
<span class="pill mono">Summary</span>
</div>
<div class="timeline">
<div v-for="item in timeline" :key="item.id" class="entry">
<div class="dot"></div>
<div>
<div class="entry-title">{{ item.title }}</div>
<div class="entry-sub">{{ item.company }} · {{ item.dates }}</div>
<ul>
<li v-for="point in item.points" :key="point">{{ point }}</li>
</ul>
</div>
</div>
</div>
<div class="divider"></div>
<p class="note">
<a href="https://www.linkedin.com/in/steinbradley/" target="_blank" rel="noreferrer">
Full role history and exact dates are on LinkedIn.
</a>
</p>
</section>
<section class="card">
<div class="section-head">
<h2>The Titan Story</h2>
<span class="pill mono">atlas + oceanus</span>
</div>
<div class="copy">
<p>
Titan Lab is my 25-node homelab with a production mindset: security, monitoring, and repeatable changes. The core is
<span class="mono">Atlas</span>, a GitOps-managed <span class="mono">k3s</span> cluster where most services are reconciled by
<span class="mono">Flux</span>.
</p>
<p>
<span class="mono">Oceanus</span> is intentionally separated for validator workloads while still feeding data back into the same
observability stack. Storage is tiered with Longhorn (<span class="mono">astreae</span> for system data and
<span class="mono">asteria</span> for user data), fronted by Traefik and backed by centralized identity via Keycloak.
</p>
</div>
</section>
</div>
</template>
<script setup>
const skills = [
"Python",
"Linux",
"Kubernetes (k3s)",
"Containers (Docker/OCI)",
"GitOps (Flux)",
"CI/CD (Jenkins)",
"Release gating",
"Test automation",
"Keycloak / OIDC",
"Grafana + VictoriaMetrics",
"Traefik ingress",
"Longhorn storage",
"Go",
"Rust",
"Bash",
"Terraform",
"SQL",
];
const timeline = [
{
id: "actalent-boeing-ptes",
title: "Senior Software Development Engineer in Test",
company: "Boeing (Actalent contract)",
dates: "Sep 2023 Oct 2025",
points: [
"Built internal tools and infrastructure around a Kubernetes microservice platform (PTES).",
"Created Lanterna, a visualization tool mapping service and environment relationships from a Flux-controlled monorepo.",
"Designed Jenkins promotion workflows and test-gating pipelines to move builds safely through environments.",
"Built TaskWatcher for drift protection and orchestration of non-Kubernetes systems, including capture of transient crypto artifacts.",
],
},
{
id: "titan-lab-architect",
title: "Titan Lab Architect",
company: "Titan Lab (personal platform)",
dates: "Apr 2020 Present",
points: [
"Operate a mixed arm64/amd64 environment with GitOps (Gitea → Jenkins → Harbor → Flux).",
"Centralize identity with Keycloak and front services via Traefik ingress.",
"Run tiered Longhorn storage: astreae (system) and asteria (user).",
"Build observability with Grafana + VictoriaMetrics and dashboards around real service health.",
],
},
{
id: "ibm-sdet",
title: "Software Development Engineer in Test",
company: "IBM",
dates: "Mar 2020 May 2023",
points: [
"Authored and planned system and end-to-end integration tests using Go, Terraform, Python, and Bash.",
"Monitored critical API endpoints with Zabbix and worked incident-style issues end to end.",
"Supported platform testing for the Madrid Data Center rollout.",
],
},
{
id: "softlayer-ibmcloud-sdet",
title: "Software Development Engineer in Test",
company: "SoftLayer / IBM Cloud (TekSystems Contract)",
dates: "Nov 2018 Mar 2020",
points: [
"Built automated end-to-end tests in Go and Python across REST and SOAP APIs.",
"Worked across frontend and backend efforts (Vue, WordPress, Docker, MySQL/Postgres).",
"Used Jenkins + Splunk/Kibana to diagnose failures and produce coverage-focused test reports.",
],
},
{
id: "unifocus-survey-programmer",
title: "Survey Programmer",
company: "UniFocus",
dates: "Aug 2014 Nov 2018",
points: [
"Wrote SQL-driven survey sites and reporting logic; customized sites via CSS and JavaScript.",
"Built Python and VBA automation tools to reduce manual work for teams and business partners.",
"Won UniFocus Excellence Award (2015) and Innovation Award (2016).",
],
},
{
id: "magic-aire-engineering-assistant",
title: "Engineering Assistant",
company: "United Electric Company - Magic Aire",
dates: "Apr 2011 Aug 2014",
points: [
"Wrote and maintained VBA tools for internal use, including interpolators and data-entry utilities.",
"Supported mechanical engineering work in SolidWorks (drawing modernization and component standardization).",
],
},
];
</script>
<style scoped>
.page {
max-width: 1000px;
margin: 0 auto;
padding: 32px 22px 72px;
}
.about {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 18px;
}
.portrait {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, rgba(0, 229, 197, 0.5), rgba(127, 124, 255, 0.45));
display: grid;
place-items: center;
font-size: 34px;
font-weight: 800;
color: #010e17;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.left {
display: flex;
align-items: center;
gap: 14px;
}
.contact .name {
font-weight: 800;
color: var(--text-strong);
}
.contact .role {
color: var(--text-muted);
}
.contact .meta {
color: var(--text-muted);
font-size: 13px;
margin-top: 2px;
}
.badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.links {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.links a {
color: var(--accent-cyan);
text-decoration: none;
}
.links a:hover {
color: var(--accent-rose);
}
.right h1 {
margin: 0 0 8px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.copy {
color: var(--text-muted);
}
.copy p {
margin: 0 0 10px;
line-height: 1.55;
}
.copy p:last-child {
margin-bottom: 0;
}
.highlights {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
}
.note {
color: var(--text-muted);
margin: 0;
}
.note a {
color: var(--text-muted);
text-decoration: none;
border-bottom: 1px dashed rgba(255, 255, 255, 0.18);
padding-bottom: 1px;
}
.note a:hover {
color: var(--accent-cyan);
border-bottom-color: rgba(0, 229, 197, 0.5);
}
.timeline {
display: grid;
gap: 12px;
margin-top: 8px;
}
.entry {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--accent-cyan);
margin-top: 6px;
}
.entry-title {
font-weight: 800;
}
.entry-sub {
color: var(--text-muted);
margin-bottom: 4px;
}
ul {
margin: 0;
padding-left: 16px;
color: var(--text-muted);
}
@media (max-width: 820px) {
.about {
grid-template-columns: 1fr;
}
.left {
justify-content: flex-start;
}
}
</style>

View File

@ -0,0 +1,25 @@
<template>
<div class="page">
<section class="card">
<h1>AI services (planned)</h1>
<p>Targets for chat.ai.bstein.dev, draw.ai.bstein.dev, and talk.ai.bstein.dev. These will land behind Keycloak once the pipelines are ready.</p>
<ul>
<li>Chat: conversational agent with SSO.</li>
<li>Image: text-to-image workflows for user media.</li>
<li>Speech: voice-to-voice translation and dubbing.</li>
</ul>
</section>
</div>
</template>
<style scoped>
.page {
max-width: 900px;
margin: 0 auto;
padding: 32px 22px 72px;
}
ul {
color: var(--text-muted);
}
</style>

View File

@ -0,0 +1,260 @@
<template>
<div class="page">
<section class="card hero glass">
<div class="hero-left">
<p class="eyebrow">Titan Lab</p>
<h1>Overview</h1>
<p class="lede">
Titan Lab is a 25-node homelab with a production mindset. Atlas is its Kubernetes cluster that runs user and dev
services. Oceanus is a dedicated SUI validator host. Underlying components such as Theia, the bastion, and Tethys, the link between
Atlas and Oceanus underpin the lab. Membership grants the following services below.
</p>
<div class="bullets">
<div :class="['pill', 'mono', atlasPillClass]">Atlas: flux-managed k3s</div>
<div :class="['pill', 'mono', oceanusPillClass]">Oceanus: SUI validator</div>
</div>
<div class="live-status">
<div v-if="error" class="status">{{ error }}</div>
<div v-else class="status mono">
{{ loading ? "Loading..." : labStatus?.connected ? "Live data connected" : "Live data unavailable" }}
</div>
</div>
</div>
<div class="availability">
<div class="availability-grid">
<div class="availability-panel">
<div class="panel-title">
<h3>Atlas</h3>
<span class="pill mono">k3s cluster</span>
</div>
<iframe
src="https://metrics.bstein.dev/d-solo/atlas-overview/atlas-overview?from=now-24h&to=now&refresh=1m&orgId=1&theme=dark&panelId=27&__feature.dashboardSceneSolo"
width="100%"
height="180"
frameborder="0"
></iframe>
</div>
<div class="availability-panel">
<div class="panel-title">
<h3>Oceanus</h3>
<span class="pill mono">dedicated host</span>
</div>
<div class="placeholder">Loading Oceanus Availability...</div>
</div>
</div>
</div>
</section>
<MetricRow :items="metricItems" />
<section class="card">
<div class="section-head">
<h2>Service Grid</h2>
<span class="pill mono">email + storage + streaming + pipelines</span>
</div>
<ServiceGrid :services="displayServices" />
</section>
<section class="grid two">
<MermaidCard
title="Atlas layout"
description="Control plane, workers, accelerators, and edge assets."
:diagram="hardwareDiagram"
card-id="hardware-home"
/>
<MermaidCard
title="Build + deploy flow"
description="Gitea to Jenkins to Harbor to Flux."
:diagram="pipelineDiagram"
card-id="pipeline-home"
/>
<MermaidCard
title="Ingress flow"
description="DNS to Traefik to workloads backed by Longhorn."
:diagram="networkDiagram"
card-id="network-home"
/>
</section>
</div>
</template>
<script setup>
import { computed } from "vue";
import MetricRow from "../components/MetricRow.vue";
import ServiceGrid from "../components/ServiceGrid.vue";
import MermaidCard from "../components/MermaidCard.vue";
import { buildHardwareDiagram, buildNetworkDiagram, buildPipelineDiagram, fallbackServices } from "../data/sample.js";
const props = defineProps({
labData: Object,
labStatus: Object,
serviceData: Object,
networkData: Object,
metricsData: Object,
loading: Boolean,
error: String,
});
const atlasPillClass = computed(() => (props.labStatus?.atlas?.up ? "pill-ok" : "pill-bad"));
const oceanusPillClass = computed(() => (props.labStatus?.oceanus?.up ? "pill-ok" : "pill-bad"));
const metricItems = computed(() => {
return [
{ label: "Lab nodes", value: "25", note: "26 total (titan-16 is down)\nWorkers: 8 rpi5, 7 rpi4, 2 jetsons, 1 minipc\nControl plane: 3 rpi5\nDedicated: titan-db, oceanus, tethys, theia" },
{ label: "CPU cores", value: "142", note: "arm + jetson + x86 mix" },
{ label: "Memory", value: "552 GB", note: "nominal (includes titan-16 even though it is down)" },
{ label: "Atlas storage", value: "80 TB", note: "Longhorn astreae + asteria" },
];
});
const displayServices = computed(() => {
const services = props.serviceData?.services || fallbackServices().services;
return services.map((svc) => ({
...svc,
icon: svc.icon || pickIcon(svc.name),
}));
});
const hardwareDiagram = computed(() => buildHardwareDiagram(props.labData || {}));
const networkDiagram = computed(() => buildNetworkDiagram(props.networkData || {}));
const pipelineDiagram = computed(() => buildPipelineDiagram());
function pickIcon(name) {
const h = name.toLowerCase();
if (h.includes("nextcloud")) return "☁️";
if (h.includes("jellyfin")) return "🎞️";
if (h.includes("jitsi")) return "📡";
if (h.includes("mail")) return "📮";
if (h.includes("vaultwarden")) return "🔒";
if (h.includes("vault")) return "🔑";
if (h.includes("gitea")) return "📚";
if (h.includes("jenkins")) return "🧰";
if (h.includes("harbor")) return "📦";
if (h.includes("flux")) return "🔄";
if (h.includes("monero")) return "⛏️";
if (h.includes("keycloak")) return "🛡️";
if (h.includes("grafana")) return "📈";
if (h.includes("pegasus")) return "🚀";
if (h.includes("ai chat")) return "💬";
if (h.includes("ai image")) return "🖼️";
if (h.includes("ai speech")) return "🎙️";
return "🛰️";
}
</script>
<style scoped>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 32px 22px 72px;
}
.hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 12px;
}
.hero-left {
display: flex;
flex-direction: column;
}
.live-status {
margin-top: auto;
padding-top: 12px;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 6px;
font-size: 13px;
}
h1 {
margin: 0 0 6px;
font-size: 32px;
}
.lede {
margin: 0 0 12px;
color: var(--text-muted);
max-width: 640px;
}
.bullets {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin: 10px 0 12px;
}
.cta-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.status {
color: var(--text-muted);
}
.pill-ok {
border-color: rgba(0, 229, 197, 0.45);
color: var(--accent-cyan);
}
.pill-bad {
border-color: rgba(255, 79, 147, 0.45);
color: var(--accent-rose);
}
.availability {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius);
padding: 10px;
background: rgba(255, 255, 255, 0.03);
}
.availability-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.availability-panel iframe {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm);
}
.placeholder {
border: 1px dashed rgba(255, 255, 255, 0.2);
border-radius: var(--radius-sm);
padding: 12px;
color: var(--text-muted);
text-align: center;
min-height: 180px;
display: grid;
place-items: center;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
}
.availability-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<div class="page">
<section class="card">
<h1>Monero node (monerod)</h1>
<p>
monerod runs in the atlas cluster (ns: <span class="mono">crypto</span>). Use the external RPC endpoint below; no cluster access or
port-forwarding is required.
</p>
<div class="panel">
<div class="label">Live status</div>
<div v-if="loading" class="mono">Loading...</div>
<div v-else-if="error" class="mono error">{{ error }}</div>
<div v-else class="grid">
<div class="kv"><span class="k">net</span><span class="v mono">{{ info.nettype }}</span></div>
<div class="kv"><span class="k">status</span><span class="v mono">{{ info.status }}</span></div>
<div class="kv"><span class="k">height</span><span class="v mono">{{ info.height }}</span></div>
<div class="kv"><span class="k">target</span><span class="v mono">{{ info.target_height }}</span></div>
</div>
</div>
<div class="panel">
<div class="label">External RPC (TLS)</div>
<code>monero.bstein.dev:443</code>
<div class="note">
Use SSL/TLS. If credentials are provided, set daemon login in your wallet; otherwise leave blank. If you need access, request a wallet
RPC user/pass.
</div>
</div>
<div class="panel">
<div class="label">Wallet config (GUI)</div>
<ul>
<li>Settings Node Address: <span class="mono">monero.bstein.dev</span></li>
<li>Port: <span class="mono">443</span></li>
<li>Use SSL/TLS: enabled</li>
<li>Daemon username/password: only if you were given credentials</li>
</ul>
</div>
<div class="panel">
<div class="label">Wallet config (CLI)</div>
<code>monero-wallet-cli --daemon-address monero.bstein.dev:443 --daemon-ssl enabled --daemon-login &lt;user:pass&gt;</code>
<div class="note">Omit <span class="mono">--daemon-login</span> if the endpoint does not require authentication.</div>
</div>
<p class="note">
If you cannot connect, reach out for RPC credentials or allowlisting. The node stays on {{ info.nettype || "mainnet" }} and exposes
standard RPC at <span class="mono">/json_rpc</span>.
</p>
</section>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import axios from "axios";
const info = ref({});
const loading = ref(true);
const error = ref("");
onMounted(async () => {
try {
const { data } = await axios.get("/api/monero/get_info", { timeout: 2500 });
info.value = data || {};
} catch (e) {
error.value = "Could not reach monerod from this site yet.";
} finally {
loading.value = false;
}
});
</script>
<style scoped>
.page {
max-width: 900px;
margin: 0 auto;
padding: 32px 22px 72px;
}
.panel {
margin: 10px 0;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.04);
}
.label {
font-weight: 700;
color: var(--text-strong);
}
.note {
color: var(--text-muted);
font-size: 13px;
margin-top: 8px;
}
code {
display: block;
margin-top: 6px;
color: var(--accent-cyan);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 10px;
}
.kv {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 10px;
background: rgba(255, 255, 255, 0.03);
}
.panel ul {
margin: 6px 0 0;
padding-left: 18px;
color: var(--text-muted);
}
.panel li {
margin: 0 0 4px;
}
.k {
display: block;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
}
.v {
display: block;
color: var(--text-strong);
font-weight: 700;
margin-top: 6px;
}
.error {
color: var(--accent-rose);
}
</style>

14
frontend/vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
"/api": {
target: "http://localhost:5000",
changeOrigin: true,
},
},
},
});

BIN
media/layout_concept_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

BIN
media/layout_concept_02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 782 KiB

BIN
media/layout_concept_03.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
media/layout_concept_04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
media/layout_concept_05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
media/layout_concept_06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB