(chore) init commit and add tasks
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.claude
|
||||||
+1348
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
# Implementation Plan: `atlas` — Interactive Cluster Visualization
|
||||||
|
|
||||||
|
**Status**: For review — no code written yet
|
||||||
|
**Companion ADR**: [ADR-0001](adr/ADR-0001-atlas-cluster-visualization.md)
|
||||||
|
**Supersedes**: `PLAN.md`, `IMPLEMENTATION.md` (Homarr + Terraform — abandoned)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
| ID | Surface | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| B | Cluster topology | Live node → namespace → workload graph, health-colored, drill-down |
|
||||||
|
| E | Delivery tree | Argo CD app-of-apps as a sync-wave-ordered DAG, live sync animation |
|
||||||
|
| C | Terminal | Read-only, enum-dispatched cluster queries in the browser |
|
||||||
|
| D | Chat | Streaming ChatGPT-style session against the `reasoning` model |
|
||||||
|
|
||||||
|
### Out of scope (v1)
|
||||||
|
|
||||||
|
- Any write operation against the cluster
|
||||||
|
- LLM tool-calling / agentic loops against live infrastructure
|
||||||
|
- Forgejo CI half of the pipeline view (`forgejo-gitea` is currently stuck `Init:0/3`)
|
||||||
|
- Log streaming to the browser (Loki content is unredactable in practice)
|
||||||
|
- Authenticated / operator-only views — Grafana already serves that need
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Replacing Grafana
|
||||||
|
- Multi-cluster support
|
||||||
|
- Historical / time-travel views
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Phase 0 — Unblock (blocking; nothing ships until done)
|
||||||
|
|
||||||
|
Verified problems, in the order they must be fixed:
|
||||||
|
|
||||||
|
| # | Problem | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.1 | `portfolio` namespace is empty; `portfolio` and `auth-infra` Applications do not exist in the cluster | `kubectl get pods -n portfolio` → no resources; neither name appears in `kubectl get app -n argocd` |
|
||||||
|
| 0.2 | `infra/argocd-apps.yaml` `repoURL` = `forgejo.riotpiao.homelab.com` — NXDOMAIN | `dig` |
|
||||||
|
| 0.3 | Deployment image `forgejo.riotpiao.homelab.com/rock/portfolio:latest` — dead host, and `:latest` + `imagePullPolicy: IfNotPresent` means a pushed image will never roll out | `infra/portfolio/base/deployment.yaml` |
|
||||||
|
| 0.4 | `forgejo-gitea` stuck `Init:0/3` for 3h — no image builds possible | `kubectl get pods -n cicd` |
|
||||||
|
| 0.5 | `riotpiao.com` returns HTTP 403 from the Cloudflare edge; no origin headers present | `curl -I https://riotpiao.com` |
|
||||||
|
| 0.6 | No test framework installed — TDD is impossible as the repo stands | `package.json` has no test script or runner |
|
||||||
|
| 0.7 | `sms` Application Degraded (`macos-bluebubbles` Pending 3h); `longhorn-config` OutOfSync | `kubectl get app -n argocd` |
|
||||||
|
|
||||||
|
**Decisions required from you before 0.1–0.3 can be actioned** — see ADR open questions 1 and 2.
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
1. Resolve which GitOps repo owns the portfolio; delete or correct the losing manifest
|
||||||
|
2. Point `repoURL` and the image reference at real hostnames
|
||||||
|
3. Replace `:latest` with a commit-SHA tag; set `imagePullPolicy: IfNotPresent` (correct once tags are immutable)
|
||||||
|
4. Diagnose `forgejo-gitea` init containers — read the actual init container logs before changing anything
|
||||||
|
5. Diagnose the apex 403 — check the tunnel's Public Hostnames list and Cloudflare WAF events; the event log names the blocking rule
|
||||||
|
6. Add Vitest + Testing Library + `msw`; add `test` and `test:watch` scripts
|
||||||
|
7. Triage 0.7 separately — unrelated to this work, but the delivery tree will render both as red on day one
|
||||||
|
|
||||||
|
**Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get pods -n portfolio # 2/2 Running
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}\n' https://riotpiao.com # 200
|
||||||
|
pnpm test # runner executes, 0 tests, exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
kube API (client-go informers) ─┐
|
||||||
|
Prometheus /api/v1/query ├──> atlas (Go, ns: portfolio, RO ServiceAccount)
|
||||||
|
Argo CD Application CRs ┘ │
|
||||||
|
├─ snapshot (in-memory, redacted at write)
|
||||||
|
├─ Redis pub/sub (kmsvc-redis-master.sqs:6379)
|
||||||
|
└─ HTTP
|
||||||
|
GET /api/topology
|
||||||
|
GET /api/delivery
|
||||||
|
GET /api/stream (SSE)
|
||||||
|
POST /api/exec
|
||||||
|
POST /api/chat (SSE)
|
||||||
|
└──> reasoning-predictor.llm-serving:80
|
||||||
|
```
|
||||||
|
|
||||||
|
Event-driven, per project architectural preference: informers push to a reducer, the reducer publishes deltas to Redis, SSE handlers subscribe. No request-triggered upstream calls anywhere in the read path.
|
||||||
|
|
||||||
|
Snapshot is redacted **at write time**, not at serialization time. A field that never enters the snapshot cannot leak from any surface.
|
||||||
|
|
||||||
|
### Language
|
||||||
|
|
||||||
|
Go, for `client-go` informers and because it matches the rest of the platform. Follow the repo's Go skill set (`go-naming`, `go-concurrency`, `go-error-handling`, `go-context`) — notably: every upstream call carries a context, no naked returns, no `_ =` on errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API contract
|
||||||
|
|
||||||
|
Envelope for all non-stream responses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "data": { }, "meta": { "snapshotAge": 3.2, "generation": 88412 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors follow RFC 9457 (`application/problem+json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "https://riotpiao.com/errors/rate-limited",
|
||||||
|
"title": "Rate limit exceeded",
|
||||||
|
"status": 429, "detail": "12 of 12 messages used", "retryAfter": 3600 }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Path | Auth | Limit | Response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET | `/api/topology` | none | 60/min/IP | Nodes, namespaces, workload summaries |
|
||||||
|
| GET | `/api/delivery` | none | 60/min/IP | Argo apps, wave-grouped; resource children lazy |
|
||||||
|
| GET | `/api/delivery/{app}/resources` | none | 60/min/IP | Virtualized child list for one app |
|
||||||
|
| GET | `/api/stream` | session cookie | 2 concurrent/IP | SSE deltas, `topology` + `delivery` event types |
|
||||||
|
| POST | `/api/exec` | session cookie | 20/min/session | Enum command result |
|
||||||
|
| POST | `/api/chat` | session + Turnstile | 12/day/session, 6 global concurrent | SSE token stream |
|
||||||
|
|
||||||
|
**Pagination**: `/api/delivery/{app}/resources` is cursor-paginated at 100 items. `prometheus` has 68 resources today, but `homelab-root`'s tree will grow.
|
||||||
|
|
||||||
|
**Payload budget**: topology response capped at 256 KB, delivery at 256 KB. Exceeding the cap truncates and sets `meta.truncated: true` — never a silent drop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Security
|
||||||
|
|
||||||
|
Mapped against OWASP Top 10 (2021). Every item is a Phase gate, not a wish list.
|
||||||
|
|
||||||
|
### A01 Broken Access Control
|
||||||
|
|
||||||
|
- `atlas` ServiceAccount: one ClusterRole, verbs `get,list,watch` only, explicit resource list. **No `secrets`. No `*`. No wildcards on apiGroups.**
|
||||||
|
- NetworkPolicy on `atlas`: egress restricted to kube API, `prometheus-operated.monitoring`, `reasoning-predictor.llm-serving`, `kmsvc-redis-master.sqs`. Ingress from `ingress-nginx` only.
|
||||||
|
- Test: an integration test asserting the SA receives 403 on `get secrets` in every namespace.
|
||||||
|
|
||||||
|
### A02 Cryptographic Failures
|
||||||
|
|
||||||
|
- Session cookie: signed (HMAC), `HttpOnly`, `Secure`, `SameSite=Lax`, 24h expiry. No PII in the payload — a random session ID only.
|
||||||
|
- Signing key from a Kubernetes Secret via SOPS (`sops-secrets` app already exists), never an env literal in a manifest.
|
||||||
|
|
||||||
|
### A03 Injection
|
||||||
|
|
||||||
|
The primary risk on surface C. Mitigation is structural, not filtering:
|
||||||
|
|
||||||
|
- Input parses to a closed command enum. Anything unmatched is rejected before any lookup.
|
||||||
|
- Namespace and resource-name arguments are validated by **set membership against the current snapshot**, not by regex or escaping.
|
||||||
|
- No shell, no `exec`, no `kubectl` binary present in the container image.
|
||||||
|
- Test: fuzz the parser; assert every input outside the allowlist returns a rejection and performs zero upstream calls.
|
||||||
|
|
||||||
|
### A04 Insecure Design — information disclosure
|
||||||
|
|
||||||
|
The core risk of the whole project. Redaction allowlist, enforced by DTO construction:
|
||||||
|
|
||||||
|
**Emitted**: name, namespace, kind, phase, ready counts, restart count, age, node name, health status, sync status, sync wave, an explicit label subset.
|
||||||
|
|
||||||
|
**Never emitted**: container env, container args, image digests, image tags, `spec.source.repoURL`, full `spec.source.path`, annotations, pod IPs, cluster IPs, Secret names, `status.conditions[].message`, node internal IPs.
|
||||||
|
|
||||||
|
Specific known leaks in current data:
|
||||||
|
- `reasoning` container args disclose the entire model and quantization strategy
|
||||||
|
- `spec.source.repoURL` discloses a private GitHub repository
|
||||||
|
- 21 `Secret` resources appear in Argo trees — render **kind and count only, never names**; `sops-secrets` included
|
||||||
|
- `status.conditions[].message` echoes raw errors containing internal hostnames — emit condition **type** only
|
||||||
|
|
||||||
|
Test: golden test asserting the serialized snapshot contains none of the denied field names, run against a fixture captured from the real cluster.
|
||||||
|
|
||||||
|
### A05 Security Misconfiguration
|
||||||
|
|
||||||
|
- Container: `runAsNonRoot`, read-only root filesystem, all capabilities dropped, `seccompProfile: RuntimeDefault`
|
||||||
|
- Security headers on all responses: `Content-Security-Policy` (no `unsafe-inline`), `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Strict-Transport-Security`
|
||||||
|
- CORS: same-origin only. No wildcard.
|
||||||
|
|
||||||
|
### A07 Authentication Failures
|
||||||
|
|
||||||
|
- Anonymous by design; Cloudflare Turnstile gates the first chat message
|
||||||
|
- Session rotation on issue; no session fixation vector since there is no login
|
||||||
|
|
||||||
|
### A08 Software and Data Integrity
|
||||||
|
|
||||||
|
- Image tags are commit SHAs, never `:latest` (fixes Phase 0.3)
|
||||||
|
- `pnpm audit` and `govulncheck` in CI, failing the build on high severity
|
||||||
|
|
||||||
|
### A09 Logging and Monitoring
|
||||||
|
|
||||||
|
- Structured logs: every rejected `/api/exec` input, every rate-limit trip, every chat queue rejection
|
||||||
|
- Prometheus metrics from `atlas`: `atlas_chat_concurrent`, `atlas_chat_queue_depth`, `atlas_ratelimit_rejections_total`, `atlas_snapshot_age_seconds`
|
||||||
|
- Alertmanager rule: chat queue saturated > 5 min, snapshot age > 60s
|
||||||
|
|
||||||
|
### A10 SSRF
|
||||||
|
|
||||||
|
- `atlas` calls a fixed, compile-time list of upstream URLs. No user input reaches any URL construction, on any path.
|
||||||
|
|
||||||
|
### LLM-specific: prompt injection
|
||||||
|
|
||||||
|
- System prompt is a compile-time constant, unreachable by user input
|
||||||
|
- Cluster snapshot digest is injected in a delimited block explicitly labelled untrusted data
|
||||||
|
- User message is always last
|
||||||
|
- **No tool-calling.** The model reads a pre-built digest and cannot query anything. This removes the entire agentic attack surface for v1.
|
||||||
|
- Output capped at `max_tokens: 1500` — DeepSeek-R1 will otherwise reason for minutes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rate limiting
|
||||||
|
|
||||||
|
Sized against the verified hard ceiling: **8 concurrent sequences** (`--max-num-seqs=4` × 2 replicas), single GPU node.
|
||||||
|
|
||||||
|
**Tier 1 — Cloudflare edge.** WAF, Bot Fight Mode, per-IP rate rules, Turnstile before first chat message. Free, and stops scripted abuse before it reaches your hardware.
|
||||||
|
|
||||||
|
**Tier 2 — Kong.** `rate-limiting` plugin, `policy: redis` against `kmsvc-redis-master.sqs:6379` so counters are cluster-wide rather than per-pod (Kong runs 2 replicas — a local policy would silently double every limit). Two profiles: generous for topology and delivery, tight for chat.
|
||||||
|
|
||||||
|
**Tier 3 — `atlas`, the tier that actually protects the GPU.**
|
||||||
|
|
||||||
|
| Control | Value | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Global chat semaphore | 6 | Leaves 2 of 8 sequence slots as operator headroom |
|
||||||
|
| Queue depth | 20, then reject with `429` | A visible queue beats a wall; an unbounded queue beats nothing |
|
||||||
|
| Per-session budget | 12 messages / 24h | Enough to explore, not enough to farm |
|
||||||
|
| Per-request timeout | 120s hard, server-side | Independent of client behaviour |
|
||||||
|
| Disconnect handling | cancel upstream immediately | **Critical** — a walked-away tab holding 1 of 8 slots is a real outage |
|
||||||
|
|
||||||
|
Queue position is streamed to the client as SSE `{"type":"queue","position":N}` events, so waiting is legible rather than a hang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Streaming
|
||||||
|
|
||||||
|
SSE throughout — unidirectional server→client fits every surface, including token streaming. WebSockets are not justified; nothing flows client→server mid-stream.
|
||||||
|
|
||||||
|
- Keepalive comment frame every 30s (idle SSE connections die at proxies)
|
||||||
|
- `Last-Event-ID` supported on `/api/stream` for resumable topology/delivery deltas; chat is not resumable
|
||||||
|
- `req.Context()` threaded to the upstream vLLM request so client abort cancels it — this is the mechanism that enforces the Tier 3 disconnect rule
|
||||||
|
- Backpressure: bounded per-client channel; a slow consumer is dropped rather than allowed to grow memory
|
||||||
|
- Chat events: `{"type":"reasoning"|"content"|"queue"|"done"|"error"}`. `--reasoning-parser=deepseek_r1` already separates `reasoning_content` from `content` — render thinking in a collapsible block. That block **is** the demo.
|
||||||
|
|
||||||
|
**Context budget** (16384 total): system prompt ~300, snapshot digest capped at 2000, `max_tokens` 1500, leaving ~12500 for history. History is truncated oldest-first to fit. The digest is a compact rendering, never raw JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Frontend
|
||||||
|
|
||||||
|
- **Topology (B)**: React Flow, force layout. Node → namespace → workload.
|
||||||
|
- **Delivery (E)**: React Flow, wave columns left→right from the existing `sync-wave` annotations (0→8). 31 app nodes — an ideal size for a readable DAG. Click an app → side panel with a `react-arborist` virtualized resource tree, children lazy-loaded. Live sync animation `OutOfSync → Syncing → Synced` driven by the Application watch. Push a commit during a demo and the wave cascades — that is the moment worth engineering for.
|
||||||
|
- **Terminal (C)**: reuse [components/InteractiveTerminal.tsx](../components/InteractiveTerminal.tsx). Command set: `get nodes`, `get pods <ns>`, `get apps`, `top nodes`, `describe pod <ns> <name>`, `help`.
|
||||||
|
- **Chat (D)**: new component. Collapsible reasoning block, queue position, streaming tokens.
|
||||||
|
|
||||||
|
Rendering budget: ~550 resources total across the tree. Lazy expansion plus virtualization is required, not optional. Target: initial delivery view interactive in < 1.5s on a cold load.
|
||||||
|
|
||||||
|
**Also in scope**: delete the fabricated statistics in [app/page.tsx](../app/page.tsx) — "40% CPU reduction", "99.2% uptime", "Mission-critical", "60% latency cut" — and either wire each card to a real number from `/api/topology` or remove the claim. Five of the six cards link to routes that do not exist (`/infrastructure`, `/systems`, `/llm`, `/kafka`, `/opensource`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Phases, TDD-first
|
||||||
|
|
||||||
|
Each phase is RED → GREEN → REFACTOR. Tests named before implementation exists.
|
||||||
|
|
||||||
|
### Phase 1 — `atlas` core (~4 days)
|
||||||
|
|
||||||
|
RED
|
||||||
|
- `redact_test.go`: golden test — serialized snapshot contains no denied field, against a real-cluster fixture
|
||||||
|
- `rbac_test.go`: SA receives 403 on `get secrets`
|
||||||
|
- `snapshot_test.go`: informer event produces the expected delta
|
||||||
|
|
||||||
|
GREEN: ClusterRole, informers, reducer, DTO construction, Redis publish.
|
||||||
|
Verify: `kubectl auth can-i get secrets --as=system:serviceaccount:portfolio:atlas` → `no`.
|
||||||
|
|
||||||
|
### Phase 2 — Surface B (~3 days)
|
||||||
|
|
||||||
|
RED
|
||||||
|
- `stream_test.go`: SSE emits a delta within 5s of a pod state change
|
||||||
|
- `topology.test.tsx`: graph re-renders on delta without a full reload
|
||||||
|
|
||||||
|
Verify: delete a pod, observe the graph update in < 5s without reloading.
|
||||||
|
|
||||||
|
### Phase 3 — Surface E (~3 days)
|
||||||
|
|
||||||
|
RED
|
||||||
|
- `delivery_test.go`: apps group correctly by `sync-wave`; Secret names absent from output; `repoURL` absent from output
|
||||||
|
- `delivery.test.tsx`: 550-node tree renders under the frame budget with virtualization on
|
||||||
|
|
||||||
|
Verify: trigger an Argo sync, observe wave-ordered animation.
|
||||||
|
|
||||||
|
### Phase 4 — Surface C (~2 days)
|
||||||
|
|
||||||
|
RED
|
||||||
|
- `exec_parse_test.go`: fuzz corpus — every non-allowlisted input rejects and performs zero upstream calls
|
||||||
|
- `exec_test.go`: unknown namespace rejects on snapshot membership, not regex
|
||||||
|
|
||||||
|
Verify: attempt injection payloads against `/api/exec`; all rejected, all logged.
|
||||||
|
|
||||||
|
### Phase 5 — Surface D + rate limiter, one PR (~5 days)
|
||||||
|
|
||||||
|
RED
|
||||||
|
- `ratelimit_test.go`: 7th concurrent chat queues rather than reaching vLLM
|
||||||
|
- `disconnect_test.go`: client abort cancels the upstream request
|
||||||
|
- `budget_test.go`: 13th message in 24h returns 429 with `Retry-After`
|
||||||
|
- `injection_test.go`: snapshot content cannot alter system-prompt behaviour
|
||||||
|
- `context_test.go`: history truncation keeps total tokens under 16384
|
||||||
|
|
||||||
|
Verify: load test at 20 concurrent clients — GPU sequence usage never exceeds 6, no upstream 5xx, queue drains.
|
||||||
|
|
||||||
|
**Total: ~17 working days.** Rate limiter ships in the same PR as chat, never after.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Risks
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| Redaction miss leaks internal detail | High | Allowlist by DTO construction; golden test; manual review of every new field |
|
||||||
|
| `worker-1` fails | Chat and all inference down | Out of scope to fix; degrade chat gracefully to "offline", never a hang |
|
||||||
|
| 8-slot ceiling makes chat feel broken under traffic | Medium | Visible queue position; consider disabling chat and shipping B/E/C only |
|
||||||
|
| Argo CD CRD schema changes | Low | Allowlist construction means new fields are ignored by default |
|
||||||
|
| `atlas` compromised | High | Read-only SA, NetworkPolicy egress restriction, no write verbs anywhere |
|
||||||
|
| Cost of GPU inference for anonymous visitors | Medium | Per-session daily budget; Turnstile; edge WAF |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Decisions needed before Phase 1
|
||||||
|
|
||||||
|
1. **Which GitOps repo owns the portfolio** — GitHub (`riotpiao.homelab.com`) or Forgejo? Blocks 0.1–0.3.
|
||||||
|
2. **Apex 403 cause** — tunnel route, WAF rule, or no origin? Blocks 0.5.
|
||||||
|
3. **Is `homarr` still wanted?** Deployed and healthy, but from the abandoned plan.
|
||||||
|
4. **Does chat stay in v1?** Given the 8-slot ceiling, shipping B + E + C first and treating D as a separate decision is defensible.
|
||||||
|
5. **Where does `atlas` live** — this repo, or the homelab repo? Follows from decision 1.
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# ADR-0001: `atlas` — Single Read-Only Aggregator for Public Cluster Visualization
|
||||||
|
|
||||||
|
**Status**: Proposed
|
||||||
|
|
||||||
|
**Date**: 2026-08-13
|
||||||
|
|
||||||
|
**Authors**: Rock Liang
|
||||||
|
|
||||||
|
**Supersedes**: `PLAN.md`, `IMPLEMENTATION.md` (Homarr + Terraform approach — abandoned; cluster is now GitOps/Kustomize + Argo CD)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Problem Statement
|
||||||
|
|
||||||
|
The homelab cluster is fully operational and runs a non-trivial platform: Talos Kubernetes, GPU-backed LLM inference, event streaming, GitOps delivery, and a full observability stack. None of it is visible to anyone but the operator. The portfolio site meant to showcase it displays **hardcoded, fabricated statistics** and links to pages that do not exist.
|
||||||
|
|
||||||
|
Goal: replace fabricated claims with a live, interactive, public view of the real system.
|
||||||
|
|
||||||
|
### Current Situation (verified 2026-08-13)
|
||||||
|
|
||||||
|
**Cluster**
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| Distro | Talos v1.13.3, Kubernetes v1.36.1 |
|
||||||
|
| Nodes | 3× control-plane (`talos-cp-1/2/3`) + 1× `worker-1` (role `gpu-node`) |
|
||||||
|
| `worker-1` allocatable | 95950m CPU, 65019644Ki memory, 1× `nvidia.com/gpu` |
|
||||||
|
| Ingress | nginx, LoadBalancer `192.168.1.160`; Kong `10.105.63.160` for `api.riotpiao.com` |
|
||||||
|
| Namespaces | 25 |
|
||||||
|
|
||||||
|
**GitOps**
|
||||||
|
|
||||||
|
- `homelab-root` is an app-of-apps: 31 child `Application` resources
|
||||||
|
- Source: `[email protected]:Riotpiaole/riotpiao.homelab.com.git`, path `k8s/argocd/apps`
|
||||||
|
- Sync waves **0 → 8** already annotated across apps
|
||||||
|
- ~550 managed resources total (largest: `prometheus` 68, `cert-manager` 47, `kong` 39)
|
||||||
|
|
||||||
|
**LLM serving** (`llm-serving`, KServe, all 5 `InferenceService` Ready)
|
||||||
|
|
||||||
|
| Model | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `reasoning` | `unsloth/DeepSeek-R1-Distill-Qwen-32B-bnb-4bit`, vLLM `v0.11.0` |
|
||||||
|
| `ornith`, `embeddings`, `reranker`, `verifier` | 1 replica each |
|
||||||
|
|
||||||
|
`reasoning` runtime args, verbatim: `--max-num-seqs=4`, `--max-model-len=16384`, `--gpu-memory-utilization=0.90`, `--enable-prefix-caching`, `--reasoning-parser=deepseek_r1`. `minReplicas: 2`, `maxReplicas: 2`, pinned to `worker-1`.
|
||||||
|
|
||||||
|
**Existing building blocks**
|
||||||
|
|
||||||
|
- Prometheus (kube-prometheus-stack) + kube-state-metrics + node-exporter + blackbox + Alertmanager — `monitoring`
|
||||||
|
- Loki + promtail + Grafana — `logging`
|
||||||
|
- Kong 3.9 + Kubernetes Ingress Controller 3.5 — `api`, 2 replicas
|
||||||
|
- Redis (`kmsvc-redis-master.sqs.svc.cluster.local:6379`), 1 master + 3 replicas
|
||||||
|
- Next.js 15.5 / React 19.2 portfolio source in this repo (not deployed)
|
||||||
|
|
||||||
|
**Public exposure** (verified by DNS + HTTP probe)
|
||||||
|
|
||||||
|
```
|
||||||
|
api.riotpiao.com NXDOMAIN
|
||||||
|
argocd|grafana|vault|longhorn|prometheus|minio|temporal|forgejo|portainer.riotpiao.com
|
||||||
|
NXDOMAIN
|
||||||
|
riotpiao.com 172.67.196.33 / 104.21.60.115 (Cloudflare) → HTTP 403 at edge
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing in the cluster is currently reachable from the public internet. The Cloudflare tunnel has no public hostnames wired (cloudflared auto-creates a CNAME per public hostname; no CNAME exists).
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. Public, anonymous, interactive visualization of live cluster state
|
||||||
|
2. Live Argo CD delivery pipeline view, ordered by sync wave
|
||||||
|
3. Read-only browser terminal for cluster queries
|
||||||
|
4. Streaming chat against the `reasoning` model, ChatGPT-style
|
||||||
|
5. No internal service becomes publicly reachable as a side effect
|
||||||
|
6. No fabricated statistics anywhere on the site
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
- **Hard capacity ceiling: 8 concurrent LLM sequences** (`--max-num-seqs=4` × 2 replicas). Single GPU. Not horizontally scalable without more hardware.
|
||||||
|
- **Context ceiling: 16384 tokens** (`--max-model-len`)
|
||||||
|
- Single GPU node — `worker-1` is a single point of failure for all inference
|
||||||
|
- Solo operator, part-time
|
||||||
|
- Cluster is GitOps-managed; every change ships through git → Argo CD (per project hard rules)
|
||||||
|
- No test framework currently installed in the portfolio repo
|
||||||
|
|
||||||
|
### Forces
|
||||||
|
|
||||||
|
- **Impressiveness vs. attack surface** — the most impressive surfaces (terminal, chat) are the most dangerous
|
||||||
|
- **Live data vs. information disclosure** — real cluster state is the whole point, and real cluster state is exactly what an attacker wants for reconnaissance
|
||||||
|
- **Anonymous access vs. abuse** — requiring login kills the portfolio demo; not requiring it exposes 8 GPU slots to the open internet
|
||||||
|
- **Four surfaces vs. one operator** — four independently-built backends is four times the security review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**We will build one read-only aggregator service, `atlas`, that is the sole public entry point to all cluster data, serving four presentation surfaces from one shared in-memory snapshot.**
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
kube API (informers) ─┐
|
||||||
|
Prometheus /api/v1 ├──> atlas (Go, ns: portfolio, read-only ServiceAccount)
|
||||||
|
Argo CD Application CRs┘ │
|
||||||
|
├─ snapshot: in-memory, redacted at write time
|
||||||
|
├─ Redis pub/sub (kmsvc-redis) — cross-replica fanout
|
||||||
|
│
|
||||||
|
└─ HTTP surfaces
|
||||||
|
GET /api/topology + /api/stream (B: cluster topology)
|
||||||
|
GET /api/delivery + /api/stream (E: Argo CD tree)
|
||||||
|
POST /api/exec (C: terminal)
|
||||||
|
POST /api/chat (D: chat) ──> reasoning-predictor.llm-serving
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core invariants
|
||||||
|
|
||||||
|
These are the load-bearing decisions. Everything else is implementation detail.
|
||||||
|
|
||||||
|
**I1 — One public hostname, forever.**
|
||||||
|
`riotpiao.com` is the only name that ever gets a public DNS record. `argocd`, `grafana`, `vault`, `longhorn`, `prometheus`, `minio`, `temporal`, `forgejo`, `portainer` stay NXDOMAIN permanently. Every new public record is a new thing to defend, and `atlas` proxying makes all of them unnecessary.
|
||||||
|
|
||||||
|
**I2 — The browser never talks to an internal API.**
|
||||||
|
No kube API, no Prometheus, no Argo CD API, no vLLM endpoint is reachable from a browser. `atlas` is the only origin. One choke point for rate limiting, redaction, and audit.
|
||||||
|
|
||||||
|
**I3 — Redaction is an allowlist, never a denylist.**
|
||||||
|
Fields are serialized by explicit construction into DTO structs. A new field appearing in an upstream CRD cannot leak by default, because nothing copies it.
|
||||||
|
|
||||||
|
**I4 — No free-form string ever reaches an internal system.**
|
||||||
|
The terminal parses to a closed command enum. Resource names are validated by **membership in the current snapshot**, not by regex. The chat model reads a pre-built snapshot digest and has no tool-calling ability.
|
||||||
|
|
||||||
|
**I5 — Global GPU concurrency is capped below physical capacity.**
|
||||||
|
Hard semaphore at **6** concurrent chat streams, leaving 2 of 8 sequence slots as operator headroom. Client disconnect cancels the upstream vLLM request immediately.
|
||||||
|
|
||||||
|
### Technology
|
||||||
|
|
||||||
|
| Component | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Aggregator | Go, `client-go` informers | Watch-based, not poll-per-request; N visitors ≠ N API calls |
|
||||||
|
| Fanout | Redis pub/sub (existing `kmsvc-redis`) | Multi-replica `atlas` shares one snapshot stream; no new infrastructure |
|
||||||
|
| Transport | SSE | Unidirectional server→client fits every surface, including LLM token streaming |
|
||||||
|
| Topology + delivery graph | React Flow | Both are graphs; one library, one mental model |
|
||||||
|
| Resource drill-down | `react-arborist` | Virtualized; the 68-resource apps must not jank |
|
||||||
|
| Edge protection | Cloudflare WAF + Turnstile | Free; stops scripted abuse before it costs a packet |
|
||||||
|
| Gateway limits | Kong `rate-limiting`, `policy: redis` | Cluster-wide counters, not per-pod; Kong 3.9 OSS ships it |
|
||||||
|
|
||||||
|
### Implementation strategy — phased, in dependency order
|
||||||
|
|
||||||
|
| Phase | Deliverable | Why this position |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Unblock deployment | Nothing is visible until this lands |
|
||||||
|
| 1 | `atlas` core: RBAC, informers, redaction | Every surface depends on it |
|
||||||
|
| 2 | Surface B — cluster topology | Proves the snapshot + SSE pipeline end to end |
|
||||||
|
| 3 | Surface E — Argo CD delivery tree | Zero new data sources, zero new attack surface, highest signal |
|
||||||
|
| 4 | Surface C — read-only terminal | First surface accepting user input |
|
||||||
|
| 5 | Surface D — chat + full rate limiter | Highest risk, highest cost; ships last, ships with its limiter |
|
||||||
|
|
||||||
|
Surface E precedes C and D deliberately: it reuses Phase 1 data wholesale and is the surface that reads as platform engineering rather than hobby.
|
||||||
|
|
||||||
|
**Timeline**: ~3 weeks part-time. **Responsibility**: solo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Fabricated portfolio statistics replaced by live, verifiable data
|
||||||
|
- One service to secure, rate-limit, audit, and operate instead of four
|
||||||
|
- Sync-wave DAG makes a real dependency-ordering decision legible to a visitor in seconds
|
||||||
|
- Informer-based design means visitor traffic does not load the kube API
|
||||||
|
- Invariant I1 leaves the cluster's public footprint at exactly one hostname
|
||||||
|
- Existing `InteractiveTerminal.tsx` and `LiveIndicator.tsx` get real backing
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- New production service to build, secure, and maintain — currently zero
|
||||||
|
- `atlas` becomes a high-value target: it holds cluster-wide read access by design
|
||||||
|
- 8-slot GPU ceiling means chat will queue under real traffic; a "please wait" queue is a worse first impression than no chat at all
|
||||||
|
- Public chat on a single GPU node has a genuine cost/abuse tail even behind three tiers of limiting
|
||||||
|
- Redaction is permanent maintenance: every new field surfaced is a new disclosure review
|
||||||
|
- Test framework must be added to the repo before any of this can be built TDD-first
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- Grafana remains for operator use; `atlas` is presentation-only and never replaces it
|
||||||
|
- Argo CD API is deliberately not used in v1 — `Application` CRs are read via the same informer, so no Argo CD token is ever minted
|
||||||
|
- `worker-1` remains a single point of failure; this ADR does not change that, only exposes it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Alternative 1: Grafana dashboards + public iframe embeds
|
||||||
|
|
||||||
|
**Description**: Build dashboards on existing data, expose a read-only Grafana org, embed in the portfolio.
|
||||||
|
|
||||||
|
**Pros**
|
||||||
|
- Approximately one day of work
|
||||||
|
- Zero new services, zero new code, zero new attack surface of our own making
|
||||||
|
- Data already flows
|
||||||
|
|
||||||
|
**Cons**
|
||||||
|
- Grafana's design, not the portfolio's — reads as a screenshot, not a product
|
||||||
|
- Requires making Grafana publicly reachable, violating invariant I1
|
||||||
|
- Iframes leak internal metric names, job labels, and namespace structure with no redaction layer available
|
||||||
|
- No path to the terminal or chat surfaces
|
||||||
|
|
||||||
|
**Why not chosen**: The one-day cost is real, but it forces a second public hostname and surrenders all control over what is disclosed. Grafana has no allowlist redaction model. Kept as the fallback if Phase 1 proves too expensive.
|
||||||
|
|
||||||
|
### Alternative 2: Four independent backends, one per surface
|
||||||
|
|
||||||
|
**Description**: Separate services for topology, delivery, terminal, and chat.
|
||||||
|
|
||||||
|
**Pros**
|
||||||
|
- Blast radius isolation — a terminal compromise does not reach the chat service
|
||||||
|
- Independent scaling and deployment
|
||||||
|
- Aligns with the microservices instinct
|
||||||
|
|
||||||
|
**Cons**
|
||||||
|
- Four RBAC policies, four redaction layers, four rate limiters, four security reviews
|
||||||
|
- Four independent informer sets hammering the kube API for the same data
|
||||||
|
- Solo operator; four services will not receive equal maintenance attention
|
||||||
|
- Cross-surface consistency (terminal and topology disagreeing about pod state) becomes a real bug class
|
||||||
|
|
||||||
|
**Why not chosen**: Blast-radius isolation is genuine, but every surface needs the *same* read-only snapshot. Duplicating the highest-risk component — cluster-wide read access — four times increases total exposure rather than reducing it. Rejected on the specific ground that the shared component is the dangerous one.
|
||||||
|
|
||||||
|
### Alternative 3: Static snapshot generated at build time
|
||||||
|
|
||||||
|
**Description**: CI job dumps cluster state to JSON at build; site renders it statically. No runtime cluster access at all.
|
||||||
|
|
||||||
|
**Pros**
|
||||||
|
- Zero runtime attack surface — no live credentials anywhere near the public internet
|
||||||
|
- Trivially cacheable, effectively free to serve, cannot be DoS'd
|
||||||
|
- No rate limiting needed
|
||||||
|
|
||||||
|
**Cons**
|
||||||
|
- Not live; "interactive" degrades to "pre-rendered"
|
||||||
|
- Kills the terminal and chat surfaces entirely
|
||||||
|
- The sync-wave cascade animation — the single best demo moment — is impossible
|
||||||
|
- Data staleness makes the fabricated-statistics problem better but not solved
|
||||||
|
|
||||||
|
**Why not chosen**: Fails requirements 1, 3, and 4. Worth revisiting for the topology surface alone if runtime cost becomes a problem.
|
||||||
|
|
||||||
|
### Alternative 4: Authentik-gated access to all surfaces
|
||||||
|
|
||||||
|
**Description**: Put the existing Authentik SSO in front of the whole visualization.
|
||||||
|
|
||||||
|
**Pros**
|
||||||
|
- Abuse problem largely disappears; rate limiting becomes a formality
|
||||||
|
- Authentik is already deployed and working
|
||||||
|
- Redaction requirements relax substantially for authenticated viewers
|
||||||
|
|
||||||
|
**Cons**
|
||||||
|
- Nobody creates an account to look at a stranger's homelab — the demo dies
|
||||||
|
- Defeats the entire purpose of a public portfolio
|
||||||
|
- Still requires a public Authentik hostname, violating I1
|
||||||
|
|
||||||
|
**Why not chosen**: Directly contradicts requirement 1. Anonymous access plus Cloudflare Turnstile achieves most of the abuse resistance without the conversion cliff. Reconsider only if abuse proves unmanageable in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Cluster state verified 2026-08-13 via `kubectl` against context `admin@homelab-cluster`
|
||||||
|
- Public exposure verified 2026-08-13 via `dig` + `curl` against `riotpiao.com` and subdomains
|
||||||
|
- vLLM engine args: `kubectl get inferenceservice reasoning -n llm-serving -o jsonpath='{.spec}'`
|
||||||
|
- Superseded: [PLAN.md](../../PLAN.md), [IMPLEMENTATION.md](../../IMPLEMENTATION.md)
|
||||||
|
- Companion implementation plan: [PLAN-atlas.md](../PLAN-atlas.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Notes (Before Acceptance)
|
||||||
|
|
||||||
|
**Open questions requiring an answer before Phase 1**
|
||||||
|
|
||||||
|
1. **Two GitOps roots exist.** `homelab-root` reads `[email protected]:Riotpiaole/riotpiao.homelab.com.git`. This repo's `infra/argocd-apps.yaml` points at `forgejo.riotpiao.homelab.com` — a hostname that does not resolve — and its `portfolio` and `auth-infra` Applications **do not exist in the cluster**. Which repository is authoritative for the portfolio?
|
||||||
|
2. **Apex returns 403 from the Cloudflare edge.** Is this an absent tunnel public-hostname route, a WAF rule, or a proxied record with no origin? The portfolio cannot ship until this is understood.
|
||||||
|
3. Is `homarr` still wanted? It is deployed and healthy, but the plan it came from is abandoned.
|
||||||
|
4. Does the chat surface stay in scope given the 8-slot ceiling, or ship topology + delivery + terminal first and treat chat as a separate decision?
|
||||||
|
|
||||||
|
**Approval**
|
||||||
|
|
||||||
|
- [ ] Architecture — invariants I1–I5 accepted
|
||||||
|
- [ ] Security — redaction allowlist and rate-limit tiers accepted
|
||||||
|
- [ ] Scope — four surfaces vs. three
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- ingress-protected.yaml
|
||||||
|
- networkpolicy.yaml
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
namespace: dashboard
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- secret.yaml
|
||||||
|
- helmrelease.yaml
|
||||||
|
- ingress.yaml
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: dashboard
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: homarr
|
||||||
|
managed-by: argocd
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: homarr-oidc
|
||||||
|
namespace: dashboard
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
AUTH_OIDC_CLIENT_SECRET: "PLACEHOLDER_CHANGE_ME"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- portfolio/base
|
||||||
|
- homarr/base
|
||||||
|
- auth-infra/base
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
namespace: portfolio
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- ingress.yaml
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: portfolio
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: portfolio
|
||||||
|
managed-by: argocd
|
||||||
@@ -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
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4247
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Blocking Decisions
|
||||||
|
|
||||||
|
All five must resolve before Phase 1 starts. Source: ADR-0001 review notes.
|
||||||
|
|
||||||
|
## 1. Which GitOps repo owns the portfolio?
|
||||||
|
|
||||||
|
Two roots exist:
|
||||||
|
- `homelab-root` → `[email protected]:Riotpiaole/riotpiao.homelab.com.git`, path `k8s/argocd/apps` (31 child Applications, this is the live one)
|
||||||
|
- This repo's `infra/argocd-apps.yaml` → `forgejo.riotpiao.homelab.com` (NXDOMAIN, `portfolio`/`auth-infra` Applications don't exist in cluster)
|
||||||
|
|
||||||
|
**Decision needed:** GitHub or Forgejo. Blocks Phase 0.1–0.3.
|
||||||
|
|
||||||
|
## 2. Apex 403 cause
|
||||||
|
|
||||||
|
`riotpiao.com` resolves via Cloudflare (172.67.196.33 / 104.21.60.115) but returns HTTP 403 at the edge, no origin headers.
|
||||||
|
|
||||||
|
**Decision needed:** tunnel route missing, WAF rule, or no origin configured at all. Check Cloudflare tunnel Public Hostnames list + WAF event log (event log names the blocking rule). Blocks Phase 0.5.
|
||||||
|
|
||||||
|
## 3. Is homarr still wanted?
|
||||||
|
|
||||||
|
Deployed and healthy, but shipped under the abandoned Homarr+Terraform plan.
|
||||||
|
|
||||||
|
**Decision needed:** keep, or decommission via GitOps (remove Application, let Argo prune).
|
||||||
|
|
||||||
|
## 4. Does chat (Surface D) stay in v1?
|
||||||
|
|
||||||
|
8-slot GPU ceiling (`--max-num-seqs=4` × 2 replicas) means chat queues under real traffic — a visible "please wait" is a worse first impression than no chat.
|
||||||
|
|
||||||
|
**Decision needed:** ship B+E+C first and treat D as separate follow-up, or commit to D in v1 with the queue UX as-is.
|
||||||
|
|
||||||
|
## 5. Where does atlas live?
|
||||||
|
|
||||||
|
This repo (`riotpiao`) or the homelab repo. Follows from decision 1 — whichever repo is authoritative for the portfolio's GitOps should also own atlas's manifests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Once answered, update this file with the decisions taken (date + rationale) before starting [01-phase0-unblock.md](01-phase0-unblock.md).
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user