265 lines
7.2 KiB
Vue

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