diff --git a/manifests/acb-api-deployment.yml b/manifests/acb-api-deployment.yml new file mode 100644 index 0000000..fa93add --- /dev/null +++ b/manifests/acb-api-deployment.yml @@ -0,0 +1,121 @@ +# acb-api: HTTP API server for AI Code Battle +# Bot registration, job coordination, replay serving, bot profiles, +# leaderboards, predictions, and community feedback. +# Connects to PostgreSQL (CNPG) and Valkey (Redis-compatible). +# +# Staging file — sync to declarative-config/k8s/apexalgo-iad/ai-code-battle/ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: acb-api + namespace: ai-code-battle + labels: + app.kubernetes.io/name: acb-api + app.kubernetes.io/part-of: ai-code-battle +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: acb-api + template: + metadata: + labels: + app.kubernetes.io/name: acb-api + app.kubernetes.io/part-of: ai-code-battle + spec: + restartPolicy: Always + containers: + - name: api + image: ronaldraygun/acb-api:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + protocol: TCP + env: + - name: ACB_LISTEN_ADDR + value: ":8080" + - name: ACB_DATABASE_URL + valueFrom: + secretKeyRef: + name: acb-app-credentials-acb-app + key: uri + - name: ACB_VALKEY_ADDR + value: "valkey-master.valkey.svc.cluster.local:6379" + - name: ACB_VALKEY_PASSWORD + valueFrom: + secretKeyRef: + name: keydb-secret + key: password + optional: true + - name: ACB_WORKER_API_KEY + valueFrom: + secretKeyRef: + name: acb-api-secrets + key: worker-api-key + - name: ACB_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: acb-api-secrets + key: encryption-key + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "256Mi" + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 15 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 3 + imagePullSecrets: + - name: docker-hub-registry +--- +apiVersion: v1 +kind: Service +metadata: + name: acb-api + namespace: ai-code-battle + labels: + app.kubernetes.io/name: acb-api + app.kubernetes.io/part-of: ai-code-battle +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: acb-api + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http +--- +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: acb-api + namespace: ai-code-battle + labels: + app.kubernetes.io/name: acb-api + app.kubernetes.io/part-of: ai-code-battle +spec: + entryPoints: + - websecure + routes: + - match: Host(`api.ai-code-battle.ardenone.com`) + kind: Rule + services: + - name: acb-api + port: 80 + tls: + certResolver: letsencrypt diff --git a/manifests/acb-evolver-deployment.yml b/manifests/acb-evolver-deployment.yml new file mode 100644 index 0000000..ddf3295 --- /dev/null +++ b/manifests/acb-evolver-deployment.yml @@ -0,0 +1,106 @@ +# acb-evolver: LLM-driven bot evolution pipeline +# Reads match data from PostgreSQL, generates candidates via LLM, validates, +# evaluates in arena, promotes winners to the live bot fleet. +# +# Staging file — sync to declarative-config/k8s/apexalgo-iad/ai-code-battle/ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: acb-evolver + namespace: ai-code-battle +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: acb-evolver + namespace: ai-code-battle + labels: + app.kubernetes.io/name: acb-evolver + app.kubernetes.io/part-of: ai-code-battle +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: acb-evolver + template: + metadata: + labels: + app.kubernetes.io/name: acb-evolver + app.kubernetes.io/part-of: ai-code-battle + spec: + serviceAccountName: acb-evolver + restartPolicy: Always + containers: + - name: evolver + image: ronaldraygun/acb-evolver:latest + imagePullPolicy: Always + args: ["run", "-continuous"] + env: + - name: ACB_DATABASE_URL + valueFrom: + secretKeyRef: + name: acb-app-credentials-acb-app + key: uri + - name: ACB_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: acb-evolver-secrets + key: encryption-key + - name: ACB_LLM_URL + valueFrom: + secretKeyRef: + name: openai-secret + key: url + optional: true + - name: ACB_LLM_API_KEY + valueFrom: + secretKeyRef: + name: openai-secret + key: api-key + optional: true + - name: ACB_KUBECTL_SERVER + value: "http://traefik-apexalgo-iad:8001" + - name: ACB_REGISTRY + value: "ronaldraygun" + - name: ACB_REPO_DIR + value: "/repo" + - name: ACB_R2_ENDPOINT + valueFrom: + secretKeyRef: + name: cloudflare-pages-secret + key: r2-endpoint + optional: true + - name: ACB_R2_BUCKET + value: "acb-data" + - name: ACB_R2_ACCESS_KEY + valueFrom: + secretKeyRef: + name: cloudflare-pages-secret + key: r2-access-key + optional: true + - name: ACB_R2_SECRET_KEY + valueFrom: + secretKeyRef: + name: cloudflare-pages-secret + key: r2-secret-key + optional: true + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "2" + memory: "4Gi" + # Evolver is a batch loop, not an HTTP server — use exec probe + livenessProbe: + exec: + command: + - pgrep + - -x + - acb-evolver + initialDelaySeconds: 30 + periodSeconds: 60 + failureThreshold: 3 + imagePullSecrets: + - name: docker-hub-registry diff --git a/web/src/lib/lazy-section.ts b/web/src/lib/lazy-section.ts new file mode 100644 index 0000000..670b16f --- /dev/null +++ b/web/src/lib/lazy-section.ts @@ -0,0 +1,89 @@ +// §16.15 Lazy section — defers rendering of below-the-fold content until +// it scrolls into view using IntersectionObserver. Shows a minimal +// placeholder until revealed. + +export interface LazySectionOptions { + /** Placeholder HTML shown until the section is visible */ + placeholder?: string; + /** Margin around the root to trigger early (default "200px") */ + rootMargin?: string; + /** Threshold for triggering (default 0) */ + threshold?: number; +} + +const DEFAULT_PLACEHOLDER = '
'; + +/** + * Wraps a block of HTML in a lazy-loaded container. The container starts + * with a lightweight placeholder and swaps in the real content when it + * enters the viewport (with a configurable margin for preloading). + * + * Returns the outer HTML string (safe to use in innerHTML assignments). + */ +export function lazySection( + id: string, + contentHtml: string, + opts: LazySectionOptions = {} +): string { + const placeholder = opts.placeholder ?? DEFAULT_PLACEHOLDER; + return `
${placeholder}
`; +} + +/** + * Activates all lazy sections within the given root element. + * Call after mounting HTML (e.g., after innerHTML assignment). + */ +export function initLazySections( + root: HTMLElement, + opts: Pick = {} +): () => void { + const rootMargin = opts.rootMargin ?? '200px'; + const threshold = opts.threshold ?? 0; + + const sections = root.querySelectorAll('.lazy-section'); + if (sections.length === 0) return () => {}; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const el = entry.target as HTMLElement; + reveal(el); + observer.unobserve(el); + } + }, + { rootMargin, threshold } + ); + + sections.forEach((s) => observer.observe(s)); + return () => observer.disconnect(); +} + +/** Immediately reveal a lazy section (also used by IntersectionObserver). */ +export function revealLazySection(root: HTMLElement, id: string): void { + const el = root.querySelector(`[data-lazy-id="${id}"]`); + if (el) reveal(el); +} + +function reveal(el: HTMLElement): void { + const content = el.getAttribute('data-lazy-content'); + if (content === null) return; + // Remove the data attribute to avoid re-revealing + el.removeAttribute('data-lazy-content'); + el.classList.add('lazy-section-revealed'); + el.innerHTML = decodeAttr(content); +} + +function escapeAttr(html: string): string { + return html + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function decodeAttr(encoded: string): string { + const el = document.createElement('textarea'); + el.innerHTML = encoded; + return el.value; +} diff --git a/web/src/lib/virtual-list.ts b/web/src/lib/virtual-list.ts new file mode 100644 index 0000000..b0e16ec --- /dev/null +++ b/web/src/lib/virtual-list.ts @@ -0,0 +1,212 @@ +// §16.15 Virtual list — renders only visible rows for large datasets. +// Keeps the DOM small even with 1000+ entries. Each row has a fixed height +// and only rows in the viewport (plus a buffer) are materialised. + +export interface VirtualListOptions { + items: T[]; + rowHeight: number; + /** Extra rows rendered above/below viewport (default 5) */ + buffer?: number; + /** Initial number of rows to show before requiring "Show more" */ + initialCount?: number; + renderRow: (item: T, index: number) => string; + renderExpanded?: (item: T, index: number) => string; + containerClass?: string; + ariaLabel?: string; +} + +interface VirtualListState { + scrollTop: number; + viewportHeight: number; + visibleStart: number; + visibleEnd: number; + expandedIndex: number | null; + showMoreCount: number; +} + +export class VirtualList { + private opts: VirtualListOptions; + private state: VirtualListState; + private scrollEl: HTMLElement | null = null; + private spacerAbove: HTMLElement | null = null; + private spacerBelow: HTMLElement | null = null; + private rowContainer: HTMLElement | null = null; + private showMoreEl: HTMLElement | null = null; + private rafId = 0; + private observer: ResizeObserver | null = null; + + constructor(opts: VirtualListOptions) { + this.opts = opts; + const initial = opts.initialCount ?? opts.items.length; + this.state = { + scrollTop: 0, + viewportHeight: 600, + visibleStart: 0, + visibleEnd: Math.min(initial, opts.items.length), + expandedIndex: null, + showMoreCount: initial, + }; + } + + /** Mount the virtual list into the given container. */ + mount(container: HTMLElement): void { + container.innerHTML = ''; + container.classList.add(this.opts.containerClass ?? 'virtual-list'); + + // Scrollable viewport + const scrollEl = document.createElement('div'); + scrollEl.className = 'virtual-list-scroll'; + scrollEl.setAttribute('role', 'list'); + if (this.opts.ariaLabel) scrollEl.setAttribute('aria-label', this.opts.ariaLabel); + scrollEl.tabIndex = 0; + this.scrollEl = scrollEl; + + // Spacers for total height + this.spacerAbove = document.createElement('div'); + this.spacerBelow = document.createElement('div'); + + // Actual rendered rows + this.rowContainer = document.createElement('div'); + this.rowContainer.className = 'virtual-list-rows'; + + scrollEl.appendChild(this.spacerAbove); + scrollEl.appendChild(this.rowContainer); + scrollEl.appendChild(this.spacerBelow); + container.appendChild(scrollEl); + + // "Show more" button + const showMoreEl = document.createElement('button'); + showMoreEl.className = 'virtual-list-show-more btn secondary'; + showMoreEl.type = 'button'; + this.showMoreEl = showMoreEl; + this.updateShowMore(); + container.appendChild(this.showMoreEl); + + // Bind events + scrollEl.addEventListener('scroll', () => this.onScroll(), { passive: true }); + this.showMoreEl.addEventListener('click', () => this.onShowMore()); + + // Keyboard: Enter/Space on a row toggles expand + scrollEl.addEventListener('keydown', (e) => this.onKeyDown(e)); + + // Track viewport height + this.observer = new ResizeObserver((entries) => { + for (const entry of entries) { + this.state.viewportHeight = entry.contentRect.height; + this.render(); + } + }); + this.observer.observe(scrollEl); + + // Initial render + this.state.viewportHeight = scrollEl.clientHeight || 600; + this.render(); + } + + destroy(): void { + if (this.rafId) cancelAnimationFrame(this.rafId); + this.observer?.disconnect(); + } + + private onScroll(): void { + if (this.rafId) return; + this.rafId = requestAnimationFrame(() => { + this.rafId = 0; + if (!this.scrollEl) return; + this.state.scrollTop = this.scrollEl.scrollTop; + this.computeWindow(); + this.render(); + }); + } + + private computeWindow(): void { + const { rowHeight } = this.opts; + const buffer = this.opts.buffer ?? 5; + const maxIdx = this.state.showMoreCount; + + const rawStart = Math.floor(this.state.scrollTop / rowHeight) - buffer; + const rawEnd = Math.ceil((this.state.scrollTop + this.state.viewportHeight) / rowHeight) + buffer; + + this.state.visibleStart = Math.max(0, rawStart); + this.state.visibleEnd = Math.min(maxIdx, rawEnd); + } + + private render(): void { + if (!this.rowContainer || !this.spacerAbove || !this.spacerBelow) return; + const { items, rowHeight, renderRow, renderExpanded } = this.opts; + const { visibleStart, visibleEnd, expandedIndex, showMoreCount } = this.state; + + const frags: string[] = []; + for (let i = visibleStart; i < visibleEnd; i++) { + const item = items[i]; + if (!item) continue; + const isExpanded = expandedIndex === i; + frags.push(`
`); + frags.push(renderRow(item, i)); + if (isExpanded && renderExpanded) { + frags.push(`
${renderExpanded(item, i)}
`); + } + frags.push('
'); + } + + this.rowContainer.innerHTML = frags.join(''); + this.spacerAbove.style.height = `${visibleStart * rowHeight}px`; + const belowStart = visibleEnd; + const belowCount = showMoreCount - belowStart; + this.spacerBelow.style.height = `${Math.max(0, belowCount) * rowHeight}px`; + + // Set total scrollable height hint + if (this.scrollEl) { + this.scrollEl.style.setProperty('--vl-total-height', `${showMoreCount * rowHeight}px`); + } + + // Wire expand toggles + this.rowContainer.querySelectorAll('.virtual-list-row').forEach(row => { + row.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('a, button')) return; + const idx = Number(row.dataset.idx); + this.toggleExpand(idx); + }); + }); + } + + private toggleExpand(idx: number): void { + this.state.expandedIndex = this.state.expandedIndex === idx ? null : idx; + this.render(); + } + + private onKeyDown(e: KeyboardEvent): void { + const row = (e.target as HTMLElement).closest('.virtual-list-row') as HTMLElement | null; + if (!row) return; + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const idx = Number(row.dataset.idx); + this.toggleExpand(idx); + } + } + + private onShowMore(): void { + const batchSize = 100; + this.state.showMoreCount = Math.min( + this.state.showMoreCount + batchSize, + this.opts.items.length + ); + this.computeWindow(); + this.render(); + this.updateShowMore(); + } + + private updateShowMore(): void { + if (!this.showMoreEl) return; + const remaining = this.opts.items.length - this.state.showMoreCount; + if (remaining <= 0) { + this.showMoreEl.style.display = 'none'; + return; + } + this.showMoreEl.style.display = ''; + const next = Math.min(100, remaining); + this.showMoreEl.textContent = `Show ${next} more (${remaining} remaining)`; + this.showMoreEl.setAttribute('aria-label', `Show ${next} more entries, ${remaining} remaining`); + } +}