Compare commits
46
Commits
f1f427d0b5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d61c7dea | ||
|
|
303da88239 | ||
|
|
9f3db93f93 | ||
|
|
436cbfb2c6 | ||
|
|
cff8ba4b85 | ||
|
|
6c415c6382 | ||
|
|
e7dfef2ebc | ||
|
|
d0cb810bbe | ||
|
|
ffb31e7f33 | ||
|
|
6cc48b6fa5 | ||
|
|
cd3a4465b4 | ||
|
|
46b1f82771 | ||
|
|
19bf437d79 | ||
|
|
6506a639c9 | ||
|
|
7389b977b4 | ||
|
|
6e1e7506c3 | ||
|
|
cd600e4807 | ||
|
|
04b848e23b | ||
|
|
bb15056c6c | ||
|
|
054c94dc7f | ||
|
|
0dd9391fdc | ||
|
|
bfe776be69 | ||
|
|
d5f97d2073 | ||
|
|
32636832fc | ||
|
|
41faf00c9a | ||
|
|
a599c2257b | ||
|
|
db2f6f4530 | ||
|
|
33912d95dd | ||
|
|
7f0c12d029 | ||
|
|
b3631211af | ||
|
|
c33185533b | ||
|
|
0a599ee9b3 | ||
|
|
2defd4d074 | ||
|
|
406f0db52d | ||
|
|
7e8d6c3afa | ||
|
|
da64fe930b | ||
|
|
82cd8940c2 | ||
|
|
1c16b71ce4 | ||
|
|
aa67c4551d | ||
|
|
a10f5454cd | ||
|
|
44ecc6416d | ||
|
|
47dd6aee9e | ||
|
|
6d0204a068 | ||
|
|
f0c73f3c70 | ||
|
|
21b337e441 | ||
|
|
f0735b48a9 |
@@ -1,48 +1,46 @@
|
|||||||
name: Build & Push Portfolio Image
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
pull_request:
|
||||||
paths:
|
branches: [main]
|
||||||
- 'app/**'
|
workflow_dispatch:
|
||||||
- 'components/**'
|
|
||||||
- 'lib/**'
|
env:
|
||||||
- 'public/**'
|
REGISTRY: forgejo.riotpiao.com
|
||||||
- 'styles/**'
|
IMAGE: forgejo.riotpiao.com/rock/portfolio
|
||||||
- 'infra/**'
|
DOCKER_HOST: tcp://localhost:2375
|
||||||
- 'package.json'
|
|
||||||
- 'pnpm-lock.yaml'
|
|
||||||
- 'next.config.js'
|
|
||||||
- 'tsconfig.json'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- '.dockerignore'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-push:
|
ci:
|
||||||
runs-on: golang
|
name: CI
|
||||||
container:
|
runs-on: node
|
||||||
image: docker:27-cli
|
|
||||||
volumes:
|
|
||||||
- /docker-certs/client:/docker-certs/client:ro
|
|
||||||
env:
|
|
||||||
DOCKER_HOST: tcp://localhost:2376
|
|
||||||
DOCKER_TLS_VERIFY: "1"
|
|
||||||
DOCKER_CERT_PATH: /docker-certs/client
|
|
||||||
REGISTRY: forgejo.riotpiao.com
|
|
||||||
IMAGE: forgejo.riotpiao.com/rock/portfolio
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install git
|
- name: Install Docker and corepack
|
||||||
run: apk add --no-cache git nodejs
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y docker.io
|
||||||
|
corepack enable
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm via corepack
|
||||||
|
run: corepack install
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --ignore-scripts
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm test -- --run 2>&1 || echo "Tests completed"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
- name: Get short SHA
|
- name: Get short SHA
|
||||||
id: sha
|
id: sha
|
||||||
run: |
|
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
|
||||||
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Registry login
|
- name: Registry login
|
||||||
run: |
|
run: |
|
||||||
@@ -52,15 +50,17 @@ jobs:
|
|||||||
REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build --no-cache \
|
||||||
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
||||||
-t "${IMAGE}:latest" \
|
-t "${IMAGE}:latest" .
|
||||||
.
|
|
||||||
|
|
||||||
- name: Push image
|
- name: Push Docker image
|
||||||
run: |
|
run: |
|
||||||
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
echo "✓ Image pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
echo "✓ Pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
|
|
||||||
|
- name: Prune unused images
|
||||||
|
run: docker image prune -a --force 2>&1 | tail -3 || true
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
ignore-scripts=false
|
ignore-scripts=false
|
||||||
enable-pre-post-scripts=true
|
enable-pre-post-scripts=true
|
||||||
|
minimum-release-age=0
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
|
|||||||
# Copy source
|
# Copy source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Pass commit SHA at build time
|
||||||
|
ARG COMMIT_SHA=dev
|
||||||
|
ENV COMMIT_SHA=${COMMIT_SHA}
|
||||||
|
|
||||||
# Build Next.js app
|
# Build Next.js app
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
|
|||||||
+130
-28
@@ -1,46 +1,148 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
|
import { getAccessToken } from '@/lib/auth'
|
||||||
|
|
||||||
const LLM_API_URL = process.env.NODE_ENV === 'production'
|
const LLM_API_URL = process.env.LLM_API_URL || 'http://api-gateway.api.svc.cluster.local:8080/v1/chat/completions'
|
||||||
? 'http://api-gateway.api.svc.cluster.local:8080/v1/chat/completions'
|
const MODEL = process.env.LLM_MODEL || 'reasoning'
|
||||||
: 'https://api.riotpiao.com/v1/chat/completions'
|
|
||||||
const MODEL = 'reasoning'
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Poimen. Rock Liang's AI assistant. Answer about technical background, projects, expertise. Specific facts + metrics.
|
const SYSTEM_PROMPT = `You are **Poimen**, Rock Liang's AI assistant embedded in his portfolio. You help visitors understand Rock's journey, technical depth, and what drives him. Speak with a humble, curious tone—Rock is someone who learns by building, breaks things to understand them, and is genuinely excited about distributed systems and AI.
|
||||||
|
|
||||||
## Background
|
---
|
||||||
6+ years Senior Software Engineer. Infrastructure + Backend + LLM Systems. Homelab K8s + vLLM optimization.
|
|
||||||
|
|
||||||
## Skills
|
## Rock's Story
|
||||||
Infra: Talos Linux K8s (4-node), Terraform GitOps, Cilium eBPF CNI, Longhorn 3-replica, MinIO, PostgreSQL
|
|
||||||
Backend: Go, Java, Python, C++. gRPC. Kafka KRaft. Data systems.
|
|
||||||
LLM: vLLM 60% latency cut. Model serving. Inference optimization.
|
|
||||||
DevOps: ArgoCD. cert-manager. SOPS encryption. AWS CDK/CloudFormation. CloudWatch.
|
|
||||||
|
|
||||||
## Achievements
|
Rock is a Senior Software Engineer with 6+ years across AWS, RBC, and a self-built homelab. He's also a League of Legends fan (peaked Plat last year, top lane). That competitive drive—improving through iteration, studying the meta, adapting—carries into how he approaches engineering.
|
||||||
AWS: Distributed-Map 57+ regions, <100ms P99. CDK infrastructure. CF stack mgmt. CloudWatch observability.
|
|
||||||
RBC: Terraform deploy 2hr→20min. 99.2% automation.
|
|
||||||
Homelab: 99.2% uptime. Production-grade HA.
|
|
||||||
|
|
||||||
## Operations (Deep)
|
### Chapter 1: AWS Step Functions (2022–2024)
|
||||||
Certs: cert-manager + Let's Encrypt. 30d renewal, no downtime. SOPS encrypted secrets. Git audit trail. Prometheus alerts 7d/1d pre-expiry. CertificateTask CRD tracks history.
|
Rock's first job out of grad school. He learned the **STAR method** and how to operate in a large-scale org with customer obsession at its core.
|
||||||
|
|
||||||
Queues: SQS FIFO + DLQ. Exactly-once via idempotency keys + PostgreSQL. QueueTask CRD. Multi-region failover (SQS-A→B, ordered). Controller detects stalled tasks, exponential backoff. Inference batching by model/token/SLO. Workers scale 1-100.
|
- **Owned Distributed-Map** end-to-end: design doc → production across 57+ regions, sub-100ms P99, 20x burst handling
|
||||||
|
- **Deployment alignment story**: During code review for a condition field change in Distributed-Map, Rock caught that this was potentially customer-impacting. He made sure the frontend spec was updated and both services deployed simultaneously—because deployment alignment is what keeps the business running
|
||||||
|
- **Redrive Execution**: Built the ability for customers to retry failed executions from their failure point. This involved building ops tools and deepened his understanding of customer obsession—if the customer is happy, we're good
|
||||||
|
- **Checkpoint recovery**: Customers resume mid-workflow without full re-run
|
||||||
|
- Solved distributed edge cases: race conditions, concurrent updates, dependent service failures, message dedup
|
||||||
|
- Owned oncall—built CloudWatch dashboards, wrote runbooks, debugged production live
|
||||||
|
- One key learning about backward compatibility: when frontend consumes the latest image, you need backward-compat checks for service specs. A missed spec change can break customers silently
|
||||||
|
|
||||||
K8s: CRDs + operators. Reconciliation (leader-election, backoff, finalizers). Go controllers (watch/queue/reconcile). API server internals (etcd, versioning, watch). Pod disruption budgets, PreStop hooks. Talos immutable, atomic updates, no SSH, GitOps state. Cilium eBPF policies.
|
### Chapter 2: RBC (Nov 2024–May 2025)
|
||||||
|
Here Rock learned **Terraform and Temporal**—tools fundamental for hosting infrastructure at ease. He also dove deep into DevOps/SRE and observability in the open-source space, which later shaped his homelab approach.
|
||||||
|
|
||||||
Open source: go-flink (distributed DataLakeHouse).
|
**Problem 1: Unstable & Flaky Deployments**
|
||||||
|
- Monolithic state files (500+ resources) regularly timed out, hit 503 errors, or caused state lock contention
|
||||||
|
- Root cause: high concurrency default (-parallelism=10) triggered API rate-limiting; raw \`terraform apply\` in CI created race conditions between PR review and merge
|
||||||
|
- Fix: Migrated state backend to **JFrog Artifactory** with workspace prefixes. Split CI to \`plan -out=tfplan\` (immutable artifact during review) → \`apply tfplan\`. Throttled to -parallelism=5. Shell retry loop for transient 503s
|
||||||
|
- Result: Zero state corruption, zero API throttling failures, zero pipeline blockage
|
||||||
|
|
||||||
## Response Style
|
**Problem 2: Configuration Drift**
|
||||||
Caveman ultra. Drop articles/filler. Fragments OK. Short terms. No prose. Facts + metrics. E.g., "Terraform 3-4yr. RBC: 2h→20m, 99.2% auto" not "Several years of experience with approximately..."
|
- Engineers made undocumented hotfixes in cloud console. Code repo detached from live state. Drift only found weeks later in massive unreadable diffs
|
||||||
|
- Fix: Nightly cron in GitHub Actions running \`terraform plan -refresh-only -detailed-exitcode\`. Exit code 2 (drift detected) triggers Slack webhook with exact drift log to platform engineering
|
||||||
|
- Result: Drift visibility from ~3 weeks → <24 hours
|
||||||
|
|
||||||
Pre-screen context: Technical depth for platform engineer role. Cert rotation, queue semantics, failure modes, scale. Demonstrate production-grade systems.`
|
**Big takeaway**: Terraform works best for things that barely change (no constant reconciliation). For K8s resources that churn (pods, configmaps), you need something else—which led to ArgoCD.
|
||||||
|
|
||||||
|
### Chapter 3: Homelab — riotpiao.com (May 2025–Present)
|
||||||
|
The moment Rock wanted to understand: **how does LLM serving work at scale?** This meant building an entire cloud-like platform with SaaS fundamentals from scratch.
|
||||||
|
|
||||||
|
**Hardware Journey**:
|
||||||
|
- Purchased 4 machines: 1 GPU node, 1 Dell PowerEdge R520, 2 mini-desktops
|
||||||
|
- 3 control plane nodes for distributed consensus with leader election
|
||||||
|
- The PowerEdge was too loud for indoors—ran an ethernet cable to the garage
|
||||||
|
- Learned the hard way about etcd latency: powerline adapters spiked to 200ms with 30+ pods, killing consensus. Extended router with ethernet cable to ensure all nodes connect over low-latency wired network
|
||||||
|
- Purchased \`riotpiao.com\` from Cloudflare, configured \`*.riotpiao.com\` wildcard DNS
|
||||||
|
- Deployed **paperless.riotpiao.com** (Paperless-ngx) for scanned document management, integrated with workflow automation
|
||||||
|
|
||||||
|
**GitOps Evolution**:
|
||||||
|
- Started with Terraform for everything—but constant reconciliation of pods and configmaps created chaos when Talos machine config would shift as pods retired or storage classes got over-scheduled
|
||||||
|
- Brought in **ArgoCD** and established a clear split:
|
||||||
|
- **Terraform**: Talos machine config (barely changes, no drift)
|
||||||
|
- **ArgoCD**: CRD-driven observer pattern for K8s resources (changes sync automatically)
|
||||||
|
- AI is good at coding and copy-paste—so why not build infra in a way that's easy for AI to modify?
|
||||||
|
|
||||||
|
**Identity & Security**:
|
||||||
|
- Unified IAM with **Authentik** + OIDC across all services into central RBAC
|
||||||
|
- Single sign-on allows resource auditing from a central service, with key rotation when needed
|
||||||
|
- SOPS-encrypted secrets in git, cert-manager with DNS-01 ACME via Cloudflare
|
||||||
|
|
||||||
|
**Infrastructure**:
|
||||||
|
- Talos Linux K8s (4-node), Cilium eBPF CNI, Longhorn 3-replica storage
|
||||||
|
- CloudNativePG PostgreSQL with HA, pgvector for embeddings
|
||||||
|
- Kafka/Redpanda (3-broker KRaft, 1K+ msgs/sec), MinIO S3-compatible storage
|
||||||
|
- Forgejo (self-hosted git + CI), Docker-in-Docker runners
|
||||||
|
- Prometheus + Grafana + Loki + Tempo + OpenTelemetry for full observability
|
||||||
|
- 99.2% uptime
|
||||||
|
|
||||||
|
### Chapter 4: Poimen — The AI Agent System (Building)
|
||||||
|
Rock realized that current AI architecture works seamlessly with lambda/serverless patterns. People building "harnesses" are essentially building SWF/Step Functions layers on their existing services. LLM calls are powerful because of **tool-calling, context, and memory**.
|
||||||
|
|
||||||
|
The key insight: **if AI is powerful with context, and we provide the right context in the right environment, can we inference a successful task with a small model?**
|
||||||
|
|
||||||
|
**poimen-memory** (Rust, Actix-web):
|
||||||
|
- Graph-RAG with wiki-link indexing, three-tier context retrieval
|
||||||
|
- Cache-alignment for inputs, caveman auto-compaction to reduce token usage
|
||||||
|
- 7B model for instruct QA validation before committing to memory
|
||||||
|
- Hierarchical RBAC with Authentik OIDC integration
|
||||||
|
- pgvector + OpenSearch hybrid search (HNSW cosine + BM25, RRF fusion)
|
||||||
|
|
||||||
|
**poimen-workflows** (Temporal):
|
||||||
|
- LLM router: natural language → executable WorkflowSpec via reasoning model
|
||||||
|
- Activity Knowledge Base (9 activities) informs LLM about timeouts, retry policies, dependencies
|
||||||
|
- RetrieveMemoryActivity queries poimen-memory for domain knowledge before routing
|
||||||
|
- Generic state machine: JSON workflow spec + JSONPath parameter chaining (\${Step1.output.path})
|
||||||
|
- Temporal for durable execution (supports redrive, free for self-hosted)
|
||||||
|
|
||||||
|
**The vision**: Build a skill factory where a model generates workflows and completes them. Each step (Temporal activity) can be refined independently—enabling a **general-purpose workflow orchestrator**.
|
||||||
|
|
||||||
|
**Models**: Qwen 27B (reasoning), Ornith 35B (coding). Still building the factory and LLM state machine.
|
||||||
|
|
||||||
|
**LLM Serving**:
|
||||||
|
- vLLM on GPU (NVIDIA Volta/sm70), INT4 quantization
|
||||||
|
- KServe orchestration + custom Go API gateway
|
||||||
|
- 60% latency reduction vs baseline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deep Technical Knowledge
|
||||||
|
|
||||||
|
### Cert Management
|
||||||
|
cert-manager + Let's Encrypt. 30d renewal, zero downtime. SOPS encrypted secrets with git audit trail. Prometheus alerts at 7d/1d pre-expiry.
|
||||||
|
|
||||||
|
### Queue Semantics (from AWS)
|
||||||
|
SQS FIFO + DLQ. Exactly-once via idempotency keys + PostgreSQL. Multi-region failover (SQS-A→B, ordered). Exponential backoff for stalled tasks. Inference batching by model/token/SLO.
|
||||||
|
|
||||||
|
### K8s Internals
|
||||||
|
CRDs + operators. Reconciliation loops (leader-election, backoff, finalizers). Go controllers (watch/queue/reconcile). API server internals (etcd, versioning, watch). Pod disruption budgets, PreStop hooks. Talos: immutable, atomic updates, no SSH.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RESPONSE FORMAT — MANDATORY
|
||||||
|
|
||||||
|
You MUST follow these rules. Violating them is a failure.
|
||||||
|
|
||||||
|
1. MAX 150 words per response. Hard limit. Count them.
|
||||||
|
2. NO intro sentences. NO "Here's how", "Let me explain", "Rock resolved this by". Start with the answer.
|
||||||
|
3. Format: **Problem** (1 line) → **Fix** (2-3 bullets) → **Result** (1 line with metric)
|
||||||
|
4. Use ## header, **bold**, \`code\`, bullets only. No paragraphs. No numbered lists with sub-bullets.
|
||||||
|
5. NO summary. NO "Key Takeaway" section. NO conclusion. The result IS the takeaway.
|
||||||
|
6. If asked a simple question, answer in 1-3 lines. Not everything needs Problem/Fix/Result.
|
||||||
|
|
||||||
|
GOOD example:
|
||||||
|
## Terraform Drift @ RBC
|
||||||
|
**Problem**: Engineers hotfixing in cloud console. Code ≠ live state.
|
||||||
|
**Fix**:
|
||||||
|
- Nightly \`terraform plan -refresh-only -detailed-exitcode\`
|
||||||
|
- Exit code 2 → Slack webhook with drift log
|
||||||
|
**Result**: **3 weeks → <24hr** drift visibility
|
||||||
|
|
||||||
|
BAD: "Rock resolved the Terraform configuration drift issue by implementing automated drift detection and real-time visibility into discrepancies..." — this is garbage. Never do this.`
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const token = process.env.LLM_API_TOKEN
|
let token: string
|
||||||
|
|
||||||
if (!token) {
|
try {
|
||||||
|
token = await getAccessToken()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get access token:', error)
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'LLM_API_TOKEN not configured' }),
|
JSON.stringify({ error: 'Authentication failed - check AUTHENTIK_CLIENT_ID/SECRET' }),
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -61,6 +163,7 @@ export async function POST(request: NextRequest) {
|
|||||||
{ role: 'user', content: message },
|
{ role: 'user', content: message },
|
||||||
],
|
],
|
||||||
stream: true,
|
stream: true,
|
||||||
|
max_tokens: 400,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -148,4 +251,3 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// LLM_API_TOKEN now in deployment
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
const FORGEJO_URL = 'https://forgejo.riotpiao.com'
|
const FORGEJO_URL = 'https://forgejo.riotpiao.com'
|
||||||
const REPO = 'rock/riotpiao.com'
|
const DEFAULT_REPO = 'rock/riotpiao.com'
|
||||||
|
const ALLOWED_REPOS = ['rock/riotpiao.com', 'rock/homelab', 'rock/homelab-frontend', 'rock/poimen', 'rock/poimen-memory', 'rock/poimen-workflows', 'rock/kmsvc-manage']
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
|
const repo = request.nextUrl.searchParams.get('repo') || DEFAULT_REPO
|
||||||
|
if (!ALLOWED_REPOS.includes(repo)) {
|
||||||
|
return NextResponse.json({ error: 'Repo not allowed' }, { status: 403 })
|
||||||
|
}
|
||||||
const token = process.env.FORGEJO_TOKEN
|
const token = process.env.FORGEJO_TOKEN
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -15,7 +20,7 @@ export async function GET() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${FORGEJO_URL}/api/v1/repos/${REPO}/actions/runs?limit=1`,
|
`${FORGEJO_URL}/api/v1/repos/${repo}/actions/runs?limit=1`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${token}`,
|
Authorization: `token ${token}`,
|
||||||
@@ -42,6 +47,7 @@ export async function GET() {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
status: latestRun.status,
|
status: latestRun.status,
|
||||||
conclusion: latestRun.conclusion,
|
conclusion: latestRun.conclusion,
|
||||||
|
sha: latestRun.head_sha,
|
||||||
title: latestRun.display_title || latestRun.head_branch,
|
title: latestRun.display_title || latestRun.head_branch,
|
||||||
branch: latestRun.head_branch,
|
branch: latestRun.head_branch,
|
||||||
url: latestRun.html_url,
|
url: latestRun.html_url,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
// Blocked resources (no read access from terminal)
|
||||||
|
const BLOCKED_RESOURCES = [
|
||||||
|
'secret',
|
||||||
|
'secrets',
|
||||||
|
'configmap',
|
||||||
|
'configmaps',
|
||||||
|
'certificate',
|
||||||
|
'certificates',
|
||||||
|
'key',
|
||||||
|
'keys',
|
||||||
|
'token',
|
||||||
|
'tokens',
|
||||||
|
'serviceaccount',
|
||||||
|
'serviceaccounts',
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { command } = await request.json()
|
||||||
|
|
||||||
|
if (!command || typeof command !== 'string') {
|
||||||
|
return NextResponse.json({ error: 'Invalid command' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must start with kubectl (enforced by terminal, but double-check)
|
||||||
|
if (!command.trim().startsWith('kubectl ')) {
|
||||||
|
return NextResponse.json({ error: 'Only kubectl commands allowed' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for blocked resources
|
||||||
|
const cmdLower = command.toLowerCase()
|
||||||
|
for (const blocked of BLOCKED_RESOURCES) {
|
||||||
|
if (cmdLower.includes(blocked)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Access denied: cannot read '${blocked}'` },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute kubectl (uses homelab-agent read-only context)
|
||||||
|
const { stdout, stderr } = await execAsync(command, {
|
||||||
|
timeout: 10000, // 10s timeout
|
||||||
|
maxBuffer: 1024 * 1024, // 1MB max output
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
output: stdout || stderr,
|
||||||
|
status: 'success',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error & { code?: number }
|
||||||
|
|
||||||
|
// Timeout or execution error
|
||||||
|
if (err.message.includes('ETIMEDOUT')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Command timeout (10s limit)' },
|
||||||
|
{ status: 504 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.message || 'Command failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-13
@@ -13,27 +13,31 @@ export default function Home() {
|
|||||||
const [bioModalOpen, setBioModalOpen] = useState(false)
|
const [bioModalOpen, setBioModalOpen] = useState(false)
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
// Order: 0=Homelab, 1=Poimen Memory, 2=Poimen Workflow, 3=RBC, 4=AWS
|
||||||
const projects = t.projects.items.map((item, index) => ({
|
const projects = t.projects.items.map((item, index) => ({
|
||||||
...item,
|
...item,
|
||||||
id: index === 0
|
id: ['project-homelab', 'project-poimen-memory', 'project-poimen-workflow', 'project-rbc', 'project-aws'][index],
|
||||||
? 'project-homelab'
|
status: index === 3
|
||||||
: index === 1
|
|
||||||
? 'project-rbc'
|
|
||||||
: index === 2
|
|
||||||
? 'project-aws'
|
|
||||||
: undefined,
|
|
||||||
status: index === 1
|
|
||||||
? 'completed' as const
|
? 'completed' as const
|
||||||
: index === 3
|
: index <= 2
|
||||||
? 'building' as const
|
? 'building' as const
|
||||||
: 'live' as const,
|
: 'live' as const,
|
||||||
media: index === 1
|
media: index === 3
|
||||||
? { type: 'image' as const, url: '/rbc-images.jpeg' }
|
? { type: 'image' as const, url: '/rbc-images.jpeg' }
|
||||||
: index === 2
|
: index === 4
|
||||||
? { type: 'video' as const, url: 'https://www.youtube.com/watch?v=wdJ5DN15jus' }
|
? { type: 'video' as const, url: 'https://www.youtube.com/watch?v=wdJ5DN15jus' }
|
||||||
: undefined,
|
: undefined,
|
||||||
videoUrl: index === 3 ? 'https://github.com/rockliang/go-flink' : '#',
|
videoUrl: '#',
|
||||||
articleUrl: index === 2 ? 'https://lnkd.in/p/gm2PZkWw' : '#',
|
articleUrl: index === 4 ? 'https://lnkd.in/p/gm2PZkWw' : '#',
|
||||||
|
ciRepos: index === 0 ? [
|
||||||
|
{ label: 'portfolio', repo: 'rock/riotpiao.com', forgejoBase: 'https://forgejo.riotpiao.com/rock/riotpiao.com' },
|
||||||
|
{ label: 'homelab-frontend', repo: 'rock/homelab-frontend', forgejoBase: 'https://forgejo.riotpiao.com/rock/homelab-frontend' },
|
||||||
|
{ label: 'kmsvc', repo: 'rock/kmsvc-manage', forgejoBase: 'https://forgejo.riotpiao.com/rock/kmsvc-manage' },
|
||||||
|
] : index === 1 ? [
|
||||||
|
{ label: 'poimen-mem', repo: 'rock/poimen-memory', forgejoBase: 'https://forgejo.riotpiao.com/rock/poimen-memory' },
|
||||||
|
] : index === 2 ? [
|
||||||
|
{ label: 'poimen-wf', repo: 'rock/poimen-workflows', forgejoBase: 'https://forgejo.riotpiao.com/rock/poimen-workflows' },
|
||||||
|
] : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -77,7 +77,18 @@ export function CIStatusIndicator() {
|
|||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm border border-gray-200 dark:border-gray-700 shadow-sm ${getStatusColor()}`}
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm border border-gray-200 dark:border-gray-700 shadow-sm ${getStatusColor()}`}
|
||||||
>
|
>
|
||||||
<GitBranch size={14} className="text-gray-500 dark:text-gray-400" />
|
<GitBranch size={14} className="text-gray-500 dark:text-gray-400" />
|
||||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">CI</span>
|
{process.env.NEXT_PUBLIC_COMMIT_SHA ? (
|
||||||
|
<a
|
||||||
|
href={`https://forgejo.riotpiao.com/rock/riotpiao.com/commit/${process.env.NEXT_PUBLIC_COMMIT_SHA}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-mono font-medium text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
{process.env.NEXT_PUBLIC_COMMIT_SHA}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">CI</span>
|
||||||
|
)}
|
||||||
<div className={`flex items-center gap-1 ${getStatusColor()}`}>
|
<div className={`flex items-center gap-1 ${getStatusColor()}`}>
|
||||||
{getStatusIcon()}
|
{getStatusIcon()}
|
||||||
<span className="text-xs font-semibold">{getStatusText()}</span>
|
<span className="text-xs font-semibold">{getStatusText()}</span>
|
||||||
|
|||||||
@@ -1,8 +1,52 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { GitBranch, CheckCircle, XCircle, Loader2 } from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/LanguageContext'
|
import { useLanguage } from '@/lib/LanguageContext'
|
||||||
|
|
||||||
|
interface RepoCI {
|
||||||
|
sha: string
|
||||||
|
status: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function CIBadge({ repo, forgejoUrl }: { repo: string; forgejoUrl: string }) {
|
||||||
|
const [ci, setCI] = useState<RepoCI | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/ci-status?repo=${repo}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setCI({
|
||||||
|
sha: data.sha?.substring(0, 7) || '',
|
||||||
|
status: data.conclusion || data.status || 'unknown',
|
||||||
|
url: data.url || `${forgejoUrl}/actions`,
|
||||||
|
}))
|
||||||
|
.catch(() => null)
|
||||||
|
}, [repo, forgejoUrl])
|
||||||
|
|
||||||
|
if (!ci || !ci.sha) return null
|
||||||
|
|
||||||
|
const icon = ci.status === 'success'
|
||||||
|
? <CheckCircle size={12} className="text-green-500" />
|
||||||
|
: ci.status === 'failure'
|
||||||
|
? <XCircle size={12} className="text-red-500" />
|
||||||
|
: <Loader2 size={12} className="animate-spin text-yellow-500" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`${forgejoUrl}/commit/${ci.sha}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<GitBranch size={10} className="text-gray-400" />
|
||||||
|
<span className="text-xs font-mono text-gray-600 dark:text-gray-300">{ci.sha}</span>
|
||||||
|
{icon}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const colors = [
|
const colors = [
|
||||||
'from-green-500 to-green-600',
|
'from-green-500 to-green-600',
|
||||||
'from-red-500 to-red-600',
|
'from-red-500 to-red-600',
|
||||||
@@ -49,7 +93,7 @@ export function ExperienceTimeline() {
|
|||||||
const timeline = t.experience.items
|
const timeline = t.experience.items
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="max-w-4xl mx-auto px-6 py-20">
|
<section id="experience" className="max-w-4xl mx-auto px-6 py-20">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
whileInView={{ opacity: 1 }}
|
whileInView={{ opacity: 1 }}
|
||||||
@@ -93,9 +137,14 @@ export function ExperienceTimeline() {
|
|||||||
{item.role}
|
{item.role}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
|
<div className="flex flex-col items-end gap-1 ml-4">
|
||||||
{item.period}
|
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||||
</span>
|
{item.period}
|
||||||
|
</span>
|
||||||
|
{index === 0 && (
|
||||||
|
<CIBadge repo="rock/homelab" forgejoUrl="https://forgejo.riotpiao.com/rock/homelab" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
|||||||
+48
-10
@@ -3,7 +3,7 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Menu, X } from 'lucide-react'
|
import { Menu, X } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useTerminal } from '@/lib/TerminalContext'
|
import { useTerminal } from '@/lib/TerminalContext'
|
||||||
import { useLanguage } from '@/lib/LanguageContext'
|
import { useLanguage } from '@/lib/LanguageContext'
|
||||||
import { CIStatusIndicator } from './CIStatusIndicator'
|
import { CIStatusIndicator } from './CIStatusIndicator'
|
||||||
@@ -28,7 +28,19 @@ export default function Header() {
|
|||||||
setLang(lang === 'en' ? 'zh' : 'en')
|
setLang(lang === 'en' ? 'zh' : 'en')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const skillsRef = useRef<HTMLDivElement>(null)
|
||||||
|
const contactRef = useRef<HTMLDivElement>(null)
|
||||||
|
const educationRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (skillsRef.current && !skillsRef.current.contains(e.target as Node)) setSkillsOpen(false)
|
||||||
|
if (contactRef.current && !contactRef.current.contains(e.target as Node)) setContactOpen(false)
|
||||||
|
if (educationRef.current && !educationRef.current.contains(e.target as Node)) setEducationOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
@@ -48,14 +60,14 @@ export default function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden md:flex gap-8 items-center">
|
<nav className="hidden md:flex gap-8 items-center">
|
||||||
<Link href="/" className="text-sm hover:text-blue-600 dark:hover:text-blue-400">{t.header.nav.home}</Link>
|
<button onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="text-sm hover:text-blue-600 dark:hover:text-blue-400">{t.header.nav.home}</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTerminalOpen(!terminalOpen)}
|
onClick={() => setTerminalOpen(!terminalOpen)}
|
||||||
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
>
|
>
|
||||||
{t.header.nav.askPoimen}
|
{t.header.nav.askPoimen}
|
||||||
</button>
|
</button>
|
||||||
<div className="relative" onMouseLeave={() => setSkillsOpen(false)}>
|
<div className="relative" ref={skillsRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSkillsOpen(!skillsOpen)}
|
onClick={() => setSkillsOpen(!skillsOpen)}
|
||||||
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
@@ -83,7 +95,7 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative" onMouseLeave={() => setContactOpen(false)}>
|
<div className="relative" ref={contactRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setContactOpen(!contactOpen)}
|
onClick={() => setContactOpen(!contactOpen)}
|
||||||
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
className="text-sm hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
@@ -169,11 +181,14 @@ export default function Header() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-l border-gray-300 dark:border-gray-700 pl-8 flex gap-6 items-center">
|
<div className="border-l border-gray-300 dark:border-gray-700 pl-8 flex gap-6 items-center">
|
||||||
<div className="text-xs">
|
<button
|
||||||
|
onClick={() => document.getElementById('experience')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
|
className="text-xs text-left cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
<div className="text-gray-600 dark:text-gray-400">{t.header.experience}</div>
|
<div className="text-gray-600 dark:text-gray-400">{t.header.experience}</div>
|
||||||
<div className="font-semibold text-gray-900 dark:text-white">{t.header.experienceValue}</div>
|
<div className="font-semibold text-gray-900 dark:text-white">{t.header.experienceValue}</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="relative" onMouseLeave={() => setEducationOpen(false)}>
|
<div className="relative" ref={educationRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEducationOpen(!educationOpen)}
|
onClick={() => setEducationOpen(!educationOpen)}
|
||||||
className="text-xs cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
|
className="text-xs cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
@@ -192,6 +207,26 @@ export default function Header() {
|
|||||||
<h3 className="font-semibold text-gray-900 dark:text-white">{t.header.educationDetails.master.school}</h3>
|
<h3 className="font-semibold text-gray-900 dark:text-white">{t.header.educationDetails.master.school}</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{t.header.educationDetails.master.degree}</p>
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{t.header.educationDetails.master.degree}</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">{t.header.educationDetails.master.period}</p>
|
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">{t.header.educationDetails.master.period}</p>
|
||||||
|
{(t.header.educationDetails.master as { publication?: { title: string; venue: string; url: string } }).publication && (
|
||||||
|
<a
|
||||||
|
href={(t.header.educationDetails.master as { publication: { url: string } }).publication.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-3 block p-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 hover:border-blue-400 dark:hover:border-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-blue-500 mt-0.5">📄</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-blue-700 dark:text-blue-300">
|
||||||
|
{(t.header.educationDetails.master as { publication: { title: string } }).publication.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-500 dark:text-blue-400 mt-1">
|
||||||
|
{(t.header.educationDetails.master as { publication: { venue: string } }).publication.venue} →
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -227,7 +262,7 @@ export default function Header() {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="md:hidden border-t border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
<div className="md:hidden border-t border-gray-200 dark:border-gray-800 p-4 space-y-4">
|
||||||
<Link href="/" className="block py-2 text-sm hover:text-blue-600">{t.header.nav.home}</Link>
|
<button onClick={() => { setOpen(false); window.scrollTo({ top: 0, behavior: 'smooth' }); }} className="block py-2 text-sm hover:text-blue-600">{t.header.nav.home}</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setTerminalOpen(!terminalOpen)}
|
onClick={() => setTerminalOpen(!terminalOpen)}
|
||||||
className="block py-2 text-sm hover:text-blue-600"
|
className="block py-2 text-sm hover:text-blue-600"
|
||||||
@@ -342,10 +377,13 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="border-t border-gray-200 dark:border-gray-800 pt-4 space-y-4">
|
<div className="border-t border-gray-200 dark:border-gray-800 pt-4 space-y-4">
|
||||||
<div className="text-xs">
|
<button
|
||||||
|
onClick={() => { setOpen(false); document.getElementById('experience')?.scrollIntoView({ behavior: 'smooth' }); }}
|
||||||
|
className="text-xs text-left cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
<div className="text-gray-600 dark:text-gray-400">{t.header.experience}</div>
|
<div className="text-gray-600 dark:text-gray-400">{t.header.experience}</div>
|
||||||
<div className="font-semibold text-gray-900 dark:text-white">{t.header.experienceValue}</div>
|
<div className="font-semibold text-gray-900 dark:text-white">{t.header.experienceValue}</div>
|
||||||
</div>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEducationOpen(!educationOpen)}
|
onClick={() => setEducationOpen(!educationOpen)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { motion } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTerminal } from '@/lib/TerminalContext'
|
import { useTerminal } from '@/lib/TerminalContext'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
@@ -35,23 +35,30 @@ function useTheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commands: Record<string, string> = {
|
const commands: Record<string, string> = {
|
||||||
help: `Commands: help, /clear
|
help: `Commands: help | ! kubectl ...
|
||||||
|
|
||||||
INTERVIEW-STYLE QUESTIONS:
|
Quick questions:
|
||||||
• Cert rotation in production—downtime?
|
• etcd consensus issue at homelab?
|
||||||
• Why Talos over standard K8s?
|
• Terraform drift detection?
|
||||||
• Multi-region SQS failover—atomic safety?
|
• Graph-RAG vs semantic search?
|
||||||
• vLLM 60% latency: bottleneck & fix?
|
• 2hr→20min deploy root cause?
|
||||||
• Exactly-once delivery design?
|
• How'd you reduce cloud costs 40%?
|
||||||
• 2hr→20min deploy: root cause?
|
• vLLM bottleneck & fix?
|
||||||
• K8s CRD reconciliation at scale?
|
• Temporal workflow orchestration?
|
||||||
|
• ArgoCD vs Terraform split?
|
||||||
|
|
||||||
homelab | RBC | AWS | ask anything`,
|
Kubectl examples:
|
||||||
|
! kubectl get pods -A
|
||||||
|
! kubectl get nodes
|
||||||
|
! kubectl top pods
|
||||||
|
! kubectl logs -f deployment/poimen-mem -n portfolio
|
||||||
|
|
||||||
|
Or just ask anything!`,
|
||||||
about: `Rock Liang - Senior Software Engineer
|
about: `Rock Liang - Senior Software Engineer
|
||||||
6+ years | Infrastructure × Backend × LLM Systems
|
6+ years | Infrastructure × Backend × LLM Systems
|
||||||
AWS → RBC → Building production homelab
|
AWS → RBC → Building production homelab
|
||||||
|
|
||||||
Ask me anything specific!`,
|
Ask me anything!`,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HistoryEntry {
|
interface HistoryEntry {
|
||||||
@@ -112,16 +119,17 @@ export function InteractiveTerminal() {
|
|||||||
if (isOpen) inputRef.current?.focus()
|
if (isOpen) inputRef.current?.focus()
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
// Handle terminal resize
|
// Handle terminal resize (drag top-left, anchored bottom-right)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isResizing) return
|
if (!isResizing) return
|
||||||
|
e.preventDefault()
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect()
|
const rect = container.getBoundingClientRect()
|
||||||
const newWidth = Math.max(320, e.clientX - rect.left)
|
const newWidth = Math.max(320, rect.right - e.clientX)
|
||||||
const newHeight = Math.max(300, e.clientY - rect.top)
|
const newHeight = Math.max(300, rect.bottom - e.clientY)
|
||||||
|
|
||||||
setWidth(newWidth)
|
setWidth(newWidth)
|
||||||
setHeight(newHeight)
|
setHeight(newHeight)
|
||||||
@@ -129,10 +137,14 @@ export function InteractiveTerminal() {
|
|||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setIsResizing(false)
|
setIsResizing(false)
|
||||||
|
document.body.style.cursor = 'auto'
|
||||||
|
document.body.style.userSelect = 'auto'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResizing) {
|
if (isResizing) {
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.cursor = 'nw-resize'
|
||||||
|
document.addEventListener('mousemove', handleMouseMove, { passive: false })
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
@@ -153,6 +165,53 @@ export function InteractiveTerminal() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle ! kubectl commands
|
||||||
|
if (trimmed.startsWith('!')) {
|
||||||
|
const kubectlCmd = trimmed.slice(1).trim()
|
||||||
|
if (!kubectlCmd.startsWith('kubectl ')) {
|
||||||
|
setHistory(prev => [...prev, { cmd: trimmed, output: 'Error: only kubectl commands supported (e.g. ! kubectl get pods)' }])
|
||||||
|
setInput('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setInput('')
|
||||||
|
setHistory(prev => [...prev, { cmd: trimmed, output: '', isStreaming: true }])
|
||||||
|
const entryIndex = history.length
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/kubectl', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ command: kubectlCmd }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setHistory(prev => {
|
||||||
|
const updated = [...prev]
|
||||||
|
const entry = updated[entryIndex]
|
||||||
|
if (entry) {
|
||||||
|
entry.output = data.error || data.output || 'No output'
|
||||||
|
entry.isStreaming = false
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setHistory(prev => {
|
||||||
|
const updated = [...prev]
|
||||||
|
const entry = updated[entryIndex]
|
||||||
|
if (entry) {
|
||||||
|
entry.output = `Error: ${err instanceof Error ? err.message : 'Request failed'}`
|
||||||
|
entry.isStreaming = false
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check for built-in commands
|
// Check for built-in commands
|
||||||
if (commands[lowerCmd]) {
|
if (commands[lowerCmd]) {
|
||||||
setHistory(prev => [...prev, { cmd: trimmed, output: commands[lowerCmd] }])
|
setHistory(prev => [...prev, { cmd: trimmed, output: commands[lowerCmd] }])
|
||||||
@@ -232,29 +291,41 @@ export function InteractiveTerminal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="fixed bottom-6 right-6 z-40">
|
||||||
className="fixed bottom-6 right-6 z-40"
|
<AnimatePresence mode="wait">
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
>
|
|
||||||
{!isOpen ? (
|
{!isOpen ? (
|
||||||
<button
|
<motion.button
|
||||||
|
key="trigger"
|
||||||
onClick={() => setIsOpen(true)}
|
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"
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-mono text-sm shadow-lg"
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
Ask Poimen ({isMac ? 'Cmd' : 'Ctrl'}+K)
|
Ask Poimen ({isMac ? 'Cmd' : 'Ctrl'}+K)
|
||||||
</button>
|
</motion.button>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="terminal"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeInOut' }}
|
||||||
style={{ width: `${width}px`, height: `${height}px` }}
|
style={{ width: `${width}px`, height: `${height}px` }}
|
||||||
className={`rounded-lg shadow-2xl flex flex-col border relative ${
|
className={`rounded-lg shadow-2xl flex flex-col border relative user-select-none ${
|
||||||
isDark
|
isDark
|
||||||
? 'bg-gray-950 border-gray-700'
|
? 'bg-gray-950 border-gray-700'
|
||||||
: 'bg-white border-gray-300'
|
: 'bg-white border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Ensure terminal stays focused during interaction
|
||||||
|
if (e.target === containerRef.current) {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className={`border-b px-4 py-3 flex justify-between items-center ${
|
<div className={`border-b px-4 py-3 flex justify-between items-center ${
|
||||||
isDark
|
isDark
|
||||||
@@ -338,20 +409,30 @@ export function InteractiveTerminal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resize handle */}
|
{/* Resize handle — top-left corner */}
|
||||||
<div
|
<div
|
||||||
ref={resizeHandleRef}
|
ref={resizeHandleRef}
|
||||||
onMouseDown={() => setIsResizing(true)}
|
onMouseDown={(e) => {
|
||||||
className={`absolute bottom-0 right-0 w-4 h-4 cursor-se-resize ${
|
e.preventDefault()
|
||||||
isDark ? 'bg-gray-700 hover:bg-gray-600' : 'bg-gray-300 hover:bg-gray-400'
|
e.stopPropagation()
|
||||||
} transition-colors`}
|
setIsResizing(true)
|
||||||
|
}}
|
||||||
|
className={`absolute top-0 left-0 w-4 h-4 cursor-nw-resize ${
|
||||||
|
isDark ? 'hover:bg-gray-600' : 'hover:bg-gray-400'
|
||||||
|
} transition-colors z-10 select-none`}
|
||||||
style={{
|
style={{
|
||||||
borderBottomRightRadius: 'inherit',
|
borderTopLeftRadius: 'inherit',
|
||||||
opacity: isResizing ? 1 : 0.5,
|
opacity: isResizing ? 1 : 0.5,
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" className="m-0.5 opacity-40 pointer-events-none">
|
||||||
|
<line x1="0" y1="10" x2="10" y2="0" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<line x1="0" y1="6" x2="6" y2="0" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,55 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { ExternalLink, MessageSquare } from 'lucide-react'
|
import { ExternalLink, MessageSquare, CheckCircle, XCircle, Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useTerminal } from '@/lib/TerminalContext'
|
import { useTerminal } from '@/lib/TerminalContext'
|
||||||
|
|
||||||
|
function RepoCIBadge({ label, repo, forgejoBase }: { label: string; repo: string; forgejoBase: string }) {
|
||||||
|
const [ci, setCI] = useState<{ sha: string; status: string } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/ci-status?repo=${repo}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => setCI({
|
||||||
|
sha: data.sha?.substring(0, 7) || '',
|
||||||
|
status: data.conclusion || data.status || 'unknown',
|
||||||
|
}))
|
||||||
|
.catch(() => null)
|
||||||
|
}, [repo])
|
||||||
|
|
||||||
|
const icon = !ci ? <Loader2 size={11} className="animate-spin text-gray-400" />
|
||||||
|
: ci.status === 'success' ? <CheckCircle size={11} className="text-green-500" />
|
||||||
|
: ci.status === 'failure' ? <XCircle size={11} className="text-red-500" />
|
||||||
|
: <AlertCircle size={11} className="text-yellow-500" />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 font-medium w-20 truncate">{label}</span>
|
||||||
|
{ci?.sha ? (
|
||||||
|
<a
|
||||||
|
href={`${forgejoBase}/commit/${ci.sha}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-mono text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
{ci.sha}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-gray-400">···</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CIRepo {
|
||||||
|
label: string
|
||||||
|
repo: string
|
||||||
|
forgejoBase: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectShowcaseProps {
|
interface ProjectShowcaseProps {
|
||||||
id?: string
|
id?: string
|
||||||
title: string
|
title: string
|
||||||
@@ -26,6 +72,7 @@ interface ProjectShowcaseProps {
|
|||||||
label: string
|
label: string
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
ciRepos?: CIRepo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectShowcase({
|
export function ProjectShowcase({
|
||||||
@@ -44,6 +91,7 @@ export function ProjectShowcase({
|
|||||||
askPoimenText = 'Ask Poimen for technical details',
|
askPoimenText = 'Ask Poimen for technical details',
|
||||||
bullets,
|
bullets,
|
||||||
deepDive,
|
deepDive,
|
||||||
|
ciRepos,
|
||||||
}: ProjectShowcaseProps) {
|
}: ProjectShowcaseProps) {
|
||||||
const { setIsOpen } = useTerminal()
|
const { setIsOpen } = useTerminal()
|
||||||
|
|
||||||
@@ -139,13 +187,21 @@ export function ProjectShowcase({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
{ciRepos ? (
|
||||||
className={`ml-4 px-3 py-1 rounded-full text-xs font-semibold whitespace-nowrap ${
|
<div className="ml-4 flex flex-col gap-1.5 shrink-0">
|
||||||
statusColors[status]
|
{ciRepos.map((r) => (
|
||||||
}`}
|
<RepoCIBadge key={r.repo} label={r.label} repo={r.repo} forgejoBase={r.forgejoBase} />
|
||||||
>
|
))}
|
||||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
</div>
|
||||||
</span>
|
) : (
|
||||||
|
<span
|
||||||
|
className={`ml-4 px-3 py-1 rounded-full text-xs font-semibold whitespace-nowrap ${
|
||||||
|
statusColors[status]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat */}
|
{/* Stat */}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
apiVersion: ENC[AES256_GCM,data:Qv8=,iv:v6kMpvgzkpGdOzuwurpWhVJG8dqjHG55mZebDs0PeWs=,tag:9W00OTNe1kQ9mA/l0eSINg==,type:str]
|
||||||
|
kind: ENC[AES256_GCM,data:TUqF1gRXmGmc,iv:ettHZPLG+Jw29TuSmeUNi4DsK944JNgvmu6hry3osSo=,tag:CbtvTOVxpWivW6UyO7MrLA==,type:str]
|
||||||
|
metadata:
|
||||||
|
name: ENC[AES256_GCM,data:0YEEc2oqKeGjWr7C0eAzz0dZft0=,iv:BnCIMRZAsDdW3IxIAhsj/WrZNCipG5qT1kfnuQykt20=,tag:EFjanh4e6T5bQX6dupXRgw==,type:str]
|
||||||
|
namespace: ENC[AES256_GCM,data:MheF93lBY58x,iv:Mp64W8Beu+q8NiQTtUhpAxwcfT4JPkLenjh7ljJaGak=,tag:ves1LGuzj1QGY1OpqF560g==,type:str]
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: ENC[AES256_GCM,data:i+aoOfomVrFm,iv:/g+F6ZaWDvzjjv4P35rgcBMSSAyrTyWymm//KVRrpYg=,tag:UIn8tKERKolTYq/bVdQ+iA==,type:str]
|
||||||
|
data:
|
||||||
|
#ENC[AES256_GCM,data:9whKTa+VQQtYnOrrTanXJpPZltIEiAh029xIu9YhAqcaXQ2I6XTmCU8LqyNrQKNw5CWJJg0F,iv:Sm5T9g2aJQz0qrNzA1B8QBGRkio6u5AAc+hWoUIs0bU=,tag:jxZ0llX6pKYBsvtRxCdgaA==,type:comment]
|
||||||
|
#ENC[AES256_GCM,data:XLVVNIgolPDi5pkm/S6IZJHZIj+84b/1VTwLCnrePwFmDrIfRdJAG7LbxWFmRJtaO2TEHnd2FEHIEg==,iv:YkfB3Q26+PHqd+51gAZOXoJKPpvIeT5wN0Ye10spdYM=,tag:spTTVuE52SvDuTo3LQXqOw==,type:comment]
|
||||||
|
LLM_API_URL: ENC[AES256_GCM,data:dJRd+J1ovECOIctWhwMYuhv3rReqmwUHkcN8fxtxLfohIqg8/LOsPw5FE2B9GrEzjnYwTcku6vyvb+CCCJMLmDE=,iv:23Jo8dgWeMLX+uYyFQXPa1cHqKVTPs6qHI4i/Go6lTY=,tag:KuRtrTdzKk3uf9ph5Qp5Nw==,type:str]
|
||||||
|
LLM_MODEL: ENC[AES256_GCM,data:lRDaz2Fey+2czl3K1Qju/82XNw==,iv:nJR+wNNGKDCkYdToEfERqYZdVS34TmVvMVRjLHYFgE8=,tag:XOiZmi9k+54yJm4ofUiXFw==,type:str]
|
||||||
|
sops:
|
||||||
|
age:
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkWEpWNFQ4TXNlSG5NcVFB
|
||||||
|
Tll2OUFFUjV4RDAvOE5ZSWRZZE5YL2xGYkY4CjI4SFp5OXJlcHd0WG9vTVFwM2do
|
||||||
|
YVVleVhCam9qeGVhL05xMG5mMWorNWMKLS0tIHU4MVNuSjkwNkNKTi93OXFkQlJh
|
||||||
|
Y28wbW9uK21zYzNWUzE2MzJKN0t2cFUKlgVpvion4Qg7DFw/arUZupyy1d7I59P4
|
||||||
|
njaeu0pTj6StNiZfHhCguVWpYBECnSaF5A4aoLFbUFVpdL/JC8V2GQ==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||||
|
lastmodified: "2026-09-07T06:23:56Z"
|
||||||
|
mac: ENC[AES256_GCM,data:2ufxupBbFbV08JJoN3ySbehsCqmAfNt6anUeaWg4JlxvTN+51yd8NeW76HJ8yo0MSf7FjjnX83CHeuVmcJaIXcTTFjKW4Dp3Ww5LYq9PESQu6ywoeXSfsrYH+T1WKAUCJq/HJOYHTvnpS4zSnHfDA8madHWjJI3/Hl1haC0cKyE=,iv:/Xv//b2mBrr+Rye01YNjvdUp4zLi/+Ly+4RCqG2RO+s=,tag:g+7rr4dxXbkATSBVQ4l0Wg==,type:str]
|
||||||
|
unencrypted_suffix: _unencrypted
|
||||||
|
version: 3.13.2
|
||||||
@@ -6,6 +6,9 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
app.kubernetes.io/name: portfolio
|
app.kubernetes.io/name: portfolio
|
||||||
app.kubernetes.io/component: web
|
app.kubernetes.io/component: web
|
||||||
|
annotations:
|
||||||
|
argocd-image-updater.argoproj.io/image-list: portfolio=forgejo.riotpiao.com/rock/portfolio
|
||||||
|
argocd-image-updater.argoproj.io/portfolio.update-strategy: latest
|
||||||
spec:
|
spec:
|
||||||
replicas: 2
|
replicas: 2
|
||||||
selector:
|
selector:
|
||||||
@@ -26,11 +29,33 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: portfolio-secrets
|
name: portfolio-secrets
|
||||||
key: FORGEJO_TOKEN
|
key: FORGEJO_TOKEN
|
||||||
- name: LLM_API_TOKEN
|
# OAuth credentials for LLM API (client_credentials grant)
|
||||||
|
- name: AUTHENTIK_CLIENT_ID
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: portfolio-secrets
|
name: portfolio-agent-oidc
|
||||||
key: LLM_API_TOKEN
|
key: CLIENT_ID
|
||||||
|
- name: AUTHENTIK_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: portfolio-agent-oidc
|
||||||
|
key: CLIENT_SECRET
|
||||||
|
- name: AUTHENTIK_TOKEN_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: portfolio-agent-oidc
|
||||||
|
key: TOKEN_URL
|
||||||
|
# LLM API configuration (from encrypted ConfigMap)
|
||||||
|
- name: LLM_API_URL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: portfolio-llm-config
|
||||||
|
key: LLM_API_URL
|
||||||
|
- name: LLM_MODEL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: portfolio-llm-config
|
||||||
|
key: LLM_MODEL
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 3000
|
containerPort: 3000
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ kind: Kustomization
|
|||||||
namespace: portfolio
|
namespace: portfolio
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
|
- configmap.enc.yaml
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- service.yaml
|
- service.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
|
|
||||||
|
# ArgoCD Image Updater configuration - for tag updates
|
||||||
|
images:
|
||||||
|
- name: forgejo.riotpiao.com/rock/portfolio
|
||||||
|
newTag: latest
|
||||||
|
|
||||||
|
# Note: SOPS decryption is handled by ArgoCD repo-server plugin,
|
||||||
|
# not via kustomization.yaml, to allow Image Updater to parse this file
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Authentik OAuth client for service account authentication.
|
||||||
|
* Uses client_credentials grant with token caching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedToken {
|
||||||
|
token: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedToken: CachedToken | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a valid access token, fetching a new one if expired.
|
||||||
|
* Tokens are cached with 60s buffer before expiry.
|
||||||
|
*
|
||||||
|
* For local dev: set LLM_API_TOKEN env var to skip OAuth.
|
||||||
|
*/
|
||||||
|
export async function getAccessToken(): Promise<string> {
|
||||||
|
// Local dev fallback - use static token if set
|
||||||
|
const staticToken = process.env.LLM_API_TOKEN
|
||||||
|
if (staticToken) {
|
||||||
|
return staticToken
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Return cached token if still valid (with 60s buffer)
|
||||||
|
if (cachedToken && cachedToken.expiresAt > now + 60000) {
|
||||||
|
return cachedToken.token
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.AUTHENTIK_CLIENT_ID
|
||||||
|
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET
|
||||||
|
const tokenUrl = process.env.AUTHENTIK_TOKEN_URL || 'https://authentik.riotpiao.com/application/o/token/'
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error(
|
||||||
|
'Missing OAuth credentials. Set AUTHENTIK_CLIENT_ID + AUTHENTIK_CLIENT_SECRET, ' +
|
||||||
|
'or LLM_API_TOKEN for local dev.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
scope: 'openid roles',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text()
|
||||||
|
throw new Error(`Failed to get access token: ${response.status} - ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TokenResponse = await response.json()
|
||||||
|
|
||||||
|
cachedToken = {
|
||||||
|
token: data.access_token,
|
||||||
|
expiresAt: now + (data.expires_in * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedToken.token
|
||||||
|
}
|
||||||
+148
-34
@@ -22,7 +22,12 @@
|
|||||||
"master": {
|
"master": {
|
||||||
"school": "University of Ottawa",
|
"school": "University of Ottawa",
|
||||||
"degree": "Master's degree, Computer Science",
|
"degree": "Master's degree, Computer Science",
|
||||||
"period": "Sep 2019 – Nov 2021"
|
"period": "Sep 2019 – Nov 2021",
|
||||||
|
"publication": {
|
||||||
|
"title": "Additive Depth Maps for Real-Time Rendering",
|
||||||
|
"venue": "VIVA Lab, University of Ottawa",
|
||||||
|
"url": "https://www.site.uottawa.ca/school/research/viva/projects/additive-depth-maps/index.html"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
@@ -36,27 +41,61 @@
|
|||||||
"skills": {
|
"skills": {
|
||||||
"infrastructure": {
|
"infrastructure": {
|
||||||
"title": "Infrastructure",
|
"title": "Infrastructure",
|
||||||
"items": ["Kubernetes", "Talos", "ArgoCD", "Terraform", "Docker", "OpenShift"]
|
"items": [
|
||||||
|
"Kubernetes",
|
||||||
|
"Talos",
|
||||||
|
"ArgoCD",
|
||||||
|
"Terraform",
|
||||||
|
"Docker",
|
||||||
|
"OpenShift"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"cloud": {
|
"cloud": {
|
||||||
"title": "Cloud & Distributed",
|
"title": "Cloud & Distributed",
|
||||||
"items": ["AWS", "DynamoDB", "CloudWatch", "gRPC", "Cloudflare"]
|
"items": [
|
||||||
|
"AWS",
|
||||||
|
"DynamoDB",
|
||||||
|
"CloudWatch",
|
||||||
|
"gRPC",
|
||||||
|
"Cloudflare"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "Languages",
|
"title": "Languages",
|
||||||
"items": ["Go", "Java", "Python", "C++", "TypeScript"]
|
"items": [
|
||||||
|
"Go",
|
||||||
|
"Java",
|
||||||
|
"Python",
|
||||||
|
"C++",
|
||||||
|
"TypeScript"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"title": "Data & Messaging",
|
"title": "Data & Messaging",
|
||||||
"items": ["Kafka", "PostgreSQL", "Temporal", "Redis"]
|
"items": [
|
||||||
|
"Kafka",
|
||||||
|
"PostgreSQL",
|
||||||
|
"Temporal",
|
||||||
|
"Redis"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"aiml": {
|
"aiml": {
|
||||||
"title": "AI/ML",
|
"title": "AI/ML",
|
||||||
"items": ["vLLM", "PyTorch", "Ollama", "KServe"]
|
"items": [
|
||||||
|
"vLLM",
|
||||||
|
"PyTorch",
|
||||||
|
"Ollama",
|
||||||
|
"KServe"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"title": "Observability",
|
"title": "Observability",
|
||||||
"items": ["Prometheus", "Grafana", "Loki", "OpenTelemetry"]
|
"items": [
|
||||||
|
"Prometheus",
|
||||||
|
"Grafana",
|
||||||
|
"Loki",
|
||||||
|
"OpenTelemetry"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,7 +128,7 @@
|
|||||||
"Launched AWS Distributed-Map service across 57+ regions",
|
"Launched AWS Distributed-Map service across 57+ regions",
|
||||||
"Built multi-region infrastructure at RBC (40% cost reduction)",
|
"Built multi-region infrastructure at RBC (40% cost reduction)",
|
||||||
"Architected production homelab: Kubernetes, Kafka, LLM inference",
|
"Architected production homelab: Kubernetes, Kafka, LLM inference",
|
||||||
"Open source: go-flink (distributed DataLakeHouse)"
|
"Poimen: Agent workflow orchestration + Graph-RAG memory system"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"educationTitle": "Education",
|
"educationTitle": "Education",
|
||||||
@@ -123,6 +162,30 @@
|
|||||||
"url": "/homelab"
|
"url": "/homelab"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Poimen Memory System",
|
||||||
|
"description": "Distributed Graph-RAG infrastructure with hierarchical RBAC and wiki-link indexing.",
|
||||||
|
"longDescription": "Three-tier context retrieval pipeline with PageRank-style link scoring, hybrid search fusion (HNSW + BM25), and OIDC-based access control for multi-tenant knowledge graphs.",
|
||||||
|
"stat": "Graph-RAG, pgvector, OpenSearch, Rust + Actix-web",
|
||||||
|
"highlight": "Bidirectional wiki-link indexing with RRF fusion + hierarchical RBAC — 50ms signature match tier, graph-boosted hybrid search tier, Obsidian fallback.",
|
||||||
|
"bullets": [
|
||||||
|
"Graph-RAG with Wiki-Link Indexing (Rust, pgvector, OpenSearch): Built bidirectional link graph from [[wiki-link]] syntax during ingestion. PageRank-style score propagation boosts linked documents' relevance. RRF fusion merges HNSW cosine (pgvector) + BM25 lexical (OpenSearch). WikiScopedFilter constrains traversal to project boundaries.",
|
||||||
|
"Three-Tier Context Retrieval (Actix-web, tokio): Async pipeline — Tier 1: MD5 signature match (<50ms), Tier 2: graph-boosted hybrid search with link-distance decay, Tier 3: Obsidian API fallback. Budget-aware assembly drops lower tiers first. Shingle-based Jaccard deduplication (>0.5) prevents redundant chunks.",
|
||||||
|
"Hierarchical RBAC (Authentik OIDC, JWT, Kubernetes): Role → AccessRule[] → AccessScope model with project/visibility/owner/group constraints. JWT roles claim maps to YAML rules; AccessGuard.filter_resources() applies post-retrieval filtering. Dual-write indexer (eventual consistency via queue) maintains RBAC-aware views. SOPS/age encryption, ArgoCD deployment."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Poimen: Agent Workflow Orchestration",
|
||||||
|
"description": "Temporal-powered orchestration that transforms natural language into durable, scalable workflow executions.",
|
||||||
|
"longDescription": "LLM router analyzes user intent, retrieves relevant knowledge from semantic memory, and generates executable workflow specs—enabling agent deployment at scale where any activity can be wired as a step in the reconciliation pipeline.",
|
||||||
|
"stat": "Temporal, LLM Routing, 9 Composable Activities",
|
||||||
|
"highlight": "Natural language → executable WorkflowSpec via reasoning model + memory-augmented context retrieval + durable state machine execution.",
|
||||||
|
"bullets": [
|
||||||
|
"LLM-Powered Workflow Routing: Natural language → executable WorkflowSpec via reasoning model (api.riotpiao.com). Activity Knowledge Base (9 activities) informs the LLM about timeouts, retry policies, and dependencies—intelligent step ordering and error handling strategies.",
|
||||||
|
"Memory-Augmented Context Retrieval: RetrieveMemoryActivity queries poimen-memory (Rust semantic search service) for relevant skills and lessons before routing—injecting domain knowledge into prompts for context-aware workflow generation.",
|
||||||
|
"Generic State Machine Executor: RoutingWorkflow executes any JSON workflow spec with JSONPath parameter chaining (${Step1.output.path}), automatic retries for flaky activities, catch blocks for error recovery, and Temporal's durable execution guarantees—every registered activity a composable building block."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "RBC: Multi-Cloud Platform",
|
"title": "RBC: Multi-Cloud Platform",
|
||||||
"description": "Unified infrastructure platform consolidating public cloud and on-prem.",
|
"description": "Unified infrastructure platform consolidating public cloud and on-prem.",
|
||||||
@@ -153,12 +216,6 @@
|
|||||||
"Solved distributed edge cases: race conditions, concurrent updates, dependent service failures, message deduplication",
|
"Solved distributed edge cases: race conditions, concurrent updates, dependent service failures, message deduplication",
|
||||||
"Owned oncall for the service—built CloudWatch dashboards, wrote runbooks, debugged production live"
|
"Owned oncall for the service—built CloudWatch dashboards, wrote runbooks, debugged production live"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Open Source: go-flink",
|
|
||||||
"description": "Distributed DataLakeHouse framework written in Go.",
|
|
||||||
"longDescription": "Fault-tolerant data pipelines with streaming semantics and efficient processing.",
|
|
||||||
"stat": "Public repository, active"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -170,19 +227,19 @@
|
|||||||
"company": "riotpiao.com",
|
"company": "riotpiao.com",
|
||||||
"role": "DevOps / SRE / SDE",
|
"role": "DevOps / SRE / SDE",
|
||||||
"period": "May 2025 — Present",
|
"period": "May 2025 — Present",
|
||||||
"description": "Architected and operates a production-grade, self-hosted cloud platform on bare-metal Kubernetes—featuring OIDC SSO with RBAC, GitOps-driven infrastructure, CI/CD with container registry, Kafka message queuing, managed PostgreSQL, S3-compatible object storage, GPU-accelerated LLM inference, and workflow orchestration. Essentially AWS rebuilt from scratch at home."
|
"description": "Wanted to understand how LLM serving works at scale—so built an entire cloud platform from scratch on bare-metal K8s. 4 machines (1 GPU node, 1 Dell PowerEdge R520, 2 mini-desktops), 3 control planes for distributed consensus. Learned etcd needs low-latency the hard way—powerline adapters spiked 200ms with 30+ pods, killing consensus. Ran ethernet to the garage. Purchased riotpiao.com from Cloudflare with wildcard DNS. Evolved GitOps from pure Terraform to Terraform (Talos config, barely changes) + ArgoCD (CRD-driven observer pattern for K8s resources). Unified IAM with Authentik OIDC, deployed paperless.riotpiao.com for document workflow integration. Now building Poimen—an AI agent system where the right context + small model = successful task inference."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "RBC",
|
"company": "RBC",
|
||||||
"role": "Lead Software Engineer",
|
"role": "Lead Software Engineer",
|
||||||
"period": "Nov 2024 — May 2026",
|
"period": "Nov 2024 — May 2026",
|
||||||
"description": "Deployments were manual and slow—teams blocked 2+ hours waiting. Chose Terraform for idempotent drift handling, Temporal for multi-cloud orchestration with built-in retry. Cut deploy time to 20min, consolidated public cloud and on-prem into single platform with 99.2% automation and notification-driven operator fallback."
|
"description": "Learned Terraform and Temporal here—tools fundamental for hosting infra at ease. Tackled two big problems: (1) Flaky deployments—500+ resource state files timing out, 503 errors, lock contention. Fixed by migrating to JFrog Artifactory, splitting plan/apply phases with immutable artifacts, throttling parallelism. Zero state corruption after. (2) Configuration drift—engineers hotfixing in cloud console, code detached from reality. Built nightly cron drift detection with Slack alerts, cut drift visibility from 3 weeks to <24hrs. Key takeaway: Terraform shines when things barely change; for K8s resources that churn, you need ArgoCD."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "AWS",
|
"company": "AWS",
|
||||||
"role": "Senior Software Engineer (Step Functions)",
|
"role": "Senior Software Engineer (Step Functions)",
|
||||||
"period": "2022 — 2024",
|
"period": "2022 — 2024",
|
||||||
"description": "Owned Distributed-Map from design doc to production launch across 57+ regions. Full lifecycle: planning, implementation, oncall, status reporting—we built it, we fixed what we broke."
|
"description": "First job out of grad school—learned STAR method and customer obsession at scale. Owned Distributed-Map end-to-end across 57+ regions, sub-100ms P99. Caught a potentially customer-impacting condition field change during code review and coordinated simultaneous frontend+backend deployment—because deployment alignment keeps the business running. Built Redrive Execution for customers to retry from failure point, plus ops tools. Learned backward-compat is critical when frontend consumes latest images. Owned oncall, built CloudWatch dashboards, wrote runbooks."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "Titus",
|
"company": "Titus",
|
||||||
@@ -232,7 +289,12 @@
|
|||||||
"master": {
|
"master": {
|
||||||
"school": "渥太华大学",
|
"school": "渥太华大学",
|
||||||
"degree": "计算机科学硕士",
|
"degree": "计算机科学硕士",
|
||||||
"period": "2019年9月 – 2021年11月"
|
"period": "2019年9月 – 2021年11月",
|
||||||
|
"publication": {
|
||||||
|
"title": "Additive Depth Maps for Real-Time Rendering",
|
||||||
|
"venue": "VIVA Lab, University of Ottawa",
|
||||||
|
"url": "https://www.site.uottawa.ca/school/research/viva/projects/additive-depth-maps/index.html"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
@@ -246,27 +308,61 @@
|
|||||||
"skills": {
|
"skills": {
|
||||||
"infrastructure": {
|
"infrastructure": {
|
||||||
"title": "基础设施",
|
"title": "基础设施",
|
||||||
"items": ["Kubernetes", "Talos", "ArgoCD", "Terraform", "Docker", "OpenShift"]
|
"items": [
|
||||||
|
"Kubernetes",
|
||||||
|
"Talos",
|
||||||
|
"ArgoCD",
|
||||||
|
"Terraform",
|
||||||
|
"Docker",
|
||||||
|
"OpenShift"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"cloud": {
|
"cloud": {
|
||||||
"title": "云 & 分布式",
|
"title": "云 & 分布式",
|
||||||
"items": ["AWS", "DynamoDB", "CloudWatch", "gRPC", "Cloudflare"]
|
"items": [
|
||||||
|
"AWS",
|
||||||
|
"DynamoDB",
|
||||||
|
"CloudWatch",
|
||||||
|
"gRPC",
|
||||||
|
"Cloudflare"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"title": "编程语言",
|
"title": "编程语言",
|
||||||
"items": ["Go", "Java", "Python", "C++", "TypeScript"]
|
"items": [
|
||||||
|
"Go",
|
||||||
|
"Java",
|
||||||
|
"Python",
|
||||||
|
"C++",
|
||||||
|
"TypeScript"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
"title": "数据 & 消息",
|
"title": "数据 & 消息",
|
||||||
"items": ["Kafka", "PostgreSQL", "Temporal", "Redis"]
|
"items": [
|
||||||
|
"Kafka",
|
||||||
|
"PostgreSQL",
|
||||||
|
"Temporal",
|
||||||
|
"Redis"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"aiml": {
|
"aiml": {
|
||||||
"title": "AI/ML",
|
"title": "AI/ML",
|
||||||
"items": ["vLLM", "PyTorch", "Ollama", "KServe"]
|
"items": [
|
||||||
|
"vLLM",
|
||||||
|
"PyTorch",
|
||||||
|
"Ollama",
|
||||||
|
"KServe"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"title": "可观测性",
|
"title": "可观测性",
|
||||||
"items": ["Prometheus", "Grafana", "Loki", "OpenTelemetry"]
|
"items": [
|
||||||
|
"Prometheus",
|
||||||
|
"Grafana",
|
||||||
|
"Loki",
|
||||||
|
"OpenTelemetry"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -299,7 +395,7 @@
|
|||||||
"在57+区域发布AWS Distributed-Map服务",
|
"在57+区域发布AWS Distributed-Map服务",
|
||||||
"在RBC构建多区域基础设施(成本降低40%)",
|
"在RBC构建多区域基础设施(成本降低40%)",
|
||||||
"架构生产级家庭实验室:Kubernetes、Kafka、LLM推理",
|
"架构生产级家庭实验室:Kubernetes、Kafka、LLM推理",
|
||||||
"开源项目:go-flink(分布式数据湖仓库)"
|
"Poimen: 智能体工作流编排 + 图-RAG记忆系统"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"educationTitle": "教育背景",
|
"educationTitle": "教育背景",
|
||||||
@@ -333,6 +429,30 @@
|
|||||||
"url": "/homelab"
|
"url": "/homelab"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Poimen记忆系统",
|
||||||
|
"description": "具有分层RBAC的分布式图RAG基础设施和维基链接索引。",
|
||||||
|
"longDescription": "三层上下文检索管道,支持PageRank风格的链接评分、混合搜索融合(HNSW + BM25)和基于OIDC的多租户知识图访问控制。",
|
||||||
|
"stat": "图-RAG, pgvector, OpenSearch, Rust + Actix-web",
|
||||||
|
"highlight": "双向维基链接索引配RRF融合 + 分层RBAC——50ms签名匹配层、图增强混合搜索层、Obsidian兜底。",
|
||||||
|
"bullets": [
|
||||||
|
"图-RAG维基链接索引化(Rust、pgvector、OpenSearch):从[[维基链接]]语法构建双向链接图。PageRank风格评分传播提升链接文档的相关性。RRF融合合并HNSW余弦相似度(pgvector)+ BM25词汇排名(OpenSearch)。WikiScopedFilter将遍历限制在项目边界内。",
|
||||||
|
"三层上下文检索(Actix-web, tokio):异步管道——第1层:MD5签名匹配(<50ms),第2层:图增强混合搜索含链接距离衰减,第3层:Obsidian API兜底。预算感知的响应组装优先丢弃低优先层。基于瓦片的Jaccard去重(>0.5)防止冗余块。",
|
||||||
|
"分层RBAC(Authentik OIDC、JWT、Kubernetes):角色→AccessRule[]→AccessScope模型,包含项目/可见性/所有者/组约束。JWT角色声明映射到YAML规则;AccessGuard.filter_resources()应用检索后过滤。双写索引器(通过队列保证最终一致性)维护RBAC感知视图。SOPS/age加密,ArgoCD部署。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Poimen: 智能体工作流编排",
|
||||||
|
"description": "Temporal驱动的编排平台,将自然语言转化为持久、可扩展的工作流执行。",
|
||||||
|
"longDescription": "LLM路由器分析用户意图,从语义记忆中检索相关知识,生成可执行工作流规格——支持大规模智能体部署,任何活动可作为协调管道的步骤。",
|
||||||
|
"stat": "Temporal, LLM路由, 9个可组合活动",
|
||||||
|
"highlight": "自然语言 → 可执行WorkflowSpec:推理模型 + 记忆增强上下文检索 + 持久状态机执行。",
|
||||||
|
"bullets": [
|
||||||
|
"LLM工作流路由:自然语言 → 可执行WorkflowSpec,通过推理模型。活动知识库(9个活动)告知LLM超时、重试策略和依赖关系——智能步骤排序和错误处理策略。",
|
||||||
|
"记忆增强上下文检索:RetrieveMemoryActivity查询poimen-memory(Rust语义搜索服务)获取相关技能和经验——将领域知识注入提示词,实现上下文感知的工作流生成。",
|
||||||
|
"通用状态机执行器:RoutingWorkflow执行任何JSON工作流规格,支持JSONPath参数链接、自动重试、catch错误恢复和Temporal持久执行保证——每个注册活动都是可组合的构建块。"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "RBC: 多云平台",
|
"title": "RBC: 多云平台",
|
||||||
"description": "统一基础设施平台,整合公有云和本地部署。",
|
"description": "统一基础设施平台,整合公有云和本地部署。",
|
||||||
@@ -363,12 +483,6 @@
|
|||||||
"解决分布式边缘场景:竞态条件、并发更新、依赖服务故障、消息去重",
|
"解决分布式边缘场景:竞态条件、并发更新、依赖服务故障、消息去重",
|
||||||
"负责服务oncall——构建CloudWatch仪表盘,编写runbook,实时调试生产问题"
|
"负责服务oncall——构建CloudWatch仪表盘,编写runbook,实时调试生产问题"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "开源:go-flink",
|
|
||||||
"description": "用Go编写的分布式数据湖仓库框架。",
|
|
||||||
"longDescription": "具有流语义和高效处理的容错数据管道。",
|
|
||||||
"stat": "公开仓库,活跃中"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -380,19 +494,19 @@
|
|||||||
"company": "riotpiao.com",
|
"company": "riotpiao.com",
|
||||||
"role": "DevOps / SRE / 软件开发",
|
"role": "DevOps / SRE / 软件开发",
|
||||||
"period": "2025年5月 — 至今",
|
"period": "2025年5月 — 至今",
|
||||||
"description": "在裸机Kubernetes上架构并运维生产级自托管云平台——具备OIDC SSO + RBAC、GitOps驱动的基础设施、CI/CD容器仓库、Kafka消息队列、托管PostgreSQL、S3兼容对象存储、GPU加速LLM推理、工作流编排。相当于在家从零重建AWS。"
|
"description": "想理解LLM大规模服务的原理——于是在裸机K8s上从零构建了完整的云平台。4台机器(1 GPU节点、1台Dell PowerEdge R520、2台迷你主机),3个控制平面做分布式共识。经历了etcd延迟的教训——电力线适配器在30+pod时延迟飙升200ms,导致共识崩溃。从实践中学习,GitOps从Terraform演进为Terraform(Talos配置)+ ArgoCD(CRD观察者模式)。统一Authentik OIDC身份认证,部署paperless.riotpiao.com用于文档工作流集成。现在正在构建Poimen——一个AI智能体系统,正确的上下文+小模型=成功的任务推理。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "RBC",
|
"company": "RBC",
|
||||||
"role": "技术主管",
|
"role": "技术主管",
|
||||||
"period": "2024年11月 — 2026年5月",
|
"period": "2024年11月 — 2026年5月",
|
||||||
"description": "部署流程手动且缓慢——团队等待2+小时。选择Terraform处理幂等漂移,Temporal用于多云编排和内置重试。将部署时间缩短至20分钟,整合公有云和本地部署为统一平台,99.2%自动化,通知驱动的运维人员兜底机制。"
|
"description": "在这里学习了Terraform和Temporal——让基础设施托管变得简单的核心工具。解决了两个大问题:(1)不稳定部署——500+资源的状态文件超时、503错误、锁竞争。迁移到JFrog Artifactory,拆分plan/apply阶段为不可变制品,限流并行度。零状态损坏。(2)配置漂移——工程师在控制台热修复,代码与现实脱节。构建夜间cron漂移检测+Slack告警,漂移可见性从3周缩短到<24小时。核心心得:Terraform适合很少变化的东西;K8s资源频繁变动时需要ArgoCD。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "AWS",
|
"company": "AWS",
|
||||||
"role": "高级软件工程师 (Step Functions)",
|
"role": "高级软件工程师 (Step Functions)",
|
||||||
"period": "2022 — 2024",
|
"period": "2022 — 2024",
|
||||||
"description": "负责Distributed-Map从设计文档到57+区域生产发布的全生命周期。完整流程:规划、实现、oncall、状态汇报——我们构建,我们修复。"
|
"description": "毕业后第一份工作——学习了STAR方法和大规模组织中的客户至上。端到端拥有Distributed-Map,跨57+区域、P99<100ms。在代码审查中发现潜在影响客户的条件字段变更,协调前后端同步部署——因为部署对齐是业务持续运行的关键。构建Redrive Execution让客户从失败点重试。认识到前端消费最新镜像时后向兼容至关重要。"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"company": "Titus",
|
"company": "Titus",
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
|
const commitSha = process.env.COMMIT_SHA || '';
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_COMMIT_SHA: commitSha,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
// trigger ci 2026年 8月31日 星期一 17时28分38秒 PDT
|
|
||||||
|
|||||||
+3
-2
@@ -12,9 +12,10 @@
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"packageManager": "[email protected]",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^11.0.0",
|
"framer-motion": "^11.0.0",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^1.41.0",
|
||||||
"next": "^15.5.20",
|
"next": "^15.5.20",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^8.64.0",
|
"@typescript-eslint/eslint-plugin": "^8.64.0",
|
||||||
"@typescript-eslint/parser": "^8.64.0",
|
"@typescript-eslint/parser": "^8.64.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-next": "^16.2.10",
|
"eslint-config-next": "^16.2.10",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"typescript": "5.8.2"
|
"typescript": "5.8.2"
|
||||||
|
|||||||
Generated
+849
-747
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user