265 lines
7.2 KiB
Vue
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>
|