Refresh professional homepage
This commit is contained in:
parent
8126bd3c96
commit
0437bfe9b3
@ -31,6 +31,65 @@ def _tcp_check(host: str, port: int, timeout_sec: float) -> bool:
|
||||
def register_account_overview(app) -> None:
|
||||
"""Register the account overview endpoint."""
|
||||
|
||||
@app.route("/api/account/member-state", methods=["GET"])
|
||||
@require_auth
|
||||
def account_member_state() -> Any:
|
||||
"""Return non-secret member navigation state for auth-aware public UI."""
|
||||
|
||||
ok, resp = require_account_access()
|
||||
if not ok:
|
||||
return resp
|
||||
|
||||
username = g.keycloak_username
|
||||
keycloak_email = g.keycloak_email or ""
|
||||
status = "unknown"
|
||||
request_code = ""
|
||||
onboarding_url = "/onboarding"
|
||||
|
||||
if settings.PORTAL_DATABASE_URL and username:
|
||||
try:
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT request_code, status
|
||||
FROM access_requests
|
||||
WHERE username = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if not row and keycloak_email:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT request_code, status
|
||||
FROM access_requests
|
||||
WHERE contact_email = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(keycloak_email,),
|
||||
).fetchone()
|
||||
if row and isinstance(row, dict):
|
||||
request_code = str(row.get("request_code") or "").strip()
|
||||
status = str(row.get("status") or "unknown").strip() or "unknown"
|
||||
except Exception:
|
||||
status = "unknown"
|
||||
request_code = ""
|
||||
|
||||
if request_code:
|
||||
onboarding_url = f"{settings.PORTAL_PUBLIC_BASE_URL}/onboarding?code={quote(request_code)}"
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups},
|
||||
"status": status,
|
||||
"onboarding_url": onboarding_url,
|
||||
"dashboard_url": "/apps",
|
||||
"account_url": "/account",
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/account/overview", methods=["GET"])
|
||||
@require_auth
|
||||
def account_overview() -> Any:
|
||||
|
||||
@ -15,9 +15,6 @@ def register(app) -> None:
|
||||
def auth_config() -> Any:
|
||||
"""Render the auth configuration payload consumed by the SPA."""
|
||||
|
||||
if not settings.KEYCLOAK_ENABLED:
|
||||
return jsonify({"enabled": False})
|
||||
|
||||
issuer = settings.KEYCLOAK_ISSUER
|
||||
public_origin = request.host_url.rstrip("/")
|
||||
redirect_uri = quote(f"{public_origin}/", safe="")
|
||||
@ -36,6 +33,17 @@ def register(app) -> None:
|
||||
account_url = f"{issuer}/account"
|
||||
account_password_url = f"{account_url}/#/security/signingin"
|
||||
|
||||
if not settings.KEYCLOAK_ENABLED:
|
||||
return jsonify(
|
||||
{
|
||||
"enabled": False,
|
||||
"login_url": login_url,
|
||||
"reset_url": reset_url,
|
||||
"account_url": account_url,
|
||||
"account_password_url": account_password_url,
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"enabled": True,
|
||||
|
||||
@ -41,6 +41,28 @@ def _vm_query(expr: str) -> float | None:
|
||||
return max(values)
|
||||
|
||||
|
||||
def _vm_query_vector(expr: str) -> list[dict[str, Any]]:
|
||||
"""Run one instant VictoriaMetrics query and return result samples."""
|
||||
|
||||
url = f"{settings.VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}"
|
||||
with urlopen(url, timeout=settings.VM_QUERY_TIMEOUT_SEC) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
if payload.get("status") != "success":
|
||||
return []
|
||||
|
||||
result = (payload.get("data") or {}).get("result") or []
|
||||
samples: list[dict[str, Any]] = []
|
||||
for item in result:
|
||||
try:
|
||||
value = float(item["value"][1])
|
||||
except (KeyError, IndexError, TypeError, ValueError):
|
||||
continue
|
||||
metric = item.get("metric") if isinstance(item.get("metric"), dict) else {}
|
||||
samples.append({"metric": metric, "value": value})
|
||||
return samples
|
||||
|
||||
|
||||
def _http_ok(url: str, expect_substring: str | None = None) -> bool:
|
||||
"""Return whether a URL responds successfully and optionally contains text."""
|
||||
|
||||
@ -56,12 +78,95 @@ def _http_ok(url: str, expect_substring: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_PUBLIC_SERVICE_ALIASES = [
|
||||
(("cloud.bstein.dev", "nextcloud", "cloud"), "Nextcloud", "https://cloud.bstein.dev"),
|
||||
(("live.bstein.dev", "element", "matrix", "synapse", "livekit"), "Element / Matrix", "https://live.bstein.dev"),
|
||||
(("stream.bstein.dev", "jellyfin", "stream"), "Jellyfin", "https://stream.bstein.dev"),
|
||||
(("notes.bstein.dev", "outline", "notes"), "Outline", "https://notes.bstein.dev"),
|
||||
(("tasks.bstein.dev", "planka", "tasks"), "Planka", "https://tasks.bstein.dev"),
|
||||
(("vault.bstein.dev", "vaultwarden"), "VaultWarden", "https://vault.bstein.dev"),
|
||||
(("scm.bstein.dev", "gitea"), "Gitea", "https://scm.bstein.dev"),
|
||||
(("ci.bstein.dev", "jenkins"), "Jenkins", "https://ci.bstein.dev"),
|
||||
(("registry.bstein.dev", "harbor", "registry"), "Harbor", "https://registry.bstein.dev"),
|
||||
(("metrics.bstein.dev", "grafana", "metrics"), "Grafana", "https://metrics.bstein.dev"),
|
||||
(("chat.ai.bstein.dev", "ai-chat", "atlasbot", "ollama"), "AI Chat", "https://bstein.dev/ai/chat"),
|
||||
(("health.bstein.dev", "wger", "health"), "Wger", "https://health.bstein.dev"),
|
||||
(("budget.bstein.dev", "actual", "budget"), "Actual Budget", "https://budget.bstein.dev"),
|
||||
(("money.bstein.dev", "firefly", "money"), "Firefly III", "https://money.bstein.dev"),
|
||||
(("mail.bstein.dev", "mailu", "mail"), "Mailu", "https://mail.bstein.dev"),
|
||||
]
|
||||
|
||||
|
||||
def _public_active_service() -> dict[str, Any]:
|
||||
"""Return the busiest mapped public service without leaking raw metrics."""
|
||||
|
||||
if not settings.LAB_ACTIVE_SERVICE_QUERY:
|
||||
return {"known": False}
|
||||
|
||||
samples = _vm_query_vector(settings.LAB_ACTIVE_SERVICE_QUERY)
|
||||
for sample in sorted(samples, key=lambda item: item["value"], reverse=True):
|
||||
labels = sample.get("metric") or {}
|
||||
haystack = " ".join(str(value).lower() for value in labels.values())
|
||||
for aliases, label, url in _PUBLIC_SERVICE_ALIASES:
|
||||
if any(alias in haystack for alias in aliases):
|
||||
return {
|
||||
"known": True,
|
||||
"label": label,
|
||||
"url": url,
|
||||
"window": settings.LAB_ACTIVE_SERVICE_WINDOW,
|
||||
}
|
||||
return {"known": False}
|
||||
|
||||
|
||||
def _dedicated_host_status(label: str, health_url: str, up_query: str) -> dict[str, Any]:
|
||||
"""Check one dedicated host and return only public-safe status fields."""
|
||||
|
||||
if health_url:
|
||||
if _http_ok(health_url):
|
||||
return {"label": label, "known": True, "up": True, "source": "health-check"}
|
||||
return {"label": label, "known": True, "up": False, "source": "health-check"}
|
||||
|
||||
if up_query:
|
||||
value = _vm_query(up_query)
|
||||
if value is not None:
|
||||
return {"label": label, "known": True, "up": value > 0, "source": "victoria-metrics"}
|
||||
|
||||
return {"label": label, "known": False, "up": False, "source": "unknown"}
|
||||
|
||||
|
||||
def _dedicated_hosts_status() -> dict[str, Any]:
|
||||
"""Return aggregate availability for the non-cluster DB and jumphost."""
|
||||
|
||||
hosts = [
|
||||
_dedicated_host_status(
|
||||
"Database node",
|
||||
settings.ATLAS_DB_HOST_HEALTH_URL,
|
||||
settings.ATLAS_DB_HOST_UP_QUERY,
|
||||
),
|
||||
_dedicated_host_status(
|
||||
"Jumphost",
|
||||
settings.ATLAS_JUMPHOST_HEALTH_URL,
|
||||
settings.ATLAS_JUMPHOST_UP_QUERY,
|
||||
),
|
||||
]
|
||||
known_hosts = [host for host in hosts if host["known"]]
|
||||
up_count = sum(1 for host in known_hosts if host["up"])
|
||||
return {
|
||||
"known": bool(known_hosts),
|
||||
"up": bool(known_hosts) and up_count == len(known_hosts),
|
||||
"up_count": up_count,
|
||||
"total": len(hosts),
|
||||
"known_count": len(known_hosts),
|
||||
"hosts": hosts,
|
||||
}
|
||||
|
||||
|
||||
def register(app) -> None:
|
||||
"""Register the lightweight lab connectivity status endpoint."""
|
||||
|
||||
@app.route("/api/lab/status")
|
||||
def lab_status() -> Any:
|
||||
"""Return cached Atlas/Oceanus health hints for the home page."""
|
||||
"""Return cached public-safe lab health hints for the home page."""
|
||||
|
||||
now = time.time()
|
||||
cached = _LAB_STATUS_CACHE.get("value")
|
||||
@ -76,9 +181,8 @@ def register(app) -> None:
|
||||
atlas_known = False
|
||||
atlas_source = "unknown"
|
||||
|
||||
oceanus_up = False
|
||||
oceanus_known = False
|
||||
oceanus_source = "unknown"
|
||||
dedicated_hosts = {"known": False, "up": False, "up_count": 0, "total": 2, "known_count": 0, "hosts": []}
|
||||
active_service = {"known": False}
|
||||
|
||||
# Atlas
|
||||
try:
|
||||
@ -106,15 +210,21 @@ def register(app) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Oceanus (node-exporter direct probe)
|
||||
try:
|
||||
t_probe = time.perf_counter()
|
||||
if _http_ok(settings.OCEANUS_NODE_EXPORTER_URL):
|
||||
timings_ms["oceanus_node_exporter"] = int((time.perf_counter() - t_probe) * 1000)
|
||||
dedicated_hosts = _dedicated_hosts_status()
|
||||
timings_ms["dedicated_hosts"] = int((time.perf_counter() - t_probe) * 1000)
|
||||
if dedicated_hosts["known"]:
|
||||
connected = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
t_probe = time.perf_counter()
|
||||
active_service = _public_active_service()
|
||||
timings_ms["active_service"] = int((time.perf_counter() - t_probe) * 1000)
|
||||
if active_service.get("known"):
|
||||
connected = True
|
||||
oceanus_known = True
|
||||
oceanus_up = True
|
||||
oceanus_source = "node-exporter"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -123,7 +233,9 @@ def register(app) -> None:
|
||||
payload = {
|
||||
"connected": connected,
|
||||
"atlas": {"up": atlas_up, "known": atlas_known, "source": atlas_source},
|
||||
"oceanus": {"up": oceanus_up, "known": oceanus_known, "source": oceanus_source},
|
||||
"dedicated_hosts": dedicated_hosts,
|
||||
"active_service": active_service,
|
||||
"oceanus": {"up": atlas_up, "known": atlas_known, "source": "atlas-member"},
|
||||
"checked_at": int(now),
|
||||
"timings_ms": timings_ms,
|
||||
}
|
||||
|
||||
@ -20,6 +20,21 @@ K8S_API_TIMEOUT_SEC = float(os.getenv("K8S_API_TIMEOUT_SEC", "5"))
|
||||
LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30"))
|
||||
GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "http://grafana.monitoring.svc.cluster.local/api/health")
|
||||
OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics")
|
||||
LAB_ACTIVE_SERVICE_WINDOW = os.getenv("LAB_ACTIVE_SERVICE_WINDOW", "15m").strip() or "15m"
|
||||
LAB_ACTIVE_SERVICE_QUERY = os.getenv(
|
||||
"LAB_ACTIVE_SERVICE_QUERY",
|
||||
f'topk(1, sum by (service) (increase(traefik_service_requests_total{{service!=""}}[{LAB_ACTIVE_SERVICE_WINDOW}])))',
|
||||
).strip()
|
||||
ATLAS_DB_HOST_HEALTH_URL = os.getenv("ATLAS_DB_HOST_HEALTH_URL", "").strip()
|
||||
ATLAS_DB_HOST_UP_QUERY = os.getenv(
|
||||
"ATLAS_DB_HOST_UP_QUERY",
|
||||
'up{instance=~".*(titan-db|atlas-db).*"}',
|
||||
).strip()
|
||||
ATLAS_JUMPHOST_HEALTH_URL = os.getenv("ATLAS_JUMPHOST_HEALTH_URL", "").strip()
|
||||
ATLAS_JUMPHOST_UP_QUERY = os.getenv(
|
||||
"ATLAS_JUMPHOST_UP_QUERY",
|
||||
'up{instance=~".*(titan-jh|theia).*"}',
|
||||
).strip()
|
||||
|
||||
AI_CHAT_API = os.getenv("AI_CHAT_API", "http://ollama.ai.svc.cluster.local:11434").rstrip("/")
|
||||
AI_CHAT_MODEL = os.getenv("AI_CHAT_MODEL", "qwen2.5-coder:7b-instruct-q4_0")
|
||||
|
||||
@ -17,8 +17,16 @@ class DummyResult:
|
||||
|
||||
|
||||
class DummyConn:
|
||||
def __init__(self, *, request_code: str = "alice~CODE", step_done: bool = True, fail: bool = False) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
request_code: str = "alice~CODE",
|
||||
status: str = "awaiting_onboarding",
|
||||
step_done: bool = True,
|
||||
fail: bool = False,
|
||||
) -> None:
|
||||
self.request_code = request_code
|
||||
self.status = status
|
||||
self.step_done = step_done
|
||||
self.fail = fail
|
||||
self.executed: list[tuple[str, object | None]] = []
|
||||
@ -30,7 +38,7 @@ class DummyConn:
|
||||
if "access_request_onboarding_steps" in query:
|
||||
return DummyResult({"exists": 1} if self.step_done else None)
|
||||
if "FROM access_requests" in query:
|
||||
return DummyResult({"request_code": self.request_code} if self.request_code else None)
|
||||
return DummyResult({"request_code": self.request_code, "status": self.status} if self.request_code else None)
|
||||
return DummyResult()
|
||||
|
||||
|
||||
@ -150,6 +158,7 @@ def test_tcp_check_paths(monkeypatch) -> None:
|
||||
def test_overview_preflight_and_admin_unavailable(monkeypatch) -> None:
|
||||
client, _conn = make_client(monkeypatch, account_ok=False)
|
||||
assert client.get("/api/account/overview").status_code == 403
|
||||
assert client.get("/api/account/member-state").status_code == 403
|
||||
|
||||
client, _conn = make_client(monkeypatch, admin=DummyAdmin(ready=False))
|
||||
data = client.get("/api/account/overview").get_json()
|
||||
@ -157,6 +166,22 @@ def test_overview_preflight_and_admin_unavailable(monkeypatch) -> None:
|
||||
assert data["jellyfin"]["sync_detail"] == "keycloak admin not configured"
|
||||
|
||||
|
||||
def test_member_state_returns_navigation_state_without_service_secrets(monkeypatch) -> None:
|
||||
client, _conn = make_client(monkeypatch, conn=DummyConn(status="awaiting_onboarding"))
|
||||
|
||||
data = client.get("/api/account/member-state").get_json()
|
||||
|
||||
assert data == {
|
||||
"user": {"username": "alice", "email": "", "groups": ["dev"]},
|
||||
"status": "awaiting_onboarding",
|
||||
"onboarding_url": "https://portal.example.dev/onboarding?code=alice~CODE",
|
||||
"dashboard_url": "/apps",
|
||||
"account_url": "/account",
|
||||
}
|
||||
assert "mailu" not in data
|
||||
assert "password" not in str(data).lower()
|
||||
|
||||
|
||||
def test_overview_reads_list_attributes_and_reports_ldap_ok(monkeypatch) -> None:
|
||||
attrs = list_attrs(full_attrs())
|
||||
user = {"id": "user-1", "email": "alice@idp.dev", "federationLink": "ldap", "attributes": attrs}
|
||||
|
||||
@ -13,7 +13,11 @@ def test_auth_config_disabled_by_default() -> None:
|
||||
resp = client.get("/api/auth/config")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"enabled": False}
|
||||
data = resp.get_json()
|
||||
assert data["enabled"] is False
|
||||
assert data["login_url"].startswith("https://sso.bstein.dev/realms/atlas/protocol/openid-connect/auth")
|
||||
assert data["reset_url"].startswith("https://sso.bstein.dev/realms/atlas/login-actions/reset-credentials")
|
||||
assert data["account_url"] == "https://sso.bstein.dev/realms/atlas/account"
|
||||
|
||||
|
||||
def test_auth_config_builds_urls_when_enabled(monkeypatch) -> None:
|
||||
|
||||
@ -49,6 +49,38 @@ def test_vm_query_success_and_empty_paths(monkeypatch) -> None:
|
||||
assert lab._vm_query("up") is None
|
||||
|
||||
|
||||
def test_vm_query_vector_filters_bad_samples(monkeypatch) -> None:
|
||||
payload = {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"result": [
|
||||
{"metric": {"service": "nextcloud"}, "value": [0, "12"]},
|
||||
{"metric": {"service": "broken"}, "value": []},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(lab, "urlopen", lambda url, timeout: DummyUrlResponse(payload))
|
||||
|
||||
assert lab._vm_query_vector("topk(1, up)") == [{"metric": {"service": "nextcloud"}, "value": 12.0}]
|
||||
|
||||
|
||||
def test_public_active_service_uses_external_ai_chat_url(monkeypatch) -> None:
|
||||
monkeypatch.setattr(lab.settings, "LAB_ACTIVE_SERVICE_QUERY", "topk(1, fake)")
|
||||
monkeypatch.setattr(
|
||||
lab,
|
||||
"_vm_query_vector",
|
||||
lambda expr: [{"metric": {"service": "atlas-ai-chat"}, "value": 9.0}],
|
||||
)
|
||||
|
||||
assert lab._public_active_service() == {
|
||||
"known": True,
|
||||
"label": "AI Chat",
|
||||
"url": "https://bstein.dev/ai/chat",
|
||||
"window": lab.settings.LAB_ACTIVE_SERVICE_WINDOW,
|
||||
}
|
||||
|
||||
|
||||
def test_http_ok_status_substring_and_errors(monkeypatch) -> None:
|
||||
responses = [
|
||||
DummyUrlResponse("service ok"),
|
||||
@ -76,16 +108,22 @@ def test_lab_status_uses_cache_and_probe_fallbacks(monkeypatch) -> None:
|
||||
client = app.test_client()
|
||||
lab._LAB_STATUS_CACHE["ts"] = 0.0
|
||||
lab._LAB_STATUS_CACHE["value"] = None
|
||||
monkeypatch.setattr(lab.settings, "OCEANUS_NODE_EXPORTER_URL", "https://oceanus.example.dev/metrics")
|
||||
monkeypatch.setattr(lab.settings, "ATLAS_DB_HOST_HEALTH_URL", "https://db.example.dev/health")
|
||||
monkeypatch.setattr(lab.settings, "ATLAS_JUMPHOST_HEALTH_URL", "https://jump.example.dev/health")
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_http_ok(url, expect_substring=None):
|
||||
calls.append(url)
|
||||
return "grafana" in url or "oceanus" in url
|
||||
return "grafana" in url or "db" in url or "jump" in url
|
||||
|
||||
monkeypatch.setattr(lab, "_http_ok", fake_http_ok)
|
||||
monkeypatch.setattr(lab, "_vm_query", lambda expr: 1.0)
|
||||
monkeypatch.setattr(
|
||||
lab,
|
||||
"_vm_query_vector",
|
||||
lambda expr: [{"metric": {"service": "nextcloud-web"}, "value": 8.0}],
|
||||
)
|
||||
|
||||
response = client.get("/api/lab/status")
|
||||
payload = response.get_json()
|
||||
@ -93,22 +131,36 @@ def test_lab_status_uses_cache_and_probe_fallbacks(monkeypatch) -> None:
|
||||
assert response.status_code == 200
|
||||
assert payload["connected"] is True
|
||||
assert payload["atlas"]["source"] == "grafana"
|
||||
assert payload["oceanus"]["source"] == "node-exporter"
|
||||
assert payload["dedicated_hosts"]["up"] is True
|
||||
assert payload["dedicated_hosts"]["up_count"] == 2
|
||||
assert payload["dedicated_hosts"]["hosts"][0]["label"] == "Database node"
|
||||
assert payload["active_service"] == {
|
||||
"known": True,
|
||||
"label": "Nextcloud",
|
||||
"url": "https://cloud.bstein.dev",
|
||||
"window": lab.settings.LAB_ACTIVE_SERVICE_WINDOW,
|
||||
}
|
||||
assert payload["oceanus"]["source"] == "atlas-member"
|
||||
|
||||
second = client.get("/api/lab/status")
|
||||
assert second.get_json() == payload
|
||||
|
||||
lab._LAB_STATUS_CACHE["ts"] = 0.0
|
||||
lab._LAB_STATUS_CACHE["value"] = None
|
||||
monkeypatch.setattr(lab.settings, "ATLAS_DB_HOST_HEALTH_URL", "")
|
||||
monkeypatch.setattr(lab.settings, "ATLAS_JUMPHOST_HEALTH_URL", "")
|
||||
monkeypatch.setattr(lab, "_http_ok", lambda *a, **k: False)
|
||||
monkeypatch.setattr(lab, "_vm_query", lambda expr: 0.0)
|
||||
monkeypatch.setattr(lab, "_vm_query_vector", lambda expr: [])
|
||||
|
||||
response = client.get("/api/lab/status")
|
||||
payload = response.get_json()
|
||||
|
||||
assert payload["atlas"]["source"] == "victoria-metrics"
|
||||
assert payload["atlas"]["up"] is False
|
||||
assert payload["oceanus"]["known"] is False
|
||||
assert payload["dedicated_hosts"]["known"] is True
|
||||
assert payload["dedicated_hosts"]["up"] is False
|
||||
assert payload["active_service"]["known"] is False
|
||||
|
||||
|
||||
def test_lab_status_handles_probe_exceptions(monkeypatch) -> None:
|
||||
@ -122,6 +174,7 @@ def test_lab_status_handles_probe_exceptions(monkeypatch) -> None:
|
||||
|
||||
monkeypatch.setattr(lab, "_http_ok", boom)
|
||||
monkeypatch.setattr(lab, "_vm_query", boom)
|
||||
monkeypatch.setattr(lab, "_vm_query_vector", boom)
|
||||
|
||||
response = client.get("/api/lab/status")
|
||||
payload = response.get_json()
|
||||
@ -129,4 +182,5 @@ def test_lab_status_handles_probe_exceptions(monkeypatch) -> None:
|
||||
assert response.status_code == 200
|
||||
assert payload["connected"] is False
|
||||
assert payload["atlas"]["known"] is False
|
||||
assert payload["oceanus"]["known"] is False
|
||||
assert payload["dedicated_hosts"]["known"] is False
|
||||
assert payload["active_service"]["known"] is False
|
||||
|
||||
@ -3,12 +3,49 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bstein.dev | Titan Lab</title>
|
||||
<title>Brad Stein | SDET, DevOps Automation & Platform Engineering</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Live status for the Titan Lab clusters powering bstein.dev."
|
||||
content="Senior SDET and DevOps Automation Engineer helping teams improve test automation, CI/CD, Docker and Kubernetes reliability, Python tooling, Linux systems, and observability. Available for remote project and contract work."
|
||||
/>
|
||||
<link rel="canonical" href="https://bstein.dev/" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://bstein.dev/" />
|
||||
<meta property="og:title" content="Brad Stein — SDET & DevOps Automation Engineer" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Test automation, CI/CD, Kubernetes, Docker, Python tooling, Linux infrastructure, observability, and live platform engineering."
|
||||
/>
|
||||
<meta property="og:image" content="https://bstein.dev/og-bstein-dev.svg" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Brad Stein — SDET & DevOps Automation Engineer" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Test automation, CI/CD, Kubernetes, Docker, Python tooling, Linux infrastructure, observability, and live platform engineering."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://bstein.dev/og-bstein-dev.svg" />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Person",
|
||||
"name": "Brad Stein",
|
||||
"jobTitle": "DevOps Automation Engineer / Senior SDET",
|
||||
"url": "https://bstein.dev/",
|
||||
"email": "mailto:brad@bstein.dev",
|
||||
"sameAs": ["https://www.linkedin.com/in/steinbradley/", "https://scm.bstein.dev/bstein"]
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"name": "bstein.dev",
|
||||
"url": "https://bstein.dev/",
|
||||
"description": "Brad Stein's professional engineering site and the home of the Titan Lab platform."
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
26
frontend/public/og-bstein-dev.svg
Normal file
26
frontend/public/og-bstein-dev.svg
Normal file
@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Brad Stein, DevOps Automation Engineer and Senior SDET</title>
|
||||
<desc id="desc">A dark bstein.dev social card with professional engineering positioning and Titan Lab platform proof.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#050914"/>
|
||||
<stop offset="0.55" stop-color="#0b1228"/>
|
||||
<stop offset="1" stop-color="#071d22"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0" stop-color="#00e5c5"/>
|
||||
<stop offset="1" stop-color="#7f7cff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#bg)"/>
|
||||
<path d="M80 118h1040M80 512h1040" stroke="#ffffff" stroke-opacity=".08" stroke-width="1"/>
|
||||
<path d="M820 120c90 28 158 94 193 178 32 78 30 158-5 213" fill="none" stroke="url(#accent)" stroke-width="2" stroke-opacity=".45"/>
|
||||
<circle cx="964" cy="250" r="6" fill="#00e5c5"/>
|
||||
<circle cx="1016" cy="386" r="6" fill="#7f7cff"/>
|
||||
<circle cx="880" cy="174" r="6" fill="#00e5c5"/>
|
||||
<text x="80" y="196" fill="#00e5c5" font-family="JetBrains Mono, monospace" font-size="26" letter-spacing="3">BSTEIN.DEV</text>
|
||||
<text x="80" y="286" fill="#f7fbff" font-family="Space Grotesk, Inter, sans-serif" font-size="74" font-weight="700">Brad Stein</text>
|
||||
<text x="80" y="352" fill="#d7e5ff" font-family="Space Grotesk, Inter, sans-serif" font-size="40" font-weight="600">DevOps Automation Engineer / Senior SDET</text>
|
||||
<text x="80" y="422" fill="#9fb2d0" font-family="Space Grotesk, Inter, sans-serif" font-size="30">CI/CD · Kubernetes · Python · Linux · Observability</text>
|
||||
<text x="80" y="482" fill="#9fb2d0" font-family="Space Grotesk, Inter, sans-serif" font-size="26">Titan Lab is the live platform proof behind the work.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@ -1,15 +1,17 @@
|
||||
<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"
|
||||
/>
|
||||
<main id="main-content" tabindex="-1">
|
||||
<router-view
|
||||
:lab-data="labData"
|
||||
:lab-status="labStatus"
|
||||
:service-data="serviceData"
|
||||
:network-data="networkData"
|
||||
:metrics-data="metricsData"
|
||||
:loading="statusLoading"
|
||||
:error="statusError"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ export const auth = reactive({
|
||||
authenticated: false,
|
||||
username: "",
|
||||
email: "",
|
||||
emailVerified: null,
|
||||
groups: [],
|
||||
loginUrl: "",
|
||||
resetUrl: "",
|
||||
@ -58,6 +59,7 @@ function updateFromToken() {
|
||||
auth.token = keycloak?.token || "";
|
||||
auth.username = parsed.preferred_username || "";
|
||||
auth.email = parsed.email || "";
|
||||
auth.emailVerified = typeof parsed.email_verified === "boolean" ? parsed.email_verified : null;
|
||||
auth.groups = normalizeGroups(parsed.groups);
|
||||
}
|
||||
|
||||
@ -131,7 +133,10 @@ export async function login(
|
||||
redirectPath = window.location.pathname + window.location.search + window.location.hash,
|
||||
loginHint = "",
|
||||
) {
|
||||
if (!keycloak) return;
|
||||
if (!keycloak) {
|
||||
if (auth.loginUrl) window.location.assign(auth.loginUrl);
|
||||
return;
|
||||
}
|
||||
const redirectUri = new URL(redirectPath, window.location.origin).toString();
|
||||
const options = { redirectUri };
|
||||
if (typeof loginHint === "string" && loginHint.trim()) {
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
<header class="hero card glass">
|
||||
<div class="eyebrow">
|
||||
<span class="pill">Titan Lab</span>
|
||||
<span class="mono accent">atlas · oceanus · nextcloud-ready</span>
|
||||
<span class="mono accent">atlas · member services · 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">
|
||||
<a v-for="link in links" :key="link.label" class="btn" :href="link.href" target="_blank" rel="noopener noreferrer">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
@ -23,9 +23,9 @@
|
||||
<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 class="label">Member services</div>
|
||||
<div class="value">identity + apps</div>
|
||||
<small>Request access · onboarding · approved services</small>
|
||||
</div>
|
||||
<div class="mini-card">
|
||||
<div class="label">Ingress</div>
|
||||
|
||||
@ -26,7 +26,6 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
@ -41,11 +40,15 @@ const isOpen = ref(false);
|
||||
let initialized = false;
|
||||
let scheduledHandle = null;
|
||||
let scheduledKind = "";
|
||||
let mermaidApi = null;
|
||||
|
||||
const renderDiagram = async () => {
|
||||
if (!props.diagram) return;
|
||||
if (!mermaidApi) {
|
||||
mermaidApi = (await import("mermaid")).default;
|
||||
}
|
||||
if (!initialized) {
|
||||
mermaid.initialize({
|
||||
mermaidApi.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "dark",
|
||||
securityLevel: "loose",
|
||||
@ -60,7 +63,7 @@ const renderDiagram = async () => {
|
||||
initialized = true;
|
||||
}
|
||||
try {
|
||||
const { svg } = await mermaid.render(`${renderKey.value}-${Date.now()}`, props.diagram);
|
||||
const { svg } = await mermaidApi.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>`;
|
||||
@ -146,7 +149,9 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.mermaid-card {
|
||||
min-height: 320px;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@ -165,15 +170,38 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.diagram {
|
||||
flex: 1;
|
||||
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;
|
||||
min-height: 170px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.diagram:empty::before {
|
||||
content: "Rendering diagram...";
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.diagram :deep(svg) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.diagram :deep(.label),
|
||||
.modal-body :deep(.label) {
|
||||
font-family: "Space Grotesk", "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@ -227,7 +255,8 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.modal-body :deep(svg) {
|
||||
width: 100%;
|
||||
width: max(100%, 920px);
|
||||
max-width: none;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<div class="metrics-body">
|
||||
<iframe
|
||||
:src="metrics.dashboard"
|
||||
title="Atlas + Oceanus metrics"
|
||||
title="Atlas platform metrics"
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
@ -16,8 +16,8 @@
|
||||
<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>
|
||||
<li>Public dashboards summarize health and operations without exposing member-only details.</li>
|
||||
<li>Grafana panels can be embedded into member tools where safe.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
918
frontend/src/components/PlatformSection.vue
Normal file
918
frontend/src/components/PlatformSection.vue
Normal file
@ -0,0 +1,918 @@
|
||||
<template>
|
||||
<section id="platform" class="platform-section" aria-labelledby="platform-title">
|
||||
<div class="section-kicker mono">Live technical platform</div>
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 id="platform-title">Titan Lab: Live Platform</h2>
|
||||
<p>
|
||||
Titan Lab is the live infrastructure behind bstein.dev. It demonstrates the same disciplines I apply
|
||||
professionally: automation, testing, GitOps delivery, identity, observability, service operations, and
|
||||
infrastructure troubleshooting.
|
||||
</p>
|
||||
</div>
|
||||
<div class="section-actions">
|
||||
<a class="section-link mono" href="https://metrics.bstein.dev" target="_blank" rel="noopener noreferrer">
|
||||
Public metrics
|
||||
</a>
|
||||
<a class="section-link mono" href="https://scm.bstein.dev/bstein/titan-iac" target="_blank" rel="noopener noreferrer">
|
||||
IaC source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-dashboard glass">
|
||||
<div class="dashboard-copy">
|
||||
<p class="eyebrow">Titan Lab</p>
|
||||
<h3>Atlas services and production-minded operations</h3>
|
||||
<p class="lede">
|
||||
Atlas runs the member platform, service catalog, identity, delivery automation, observability, source control,
|
||||
container delivery, storage, and onboarding workflows that support bstein.dev.
|
||||
</p>
|
||||
<div class="bullets">
|
||||
<div :class="['pill', 'mono', atlasPillClass]">Atlas: flux-managed k3s</div>
|
||||
<div class="pill mono">Public metrics</div>
|
||||
<div class="pill mono">Member platform</div>
|
||||
</div>
|
||||
<div class="live-status" aria-live="polite">
|
||||
<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>
|
||||
<p v-if="error || (!loading && !labStatus?.connected)" class="fallback-copy">
|
||||
Live status is temporarily unavailable. The platform overview remains available below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="availability dashboard-panel">
|
||||
<div class="availability-grid">
|
||||
<div class="availability-panel">
|
||||
<div class="panel-title availability-title">
|
||||
<div>
|
||||
<h4>Atlas availability</h4>
|
||||
<p class="panel-subtitle mono">k3s cluster</p>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
title="Atlas public availability panel"
|
||||
src="https://metrics.bstein.dev/d-solo/atlas-overview/atlas-overview?from=now-24h&to=now&refresh=1m&theme=dark&panelId=27&__feature.dashboardSceneSolo"
|
||||
width="100%"
|
||||
height="180"
|
||||
frameborder="0"
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
<div class="availability-panel">
|
||||
<div class="panel-title availability-title">
|
||||
<div>
|
||||
<h4>Dedicated host availability</h4>
|
||||
<p :class="['panel-subtitle', 'mono', dedicatedHostsPillClass]">{{ dedicatedHostsLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-status-list">
|
||||
<div v-for="host in dedicatedHostItems" :key="host.label" class="host-status-row">
|
||||
<span class="host-dot" :class="host.state" aria-hidden="true"></span>
|
||||
<div>
|
||||
<div class="host-label">{{ host.label }}</div>
|
||||
<div class="host-value mono">{{ host.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MetricRow class="dashboard-metrics" :items="metricItems" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="platform-subsection platform-services-section" aria-labelledby="platform-services-title">
|
||||
<div class="section-heading platform-subheading">
|
||||
<div>
|
||||
<p class="section-kicker mono">Live service surface</p>
|
||||
<h2 id="platform-services-title">Service Overview</h2>
|
||||
<p>
|
||||
A compact surface of public and member-facing services that shows how Titan Lab is delivered, routed,
|
||||
observed, and maintained as a running platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="platform-card card">
|
||||
<div class="section-head">
|
||||
<h3>Selected Services</h3>
|
||||
<span class="pill mono">identity + ci/cd + registry + observability + product services</span>
|
||||
</div>
|
||||
<ServiceGrid :services="featuredServices" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="platform-subsection platform-architecture-section" aria-labelledby="platform-architecture-title">
|
||||
<div class="section-heading platform-subheading">
|
||||
<div>
|
||||
<p class="section-kicker mono">Architecture</p>
|
||||
<h2 id="platform-architecture-title">How It All Fits Together</h2>
|
||||
<p>
|
||||
A high-level operating map for public edge traffic, Kubernetes services, delivery flow, and non-cluster host
|
||||
responsibilities. Internal addresses, credentials, and member-only details stay private.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="architecture-board card" aria-label="Titan Lab operating map">
|
||||
<div class="board-head">
|
||||
<div>
|
||||
<h3>Atlas operating map</h3>
|
||||
<p>Public edge, Kubernetes services, delivery machinery, and the two dedicated hosts that sit outside the cluster.</p>
|
||||
</div>
|
||||
<span class="pill mono">public abstraction</span>
|
||||
</div>
|
||||
|
||||
<div class="architecture-map">
|
||||
<div class="map-column edge-column">
|
||||
<div class="map-node edge-node">
|
||||
<span class="map-label mono">public edge</span>
|
||||
<strong>DNS / TLS / Traefik</strong>
|
||||
<p>Routes public pages, safe dashboards, and auth-gated member services.</p>
|
||||
</div>
|
||||
<div class="map-node access-node">
|
||||
<span class="map-label mono">access</span>
|
||||
<strong>Keycloak + OAuth gates</strong>
|
||||
<p>OIDC, SSO, account flows, request access, verification, and service boundaries.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-arrow" aria-hidden="true">-></div>
|
||||
|
||||
<div class="map-node cluster-node">
|
||||
<span class="map-label mono">atlas k3s</span>
|
||||
<strong>Flux-managed service platform</strong>
|
||||
<div class="node-grid" aria-label="Atlas cluster layers and operating systems">
|
||||
<span>
|
||||
<b>Control plane</b>
|
||||
HA k3s backed by the dedicated database node.
|
||||
</span>
|
||||
<span>
|
||||
<b>Workloads</b>
|
||||
Member apps, source control, CI, registry, media, and AI services.
|
||||
</span>
|
||||
<span>
|
||||
<b>State + storage</b>
|
||||
Longhorn volumes, storage pools, and PVC-backed workloads.
|
||||
</span>
|
||||
<span>
|
||||
<b>Secrets + identity</b>
|
||||
Vault, Keycloak, OAuth gates, and scoped configuration.
|
||||
</span>
|
||||
<span>
|
||||
<b>Observability</b>
|
||||
Prometheus, Grafana, OpenSearch, and Fluent Bit logs.
|
||||
</span>
|
||||
<span>
|
||||
<b>Backups + restore</b>
|
||||
Backup policies, restore checks, freshness reports, and recovery paths.
|
||||
</span>
|
||||
<span class="delivery-layer">
|
||||
<b>Delivery</b>
|
||||
<span class="delivery-chain mono">Gitea -> Jenkins -> Harbor -> Flux</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-arrow" aria-hidden="true"><-></div>
|
||||
|
||||
<div class="map-column ops-column">
|
||||
<div class="map-node ops-node">
|
||||
<span class="map-label mono">operations</span>
|
||||
<strong>Operator tools + recovery</strong>
|
||||
<p>Remote control, backup status, node rebuild paths, restore checks, and operational reports.</p>
|
||||
</div>
|
||||
<div class="map-node dedicated-node">
|
||||
<span class="map-label mono">outside cluster</span>
|
||||
<strong>Dedicated hosts</strong>
|
||||
<p>Database node plus jumphost / KVM landing host for operations and controlled access.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="source-diagrams">
|
||||
<summary>
|
||||
<span>
|
||||
<strong>Source diagrams</strong>
|
||||
<small>Mermaid views for full-screen inspection.</small>
|
||||
</span>
|
||||
<span class="pill mono">Open diagrams</span>
|
||||
</summary>
|
||||
|
||||
<div class="platform-diagrams" aria-label="Titan Lab architecture diagrams">
|
||||
<MermaidCard
|
||||
class="architecture-primary"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import MetricRow from "./MetricRow.vue";
|
||||
import ServiceGrid from "./ServiceGrid.vue";
|
||||
import MermaidCard from "./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 dedicatedHosts = computed(() => props.labStatus?.dedicated_hosts || null);
|
||||
const dedicatedHostItems = computed(() => {
|
||||
const hosts = dedicatedHosts.value?.hosts?.length
|
||||
? dedicatedHosts.value.hosts
|
||||
: [
|
||||
{ label: "Database node", known: false, up: false },
|
||||
{ label: "Jumphost", known: false, up: false },
|
||||
];
|
||||
return hosts.map((host) => {
|
||||
const state = hostState(host);
|
||||
return {
|
||||
label: host.label || "Dedicated host",
|
||||
state,
|
||||
value: statusLabelFrom(state),
|
||||
};
|
||||
});
|
||||
});
|
||||
const dedicatedHostsPillClass = computed(() => {
|
||||
if (!dedicatedHosts.value?.known) return "";
|
||||
return dedicatedHosts.value.up ? "pill-ok" : "pill-bad";
|
||||
});
|
||||
const dedicatedHostsLabel = computed(() => {
|
||||
if (!dedicatedHosts.value?.known) return "status pending";
|
||||
const total = Number.isFinite(dedicatedHosts.value.total) ? dedicatedHosts.value.total : 2;
|
||||
const upCount = Number.isFinite(dedicatedHosts.value.up_count) ? dedicatedHosts.value.up_count : 0;
|
||||
return `${upCount}/${total} responding`;
|
||||
});
|
||||
|
||||
const metricItems = computed(() => {
|
||||
const items = props.metricsData?.items?.length
|
||||
? props.metricsData.items
|
||||
: [
|
||||
{
|
||||
label: "Lab nodes",
|
||||
value: "26",
|
||||
note: "Atlas includes the main service fleet plus Oceanus and Tethys as cluster members.\nDedicated non-cluster hosts: titan-db and titan-jh.",
|
||||
},
|
||||
{
|
||||
label: "CPU cores",
|
||||
value: "142",
|
||||
note: "Mixed arm64 and amd64 capacity for platform, storage, media, and AI workloads.",
|
||||
},
|
||||
{
|
||||
label: "Memory",
|
||||
value: "552 GB",
|
||||
note: "Raspberry Pi, NVIDIA Jetson, and AMD64 systems across service and platform tiers.",
|
||||
},
|
||||
{
|
||||
label: "Storage",
|
||||
value: "80 TB",
|
||||
note: "Longhorn-backed storage pools for system and user workloads.",
|
||||
},
|
||||
];
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
note: item.note ? item.note.replaceAll("\t", " ") : "",
|
||||
}));
|
||||
});
|
||||
|
||||
const displayServices = computed(() => {
|
||||
const services = props.serviceData?.services || fallbackServices().services;
|
||||
return services
|
||||
.filter((svc) => svc.status !== "planned")
|
||||
.map((svc) => sanitizeService({
|
||||
...svc,
|
||||
icon: svc.icon || pickIcon(svc.name),
|
||||
}))
|
||||
.sort((a, b) => serviceRank(a) - serviceRank(b) || a.name.localeCompare(b.name));
|
||||
});
|
||||
const featuredServices = computed(() => displayServices.value.slice(0, 12));
|
||||
|
||||
const hardwareDiagram = computed(() => buildHardwareDiagram(props.labData || {}));
|
||||
const networkDiagram = computed(() => buildNetworkDiagram(props.networkData || {}));
|
||||
const pipelineDiagram = computed(() => buildPipelineDiagram());
|
||||
|
||||
function sanitizeService(service) {
|
||||
const host = typeof service.host === "string" ? service.host : "";
|
||||
const link = typeof service.link === "string" ? service.link : "";
|
||||
const hostLooksInternal = host.includes(".svc.cluster.local") || (host && !host.includes(".") && !host.includes("/"));
|
||||
if (!hostLooksInternal) return service;
|
||||
return { ...service, host: link.startsWith("/") ? link : "" };
|
||||
}
|
||||
|
||||
function pickIcon(name) {
|
||||
const h = name.toLowerCase();
|
||||
if (h.includes("nextcloud")) return "NC";
|
||||
if (h.includes("jellyfin")) return "JF";
|
||||
if (h.includes("matrix")) return "MX";
|
||||
if (h.includes("element")) return "EL";
|
||||
if (h.includes("livekit")) return "LK";
|
||||
if (h.includes("coturn") || h.includes("turn")) return "TN";
|
||||
if (h.includes("mail")) return "ML";
|
||||
if (h.includes("vaultwarden")) return "VW";
|
||||
if (h.includes("vault")) return "VT";
|
||||
if (h.includes("gitea")) return "GT";
|
||||
if (h.includes("jenkins")) return "JK";
|
||||
if (h.includes("harbor")) return "HB";
|
||||
if (h.includes("flux")) return "FX";
|
||||
if (h.includes("monero")) return "XM";
|
||||
if (h.includes("sui")) return "SU";
|
||||
if (h.includes("keycloak")) return "KC";
|
||||
if (h.includes("translation")) return "TR";
|
||||
if (h.includes("grafana")) return "GF";
|
||||
if (h.includes("pegasus")) return "PG";
|
||||
if (h.includes("veles")) return "VL";
|
||||
if (h.includes("ai chat")) return "AI";
|
||||
if (h.includes("ai image") || h.includes("vision")) return "VI";
|
||||
if (h.includes("ai speech")) return "SP";
|
||||
return "TL";
|
||||
}
|
||||
|
||||
function serviceRank(service) {
|
||||
const priority = [
|
||||
"Keycloak",
|
||||
"Gitea",
|
||||
"Jenkins",
|
||||
"Harbor",
|
||||
"Flux",
|
||||
"Grafana",
|
||||
"OpenSearch",
|
||||
"Veles",
|
||||
"Nextcloud",
|
||||
"Outline",
|
||||
"Planka",
|
||||
"VaultWarden",
|
||||
"Vault",
|
||||
];
|
||||
const index = priority.indexOf(service.name);
|
||||
return index === -1 ? priority.length : index;
|
||||
}
|
||||
|
||||
function hostState(host) {
|
||||
if (!host || host.known === false) return "unknown";
|
||||
return host.up ? "ok" : "bad";
|
||||
}
|
||||
|
||||
function statusLabelFrom(state) {
|
||||
if (state === "ok") return "Responding";
|
||||
if (state === "bad") return "Needs attention";
|
||||
return "Unknown";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-section,
|
||||
.platform-subsection {
|
||||
box-sizing: border-box;
|
||||
scroll-margin-top: 0;
|
||||
margin-top: 0;
|
||||
padding: clamp(44px, 6vh, 72px) 0;
|
||||
}
|
||||
|
||||
.platform-subsection {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(30px, 5vw, 44px);
|
||||
}
|
||||
|
||||
.section-heading p {
|
||||
max-width: 760px;
|
||||
margin: 10px 0 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
flex: 0 0 auto;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.platform-subheading {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.platform-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(430px, 1.1fr);
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
margin-bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 82% 10%, rgba(0, 229, 197, 0.1), transparent 28%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.045), rgba(5, 12, 28, 0.72));
|
||||
}
|
||||
|
||||
.dashboard-copy,
|
||||
.dashboard-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.platform-dashboard h3 {
|
||||
font-size: clamp(24px, 3vw, 32px);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-muted);
|
||||
max-width: 650px;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.bullets {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 0 12px;
|
||||
}
|
||||
|
||||
.live-status {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fallback-copy {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.availability {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-metrics {
|
||||
grid-column: 1 / -1;
|
||||
margin: 2px 0 0;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-metrics :deep(.metric-card) {
|
||||
min-height: 116px;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.availability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.availability-panel {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.availability-panel iframe {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.availability-title {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.availability-title h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin: 3px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panel-subtitle.pill-ok {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.panel-subtitle.pill-bad {
|
||||
color: var(--accent-rose);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.host-status-list {
|
||||
min-height: 180px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
.host-status-row {
|
||||
display: grid;
|
||||
grid-template-columns: 12px 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.host-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
margin-top: 5px;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.host-dot.ok {
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.host-dot.bad {
|
||||
background: var(--accent-rose);
|
||||
}
|
||||
|
||||
.host-label {
|
||||
color: var(--text-strong);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.host-value {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.platform-services-section .platform-card {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.architecture-board {
|
||||
padding: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.board-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.board-head h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: clamp(22px, 3vw, 30px);
|
||||
}
|
||||
|
||||
.board-head p {
|
||||
max-width: 720px;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.architecture-map {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 0.82fr) 28px minmax(450px, 1.65fr) 28px minmax(220px, 0.9fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.map-column {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.map-node {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.map-node strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.map-node p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.map-label {
|
||||
width: fit-content;
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(0, 229, 197, 0.08);
|
||||
border: 1px solid rgba(0, 229, 197, 0.18);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.edge-node {
|
||||
border-color: rgba(0, 229, 197, 0.16);
|
||||
}
|
||||
|
||||
.access-node {
|
||||
border-color: rgba(0, 229, 197, 0.12);
|
||||
}
|
||||
|
||||
.cluster-node {
|
||||
border-color: rgba(120, 180, 255, 0.18);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 229, 197, 0.05), rgba(120, 180, 255, 0.08)),
|
||||
rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.ops-node {
|
||||
border-color: rgba(127, 124, 255, 0.2);
|
||||
}
|
||||
|
||||
.dedicated-node {
|
||||
border-color: rgba(255, 220, 120, 0.22);
|
||||
}
|
||||
|
||||
.map-arrow {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 800;
|
||||
opacity: 0.8;
|
||||
align-self: center;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.node-grid > span {
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(3, 8, 18, 0.55);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.node-grid > span b {
|
||||
display: block;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.delivery-chain {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.delivery-layer {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 74px;
|
||||
}
|
||||
|
||||
.source-diagrams {
|
||||
margin-top: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.source-diagrams summary {
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.source-diagrams summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.source-diagrams small {
|
||||
display: inline;
|
||||
margin-left: 4px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.platform-diagrams {
|
||||
padding: 0 14px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.platform-diagrams :deep(.mermaid-card) {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.platform-diagrams :deep(.diagram) {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.platform-diagrams :deep(.diagram svg) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.section-heading,
|
||||
.platform-dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.platform-section,
|
||||
.platform-subsection {
|
||||
padding: 44px 0;
|
||||
}
|
||||
|
||||
.dashboard-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.platform-diagrams {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.architecture-map {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.map-arrow {
|
||||
min-height: 18px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.platform-diagrams :deep(.diagram) {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.availability-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.platform-dashboard {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dashboard-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.board-head,
|
||||
.source-diagrams summary {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.node-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -10,7 +10,7 @@
|
||||
:href="!isInternal(svc.link) ? svc.link || '#' : undefined"
|
||||
:title="svc.link"
|
||||
:target="isInternal(svc.link) ? undefined : '_blank'"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div class="service-top">
|
||||
<div class="icon">{{ svc.icon || "🛰️" }}</div>
|
||||
@ -48,7 +48,7 @@ const isInternal = (link) => typeof link === "string" && link.startsWith("/");
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.service {
|
||||
@ -56,6 +56,8 @@ const isInternal = (link) => typeof link === "string" && link.startsWith("/");
|
||||
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);
|
||||
min-height: 108px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.service:hover {
|
||||
@ -78,27 +80,28 @@ const isInternal = (link) => typeof link === "string" && link.startsWith("/");
|
||||
.service-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@ -106,12 +109,18 @@ const isInternal = (link) => typeof link === "string" && link.startsWith("/");
|
||||
.summary {
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
min-height: 22px;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: 6px;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
@ -21,9 +21,9 @@
|
||||
<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="label">Dedicated hosts</div>
|
||||
<div class="value">{{ specialty.length }}</div>
|
||||
<p>Oceanus (validator), Tethys bridge, and Bastion.</p>
|
||||
<p>Database node and jumphost kept outside the Atlas cluster.</p>
|
||||
</div>
|
||||
<div class="card stat">
|
||||
<div class="label">Storage fabric</div>
|
||||
|
||||
@ -1,64 +1,192 @@
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="profile" @click="goAbout">
|
||||
<div class="avatar">
|
||||
<img src="@/assets/profile-avatar.jpg" alt="Brad Stein" />
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
<RouterLink class="profile" to="/" @click="closeMenu">
|
||||
<div class="avatar" aria-hidden="true">
|
||||
<img src="@/assets/profile-avatar.jpg" alt="" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="name">Brad Stein</div>
|
||||
<div class="role">Software Development Engineer</div>
|
||||
<div class="role">{{ content.shortRole }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="links">
|
||||
<RouterLink to="/" class="nav-link">Home</RouterLink>
|
||||
<RouterLink to="/about" class="nav-link">About</RouterLink>
|
||||
</RouterLink>
|
||||
|
||||
<template v-if="auth.enabled">
|
||||
<template v-if="auth.authenticated">
|
||||
<RouterLink to="/apps" class="nav-link">Apps</RouterLink>
|
||||
<RouterLink to="/account" class="nav-link">Account</RouterLink>
|
||||
<button class="nav-link button" type="button" @click="doLogout">Logout</button>
|
||||
<button
|
||||
ref="menuButton"
|
||||
class="menu-toggle"
|
||||
type="button"
|
||||
aria-controls="site-navigation"
|
||||
:aria-expanded="menuOpen ? 'true' : 'false'"
|
||||
aria-label="Toggle navigation"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
|
||||
<nav
|
||||
id="site-navigation"
|
||||
ref="navEl"
|
||||
class="links"
|
||||
:class="{ open: menuOpen }"
|
||||
aria-label="Primary navigation"
|
||||
@keydown="onNavKeydown"
|
||||
>
|
||||
<a v-for="link in sectionLinks" :key="link.href" class="nav-link" :href="link.href" @click="closeMenu">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
<a class="nav-link professional-action" href="/#contact" @click="closeMenu">Contact</a>
|
||||
|
||||
<div class="utility">
|
||||
<template v-if="!auth.ready">
|
||||
<button class="nav-link button" type="button" @click="doLogin">Login</button>
|
||||
<RouterLink class="nav-link" to="/request-access" @click="closeMenu">Register</RouterLink>
|
||||
</template>
|
||||
|
||||
<template v-else-if="auth.authenticated">
|
||||
<RouterLink class="nav-link strong" :to="memberAction?.to || '/apps'" @click="closeMenu">
|
||||
{{ memberAction?.label || "Open Services" }}
|
||||
</RouterLink>
|
||||
<RouterLink class="nav-link" to="/account" @click="closeMenu">Account</RouterLink>
|
||||
<button class="nav-link button" type="button" @click="doLogout">Sign Out</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button class="nav-link button" type="button" @click="doLogin">Login</button>
|
||||
<RouterLink to="/request-access" class="nav-link">Request Access</RouterLink>
|
||||
<a v-if="auth.resetUrl" :href="auth.resetUrl" class="nav-link" target="_blank" rel="noreferrer">Reset Password</a>
|
||||
<RouterLink class="nav-link" to="/request-access" @click="closeMenu">Register</RouterLink>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter, RouterLink } from "vue-router";
|
||||
import { nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { auth, login, logout } from "@/auth";
|
||||
import { homepageContent } from "@/data/homepageContent";
|
||||
import { useMemberAccessState } from "@/member/useMemberAccessState";
|
||||
|
||||
const router = useRouter();
|
||||
const goAbout = () => router.push("/about");
|
||||
const { memberAction } = useMemberAccessState();
|
||||
const content = homepageContent;
|
||||
|
||||
const doLogin = () => login();
|
||||
const doLogout = () => logout();
|
||||
const sectionLinks = [
|
||||
{ label: "Work", href: "/#work" },
|
||||
{ label: "Capabilities", href: "/#capabilities" },
|
||||
{ label: "Platform", href: "/#platform" },
|
||||
{ label: "Process", href: "/#process" },
|
||||
];
|
||||
|
||||
const menuOpen = ref(false);
|
||||
const navEl = ref(null);
|
||||
const menuButton = ref(null);
|
||||
|
||||
const focusableSelector = "a[href], button:not([disabled]), [tabindex]:not([tabindex='-1'])";
|
||||
|
||||
function closeMenu() {
|
||||
menuOpen.value = false;
|
||||
}
|
||||
|
||||
async function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value;
|
||||
if (menuOpen.value) {
|
||||
await nextTick();
|
||||
const first = navEl.value?.querySelector(focusableSelector);
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowKeydown(event) {
|
||||
if (event.key !== "Escape" || !menuOpen.value) return;
|
||||
closeMenu();
|
||||
menuButton.value?.focus();
|
||||
}
|
||||
|
||||
function onNavKeydown(event) {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
menuButton.value?.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !menuOpen.value) return;
|
||||
|
||||
const items = Array.from(navEl.value?.querySelectorAll(focusableSelector) || []);
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const doLogout = () => {
|
||||
closeMenu();
|
||||
logout();
|
||||
};
|
||||
|
||||
const doLogin = () => {
|
||||
closeMenu();
|
||||
login();
|
||||
};
|
||||
|
||||
watch(menuOpen, (open) => {
|
||||
if (open) {
|
||||
window.addEventListener("keydown", onWindowKeydown);
|
||||
} else {
|
||||
window.removeEventListener("keydown", onWindowKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", onWindowKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 28px;
|
||||
background: rgba(5, 9, 20, 0.92);
|
||||
gap: 18px;
|
||||
padding: 12px 28px;
|
||||
background: rgba(5, 9, 20, 0.94);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
transform: translateY(-140%);
|
||||
background: var(--text-strong);
|
||||
color: var(--bg-deep);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 800;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
min-height: 48px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@ -84,7 +212,7 @@ const doLogout = () => logout();
|
||||
|
||||
.name {
|
||||
color: var(--text-strong);
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.role {
|
||||
@ -95,31 +223,128 @@ const doLogout = () => logout();
|
||||
.links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.utility {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 4px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.professional-action,
|
||||
.nav-link.strong {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
border-color: rgba(0, 229, 197, 0.22);
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(0, 229, 197, 0.06);
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link:focus-visible,
|
||||
.profile:focus-visible,
|
||||
.button:focus-visible,
|
||||
.menu-toggle:focus-visible {
|
||||
outline: 2px solid rgba(0, 229, 197, 0.72);
|
||||
outline-offset: 2px;
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
color: var(--accent-cyan);
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-strong);
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-toggle span {
|
||||
display: block;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 999px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.topbar {
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.links {
|
||||
position: fixed;
|
||||
top: 73px;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
display: none;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(5, 9, 20, 0.98);
|
||||
box-shadow: var(--shadow-strong);
|
||||
}
|
||||
|
||||
.links.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.utility {
|
||||
margin-left: 0;
|
||||
padding-left: 0;
|
||||
padding-top: 10px;
|
||||
border-left: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
justify-content: flex-start;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.role {
|
||||
max-width: 190px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
803
frontend/src/data/homepageContent.js
Normal file
803
frontend/src/data/homepageContent.js
Normal file
@ -0,0 +1,803 @@
|
||||
const focusMode = (import.meta.env?.VITE_HOMEPAGE_FOCUS || "devops").trim().toLowerCase();
|
||||
|
||||
const focusProfiles = {
|
||||
devops: {
|
||||
eyebrow: "AVAILABLE FOR REMOTE DEVOPS AUTOMATION AND PLATFORM WORK",
|
||||
role: "DevOps Automation Engineer / Senior SDET",
|
||||
shortRole: "DevOps Automation / Platform",
|
||||
summary:
|
||||
"I build and repair the machinery that gets code safely into production: CI/CD, Docker, Kubernetes, Linux infrastructure, Python automation, observability, and test gates.",
|
||||
experienceLine:
|
||||
"Engineering experience across Boeing, IBM, deployment automation, infrastructure tooling, system testing, and production-minded platform operations.",
|
||||
techStack: [
|
||||
"Jenkins",
|
||||
"Docker",
|
||||
"Kubernetes",
|
||||
"Linux",
|
||||
"Terraform",
|
||||
"Flux",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
"Python",
|
||||
"Bash",
|
||||
"Pytest",
|
||||
"Playwright",
|
||||
],
|
||||
capabilityOrder: [
|
||||
"CI/CD & Release Flow",
|
||||
"Docker & Kubernetes",
|
||||
"Python & Operational Automation",
|
||||
"Observability & Reliability",
|
||||
"Platform Tooling & GitOps",
|
||||
"Test Automation & SDET",
|
||||
],
|
||||
serviceOrder: [
|
||||
"Platform bring-up and GitOps operating model",
|
||||
"AWS and dedicated-host Kubernetes buildouts",
|
||||
"Cluster recovery, bootstrap, and outage drills",
|
||||
"CI/CD quality gates and release machinery",
|
||||
"Storage, backup, and restore operations",
|
||||
"Operational tools for real infrastructure",
|
||||
],
|
||||
capabilitySection: {
|
||||
kicker: "Capabilities",
|
||||
heading: "Where I Add Platform Value",
|
||||
description:
|
||||
"Delivery automation, infrastructure operations, and platform troubleshooting across the full path from commit to running service.",
|
||||
},
|
||||
serviceSection: {
|
||||
kicker: "Platform problem solving",
|
||||
heading: "From 0 to 100",
|
||||
description:
|
||||
"Beyond ticket-sized fixes, I build the operating machinery around delivery: cloud or dedicated-host Kubernetes, source-of-truth repositories, CI/CD gates, GitOps, recovery automation, backups, observability, and operator tools.",
|
||||
},
|
||||
workSection: {
|
||||
kicker: "Projects & Experience",
|
||||
heading: "Platform Showcase",
|
||||
description:
|
||||
"Inspectable repos, live services, and professional work samples that show how I approach real infrastructure.",
|
||||
},
|
||||
selectedWorkOrder: ["Titan Lab + titan-iac", "Atlas operator tools", "IBM", "Boeing", "Veles"],
|
||||
engagementOrder: [
|
||||
"Fixed-Scope Technical Rescue",
|
||||
"Short-Term Remote Contract",
|
||||
"Ongoing Engineering Support",
|
||||
"Senior Remote Engineering Role",
|
||||
],
|
||||
contactSection: {
|
||||
heading: "Have a Platform, Pipeline, Deployment, or Automation Problem?",
|
||||
description:
|
||||
"Send the problem, technical stack, expected outcome, and timeline. I will respond with whether it is a fit, what information is needed, and the most practical next step.",
|
||||
},
|
||||
veles: {
|
||||
description:
|
||||
"Homegrown AI playtester for Magic: The Gathering deck construction, deployed for the community on Atlas with backend orchestration and disposable simulation workers.",
|
||||
points: ["community AI service", "Kubernetes deployment", "worker orchestration", "job cleanup"],
|
||||
},
|
||||
about: {
|
||||
role: "DevOps Automation Engineer / Senior SDET",
|
||||
focusLine: "Platform Engineering · CI/CD · Reliability",
|
||||
summary: [
|
||||
"DevOps Automation Engineer and Senior SDET with experience across CI/CD pipelines, Kubernetes, Docker, Linux infrastructure, Python automation, observability, release gates, and system testing.",
|
||||
"I build deployment workflows, infrastructure utilities, dashboards, operator tools, and test automation that make complex delivery environments easier to run and safer to change.",
|
||||
"My projects and dashboards run on Atlas, the live bstein.dev platform.",
|
||||
],
|
||||
skillGroups: [
|
||||
{
|
||||
label: "Platform / Cloud",
|
||||
items: ["AWS", "GCP", "Kubernetes", "Helm", "Docker Compose", "Harbor", "Artifactory", "IronBank", "Keycloak", "Vault"],
|
||||
},
|
||||
{
|
||||
label: "CI/CD & GitOps",
|
||||
items: ["Jenkins", "Flux CD", "Argo CD", "GitHub Actions", "Drone", "Terraform", "Ansible"],
|
||||
},
|
||||
{
|
||||
label: "Observability / Data",
|
||||
items: ["Grafana", "Prometheus", "OpenSearch", "Zabbix", "LogDNA", "ELK", "PostgreSQL", "MySQL", "Redis", "Kafka"],
|
||||
},
|
||||
{
|
||||
label: "Automation",
|
||||
items: ["Python", "Bash", "Go", "Groovy", "SQL", "Rust"],
|
||||
},
|
||||
{
|
||||
label: "Testing",
|
||||
items: ["Pytest", "Selenium", "Playwright", "Jest", "API", "E2E", "System", "Chaos", "Load", "Performance", "Contract"],
|
||||
},
|
||||
{
|
||||
label: "Web & AI",
|
||||
items: ["Vue", "React", "Django", "Flask", "Tailwind", "JQuery", "Codex", "Cursor", "Claude Code"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
sdet: {
|
||||
eyebrow: "AVAILABLE FOR REMOTE SDET AND QA AUTOMATION WORK",
|
||||
role: "Senior SDET / DevOps Automation Engineer",
|
||||
shortRole: "SDET / QA Automation",
|
||||
summary:
|
||||
"I diagnose, automate, and stabilize the systems between code and production: test automation, CI/CD, Docker and Kubernetes, Linux infrastructure, Python tooling, and observability.",
|
||||
experienceLine:
|
||||
"Engineering experience across Boeing, IBM, system and end-to-end testing, deployment automation, infrastructure tooling, and production-minded platform operations.",
|
||||
techStack: [
|
||||
"Python",
|
||||
"Pytest",
|
||||
"Selenium",
|
||||
"Playwright",
|
||||
"API testing",
|
||||
"Jenkins",
|
||||
"Docker",
|
||||
"Kubernetes",
|
||||
"Linux",
|
||||
"Grafana",
|
||||
"Prometheus",
|
||||
"Terraform",
|
||||
],
|
||||
capabilityOrder: [
|
||||
"Test Automation & SDET",
|
||||
"CI/CD & Release Flow",
|
||||
"Docker & Kubernetes",
|
||||
"Python & Operational Automation",
|
||||
"Observability & Reliability",
|
||||
"Platform Tooling & GitOps",
|
||||
],
|
||||
serviceOrder: [
|
||||
"CI/CD quality gates and release machinery",
|
||||
"Platform bring-up and GitOps operating model",
|
||||
"AWS and dedicated-host Kubernetes buildouts",
|
||||
"Cluster recovery, bootstrap, and outage drills",
|
||||
"Operational tools for real infrastructure",
|
||||
"Storage, backup, and restore operations",
|
||||
],
|
||||
capabilitySection: {
|
||||
kicker: "Capabilities",
|
||||
heading: "Where I Add Quality Value",
|
||||
description:
|
||||
"Test engineering, delivery automation, and release confidence across the full path from code to operating system.",
|
||||
},
|
||||
serviceSection: {
|
||||
kicker: "Delivery confidence",
|
||||
heading: "Quality Systems I Can Take From Zero to Reliable",
|
||||
description:
|
||||
"I build the checks, diagnostics, release gates, and automation that help teams know whether a system is actually ready to ship.",
|
||||
},
|
||||
workSection: {
|
||||
kicker: "Projects & Experience",
|
||||
heading: "Testing, Automation, and Platform Work",
|
||||
description:
|
||||
"Engineering work across system testing, cloud validation, delivery gates, and live platform operations.",
|
||||
},
|
||||
selectedWorkOrder: ["Boeing", "IBM", "Titan Lab + titan-iac", "Atlas operator tools", "Veles"],
|
||||
engagementOrder: [
|
||||
"Short-Term Remote Contract",
|
||||
"Fixed-Scope Technical Rescue",
|
||||
"Ongoing Engineering Support",
|
||||
"Senior Remote Engineering Role",
|
||||
],
|
||||
contactSection: {
|
||||
heading: "Have a Test, Release, or Automation Problem?",
|
||||
description:
|
||||
"Send the problem, technical stack, expected outcome, and timeline. I will respond with whether it is a fit, what information is needed, and the most practical next step.",
|
||||
},
|
||||
veles: {
|
||||
description:
|
||||
"Homegrown AI playtester with simulation flows, backend service boundaries, and useful surfaces for API, worker, regression, and result-quality testing.",
|
||||
points: ["simulation flows", "API coverage", "regression surfaces", "result checks"],
|
||||
},
|
||||
about: {
|
||||
role: "Senior SDET / DevOps Automation Engineer",
|
||||
focusLine: "Test Automation · Release Quality · CI/CD",
|
||||
summary: [
|
||||
"Senior SDET and DevOps Automation Engineer with experience across Python automation, system and end-to-end testing, chaos testing, CI/CD pipelines, Kubernetes, Docker, Linux infrastructure, and observability.",
|
||||
"I build test frameworks, API and browser automation, release gates, diagnostics, dashboards, and delivery workflows that help teams understand whether a system is ready to ship.",
|
||||
"My projects and dashboards run on Atlas, the live bstein.dev platform.",
|
||||
],
|
||||
skillGroups: [
|
||||
{
|
||||
label: "Testing",
|
||||
items: ["Selenium", "Pytest", "Playwright", "Jest", "SonarQube", "Postman", "API", "E2E", "System", "Chaos", "Load", "Performance", "Stress", "Contract"],
|
||||
},
|
||||
{
|
||||
label: "Automation",
|
||||
items: ["Python", "Go", "Bash", "Groovy", "SQL", "JavaScript", "TypeScript"],
|
||||
},
|
||||
{
|
||||
label: "CI/CD & GitOps",
|
||||
items: ["Jenkins", "Drone", "Flux CD", "Argo CD", "GitHub Actions", "Terraform", "Ansible"],
|
||||
},
|
||||
{
|
||||
label: "Platform / Cloud",
|
||||
items: ["AWS", "GCP", "Kubernetes", "Helm", "Docker Compose", "Harbor", "Artifactory", "IronBank", "Keycloak", "Vault"],
|
||||
},
|
||||
{
|
||||
label: "Observability / Data",
|
||||
items: ["Grafana", "Prometheus", "OpenSearch", "Zabbix", "LogDNA", "ELK", "PostgreSQL", "MySQL", "Redis", "Kafka"],
|
||||
},
|
||||
{
|
||||
label: "Web & AI",
|
||||
items: ["Vue", "React", "Django", "Flask", "Tailwind", "JQuery", "Codex", "Cursor", "Claude Code"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
dev: {
|
||||
eyebrow: "AVAILABLE FOR REMOTE PRODUCT, PLATFORM, AND AI SERVICE WORK",
|
||||
role: "Full-Stack Platform Engineer / Senior SDET",
|
||||
shortRole: "Product / Platform Engineering",
|
||||
summary:
|
||||
"I build useful software on real infrastructure: Vue frontends, Python services, AI integrations, CI/CD, Kubernetes, observability, and testable delivery paths.",
|
||||
experienceLine:
|
||||
"Engineering experience across live product delivery, Boeing, IBM, AI services, infrastructure tooling, and production-minded platform operations.",
|
||||
techStack: [
|
||||
"Vue",
|
||||
"Python",
|
||||
"AI integration",
|
||||
"API design",
|
||||
"Docker",
|
||||
"Kubernetes",
|
||||
"Jenkins",
|
||||
"Flux",
|
||||
"Grafana",
|
||||
"PostgreSQL",
|
||||
"Pytest",
|
||||
"Playwright",
|
||||
],
|
||||
capabilityOrder: [
|
||||
"Python & Operational Automation",
|
||||
"Docker & Kubernetes",
|
||||
"Platform Tooling & GitOps",
|
||||
"CI/CD & Release Flow",
|
||||
"Test Automation & SDET",
|
||||
"Observability & Reliability",
|
||||
],
|
||||
serviceOrder: [
|
||||
"Operational tools for real infrastructure",
|
||||
"Platform bring-up and GitOps operating model",
|
||||
"CI/CD quality gates and release machinery",
|
||||
"AWS and dedicated-host Kubernetes buildouts",
|
||||
"Cluster recovery, bootstrap, and outage drills",
|
||||
"Storage, backup, and restore operations",
|
||||
],
|
||||
capabilitySection: {
|
||||
kicker: "Capabilities",
|
||||
heading: "Where I Add Product and Platform Value",
|
||||
description:
|
||||
"Application engineering, AI service integration, infrastructure automation, and release quality across the full path from idea to running service.",
|
||||
},
|
||||
serviceSection: {
|
||||
kicker: "Product-to-platform delivery",
|
||||
heading: "From 0 to 100",
|
||||
description:
|
||||
"I can help turn a useful idea into a running service: application workflow, API boundaries, background jobs, CI/CD, Kubernetes deployment, observability, and test coverage.",
|
||||
},
|
||||
workSection: {
|
||||
kicker: "Projects & Experience",
|
||||
heading: "Product and Platform Showcase",
|
||||
description:
|
||||
"Inspectable repos, live services, and professional work samples that show product engineering backed by real operations.",
|
||||
},
|
||||
selectedWorkOrder: ["Veles", "Titan Lab + titan-iac", "Atlas operator tools", "IBM", "Boeing"],
|
||||
engagementOrder: [
|
||||
"Short-Term Remote Contract",
|
||||
"Fixed-Scope Technical Rescue",
|
||||
"Ongoing Engineering Support",
|
||||
"Senior Remote Engineering Role",
|
||||
],
|
||||
contactSection: {
|
||||
heading: "Have a Product, AI Service, Platform, or Automation Problem?",
|
||||
description:
|
||||
"Send the idea or problem, technical stack, expected outcome, and timeline. I will respond with whether it is a fit, what information is needed, and the most practical next step.",
|
||||
},
|
||||
veles: {
|
||||
description:
|
||||
"Homegrown AI service for Magic: The Gathering deck construction, with product workflow, AI integration, backend orchestration, content seeding, and disposable simulation jobs.",
|
||||
points: ["AI integration", "product workflow", "service boundaries", "simulation workers"],
|
||||
},
|
||||
about: {
|
||||
role: "Full-Stack Platform Engineer / Senior SDET",
|
||||
focusLine: "AI Services · Vue · Python · Kubernetes",
|
||||
summary: [
|
||||
"Full-stack platform engineer with experience across Vue frontends, Python services, AI integration, CI/CD, Kubernetes, Docker, Linux infrastructure, observability, and testable delivery paths.",
|
||||
"I build product workflows, APIs, dashboards, background jobs, deployment automation, and test surfaces that turn useful ideas into systems people can actually run.",
|
||||
"My projects and dashboards run on Atlas, the live bstein.dev platform.",
|
||||
],
|
||||
skillGroups: [
|
||||
{
|
||||
label: "Product / Web",
|
||||
items: ["Vue", "Quasar", "React", "Tailwind", "JavaScript", "TypeScript", "JQuery", "FusionCharts"],
|
||||
},
|
||||
{
|
||||
label: "Backend / AI",
|
||||
items: ["Python", "Django", "Flask", "API design", "AI integration", "workers", "PostgreSQL", "Redis"],
|
||||
},
|
||||
{
|
||||
label: "Platform",
|
||||
items: ["Kubernetes", "Docker Compose", "Helm", "Flux CD", "Jenkins", "Harbor", "Keycloak", "Vault"],
|
||||
},
|
||||
{
|
||||
label: "Testing",
|
||||
items: ["Pytest", "Playwright", "Selenium", "Jest", "API", "E2E", "Regression", "Contract"],
|
||||
},
|
||||
{
|
||||
label: "Observability",
|
||||
items: ["Grafana", "Prometheus", "OpenSearch", "Zabbix", "ELK", "LogDNA"],
|
||||
},
|
||||
{
|
||||
label: "Tools",
|
||||
items: ["GitHub Actions", "Terraform", "Ansible", "Go", "Bash", "Codex", "Cursor", "Claude Code"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const homepageFocus = focusProfiles[focusMode] ? focusMode : "devops";
|
||||
|
||||
const capabilities = [
|
||||
{
|
||||
icon: "TA",
|
||||
title: "Test Automation & SDET",
|
||||
description:
|
||||
"System, end-to-end, API, browser, and release-gate testing using Python, Pytest, Selenium, Playwright, and related tooling. Includes test architecture, coverage strategy, flaky-test diagnosis, and CI integration.",
|
||||
},
|
||||
{
|
||||
icon: "CI",
|
||||
title: "CI/CD & Release Flow",
|
||||
description:
|
||||
"Jenkins pipelines, build and test stages, promotion workflows, GitOps delivery, artifact handling, deployment gates, and failure diagnosis.",
|
||||
},
|
||||
{
|
||||
icon: "K8",
|
||||
title: "Docker & Kubernetes",
|
||||
description:
|
||||
"Docker and Docker Compose troubleshooting, Kubernetes workloads, Helm deployments, Flux or Argo CD workflows, service routing, configuration, storage, and deployment debugging.",
|
||||
},
|
||||
{
|
||||
icon: "PY",
|
||||
title: "Python & Operational Automation",
|
||||
description:
|
||||
"Python and Bash tools that replace repetitive work, connect APIs, validate systems, manage configuration, and make operational processes more reliable.",
|
||||
},
|
||||
{
|
||||
icon: "OB",
|
||||
title: "Observability & Reliability",
|
||||
description:
|
||||
"Metrics, dashboards, logs, alerting, and root-cause investigation using Grafana, Prometheus, Zabbix, OpenSearch, ELK, and related systems.",
|
||||
},
|
||||
{
|
||||
icon: "GT",
|
||||
title: "Platform Tooling & GitOps",
|
||||
description:
|
||||
"Internal tools, infrastructure utilities, configuration-drift prevention, environment management, GitOps workflows, and production-minded platform operations.",
|
||||
},
|
||||
];
|
||||
|
||||
const services = [
|
||||
{
|
||||
title: "Platform bring-up and GitOps operating model",
|
||||
investigation:
|
||||
"Turn ad hoc services into a source-controlled platform: Kubernetes layout, Kustomize or Flux structure, environments, ingress, identity, storage, secrets, and validation workflow.",
|
||||
outcome:
|
||||
"A platform that can be changed through reviewable commits, reconciled by automation, and recovered without mystery manual edits.",
|
||||
},
|
||||
{
|
||||
title: "Cluster recovery, bootstrap, and outage drills",
|
||||
investigation:
|
||||
"Encode startup and shutdown order, node reachability, Flux health, workload convergence, ingress checks, service checklists, critical endpoints, and stability soaks.",
|
||||
outcome:
|
||||
"Repeatable recovery runs with status reports and gaps converted into automation instead of tribal knowledge.",
|
||||
},
|
||||
{
|
||||
title: "AWS and dedicated-host Kubernetes buildouts",
|
||||
investigation:
|
||||
"Design and stand up Kubernetes where the work actually lives: AWS VPCs, subnets, IAM, security groups, EKS or k3s on EC2, plus dedicated-host clusters with ingress, storage, identity, observability, and Terraform-backed repeatability.",
|
||||
outcome:
|
||||
"A documented cluster path from empty account or bare host to running services, with the foundations needed for CI/CD, GitOps, monitoring, and future handoff.",
|
||||
},
|
||||
{
|
||||
title: "CI/CD quality gates and release machinery",
|
||||
investigation:
|
||||
"Build Jenkins pipelines, container/image promotion, coverage floors, lint and security gates, artifact publishing, branch-aware metrics, and failure reporting.",
|
||||
outcome:
|
||||
"A delivery path that tells teams what failed, blocks unsafe changes, and produces evidence for every release.",
|
||||
},
|
||||
{
|
||||
title: "Storage, backup, and restore operations",
|
||||
investigation:
|
||||
"Map stateful workloads, PVCs, Longhorn volumes, policy backups, namespace restore flows, auth boundaries, and backup freshness metrics.",
|
||||
outcome:
|
||||
"Recoverable data paths, visible backup health, safer restore testing, and fewer surprise gaps around stateful services.",
|
||||
},
|
||||
{
|
||||
title: "Operational tools for real infrastructure",
|
||||
investigation:
|
||||
"Build CLIs, web UIs, diagnostics, logs, Prometheus metrics, remote-control surfaces, and admin workflows around the parts operators actually touch.",
|
||||
outcome:
|
||||
"A platform that is easier to operate under pressure because the important actions and evidence are available in one place.",
|
||||
},
|
||||
];
|
||||
|
||||
function orderedBy(items, order) {
|
||||
const rank = new Map(order.map((title, index) => [title, index]));
|
||||
return [...items].sort((a, b) => (rank.get(a.title) ?? 99) - (rank.get(b.title) ?? 99));
|
||||
}
|
||||
|
||||
const selectedFocus = focusProfiles[homepageFocus];
|
||||
|
||||
const workDetailsByFocus = {
|
||||
devops: {
|
||||
titan: {
|
||||
kind: "Live platform",
|
||||
description:
|
||||
"Public IaC for bstein.dev: k3s, Flux, CI/CD, identity, ingress, Longhorn, observability, hosted services, recovery paths, and member onboarding.",
|
||||
points: [
|
||||
"IaC repo",
|
||||
"Kubernetes",
|
||||
"GitOps",
|
||||
"CI/CD",
|
||||
"identity",
|
||||
"registry",
|
||||
"storage",
|
||||
"observability",
|
||||
"backups",
|
||||
"recovery",
|
||||
],
|
||||
},
|
||||
operators: {
|
||||
kind: "Public tooling",
|
||||
description:
|
||||
"Public tools for real Atlas operations: remote control, job reporting, backups, recovery image creation, sentinels, and UPS orchestration.",
|
||||
points: ["remote control", "job reporting", "backup status", "recovery images", "sentinels", "UPS orchestration"],
|
||||
},
|
||||
ibm: {
|
||||
description:
|
||||
"IBM Cloud automation across infrastructure validation, GPU environments, Terraform workflows, API health, and operational monitoring.",
|
||||
points: ["Terraform", "cloud validation", "RHEL/SUSE", "GPU environments", "Zabbix", "API health", "autoscaling"],
|
||||
},
|
||||
boeing: {
|
||||
description:
|
||||
"DevSecOps-adjacent automation, Flux-controlled environments, Jenkins promotion gates, Docker Compose drift protection, and infrastructure diagnosis.",
|
||||
points: ["Flux envs", "Jenkins gates", "Docker Compose", "drift protection", "infra diagnosis", "Lanterna", "Python tools"],
|
||||
},
|
||||
},
|
||||
sdet: {
|
||||
titan: {
|
||||
kind: "Live platform",
|
||||
description:
|
||||
"A running member platform with authentication, onboarding, dashboards, service boundaries, metrics, health checks, and failure modes that can be tested end to end.",
|
||||
points: ["auth flows", "onboarding", "service checks", "dashboard smoke", "metrics", "failure fallback", "release gates"],
|
||||
},
|
||||
operators: {
|
||||
kind: "Automation tooling",
|
||||
description:
|
||||
"Operational tools with repeatable checks, job status, failure reporting, and validation surfaces useful for system-level testing.",
|
||||
points: ["state checks", "job reports", "failure diagnosis", "repeatable jobs", "validation flows", "safe actions"],
|
||||
},
|
||||
ibm: {
|
||||
description:
|
||||
"IBM Cloud test automation across system validation, GPU environments, API workflows, Go and Terraform tests, and operational health checks.",
|
||||
points: ["Python E2E", "Go tests", "Terraform tests", "GPU validation", "API workflows", "data-center validation", "Zabbix"],
|
||||
},
|
||||
boeing: {
|
||||
description:
|
||||
"System and end-to-end testing, Python and Selenium automation, microservice and web UI validation, release gates, and failure diagnostics.",
|
||||
points: ["Python", "Selenium", "system E2E", "microservices", "web UI", "quality gates", "test architecture"],
|
||||
},
|
||||
},
|
||||
dev: {
|
||||
titan: {
|
||||
kind: "Live platform",
|
||||
description:
|
||||
"A productized platform surface with member accounts, service catalog, OAuth, dashboards, hosted applications, AI workloads, and delivery automation.",
|
||||
points: ["Vue frontend", "member system", "service catalog", "OAuth", "AI workloads", "CI/CD", "observability"],
|
||||
},
|
||||
operators: {
|
||||
kind: "Operator products",
|
||||
description:
|
||||
"Small internal products for real operations: admin workflows, job surfaces, status reporting, remote actions, and automation APIs.",
|
||||
points: ["admin workflows", "job surfaces", "status reports", "remote actions", "automation APIs", "operator UX"],
|
||||
},
|
||||
ibm: {
|
||||
description:
|
||||
"IBM Cloud engineering across Python, Go, Terraform, API workflows, platform health dashboards, feature demos, and knowledge transfer.",
|
||||
points: ["Python", "Go", "Terraform", "API workflows", "health dashboards", "feature demos", "knowledge transfer"],
|
||||
},
|
||||
boeing: {
|
||||
description:
|
||||
"Engineering automation around system workflows, Python tools, release visualization, environment state, and complex integration surfaces.",
|
||||
points: ["Python tools", "Lanterna", "workflow visibility", "system integration", "environment state", "automation"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const workDetails = workDetailsByFocus[homepageFocus] || workDetailsByFocus.devops;
|
||||
|
||||
export const homepageContent = {
|
||||
professionalName: "Brad Stein",
|
||||
role: selectedFocus.role,
|
||||
shortRole: selectedFocus.shortRole,
|
||||
focus: homepageFocus,
|
||||
eyebrow: selectedFocus.eyebrow,
|
||||
title: "Brad Stein | SDET, DevOps Automation & Platform Engineering",
|
||||
description:
|
||||
"Senior SDET and DevOps Automation Engineer helping teams improve test automation, CI/CD, Docker and Kubernetes reliability, Python tooling, Linux systems, and observability. Available for remote project and contract work.",
|
||||
summary: selectedFocus.summary,
|
||||
experienceLine: selectedFocus.experienceLine,
|
||||
availability: {
|
||||
visible: true,
|
||||
label: "Available for remote project, contract, and engineering opportunities",
|
||||
timeZone: "Remote, US-friendly time zones",
|
||||
updatedAt: "2026-06-19",
|
||||
},
|
||||
primaryContact: {
|
||||
email: "brad@bstein.dev",
|
||||
subject: "Project, contract, or engineering inquiry from bstein.dev",
|
||||
},
|
||||
resumeUrl: "/about",
|
||||
resumePdfUrl: "",
|
||||
linkedInUrl: "https://www.linkedin.com/in/steinbradley/",
|
||||
sourceUrl: "https://scm.bstein.dev/bstein",
|
||||
platformMetricsUrl: "https://metrics.bstein.dev",
|
||||
techStack: selectedFocus.techStack,
|
||||
credibility: [
|
||||
"Professional experience: Boeing",
|
||||
"Professional experience: IBM",
|
||||
"Python automation",
|
||||
"Kubernetes and CI/CD",
|
||||
"Remote availability",
|
||||
],
|
||||
capabilitySection: selectedFocus.capabilitySection,
|
||||
capabilities: orderedBy(capabilities, selectedFocus.capabilityOrder),
|
||||
serviceSection: selectedFocus.serviceSection,
|
||||
services: orderedBy(services, selectedFocus.serviceOrder),
|
||||
workSection: selectedFocus.workSection,
|
||||
contactSection: selectedFocus.contactSection,
|
||||
about: selectedFocus.about,
|
||||
selectedWork: [
|
||||
{
|
||||
icon: "IaC",
|
||||
kind: workDetails.titan.kind,
|
||||
title: "Titan Lab + titan-iac",
|
||||
description: workDetails.titan.description,
|
||||
points: workDetails.titan.points,
|
||||
links: [
|
||||
{
|
||||
label: "IaC repo",
|
||||
href: "https://scm.bstein.dev/bstein/titan-iac",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "OPS",
|
||||
kind: workDetails.operators.kind,
|
||||
title: "Atlas operator tools",
|
||||
description: workDetails.operators.description,
|
||||
points: workDetails.operators.points,
|
||||
links: [
|
||||
{ label: "lesavka", href: "https://scm.bstein.dev/bstein/lesavka" },
|
||||
{ label: "ariadne", href: "https://scm.bstein.dev/bstein/ariadne" },
|
||||
{ label: "soteria", href: "https://scm.bstein.dev/bstein/soteria" },
|
||||
{ label: "metis", href: "https://scm.bstein.dev/bstein/metis" },
|
||||
{ label: "ananke", href: "https://scm.bstein.dev/bstein/ananke" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: "IBM",
|
||||
kind: "Professional experience",
|
||||
title: "IBM",
|
||||
description: workDetails.ibm.description,
|
||||
points: workDetails.ibm.points,
|
||||
},
|
||||
{
|
||||
icon: "BA",
|
||||
kind: "Professional experience",
|
||||
title: "Boeing",
|
||||
description: workDetails.boeing.description,
|
||||
points: workDetails.boeing.points,
|
||||
},
|
||||
{
|
||||
icon: "AI",
|
||||
kind: "Community AI service",
|
||||
title: "Veles",
|
||||
description: selectedFocus.veles.description,
|
||||
points: selectedFocus.veles.points,
|
||||
weight: "minor",
|
||||
links: [
|
||||
{
|
||||
label: "Live app",
|
||||
href: "https://veles.bstein.dev/",
|
||||
},
|
||||
{
|
||||
label: "Content seed",
|
||||
href: "https://scm.bstein.dev/bstein/veles-content",
|
||||
},
|
||||
],
|
||||
},
|
||||
].sort((a, b) => {
|
||||
const rank = new Map(selectedFocus.selectedWorkOrder.map((title, index) => [title, index]));
|
||||
return (rank.get(a.title) ?? 99) - (rank.get(b.title) ?? 99);
|
||||
}),
|
||||
engagementTypes: [
|
||||
{
|
||||
title: "Fixed-Scope Technical Rescue",
|
||||
duration: "Several hours to several days.",
|
||||
description:
|
||||
"A bounded diagnosis or repair for a failing pipeline, deployment, automation workflow, test suite, or infrastructure problem.",
|
||||
},
|
||||
{
|
||||
title: "Short-Term Remote Contract",
|
||||
duration: "Several weeks to several months.",
|
||||
description:
|
||||
"Focused engineering capacity for test automation, CI/CD, infrastructure tooling, platform quality, release flow, or technical backlog reduction.",
|
||||
},
|
||||
{
|
||||
title: "Ongoing Engineering Support",
|
||||
duration: "Recurring support based on the operating need.",
|
||||
description:
|
||||
"Recurring help with automation, quality engineering, operational tooling, release systems, and platform reliability.",
|
||||
},
|
||||
{
|
||||
title: "Senior Remote Engineering Role",
|
||||
duration: "Full-time remote roles where the fit is strong.",
|
||||
description:
|
||||
homepageFocus === "devops"
|
||||
? "I am open to well-aligned remote DevOps Automation, platform engineering, CI/CD, infrastructure automation, Kubernetes, Linux, or systems reliability roles. SDET and test automation depth comes with it."
|
||||
: "I am open to well-aligned remote SDET, QA Automation, systems testing, release quality, CI/CD, or DevOps Automation roles. Platform engineering depth comes with it.",
|
||||
},
|
||||
].sort((a, b) => {
|
||||
const rank = new Map(selectedFocus.engagementOrder.map((title, index) => [title, index]));
|
||||
return (rank.get(a.title) ?? 99) - (rank.get(b.title) ?? 99);
|
||||
}),
|
||||
processSteps: [
|
||||
{
|
||||
title: "Share the Problem",
|
||||
description:
|
||||
"Send a concise description, relevant stack, desired outcome, timeline, and any safe logs, screenshots, or links.",
|
||||
},
|
||||
{
|
||||
title: "Confirm Fit and Scope",
|
||||
description:
|
||||
"Review the problem, identify missing information, and determine whether it fits a fixed-scope diagnostic, project, or contract engagement.",
|
||||
},
|
||||
{
|
||||
title: "Diagnose and Implement",
|
||||
description:
|
||||
"Work proceeds through a written scope, bounded investigation, implementation, testing, and documented findings.",
|
||||
},
|
||||
{
|
||||
title: "Handoff",
|
||||
description:
|
||||
"Receive the fix or deliverable, root-cause notes, operational guidance, and a concise handoff appropriate to the project.",
|
||||
},
|
||||
],
|
||||
memberAccessCopy: {
|
||||
heading: "Titan Lab Member Access",
|
||||
description:
|
||||
"Titan Lab member accounts are for family, friends, and the lucky few with an invite: register for access or sign in here.",
|
||||
authenticated:
|
||||
"Use the existing member tools for dashboards, account management, onboarding, and approved service access.",
|
||||
restricted:
|
||||
"If access is pending or restricted, use the existing request-access status and onboarding flow. No platform permissions are bypassed here.",
|
||||
},
|
||||
memberServices: [
|
||||
{
|
||||
name: "Keycloak",
|
||||
icon: "🔐",
|
||||
href: "https://sso.bstein.dev",
|
||||
description: "Use one account to sign in across bstein.dev.",
|
||||
},
|
||||
{
|
||||
name: "Nextcloud",
|
||||
icon: "☁️",
|
||||
href: "https://cloud.bstein.dev",
|
||||
description: "Keep files, photos, office docs, and personal cloud data in sync.",
|
||||
},
|
||||
{
|
||||
name: "Mailu",
|
||||
icon: "✉️",
|
||||
href: "https://mail.bstein.dev",
|
||||
description: "Use your bstein.dev mailbox and webmail.",
|
||||
},
|
||||
{
|
||||
name: "Element",
|
||||
icon: "💬",
|
||||
href: "https://live.bstein.dev",
|
||||
description: "Chat in private rooms and join voice or video calls.",
|
||||
},
|
||||
{
|
||||
name: "Vaultwarden",
|
||||
icon: "🔑",
|
||||
href: "https://vault.bstein.dev",
|
||||
description: "Store passwords, secure notes, and shared secrets privately.",
|
||||
},
|
||||
{
|
||||
name: "Outline",
|
||||
icon: "📝",
|
||||
href: "https://notes.bstein.dev",
|
||||
description: "Read and write shared notes, guides, and docs.",
|
||||
},
|
||||
{
|
||||
name: "Planka",
|
||||
icon: "🧭",
|
||||
href: "https://tasks.bstein.dev",
|
||||
description: "Track shared projects and personal task boards.",
|
||||
},
|
||||
{
|
||||
name: "Jellyfin",
|
||||
icon: "🎬",
|
||||
href: "https://stream.bstein.dev",
|
||||
description: "Stream the family media library from your devices.",
|
||||
},
|
||||
{
|
||||
name: "Pegasus",
|
||||
icon: "📤",
|
||||
href: "https://pegasus.bstein.dev",
|
||||
description: "Upload media into the shared streaming library.",
|
||||
},
|
||||
{
|
||||
name: "Wger",
|
||||
icon: "🏋️",
|
||||
href: "https://health.bstein.dev",
|
||||
description: "Track workouts, routines, and nutrition.",
|
||||
},
|
||||
{
|
||||
name: "Actual Budget",
|
||||
icon: "💸",
|
||||
href: "https://budget.bstein.dev",
|
||||
description: "Plan budgets with private, local-first finance tracking.",
|
||||
},
|
||||
{
|
||||
name: "Firefly III",
|
||||
icon: "💵",
|
||||
href: "https://money.bstein.dev",
|
||||
description: "Review accounts, spending, and personal finance history.",
|
||||
},
|
||||
{
|
||||
name: "Gitea",
|
||||
icon: "🍵",
|
||||
href: "https://scm.bstein.dev",
|
||||
description: "Host Git repos and collaborate on code projects.",
|
||||
},
|
||||
{
|
||||
name: "Jenkins",
|
||||
icon: "🏗️",
|
||||
href: "https://ci.bstein.dev",
|
||||
description: "Run builds and automation for lab projects.",
|
||||
},
|
||||
{
|
||||
name: "Harbor",
|
||||
icon: "⚓",
|
||||
href: "https://registry.bstein.dev",
|
||||
description: "Publish and pull container images for projects.",
|
||||
},
|
||||
{
|
||||
name: "Grafana",
|
||||
icon: "📈",
|
||||
href: "https://metrics.bstein.dev",
|
||||
description: "Check dashboards for lab and service health.",
|
||||
},
|
||||
{
|
||||
name: "AI Chat",
|
||||
icon: "🤖",
|
||||
href: "https://bstein.dev/ai/chat",
|
||||
description: "Ask the Titan Lab AI assistant for help.",
|
||||
},
|
||||
{
|
||||
name: "Veles",
|
||||
icon: "🃏",
|
||||
href: "https://veles.bstein.dev/",
|
||||
description: "Test Magic: The Gathering deck ideas with AI playtesting.",
|
||||
},
|
||||
{
|
||||
name: "Monero",
|
||||
icon: "🪙",
|
||||
href: "https://bstein.dev/monero",
|
||||
description: "Use the private RPC endpoint through the Monero guide.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function contactMailto() {
|
||||
const email = homepageContent.primaryContact.email;
|
||||
const subject = encodeURIComponent(homepageContent.primaryContact.subject);
|
||||
return `mailto:${email}?subject=${subject}`;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Return the static Atlas and Oceanus hardware inventory used as fallback data.
|
||||
* Return the static Atlas hardware inventory used as fallback data.
|
||||
*
|
||||
* WHY: the home page needs stable content when live cluster data cannot be
|
||||
* fetched during startup or testing.
|
||||
@ -33,13 +33,13 @@ export function fallbackHardware() {
|
||||
{ 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" },
|
||||
{ name: "titan-23", alias: "oceanus", role: "worker (upcoming AI workload staging)", hardware: "epyc-24c", status: "ready" },
|
||||
{ name: "titan-24", alias: "tethys", role: "worker (utility + metrics)", hardware: "ryzen-3900x", 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" },
|
||||
],
|
||||
};
|
||||
@ -221,27 +221,27 @@ export function fallbackServices() {
|
||||
icon: "🪙",
|
||||
category: "crypto",
|
||||
summary: "Private monero node for monero wallets.",
|
||||
link: "/monero",
|
||||
link: "https://bstein.dev/monero",
|
||||
host: "monerod.crypto.svc.cluster.local:18081",
|
||||
},
|
||||
{
|
||||
name: "Oceanus",
|
||||
icon: "🌊",
|
||||
category: "crypto",
|
||||
summary: "Dedicated SUI Validator - Planned.",
|
||||
link: "#",
|
||||
host: "oceanus",
|
||||
status: "planned",
|
||||
},
|
||||
{
|
||||
name: "AI Chat",
|
||||
icon: "🤖",
|
||||
category: "ai",
|
||||
summary: "Customized LLM for the titan home lab.",
|
||||
link: "/ai/chat",
|
||||
link: "https://bstein.dev/ai/chat",
|
||||
host: "chat.ai.bstein.dev",
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
name: "Veles",
|
||||
icon: "🃏",
|
||||
category: "ai",
|
||||
summary: "Magic: The Gathering AI playtester for deck construction.",
|
||||
link: "https://veles.bstein.dev/",
|
||||
host: "veles.bstein.dev",
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
name: "AI Vision",
|
||||
icon: "👁️",
|
||||
@ -306,7 +306,7 @@ export function fallbackNetwork() {
|
||||
{
|
||||
name: "metrics",
|
||||
path: "Atlas scraping -> Prometheus -> Grafana -> metrics.bstein.dev",
|
||||
notes: "titan-24 scrapes oceanus (titan-23).",
|
||||
notes: "Cluster and service telemetry feed public dashboards where safe.",
|
||||
},
|
||||
],
|
||||
ingress_gateway: "Traefik with oauth2-proxy and Keycloak; Longhorn backs stateful ingress targets.",
|
||||
@ -322,7 +322,7 @@ export function fallbackNetwork() {
|
||||
export function fallbackMetrics() {
|
||||
return {
|
||||
dashboard: "https://metrics.bstein.dev",
|
||||
description: "Atlas + Oceanus metrics.",
|
||||
description: "Atlas platform metrics.",
|
||||
};
|
||||
}
|
||||
|
||||
@ -337,67 +337,38 @@ export function fallbackMetrics() {
|
||||
*/
|
||||
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
|
||||
flowchart LR
|
||||
Users["Members + public visitors"] --> Edge["DNS / TLS / Traefik"]
|
||||
|
||||
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
|
||||
subgraph TitanLab["Titan Lab"]
|
||||
subgraph Atlas["Atlas k3s cluster"]
|
||||
Control["HA control plane"]
|
||||
Workers["service workers"]
|
||||
Storage["Longhorn storage pool"]
|
||||
Heavy["AI / media / utility nodes<br/>Oceanus + Tethys included"]
|
||||
Services["member services<br/>identity / cloud / media / CI / registry / AI"]
|
||||
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"]
|
||||
subgraph Dedicated["Dedicated non-cluster hosts"]
|
||||
DB["database node"]
|
||||
Jump["jumphost / KVM landing"]
|
||||
end
|
||||
|
||||
titanDb -->|DB| titan0a
|
||||
theia -->|ssh| titan0a
|
||||
oceanus -->|metrics| titan24
|
||||
end
|
||||
|
||||
classDef down fill:#311023,stroke:#ff4f93,color:#fff,stroke-width:2px;
|
||||
Edge --> Services
|
||||
Control --> Workers
|
||||
Workers --> Services
|
||||
Services --> Storage
|
||||
Heavy --> Services
|
||||
DB --> Control
|
||||
Jump --> Control
|
||||
|
||||
classDef cluster fill:#0d1a2b,stroke:#4fb3ff,color:#eef6ff;
|
||||
classDef edge fill:#082421,stroke:#00e5c5,color:#effffc;
|
||||
classDef dedicated fill:#21182b,stroke:#b58cff,color:#f8f2ff;
|
||||
class TitanLab,Atlas,Control,Workers,Storage,Heavy,Services cluster;
|
||||
class Users,Edge edge;
|
||||
class Dedicated,DB,Jump dedicated;
|
||||
`;
|
||||
}
|
||||
|
||||
@ -409,25 +380,26 @@ flowchart TB
|
||||
*/
|
||||
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
|
||||
flowchart LR
|
||||
Browser["browser"] --> DNS["DNS + TLS"]
|
||||
DNS --> Traefik["Traefik ingress"]
|
||||
|
||||
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
|
||||
Traefik --> Public["public routes<br/>home / metrics / selected apps"]
|
||||
Traefik --> Auth["auth gate<br/>oauth2-proxy"]
|
||||
Auth --> Keycloak["Keycloak SSO"]
|
||||
Keycloak --> Auth
|
||||
Auth --> Member["member services"]
|
||||
|
||||
Member --> Storage["Longhorn PVCs"]
|
||||
Member --> Logs["logs / metrics"]
|
||||
Logs --> Grafana["Grafana dashboards"]
|
||||
|
||||
classDef public fill:#082421,stroke:#00e5c5,color:#effffc;
|
||||
classDef protected fill:#22172b,stroke:#b58cff,color:#f8f2ff;
|
||||
classDef data fill:#1e2430,stroke:#7aa2ff,color:#eef6ff;
|
||||
class Browser,DNS,Traefik,Public public;
|
||||
class Auth,Keycloak,Member protected;
|
||||
class Storage,Logs,Grafana data;
|
||||
`;
|
||||
}
|
||||
|
||||
@ -440,16 +412,26 @@ sequenceDiagram
|
||||
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]
|
||||
Source["scm.bstein.dev<br/>source of truth"] --> CI["Jenkins<br/>build + test"]
|
||||
CI --> Image["Harbor<br/>image registry"]
|
||||
Image --> GitOps["Flux<br/>reconcile desired state"]
|
||||
GitOps --> Atlas["Atlas<br/>running workloads"]
|
||||
|
||||
keycloak[sso.bstein.dev] --> gitea
|
||||
keycloak --> jenkins
|
||||
keycloak --> harbor
|
||||
keycloak --> flux
|
||||
CI --> Gates["quality gates<br/>tests / lint / reports"]
|
||||
Atlas --> Observe["Prometheus + Grafana<br/>health and evidence"]
|
||||
Observe --> Feedback["fix / tune / automate"]
|
||||
Feedback --> Source
|
||||
|
||||
Keycloak["Keycloak SSO"] --> Source
|
||||
Keycloak --> CI
|
||||
Keycloak --> Image
|
||||
Keycloak --> GitOps
|
||||
|
||||
classDef source fill:#082421,stroke:#00e5c5,color:#effffc;
|
||||
classDef delivery fill:#0d1a2b,stroke:#4fb3ff,color:#eef6ff;
|
||||
classDef guard fill:#22172b,stroke:#b58cff,color:#f8f2ff;
|
||||
class Source,Feedback source;
|
||||
class CI,Image,GitOps,Atlas,Observe delivery;
|
||||
class Gates,Keycloak guard;
|
||||
`;
|
||||
}
|
||||
|
||||
135
frontend/src/member/useMemberAccessState.js
Normal file
135
frontend/src/member/useMemberAccessState.js
Normal file
@ -0,0 +1,135 @@
|
||||
import { computed, reactive, watch } from "vue";
|
||||
import { auth, authFetch } from "../auth";
|
||||
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
loaded: false,
|
||||
status: "",
|
||||
onboardingUrl: "/onboarding",
|
||||
dashboardUrl: "/apps",
|
||||
accountUrl: "/account",
|
||||
accessAllowed: true,
|
||||
error: "",
|
||||
});
|
||||
|
||||
let watcherStarted = false;
|
||||
let requestToken = 0;
|
||||
|
||||
function normalizeInternalUrl(value, fallback) {
|
||||
if (typeof value !== "string" || !value.trim()) return fallback;
|
||||
try {
|
||||
const url = new URL(value, window.location.origin);
|
||||
if (url.origin === window.location.origin) return `${url.pathname}${url.search}${url.hash}`;
|
||||
return value;
|
||||
} catch {
|
||||
return value.startsWith("/") ? value : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
state.loading = false;
|
||||
state.loaded = false;
|
||||
state.status = "";
|
||||
state.onboardingUrl = "/onboarding";
|
||||
state.dashboardUrl = "/apps";
|
||||
state.accountUrl = "/account";
|
||||
state.accessAllowed = true;
|
||||
state.error = "";
|
||||
}
|
||||
|
||||
async function refreshMemberState() {
|
||||
if (!auth.ready || !auth.authenticated) {
|
||||
resetState();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = (requestToken += 1);
|
||||
state.loading = true;
|
||||
state.error = "";
|
||||
|
||||
try {
|
||||
const resp = await authFetch("/api/account/member-state", {
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (token !== requestToken) return;
|
||||
if (resp.status === 403) {
|
||||
state.loaded = true;
|
||||
state.status = "restricted";
|
||||
state.accessAllowed = false;
|
||||
state.onboardingUrl = "/request-access";
|
||||
state.dashboardUrl = "/apps";
|
||||
state.accountUrl = "/account";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
|
||||
state.loaded = true;
|
||||
state.accessAllowed = true;
|
||||
state.status = data.status || "unknown";
|
||||
state.onboardingUrl = normalizeInternalUrl(data.onboarding_url, "/onboarding");
|
||||
state.dashboardUrl = normalizeInternalUrl(data.dashboard_url, "/apps");
|
||||
state.accountUrl = normalizeInternalUrl(data.account_url, "/account");
|
||||
} catch (err) {
|
||||
if (token !== requestToken) return;
|
||||
state.loaded = true;
|
||||
state.status = "unknown";
|
||||
state.accessAllowed = true;
|
||||
state.error = err?.message || "Member state unavailable";
|
||||
} finally {
|
||||
if (token === requestToken) state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startWatcher() {
|
||||
if (watcherStarted) return;
|
||||
watcherStarted = true;
|
||||
watch(
|
||||
() => [auth.ready, auth.authenticated, auth.token],
|
||||
() => {
|
||||
refreshMemberState();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
||||
function memberActionForState() {
|
||||
if (!auth.enabled) return null;
|
||||
if (!auth.ready) {
|
||||
return { kind: "loading", label: "Checking Session", to: "", secondary: "" };
|
||||
}
|
||||
if (!auth.authenticated) {
|
||||
return { kind: "signed-out", label: "Sign In", to: "", secondary: "/request-access" };
|
||||
}
|
||||
if (auth.emailVerified === false) {
|
||||
return {
|
||||
kind: "verify",
|
||||
label: "Continue Verification",
|
||||
to: normalizeInternalUrl(auth.accountUrl, "/request-access"),
|
||||
secondary: state.accountUrl,
|
||||
};
|
||||
}
|
||||
if (state.loading && !state.loaded) {
|
||||
return { kind: "loading", label: "Checking Account", to: "", secondary: state.accountUrl };
|
||||
}
|
||||
if (["pending_email_verification", "pending"].includes(state.status)) {
|
||||
return { kind: "pending", label: "Check Access Status", to: "/request-access", secondary: state.accountUrl };
|
||||
}
|
||||
if (["accounts_building", "awaiting_onboarding"].includes(state.status)) {
|
||||
return { kind: "onboarding", label: "Continue Onboarding", to: state.onboardingUrl, secondary: state.accountUrl };
|
||||
}
|
||||
if (state.status === "restricted" || !state.accessAllowed) {
|
||||
return { kind: "restricted", label: "Account Status", to: "/request-access", secondary: state.accountUrl };
|
||||
}
|
||||
return { kind: "ready", label: "Open Services", to: state.dashboardUrl, secondary: state.accountUrl };
|
||||
}
|
||||
|
||||
export function useMemberAccessState() {
|
||||
startWatcher();
|
||||
return {
|
||||
auth,
|
||||
memberState: state,
|
||||
memberAction: computed(memberActionForState),
|
||||
refreshMemberState,
|
||||
};
|
||||
}
|
||||
@ -7,13 +7,13 @@
|
||||
</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="role">{{ about.role }}</div>
|
||||
<div class="role">{{ about.focusLine }}</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="https://www.linkedin.com/in/steinbradley/" target="_blank" rel="noopener noreferrer">LinkedIn</a>
|
||||
<a href="https://scm.bstein.dev/bstein" target="_blank" rel="noopener noreferrer">Gitea</a>
|
||||
<a href="https://metrics.bstein.dev" target="_blank" rel="noopener noreferrer">Metrics</a>
|
||||
<a href="mailto:brad@bstein.dev">brad@bstein.dev</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -21,22 +21,21 @@
|
||||
<div class="right">
|
||||
<h1>About Me</h1>
|
||||
<div class="copy">
|
||||
<p v-for="paragraph in about.summary.slice(0, 2)" :key="paragraph">{{ paragraph }}</p>
|
||||
<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>.
|
||||
Public platform work is visible in the
|
||||
<a href="https://scm.bstein.dev/bstein/titan-iac" target="_blank" rel="noopener noreferrer">Titan IaC repo</a>,
|
||||
<a href="https://scm.bstein.dev/bstein" target="_blank" rel="noopener noreferrer">scm.bstein.dev</a> and
|
||||
<a href="https://metrics.bstein.dev" target="_blank" rel="noopener noreferrer">metrics.bstein.dev</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="highlights">
|
||||
<div v-for="skill in skills" :key="skill" class="pill mono">{{ skill }}</div>
|
||||
<div class="skill-groups" aria-label="Résumé skill summary">
|
||||
<div v-for="group in about.skillGroups" :key="group.label" class="skill-group">
|
||||
<h2>{{ group.label }}</h2>
|
||||
<div class="highlights">
|
||||
<div v-for="skill in group.items" :key="skill" class="pill mono">{{ skill }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -51,7 +50,13 @@
|
||||
<div class="dot"></div>
|
||||
<div>
|
||||
<div class="entry-title">{{ item.title }}</div>
|
||||
<div class="entry-sub">{{ item.company }} · {{ item.dates }}</div>
|
||||
<div class="entry-sub">
|
||||
<a v-if="item.companyUrl" :href="item.companyUrl" target="_blank" rel="noopener noreferrer">
|
||||
{{ item.company }}
|
||||
</a>
|
||||
<span v-else>{{ item.company }}</span>
|
||||
<span> · {{ item.dates }}</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="point in item.points" :key="point">{{ point }}</li>
|
||||
</ul>
|
||||
@ -60,33 +65,49 @@
|
||||
</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>
|
||||
Web version aligned to <span class="mono">Stein.Brad.Resume.2026.06.pdf</span>. Phone and street address are
|
||||
intentionally omitted from the public site.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Titan Lab</h2>
|
||||
<span class="pill mono">atlas + oceanus</span>
|
||||
<span class="pill mono">atlas platform</span>
|
||||
</div>
|
||||
<div class="copy">
|
||||
<p>
|
||||
The Titan Lab is my 26-node (and growing) 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 services are reconciled by
|
||||
<span class="mono">Flux</span>.
|
||||
Titan Lab is the live platform behind bstein.dev. The core is <span class="mono">Atlas</span>, a GitOps-managed
|
||||
<span class="mono">k3s</span> cluster where services are reconciled by <span class="mono">Flux</span> and operated
|
||||
with the same habits I use professionally: source of truth, quality gates, observability, identity, storage,
|
||||
recovery paths, and documented handoff.
|
||||
</p>
|
||||
<p>
|
||||
<span class="mono">Atlas</span> is my attempt to fully replace all online services that I need or use with self-hosted versions. I care
|
||||
deeply about security both for the lab and the cluster and personally. My first hosted services was <span class="mono">VaultWarden</span>
|
||||
but now the cluster hosts everything from email to ai. In my freetime, I'm always working on <span class="mono">Atlas</span> with small
|
||||
services or organization improvements to make it better and cleaner and cooler.
|
||||
The platform runs identity, source control, CI/CD, container delivery, observability, storage, member services,
|
||||
and experimental AI workloads. It is also where I build automation around bootstrap, recovery, service health,
|
||||
and operator workflows instead of leaving them as manual notes.
|
||||
</p>
|
||||
<p>
|
||||
<span class="mono">Oceanus</span> is an intentionally separated host for validator workloads while still feeding data back into the same
|
||||
observability stack. SUI is a crypto currency I follow and believe in and so <span class="mono">Oceanus</span> is in my lab and is dedicated
|
||||
hardware to extend their infrastructure and make the SUI project more resilient.
|
||||
<span class="mono">Oceanus</span> and <span class="mono">Tethys</span> are now part of Atlas capacity instead of separate public status surfaces.
|
||||
The dedicated non-cluster hosts are the control-plane database node and the jumphost. <span class="mono">Veles</span>,
|
||||
the MTG AI playtester, is deployed at
|
||||
<a href="https://veles.bstein.dev/" target="_blank" rel="noopener noreferrer">veles.bstein.dev</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-head">
|
||||
<h2>Education</h2>
|
||||
<span class="pill mono">UT Austin</span>
|
||||
</div>
|
||||
<div class="copy">
|
||||
<p>
|
||||
Studied Mechanical Engineering and Applied Math at the University of Texas at Austin, including Java, C++,
|
||||
probability, statistics, complex analysis, numerical methods, number theory, and differential equations.
|
||||
</p>
|
||||
<p>
|
||||
Participated in the Emerging Scholars honors calculus program and later served as an assistant for it.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -94,113 +115,94 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const skills = [
|
||||
"Python",
|
||||
"Linux",
|
||||
"Kubernetes (k3s/k8s)",
|
||||
"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",
|
||||
];
|
||||
import { homepageContent } from "../data/homepageContent";
|
||||
|
||||
const about = homepageContent.about;
|
||||
|
||||
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)",
|
||||
title: "Kubernetes Cluster Development",
|
||||
company: "Titan Lab",
|
||||
companyUrl: "https://scm.bstein.dev/bstein/titan-iac",
|
||||
dates: "Apr 2020 – Present",
|
||||
points: [
|
||||
"Operate a mixed arm64/amd64 environment with GitOps (Gitea -> Jenkins -> Harbor -> Flux).",
|
||||
"Centralized identity with Keycloak and front services via Traefik ingress.",
|
||||
"Run tiered Longhorn storage: astreae (system) and asteria (user).",
|
||||
"Build observability with Grafana + VictoriaMetrics and OpenSearch dashboards around real service health and log tracking.",
|
||||
"Audio, video, and text communication on a Matrix-LiveKit-Coturn-Element stack with mobile compatibility and AI chat integration.",
|
||||
"Video streaming of home movies via Jellyfin and instant uploading/publishing with Pegasus.",
|
||||
"Host crypto projects with a XRM node and a SUI validator.",
|
||||
"Has a knowledge base about the cluster itself for AI bot awareness.",
|
||||
"Vault based secret management - no critical information in Kubernetes secrets.",
|
||||
"Created and operate a local k3s cluster with metrics for home and self-hosted services.",
|
||||
"Run personal SCM, CI/CD, password management, mail, cloud storage, movie streaming, video conferencing, AI chatbot, and cluster automation services.",
|
||||
"Build automation around bootstrap, recovery, service health, operator workflows, identity, storage, and dashboards.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "actalent-boeing-ptes",
|
||||
title: "Software Development Engineer in Test",
|
||||
company: "Boeing",
|
||||
companyUrl: "https://www.boeing.com/",
|
||||
dates: "Oct 2023 – Oct 2025",
|
||||
points: [
|
||||
"Consulted through Actalent on a complex communications program at the intersection of test engineering, DevSecOps, and infrastructure automation.",
|
||||
"Created system and end-to-end tests using Python and Selenium to validate microservice and web UI interactions.",
|
||||
"Built TaskWatcher tooling for key capture/decryption and Docker Compose drift protection on dedicated hosts.",
|
||||
"Created Lanterna to visualize a Flux-driven monorepo with Mermaid charts rerun after merged changes.",
|
||||
"Created promotion-pipeline quality gates for a system test suite and microservice release flow.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tradehat-frontend",
|
||||
title: "Frontend Engineer",
|
||||
company: "TradeHat",
|
||||
companyUrl: "https://www.tradehat.com/",
|
||||
dates: "Mar 2020 – Mar 2021",
|
||||
points: [
|
||||
"Worked as one of four engineers building the startup product from scratch.",
|
||||
"Selected Vue.js and Quasar as the frontend foundation for multi-architecture support.",
|
||||
"Helped build the alpha deployment infrastructure using redundant ThinkPads hosted at each engineer's home in Dallas.",
|
||||
"Built reporting and dashboard pages in Vue with Tailwind CSS and FusionCharts.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ibm-sdet",
|
||||
title: "Software Development Engineer in Test",
|
||||
company: "IBM",
|
||||
dates: "Mar 2020 – May 2023",
|
||||
companyUrl: "https://www.ibm.com/cloud",
|
||||
dates: "Nov 2018 – 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.",
|
||||
"Authored and planned automated end-to-end and system tests for IBM Cloud systems in Python, Go, and Terraform.",
|
||||
"Served as a consultant before accepting a full-time role in 2020; documented contributions and hosted feature demos and knowledge transfers.",
|
||||
"Automated platform validation for a remote data center, including RHEL and SUSE functionality, licensing, repository availability, and performance.",
|
||||
"Automated A100 and V100 GPU resource tests for CUDA functionality and data security in accelerated-computing environments.",
|
||||
"Created a Zabbix health dashboard for Cloud APIs and planned key feature testing in Python.",
|
||||
"Created Terraform and Go system tests for microservice interactions and platform scalability/autoscaling.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "unifocus-survey-programmer",
|
||||
title: "Survey Programmer",
|
||||
title: "Lead Survey Programmer",
|
||||
company: "UniFocus",
|
||||
companyUrl: "https://www.unifocus.com/",
|
||||
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.",
|
||||
"Transformed a configuration role into an automation role using Python and SQL for online surveys and reports.",
|
||||
"Served as team lead, recruiting staff and mentoring junior engineers.",
|
||||
"Converted Python automation scripts into a CLI tool and built a VBA-driven Excel workbook for business contacts.",
|
||||
"Automated responsive design updates for web surveys with Python and added JavaScript/CSS mobile support.",
|
||||
"Created a Python XML parser to restore survey data that could otherwise be lost.",
|
||||
"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;
|
||||
max-width: 1260px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 22px 72px;
|
||||
}
|
||||
|
||||
.about {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(220px, 0.48fr) minmax(0, 2fr);
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.portrait {
|
||||
@ -224,8 +226,10 @@ const timeline = [
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.contact .name {
|
||||
@ -235,6 +239,8 @@ const timeline = [
|
||||
|
||||
.contact .role {
|
||||
color: var(--text-muted);
|
||||
max-width: 220px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.contact .meta {
|
||||
@ -270,6 +276,27 @@ const timeline = [
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.skill-groups {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.skill-group {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 160px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 11px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.skill-group h2 {
|
||||
margin: 5px 0 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -292,9 +319,15 @@ const timeline = [
|
||||
|
||||
.highlights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.highlights .pill {
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.note {
|
||||
@ -343,6 +376,15 @@ const timeline = [
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.entry-sub a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.entry-sub a:hover {
|
||||
color: var(--accent-rose);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
@ -353,8 +395,14 @@ ul {
|
||||
.about {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.skill-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
class="pill mono"
|
||||
:href="auth.accountPasswordUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Change password
|
||||
</a>
|
||||
@ -53,7 +53,7 @@
|
||||
<div class="kv">
|
||||
<div class="row">
|
||||
<span class="k mono">URL</span>
|
||||
<a class="v mono link" href="https://money.bstein.dev" target="_blank" rel="noreferrer">
|
||||
<a class="v mono link" href="https://money.bstein.dev" target="_blank" rel="noopener noreferrer">
|
||||
money.bstein.dev
|
||||
</a>
|
||||
</div>
|
||||
@ -223,7 +223,7 @@
|
||||
<div class="kv">
|
||||
<div class="row">
|
||||
<span class="k mono">URL</span>
|
||||
<a class="v mono link" href="https://vault.bstein.dev" target="_blank" rel="noreferrer">vault.bstein.dev</a>
|
||||
<a class="v mono link" href="https://vault.bstein.dev" target="_blank" rel="noopener noreferrer">vault.bstein.dev</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="k mono">Username</span>
|
||||
@ -267,7 +267,7 @@
|
||||
<div class="kv">
|
||||
<div class="row">
|
||||
<span class="k mono">URL</span>
|
||||
<a class="v mono link" href="https://health.bstein.dev" target="_blank" rel="noreferrer">
|
||||
<a class="v mono link" href="https://health.bstein.dev" target="_blank" rel="noopener noreferrer">
|
||||
health.bstein.dev
|
||||
</a>
|
||||
</div>
|
||||
@ -330,7 +330,7 @@
|
||||
<div class="kv">
|
||||
<div class="row">
|
||||
<span class="k mono">URL</span>
|
||||
<a class="v mono link" href="https://stream.bstein.dev" target="_blank" rel="noreferrer">
|
||||
<a class="v mono link" href="https://stream.bstein.dev" target="_blank" rel="noopener noreferrer">
|
||||
stream.bstein.dev
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
class="tile"
|
||||
:href="app.url"
|
||||
:target="app.target"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div class="tile-title">{{ app.name }}</div>
|
||||
<div class="tile-desc">{{ app.description }}</div>
|
||||
@ -154,8 +154,8 @@ const sections = [
|
||||
},
|
||||
{
|
||||
name: "AI Chat",
|
||||
url: "/ai/chat",
|
||||
target: "_self",
|
||||
url: "https://bstein.dev/ai/chat",
|
||||
target: "_blank",
|
||||
description: "Chat with Atlas AI (GPU-accelerated).",
|
||||
},
|
||||
],
|
||||
@ -194,8 +194,8 @@ const sections = [
|
||||
apps: [
|
||||
{
|
||||
name: "Monero Node",
|
||||
url: "/monero",
|
||||
target: "_self",
|
||||
url: "https://bstein.dev/monero",
|
||||
target: "_blank",
|
||||
description: "Faster sync using the Atlas Monero node.",
|
||||
},
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -204,7 +204,7 @@
|
||||
:href="link.href"
|
||||
:title="link.href"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ link.text }}
|
||||
</a>
|
||||
|
||||
@ -3,9 +3,9 @@ import { expect, test } from "../../../frontend/node_modules/@playwright/test/in
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
const jsonResponse = (body) =>
|
||||
const jsonResponse = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
@ -13,14 +13,28 @@ test.beforeEach(async ({ page }) => {
|
||||
const requestUrl = typeof resource === "string" ? resource : resource?.url || "";
|
||||
const url = new URL(requestUrl, window.location.origin);
|
||||
if (url.pathname === "/api/auth/config") {
|
||||
return Promise.resolve(jsonResponse({ enabled: false }));
|
||||
return Promise.resolve(jsonResponse({ enabled: true, reset_url: "https://sso.example.dev/reset" }));
|
||||
}
|
||||
if (url.pathname === "/api/lab/status") {
|
||||
if (window.__LAB_STATUS_FAIL__) {
|
||||
return Promise.resolve(jsonResponse({ error: "status 503" }, 503));
|
||||
}
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
connected: true,
|
||||
atlas: { up: true },
|
||||
oceanus: { up: false },
|
||||
atlas: { up: true, known: true },
|
||||
active_service: { known: true, label: "Nextcloud", url: "https://cloud.bstein.dev", window: "15m" },
|
||||
dedicated_hosts: {
|
||||
known: true,
|
||||
up: true,
|
||||
up_count: 2,
|
||||
total: 2,
|
||||
hosts: [
|
||||
{ label: "Database node", known: true, up: true },
|
||||
{ label: "Jumphost", known: true, up: true },
|
||||
],
|
||||
},
|
||||
checked_at: 1000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -32,7 +46,7 @@ test.beforeEach(async ({ page }) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
body: JSON.stringify({ enabled: true, reset_url: "https://sso.example.dev/reset" }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/lab/status", async (route) => {
|
||||
@ -41,24 +55,68 @@ test.beforeEach(async ({ page }) => {
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
connected: true,
|
||||
atlas: { up: true },
|
||||
oceanus: { up: false },
|
||||
atlas: { up: true, known: true },
|
||||
active_service: { known: true, label: "Nextcloud", url: "https://cloud.bstein.dev", window: "15m" },
|
||||
dedicated_hosts: {
|
||||
known: true,
|
||||
up: true,
|
||||
up_count: 2,
|
||||
total: 2,
|
||||
hosts: [
|
||||
{ label: "Database node", known: true, up: true },
|
||||
{ label: "Jumphost", known: true, up: true },
|
||||
],
|
||||
},
|
||||
checked_at: 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("shows the overview and opens the diagram overlay", async ({ page }) => {
|
||||
test("shows Brad's professional homepage and opens the platform diagram overlay", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Brad Stein" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "DevOps Automation Engineer / Senior SDET" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Discuss a Project" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "View Résumé" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Explore the Live Platform" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Login" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Register" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "From 0 to 100" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Nextcloud", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("2 of 2 responding")).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "Explore the Live Platform" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Titan Lab: Live Platform" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "IaC source" })).toBeVisible();
|
||||
await expect(page.getByText("Live data connected")).toBeVisible();
|
||||
await expect(page.locator(".service-grid .service").filter({ hasText: "Nextcloud" }).first()).toBeVisible();
|
||||
|
||||
const firstCard = page.locator(".mermaid-card").first();
|
||||
await page.locator("details.source-diagrams summary").click();
|
||||
const firstCard = page.locator("details.source-diagrams .mermaid-card").first();
|
||||
await expect(firstCard).toBeVisible();
|
||||
await firstCard.locator(".diagram").click();
|
||||
await expect(page.locator(".overlay")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator(".overlay")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps the homepage usable when public status fails", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.__LAB_STATUS_FAIL__ = true;
|
||||
});
|
||||
await page.route("**/api/lab/status", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "status 503" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Brad Stein" })).toBeVisible();
|
||||
await expect(page.getByText("Live status is temporarily unavailable").first()).toBeVisible();
|
||||
await expect(page.getByText("status 503")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@ -92,6 +92,7 @@ describe("auth helpers", () => {
|
||||
tokenParsed: {
|
||||
preferred_username: "bob",
|
||||
email: "bob@example.dev",
|
||||
email_verified: true,
|
||||
groups: ["/ops"],
|
||||
},
|
||||
init: vi.fn(async () => true),
|
||||
@ -168,6 +169,7 @@ describe("auth helpers", () => {
|
||||
expect(authModule.auth.authenticated).toBe(false);
|
||||
expect(authModule.auth.username).toBe("");
|
||||
expect(authModule.auth.email).toBe("");
|
||||
expect(authModule.auth.emailVerified).toBeNull();
|
||||
expect(authModule.auth.groups).toEqual([]);
|
||||
});
|
||||
|
||||
@ -179,6 +181,7 @@ describe("auth helpers", () => {
|
||||
tokenParsed: {
|
||||
preferred_username: "alice",
|
||||
email: "alice@example.dev",
|
||||
email_verified: true,
|
||||
groups: ["/dev", "/admin"],
|
||||
},
|
||||
init: vi.fn(async () => {
|
||||
@ -239,6 +242,7 @@ describe("auth helpers", () => {
|
||||
expect(authModule.auth.authenticated).toBe(true);
|
||||
expect(authModule.auth.username).toBe("alice");
|
||||
expect(authModule.auth.email).toBe("alice@example.dev");
|
||||
expect(authModule.auth.emailVerified).toBe(true);
|
||||
expect(authModule.auth.groups).toEqual(["dev", "admin"]);
|
||||
expect(authModule.auth.token).toBe("mock-token");
|
||||
});
|
||||
@ -251,6 +255,7 @@ describe("auth helpers", () => {
|
||||
tokenParsed: {
|
||||
preferred_username: "carol",
|
||||
email: "carol@example.dev",
|
||||
email_verified: false,
|
||||
groups: ["/ops"],
|
||||
},
|
||||
init: vi.fn(async () => true),
|
||||
@ -315,12 +320,19 @@ describe("auth helpers", () => {
|
||||
await authModule.authFetch("/api/healthz");
|
||||
|
||||
expect(authModule.auth.username).toBe("carol");
|
||||
expect(authModule.auth.emailVerified).toBe(false);
|
||||
expect(authModule.auth.groups).toEqual(["ops"]);
|
||||
expect(client.updateToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves auth alone when login/logout are called before initialization", async () => {
|
||||
const authModule = await loadAuth();
|
||||
const assign = vi.fn();
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...window.location, assign },
|
||||
});
|
||||
authModule.auth.loginUrl = "https://sso.example.dev/login";
|
||||
const fetchMock = vi.fn(async () => new Response("{}", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
@ -333,6 +345,7 @@ describe("auth helpers", () => {
|
||||
const headers = new Headers(options.headers);
|
||||
expect(headers.get("X-Test")).toBe("1");
|
||||
expect(headers.get("Authorization")).toBeNull();
|
||||
expect(assign).toHaveBeenCalledWith("https://sso.example.dev/login");
|
||||
});
|
||||
|
||||
it("recovers when the auth config endpoint fails", async () => {
|
||||
|
||||
@ -11,7 +11,7 @@ describe("shared dashboard components", () => {
|
||||
const wrapper = shallowMount(MetricRow, {
|
||||
props: {
|
||||
items: [
|
||||
{ label: "Nodes", value: "26", note: "atlas + oceanus" },
|
||||
{ label: "Nodes", value: "26", note: "atlas platform" },
|
||||
{ label: "Storage", value: "80 TB", note: "Longhorn" },
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,80 +1,160 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { shallowMount } from "@vue/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals";
|
||||
import { RouterLinkStub, flushPromises, shallowMount } from "@vue/test-utils";
|
||||
|
||||
import PlatformSection from "../../../frontend/src/components/PlatformSection.vue";
|
||||
import { auth } from "../../../frontend/src/auth.js";
|
||||
import HomeView from "../../../frontend/src/views/HomeView.vue";
|
||||
|
||||
function resetAuth() {
|
||||
auth.ready = true;
|
||||
auth.enabled = true;
|
||||
auth.authenticated = false;
|
||||
auth.username = "";
|
||||
auth.email = "";
|
||||
auth.emailVerified = null;
|
||||
auth.groups = [];
|
||||
auth.resetUrl = "https://sso.example.dev/reset";
|
||||
}
|
||||
|
||||
describe("HomeView", () => {
|
||||
it("adds fallback icons and builds diagrams for the overview page", () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn(async (resource) => {
|
||||
const url = typeof resource === "string" ? resource : resource?.url || "";
|
||||
if (url.includes("/api/account/member-state")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: "ready",
|
||||
onboarding_url: "/onboarding?code=ada~CODE",
|
||||
dashboard_url: "/apps",
|
||||
account_url: "/account",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetAuth();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders the professional homepage while logged out", () => {
|
||||
resetAuth();
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
MetricRow: true,
|
||||
ServiceGrid: true,
|
||||
MermaidCard: true,
|
||||
RouterLink: RouterLinkStub,
|
||||
PlatformSection: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
serviceData: {
|
||||
services: [
|
||||
{ name: "Nextcloud", summary: "Storage", link: "https://cloud.example.dev" },
|
||||
{ name: "Jellyfin", summary: "Media", link: "https://stream.example.dev" },
|
||||
{ name: "Matrix (Synapse)", summary: "Chat", link: "https://chat.example.dev" },
|
||||
{ name: "Element", summary: "Rooms", link: "https://rooms.example.dev" },
|
||||
{ name: "LiveKit", summary: "Calls", link: "https://calls.example.dev" },
|
||||
{ name: "Coturn", summary: "TURN", link: "https://turn.example.dev" },
|
||||
{ name: "Mailu", summary: "Mail", link: "https://mail.example.dev" },
|
||||
{ name: "Vaultwarden", summary: "Passwords", link: "https://vault.example.dev" },
|
||||
{ name: "Vault", summary: "Secrets", link: "https://secret.example.dev" },
|
||||
{ name: "Gitea", summary: "Git", link: "https://scm.example.dev" },
|
||||
{ name: "Jenkins", summary: "CI", link: "https://ci.example.dev" },
|
||||
{ name: "Harbor", summary: "Registry", link: "https://registry.example.dev" },
|
||||
{ name: "Flux", summary: "GitOps", link: "https://cd.example.dev" },
|
||||
{ name: "Monero", summary: "Node", link: "https://monero.example.dev" },
|
||||
{ name: "SUI Validator", summary: "Crypto", link: "https://sui.example.dev" },
|
||||
{ name: "Keycloak", summary: "SSO", link: "https://sso.example.dev" },
|
||||
{ name: "AI Translation", summary: "Translate", link: "https://translate.example.dev" },
|
||||
{ name: "Grafana", summary: "Metrics", link: "https://metrics.example.dev" },
|
||||
{ name: "Pegasus", summary: "Uploads", link: "https://pegasus.example.dev" },
|
||||
{ name: "AI Chat", summary: "Chat", link: "https://chat.example.dev" },
|
||||
{ name: "AI Vision", summary: "Vision", link: "https://vision.example.dev" },
|
||||
{ name: "AI Speech", summary: "Speech", link: "https://speech.example.dev" },
|
||||
{ name: "Mystery", summary: "Default", link: "https://default.example.dev" },
|
||||
],
|
||||
labStatus: {
|
||||
connected: true,
|
||||
atlas: { up: true, known: true },
|
||||
active_service: { known: true, label: "Nextcloud", url: "https://cloud.example.dev", window: "15m" },
|
||||
dedicated_hosts: {
|
||||
known: true,
|
||||
up: true,
|
||||
up_count: 2,
|
||||
total: 2,
|
||||
hosts: [
|
||||
{ label: "Database node", known: true, up: true },
|
||||
{ label: "Jumphost", known: true, up: true },
|
||||
],
|
||||
},
|
||||
checked_at: 1000,
|
||||
},
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
const icons = wrapper.vm.displayServices.map((service) => service.icon);
|
||||
expect(icons).toContain("☁️");
|
||||
expect(icons).toContain("🎞️");
|
||||
expect(icons).toContain("🗨️");
|
||||
expect(icons).toContain("🧩");
|
||||
expect(icons).toContain("🎥");
|
||||
expect(icons).toContain("📞");
|
||||
expect(icons).toContain("📮");
|
||||
expect(icons).toContain("🔒");
|
||||
expect(icons).toContain("🔑");
|
||||
expect(icons).toContain("📚");
|
||||
expect(icons).toContain("🧰");
|
||||
expect(icons).toContain("📦");
|
||||
expect(icons).toContain("🔄");
|
||||
expect(icons).toContain("⛏️");
|
||||
expect(icons).toContain("💠");
|
||||
expect(icons).toContain("🛡️");
|
||||
expect(icons).toContain("🌐");
|
||||
expect(icons).toContain("📈");
|
||||
expect(icons).toContain("🚀");
|
||||
expect(icons).toContain("💬");
|
||||
expect(icons).toContain("🖼️");
|
||||
expect(icons).toContain("🎙️");
|
||||
expect(icons).toContain("🛰️");
|
||||
expect(wrapper.vm.hardwareDiagram).toContain("Titan Lab");
|
||||
expect(wrapper.vm.networkDiagram).toContain("oauth2-proxy");
|
||||
expect(wrapper.vm.pipelineDiagram).toContain("flux[cd.bstein.dev]");
|
||||
expect(wrapper.text()).toContain("Brad Stein");
|
||||
expect(wrapper.text()).toContain("DevOps Automation Engineer / Senior SDET");
|
||||
expect(wrapper.text()).toContain("Discuss a Project");
|
||||
expect(wrapper.text()).toContain("View Résumé");
|
||||
expect(wrapper.text()).toContain("Explore the Live Platform");
|
||||
expect(wrapper.text()).toContain("From 0 to 100");
|
||||
expect(wrapper.text()).toContain("Platform Showcase");
|
||||
expect(wrapper.text()).toContain("Atlas operator tools");
|
||||
expect(wrapper.text()).toContain("Veles");
|
||||
expect(wrapper.text()).not.toContain("TaskWatcher");
|
||||
expect(wrapper.text()).toContain("family, friends, and the lucky few");
|
||||
expect(wrapper.text()).toContain("Register");
|
||||
expect(wrapper.text()).toContain("Request Lab Access");
|
||||
expect(wrapper.find("[aria-label='Keycloak: Use one account to sign in across bstein.dev.']").exists()).toBe(true);
|
||||
expect(wrapper.find("[title='Nextcloud: Keep files, photos, office docs, and personal cloud data in sync.']").exists()).toBe(true);
|
||||
expect(wrapper.find("a[href='https://bstein.dev/ai/chat']").exists()).toBe(true);
|
||||
expect(wrapper.find("a[href='https://bstein.dev/monero']").exists()).toBe(true);
|
||||
expect(wrapper.find("a[href='https://chat.ai.bstein.dev']").exists()).toBe(false);
|
||||
expect(wrapper.find("a[href='https://monero.bstein.dev:443']").exists()).toBe(false);
|
||||
expect(wrapper.find("a[href='/ai/chat']").exists()).toBe(false);
|
||||
expect(wrapper.find("a[href='/monero']").exists()).toBe(false);
|
||||
expect(wrapper.text()).not.toContain("Existing registration, verification, onboarding");
|
||||
expect(wrapper.text()).not.toContain("Looking for hosted services or an existing Titan Lab account");
|
||||
expect(wrapper.find("a[href^='mailto:brad@bstein.dev']").exists()).toBe(true);
|
||||
expect(wrapper.findComponent(PlatformSection).exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain("Atlas platform");
|
||||
expect(wrapper.text()).toContain("Most active service");
|
||||
expect(wrapper.text()).toContain("Nextcloud");
|
||||
expect(wrapper.text()).toContain("Dedicated hosts");
|
||||
expect(wrapper.text()).toContain("2 of 2 responding");
|
||||
expect(wrapper.text()).toContain("Responding");
|
||||
});
|
||||
|
||||
it("renders loading, error, and healthy status states", () => {
|
||||
it("shows a calm fallback when public status is unavailable", () => {
|
||||
resetAuth();
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
PlatformSection: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
labStatus: null,
|
||||
loading: false,
|
||||
error: "Live data unavailable",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain("Live status is temporarily unavailable");
|
||||
expect(wrapper.text()).not.toContain("status 500");
|
||||
});
|
||||
|
||||
it("shows authenticated member actions without exposing service secrets", async () => {
|
||||
resetAuth();
|
||||
auth.authenticated = true;
|
||||
auth.username = "ada";
|
||||
auth.email = "ada@example.dev";
|
||||
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: RouterLinkStub,
|
||||
PlatformSection: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
labStatus: { connected: true, atlas: { up: true, known: true } },
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain("Open Services");
|
||||
expect(wrapper.text()).toContain("Account");
|
||||
expect(wrapper.text()).not.toContain("app_password");
|
||||
expect(wrapper.text()).not.toContain("mailu_app_password");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlatformSection", () => {
|
||||
it("preserves platform service, status, metric, and diagram behavior", () => {
|
||||
const wrapper = shallowMount(PlatformSection, {
|
||||
global: {
|
||||
stubs: {
|
||||
MetricRow: true,
|
||||
@ -83,21 +163,49 @@ describe("HomeView", () => {
|
||||
},
|
||||
},
|
||||
props: {
|
||||
labStatus: { connected: true, atlas: { up: true }, oceanus: { up: true } },
|
||||
serviceData: undefined,
|
||||
labStatus: {
|
||||
connected: true,
|
||||
atlas: { up: true, known: true },
|
||||
dedicated_hosts: {
|
||||
known: true,
|
||||
up: false,
|
||||
up_count: 1,
|
||||
total: 2,
|
||||
hosts: [
|
||||
{ label: "Database node", known: true, up: true },
|
||||
{ label: "Jumphost", known: true, up: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
serviceData: {
|
||||
services: [
|
||||
{ name: "Nextcloud", summary: "Storage", link: "https://cloud.example.dev" },
|
||||
{ name: "Monero", summary: "Node", link: "/monero", host: "monerod.crypto.svc.cluster.local:18081" },
|
||||
{ name: "Mystery", summary: "Default", link: "https://default.example.dev" },
|
||||
],
|
||||
},
|
||||
loading: true,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.vm.atlasPillClass).toBe("pill-ok");
|
||||
expect(wrapper.vm.oceanusPillClass).toBe("pill-ok");
|
||||
expect(wrapper.vm.dedicatedHostsPillClass).toBe("pill-bad");
|
||||
expect(wrapper.vm.dedicatedHostsLabel).toBe("1/2 responding");
|
||||
expect(wrapper.vm.dedicatedHostItems.map((host) => host.value)).toEqual(["Responding", "Needs attention"]);
|
||||
expect(wrapper.get(".status").text()).toBe("Loading...");
|
||||
expect(wrapper.vm.displayServices.length).toBeGreaterThan(0);
|
||||
expect(wrapper.vm.displayServices.map((service) => service.icon)).toEqual(["NC", "XM", "TL"]);
|
||||
expect(wrapper.vm.displayServices.find((service) => service.name === "Monero").host).toBe("/monero");
|
||||
expect(wrapper.vm.hardwareDiagram).toContain("Titan Lab");
|
||||
expect(wrapper.vm.hardwareDiagram).toContain("Dedicated non-cluster hosts");
|
||||
expect(wrapper.vm.networkDiagram).toContain("oauth2-proxy");
|
||||
expect(wrapper.vm.pipelineDiagram).toContain("Flux");
|
||||
expect(wrapper.text()).toContain("IaC source");
|
||||
expect(wrapper.text()).toContain("Titan Lab: Live Platform");
|
||||
});
|
||||
|
||||
it("shows the error state when lab data fetches fail", () => {
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
it("shows public status errors without raw API detail", () => {
|
||||
const wrapper = shallowMount(PlatformSection, {
|
||||
global: {
|
||||
stubs: {
|
||||
MetricRow: true,
|
||||
@ -106,39 +214,17 @@ describe("HomeView", () => {
|
||||
},
|
||||
},
|
||||
props: {
|
||||
labStatus: { connected: false, atlas: { up: false }, oceanus: { up: false } },
|
||||
labStatus: { connected: false, atlas: { up: false, known: false } },
|
||||
serviceData: { services: [] },
|
||||
metricsData: { items: [{ label: "Custom", value: "1", note: "" }] },
|
||||
loading: false,
|
||||
error: "unable to load status",
|
||||
error: "Live data unavailable",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.vm.atlasPillClass).toBe("pill-bad");
|
||||
expect(wrapper.vm.oceanusPillClass).toBe("pill-bad");
|
||||
expect(wrapper.get(".status").text()).toBe("unable to load status");
|
||||
});
|
||||
|
||||
it("shows live data unavailable and respects custom metric items", () => {
|
||||
const wrapper = shallowMount(HomeView, {
|
||||
global: {
|
||||
stubs: {
|
||||
MetricRow: true,
|
||||
ServiceGrid: true,
|
||||
MermaidCard: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
labStatus: { connected: false, atlas: { up: false }, oceanus: { up: false } },
|
||||
serviceData: { services: [] },
|
||||
metricsData: {
|
||||
items: [{ label: "Custom", value: "1", note: "" }],
|
||||
},
|
||||
loading: false,
|
||||
error: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.get(".status").text()).toBe("Live data unavailable");
|
||||
expect(wrapper.vm.metricItems[0].note).toBe("");
|
||||
expect(wrapper.text()).toContain("The platform overview remains available below");
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,20 +16,26 @@ describe("sample data builders", () => {
|
||||
const services = fallbackServices();
|
||||
|
||||
expect(hardware.clusters[0].name).toBe("atlas");
|
||||
expect(hardware.specialty.map((node) => node.alias)).toContain("oceanus");
|
||||
expect(hardware.clusters[0].nodes.map((node) => node.alias)).toContain("oceanus");
|
||||
expect(hardware.specialty.map((node) => node.alias)).toEqual(["atlas-db", "theia"]);
|
||||
expect(services.services.some((service) => service.name === "Keycloak")).toBe(true);
|
||||
expect(services.services.some((service) => service.name === "Oceanus")).toBe(false);
|
||||
expect(services.services.some((service) => service.name === "Veles")).toBe(true);
|
||||
expect(services.services.some((service) => service.name === "AI Chat")).toBe(true);
|
||||
expect(services.services.find((service) => service.name === "AI Chat").link).toBe("https://bstein.dev/ai/chat");
|
||||
expect(services.services.find((service) => service.name === "Monero").link).toBe("https://bstein.dev/monero");
|
||||
});
|
||||
|
||||
it("builds the rendered diagrams and network summary", () => {
|
||||
expect(buildHardwareDiagram({})).toContain("Titan Lab");
|
||||
expect(buildHardwareDiagram({})).toContain("titan-0a");
|
||||
expect(buildHardwareDiagram({})).toContain("Atlas k3s cluster");
|
||||
expect(buildHardwareDiagram({})).toContain("Dedicated non-cluster hosts");
|
||||
expect(buildNetworkDiagram()).toContain("oauth2-proxy");
|
||||
expect(buildPipelineDiagram()).toContain("flux[cd.bstein.dev]");
|
||||
expect(buildPipelineDiagram()).toContain("Flux");
|
||||
expect(fallbackNetwork().ingress_gateway).toContain("Traefik");
|
||||
expect(fallbackMetrics()).toEqual({
|
||||
dashboard: "https://metrics.bstein.dev",
|
||||
description: "Atlas + Oceanus metrics.",
|
||||
description: "Atlas platform metrics.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,8 +13,6 @@ import AiPlanView from "../../../frontend/src/views/AiPlanView.vue";
|
||||
import AppsView from "../../../frontend/src/views/AppsView.vue";
|
||||
import MoneroView from "../../../frontend/src/views/MoneroView.vue";
|
||||
|
||||
const mockRouterPush = jest.fn();
|
||||
|
||||
jest.mock("axios", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
@ -26,17 +24,17 @@ jest.mock("vue-router", () => ({
|
||||
RouterLink: {
|
||||
name: "RouterLink",
|
||||
props: ["to"],
|
||||
template: "<a><slot /></a>",
|
||||
template: "<a :href=\"typeof to === 'string' ? to : '#'\" @click=\"$emit('click', $event)\"><slot /></a>",
|
||||
},
|
||||
useRouter: () => ({ push: mockRouterPush }),
|
||||
}), { virtual: true });
|
||||
|
||||
describe("static shell views and components", () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.useRealTimers();
|
||||
mockRouterPush.mockClear();
|
||||
document.body.style.overflow = "";
|
||||
auth.enabled = false;
|
||||
auth.ready = false;
|
||||
auth.authenticated = false;
|
||||
auth.resetUrl = "";
|
||||
});
|
||||
@ -45,12 +43,27 @@ describe("static shell views and components", () => {
|
||||
const about = shallowMount(AboutView);
|
||||
expect(about.text()).toContain("About Me");
|
||||
expect(about.text()).toContain("Titan Lab");
|
||||
expect(about.vm.skills).toContain("Kubernetes (k3s/k8s)");
|
||||
expect(about.text()).toContain("DevOps Automation Engineer / Senior SDET");
|
||||
expect(about.text()).toContain("Kubernetes");
|
||||
expect(about.text()).toContain("IBM Cloud");
|
||||
expect(about.text()).toContain("TradeHat");
|
||||
expect(about.find("a[href='https://www.tradehat.com/']").exists()).toBe(true);
|
||||
expect(about.find("a[href='https://www.unifocus.com/']").exists()).toBe(true);
|
||||
expect(about.find("a[href='https://scm.bstein.dev/bstein/titan-iac']").exists()).toBe(true);
|
||||
|
||||
const apps = shallowMount(AppsView);
|
||||
expect(apps.text()).toContain("Apps");
|
||||
expect(apps.text()).toContain("Nextcloud");
|
||||
expect(apps.vm.sections.map((section) => section.title)).toContain("Security");
|
||||
const appLinks = apps.vm.sections.flatMap((section) => section.groups.flatMap((group) => group.apps));
|
||||
expect(appLinks.find((app) => app.name === "AI Chat")).toMatchObject({
|
||||
url: "https://bstein.dev/ai/chat",
|
||||
target: "_blank",
|
||||
});
|
||||
expect(appLinks.find((app) => app.name === "Monero Node")).toMatchObject({
|
||||
url: "https://bstein.dev/monero",
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
const aiPlan = shallowMount(AiPlanView);
|
||||
expect(aiPlan.text()).toContain("Roadmap");
|
||||
@ -92,25 +105,41 @@ describe("static shell views and components", () => {
|
||||
});
|
||||
|
||||
it("drives top bar navigation and auth state rendering", async () => {
|
||||
global.fetch = jest.fn(async (resource) => {
|
||||
const url = typeof resource === "string" ? resource : resource?.url || "";
|
||||
if (url.includes("/api/account/member-state")) {
|
||||
return new Response(JSON.stringify({ status: "ready", dashboard_url: "/apps", account_url: "/account" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
});
|
||||
auth.enabled = true;
|
||||
auth.ready = true;
|
||||
auth.authenticated = false;
|
||||
auth.resetUrl = "https://sso.example.dev/reset";
|
||||
const login = jest.spyOn(await import("../../../frontend/src/auth.js"), "login").mockImplementation(() => {});
|
||||
const logout = jest.spyOn(await import("../../../frontend/src/auth.js"), "logout").mockImplementation(() => {});
|
||||
const authModule = await import("../../../frontend/src/auth.js");
|
||||
const logout = jest.spyOn(authModule, "logout").mockImplementation(() => {});
|
||||
jest.spyOn(authModule, "login").mockImplementation(() => {});
|
||||
|
||||
const wrapper = mount(TopBar);
|
||||
expect(wrapper.text()).toContain("Contact");
|
||||
expect(wrapper.text()).toContain("Login");
|
||||
expect(wrapper.text()).toContain("Request Access");
|
||||
await wrapper.find(".profile").trigger("click");
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/about");
|
||||
expect(wrapper.text()).toContain("Register");
|
||||
expect(wrapper.text()).not.toContain("Reset Password");
|
||||
expect(wrapper.find("a[href='/#contact']").exists()).toBe(true);
|
||||
|
||||
await wrapper.find("button").trigger("click");
|
||||
expect(login).toHaveBeenCalled();
|
||||
await wrapper.find("button.menu-toggle").trigger("click");
|
||||
expect(wrapper.find("nav").classes()).toContain("open");
|
||||
await wrapper.find("a[href='/#contact']").trigger("click");
|
||||
expect(wrapper.find("nav").classes()).not.toContain("open");
|
||||
|
||||
auth.authenticated = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain("Account");
|
||||
await wrapper.find("button").trigger("click");
|
||||
expect(wrapper.text()).toContain("Open Services");
|
||||
await wrapper.findAll("button.button").at(-1).trigger("click");
|
||||
expect(logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user