From 0437bfe9b3eb01e9c3c3b836c71892a631b8d390 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 29 Jun 2026 13:23:07 -0300 Subject: [PATCH] Refresh professional homepage --- .../atlas_portal/routes/account_overview.py | 59 + backend/atlas_portal/routes/auth_config.py | 14 +- backend/atlas_portal/routes/lab.py | 134 +- backend/atlas_portal/settings.py | 15 + backend/tests/test_account_overview.py | 29 +- backend/tests/test_auth_config.py | 6 +- backend/tests/test_lab_routes.py | 64 +- frontend/index.html | 41 +- frontend/public/og-bstein-dev.svg | 26 + frontend/src/App.vue | 20 +- frontend/src/auth.js | 7 +- frontend/src/components/HeroSection.vue | 10 +- frontend/src/components/MermaidCard.vue | 41 +- frontend/src/components/MetricsPanel.vue | 6 +- frontend/src/components/PlatformSection.vue | 918 +++++++++++ frontend/src/components/ServiceGrid.vue | 27 +- frontend/src/components/StatsGrid.vue | 4 +- frontend/src/components/TopBar.vue | 295 +++- frontend/src/data/homepageContent.js | 803 ++++++++++ frontend/src/data/sample.js | 180 +-- frontend/src/member/useMemberAccessState.js | 135 ++ frontend/src/views/AboutView.vue | 266 ++-- frontend/src/views/AccountView.vue | 10 +- frontend/src/views/AppsView.vue | 10 +- frontend/src/views/HomeView.vue | 1371 ++++++++++++++--- frontend/src/views/OnboardingView.vue | 2 +- testing/frontend/e2e/home.spec.js | 80 +- testing/frontend/unit/auth.spec.js | 13 + testing/frontend/unit/components.spec.js | 2 +- testing/frontend/unit/home.spec.js | 270 ++-- testing/frontend/unit/sample.spec.js | 14 +- testing/frontend/unit/static-views.spec.js | 59 +- 32 files changed, 4294 insertions(+), 637 deletions(-) create mode 100644 frontend/public/og-bstein-dev.svg create mode 100644 frontend/src/components/PlatformSection.vue create mode 100644 frontend/src/data/homepageContent.js create mode 100644 frontend/src/member/useMemberAccessState.js diff --git a/backend/atlas_portal/routes/account_overview.py b/backend/atlas_portal/routes/account_overview.py index b89389d..74d3419 100644 --- a/backend/atlas_portal/routes/account_overview.py +++ b/backend/atlas_portal/routes/account_overview.py @@ -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: diff --git a/backend/atlas_portal/routes/auth_config.py b/backend/atlas_portal/routes/auth_config.py index ec02f90..337e4e7 100644 --- a/backend/atlas_portal/routes/auth_config.py +++ b/backend/atlas_portal/routes/auth_config.py @@ -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, diff --git a/backend/atlas_portal/routes/lab.py b/backend/atlas_portal/routes/lab.py index b03dd5c..40d0a23 100644 --- a/backend/atlas_portal/routes/lab.py +++ b/backend/atlas_portal/routes/lab.py @@ -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, } diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index d43eeda..ce5a6e4 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -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") diff --git a/backend/tests/test_account_overview.py b/backend/tests/test_account_overview.py index bd52e43..1afaa3e 100644 --- a/backend/tests/test_account_overview.py +++ b/backend/tests/test_account_overview.py @@ -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} diff --git a/backend/tests/test_auth_config.py b/backend/tests/test_auth_config.py index 6ebd375..582b253 100644 --- a/backend/tests/test_auth_config.py +++ b/backend/tests/test_auth_config.py @@ -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: diff --git a/backend/tests/test_lab_routes.py b/backend/tests/test_lab_routes.py index 21ba1cf..912e73f 100644 --- a/backend/tests/test_lab_routes.py +++ b/backend/tests/test_lab_routes.py @@ -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 diff --git a/frontend/index.html b/frontend/index.html index c76838c..529f12a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,12 +3,49 @@ - bstein.dev | Titan Lab + Brad Stein | SDET, DevOps Automation & Platform Engineering + + + + + + + + + + +
diff --git a/frontend/public/og-bstein-dev.svg b/frontend/public/og-bstein-dev.svg new file mode 100644 index 0000000..cb873e7 --- /dev/null +++ b/frontend/public/og-bstein-dev.svg @@ -0,0 +1,26 @@ + + Brad Stein, DevOps Automation Engineer and Senior SDET + A dark bstein.dev social card with professional engineering positioning and Titan Lab platform proof. + + + + + + + + + + + + + + + + + + BSTEIN.DEV + Brad Stein + DevOps Automation Engineer / Senior SDET + CI/CD · Kubernetes · Python · Linux · Observability + Titan Lab is the live platform proof behind the work. + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 11b99a2..aeea30d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,15 +1,17 @@ diff --git a/frontend/src/auth.js b/frontend/src/auth.js index 84a5978..c9d2ce8 100644 --- a/frontend/src/auth.js +++ b/frontend/src/auth.js @@ -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()) { diff --git a/frontend/src/components/HeroSection.vue b/frontend/src/components/HeroSection.vue index 16f8a28..18203a4 100644 --- a/frontend/src/components/HeroSection.vue +++ b/frontend/src/components/HeroSection.vue @@ -2,12 +2,12 @@
Titan Lab - atlas · oceanus · nextcloud-ready + atlas · member services · nextcloud-ready

{{ title }}

{{ subtitle }}

@@ -23,9 +23,9 @@ Flux + Longhorn + Traefik
-
Oceanus
-
validator
- Scraped via titan-24 into Grafana +
Member services
+
identity + apps
+ Request access · onboarding · approved services
Ingress
diff --git a/frontend/src/components/MermaidCard.vue b/frontend/src/components/MermaidCard.vue index 88921c5..9517d08 100644 --- a/frontend/src/components/MermaidCard.vue +++ b/frontend/src/components/MermaidCard.vue @@ -26,7 +26,6 @@ + + diff --git a/frontend/src/components/ServiceGrid.vue b/frontend/src/components/ServiceGrid.vue index 95b6d7c..3e65a27 100644 --- a/frontend/src/components/ServiceGrid.vue +++ b/frontend/src/components/ServiceGrid.vue @@ -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" >
{{ svc.icon || "🛰️" }}
@@ -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) { diff --git a/frontend/src/components/StatsGrid.vue b/frontend/src/components/StatsGrid.vue index 5cd9d55..613e846 100644 --- a/frontend/src/components/StatsGrid.vue +++ b/frontend/src/components/StatsGrid.vue @@ -21,9 +21,9 @@

Jetson pair for AI, GPU mini-pc titan-22 for Jellyfin.

-
Specialty nodes
+
Dedicated hosts
{{ specialty.length }}
-

Oceanus (validator), Tethys bridge, and Bastion.

+

Database node and jumphost kept outside the Atlas cluster.

Storage fabric
diff --git a/frontend/src/components/TopBar.vue b/frontend/src/components/TopBar.vue index 0aa06af..a88e904 100644 --- a/frontend/src/components/TopBar.vue +++ b/frontend/src/components/TopBar.vue @@ -1,64 +1,192 @@ diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index faf578f..270c998 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -16,7 +16,7 @@ class="pill mono" :href="auth.accountPasswordUrl" target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > Change password @@ -53,7 +53,7 @@
@@ -223,7 +223,7 @@
Username @@ -267,7 +267,7 @@
@@ -330,7 +330,7 @@
diff --git a/frontend/src/views/AppsView.vue b/frontend/src/views/AppsView.vue index 1437242..d96fcff 100644 --- a/frontend/src/views/AppsView.vue +++ b/frontend/src/views/AppsView.vue @@ -27,7 +27,7 @@ class="tile" :href="app.url" :target="app.target" - rel="noreferrer" + rel="noopener noreferrer" >
{{ app.name }}
{{ app.description }}
@@ -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.", }, ], diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 5d7ceb2..d918688 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,89 +1,296 @@ diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index a5f117c..f0f1bd8 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -204,7 +204,7 @@ :href="link.href" :title="link.href" target="_blank" - rel="noreferrer" + rel="noopener noreferrer" > {{ link.text }} diff --git a/testing/frontend/e2e/home.spec.js b/testing/frontend/e2e/home.spec.js index 2074700..b71e9d2 100644 --- a/testing/frontend/e2e/home.spec.js +++ b/testing/frontend/e2e/home.spec.js @@ -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); +}); diff --git a/testing/frontend/unit/auth.spec.js b/testing/frontend/unit/auth.spec.js index e6af415..0771691 100644 --- a/testing/frontend/unit/auth.spec.js +++ b/testing/frontend/unit/auth.spec.js @@ -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 () => { diff --git a/testing/frontend/unit/components.spec.js b/testing/frontend/unit/components.spec.js index 9f95548..4dcdc7d 100644 --- a/testing/frontend/unit/components.spec.js +++ b/testing/frontend/unit/components.spec.js @@ -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" }, ], }, diff --git a/testing/frontend/unit/home.spec.js b/testing/frontend/unit/home.spec.js index cd660e6..31f25dc 100644 --- a/testing/frontend/unit/home.spec.js +++ b/testing/frontend/unit/home.spec.js @@ -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"); }); }); diff --git a/testing/frontend/unit/sample.spec.js b/testing/frontend/unit/sample.spec.js index 621f318..6e9e2a2 100644 --- a/testing/frontend/unit/sample.spec.js +++ b/testing/frontend/unit/sample.spec.js @@ -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.", }); }); }); diff --git a/testing/frontend/unit/static-views.spec.js b/testing/frontend/unit/static-views.spec.js index 5b059bd..e73e1ba 100644 --- a/testing/frontend/unit/static-views.spec.js +++ b/testing/frontend/unit/static-views.spec.js @@ -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: "", + template: "", }, - 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(); });