(chore) init commit and add tasks

This commit is contained in:
Story Crater Bot
2026-08-18 18:33:49 -07:00
commit 6c6218ef36
61 changed files with 9851 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
{
"parser": "@typescript-eslint/parser",
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["@typescript-eslint"],
"env": {
"browser": true,
"es2021": true,
"node": true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"jsx": true
},
"ignorePatterns": ["tsconfig.json", ".next", "node_modules"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/triple-slash-reference": "off"
}
}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.next/
*.log
.DS_Store
.env
.env.local
.env.*.local
.claude
+1348
View File
File diff suppressed because it is too large Load Diff
+620
View File
@@ -0,0 +1,620 @@
# Homelab Auth + Portfolio: Authentik Forward-Auth + Homarr SSO + Portfolio Site (Terraform)
## Context
Three services (Longhorn, Portainer, Prometheus) currently zero-auth. Goal: rebuild with Authentik forward-auth + Homarr dashboard + personal portfolio site. Infrastructure as Terraform (not Helm).
Authentik bootstrap (Proxy Providers, groups) via Python helper. Homarr + Portfolio deployed as K8s manifests via Terraform modules.
---
## Track A — Authentik Forward-Auth (Setup via Python, Ingress via TF)
Authentik cluster already running (deployed separately). Python helper script registers Proxy Providers + groups:
**`k8s/talos-iam/register_proxy_app.py`** (mirrors `register_oauth_app.py`):
- Queries embedded outpost, creates `/providers/proxy/` (mode: `forward_single`)
- Creates `/core/applications/` entry
- Creates/binds Authentik group
- Patches outpost `providers` list
```bash
python3 k8s/talos-iam/register_proxy_app.py \
--service-name longhorn --namespace longhorn-system \
--external-host longhorn.riotpiao.homelab.com \
--add-group infra-admins
# Repeat for portainer, prometheus
```
**Ingress wiring (Terraform):**
`modules/ingress/protected-services.tf`:
```hcl
resource "kubernetes_ingress_v1" "protected_services" {
for_each = var.protected_services
metadata {
name = each.key
namespace = each.value.namespace
annotations = {
"nginx.ingress.kubernetes.io/auth-url" = "http://authentik-server.iam.svc.cluster.local/outpost.goauthentik.io/auth/nginx"
"nginx.ingress.kubernetes.io/auth-signin" = "https://authentik.riotpiao.homelab.com/outpost.goauthentik.io/start?rd=$scheme://$http_host$escaped_request_uri"
"nginx.ingress.kubernetes.io/auth-response-headers" = "Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid"
}
}
spec {
ingress_class_name = "nginx"
rule {
host = each.value.host
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = each.value.service_name
port {
number = each.value.service_port
}
}
}
}
}
}
}
}
```
**NetworkPolicy (per-service, Terraform):**
`modules/network-policy/backend-isolation.tf`:
```hcl
resource "kubernetes_network_policy" "backend_only_from_ingress" {
for_each = var.isolated_services
metadata {
name = "${each.key}-deny-except-ingress"
namespace = each.value.namespace
}
spec {
pod_selector {
match_labels = each.value.pod_selector
}
policy_types = ["Ingress"]
ingress {
from {
namespace_selector {
match_labels = {
name = "ingress-nginx"
}
}
}
}
}
}
```
---
## Track B — Homarr (Terraform Deployment)
**Homarr Helm chart wrapper (Terraform):**
`modules/homarr/main.tf`:
```hcl
resource "kubernetes_namespace" "homarr" {
metadata {
name = "dashboard"
labels = {
"managed_by" = "terraform"
"reloader" = "enabled"
}
}
}
resource "kubernetes_secret" "homarr_oidc" {
metadata {
name = "homarr-oidc"
namespace = kubernetes_namespace.homarr.metadata[0].name
}
data = {
AUTH_OIDC_CLIENT_SECRET = var.homarr_oidc_client_secret
}
type = "Opaque"
}
resource "helm_release" "homarr" {
name = "homarr"
namespace = kubernetes_namespace.homarr.metadata[0].name
chart = "homarr"
repository = "https://homarr-labs.github.io/charts/"
version = var.homarr_version
set {
name = "env.AUTH_PROVIDERS"
value = "oidc"
}
set {
name = "env.AUTH_OIDC_CLIENT_ID"
value = "homarr"
}
set {
name = "env.AUTH_OIDC_ISSUER"
value = "https://authentik.riotpiao.homelab.com/application/o/homarr/"
}
set {
name = "env.AUTH_OIDC_URI"
value = "https://authentik.riotpiao.homelab.com/application/o/homarr/.well-known/openid-configuration"
}
set {
name = "env.AUTH_OIDC_GROUPS_ATTRIBUTE"
value = "groups"
}
set {
name = "envFrom[0].secretRef.name"
value = kubernetes_secret.homarr_oidc.metadata[0].name
}
set {
name = "persistence.enabled"
value = "true"
}
set {
name = "persistence.storageClass"
value = "longhorn"
}
set {
name = "persistence.size"
value = "2Gi"
}
depends_on = [kubernetes_secret.homarr_oidc]
}
```
**Homarr Ingress (no auth, Homarr handles login):**
`modules/homarr/ingress.tf`:
```hcl
resource "kubernetes_ingress_v1" "homarr" {
metadata {
name = "homarr"
namespace = kubernetes_namespace.homarr.metadata[0].name
annotations = {
"cert-manager.io/cluster-issuer" = "letsencrypt-prod"
}
}
spec {
ingress_class_name = "nginx"
tls {
hosts = ["homarr.riotpiao.homelab.com"]
secret_name = "homarr-tls"
}
rule {
host = "homarr.riotpiao.homelab.com"
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = helm_release.homarr.name
port {
number = 3000
}
}
}
}
}
}
}
}
```
**Authentik setup for Homarr (Python):**
```bash
python3 k8s/talos-iam/register_oauth_app.py \
--service-name homarr \
--namespace dashboard \
--redirect-uri "https://homarr.riotpiao.homelab.com/api/auth/callback/oidc" \
--add-group homarr-infra-admins
```
Then attach `groups` scope mapping (manual Authentik UI or separate Terraform provider if available).
**Homarr board setup (manual runbook in `k8s/homarr/README.md`):**
- Create groups: Infra (Longhorn/Portainer/Prometheus), Platform (Grafana/Vault), Workflows (Temporal/kmsvc)
- Each tile deep-links to existing ingress
- Restrict Infra group visibility to `infra-admins`
---
## Track C — Portfolio Site (Terraform Deployment)
Portfolio source repo: `~/workplace/riotpiao` (Next.js, standalone output).
**Portfolio Helm chart (Terraform wrapper):**
`modules/portfolio/main.tf`:
```hcl
resource "kubernetes_namespace" "portfolio" {
metadata {
name = "portfolio"
labels = {
"managed_by" = "terraform"
}
}
}
resource "helm_release" "portfolio" {
name = "portfolio"
namespace = kubernetes_namespace.portfolio.metadata[0].name
chart = "./k8s/portfolio" # Local chart from homelab repo
set {
name = "image.repository"
value = var.portfolio_image_repo
}
set {
name = "image.tag"
value = var.portfolio_image_tag
}
set {
name = "replicaCount"
value = 2
}
set {
name = "resources.requests.cpu"
value = "100m"
}
set {
name = "resources.requests.memory"
value = "128Mi"
}
set {
name = "resources.limits.cpu"
value = "500m"
}
set {
name = "resources.limits.memory"
value = "512Mi"
}
}
```
**Portfolio Ingress (public, no auth):**
`modules/portfolio/ingress.tf`:
```hcl
resource "kubernetes_ingress_v1" "portfolio" {
metadata {
name = "portfolio"
namespace = kubernetes_namespace.portfolio.metadata[0].name
annotations = {
"cert-manager.io/cluster-issuer" = "letsencrypt-prod"
}
}
spec {
ingress_class_name = "nginx"
tls {
hosts = ["portfolio.riotpiao.homelab.com"]
secret_name = "portfolio-tls"
}
rule {
host = "portfolio.riotpiao.homelab.com"
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = helm_release.portfolio.name
port {
number = 3000
}
}
}
}
}
}
}
}
```
**Portfolio Helm chart structure** (local, in homelab repo):
```
k8s/portfolio/
├── Chart.yaml
├── values.yaml
├── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── _helpers.tpl
└── README.md
```
**Build + push script** (portfolio repo):
`~/workplace/riotpiao/build.sh`:
```bash
#!/bin/bash
set -euo pipefail
REGISTRY="forgejo.riotpiao.homelab.com"
IMAGE="${REGISTRY}/rock/portfolio:latest"
docker buildx build --platform linux/amd64 -t "${IMAGE}" .
docker push "${IMAGE}"
echo "✓ Pushed ${IMAGE}"
```
---
## Terraform Structure
```
~/workplace/homelab-terraform/ (or extend existing homelab repo)
├── main.tf # K8s provider config
├── variables.tf # Inputs
├── outputs.tf
│
├── modules/
│ ├── ingress/
│ │ ├── protected-services.tf # Longhorn, Portainer, Prometheus
│ │ └── variables.tf
│ │
│ ├── homarr/
│ │ ├── main.tf # Namespace, Secret, Helm release
│ │ ├── ingress.tf # Public Homarr ingress
│ │ └── variables.tf
│ │
│ ├── portfolio/
│ │ ├── main.tf # Namespace, Helm release
│ │ ├── ingress.tf # Public portfolio ingress
│ │ └── variables.tf
│ │
│ └── network-policy/
│ └── backend-isolation.tf # Deny ingress except nginx
│
├── environments/
│ └── prod.tfvars # Domain, image repos, versions
│
└── .terraform.lock.hcl
```
**Main entry point:**
`main.tf`:
```hcl
terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.25"
}
helm = {
source = "hashicorp/helm"
version = "~> 2.12"
}
}
backend "local" {
path = "terraform.tfstate"
}
}
provider "kubernetes" {
host = var.cluster_endpoint
cluster_ca_certificate = base64decode(var.cluster_ca_cert)
token = var.cluster_token
}
provider "helm" {
kubernetes {
host = var.cluster_endpoint
cluster_ca_certificate = base64decode(var.cluster_ca_cert)
token = var.cluster_token
}
}
module "ingress" {
source = "./modules/ingress"
protected_services = var.protected_services
}
module "homarr" {
source = "./modules/homarr"
homarr_version = var.homarr_version
homarr_oidc_client_secret = var.homarr_oidc_client_secret
}
module "portfolio" {
source = "./modules/portfolio"
portfolio_image_repo = var.portfolio_image_repo
portfolio_image_tag = var.portfolio_image_tag
}
module "network_policy" {
source = "./modules/network-policy"
isolated_services = var.isolated_services
}
```
**`variables.tf`:**
```hcl
variable "cluster_endpoint" {
type = string
description = "K8s API endpoint"
}
variable "cluster_ca_cert" {
type = string
sensitive = true
}
variable "cluster_token" {
type = string
sensitive = true
}
variable "protected_services" {
type = map(object({
host = string
namespace = string
service_name = string
service_port = number
}))
default = {
"longhorn" = {
host = "longhorn.riotpiao.homelab.com"
namespace = "longhorn-system"
service_name = "longhorn-frontend"
service_port = 80
}
"portainer" = {
host = "portainer.riotpiao.homelab.com"
namespace = "dashboard"
service_name = "portainer"
service_port = 9000
}
"prometheus" = {
host = "prometheus.riotpiao.homelab.com"
namespace = "monitoring"
service_name = "prometheus-operated"
service_port = 9090
}
}
}
variable "homarr_version" {
type = string
default = "~1"
}
variable "homarr_oidc_client_secret" {
type = string
sensitive = true
description = "From Authentik provider"
}
variable "portfolio_image_repo" {
type = string
default = "forgejo.riotpiao.homelab.com/rock/portfolio"
}
variable "portfolio_image_tag" {
type = string
default = "latest"
}
variable "isolated_services" {
type = map(object({
namespace = string
pod_selector = map(string)
}))
default = {
"longhorn" = {
namespace = "longhorn-system"
pod_selector = {
"app.kubernetes.io/name" = "longhorn"
}
}
"portainer" = {
namespace = "dashboard"
pod_selector = {
"app" = "portainer"
}
}
"prometheus" = {
namespace = "monitoring"
pod_selector = {
"app.kubernetes.io/name" = "prometheus"
}
}
}
}
```
---
## Implementation Sequencing
1. **Authentik setup (Python):**
- Register Proxy Providers (Longhorn, Portainer, Prometheus)
- Create groups (infra-admins, homarr-infra-admins)
- Verify `/outpost.goauthentik.io/auth/nginx` endpoint accessible
2. **Terraform deploy (parallel):**
- Ingress + NetworkPolicies (protected services)
- Homarr (depends on Authentik, has own OIDC)
- Portfolio (independent, public)
3. **Portfolio build + push:**
- `cd ~/workplace/riotpiao && ./build.sh`
- Verify image in Forgejo registry
4. **Terraform apply:**
```bash
terraform init
terraform plan
terraform apply
```
5. **Verification:**
- `curl -I https://longhorn.riotpiao.homelab.com` → 302 to Authentik (unauthenticated)
- Login as `infra-admins` member → Longhorn UI loads
- `curl -I https://homarr.riotpiao.homelab.com` → OIDC sign-in page
- Login → Homarr boards visible
- `curl -I https://portfolio.riotpiao.homelab.com` → 200, no redirect
---
## Files to Create
**In homelab repo:**
- `terraform.tfvars` (git-ignored)
- `main.tf`, `variables.tf`, `outputs.tf`
- `modules/{ingress,homarr,portfolio,network-policy}/*`
- `k8s/homarr/README.md` (board setup runbook)
- `k8s/portfolio/Chart.yaml`, `templates/*`
**In portfolio repo (`~/workplace/riotpiao`):**
- `app/layout.tsx`, `app/page.tsx`, `app/about/page.tsx`
- `components/{Header,Footer,ProjectCard}.tsx`
- `content/{projects,experience,metadata}.ts`
- `styles/globals.css`
- `Dockerfile`, `build.sh`, `next.config.js`
- `package.json` (Next.js 15 + Tailwind)
---
## Success Criteria
- [ ] Protected services (Longhorn/Portainer/Prometheus) redirect unauthenticated to Authentik
- [ ] Infra-admins group members see UI; non-members denied
- [ ] Homarr OIDC login works; boards render for authorized groups
- [ ] Portfolio site accessible without auth; project grid + about page live
- [ ] All deployed via `terraform apply` (no manual kubectl)
- [ ] Terraform state tracks 100% of resources
+217
View File
@@ -0,0 +1,217 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { Panel } from '@/components/cluster/Panel'
import { SlotMeter } from '@/components/cluster/SlotMeter'
const DAILY_BUDGET = 12
type Turn = {
role: 'user' | 'model'
reasoning?: string
content: string
streaming?: boolean
}
const SEED: Turn[] = [
{ role: 'user', content: 'Why is the sms application degraded?' },
{
role: 'model',
reasoning:
'The snapshot lists sms with health "degraded" and sync "synced". Sync being clean means the manifests applied fine, so this is a runtime problem rather than a delivery one. The sms namespace shows 0 of 1 pods running. A single pod that never reaches Running, with Argo reporting the desired state as applied, usually means the pod cannot be placed at all.',
content:
'Delivery is fine — sms is Synced, so Argo applied everything it was asked to. The failure is at runtime: the namespace has one pod and zero running. Argo marks the app degraded because the workload never became ready, not because the manifests are wrong.',
},
]
const REPLY =
'Wave 4 is empty. Waves are sort keys, not a sequence — Argo orders by value and skips gaps, so 3 is followed directly by 5. Nothing is missing.'
const REPLY_REASONING =
'The user is asking about a gap in the wave column display. Argo CD sync waves are integers used purely for ordering; there is no requirement that they be contiguous. The cluster uses 0,1,2,3,5,6,7,8. This is normal and usually happens when a wave is retired or intentionally reserved.'
export default function ChatPage() {
const [turns, setTurns] = useState<Turn[]>(SEED)
const [value, setValue] = useState('')
const [queue, setQueue] = useState<number | null>(null)
const [slots, setSlots] = useState(3)
const [used, setUsed] = useState(2)
const [openReasoning, setOpenReasoning] = useState<number | null>(1)
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
}, [turns, queue])
const send = () => {
if (!value.trim() || queue !== null || used >= DAILY_BUDGET) return
setTurns((t) => [...t, { role: 'user', content: value }])
setValue('')
setQueue(3)
}
// Placeholder for the queue → stream transition. Real version is SSE from atlas.
useEffect(() => {
if (queue === null) return
if (queue > 0) {
const t = setTimeout(() => setQueue((q) => (q === null ? null : q - 1)), 700)
return () => clearTimeout(t)
}
setQueue(null)
setSlots((s) => Math.min(s + 1, 6))
setUsed((u) => u + 1)
setTurns((t) => [...t, { role: 'model', reasoning: REPLY_REASONING, content: '', streaming: true }])
let i = 0
const stream = setInterval(() => {
i += 3
setTurns((t) => {
const next = [...t]
const last = next[next.length - 1]
if (last?.role !== 'model') return t
next[next.length - 1] = { ...last, content: REPLY.slice(0, i), streaming: i < REPLY.length }
return next
})
if (i >= REPLY.length) {
clearInterval(stream)
setSlots((s) => Math.max(s - 1, 1))
}
}, 40)
return () => clearInterval(stream)
}, [queue])
const exhausted = used >= DAILY_BUDGET
return (
<div className="space-y-6">
<header className="border border-wire bg-deck p-6 md:p-8">
<p className="font-data text-[10px] uppercase tracking-[0.2em] text-dim">Surface D — Chat</p>
<h1 className="mt-3 max-w-3xl font-signage text-3xl font-semibold leading-[1.1] tracking-signage md:text-5xl">
A 32B model, running on one card, <span className="text-lamp">two rooms from here.</span>
</h1>
<p className="mt-5 max-w-2xl font-plex text-sm leading-relaxed text-dim">
It reads a redacted snapshot of the cluster and answers questions about it. It cannot query anything, run
anything, or change anything. When all six public slots are busy, you wait in line — the queue is real,
and so is the hardware.
</p>
<div className="mt-6 flex flex-wrap items-center gap-6">
<SlotMeter used={slots} size="lg" showCap />
<span className="font-data text-[10px] uppercase tracking-[0.14em] text-dim">
{DAILY_BUDGET - used} of {DAILY_BUDGET} messages left today
</span>
</div>
</header>
<div className="grid gap-6 lg:grid-cols-[1fr_260px]">
<Panel label="Session" hint="anonymous">
<div ref={scrollRef} className="h-[440px] space-y-5 overflow-y-auto pr-1">
{turns.map((turn, i) =>
turn.role === 'user' ? (
<div key={i} className="flex justify-end">
<p className="max-w-[85%] border border-wire-lit bg-riser px-3 py-2 font-plex text-sm text-chalk">
{turn.content}
</p>
</div>
) : (
<div key={i} className="max-w-[92%] space-y-2">
{turn.reasoning && (
<div className="border-l-2 border-lamp/50">
<button
onClick={() => setOpenReasoning(openReasoning === i ? null : i)}
aria-expanded={openReasoning === i}
className="flex w-full items-center gap-2 py-1 pl-3 font-data text-[10px] uppercase tracking-[0.16em] text-lamp focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp"
>
<ChevronDown
size={12}
className={`transition-transform ${openReasoning === i ? '' : '-rotate-90'}`}
/>
thinking
</button>
{openReasoning === i && (
<p className="py-1 pl-3 pr-2 font-data text-[11px] leading-relaxed text-dim">
{turn.reasoning}
</p>
)}
</div>
)}
<p className="font-plex text-sm leading-relaxed text-chalk">
{turn.content}
{turn.streaming && <span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-lamp align-text-bottom" />}
</p>
</div>
),
)}
{queue !== null && (
<div className="flex items-center gap-3 border border-lamp/40 px-3 py-2">
<SlotMeter used={6} />
<p className="font-data text-[11px] text-lamp">
all six public slots busy · position {queue} in queue
</p>
</div>
)}
</div>
<form
onSubmit={(e) => {
e.preventDefault()
send()
}}
className="mt-3 flex items-center gap-2 border-t border-wire pt-3"
>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={exhausted}
aria-label="Message"
placeholder={exhausted ? 'Daily limit reached — resets in 14h' : 'Ask about the cluster'}
className="min-w-0 flex-1 bg-transparent font-plex text-sm text-chalk placeholder:text-dim/60 focus:outline-none disabled:cursor-not-allowed"
/>
<button
type="submit"
disabled={exhausted || queue !== null}
className="border border-lamp px-3 py-1 font-data text-[10px] uppercase tracking-[0.14em] text-lamp transition-colors hover:bg-lamp hover:text-void focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-lamp"
>
send
</button>
</form>
</Panel>
<div className="space-y-6">
<Panel label="Limits" hint="tier 3">
<dl className="space-y-2.5 font-data text-[11px]">
{[
['public slots', '6 of 8'],
['queue depth', '20'],
['per session', '12 / day'],
['request timeout', '120s'],
['output cap', '1500 tokens'],
].map(([k, v]) => (
<div key={k} className="flex items-baseline gap-2">
<dt className="text-dim">{k}</dt>
<dd className="ml-auto tabular-nums text-chalk">{v}</dd>
</div>
))}
</dl>
<p className="mt-4 border-t border-wire pt-3 font-plex text-[11px] leading-relaxed text-dim">
Closing this tab frees your slot immediately. Two slots stay reserved so the operator is never locked
out of their own hardware.
</p>
</Panel>
<Panel label="Cannot do" hint="by construction">
<ul className="space-y-1.5 font-data text-[10px] text-dim">
{['call tools', 'query the cluster', 'read logs', 'change anything', 'see Secret contents'].map((x) => (
<li key={x} className="flex items-center gap-2">
<span className="h-px w-3 bg-wire-lit" />
{x}
</li>
))}
</ul>
</Panel>
</div>
</div>
</div>
)
}
+219
View File
@@ -0,0 +1,219 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Panel } from '@/components/cluster/Panel'
import { DEFAULT_KINDS, WAVES, apps, appsInWave, resourceKinds, type App } from '@/lib/clusterMock'
const TOTAL_RESOURCES = apps.reduce((n, a) => n + a.resources, 0)
/** Resource count as discrete cells, capped so a 68-resource app stays readable. */
function CountBar({ n }: { n: number }) {
const cells = Math.min(n, 24)
return (
<div className="mt-2 flex flex-wrap gap-[2px]" aria-label={`${n} resources`}>
{Array.from({ length: cells }, (_, i) => (
<span key={i} className="h-1.5 w-1.5 rounded-[1px] bg-wire-lit" />
))}
{n > 24 && <span className="ml-1 font-data text-[9px] leading-none text-dim">+{n - 24}</span>}
</div>
)
}
function AppCard({
app,
state,
active,
onSelect,
}: {
app: App
state: 'idle' | 'syncing' | 'done'
active: boolean
onSelect: () => void
}) {
const degraded = app.health === 'degraded'
const drifted = app.sync === 'outofsync'
const border = active
? 'border-lamp'
: state === 'syncing'
? 'border-lamp/70'
: degraded
? 'border-rose/50'
: drifted
? 'border-lamp/40'
: 'border-wire'
return (
<button
onClick={onSelect}
aria-pressed={active}
className={[
'w-full border bg-deck p-2.5 text-left transition-all duration-300',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp',
border,
state === 'syncing' ? 'bg-riser' : '',
active ? 'bg-riser' : 'hover:border-wire-lit',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="font-data text-[11px] leading-tight text-chalk">{app.name}</span>
<span
className={[
'mt-1 h-1.5 w-1.5 shrink-0 rounded-full transition-colors',
state === 'syncing' ? 'bg-lamp' : degraded ? 'bg-rose' : drifted ? 'bg-lamp' : 'bg-flux',
].join(' ')}
/>
</div>
<CountBar n={app.resources} />
{(degraded || drifted) && (
<p className={`mt-2 font-data text-[9px] uppercase tracking-[0.14em] ${degraded ? 'text-rose' : 'text-lamp'}`}>
{degraded ? 'degraded' : 'out of sync'}
</p>
)}
</button>
)
}
export default function DeliveryPage() {
const [selected, setSelected] = useState<string | null>('prometheus')
const [front, setFront] = useState<number | null>(null)
const replay = useCallback(() => setFront(0), [])
useEffect(() => {
if (front === null) return
if (front > 8) {
const done = setTimeout(() => setFront(null), 700)
return () => clearTimeout(done)
}
const next = setTimeout(() => setFront((w) => (w === null ? null : w + 1)), 520)
return () => clearTimeout(next)
}, [front])
const selectedApp = apps.find((a) => a.name === selected) ?? null
const kinds = selectedApp ? (resourceKinds[selectedApp.name] ?? DEFAULT_KINDS) : []
return (
<div className="space-y-6">
<header className="border border-wire bg-deck p-6 md:p-8">
<p className="font-data text-[10px] uppercase tracking-[0.2em] text-dim">Surface E — Delivery</p>
<h1 className="mt-3 max-w-3xl font-signage text-3xl font-semibold leading-[1.1] tracking-signage md:text-5xl">
Nothing starts until the thing it needs is already running.
</h1>
<p className="mt-5 max-w-2xl font-plex text-sm leading-relaxed text-dim">
{apps.length} applications, {TOTAL_RESOURCES} resources, applied in nine ordered waves. Certificates
before issuers, issuers before ingress, storage before the databases that sit on it. The order is the
design.
</p>
<button
onClick={replay}
disabled={front !== null}
className="mt-6 border border-lamp px-4 py-2 font-data text-[11px] uppercase tracking-[0.16em] text-lamp transition-colors hover:bg-lamp hover:text-void focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-lamp"
>
{front !== null ? `syncing wave ${Math.min(front, 8)}` : 'Replay sync'}
</button>
</header>
<div className="grid gap-6 xl:grid-cols-[1fr_340px]">
<Panel label="Sync waves" hint="0 → 8, left to right" className="min-w-0">
<div className="-mx-4 overflow-x-auto px-4 pb-2">
<div className="flex min-w-max gap-3">
{WAVES.map((wave) => {
const inWave = appsInWave(wave)
const state = front === null ? 'idle' : front === wave ? 'syncing' : front > wave ? 'done' : 'idle'
return (
<div key={wave} className="w-[168px] shrink-0">
<div
className={[
'mb-3 flex items-baseline justify-between border-b pb-1.5 transition-colors',
state === 'syncing' ? 'border-lamp' : 'border-wire',
].join(' ')}
>
<span
className={[
'font-signage text-2xl font-semibold leading-none transition-colors',
state === 'syncing' ? 'text-lamp' : inWave.length ? 'text-chalk' : 'text-wire-lit',
].join(' ')}
>
{wave}
</span>
<span className="font-data text-[9px] uppercase tracking-[0.14em] text-dim">
{inWave.length || '—'}
</span>
</div>
{inWave.length === 0 ? (
<p className="border border-dashed border-wire p-2.5 font-data text-[10px] leading-relaxed text-dim">
unused — waves are sparse by design, not sequential
</p>
) : (
<div className="space-y-2">
{inWave.map((app) => (
<AppCard
key={app.name}
app={app}
state={state}
active={selected === app.name}
onSelect={() => setSelected(app.name)}
/>
))}
</div>
)}
</div>
)
})}
</div>
</div>
</Panel>
<Panel
label={selectedApp ? selectedApp.name : 'No selection'}
hint={selectedApp ? `wave ${selectedApp.wave ?? '—'} · ${selectedApp.resources} resources` : 'pick an app'}
>
{selectedApp ? (
<div className="space-y-4">
<dl className="grid grid-cols-2 gap-3 font-data text-[11px]">
<div>
<dt className="text-dim">sync</dt>
<dd className={selectedApp.sync === 'synced' ? 'text-flux' : 'text-lamp'}>{selectedApp.sync}</dd>
</div>
<div>
<dt className="text-dim">health</dt>
<dd className={selectedApp.health === 'healthy' ? 'text-flux' : 'text-rose'}>
{selectedApp.health}
</dd>
</div>
</dl>
<div>
<p className="mb-2 font-data text-[10px] uppercase tracking-[0.16em] text-dim">Resources by kind</p>
<ul className="space-y-1.5">
{kinds.map((k) => (
<li key={k.kind} className="flex items-center gap-3 font-data text-[11px]">
<span className={k.named ? 'text-chalk' : 'text-dim'}>{k.kind}</span>
<span className="h-px flex-1 bg-wire" />
<span className="tabular-nums text-dim">{k.count}</span>
{!k.named && (
<span className="border border-wire-lit px-1 text-[9px] uppercase tracking-[0.1em] text-dim">
count only
</span>
)}
</li>
))}
</ul>
</div>
<p className="border-t border-wire pt-3 font-plex text-[11px] leading-relaxed text-dim">
Secrets appear as counts. Names, source repositories, and condition messages are never sent to the
browser.
</p>
</div>
) : (
<p className="font-plex text-sm text-dim">Select an application to inspect its resources.</p>
)}
</Panel>
</div>
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react'
import { IBM_Plex_Mono, IBM_Plex_Sans, IBM_Plex_Sans_Condensed } from 'next/font/google'
import { Rail } from '@/components/cluster/Rail'
import { Ribbon } from '@/components/cluster/Ribbon'
const signage = IBM_Plex_Sans_Condensed({
subsets: ['latin'],
weight: ['600', '700'],
variable: '--font-signage',
})
const plex = IBM_Plex_Sans({
subsets: ['latin'],
weight: ['400', '500', '600'],
variable: '--font-plex',
})
const data = IBM_Plex_Mono({
subsets: ['latin'],
weight: ['400', '500'],
variable: '--font-data',
})
export const metadata = {
title: 'Cluster — Rock Liang',
description: 'Live view of a Talos Kubernetes cluster: topology, GitOps delivery, and GPU inference.',
}
export default function ClusterLayout({ children }: { children: ReactNode }) {
return (
<div className={`${signage.variable} ${plex.variable} ${data.variable} min-h-screen bg-void font-plex text-chalk`}>
<Ribbon />
<div className="flex min-h-[calc(100vh-42px)] flex-col md:flex-row">
<Rail />
<main className="min-w-0 flex-1 p-4 md:p-6">{children}</main>
</div>
</div>
)
}
+147
View File
@@ -0,0 +1,147 @@
'use client'
import { useState } from 'react'
import { Panel } from '@/components/cluster/Panel'
import { SlotMeter } from '@/components/cluster/SlotMeter'
import { models, namespaces, nodes, type Node } from '@/lib/clusterMock'
function Cells({ filled, total, tone = 'flux' }: { filled: number; total: number; tone?: 'flux' | 'rose' }) {
return (
<div className="flex gap-[2px]" aria-label={`${filled} of ${total} running`}>
{Array.from({ length: total }, (_, i) => (
<span
key={i}
className={`h-2.5 w-[5px] rounded-[1px] ${i < filled ? (tone === 'rose' ? 'bg-rose' : 'bg-flux') : 'bg-wire'}`}
/>
))}
</div>
)
}
function NodeCard({ node, active, onSelect }: { node: Node; active: boolean; onSelect: () => void }) {
const isGpu = node.gpu > 0
return (
<button
onClick={onSelect}
aria-pressed={active}
className={[
'group relative border p-4 text-left transition-colors',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp',
active ? 'border-lamp bg-riser' : 'border-wire bg-deck hover:border-wire-lit',
].join(' ')}
>
{isGpu && <span className="absolute right-0 top-0 h-6 w-px bg-lamp" />}
<div className="flex items-baseline justify-between">
<h3 className="font-signage text-xl font-semibold tracking-signage">{node.name}</h3>
<span className={`h-1.5 w-1.5 rounded-full ${node.health === 'healthy' ? 'bg-flux' : 'bg-rose'}`} />
</div>
<p className={`mt-1 font-data text-[10px] uppercase tracking-[0.14em] ${isGpu ? 'text-lamp' : 'text-dim'}`}>
{node.role}
</p>
<dl className="mt-4 space-y-1.5 font-data text-[11px]">
<div className="flex justify-between">
<dt className="text-dim">cpu</dt>
<dd className="tabular-nums">{node.cpu}</dd>
</div>
<div className="flex justify-between">
<dt className="text-dim">mem</dt>
<dd className="tabular-nums">{node.memoryGi} Gi</dd>
</div>
<div className="flex justify-between">
<dt className="text-dim">gpu</dt>
<dd className={`tabular-nums ${isGpu ? 'text-lamp' : 'text-dim'}`}>{node.gpu}</dd>
</div>
<div className="flex justify-between">
<dt className="text-dim">pods</dt>
<dd className="tabular-nums">{node.pods}</dd>
</div>
</dl>
</button>
)
}
export default function TopologyPage() {
const [selected, setSelected] = useState('worker-1')
return (
<div className="space-y-6">
{/* Thesis: the constraint that shaped everything downstream. */}
<header className="border border-wire bg-deck p-6 md:p-8">
<p className="font-data text-[10px] uppercase tracking-[0.2em] text-dim">Surface B — Topology</p>
<h1 className="mt-3 max-w-3xl font-signage text-3xl font-semibold leading-[1.1] tracking-signage md:text-5xl">
Four nodes. One GPU. <span className="text-lamp">Eight sequence slots</span> for everything that thinks.
</h1>
<div className="mt-6 flex flex-wrap items-center gap-4">
<SlotMeter used={3} size="lg" showCap />
</div>
<p className="mt-6 max-w-2xl font-plex text-sm leading-relaxed text-dim">
Every workload below runs on hardware sitting in one room. The scarcest thing in it is inference
capacity, so that is the number this page keeps in front of you.
</p>
</header>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{nodes.map((n) => (
<NodeCard key={n.name} node={n} active={selected === n.name} onSelect={() => setSelected(n.name)} />
))}
</div>
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr]">
<Panel label="Namespaces" hint="pods running / scheduled">
<ul className="space-y-1.5">
{namespaces.map((ns) => {
const short = ns.running < ns.total
return (
<li key={ns.name} className="flex items-center gap-4 py-0.5">
<span className="w-40 shrink-0 truncate font-data text-[11px] text-chalk">{ns.name}</span>
<Cells filled={ns.running} total={ns.total} tone={ns.running === 0 ? 'rose' : 'flux'} />
<span
className={`ml-auto shrink-0 font-data text-[11px] tabular-nums ${short ? 'text-lamp' : 'text-dim'}`}
>
{ns.running}/{ns.total}
</span>
</li>
)
})}
</ul>
</Panel>
<div className="space-y-6">
<Panel label="Inference" hint="KServe · worker-1">
<ul className="space-y-3">
{models.map((m) => (
<li key={m.name} className="border-b border-wire pb-3 last:border-0 last:pb-0">
<div className="flex items-baseline justify-between">
<span className="font-signage text-base font-semibold tracking-signage">{m.name}</span>
<span className={`font-data text-[10px] uppercase tracking-[0.14em] ${m.ready ? 'text-flux' : 'text-rose'}`}>
{m.ready ? 'ready' : 'down'}
</span>
</div>
<p className="mt-1 font-data text-[10px] text-dim">
{m.replicas}× replica · {m.seqPerReplica} seq · {m.contextTokens.toLocaleString()} ctx
</p>
</li>
))}
</ul>
</Panel>
<Panel label="Withheld" hint="redaction allowlist">
<ul className="space-y-1.5 font-data text-[10px] text-dim">
{['node and pod addresses', 'container arguments', 'image tags and digests', 'source repository URLs', 'Secret names', 'condition messages'].map((x) => (
<li key={x} className="flex items-center gap-2">
<span className="h-px w-3 bg-wire-lit" />
{x}
</li>
))}
</ul>
<p className="mt-4 font-plex text-[11px] leading-relaxed text-dim">
Fields are built by explicit construction, so anything not listed as public never enters the
response.
</p>
</Panel>
</div>
</div>
</div>
)
}
+173
View File
@@ -0,0 +1,173 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Panel } from '@/components/cluster/Panel'
import { namespaces, nodes, apps } from '@/lib/clusterMock'
const COMMANDS = [
'get nodes',
'get pods <ns>',
'get apps',
'top nodes',
'describe pod <ns> <name>',
'help',
]
type Line = { kind: 'input' | 'output' | 'reject'; text: string }
const pad = (s: string, n: number) => s.padEnd(n, ' ')
function run(raw: string): Line[] {
const cmd = raw.trim().toLowerCase()
if (cmd === 'help') {
return [{ kind: 'output', text: COMMANDS.map((c) => ` ${c}`).join('\n') }]
}
if (cmd === 'get nodes') {
const head = `${pad('NAME', 14)}${pad('ROLE', 16)}${pad('CPU', 6)}${pad('MEM', 8)}GPU`
const rows = nodes.map((n) => `${pad(n.name, 14)}${pad(n.role, 16)}${pad(String(n.cpu), 6)}${pad(`${n.memoryGi}Gi`, 8)}${n.gpu}`)
return [{ kind: 'output', text: [head, ...rows].join('\n') }]
}
if (cmd === 'get apps') {
const head = `${pad('NAME', 24)}${pad('WAVE', 7)}${pad('SYNC', 12)}HEALTH`
const rows = apps.map((a) => `${pad(a.name, 24)}${pad(String(a.wave ?? '-'), 7)}${pad(a.sync, 12)}${a.health}`)
return [{ kind: 'output', text: [head, ...rows].join('\n') }]
}
if (cmd === 'top nodes') {
const head = `${pad('NAME', 14)}${pad('CPU%', 8)}MEM%`
const rows = nodes.map((n) => `${pad(n.name, 14)}${pad(n.gpu ? '61%' : '18%', 8)}${n.gpu ? '74%' : '42%'}`)
return [{ kind: 'output', text: [head, ...rows].join('\n') }]
}
if (cmd.startsWith('get pods')) {
const ns = cmd.split(/\s+/)[2]
if (!ns) return [{ kind: 'reject', text: 'get pods requires a namespace. Try: get pods llm-serving' }]
const known = namespaces.find((n) => n.name === ns)
if (!known) {
return [
{
kind: 'reject',
text: `unknown namespace "${ns}" — rejected on snapshot membership, no lookup performed`,
},
]
}
const rows = Array.from({ length: Math.min(known.total, 8) }, (_, i) => {
const running = i < known.running
return `${pad(`${ns}-workload-${i + 1}`, 30)}${pad(running ? '1/1' : '0/1', 6)}${running ? 'Running' : 'Pending'}`
})
return [{ kind: 'output', text: [`${pad('NAME', 30)}${pad('READY', 6)}STATUS`, ...rows].join('\n') }]
}
if (cmd.startsWith('describe pod')) {
return [{ kind: 'output', text: 'phase: Running\nrestarts: 0\nage: 17h\nnode: worker-1\nready: true' }]
}
return [
{
kind: 'reject',
text: `"${raw.trim()}" is not in the command set. Input parses to a closed enum; nothing else reaches the cluster. Type help.`,
},
]
}
const BOOT: Line[] = [
{ kind: 'output', text: 'atlas read-only shell. Six commands, no shell, no exec.\nType help to list them.' },
]
export default function TerminalPage() {
const [lines, setLines] = useState<Line[]>(BOOT)
const [value, setValue] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [lines])
const submit = (raw: string) => {
if (!raw.trim()) return
setLines((prev) => [...prev, { kind: 'input', text: raw }, ...run(raw)])
setValue('')
}
return (
<div className="space-y-6">
<header className="border border-wire bg-deck p-6 md:p-8">
<p className="font-data text-[10px] uppercase tracking-[0.2em] text-dim">Surface C — Terminal</p>
<h1 className="mt-3 max-w-3xl font-signage text-3xl font-semibold leading-[1.1] tracking-signage md:text-5xl">
Six commands. <span className="text-lamp">Everything else is rejected</span> before it reaches anything.
</h1>
<p className="mt-5 max-w-2xl font-plex text-sm leading-relaxed text-dim">
Input parses to a closed enum. Namespace and pod arguments are checked against the current snapshot by
membership, not by pattern matching. There is no shell in the container. Try to break it.
</p>
</header>
<div className="grid gap-6 lg:grid-cols-[1fr_260px]">
<Panel label="Shell" hint="read-only">
<div ref={scrollRef} className="h-[420px] overflow-y-auto font-data text-[12px] leading-relaxed">
{lines.map((line, i) => (
<div key={i} className="whitespace-pre-wrap">
{line.kind === 'input' && (
<p className="mt-3 text-chalk">
<span className="text-lamp">$ </span>
{line.text}
</p>
)}
{line.kind === 'output' && <p className="text-dim">{line.text}</p>}
{line.kind === 'reject' && (
<p className="mt-1 border-l-2 border-rose pl-3 text-rose">{line.text}</p>
)}
</div>
))}
</div>
<form
onSubmit={(e) => {
e.preventDefault()
submit(value)
}}
className="mt-3 flex items-center gap-2 border-t border-wire pt-3"
>
<span className="font-data text-[12px] text-lamp">$</span>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
spellCheck={false}
autoComplete="off"
aria-label="Cluster command"
placeholder="get pods llm-serving"
className="min-w-0 flex-1 bg-transparent font-data text-[12px] text-chalk placeholder:text-dim/60 focus:outline-none"
/>
<button
type="submit"
className="border border-wire-lit px-3 py-1 font-data text-[10px] uppercase tracking-[0.14em] text-dim transition-colors hover:border-lamp hover:text-lamp focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp"
>
run
</button>
</form>
</Panel>
<Panel label="Command set" hint="allowlist">
<ul className="space-y-2">
{COMMANDS.map((c) => (
<li key={c}>
<button
onClick={() => submit(c.replace('<ns>', 'llm-serving').replace('<name>', 'ornith-predictor'))}
className="w-full border border-wire px-2.5 py-2 text-left font-data text-[11px] text-chalk transition-colors hover:border-lamp hover:text-lamp focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lamp"
>
{c}
</button>
</li>
))}
</ul>
<p className="mt-4 border-t border-wire pt-3 font-plex text-[11px] leading-relaxed text-dim">
Every rejected input is logged with its source address. The allowlist is the whole security model.
</p>
</Panel>
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
+22
View File
@@ -0,0 +1,22 @@
import './globals.css'
import Header from '@/components/Header'
export const metadata = {
title: 'Rock Liang — Portfolio & Live Infrastructure',
description: 'Personal portfolio showcasing Kubernetes, Terraform, and cloud-native systems.',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className="bg-white dark:bg-gray-950 text-gray-900 dark:text-white">
<Header />
{children}
</body>
</html>
);
}
+182
View File
@@ -0,0 +1,182 @@
'use client'
import { motion } from 'framer-motion'
import { FeatureCard } from '@/components/FeatureCard'
import { HeroBlobFlow } from '@/components/HeroBlobFlow'
import { InteractiveTerminal } from '@/components/InteractiveTerminal'
import { ExperienceTimeline } from '@/components/ExperienceTimeline'
import {
GitBranch,
Database,
BarChart3,
MessageSquare,
Cpu,
Layers,
} from 'lucide-react'
const features = [
{
icon: GitBranch,
title: 'Infrastructure Platform',
description: 'Multi-region Terraform + Argo CD GitOps on Kubernetes',
href: '/infrastructure',
stat: '40% CPU reduction',
status: 'live' as const,
},
{
icon: Cpu,
title: 'Distributed Systems',
description: 'gRPC, Kafka, AWS Step Functions across 57+ regions',
href: '/systems',
stat: 'Mission-critical',
status: 'live' as const,
},
{
icon: MessageSquare,
title: 'LLM Systems',
description: 'CPU-bound inference optimization, INT4/INT8 quantization',
href: '/llm',
stat: '60% latency cut',
status: 'live' as const,
},
{
icon: Database,
title: 'Kafka Cluster',
description: 'Strimzi KRaft cluster with auto-scaling brokers',
href: '/kafka',
stat: '3 brokers',
status: 'live' as const,
},
{
icon: Layers,
title: 'Open Source',
description: 'go-flink: distributed DataLakeHouse in Go',
href: '/opensource',
stat: 'Public repo',
status: 'live' as const,
},
{
icon: BarChart3,
title: 'Observability',
description: 'Prometheus, Grafana, Dynatrace — live metrics',
href: '/infrastructure',
stat: '99.2% uptime',
status: 'live' as const,
},
]
export default function Home() {
return (
<main className="min-h-screen">
{/* Hero */}
<section className="bg-gradient-to-br from-blue-50 to-purple-50 dark:from-gray-950 dark:to-gray-900 py-24 px-6">
<div className="max-w-4xl mx-auto">
{/* Avatar with flowing outcomes */}
<HeroBlobFlow />
{/* Description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 1 }}
className="text-center mt-16"
>
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-2">
Full-Stack Systems Engineer
</h1>
<p className="text-xl text-gray-700 dark:text-gray-300 mb-6 font-medium">
Infrastructure × Backend × LLM Systems
</p>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto mb-10">
Building observable, fault-tolerant systems from bare metal to cloud. Optimizing cost (infrastructure) × performance (inference) × reliability (SRE).
</p>
<div className="flex gap-4 justify-center flex-wrap mb-8">
<a
href="#features"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-lg font-semibold transition"
>
Explore Systems
</a>
<a
href="https://github.com/rockliang"
target="_blank"
rel="noopener noreferrer"
className="border-2 border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
GitHub
</a>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 1.2 }}
className="text-sm text-gray-600 dark:text-gray-400"
>
💡 Press <kbd className="bg-gray-200 dark:bg-gray-800 px-2 py-1 rounded">Cmd+K</kbd> to explore via terminal
</motion.div>
</motion.div>
</div>
</section>
{/* Experience Timeline */}
<ExperienceTimeline />
{/* Features Grid */}
<section id="features" className="max-w-6xl mx-auto px-6 py-20">
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="mb-16"
>
<h2 className="text-4xl font-bold mb-4">What I Build</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
Production systems spanning infrastructure, distributed backends, and LLM optimization. Click any domain to explore.
</p>
</motion.div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
}}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{features.map((feature) => (
<FeatureCard key={feature.title} {...feature} />
))}
</motion.div>
</section>
{/* Footer */}
<footer className="border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 py-12 px-6">
<div className="max-w-6xl mx-auto text-center text-sm text-gray-600 dark:text-gray-400">
<p>© 2025 Rock Liang. Deployed on homelab Kubernetes cluster (3-node Talos).</p>
<p className="mt-2">
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:hover:text-blue-400">
GitHub
</a>
{' • '}
<a href="mailto:[email protected]" className="hover:text-blue-600 dark:hover:text-blue-400">
Email
</a>
</p>
</div>
</footer>
{/* Interactive Terminal */}
<InteractiveTerminal />
</main>
)
}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import { motion } from 'framer-motion'
interface AnimatedRolesProps {
roles: string[]
className?: string
}
export function AnimatedRoles({ roles, className }: AnimatedRolesProps) {
const animations = [
{
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
},
{
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
},
{
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
},
{
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
},
]
return (
<div className={className}>
{roles.map((role, index) => {
const animation = animations[index % animations.length]
return (
<motion.span
key={index}
initial={animation.initial}
animate={animation.animate}
transition={{
duration: 0.6,
delay: 0.2 + index * 0.15,
ease: 'easeOut',
}}
className="inline-block"
>
{role}
{index < roles.length - 1 && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 + index * 0.15 + 0.3 }}
className="mx-2"
>
•
</motion.span>
)}
</motion.span>
)
})}
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
'use client'
import { motion } from 'framer-motion'
interface AvatarWithBlobProps {
roles: string[]
}
export function AvatarWithBlob({ roles }: AvatarWithBlobProps) {
// Floating animation variants for each role blob
const getBlobVariants = (index: number) => ({
animate: {
y: [0, -20, 0],
x: [0, Math.cos((index * 2 * Math.PI) / roles.length) * 10, 0],
rotate: [0, 5, -5, 0],
transition: {
duration: 4 + index * 0.5,
repeat: Infinity,
ease: 'easeInOut',
},
},
})
return (
<div className="relative w-full max-w-md mx-auto aspect-square flex items-center justify-center">
{/* Animated background blobs */}
<div className="absolute inset-0">
{/* Center circle glow */}
<motion.div
animate={{
scale: [1, 1.1, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 4,
repeat: Infinity,
}}
className="absolute inset-0 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-full blur-3xl"
/>
</div>
{/* Avatar center */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="relative z-10 w-32 h-32 mx-auto"
>
<div className="w-full h-full rounded-full bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white font-bold text-4xl border-4 border-white dark:border-gray-950 shadow-lg">
RL
</div>
</motion.div>
{/* Floating role blobs */}
{roles.map((role, index) => {
const angle = (index * 2 * Math.PI) / roles.length
const radius = 120
const x = Math.cos(angle) * radius
const y = Math.sin(angle) * radius
return (
<motion.div
key={role}
variants={getBlobVariants(index)}
animate="animate"
className="absolute z-20"
style={{
left: '50%',
top: '50%',
marginLeft: x,
marginTop: y,
}}
>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4 + index * 0.15 }}
className="px-4 py-2 rounded-full bg-white dark:bg-gray-900 border-2 border-blue-400 dark:border-blue-600 shadow-lg whitespace-nowrap text-sm font-semibold text-gray-900 dark:text-white"
>
{role}
</motion.div>
</motion.div>
)
})}
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
'use client'
import { motion } from 'framer-motion'
interface TimelineItem {
company: string
role: string
period: string
description: string
impact: string
skills: string[]
color: string
}
const timeline: TimelineItem[] = [
{
company: 'RBC',
role: 'Infrastructure Platform Architect',
period: '2020 — Present',
description: 'Leading multi-region infrastructure platform on Kubernetes. Building GitOps workflows with Terraform and Argo CD across 4 global regions.',
impact: '40% CPU reduction, 99.2% uptime',
skills: ['Kubernetes', 'Terraform', 'Argo CD', 'Go', 'AWS', 'Cilium', 'Prometheus', 'Grafana'],
color: 'from-blue-500 to-blue-600',
},
{
company: 'AWS',
role: 'Senior Software Engineer (Step Functions)',
period: '2019 — 2020',
description: 'Designed and optimized distributed orchestration platform. Built fault-tolerant task scheduling across 57+ regions serving mission-critical workloads.',
impact: '8 core components, 57+ regions',
skills: ['AWS', 'Java', 'gRPC', 'Distributed Systems', 'DynamoDB', 'CloudWatch'],
color: 'from-orange-500 to-orange-600',
},
{
company: 'Homelab',
role: 'DevOps / SRE / SDE',
period: '2021 — Present',
description: 'Production-grade bare-metal K8s cluster (Talos + Cilium). Built Temporal-based LLM inference pipeline with CPU-bound optimization and quantization.',
impact: '60% latency cut, 75% memory saved',
skills: ['LangGraph', 'Temporal', 'LLM Ops', 'PyTorch', 'Kafka', 'Observability'],
color: 'from-green-500 to-green-600',
},
{
company: 'Open Source',
role: 'Maintainer — go-flink',
period: '2022 — Present',
description: 'Building distributed DataLakeHouse framework in Go. Fault-tolerant task scheduling, streaming semantics, and efficient data processing.',
impact: 'Public repository, active development',
skills: ['Go', 'Distributed Systems', 'Data Pipeline', 'Testing', 'Architecture'],
color: 'from-purple-500 to-purple-600',
},
]
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
},
},
}
const itemVariants = {
hidden: { opacity: 0, x: -20 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.6, ease: 'easeOut' },
},
}
export function ExperienceTimeline() {
return (
<section className="max-w-4xl mx-auto px-6 py-20">
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="mb-16"
>
<h2 className="text-4xl font-bold mb-4">Explore Experience</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
From AWS to RBC. Building systems at scale. Skills acquired along the journey.
</p>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="relative"
>
{/* Timeline line */}
<div className="absolute left-0 md:left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-blue-500 via-purple-500 to-green-500 md:transform md:-translate-x-1/2" />
{/* Timeline items */}
<div className="space-y-12">
{timeline.map((item, index) => (
<motion.div
key={item.company}
variants={itemVariants}
className="relative pl-8 md:pl-0"
>
{/* Timeline dot */}
<div className={`absolute left-0 md:left-1/2 top-2 w-4 h-4 rounded-full bg-gradient-to-br ${item.color} transform md:-translate-x-1/2 -translate-x-1.5 border-4 border-white dark:border-gray-950 shadow-lg`} />
{/* Content card */}
<div className={index % 2 === 0 ? 'md:w-1/2 md:pr-8' : 'md:w-1/2 md:ml-auto md:pl-8'}>
<div className="bg-white dark:bg-gray-900 rounded-lg p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
{item.company}
</h3>
<p className="text-sm font-semibold text-blue-600 dark:text-blue-400">
{item.role}
</p>
</div>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{item.period}
</span>
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
{item.description}
</p>
<div className="bg-gradient-to-r from-blue-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 rounded px-3 py-2 mb-4 text-sm font-semibold text-blue-600 dark:text-blue-400">
📊 {item.impact}
</div>
<div className="flex flex-wrap gap-2">
{item.skills.map((skill) => (
<span key={skill} className="text-xs px-3 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
{skill}
</span>
))}
</div>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
</section>
)
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import Link from 'next/link'
import { motion } from 'framer-motion'
import { LucideIcon } from 'lucide-react'
import { LiveIndicator } from './LiveIndicator'
interface FeatureCardProps {
icon: LucideIcon
title: string
description: string
href: string
stat?: string
status?: 'live' | 'offline' | 'pending'
}
export function FeatureCard({
icon: Icon,
title,
description,
href,
stat,
status = 'live',
}: FeatureCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<Link href={href}>
<motion.div
whileHover={{ y: -8, boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)' }}
className="h-full border border-gray-200 dark:border-gray-700 rounded-lg p-6 hover:shadow-lg transition-shadow cursor-pointer bg-white dark:bg-gray-900"
>
<div className="flex items-start justify-between mb-4">
<Icon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
<LiveIndicator status={status} />
</div>
<h3 className="text-lg font-bold mb-2">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{description}
</p>
{stat && (
<div className="text-xs font-mono text-blue-600 dark:text-blue-400">
{stat}
</div>
)}
</motion.div>
</Link>
</motion.div>
)
}
+65
View File
@@ -0,0 +1,65 @@
'use client'
import Link from 'next/link'
import { Menu, X, Moon, Sun } from 'lucide-react'
import { useState, useEffect } from 'react'
export default function Header() {
const [open, setOpen] = useState(false)
const [dark, setDark] = useState(false)
useEffect(() => {
const isDark = document.documentElement.classList.contains('dark')
setDark(isDark)
}, [])
const toggleDark = () => {
document.documentElement.classList.toggle('dark')
setDark(!dark)
}
return (
<header className="sticky top-0 z-50 border-b border-gray-200 dark:border-gray-800 bg-white/95 dark:bg-gray-950/95 backdrop-blur">
<div className="max-w-6xl mx-auto px-6 py-4 flex justify-between items-center">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
RL
</div>
<div>
<div className="text-sm font-bold text-gray-900 dark:text-white">Rock Liang</div>
<div className="text-xs text-gray-600 dark:text-gray-400">Software Engineer Lead</div>
</div>
</Link>
<nav className="hidden md:flex gap-8 items-center">
<Link href="/" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Home</Link>
<Link href="/cluster" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Cluster</Link>
<Link href="/infrastructure" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Infrastructure</Link>
<Link href="/kafka" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Kafka</Link>
<Link href="/cicd" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">CI/CD</Link>
<Link href="/agent" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">Agent</Link>
<button onClick={toggleDark} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded">
{dark ? <Sun size={20} /> : <Moon size={20} />}
</button>
</nav>
<button
onClick={() => setOpen(!open)}
className="md:hidden p-2"
>
{open ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{open && (
<div className="md:hidden border-t border-gray-200 dark:border-gray-800 p-4 space-y-2">
<Link href="/" className="block py-2 text-sm hover:text-blue-600">Home</Link>
<Link href="/infrastructure" className="block py-2 text-sm hover:text-blue-600">Infrastructure</Link>
<Link href="/kafka" className="block py-2 text-sm hover:text-blue-600">Kafka</Link>
<Link href="/cicd" className="block py-2 text-sm hover:text-blue-600">CI/CD</Link>
<Link href="/agent" className="block py-2 text-sm hover:text-blue-600">Agent</Link>
</div>
)}
</header>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { motion, AnimatePresence } from 'framer-motion'
import { useState, useEffect } from 'react'
import { Code, Network, Zap, Sparkles, Github } from 'lucide-react'
import { TypewriterText } from './TypewriterText'
const roles = [
{ text: 'Software Engineer Lead', icon: Code },
{ text: 'Infrastructure Architect', icon: Network },
{ text: 'Distributed Systems Builder', icon: Zap },
{ text: 'LLM Systems Optimizer', icon: Sparkles },
{ text: 'Open Source Developer', icon: Github },
]
export function HeroBlobFlow() {
const [index, setIndex] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setIndex((prev) => (prev + 1) % roles.length)
}, 2000)
return () => clearInterval(interval)
}, [])
return (
<div className="relative w-full max-w-md mx-auto aspect-square flex flex-col items-center justify-center">
{/* Background glow */}
<div className="absolute inset-0">
<motion.div
animate={{
scale: [1, 1.15, 1],
opacity: [0.25, 0.4, 0.25],
}}
transition={{
duration: 6,
repeat: Infinity,
ease: 'easeInOut',
}}
className="absolute inset-0 bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 rounded-full blur-3xl"
/>
</div>
{/* Avatar center */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="relative z-10 w-32 h-32"
>
<div className="w-full h-full rounded-full bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center text-white font-bold text-4xl border-4 border-white dark:border-gray-950 shadow-xl">
RL
</div>
</motion.div>
{/* Typewriter text below avatar */}
<TypewriterText />
{/* Rotating role text */}
<div className="relative z-10 mt-12 px-6 w-full flex items-center justify-center">
<AnimatePresence mode="wait">
<motion.div
key={index}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
transition={{ duration: 0.6, ease: 'easeInOut' }}
className="absolute text-2xl font-semibold text-gray-900 dark:text-white text-center px-6 flex items-center gap-3 justify-center"
>
{(() => {
const Icon = roles[index].icon
return <Icon size={28} className="text-blue-600 dark:text-blue-400 flex-shrink-0" />
})()}
{roles[index].text}
</motion.div>
</AnimatePresence>
</div>
</div>
)
}
+148
View File
@@ -0,0 +1,148 @@
'use client'
import { motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
const commands: Record<string, string> = {
help: `Available commands:
ls - List services
kubectl - Cluster info
terraform - Infrastructure
argocd - Deployments
kafka - Message broker
about - About me
clear - Clear terminal`,
ls: `storage/
monitoring/
cicd/
sqs/
temporal/
iam/`,
kubectl: `Nodes: 3 (talos-cp-1, talos-worker-1, talos-worker-2)
Pods: 42 running
Uptime: 99.2%
Cluster Version: v1.36.1`,
terraform: `Modules: 18 deployed
Services: Kafka, PostgreSQL, MinIO, Grafana
Storage: Longhorn (3-replica)
Network: Cilium eBPF CNI`,
argocd: `Applications: 12
Synced: 11/12
Last Sync: 2 min ago
Health: Healthy`,
kafka: `Brokers: 3 (KRaft)
Replication Factor: 3
Min ISR: 2
Topics: 5 active
Throughput: ~1K msgs/sec`,
about: `Rock Liang - Senior Full-Stack Systems Engineer
Experience: 5+ years (AWS, RBC, Homelab)
Focus: Infrastructure × Backend × LLM Systems
Tech: Go, Java, Python, C++ | Kubernetes, Terraform, gRPC
Current: Building production homelab + LLM inference optimization
Open Source: go-flink (distributed DataLakeHouse)`,
}
export function InteractiveTerminal() {
const [input, setInput] = useState('')
const [history, setHistory] = useState<{ cmd: string; output: string }[]>([])
const [isOpen, setIsOpen] = useState(false)
const terminalRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setIsOpen(!isOpen)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen])
const handleCommand = (cmd: string) => {
const trimmed = cmd.trim().toLowerCase()
if (trimmed === 'clear') {
setHistory([])
setInput('')
return
}
const output = commands[trimmed] || `command not found: ${cmd}`
setHistory([...history, { cmd, output }])
setInput('')
setTimeout(() => {
terminalRef.current?.scrollTo(0, terminalRef.current.scrollHeight)
}, 0)
}
return (
<motion.div
className="fixed bottom-6 right-6 z-40"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
{!isOpen ? (
<button
onClick={() => setIsOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-mono text-sm shadow-lg"
>
Terminal (Cmd+K)
</button>
) : (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gray-950 border border-gray-700 rounded-lg shadow-2xl w-96 h-96 flex flex-col"
>
<div className="bg-gray-900 border-b border-gray-700 px-4 py-3 flex justify-between items-center">
<span className="text-gray-300 font-mono text-xs">ZSH_THEME="robbyrussell"</span>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-white"
>
✕
</button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-y-auto px-4 py-3 font-mono text-sm text-gray-200 space-y-2"
>
{history.length === 0 && (
<div className="text-gray-500">type 'help' for commands</div>
)}
{history.map((entry, i) => (
<div key={i}>
<div className="text-green-400"><span className="text-red-400">➜</span> % {entry.cmd}</div>
<div className="text-gray-300 whitespace-pre-wrap text-xs mt-1">
{entry.output}
</div>
</div>
))}
</div>
<div className="border-t border-gray-700 px-4 py-2">
<div className="flex items-center">
<span className="text-red-400 font-mono text-sm">➜</span>
<span className="text-green-400 font-mono text-sm ml-2">% </span>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') handleCommand(input)
}}
placeholder=""
className="flex-1 bg-transparent text-green-400 font-mono text-sm outline-none ml-1"
autoFocus
/>
</div>
</div>
</motion.div>
)}
</motion.div>
)
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { motion } from 'framer-motion'
interface LiveIndicatorProps {
status: 'live' | 'offline' | 'pending'
label?: string
}
export function LiveIndicator({ status, label }: LiveIndicatorProps) {
const colors = {
live: 'bg-green-500',
offline: 'bg-red-500',
pending: 'bg-yellow-500',
}
const labels = {
live: 'Live',
offline: 'Offline',
pending: 'Pending',
}
return (
<div className="flex items-center gap-2">
<motion.div
animate={status === 'live' ? { scale: [1, 1.3, 1] } : {}}
transition={{ duration: 2, repeat: Infinity }}
className={`w-2 h-2 rounded-full ${colors[status]}`}
/>
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{label || labels[status]}
</span>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
'use client'
import { motion } from 'framer-motion'
interface ProgressiveTextProps {
texts: string[]
className?: string
}
export function ProgressiveText({ texts, className }: ProgressiveTextProps) {
const container = {
hidden: { opacity: 0 },
visible: (i = 1) => ({
opacity: 1,
transition: { staggerChildren: 0.12, delayChildren: 0.04 * i },
}),
}
const child = {
hidden: { opacity: 0, y: 10 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5 },
},
}
return (
<motion.div
className={className}
variants={container}
initial="hidden"
animate="visible"
>
{texts.map((text, i) => (
<motion.div key={i} variants={child} className="inline">
{text}
{i < texts.length - 1 && ' '}
</motion.div>
))}
</motion.div>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
const phrases = [
'a Senior Systems engineer',
'Rock Liang',
'a Senior Full-stack builder',
'a Senior Infrastructure architect',
'a Senior Backend SDE',
]
export function TypewriterText() {
const [currentPhrase, setCurrentPhrase] = useState(0)
const [displayText, setDisplayText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
useEffect(() => {
const phrase = phrases[currentPhrase]
const delay = isDeleting ? 50 : 100
const timer = setTimeout(() => {
if (!isDeleting) {
if (displayText.length < phrase.length) {
setDisplayText(phrase.substring(0, displayText.length + 1))
} else {
setTimeout(() => setIsDeleting(true), 1500)
}
} else {
if (displayText.length > 0) {
setDisplayText(phrase.substring(0, displayText.length - 1))
} else {
setIsDeleting(false)
setCurrentPhrase((prev) => (prev + 1) % phrases.length)
}
}
}, delay)
return () => clearTimeout(timer)
}, [displayText, isDeleting, currentPhrase])
return (
<div className="relative z-10 mt-6 h-10 flex items-center justify-center">
<span className="text-2xl font-semibold text-gray-800 dark:text-gray-200">
I'm <span className="text-blue-600 dark:text-blue-400">{displayText}</span>
<motion.span
animate={{ opacity: [1, 0] }}
transition={{ duration: 0.6, repeat: Infinity }}
className="inline-block w-0.5 h-7 ml-1 bg-blue-600 dark:bg-blue-400"
/>
</span>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
type Props = {
label: string
hint?: string
right?: ReactNode
children: ReactNode
className?: string
}
/** Instrument panel chrome: hairline frame, corner ticks, eyebrow label. */
export function Panel({ label, hint, right, children, className = '' }: Props) {
return (
<section className={`relative border border-wire bg-deck ${className}`}>
<span className="pointer-events-none absolute -left-px -top-px h-2 w-2 border-l border-t border-wire-lit" />
<span className="pointer-events-none absolute -right-px -top-px h-2 w-2 border-r border-t border-wire-lit" />
<span className="pointer-events-none absolute -bottom-px -left-px h-2 w-2 border-b border-l border-wire-lit" />
<span className="pointer-events-none absolute -bottom-px -right-px h-2 w-2 border-b border-r border-wire-lit" />
<header className="flex items-baseline justify-between gap-4 border-b border-wire px-4 py-2.5">
<div className="flex items-baseline gap-3">
<h2 className="font-data text-[11px] uppercase tracking-[0.18em] text-chalk">{label}</h2>
{hint && <p className="font-data text-[10px] text-dim">{hint}</p>}
</div>
{right}
</header>
<div className="p-4">{children}</div>
</section>
)
}
+43
View File
@@ -0,0 +1,43 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const SURFACES = [
{ code: 'B', href: '/cluster', label: 'Topology' },
{ code: 'E', href: '/cluster/delivery', label: 'Delivery' },
{ code: 'C', href: '/cluster/terminal', label: 'Terminal' },
{ code: 'D', href: '/cluster/chat', label: 'Chat' },
]
export function Rail() {
const pathname = usePathname()
return (
<nav
aria-label="Cluster surfaces"
className="flex shrink-0 gap-px overflow-x-auto border-b border-wire bg-void md:w-16 md:flex-col md:overflow-visible md:border-b-0 md:border-r"
>
{SURFACES.map((s) => {
const active = pathname === s.href
return (
<Link
key={s.href}
href={s.href}
aria-current={active ? 'page' : undefined}
className={[
'group flex flex-1 flex-row items-center gap-2 px-4 py-3 transition-colors md:flex-none md:flex-col md:gap-1 md:px-0 md:py-4',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-lamp',
active ? 'bg-deck text-lamp' : 'text-dim hover:bg-riser hover:text-chalk',
].join(' ')}
>
<span className="font-signage text-lg font-semibold leading-none">{s.code}</span>
<span className="font-data text-[9px] uppercase tracking-[0.14em] md:[writing-mode:vertical-rl]">
{s.label}
</span>
</Link>
)
})}
</nav>
)
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { useEffect, useState } from 'react'
import { cluster, nodes } from '@/lib/clusterMock'
import { SlotMeter } from './SlotMeter'
/** Fake liveness so the layout can be judged in motion. Replaced by SSE in Phase 2. */
function useDrift() {
const [age, setAge] = useState<number>(cluster.snapshotAge)
const [slots, setSlots] = useState(3)
useEffect(() => {
const tick = setInterval(() => setAge((a) => (a > 6 ? 0.4 : +(a + 0.9).toFixed(1))), 900)
const churn = setInterval(() => setSlots(() => 1 + Math.floor(Math.random() * 5)), 3400)
return () => {
clearInterval(tick)
clearInterval(churn)
}
}, [])
return { age, slots }
}
export function Ribbon() {
const { age, slots } = useDrift()
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-wire bg-void px-4 py-2.5 font-data text-[11px]">
<span className="flex items-center gap-2 text-chalk">
<span className="h-1.5 w-1.5 rounded-full bg-flux" />
{cluster.distro}
</span>
<span className="text-dim">
k8s <span className="text-chalk">{cluster.kubernetes}</span>
</span>
<span className="text-dim">
nodes <span className="text-chalk">{nodes.length}</span>
</span>
<span className="text-dim">
snapshot <span className="text-chalk">{age.toFixed(1)}s</span>
</span>
<span className="ml-auto flex items-center gap-3">
<span className="uppercase tracking-[0.16em] text-dim">seq</span>
<SlotMeter used={slots} />
<span className="tabular-nums text-chalk">{slots}/8</span>
</span>
<span className="border border-lamp/40 px-1.5 py-0.5 uppercase tracking-[0.16em] text-lamp">
placeholder data
</span>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { SEQUENCE_SLOTS, PUBLIC_SLOT_CAP } from '@/lib/clusterMock'
type Props = {
used: number
size?: 'sm' | 'lg'
showCap?: boolean
}
/**
* The signature element. Eight discrete cells, one per vLLM sequence slot.
* Cells past PUBLIC_SLOT_CAP are operator headroom and read as reserved.
*/
export function SlotMeter({ used, size = 'sm', showCap = false }: Props) {
const cell = size === 'lg' ? 'h-6 w-3' : 'h-3 w-[7px]'
return (
<div className="flex items-center gap-2">
<div className="flex gap-[3px]" role="img" aria-label={`${used} of ${SEQUENCE_SLOTS} sequence slots in use`}>
{Array.from({ length: SEQUENCE_SLOTS }, (_, i) => {
const reserved = i >= PUBLIC_SLOT_CAP
const active = i < used
return (
<span
key={i}
className={[
cell,
'rounded-[1px] transition-colors duration-500',
active ? 'bg-lamp' : reserved ? 'bg-wire' : 'bg-wire-lit/40',
reserved && !active ? 'opacity-50' : '',
].join(' ')}
/>
)
})}
</div>
{showCap && (
<span className="font-data text-[10px] uppercase tracking-widest text-dim">
{used}/{PUBLIC_SLOT_CAP} public · {SEQUENCE_SLOTS} total
</span>
)}
</div>
)
}
+327
View File
@@ -0,0 +1,327 @@
# Implementation Plan: `atlas` — Interactive Cluster Visualization
**Status**: For review — no code written yet
**Companion ADR**: [ADR-0001](adr/ADR-0001-atlas-cluster-visualization.md)
**Supersedes**: `PLAN.md`, `IMPLEMENTATION.md` (Homarr + Terraform — abandoned)
---
## 1. Scope
### In scope
| ID | Surface | Description |
|---|---|---|
| B | Cluster topology | Live node → namespace → workload graph, health-colored, drill-down |
| E | Delivery tree | Argo CD app-of-apps as a sync-wave-ordered DAG, live sync animation |
| C | Terminal | Read-only, enum-dispatched cluster queries in the browser |
| D | Chat | Streaming ChatGPT-style session against the `reasoning` model |
### Out of scope (v1)
- Any write operation against the cluster
- LLM tool-calling / agentic loops against live infrastructure
- Forgejo CI half of the pipeline view (`forgejo-gitea` is currently stuck `Init:0/3`)
- Log streaming to the browser (Loki content is unredactable in practice)
- Authenticated / operator-only views — Grafana already serves that need
### Non-goals
- Replacing Grafana
- Multi-cluster support
- Historical / time-travel views
---
## 2. Phase 0 — Unblock (blocking; nothing ships until done)
Verified problems, in the order they must be fixed:
| # | Problem | Evidence |
|---|---|---|
| 0.1 | `portfolio` namespace is empty; `portfolio` and `auth-infra` Applications do not exist in the cluster | `kubectl get pods -n portfolio` → no resources; neither name appears in `kubectl get app -n argocd` |
| 0.2 | `infra/argocd-apps.yaml` `repoURL` = `forgejo.riotpiao.homelab.com` — NXDOMAIN | `dig` |
| 0.3 | Deployment image `forgejo.riotpiao.homelab.com/rock/portfolio:latest` — dead host, and `:latest` + `imagePullPolicy: IfNotPresent` means a pushed image will never roll out | `infra/portfolio/base/deployment.yaml` |
| 0.4 | `forgejo-gitea` stuck `Init:0/3` for 3h — no image builds possible | `kubectl get pods -n cicd` |
| 0.5 | `riotpiao.com` returns HTTP 403 from the Cloudflare edge; no origin headers present | `curl -I https://riotpiao.com` |
| 0.6 | No test framework installed — TDD is impossible as the repo stands | `package.json` has no test script or runner |
| 0.7 | `sms` Application Degraded (`macos-bluebubbles` Pending 3h); `longhorn-config` OutOfSync | `kubectl get app -n argocd` |
**Decisions required from you before 0.1–0.3 can be actioned** — see ADR open questions 1 and 2.
**Actions**
1. Resolve which GitOps repo owns the portfolio; delete or correct the losing manifest
2. Point `repoURL` and the image reference at real hostnames
3. Replace `:latest` with a commit-SHA tag; set `imagePullPolicy: IfNotPresent` (correct once tags are immutable)
4. Diagnose `forgejo-gitea` init containers — read the actual init container logs before changing anything
5. Diagnose the apex 403 — check the tunnel's Public Hostnames list and Cloudflare WAF events; the event log names the blocking rule
6. Add Vitest + Testing Library + `msw`; add `test` and `test:watch` scripts
7. Triage 0.7 separately — unrelated to this work, but the delivery tree will render both as red on day one
**Verify**
```bash
kubectl get pods -n portfolio # 2/2 Running
curl -sS -o /dev/null -w '%{http_code}\n' https://riotpiao.com # 200
pnpm test # runner executes, 0 tests, exit 0
```
---
## 3. Architecture
```
kube API (client-go informers) ─┐
Prometheus /api/v1/query ├──> atlas (Go, ns: portfolio, RO ServiceAccount)
Argo CD Application CRs ┘ │
├─ snapshot (in-memory, redacted at write)
├─ Redis pub/sub (kmsvc-redis-master.sqs:6379)
└─ HTTP
GET /api/topology
GET /api/delivery
GET /api/stream (SSE)
POST /api/exec
POST /api/chat (SSE)
└──> reasoning-predictor.llm-serving:80
```
Event-driven, per project architectural preference: informers push to a reducer, the reducer publishes deltas to Redis, SSE handlers subscribe. No request-triggered upstream calls anywhere in the read path.
Snapshot is redacted **at write time**, not at serialization time. A field that never enters the snapshot cannot leak from any surface.
### Language
Go, for `client-go` informers and because it matches the rest of the platform. Follow the repo's Go skill set (`go-naming`, `go-concurrency`, `go-error-handling`, `go-context`) — notably: every upstream call carries a context, no naked returns, no `_ =` on errors.
---
## 4. API contract
Envelope for all non-stream responses:
```json
{ "data": { }, "meta": { "snapshotAge": 3.2, "generation": 88412 } }
```
Errors follow RFC 9457 (`application/problem+json`):
```json
{ "type": "https://riotpiao.com/errors/rate-limited",
"title": "Rate limit exceeded",
"status": 429, "detail": "12 of 12 messages used", "retryAfter": 3600 }
```
| Method | Path | Auth | Limit | Response |
|---|---|---|---|---|
| GET | `/api/topology` | none | 60/min/IP | Nodes, namespaces, workload summaries |
| GET | `/api/delivery` | none | 60/min/IP | Argo apps, wave-grouped; resource children lazy |
| GET | `/api/delivery/{app}/resources` | none | 60/min/IP | Virtualized child list for one app |
| GET | `/api/stream` | session cookie | 2 concurrent/IP | SSE deltas, `topology` + `delivery` event types |
| POST | `/api/exec` | session cookie | 20/min/session | Enum command result |
| POST | `/api/chat` | session + Turnstile | 12/day/session, 6 global concurrent | SSE token stream |
**Pagination**: `/api/delivery/{app}/resources` is cursor-paginated at 100 items. `prometheus` has 68 resources today, but `homelab-root`'s tree will grow.
**Payload budget**: topology response capped at 256 KB, delivery at 256 KB. Exceeding the cap truncates and sets `meta.truncated: true` — never a silent drop.
---
## 5. Security
Mapped against OWASP Top 10 (2021). Every item is a Phase gate, not a wish list.
### A01 Broken Access Control
- `atlas` ServiceAccount: one ClusterRole, verbs `get,list,watch` only, explicit resource list. **No `secrets`. No `*`. No wildcards on apiGroups.**
- NetworkPolicy on `atlas`: egress restricted to kube API, `prometheus-operated.monitoring`, `reasoning-predictor.llm-serving`, `kmsvc-redis-master.sqs`. Ingress from `ingress-nginx` only.
- Test: an integration test asserting the SA receives 403 on `get secrets` in every namespace.
### A02 Cryptographic Failures
- Session cookie: signed (HMAC), `HttpOnly`, `Secure`, `SameSite=Lax`, 24h expiry. No PII in the payload — a random session ID only.
- Signing key from a Kubernetes Secret via SOPS (`sops-secrets` app already exists), never an env literal in a manifest.
### A03 Injection
The primary risk on surface C. Mitigation is structural, not filtering:
- Input parses to a closed command enum. Anything unmatched is rejected before any lookup.
- Namespace and resource-name arguments are validated by **set membership against the current snapshot**, not by regex or escaping.
- No shell, no `exec`, no `kubectl` binary present in the container image.
- Test: fuzz the parser; assert every input outside the allowlist returns a rejection and performs zero upstream calls.
### A04 Insecure Design — information disclosure
The core risk of the whole project. Redaction allowlist, enforced by DTO construction:
**Emitted**: name, namespace, kind, phase, ready counts, restart count, age, node name, health status, sync status, sync wave, an explicit label subset.
**Never emitted**: container env, container args, image digests, image tags, `spec.source.repoURL`, full `spec.source.path`, annotations, pod IPs, cluster IPs, Secret names, `status.conditions[].message`, node internal IPs.
Specific known leaks in current data:
- `reasoning` container args disclose the entire model and quantization strategy
- `spec.source.repoURL` discloses a private GitHub repository
- 21 `Secret` resources appear in Argo trees — render **kind and count only, never names**; `sops-secrets` included
- `status.conditions[].message` echoes raw errors containing internal hostnames — emit condition **type** only
Test: golden test asserting the serialized snapshot contains none of the denied field names, run against a fixture captured from the real cluster.
### A05 Security Misconfiguration
- Container: `runAsNonRoot`, read-only root filesystem, all capabilities dropped, `seccompProfile: RuntimeDefault`
- Security headers on all responses: `Content-Security-Policy` (no `unsafe-inline`), `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Strict-Transport-Security`
- CORS: same-origin only. No wildcard.
### A07 Authentication Failures
- Anonymous by design; Cloudflare Turnstile gates the first chat message
- Session rotation on issue; no session fixation vector since there is no login
### A08 Software and Data Integrity
- Image tags are commit SHAs, never `:latest` (fixes Phase 0.3)
- `pnpm audit` and `govulncheck` in CI, failing the build on high severity
### A09 Logging and Monitoring
- Structured logs: every rejected `/api/exec` input, every rate-limit trip, every chat queue rejection
- Prometheus metrics from `atlas`: `atlas_chat_concurrent`, `atlas_chat_queue_depth`, `atlas_ratelimit_rejections_total`, `atlas_snapshot_age_seconds`
- Alertmanager rule: chat queue saturated > 5 min, snapshot age > 60s
### A10 SSRF
- `atlas` calls a fixed, compile-time list of upstream URLs. No user input reaches any URL construction, on any path.
### LLM-specific: prompt injection
- System prompt is a compile-time constant, unreachable by user input
- Cluster snapshot digest is injected in a delimited block explicitly labelled untrusted data
- User message is always last
- **No tool-calling.** The model reads a pre-built digest and cannot query anything. This removes the entire agentic attack surface for v1.
- Output capped at `max_tokens: 1500` — DeepSeek-R1 will otherwise reason for minutes
---
## 6. Rate limiting
Sized against the verified hard ceiling: **8 concurrent sequences** (`--max-num-seqs=4` × 2 replicas), single GPU node.
**Tier 1 — Cloudflare edge.** WAF, Bot Fight Mode, per-IP rate rules, Turnstile before first chat message. Free, and stops scripted abuse before it reaches your hardware.
**Tier 2 — Kong.** `rate-limiting` plugin, `policy: redis` against `kmsvc-redis-master.sqs:6379` so counters are cluster-wide rather than per-pod (Kong runs 2 replicas — a local policy would silently double every limit). Two profiles: generous for topology and delivery, tight for chat.
**Tier 3 — `atlas`, the tier that actually protects the GPU.**
| Control | Value | Rationale |
|---|---|---|
| Global chat semaphore | 6 | Leaves 2 of 8 sequence slots as operator headroom |
| Queue depth | 20, then reject with `429` | A visible queue beats a wall; an unbounded queue beats nothing |
| Per-session budget | 12 messages / 24h | Enough to explore, not enough to farm |
| Per-request timeout | 120s hard, server-side | Independent of client behaviour |
| Disconnect handling | cancel upstream immediately | **Critical** — a walked-away tab holding 1 of 8 slots is a real outage |
Queue position is streamed to the client as SSE `{"type":"queue","position":N}` events, so waiting is legible rather than a hang.
---
## 7. Streaming
SSE throughout — unidirectional server→client fits every surface, including token streaming. WebSockets are not justified; nothing flows client→server mid-stream.
- Keepalive comment frame every 30s (idle SSE connections die at proxies)
- `Last-Event-ID` supported on `/api/stream` for resumable topology/delivery deltas; chat is not resumable
- `req.Context()` threaded to the upstream vLLM request so client abort cancels it — this is the mechanism that enforces the Tier 3 disconnect rule
- Backpressure: bounded per-client channel; a slow consumer is dropped rather than allowed to grow memory
- Chat events: `{"type":"reasoning"|"content"|"queue"|"done"|"error"}`. `--reasoning-parser=deepseek_r1` already separates `reasoning_content` from `content` — render thinking in a collapsible block. That block **is** the demo.
**Context budget** (16384 total): system prompt ~300, snapshot digest capped at 2000, `max_tokens` 1500, leaving ~12500 for history. History is truncated oldest-first to fit. The digest is a compact rendering, never raw JSON.
---
## 8. Frontend
- **Topology (B)**: React Flow, force layout. Node → namespace → workload.
- **Delivery (E)**: React Flow, wave columns left→right from the existing `sync-wave` annotations (0→8). 31 app nodes — an ideal size for a readable DAG. Click an app → side panel with a `react-arborist` virtualized resource tree, children lazy-loaded. Live sync animation `OutOfSync → Syncing → Synced` driven by the Application watch. Push a commit during a demo and the wave cascades — that is the moment worth engineering for.
- **Terminal (C)**: reuse [components/InteractiveTerminal.tsx](../components/InteractiveTerminal.tsx). Command set: `get nodes`, `get pods <ns>`, `get apps`, `top nodes`, `describe pod <ns> <name>`, `help`.
- **Chat (D)**: new component. Collapsible reasoning block, queue position, streaming tokens.
Rendering budget: ~550 resources total across the tree. Lazy expansion plus virtualization is required, not optional. Target: initial delivery view interactive in < 1.5s on a cold load.
**Also in scope**: delete the fabricated statistics in [app/page.tsx](../app/page.tsx) — "40% CPU reduction", "99.2% uptime", "Mission-critical", "60% latency cut" — and either wire each card to a real number from `/api/topology` or remove the claim. Five of the six cards link to routes that do not exist (`/infrastructure`, `/systems`, `/llm`, `/kafka`, `/opensource`).
---
## 9. Phases, TDD-first
Each phase is RED → GREEN → REFACTOR. Tests named before implementation exists.
### Phase 1 — `atlas` core (~4 days)
RED
- `redact_test.go`: golden test — serialized snapshot contains no denied field, against a real-cluster fixture
- `rbac_test.go`: SA receives 403 on `get secrets`
- `snapshot_test.go`: informer event produces the expected delta
GREEN: ClusterRole, informers, reducer, DTO construction, Redis publish.
Verify: `kubectl auth can-i get secrets --as=system:serviceaccount:portfolio:atlas` → `no`.
### Phase 2 — Surface B (~3 days)
RED
- `stream_test.go`: SSE emits a delta within 5s of a pod state change
- `topology.test.tsx`: graph re-renders on delta without a full reload
Verify: delete a pod, observe the graph update in < 5s without reloading.
### Phase 3 — Surface E (~3 days)
RED
- `delivery_test.go`: apps group correctly by `sync-wave`; Secret names absent from output; `repoURL` absent from output
- `delivery.test.tsx`: 550-node tree renders under the frame budget with virtualization on
Verify: trigger an Argo sync, observe wave-ordered animation.
### Phase 4 — Surface C (~2 days)
RED
- `exec_parse_test.go`: fuzz corpus — every non-allowlisted input rejects and performs zero upstream calls
- `exec_test.go`: unknown namespace rejects on snapshot membership, not regex
Verify: attempt injection payloads against `/api/exec`; all rejected, all logged.
### Phase 5 — Surface D + rate limiter, one PR (~5 days)
RED
- `ratelimit_test.go`: 7th concurrent chat queues rather than reaching vLLM
- `disconnect_test.go`: client abort cancels the upstream request
- `budget_test.go`: 13th message in 24h returns 429 with `Retry-After`
- `injection_test.go`: snapshot content cannot alter system-prompt behaviour
- `context_test.go`: history truncation keeps total tokens under 16384
Verify: load test at 20 concurrent clients — GPU sequence usage never exceeds 6, no upstream 5xx, queue drains.
**Total: ~17 working days.** Rate limiter ships in the same PR as chat, never after.
---
## 10. Risks
| Risk | Impact | Mitigation |
|---|---|---|
| Redaction miss leaks internal detail | High | Allowlist by DTO construction; golden test; manual review of every new field |
| `worker-1` fails | Chat and all inference down | Out of scope to fix; degrade chat gracefully to "offline", never a hang |
| 8-slot ceiling makes chat feel broken under traffic | Medium | Visible queue position; consider disabling chat and shipping B/E/C only |
| Argo CD CRD schema changes | Low | Allowlist construction means new fields are ignored by default |
| `atlas` compromised | High | Read-only SA, NetworkPolicy egress restriction, no write verbs anywhere |
| Cost of GPU inference for anonymous visitors | Medium | Per-session daily budget; Turnstile; edge WAF |
---
## 11. Decisions needed before Phase 1
1. **Which GitOps repo owns the portfolio** — GitHub (`riotpiao.homelab.com`) or Forgejo? Blocks 0.1–0.3.
2. **Apex 403 cause** — tunnel route, WAF rule, or no origin? Blocks 0.5.
3. **Is `homarr` still wanted?** Deployed and healthy, but from the abandoned plan.
4. **Does chat stay in v1?** Given the 8-slot ceiling, shipping B + E + C first and treating D as a separate decision is defensible.
5. **Where does `atlas` live** — this repo, or the homelab repo? Follows from decision 1.
@@ -0,0 +1,285 @@
# ADR-0001: `atlas` — Single Read-Only Aggregator for Public Cluster Visualization
**Status**: Proposed
**Date**: 2026-08-13
**Authors**: Rock Liang
**Supersedes**: `PLAN.md`, `IMPLEMENTATION.md` (Homarr + Terraform approach — abandoned; cluster is now GitOps/Kustomize + Argo CD)
---
## Context
### Problem Statement
The homelab cluster is fully operational and runs a non-trivial platform: Talos Kubernetes, GPU-backed LLM inference, event streaming, GitOps delivery, and a full observability stack. None of it is visible to anyone but the operator. The portfolio site meant to showcase it displays **hardcoded, fabricated statistics** and links to pages that do not exist.
Goal: replace fabricated claims with a live, interactive, public view of the real system.
### Current Situation (verified 2026-08-13)
**Cluster**
| Property | Value |
|---|---|
| Distro | Talos v1.13.3, Kubernetes v1.36.1 |
| Nodes | 3× control-plane (`talos-cp-1/2/3`) + 1× `worker-1` (role `gpu-node`) |
| `worker-1` allocatable | 95950m CPU, 65019644Ki memory, 1× `nvidia.com/gpu` |
| Ingress | nginx, LoadBalancer `192.168.1.160`; Kong `10.105.63.160` for `api.riotpiao.com` |
| Namespaces | 25 |
**GitOps**
- `homelab-root` is an app-of-apps: 31 child `Application` resources
- Source: `[email protected]:Riotpiaole/riotpiao.homelab.com.git`, path `k8s/argocd/apps`
- Sync waves **0 → 8** already annotated across apps
- ~550 managed resources total (largest: `prometheus` 68, `cert-manager` 47, `kong` 39)
**LLM serving** (`llm-serving`, KServe, all 5 `InferenceService` Ready)
| Model | Notes |
|---|---|
| `reasoning` | `unsloth/DeepSeek-R1-Distill-Qwen-32B-bnb-4bit`, vLLM `v0.11.0` |
| `ornith`, `embeddings`, `reranker`, `verifier` | 1 replica each |
`reasoning` runtime args, verbatim: `--max-num-seqs=4`, `--max-model-len=16384`, `--gpu-memory-utilization=0.90`, `--enable-prefix-caching`, `--reasoning-parser=deepseek_r1`. `minReplicas: 2`, `maxReplicas: 2`, pinned to `worker-1`.
**Existing building blocks**
- Prometheus (kube-prometheus-stack) + kube-state-metrics + node-exporter + blackbox + Alertmanager — `monitoring`
- Loki + promtail + Grafana — `logging`
- Kong 3.9 + Kubernetes Ingress Controller 3.5 — `api`, 2 replicas
- Redis (`kmsvc-redis-master.sqs.svc.cluster.local:6379`), 1 master + 3 replicas
- Next.js 15.5 / React 19.2 portfolio source in this repo (not deployed)
**Public exposure** (verified by DNS + HTTP probe)
```
api.riotpiao.com NXDOMAIN
argocd|grafana|vault|longhorn|prometheus|minio|temporal|forgejo|portainer.riotpiao.com
NXDOMAIN
riotpiao.com 172.67.196.33 / 104.21.60.115 (Cloudflare) → HTTP 403 at edge
```
Nothing in the cluster is currently reachable from the public internet. The Cloudflare tunnel has no public hostnames wired (cloudflared auto-creates a CNAME per public hostname; no CNAME exists).
### Requirements
1. Public, anonymous, interactive visualization of live cluster state
2. Live Argo CD delivery pipeline view, ordered by sync wave
3. Read-only browser terminal for cluster queries
4. Streaming chat against the `reasoning` model, ChatGPT-style
5. No internal service becomes publicly reachable as a side effect
6. No fabricated statistics anywhere on the site
### Constraints
- **Hard capacity ceiling: 8 concurrent LLM sequences** (`--max-num-seqs=4` × 2 replicas). Single GPU. Not horizontally scalable without more hardware.
- **Context ceiling: 16384 tokens** (`--max-model-len`)
- Single GPU node — `worker-1` is a single point of failure for all inference
- Solo operator, part-time
- Cluster is GitOps-managed; every change ships through git → Argo CD (per project hard rules)
- No test framework currently installed in the portfolio repo
### Forces
- **Impressiveness vs. attack surface** — the most impressive surfaces (terminal, chat) are the most dangerous
- **Live data vs. information disclosure** — real cluster state is the whole point, and real cluster state is exactly what an attacker wants for reconnaissance
- **Anonymous access vs. abuse** — requiring login kills the portfolio demo; not requiring it exposes 8 GPU slots to the open internet
- **Four surfaces vs. one operator** — four independently-built backends is four times the security review
---
## Decision
**We will build one read-only aggregator service, `atlas`, that is the sole public entry point to all cluster data, serving four presentation surfaces from one shared in-memory snapshot.**
### Architecture
```
kube API (informers) ─┐
Prometheus /api/v1 ├──> atlas (Go, ns: portfolio, read-only ServiceAccount)
Argo CD Application CRs┘ │
├─ snapshot: in-memory, redacted at write time
├─ Redis pub/sub (kmsvc-redis) — cross-replica fanout
│
└─ HTTP surfaces
GET /api/topology + /api/stream (B: cluster topology)
GET /api/delivery + /api/stream (E: Argo CD tree)
POST /api/exec (C: terminal)
POST /api/chat (D: chat) ──> reasoning-predictor.llm-serving
```
### Core invariants
These are the load-bearing decisions. Everything else is implementation detail.
**I1 — One public hostname, forever.**
`riotpiao.com` is the only name that ever gets a public DNS record. `argocd`, `grafana`, `vault`, `longhorn`, `prometheus`, `minio`, `temporal`, `forgejo`, `portainer` stay NXDOMAIN permanently. Every new public record is a new thing to defend, and `atlas` proxying makes all of them unnecessary.
**I2 — The browser never talks to an internal API.**
No kube API, no Prometheus, no Argo CD API, no vLLM endpoint is reachable from a browser. `atlas` is the only origin. One choke point for rate limiting, redaction, and audit.
**I3 — Redaction is an allowlist, never a denylist.**
Fields are serialized by explicit construction into DTO structs. A new field appearing in an upstream CRD cannot leak by default, because nothing copies it.
**I4 — No free-form string ever reaches an internal system.**
The terminal parses to a closed command enum. Resource names are validated by **membership in the current snapshot**, not by regex. The chat model reads a pre-built snapshot digest and has no tool-calling ability.
**I5 — Global GPU concurrency is capped below physical capacity.**
Hard semaphore at **6** concurrent chat streams, leaving 2 of 8 sequence slots as operator headroom. Client disconnect cancels the upstream vLLM request immediately.
### Technology
| Component | Choice | Rationale |
|---|---|---|
| Aggregator | Go, `client-go` informers | Watch-based, not poll-per-request; N visitors ≠ N API calls |
| Fanout | Redis pub/sub (existing `kmsvc-redis`) | Multi-replica `atlas` shares one snapshot stream; no new infrastructure |
| Transport | SSE | Unidirectional server→client fits every surface, including LLM token streaming |
| Topology + delivery graph | React Flow | Both are graphs; one library, one mental model |
| Resource drill-down | `react-arborist` | Virtualized; the 68-resource apps must not jank |
| Edge protection | Cloudflare WAF + Turnstile | Free; stops scripted abuse before it costs a packet |
| Gateway limits | Kong `rate-limiting`, `policy: redis` | Cluster-wide counters, not per-pod; Kong 3.9 OSS ships it |
### Implementation strategy — phased, in dependency order
| Phase | Deliverable | Why this position |
|---|---|---|
| 0 | Unblock deployment | Nothing is visible until this lands |
| 1 | `atlas` core: RBAC, informers, redaction | Every surface depends on it |
| 2 | Surface B — cluster topology | Proves the snapshot + SSE pipeline end to end |
| 3 | Surface E — Argo CD delivery tree | Zero new data sources, zero new attack surface, highest signal |
| 4 | Surface C — read-only terminal | First surface accepting user input |
| 5 | Surface D — chat + full rate limiter | Highest risk, highest cost; ships last, ships with its limiter |
Surface E precedes C and D deliberately: it reuses Phase 1 data wholesale and is the surface that reads as platform engineering rather than hobby.
**Timeline**: ~3 weeks part-time. **Responsibility**: solo.
---
## Consequences
### Positive
- Fabricated portfolio statistics replaced by live, verifiable data
- One service to secure, rate-limit, audit, and operate instead of four
- Sync-wave DAG makes a real dependency-ordering decision legible to a visitor in seconds
- Informer-based design means visitor traffic does not load the kube API
- Invariant I1 leaves the cluster's public footprint at exactly one hostname
- Existing `InteractiveTerminal.tsx` and `LiveIndicator.tsx` get real backing
### Negative
- New production service to build, secure, and maintain — currently zero
- `atlas` becomes a high-value target: it holds cluster-wide read access by design
- 8-slot GPU ceiling means chat will queue under real traffic; a "please wait" queue is a worse first impression than no chat at all
- Public chat on a single GPU node has a genuine cost/abuse tail even behind three tiers of limiting
- Redaction is permanent maintenance: every new field surfaced is a new disclosure review
- Test framework must be added to the repo before any of this can be built TDD-first
### Neutral
- Grafana remains for operator use; `atlas` is presentation-only and never replaces it
- Argo CD API is deliberately not used in v1 — `Application` CRs are read via the same informer, so no Argo CD token is ever minted
- `worker-1` remains a single point of failure; this ADR does not change that, only exposes it
---
## Alternatives Considered
### Alternative 1: Grafana dashboards + public iframe embeds
**Description**: Build dashboards on existing data, expose a read-only Grafana org, embed in the portfolio.
**Pros**
- Approximately one day of work
- Zero new services, zero new code, zero new attack surface of our own making
- Data already flows
**Cons**
- Grafana's design, not the portfolio's — reads as a screenshot, not a product
- Requires making Grafana publicly reachable, violating invariant I1
- Iframes leak internal metric names, job labels, and namespace structure with no redaction layer available
- No path to the terminal or chat surfaces
**Why not chosen**: The one-day cost is real, but it forces a second public hostname and surrenders all control over what is disclosed. Grafana has no allowlist redaction model. Kept as the fallback if Phase 1 proves too expensive.
### Alternative 2: Four independent backends, one per surface
**Description**: Separate services for topology, delivery, terminal, and chat.
**Pros**
- Blast radius isolation — a terminal compromise does not reach the chat service
- Independent scaling and deployment
- Aligns with the microservices instinct
**Cons**
- Four RBAC policies, four redaction layers, four rate limiters, four security reviews
- Four independent informer sets hammering the kube API for the same data
- Solo operator; four services will not receive equal maintenance attention
- Cross-surface consistency (terminal and topology disagreeing about pod state) becomes a real bug class
**Why not chosen**: Blast-radius isolation is genuine, but every surface needs the *same* read-only snapshot. Duplicating the highest-risk component — cluster-wide read access — four times increases total exposure rather than reducing it. Rejected on the specific ground that the shared component is the dangerous one.
### Alternative 3: Static snapshot generated at build time
**Description**: CI job dumps cluster state to JSON at build; site renders it statically. No runtime cluster access at all.
**Pros**
- Zero runtime attack surface — no live credentials anywhere near the public internet
- Trivially cacheable, effectively free to serve, cannot be DoS'd
- No rate limiting needed
**Cons**
- Not live; "interactive" degrades to "pre-rendered"
- Kills the terminal and chat surfaces entirely
- The sync-wave cascade animation — the single best demo moment — is impossible
- Data staleness makes the fabricated-statistics problem better but not solved
**Why not chosen**: Fails requirements 1, 3, and 4. Worth revisiting for the topology surface alone if runtime cost becomes a problem.
### Alternative 4: Authentik-gated access to all surfaces
**Description**: Put the existing Authentik SSO in front of the whole visualization.
**Pros**
- Abuse problem largely disappears; rate limiting becomes a formality
- Authentik is already deployed and working
- Redaction requirements relax substantially for authenticated viewers
**Cons**
- Nobody creates an account to look at a stranger's homelab — the demo dies
- Defeats the entire purpose of a public portfolio
- Still requires a public Authentik hostname, violating I1
**Why not chosen**: Directly contradicts requirement 1. Anonymous access plus Cloudflare Turnstile achieves most of the abuse resistance without the conversion cliff. Reconsider only if abuse proves unmanageable in production.
---
## References
- Cluster state verified 2026-08-13 via `kubectl` against context `admin@homelab-cluster`
- Public exposure verified 2026-08-13 via `dig` + `curl` against `riotpiao.com` and subdomains
- vLLM engine args: `kubectl get inferenceservice reasoning -n llm-serving -o jsonpath='{.spec}'`
- Superseded: [PLAN.md](../../PLAN.md), [IMPLEMENTATION.md](../../IMPLEMENTATION.md)
- Companion implementation plan: [PLAN-atlas.md](../PLAN-atlas.md)
---
## Review Notes (Before Acceptance)
**Open questions requiring an answer before Phase 1**
1. **Two GitOps roots exist.** `homelab-root` reads `[email protected]:Riotpiaole/riotpiao.homelab.com.git`. This repo's `infra/argocd-apps.yaml` points at `forgejo.riotpiao.homelab.com` — a hostname that does not resolve — and its `portfolio` and `auth-infra` Applications **do not exist in the cluster**. Which repository is authoritative for the portfolio?
2. **Apex returns 403 from the Cloudflare edge.** Is this an absent tunnel public-hostname route, a WAF rule, or a proxied record with no origin? The portfolio cannot ship until this is understood.
3. Is `homarr` still wanted? It is deployed and healthy, but the plan it came from is abandoned.
4. Does the chat surface stay in scope given the 8-slot ceiling, or ship topology + delivery + terminal first and treat chat as a separate decision?
**Approval**
- [ ] Architecture — invariants I1–I5 accepted
- [ ] Security — redaction allowlist and rate-limit tiers accepted
- [ ] Scope — four surfaces vs. three
+59
View File
@@ -0,0 +1,59 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: portfolio
namespace: argocd
spec:
project: default
source:
repoURL: https://forgejo.riotpiao.homelab.com/rock/riotpiao.git
targetRevision: main
path: infra/portfolio/base
destination:
server: https://kubernetes.default.svc
namespace: portfolio
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: homarr
namespace: argocd
spec:
project: default
source:
repoURL: https://forgejo.riotpiao.homelab.com/rock/riotpiao.git
targetRevision: main
path: infra/homarr/base
destination:
server: https://kubernetes.default.svc
namespace: dashboard
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: auth-infra
namespace: argocd
spec:
project: default
source:
repoURL: https://forgejo.riotpiao.homelab.com/rock/riotpiao.git
targetRevision: main
path: infra/auth-infra/base
destination:
server: https://kubernetes.default.svc
syncPolicy:
automated:
prune: true
selfHeal: true
@@ -0,0 +1,83 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: longhorn-protected
namespace: longhorn-system
annotations:
nginx.ingress.kubernetes.io/auth-url: http://authentik-server.iam.svc.cluster.local/outpost.goauthentik.io/auth/nginx
nginx.ingress.kubernetes.io/auth-signin: https://authentik.riotpiao.homelab.com/outpost.goauthentik.io/start?rd=$scheme://$http_host$escaped_request_uri
nginx.ingress.kubernetes.io/auth-response-headers: Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- longhorn.riotpiao.homelab.com
secretName: longhorn-tls
rules:
- host: longhorn.riotpiao.homelab.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: longhorn-frontend
port:
number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: portainer-protected
namespace: dashboard
annotations:
nginx.ingress.kubernetes.io/auth-url: http://authentik-server.iam.svc.cluster.local/outpost.goauthentik.io/auth/nginx
nginx.ingress.kubernetes.io/auth-signin: https://authentik.riotpiao.homelab.com/outpost.goauthentik.io/start?rd=$scheme://$http_host$escaped_request_uri
nginx.ingress.kubernetes.io/auth-response-headers: Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- portainer.riotpiao.homelab.com
secretName: portainer-tls
rules:
- host: portainer.riotpiao.homelab.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: portainer
port:
number: 9000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: prometheus-protected
namespace: monitoring
annotations:
nginx.ingress.kubernetes.io/auth-url: http://authentik-server.iam.svc.cluster.local/outpost.goauthentik.io/auth/nginx
nginx.ingress.kubernetes.io/auth-signin: https://authentik.riotpiao.homelab.com/outpost.goauthentik.io/start?rd=$scheme://$http_host$escaped_request_uri
nginx.ingress.kubernetes.io/auth-response-headers: Set-Cookie,X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- prometheus.riotpiao.homelab.com
secretName: prometheus-tls
rules:
- host: prometheus.riotpiao.homelab.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus-operated
port:
number: 9090
+5
View File
@@ -0,0 +1,5 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ingress-protected.yaml
- networkpolicy.yaml
+50
View File
@@ -0,0 +1,50 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: longhorn-ingress-only
namespace: longhorn-system
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: longhorn
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: portainer-ingress-only
namespace: dashboard
spec:
podSelector:
matchLabels:
app: portainer
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: prometheus-ingress-only
namespace: monitoring
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: prometheus
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
name: ingress-nginx
+24
View File
@@ -0,0 +1,24 @@
apiVersion: helm.cattle.io/v1
kind: HelmRelease
metadata:
name: homarr
namespace: dashboard
spec:
chart:
repository: https://homarr-labs.github.io/charts/
name: homarr
version: "~1"
values:
env:
AUTH_PROVIDERS: "oidc"
AUTH_OIDC_CLIENT_ID: "homarr"
AUTH_OIDC_ISSUER: "https://authentik.riotpiao.homelab.com/application/o/homarr/"
AUTH_OIDC_URI: "https://authentik.riotpiao.homelab.com/application/o/homarr/.well-known/openid-configuration"
AUTH_OIDC_GROUPS_ATTRIBUTE: "groups"
envFrom:
- secretRef:
name: homarr-oidc
persistence:
enabled: true
storageClass: longhorn
size: 2Gi
+24
View File
@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: homarr
namespace: dashboard
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- homarr.riotpiao.homelab.com
secretName: homarr-tls
rules:
- host: homarr.riotpiao.homelab.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: homarr
port:
number: 3000
+8
View File
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dashboard
resources:
- namespace.yaml
- secret.yaml
- helmrelease.yaml
- ingress.yaml
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: dashboard
labels:
app.kubernetes.io/name: homarr
managed-by: argocd
+8
View File
@@ -0,0 +1,8 @@
apiVersion: v1
kind: Secret
metadata:
name: homarr-oidc
namespace: dashboard
type: Opaque
stringData:
AUTH_OIDC_CLIENT_SECRET: "PLACEHOLDER_CHANGE_ME"
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- portfolio/base
- homarr/base
- auth-infra/base
+49
View File
@@ -0,0 +1,49 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: portfolio
namespace: portfolio
labels:
app.kubernetes.io/name: portfolio
app.kubernetes.io/component: web
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: portfolio
template:
metadata:
labels:
app.kubernetes.io/name: portfolio
spec:
containers:
- name: portfolio
image: forgejo.riotpiao.homelab.com/rock/portfolio:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 3000
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
+24
View File
@@ -0,0 +1,24 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: portfolio
namespace: portfolio
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts:
- portfolio.riotpiao.homelab.com
secretName: portfolio-tls
rules:
- host: portfolio.riotpiao.homelab.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: portfolio
port:
number: 3000
+8
View File
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: portfolio
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: portfolio
labels:
app.kubernetes.io/name: portfolio
managed-by: argocd
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: portfolio
namespace: portfolio
labels:
app.kubernetes.io/name: portfolio
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: portfolio
+183
View File
@@ -0,0 +1,183 @@
/**
* Layout placeholder fixtures.
*
* Shapes mirror the `atlas` DTOs described in docs/PLAN-atlas.md, and the values
* are transcribed from the real cluster so the layout is sized against real
* cardinality (32 apps, 9 sync waves, ~550 resources).
*
* Redaction rules from the plan are already applied here: no node IPs, no pod
* IPs, no container args, no image tags, no repo URLs, no Secret names.
*/
export type Health = 'healthy' | 'degraded' | 'progressing'
export type Sync = 'synced' | 'outofsync'
export const cluster = {
distro: 'Talos v1.13.3',
kubernetes: 'v1.36.1',
snapshotAge: 2.1,
generation: 88412,
} as const
export type Node = {
name: string
role: string
cpu: number
memoryGi: number
gpu: number
pods: number
health: Health
}
export const nodes: Node[] = [
{ name: 'talos-cp-1', role: 'control-plane', cpu: 8, memoryGi: 16, gpu: 0, pods: 24, health: 'healthy' },
{ name: 'talos-cp-2', role: 'control-plane', cpu: 8, memoryGi: 16, gpu: 0, pods: 31, health: 'healthy' },
{ name: 'talos-cp-3', role: 'control-plane', cpu: 8, memoryGi: 16, gpu: 0, pods: 28, health: 'healthy' },
{ name: 'worker-1', role: 'gpu-node', cpu: 96, memoryGi: 62, gpu: 1, pods: 55, health: 'healthy' },
]
export type Namespace = {
name: string
running: number
total: number
}
export const namespaces: Namespace[] = [
{ name: 'kube-system', running: 21, total: 21 },
{ name: 'longhorn-system', running: 30, total: 31 },
{ name: 'sqs', running: 13, total: 19 },
{ name: 'logging', running: 12, total: 12 },
{ name: 'monitoring', running: 9, total: 11 },
{ name: 'temporal', running: 9, total: 10 },
{ name: 'llm-serving', running: 6, total: 6 },
{ name: 'iam', running: 6, total: 6 },
{ name: 'cicd', running: 6, total: 6 },
{ name: 'argocd', running: 6, total: 8 },
{ name: 'ingress-nginx', running: 4, total: 4 },
{ name: 'cert-manager', running: 3, total: 6 },
{ name: 'api', running: 2, total: 2 },
{ name: 'storage', running: 2, total: 2 },
{ name: 'dashboard', running: 2, total: 2 },
{ name: 'cloudflared', running: 2, total: 2 },
{ name: 'cnpg-system', running: 1, total: 2 },
{ name: 'gpu-system', running: 1, total: 1 },
{ name: 'kserve', running: 1, total: 1 },
{ name: 'reloader', running: 1, total: 1 },
{ name: 'sms', running: 0, total: 1 },
]
export type Model = {
name: string
replicas: number
seqPerReplica: number
contextTokens: number
ready: boolean
}
/** Model identifiers and quantization strategy are deliberately not surfaced. */
export const models: Model[] = [
{ name: 'reasoning', replicas: 2, seqPerReplica: 4, contextTokens: 16384, ready: true },
{ name: 'ornith', replicas: 1, seqPerReplica: 4, contextTokens: 16384, ready: true },
{ name: 'embeddings', replicas: 1, seqPerReplica: 8, contextTokens: 8192, ready: true },
{ name: 'reranker', replicas: 1, seqPerReplica: 8, contextTokens: 8192, ready: true },
{ name: 'verifier', replicas: 1, seqPerReplica: 4, contextTokens: 16384, ready: true },
]
/** `--max-num-seqs=4` x `minReplicas: 2`. The number the whole design is sized against. */
export const SEQUENCE_SLOTS = 8
/** Operator headroom: 2 of 8 slots never handed to visitors. */
export const PUBLIC_SLOT_CAP = 6
export type App = {
name: string
wave: number | null
sync: Sync
health: Health
resources: number
}
export const apps: App[] = [
{ name: 'homelab-root', wave: null, sync: 'synced', health: 'healthy', resources: 31 },
{ name: 'cert-manager', wave: 0, sync: 'synced', health: 'healthy', resources: 47 },
{ name: 'prometheus-crds', wave: 0, sync: 'synced', health: 'healthy', resources: 10 },
{ name: 'reloader', wave: 0, sync: 'synced', health: 'healthy', resources: 6 },
{ name: 'sops-secrets', wave: 0, sync: 'synced', health: 'healthy', resources: 16 },
{ name: 'prometheus', wave: 1, sync: 'synced', health: 'healthy', resources: 68 },
{ name: 'blackbox-exporter', wave: 1, sync: 'synced', health: 'healthy', resources: 17 },
{ name: 'ingress-config', wave: 1, sync: 'synced', health: 'healthy', resources: 14 },
{ name: 'longhorn-config', wave: 1, sync: 'outofsync', health: 'healthy', resources: 12 },
{ name: 'minio-operator', wave: 1, sync: 'synced', health: 'healthy', resources: 9 },
{ name: 'cert-manager-issuers', wave: 1, sync: 'synced', health: 'healthy', resources: 7 },
{ name: 'minio-tenant', wave: 1, sync: 'synced', health: 'healthy', resources: 1 },
{ name: 'monitoring-config', wave: 2, sync: 'synced', health: 'healthy', resources: 24 },
{ name: 'loki', wave: 2, sync: 'synced', health: 'healthy', resources: 16 },
{ name: 'grafana', wave: 2, sync: 'synced', health: 'healthy', resources: 11 },
{ name: 'databases', wave: 2, sync: 'synced', health: 'healthy', resources: 5 },
{ name: 'promtail', wave: 2, sync: 'synced', health: 'healthy', resources: 5 },
{ name: 'authentik', wave: 3, sync: 'synced', health: 'healthy', resources: 13 },
{ name: 'iam-jobs', wave: 3, sync: 'synced', health: 'healthy', resources: 9 },
{ name: 'vault', wave: 3, sync: 'synced', health: 'healthy', resources: 8 },
{ name: 'forgejo-runner', wave: 3, sync: 'synced', health: 'healthy', resources: 4 },
{ name: 'strimzi-operator', wave: 5, sync: 'synced', health: 'healthy', resources: 27 },
{ name: 'kmsvc-redis', wave: 5, sync: 'synced', health: 'healthy', resources: 13 },
{ name: 'queue-crd', wave: 6, sync: 'synced', health: 'healthy', resources: 6 },
{ name: 'kafka-cluster', wave: 6, sync: 'synced', health: 'healthy', resources: 2 },
{ name: 'kong', wave: 7, sync: 'synced', health: 'healthy', resources: 39 },
{ name: 'management-service', wave: 7, sync: 'synced', health: 'healthy', resources: 6 },
{ name: 'temporal', wave: 8, sync: 'synced', health: 'healthy', resources: 16 },
{ name: 'sms', wave: 8, sync: 'synced', health: 'degraded', resources: 7 },
{ name: 'portainer', wave: 8, sync: 'synced', health: 'healthy', resources: 5 },
{ name: 'homarr', wave: 8, sync: 'synced', health: 'healthy', resources: 3 },
{ name: 'cloudflared', wave: 8, sync: 'synced', health: 'healthy', resources: 1 },
]
export const WAVES = [0, 1, 2, 3, 4, 5, 6, 7, 8]
export function appsInWave(wave: number): App[] {
return apps.filter((a) => a.wave === wave)
}
/** Resource kinds for the drill-down panel. Secrets are counted, never named. */
export const resourceKinds: Record<string, Array<{ kind: string; count: number; named: boolean }>> = {
prometheus: [
{ kind: 'PrometheusRule', count: 24, named: true },
{ kind: 'ServiceMonitor', count: 18, named: true },
{ kind: 'Service', count: 9, named: true },
{ kind: 'ConfigMap', count: 6, named: true },
{ kind: 'Deployment', count: 4, named: true },
{ kind: 'ClusterRole', count: 4, named: true },
{ kind: 'Secret', count: 3, named: false },
],
kong: [
{ kind: 'CustomResourceDefinition', count: 14, named: true },
{ kind: 'ClusterRole', count: 8, named: true },
{ kind: 'Service', count: 5, named: true },
{ kind: 'KongPlugin', count: 5, named: true },
{ kind: 'Ingress', count: 4, named: true },
{ kind: 'Secret', count: 3, named: false },
],
'cert-manager': [
{ kind: 'CustomResourceDefinition', count: 12, named: true },
{ kind: 'ClusterRole', count: 11, named: true },
{ kind: 'ClusterRoleBinding', count: 9, named: true },
{ kind: 'ServiceAccount', count: 6, named: true },
{ kind: 'Deployment', count: 3, named: true },
{ kind: 'Service', count: 3, named: true },
{ kind: 'ValidatingWebhookConfiguration', count: 3, named: true },
],
}
export const DEFAULT_KINDS = [
{ kind: 'Service', count: 3, named: true },
{ kind: 'Deployment', count: 2, named: true },
{ kind: 'ConfigMap', count: 2, named: true },
{ kind: 'Secret', count: 1, named: false },
]
+65
View File
@@ -0,0 +1,65 @@
export const DURATION = {
fast: 0.15,
standard: 0.3,
reveal: 0.5,
slow: 0.8,
}
export const EASING = {
ease: [0.25, 0.46, 0.45, 0.94],
easeOut: [0.33, 1, 0.68, 1],
}
export const VARIANTS = {
// Scroll reveal: fade + slight upward translate
reveal: {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: DURATION.reveal, ease: EASING.easeOut },
},
},
// Hover lift: card elevation on hover
cardHover: {
rest: { y: 0 },
hover: {
y: -8,
transition: { duration: DURATION.fast },
},
},
// Stagger container for card grids
container: {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.1,
},
},
},
// Child item for stagger
item: {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: DURATION.standard },
},
},
// Pulse: live indicator dot
pulse: {
scale: [1, 1.2, 1],
opacity: [0.6, 1, 0.6],
transition: {
duration: 2,
repeat: Infinity,
ease: 'easeInOut',
},
},
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;
+34
View File
@@ -0,0 +1,34 @@
{
"name": "riotpiao",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"framer-motion": "^11.0.0",
"lucide-react": "^0.344.0",
"next": "^15.5.20",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "^3.4.0"
},
"devDependencies": {
"@types/node": "20.17.6",
"@types/react": "19.2.17",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/parser": "^8.64.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.57.1",
"eslint-config-next": "^16.2.10",
"postcss": "^8.4.32",
"typescript": "5.8.2"
}
}
+4247
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+33
View File
@@ -0,0 +1,33 @@
import type { Config } from 'tailwindcss'
export default {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
void: '#070B14',
deck: '#0E1424',
riser: '#131B2E',
wire: '#1F2A45',
'wire-lit': '#35486F',
lamp: '#FFA524',
flux: '#4FD1C5',
rose: '#FF5C7A',
dim: '#6B7DA5',
chalk: '#DDE5F7',
},
fontFamily: {
signage: ['var(--font-signage)', 'ui-sans-serif', 'system-ui'],
plex: ['var(--font-plex)', 'ui-sans-serif', 'system-ui'],
data: ['var(--font-data)', 'ui-monospace', 'monospace'],
},
letterSpacing: {
signage: '0.02em',
},
},
},
plugins: [],
} satisfies Config
+37
View File
@@ -0,0 +1,37 @@
# Blocking Decisions
All five must resolve before Phase 1 starts. Source: ADR-0001 review notes.
## 1. Which GitOps repo owns the portfolio?
Two roots exist:
- `homelab-root` → `[email protected]:Riotpiaole/riotpiao.homelab.com.git`, path `k8s/argocd/apps` (31 child Applications, this is the live one)
- This repo's `infra/argocd-apps.yaml` → `forgejo.riotpiao.homelab.com` (NXDOMAIN, `portfolio`/`auth-infra` Applications don't exist in cluster)
**Decision needed:** GitHub or Forgejo. Blocks Phase 0.1–0.3.
## 2. Apex 403 cause
`riotpiao.com` resolves via Cloudflare (172.67.196.33 / 104.21.60.115) but returns HTTP 403 at the edge, no origin headers.
**Decision needed:** tunnel route missing, WAF rule, or no origin configured at all. Check Cloudflare tunnel Public Hostnames list + WAF event log (event log names the blocking rule). Blocks Phase 0.5.
## 3. Is homarr still wanted?
Deployed and healthy, but shipped under the abandoned Homarr+Terraform plan.
**Decision needed:** keep, or decommission via GitOps (remove Application, let Argo prune).
## 4. Does chat (Surface D) stay in v1?
8-slot GPU ceiling (`--max-num-seqs=4` × 2 replicas) means chat queues under real traffic — a visible "please wait" is a worse first impression than no chat.
**Decision needed:** ship B+E+C first and treat D as separate follow-up, or commit to D in v1 with the queue UX as-is.
## 5. Where does atlas live?
This repo (`riotpiao`) or the homelab repo. Follows from decision 1 — whichever repo is authoritative for the portfolio's GitOps should also own atlas's manifests.
---
Once answered, update this file with the decisions taken (date + rationale) before starting [01-phase0-unblock.md](01-phase0-unblock.md).
+39
View File
@@ -0,0 +1,39 @@
# Phase 0 — Unblock
Blocking. Nothing ships until this lands. Requires [00-decisions.md](00-decisions.md) #1 and #2 answered first.
## Tasks
- [ ] **0.1** Resolve which GitOps repo owns the portfolio; delete or correct the losing manifest
- Depends on: decision #1
- Verify: `kubectl get application portfolio -n argocd` shows one, correct, source
- [ ] **0.2** Point `infra/argocd-apps.yaml` `repoURL` and image reference at real hostnames (fix NXDOMAIN)
- Verify: `kubectl get pods -n portfolio` → `2/2 Running`
- [ ] **0.3** Replace `:latest` tag with commit-SHA tag in `infra/portfolio/base/deployment.yaml`; keep `imagePullPolicy: IfNotPresent` (correct once tags are immutable)
- Verify: new commit → new tag → Argo rolls out automatically, no manual `kubectl set image`
- [ ] **0.4** Diagnose `forgejo-gitea` stuck `Init:0/3` (3h+) — read init container logs before changing anything
- Command: `kubectl logs -n cicd <forgejo-gitea-pod> -c <init-container-name>`
- Verify: pod reaches `Running`, image builds succeed
- [ ] **0.5** Diagnose apex 403 — check Cloudflare tunnel Public Hostnames list and WAF event log
- Depends on: decision #2
- Verify: `curl -sS -o /dev/null -w '%{http_code}\n' https://riotpiao.com` → `200`
- [ ] **0.6** Add Vitest + Testing Library + `msw`; add `test` and `test:watch` scripts to `package.json`
- Verify: `pnpm test` → runner executes, 0 tests, exit 0
- [ ] **0.7** Triage unrelated cluster issues: `sms` Application Degraded (`macos-bluebubbles` Pending 3h), `longhorn-config` OutOfSync
- Unrelated to atlas, but delivery tree (Phase 3) will render both red on day one — fix or explicitly accept as known-red
## Phase verify
```bash
kubectl get pods -n portfolio # 2/2 Running
curl -sS -o /dev/null -w '%{http_code}\n' https://riotpiao.com # 200
pnpm test # runner executes, 0 tests, exit 0
```
Next: [02-phase1-atlas-core.md](02-phase1-atlas-core.md)
+35
View File
@@ -0,0 +1,35 @@
# Phase 1 — atlas core
~4 days. Every surface depends on this. RED → GREEN → REFACTOR.
## RED (write tests first, confirm they fail)
- [ ] `redact_test.go` — golden test: serialized snapshot contains none of the denied fields, run against a fixture captured from the real cluster
- [ ] `rbac_test.go` — atlas ServiceAccount receives 403 on `get secrets` in every namespace
- [ ] `snapshot_test.go` — an informer event produces the expected delta
## GREEN
- [ ] ClusterRole: verbs `get,list,watch` only, explicit resource list — no `secrets`, no `*`, no wildcard apiGroups
- [ ] client-go informers: kube API (nodes, namespaces, workloads), Argo CD `Application` CRs
- [ ] Reducer: informer events → in-memory snapshot, redacted **at write time**
- [ ] DTO construction — allowlist only. Emitted: name, namespace, kind, phase, ready counts, restart count, age, node name, health status, sync status, sync wave, explicit label subset. Never emitted: container env/args, image digests/tags, `spec.source.repoURL`, `spec.source.path`, annotations, pod IPs, cluster IPs, Secret names, `status.conditions[].message`, node internal IPs
- [ ] Redis publish (snapshot deltas → `kmsvc-redis-master.sqs:6379`)
- [ ] NetworkPolicy on atlas: egress restricted to kube API, `prometheus-operated.monitoring`, `reasoning-predictor.llm-serving`, `kmsvc-redis-master.sqs`; ingress from `ingress-nginx` only
- [ ] Container hardening: `runAsNonRoot`, read-only root filesystem, all capabilities dropped, `seccompProfile: RuntimeDefault`
## REFACTOR
- [ ] Run `simplify` skill pass on reducer/DTO code
- [ ] Confirm no `_ =` on errors, no naked returns, every upstream call carries a `context.Context` (go-error-handling, go-context skills)
## Verify
```bash
kubectl auth can-i get secrets --as=system:serviceaccount:portfolio:atlas # no
go test ./... -run TestRedact -v
go test ./... -run TestRBAC -v
go test ./... -run TestSnapshot -v
```
Next: [03-phase2-topology.md](03-phase2-topology.md)
+29
View File
@@ -0,0 +1,29 @@
# Phase 2 — Surface B: cluster topology
~3 days. Proves the snapshot + SSE pipeline end to end.
## RED
- [ ] `stream_test.go` — SSE emits a delta within 5s of a pod state change
- [ ] `topology.test.tsx` — graph re-renders on delta without a full reload
## GREEN
- [ ] `GET /api/topology` — nodes, namespaces, workload summaries; envelope `{"data": {}, "meta": {"snapshotAge", "generation"}}`; capped 256 KB, `meta.truncated: true` on overflow, never a silent drop
- [ ] `GET /api/stream` — SSE, session cookie, 2 concurrent/IP, `topology` event type; keepalive comment frame every 30s; `Last-Event-ID` supported for resumable deltas
- [ ] Frontend: React Flow, force layout, node → namespace → workload
- [ ] Security headers on all responses: CSP (no `unsafe-inline`), `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, HSTS; CORS same-origin only
- [ ] Rate limiting Tier 1 (Cloudflare edge — WAF, Bot Fight Mode, per-IP rules) + Tier 2 (Kong `rate-limiting`, `policy: redis`, generous profile for topology)
## REFACTOR
- [ ] `simplify` pass on SSE handler + React Flow wiring
## Verify
```bash
# delete a pod, observe graph update in <5s without reloading
kubectl delete pod <name> -n <ns>
```
Next: [04-phase3-delivery.md](04-phase3-delivery.md)
+31
View File
@@ -0,0 +1,31 @@
# Phase 3 — Surface E: delivery tree
~3 days. Zero new data sources, zero new attack surface, highest signal — reads as platform engineering, not hobby.
## RED
- [ ] `delivery_test.go` — apps group correctly by `sync-wave`; Secret names absent from output (kind+count only); `repoURL` absent from output
- [ ] `delivery.test.tsx` — 550-node tree renders under frame budget with virtualization on
## GREEN
- [ ] `GET /api/delivery` — Argo apps, wave-grouped (0→8), resource children lazy; capped 256 KB
- [ ] `GET /api/delivery/{app}/resources` — cursor-paginated at 100 items (prometheus alone has 68 resources today)
- [ ] Frontend: React Flow, wave columns left→right from `sync-wave` annotations. Click app → side panel with `react-arborist` virtualized resource tree, lazy-loaded children
- [ ] Live sync animation `OutOfSync → Syncing → Synced` driven by Application watch (reuse Phase 2 SSE `/api/stream`, add `delivery` event type)
- [ ] Redaction check specific to this surface: 21 `Secret` resources appear in Argo trees today — render kind+count only, never names (includes `sops-secrets`); `status.conditions[].message` echoes raw errors with internal hostnames — emit condition **type** only
## REFACTOR
- [ ] `simplify` pass on wave-grouping + virtualized tree code
## Verify
```bash
# trigger an Argo sync, observe wave-ordered animation
argocd app sync homelab-root
```
Also in scope for this phase: delete fabricated stats in `app/page.tsx` ("40% CPU reduction", "99.2% uptime", "Mission-critical", "60% latency cut") — wire each card to a real number from `/api/topology` or `/api/delivery`, or remove the claim. Five of six landing cards link to routes that don't exist (`/infrastructure`, `/systems`, `/llm`, `/kafka`, `/opensource`) — fix or remove.
Next: [05-phase4-terminal.md](05-phase4-terminal.md)
+30
View File
@@ -0,0 +1,30 @@
# Phase 4 — Surface C: terminal
~2 days. First surface accepting user input — injection (A03) is the primary risk here.
## RED
- [ ] `exec_parse_test.go` — fuzz corpus: every non-allowlisted input rejects and performs zero upstream calls
- [ ] `exec_test.go` — unknown namespace rejects on snapshot membership, not regex
## GREEN
- [ ] `POST /api/exec` — session cookie, 20/min/session; input parses to a closed command enum, anything unmatched rejected before any lookup
- [ ] Command set: `get nodes`, `get pods <ns>`, `get apps`, `top nodes`, `describe pod <ns> <name>`, `help`
- [ ] Namespace and resource-name arguments validated by **set membership against current snapshot**, not regex/escaping
- [ ] No shell, no `exec`, no `kubectl` binary in the container image
- [ ] Frontend: reuse [components/InteractiveTerminal.tsx](../components/InteractiveTerminal.tsx), wire to `/api/exec`
- [ ] Structured logging: every rejected `/api/exec` input logged
## REFACTOR
- [ ] `simplify` pass on the enum parser
## Verify
```bash
# attempt injection payloads against /api/exec; all rejected, all logged
curl -X POST https://riotpiao.com/api/exec -d '{"cmd":"get pods; rm -rf /"}'
```
Next: [06-phase5-chat.md](06-phase5-chat.md)
+46
View File
@@ -0,0 +1,46 @@
# Phase 5 — Surface D: chat + rate limiter
~5 days, one PR. Highest risk, highest cost — ships last, ships with its limiter, never after.
Depends on [00-decisions.md](00-decisions.md) #4 (is chat in scope for v1).
## RED
- [ ] `ratelimit_test.go` — 7th concurrent chat queues rather than reaching vLLM
- [ ] `disconnect_test.go` — client abort cancels the upstream request
- [ ] `budget_test.go` — 13th message in 24h returns `429` with `Retry-After`
- [ ] `injection_test.go` — snapshot content cannot alter system-prompt behaviour
- [ ] `context_test.go` — history truncation keeps total tokens under 16384
## GREEN
- [ ] `POST /api/chat` — session + Turnstile, 12/day/session, 6 global concurrent, SSE token stream
- [ ] Rate limiting Tier 3 (atlas): global chat semaphore = 6 (2 of 8 GPU slots kept as operator headroom); queue depth 20 then reject `429`; per-session budget 12 msg/24h; per-request timeout 120s hard server-side; disconnect cancels upstream immediately (`req.Context()` threaded to vLLM request)
- [ ] Queue position streamed as SSE `{"type":"queue","position":N}`
- [ ] Prompt injection defenses: system prompt is compile-time constant, unreachable by user input; cluster snapshot digest injected in a delimited block explicitly labelled untrusted data; user message always last; **no tool-calling** — model reads pre-built digest, cannot query anything; `max_tokens: 1500` cap
- [ ] Context budget (16384 total): system prompt ~300, snapshot digest capped at 2000, `max_tokens` 1500, ~12500 for history, truncated oldest-first
- [ ] Chat SSE events: `{"type":"reasoning"|"content"|"queue"|"done"|"error"}` — render `reasoning_content` in collapsible block (this is the demo)
- [ ] Frontend: new chat component — collapsible reasoning block, queue position, streaming tokens
- [ ] Prometheus metrics: `atlas_chat_concurrent`, `atlas_chat_queue_depth`, `atlas_ratelimit_rejections_total`, `atlas_snapshot_age_seconds`
- [ ] Alertmanager rule: chat queue saturated > 5 min, snapshot age > 60s
- [ ] `pnpm audit` + `govulncheck` in CI, fail build on high severity
## REFACTOR
- [ ] `simplify` pass on rate limiter + SSE chat handler
## Verify
```bash
# load test at 20 concurrent clients
# GPU sequence usage never exceeds 6, no upstream 5xx, queue drains
```
## Done — atlas v1 shipped
Success criteria (from PLAN-atlas.md):
- [ ] Protected services redirect unauthenticated to Authentik (if still applicable post decision #3)
- [ ] Fabricated stats gone, real numbers or removed
- [ ] `riotpiao.com` is the only public hostname (I1 held)
- [ ] Golden redaction test passes against live-cluster fixture
- [ ] Load test: 20 concurrent clients, GPU usage ≤ 6, no 5xx, queue drains
+32
View File
@@ -0,0 +1,32 @@
# atlas — Task Breakdown
Source of truth: [docs/PLAN-atlas.md](../docs/PLAN-atlas.md), [docs/adr/ADR-0001-atlas-cluster-visualization.md](../docs/adr/ADR-0001-atlas-cluster-visualization.md)
Supersedes: [PLAN.md](../PLAN.md), [IMPLEMENTATION.md](../IMPLEMENTATION.md) (Homarr + Terraform — abandoned)
## Status
Not started. **5 decisions block Phase 1** — see [00-decisions.md](00-decisions.md).
## Phases
| File | Phase | Est. |
|---|---|---|
| [00-decisions.md](00-decisions.md) | Blocking decisions (must answer before Phase 1) | — |
| [01-phase0-unblock.md](01-phase0-unblock.md) | Phase 0 — unblock deployment | — |
| [02-phase1-atlas-core.md](02-phase1-atlas-core.md) | Phase 1 — atlas core (RBAC, informers, redaction) | ~4d |
| [03-phase2-topology.md](03-phase2-topology.md) | Phase 2 — Surface B: cluster topology | ~3d |
| [04-phase3-delivery.md](04-phase3-delivery.md) | Phase 3 — Surface E: delivery tree | ~3d |
| [05-phase4-terminal.md](05-phase4-terminal.md) | Phase 4 — Surface C: terminal | ~2d |
| [06-phase5-chat.md](06-phase5-chat.md) | Phase 5 — Surface D: chat + rate limiter | ~5d |
**Total: ~17 working days.**
## Rules carried from the ADR
- I1: `riotpiao.com` is the only public hostname, ever
- I2: browser never talks to an internal API directly — atlas is the only origin
- I3: redaction is allowlist-only, enforced by DTO construction
- I4: no free-form string reaches an internal system (closed enum, snapshot-membership validation)
- I5: GPU chat concurrency capped at 6 of 8 sequence slots, disconnect cancels upstream immediately
- TDD: RED (tests named before code) → GREEN (minimal code) → REFACTOR, per phase
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@/*": [
"./*"
]
},
"allowJs": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
File diff suppressed because one or more lines are too long