(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.10.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.10.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 I1I5 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.10.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