Initial site with CI/CD pipeline
This commit is contained in:
commit
f454df4f9c
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
14
.gitignore
vendored
Normal 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
18
Dockerfile.backend
Normal 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
20
Dockerfile.frontend
Normal 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
173
Jenkinsfile
vendored
Normal 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
6
README.md
Normal 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
161
backend/app.py
Normal 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
3
backend/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
flask==3.0.3
|
||||
flask-cors==4.0.0
|
||||
gunicorn==21.2.0
|
||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal 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
19
frontend/nginx.conf
Normal 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
2802
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
frontend/package.json
Normal file
21
frontend/package.json
Normal 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
59
frontend/src/App.vue
Normal 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>
|
||||
54
frontend/src/assets/base.css
Normal file
54
frontend/src/assets/base.css
Normal 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;
|
||||
}
|
||||
136
frontend/src/assets/theme.css
Normal file
136
frontend/src/assets/theme.css
Normal 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;
|
||||
}
|
||||
116
frontend/src/components/HeroSection.vue
Normal file
116
frontend/src/components/HeroSection.vue
Normal 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>
|
||||
188
frontend/src/components/MermaidCard.vue
Normal file
188
frontend/src/components/MermaidCard.vue
Normal 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>
|
||||
52
frontend/src/components/MetricRow.vue
Normal file
52
frontend/src/components/MetricRow.vue
Normal 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>
|
||||
70
frontend/src/components/MetricsPanel.vue
Normal file
70
frontend/src/components/MetricsPanel.vue
Normal 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>
|
||||
134
frontend/src/components/ServiceGrid.vue
Normal file
134
frontend/src/components/ServiceGrid.vue
Normal 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>
|
||||
103
frontend/src/components/StatsGrid.vue
Normal file
103
frontend/src/components/StatsGrid.vue
Normal 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>
|
||||
96
frontend/src/components/TopBar.vue
Normal file
96
frontend/src/components/TopBar.vue
Normal 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
300
frontend/src/data/sample.js
Normal 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
9
frontend/src/main.js
Normal 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
15
frontend/src/router.js
Normal 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 },
|
||||
],
|
||||
});
|
||||
341
frontend/src/views/AboutView.vue
Normal file
341
frontend/src/views/AboutView.vue
Normal 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 & 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 Force’s 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>
|
||||
25
frontend/src/views/AiView.vue
Normal file
25
frontend/src/views/AiView.vue
Normal 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>
|
||||
260
frontend/src/views/HomeView.vue
Normal file
260
frontend/src/views/HomeView.vue
Normal 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>
|
||||
144
frontend/src/views/MoneroView.vue
Normal file
144
frontend/src/views/MoneroView.vue
Normal 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 <user:pass></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
14
frontend/vite.config.js
Normal 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
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
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
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
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
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
BIN
media/layout_concept_06.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Loading…
x
Reference in New Issue
Block a user