Build and push app

This commit is contained in:
dvirlabs 2026-04-13 00:16:56 +03:00
commit c1c56a1f3e
38 changed files with 3610 additions and 0 deletions

24
.dockerignore Normal file
View File

@ -0,0 +1,24 @@
# Build outputs
dist/
node_modules/
# Version control
.git/
.gitignore
# Helm chart (not needed inside the image)
helm/
# Editor / OS
.vscode/
.idea/
*.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Env files (never bake secrets into the image)
.env
.env.*

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
cloudflare-my-apps-tunnel.yaml

65
.woodpecker.yml Normal file
View File

@ -0,0 +1,65 @@
steps:
build:
name: Build & Push
image: woodpeckerci/plugin-kaniko
when:
branch: [ master, develop ]
event: [ push, pull_request, tag ]
settings:
registry: harbor.dvirlabs.com
repo: my-apps/${CI_REPO_NAME}
dockerfile: Dockerfile
context: .
tags:
- latest
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
username:
from_secret: DOCKER_USERNAME
password:
from_secret: DOCKER_PASSWORD
update-values:
name: Update image tag in values.yaml
image: alpine:3.19
when:
branch: [ master, develop ]
event: [ push ]
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_TOKEN:
from_secret: GIT_TOKEN
commands:
- apk add --no-cache git yq
- git config --global user.name "woodpecker-bot"
- git config --global user.email "ci@dvirlabs.com"
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
- cd my-apps
- |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
echo "💡 Setting image tag to: $TAG"
yq -i ".image.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
git add manifests/${CI_REPO_NAME}/values.yaml
git commit -m "ci(${CI_REPO_NAME}): update image tag to $TAG" || echo "No changes"
git push origin HEAD
trigger-gitops-via-push:
name: Trigger apps-gitops via Git push
image: alpine/git
when:
branch: [ master, develop ]
event: [ push ]
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_TOKEN:
from_secret: GIT_TOKEN
commands:
- git config --global user.name "woodpecker-bot"
- git config --global user.email "ci@dvirlabs.com"
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/apps-gitops.git"
- cd apps-gitops
- echo "# trigger at $(date) by ${CI_REPO_NAME}" >> .trigger
- git add .trigger
- git commit -m "ci: trigger apps-gitops build" || echo "no changes"
- git push origin HEAD

39
Dockerfile Normal file
View File

@ -0,0 +1,39 @@
# ── Stage 1: build ───────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Install deps first (layer-cached unless package files change)
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
# Copy source and build
COPY . .
RUN npm run build
# ── Stage 2: serve ───────────────────────────────────────────────────────────
FROM nginx:1.27-alpine AS runner
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
# Drop our custom config
COPY nginx.conf /etc/nginx/conf.d/errorlab.conf
# Copy built static files from the builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Run as non-root (nginx:alpine UID 101)
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
touch /var/run/nginx.pid && \
chown nginx:nginx /var/run/nginx.pid
USER nginx
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost/index.html || exit 1
CMD ["nginx", "-g", "daemon off;"]

67
dist/assets/index-BmIETw7i.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-DmKezAnz.css vendored Normal file

File diff suppressed because one or more lines are too long

14
dist/index.html vendored Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080c14" />
<title>ErrorLab</title>
<script type="module" crossorigin src="/assets/index-BmIETw7i.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DmKezAnz.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

15
helm/errorlab/Chart.yaml Normal file
View File

@ -0,0 +1,15 @@
apiVersion: v2
name: errorlab
description: Custom public error and landing pages for the home lab
type: application
version: 0.1.0
appVersion: "1.0.0"
keywords:
- errorlab
- '404'
- homelab
- devops
home: ""
sources: []
maintainers:
- name: dvirl

View File

@ -0,0 +1,49 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "errorlab.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "errorlab.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart label (name + version).
*/}}
{{- define "errorlab.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels applied to every resource.
*/}}
{{- define "errorlab.labels" -}}
helm.sh/chart: {{ include "errorlab.chart" . }}
{{ include "errorlab.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels (used in spec.selector.matchLabels and pod template labels).
*/}}
{{- define "errorlab.selectorLabels" -}}
app.kubernetes.io/name: {{ include "errorlab.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "errorlab.fullname" . }}
labels:
{{- include "errorlab.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "errorlab.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "errorlab.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@ -0,0 +1,22 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "errorlab.fullname" . }}
labels:
{{- include "errorlab.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "errorlab.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}

View File

@ -0,0 +1,35 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "errorlab.fullname" . }}
labels:
{{- include "errorlab.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "errorlab.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "errorlab.fullname" . }}
labels:
{{- include "errorlab.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "errorlab.selectorLabels" . | nindent 4 }}

87
helm/errorlab/values.yaml Normal file
View File

@ -0,0 +1,87 @@
# ── Replica count ─────────────────────────────────────────────────────────────
replicaCount: 1
# ── Image ─────────────────────────────────────────────────────────────────────
image:
repository: errorlab # override with your registry, e.g. ghcr.io/dvirl/errorlab
tag: latest
pullPolicy: IfNotPresent
imagePullSecrets: []
# ── Service ───────────────────────────────────────────────────────────────────
service:
type: ClusterIP
port: 80
# ── Ingress ───────────────────────────────────────────────────────────────────
ingress:
enabled: true
className: nginx # set to your ingress class (e.g. "nginx", "traefik")
annotations: {}
# cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
hosts:
- host: errorlab.lab # replace with your actual hostname
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: errorlab-tls
# hosts:
# - errorlab.lab
# ── Resources ─────────────────────────────────────────────────────────────────
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
# ── Pod settings ──────────────────────────────────────────────────────────────
podAnnotations: {}
podLabels: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 101 # nginx user in nginx:alpine
fsGroup: 101
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false # nginx needs to write to /var/cache/nginx
capabilities:
drop:
- ALL
# ── Probes ────────────────────────────────────────────────────────────────────
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 20
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 3
periodSeconds: 10
# ── Autoscaling (disabled by default for a static site) ───────────────────────
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 3
targetCPUUtilizationPercentage: 80
# ── Misc ──────────────────────────────────────────────────────────────────────
nameOverride: ""
fullnameOverride: ""
nodeSelector: {}
tolerations: []
affinity: {}

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080c14" />
<title>ErrorLab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

35
nginx.conf Normal file
View File

@ -0,0 +1,35 @@
# nginx configuration for a React SPA (React Router support)
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json
image/svg+xml image/x-icon font/woff2;
# Long-lived cache for hashed assets (Vite outputs hash in filename)
location ~* \.(?:js|css|woff2?|ttf|ico|png|jpg|jpeg|gif|svg|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# index.html no cache; browser always re-validates
location = /index.html {
add_header Cache-Control "no-store";
}
# SPA fallback any unknown path serves index.html so React Router works
location / {
try_files $uri $uri/ /index.html;
}
# Hide nginx version in error responses
server_tokens off;
}

1719
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "errorlab",
"version": "1.0.0",
"description": "Custom public error and landing pages for the home lab",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.3",
"vite": "^5.4.11"
}
}

19
src/App.jsx Normal file
View File

@ -0,0 +1,19 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import NotFoundPage from './pages/404/NotFoundPage.jsx'
import BadGatewayPage from './pages/502/BadGatewayPage.jsx'
import MaintenancePage from './pages/maintenance/MaintenancePage.jsx'
import HomePage from './pages/home/HomePage.jsx'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/502" element={<BadGatewayPage />} />
<Route path="/maintenance" element={<MaintenancePage />} />
{/* Catch-all — must be last */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
)
}

8
src/assets/README.md Normal file
View File

@ -0,0 +1,8 @@
# assets/
Place static files here: icons, images, SVGs, fonts, etc.
Examples:
- favicon.svg
- logo.svg
- og-image.png

View File

@ -0,0 +1,20 @@
/* DinoGame canvas wrapper */
.dino-game-wrapper {
width: 100%;
max-width: 820px;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.dino-game-canvas {
width: 100%;
height: auto;
display: block;
border: 1px solid rgba(0, 200, 255, 0.18);
border-radius: 8px;
box-shadow:
0 0 0 1px rgba(0,0,0,0.5),
0 0 32px rgba(0, 200, 255, 0.08);
}

View File

@ -0,0 +1,416 @@
import { useRef, useEffect, useCallback, useState } from 'react'
import './DinoGame.css'
import {
CANVAS_W, CANVAS_H, GROUND_Y,
GRAVITY, JUMP_VEL, INITIAL_SPEED, SPEED_GROWTH, MAX_SPEED,
PLAYER_X, PLAYER_W, PLAYER_H,
MIN_GAP, MAX_GAP,
OBSTACLE_TEMPLATES,
} from './gameConstants.js'
// Helpers
function randObstacle() {
const t = OBSTACLE_TEMPLATES[Math.floor(Math.random() * OBSTACLE_TEMPLATES.length)]
return { ...t, x: CANVAS_W + 60, y: GROUND_Y - t.h }
}
/** AABB collision with per-axis inward padding for forgiveness */
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh, pad = 7) {
return (
ax + pad < bx + bw - pad &&
ax + aw - pad > bx + pad &&
ay + pad < by + bh - pad &&
ay + ah - pad > by + pad
)
}
// Canvas drawing
function roundRect(ctx, x, y, w, h, r = 5) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath()
}
function drawBackground(ctx, bgOffset) {
// Fill
ctx.fillStyle = '#080c14'
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
// Scrolling dot grid
ctx.fillStyle = 'rgba(0, 180, 255, 0.055)'
const spacing = 28
const startX = (-bgOffset % spacing) - spacing
for (let x = startX; x < CANVAS_W + spacing; x += spacing) {
for (let y = 10; y < GROUND_Y - 4; y += spacing) {
ctx.fillRect(x, y, 1.5, 1.5)
}
}
}
function drawGround(ctx, bgOffset) {
// Glowing ground line
ctx.save()
ctx.shadowBlur = 8
ctx.shadowColor = '#00ccff'
ctx.strokeStyle = '#00ccff'
ctx.lineWidth = 1.5
ctx.beginPath()
ctx.moveTo(0, GROUND_Y)
ctx.lineTo(CANVAS_W, GROUND_Y)
ctx.stroke()
ctx.restore()
// Scrolling dashes below ground line
ctx.save()
ctx.strokeStyle = 'rgba(0, 140, 200, 0.35)'
ctx.lineWidth = 1
ctx.setLineDash([18, 14])
ctx.lineDashOffset = -bgOffset
ctx.beginPath()
ctx.moveTo(0, GROUND_Y + 7)
ctx.lineTo(CANVAS_W, GROUND_Y + 7)
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
/** Pod player — kubernetes steering-wheel symbol inside a neon green box */
function drawPlayer(ctx, playerY, frameCount, isJumping) {
const x = PLAYER_X
const y = playerY
const w = PLAYER_W
const h = PLAYER_H
const cx = x + w / 2
const cy = y + h / 2 - 1
ctx.save()
// Outer glow
ctx.shadowBlur = 14
ctx.shadowColor = '#00ff88'
// Body fill
roundRect(ctx, x, y, w, h, 5)
ctx.fillStyle = '#081410'
ctx.fill()
// Body border
ctx.strokeStyle = '#00ff88'
ctx.lineWidth = 2
ctx.stroke()
ctx.shadowBlur = 0
// Kubernetes helm wheel (6 spokes + outer ring)
ctx.strokeStyle = '#00ff88'
ctx.lineWidth = 1
for (let i = 0; i < 6; i++) {
const a = (Math.PI / 3) * i - Math.PI / 6
ctx.beginPath()
ctx.moveTo(cx + Math.cos(a) * 3, cy + Math.sin(a) * 3)
ctx.lineTo(cx + Math.cos(a) * 9, cy + Math.sin(a) * 9)
ctx.stroke()
}
ctx.beginPath()
ctx.arc(cx, cy, 10, 0, Math.PI * 2)
ctx.stroke()
// Centre dot
ctx.beginPath()
ctx.arc(cx, cy, 2.5, 0, Math.PI * 2)
ctx.fillStyle = '#00ff88'
ctx.fill()
// Running legs (2-frame animation, hidden while airborne)
if (!isJumping) {
const phase = Math.floor(frameCount / 6) % 2
ctx.fillStyle = '#00ff88'
// Left leg
const lh = phase === 0 ? 8 : 4
ctx.fillRect(x + 7, y + h, 6, lh)
ctx.fillRect(x + 3, y + h + lh, 10, 3)
// Right leg (opposite phase)
const rh = phase === 0 ? 4 : 8
ctx.fillRect(x + w - 13, y + h, 6, rh)
ctx.fillRect(x + w - 17, y + h + rh, 10, 3)
}
ctx.restore()
}
function drawObstacle(ctx, obs) {
const { x, y, w, h, lines, color } = obs
const cx = x + w / 2
const cy = y + h / 2
ctx.save()
// Diagonal warning stripes clipped to obstacle rect
roundRect(ctx, x, y, w, h, 4)
ctx.fillStyle = 'rgba(0,0,0,0.82)'
ctx.fill()
ctx.save()
roundRect(ctx, x, y, w, h, 4)
ctx.clip()
// Translucent diagonal stripes using 8-digit hex alpha
ctx.strokeStyle = `${color}22`
ctx.lineWidth = 5
for (let s = -h; s < w + h; s += 10) {
ctx.beginPath()
ctx.moveTo(x + s, y)
ctx.lineTo(x + s - h, y + h)
ctx.stroke()
}
ctx.restore()
// Glowing border
ctx.shadowBlur = 14
ctx.shadowColor = color
roundRect(ctx, x, y, w, h, 4)
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.stroke()
ctx.shadowBlur = 0
// Label text
ctx.fillStyle = color
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
if (lines.length === 1) {
ctx.font = 'bold 10px monospace'
ctx.fillText(lines[0], cx, cy)
} else {
ctx.font = 'bold 9px monospace'
ctx.fillText(lines[0], cx, cy - 6)
ctx.fillText(lines[1], cx, cy + 6)
}
ctx.restore()
}
function drawHUD(ctx, score, highScore) {
// Score
ctx.save()
ctx.fillStyle = '#00ccff'
ctx.font = 'bold 13px monospace'
ctx.textAlign = 'right'
ctx.fillText(`SCORE ${String(score).padStart(5, '0')}`, CANVAS_W - 14, 20)
// High score
ctx.fillStyle = 'rgba(0,200,255,0.42)'
ctx.font = '11px monospace'
ctx.fillText(`HI ${String(highScore).padStart(5, '0')}`, CANVAS_W - 14, 36)
ctx.restore()
}
function drawIdleOverlay(ctx) {
ctx.save()
ctx.fillStyle = 'rgba(8, 12, 20, 0.72)'
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
ctx.fillStyle = '#00ffcc'
ctx.font = 'bold 15px monospace'
ctx.textAlign = 'center'
ctx.fillText('[ SPACE / ↑ / TAP TO LAUNCH ]', CANVAS_W / 2, CANVAS_H / 2 - 8)
ctx.fillStyle = 'rgba(0,220,255,0.5)'
ctx.font = '11px monospace'
ctx.fillText('dodge the cluster faults', CANVAS_W / 2, CANVAS_H / 2 + 12)
ctx.restore()
}
function drawGameOverOverlay(ctx, score, highScore) {
ctx.save()
ctx.fillStyle = 'rgba(8, 12, 20, 0.72)'
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
ctx.fillStyle = '#ff4455'
ctx.font = 'bold 17px monospace'
ctx.textAlign = 'center'
ctx.fillText('── POD TERMINATED ──', CANVAS_W / 2, CANVAS_H / 2 - 26)
ctx.fillStyle = '#e0e8f0'
ctx.font = '13px monospace'
ctx.fillText(`score: ${score}`, CANVAS_W / 2, CANVAS_H / 2 - 2)
if (score > 0 && score >= highScore) {
ctx.fillStyle = '#ffee22'
ctx.font = 'bold 11px monospace'
ctx.fillText('✦ new high score ✦', CANVAS_W / 2, CANVAS_H / 2 + 16)
}
ctx.fillStyle = '#00ffcc'
ctx.font = '12px monospace'
ctx.fillText('[ SPACE / ↑ / TAP to respawn ]', CANVAS_W / 2, CANVAS_H / 2 + 38)
ctx.restore()
}
// Component
/**
* DinoGame canvas-based infinite runner with a Kubernetes pod player
* and DevOps-themed obstacles.
*
* Controls: Space / ArrowUp / click / tap to jump (or start / restart).
*/
export default function DinoGame() {
const canvasRef = useRef(null)
// All mutable game state lives in a ref so the rAF loop never re-renders
const s = useRef({
status: 'idle', // 'idle' | 'playing' | 'gameover'
playerY: GROUND_Y - PLAYER_H,
velocityY: 0,
isJumping: false,
obstacles: [],
bgOffset: 0,
frameCount: 0, // frames while playing (used for animation & score)
distTravelled: 0, // pixels travelled (used for obstacle spawning)
nextSpawnAt: 400, // distTravelled value at which to spawn next obstacle
speed: INITIAL_SPEED,
score: 0,
highScore: parseInt(localStorage.getItem('lab404_hi') ?? '0', 10),
})
// Minimal React state only for values shown in JSX buttons outside canvas
const [gameStatus, setGameStatus] = useState('idle')
// Input handler
const handleAction = useCallback(() => {
const g = s.current
if (g.status === 'idle') {
g.status = 'playing'
setGameStatus('playing')
} else if (g.status === 'playing' && !g.isJumping) {
g.velocityY = JUMP_VEL
g.isJumping = true
} else if (g.status === 'gameover') {
// Full reset
g.status = 'playing'
g.playerY = GROUND_Y - PLAYER_H
g.velocityY = 0
g.isJumping = false
g.obstacles = []
g.bgOffset = 0
g.frameCount = 0
g.distTravelled = 0
g.nextSpawnAt = 400
g.speed = INITIAL_SPEED
g.score = 0
setGameStatus('playing')
}
}, [])
// Keyboard listeners
useEffect(() => {
const onKey = (e) => {
if (e.code === 'Space' || e.code === 'ArrowUp') {
e.preventDefault()
handleAction()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [handleAction])
// Game loop
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
let rafId
const loop = () => {
const g = s.current
// Update
if (g.status === 'playing') {
g.frameCount++
// Speed ramp
g.score = Math.floor(g.frameCount / 7)
g.speed = Math.min(INITIAL_SPEED + g.score * SPEED_GROWTH, MAX_SPEED)
// Background scroll
g.bgOffset = (g.bgOffset + g.speed * 0.35) % CANVAS_W
g.distTravelled += g.speed
// Player physics
g.velocityY += GRAVITY
g.playerY += g.velocityY
if (g.playerY >= GROUND_Y - PLAYER_H) {
g.playerY = GROUND_Y - PLAYER_H
g.velocityY = 0
g.isJumping = false
}
// Spawn obstacle when distance threshold is reached
if (g.distTravelled >= g.nextSpawnAt) {
g.obstacles.push(randObstacle())
const gap = MIN_GAP + Math.random() * (MAX_GAP - MIN_GAP)
g.nextSpawnAt = g.distTravelled + gap
}
// Move & cull obstacles
for (const o of g.obstacles) o.x -= g.speed
g.obstacles = g.obstacles.filter(o => o.x + o.w > -10)
// Collision detection
const hit = g.obstacles.some(o =>
rectsOverlap(PLAYER_X, g.playerY, PLAYER_W, PLAYER_H, o.x, o.y, o.w, o.h)
)
if (hit) {
if (g.score > g.highScore) {
g.highScore = g.score
localStorage.setItem('lab404_hi', String(g.score))
}
g.status = 'gameover'
setGameStatus('gameover')
}
} else {
// Even while idle/gameover keep background subtly drifting
g.bgOffset = (g.bgOffset + 0.4) % CANVAS_W
}
// Draw
drawBackground(ctx, g.bgOffset)
drawGround(ctx, g.bgOffset)
g.obstacles.forEach(o => drawObstacle(ctx, o))
drawPlayer(ctx, g.playerY, g.frameCount, g.isJumping)
drawHUD(ctx, g.score, g.highScore)
if (g.status === 'idle') drawIdleOverlay(ctx)
if (g.status === 'gameover') drawGameOverOverlay(ctx, g.score, g.highScore)
rafId = requestAnimationFrame(loop)
}
rafId = requestAnimationFrame(loop)
return () => cancelAnimationFrame(rafId)
}, []) // runs once game loop owns its own state via ref
return (
<div
className="dino-game-wrapper"
onClick={handleAction}
onTouchStart={(e) => { e.preventDefault(); handleAction() }}
>
<canvas
ref={canvasRef}
width={CANVAS_W}
height={CANVAS_H}
className="dino-game-canvas"
role="img"
aria-label="Kubernetes pod runner mini-game. Press Space or tap to jump."
/>
</div>
)
}

View File

@ -0,0 +1,39 @@
// ─── Canvas & World ───────────────────────────────────────────────────────────
export const CANVAS_W = 800
export const CANVAS_H = 220
export const GROUND_Y = 168 // y of the ground surface
// ─── Physics ──────────────────────────────────────────────────────────────────
export const GRAVITY = 0.58
export const JUMP_VEL = -13.5
export const INITIAL_SPEED = 5.2
export const SPEED_GROWTH = 0.00055 // added per score point (score = frames / 7)
export const MAX_SPEED = 16
// ─── Player ───────────────────────────────────────────────────────────────────
export const PLAYER_X = 82
export const PLAYER_W = 36
export const PLAYER_H = 36
// ─── Obstacle spawning ────────────────────────────────────────────────────────
export const MIN_GAP = 340 // minimum pixel gap between spawns (at game speed 1)
export const MAX_GAP = 680
// ─── Obstacle visual templates ────────────────────────────────────────────────
// Each obstacle has two display lines, a neon colour, and hit-box dimensions.
export const OBSTACLE_TEMPLATES = [
{ lines: ['Crash', 'Loop'], color: '#ff4455', w: 46, h: 52 },
{ lines: ['OOM', 'Killed'], color: '#ff2288', w: 38, h: 60 },
{ lines: ['Image', 'PullErr'], color: '#ff8820', w: 44, h: 46 },
{ lines: ['404'], color: '#00ffff', w: 58, h: 36 },
{ lines: ['Expired', 'Cert'], color: '#ffee22', w: 48, h: 48 },
{ lines: ['Evicted'], color: '#aaaaaa', w: 40, h: 34 },
{ lines: ['Pod', 'Failed'], color: '#ff3366', w: 40, h: 44 },
{ lines: ['Bad', 'Ingress'], color: '#ff6600', w: 50, h: 40 },
{ lines: ['DNS', 'Fail'], color: '#cc44ff', w: 42, h: 50 },
{ lines: ['Pending', '...'], color: '#4499ff', w: 52, h: 32 },
{ lines: ['Timeout'], color: '#ff9944', w: 48, h: 42 },
{ lines: ['502'], color: '#ff4444', w: 52, h: 36 },
{ lines: ['No', 'Route'], color: '#55ff88', w: 46, h: 44 },
{ lines: ['Tunnel', 'Down'], color: '#ff55cc', w: 50, h: 46 },
]

View File

@ -0,0 +1,34 @@
/* PageLayout — full-viewport dark shell used by all pages */
.page-layout {
min-height: 100vh;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
position: relative;
overflow: hidden;
}
/* Subtle animated star field in the background */
.page-layout::before {
content: '';
position: fixed;
inset: 0;
background-image:
radial-gradient(1px 1px at 15% 20%, rgba(0,200,255,0.18) 0%, transparent 100%),
radial-gradient(1px 1px at 42% 8%, rgba(0,255,204,0.12) 0%, transparent 100%),
radial-gradient(1px 1px at 70% 35%, rgba(0,200,255,0.15) 0%, transparent 100%),
radial-gradient(1px 1px at 88% 60%, rgba(0,255,204,0.10) 0%, transparent 100%),
radial-gradient(1px 1px at 25% 75%, rgba(0,200,255,0.12) 0%, transparent 100%),
radial-gradient(1px 1px at 55% 90%, rgba(0,255,204,0.08) 0%, transparent 100%);
pointer-events: none;
z-index: 0;
}
.page-layout > * {
position: relative;
z-index: 1;
}

View File

@ -0,0 +1,17 @@
import './PageLayout.css'
/**
* PageLayout shared full-viewport wrapper for all public pages.
* Applies the dark background and centres content vertically.
*
* Props:
* children page content
* className optional extra class on the root element
*/
export default function PageLayout({ children, className = '' }) {
return (
<div className={`page-layout ${className}`.trim()}>
{children}
</div>
)
}

View File

@ -0,0 +1,82 @@
/* TerminalLog — fake cluster-diagnostics terminal window */
.terminal-log {
width: 100%;
max-width: 620px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 24px rgba(0, 180, 255, 0.07);
}
/* Title bar */
.terminal-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--surface-alt);
border-bottom: 1px solid var(--border);
user-select: none;
}
.terminal-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.terminal-dot.red { background: #ff5f57; }
.terminal-dot.yellow { background: #febc2e; }
.terminal-dot.green { background: #28c840; }
.terminal-title {
margin-left: 6px;
font-size: 0.72rem;
color: var(--text-muted);
letter-spacing: 0.04em;
}
/* Log body */
.terminal-body {
padding: 12px 16px 10px;
min-height: 110px;
display: flex;
flex-direction: column;
gap: 3px;
overflow: hidden;
}
.terminal-line {
font-size: 0.78rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
/* Type colour coding */
.terminal-line--cmd { color: var(--accent); }
.terminal-line--info { color: var(--cyan-dim); }
.terminal-line--warn { color: var(--warning); }
.terminal-line--err { color: var(--danger); }
/* Slide-in animation for the newest line */
@keyframes slideInLine {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.terminal-line--new {
animation: slideInLine 0.3s ease-out both;
}
/* Blinking cursor */
.terminal-cursor {
display: inline-block;
color: var(--accent);
font-size: 0.85rem;
animation: blink 1.1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}

View File

@ -0,0 +1,77 @@
import { useState, useEffect, useRef } from 'react'
import './TerminalLog.css'
/** Rotating pool of cluster-flavoured diagnostic messages */
const MESSAGE_POOL = [
{ type: 'cmd', text: '$ kubectl get page /this-path --namespace default' },
{ type: 'err', text: 'Error from server (NotFound): pages "this-path" not found' },
{ type: 'cmd', text: '$ kubectl describe ingress/main-ingress' },
{ type: 'warn', text: '[WARN] ingress-nginx: no matching rule for path "/this-path"' },
{ type: 'cmd', text: '$ nslookup this-page.cluster.local' },
{ type: 'err', text: 'NXDOMAIN: Name does not exist.' },
{ type: 'cmd', text: '$ kubectl get pod -l app=this-page' },
{ type: 'err', text: 'No resources found in default namespace.' },
{ type: 'warn', text: '[WARN] cloudflared: tunnel endpoint unreachable' },
{ type: 'info', text: '[INFO] attempting to reschedule pod...' },
{ type: 'err', text: '[ERROR] ImagePullBackOff: image "this-page:latest" not found' },
{ type: 'cmd', text: '$ kubectl rollout status deploy/this-page' },
{ type: 'err', text: 'error: deployment "this-page" not found' },
{ type: 'warn', text: '[WARN] certificate for this-page.lab expired 42 days ago' },
{ type: 'info', text: '[INFO] DNS TTL 30s — retrying...' },
{ type: 'err', text: '[ERROR] upstream "this-page:8080": connection refused' },
{ type: 'cmd', text: '$ kubectl logs -l app=this-page --tail=5' },
{ type: 'err', text: 'Error from server (NotFound): pods "this-page" not found' },
{ type: 'warn', text: '[WARN] 3 consecutive CrashLoopBackOff events detected' },
{ type: 'info', text: '[INFO] route table: /this-path → ??? (unresolved)' },
]
const MAX_VISIBLE_LINES = 6
const TICK_MS = 1700
/**
* TerminalLog auto-scrolling fake terminal showing rotating cluster errors.
* title override the window title bar text (default: "cluster-diagnostics")
*/
export default function TerminalLog({ title = 'cluster-diagnostics' }) {
const [lines, setLines] = useState([MESSAGE_POOL[0]])
const indexRef = useRef(1)
const containerRef = useRef(null)
useEffect(() => {
const id = setInterval(() => {
const next = MESSAGE_POOL[indexRef.current % MESSAGE_POOL.length]
indexRef.current += 1
setLines(prev => [...prev, next].slice(-MAX_VISIBLE_LINES))
}, TICK_MS)
return () => clearInterval(id)
}, [])
// Auto-scroll to bottom whenever lines update
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [lines])
return (
<div className="terminal-log">
<div className="terminal-header">
<span className="terminal-dot red" />
<span className="terminal-dot yellow" />
<span className="terminal-dot green" />
<span className="terminal-title">{title}</span>
</div>
<div className="terminal-body" ref={containerRef}>
{lines.map((line, i) => (
<div
key={i}
className={`terminal-line terminal-line--${line.type}${i === lines.length - 1 ? ' terminal-line--new' : ''}`}
>
{line.text}
</div>
))}
<span className="terminal-cursor"></span>
</div>
</div>
)
}

50
src/index.css Normal file
View File

@ -0,0 +1,50 @@
/* ── Global reset & dark theme base ─────────────────────────────────────── */
:root {
--bg: #080c14;
--surface: #0d1520;
--surface-alt: #111827;
--border: rgba(0, 200, 255, 0.14);
--accent: #00ffcc;
--accent-dim: rgba(0, 255, 204, 0.25);
--accent-glow: rgba(0, 255, 204, 0.35);
--cyan: #00ccff;
--cyan-dim: rgba(0, 200, 255, 0.5);
--text: #e0e8f0;
--text-muted: rgba(200, 220, 240, 0.45);
--danger: #ff4455;
--warning: #ffaa00;
--font-mono: 'Consolas', 'Monaco', 'Courier New', monospace;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
#root {
min-height: 100%;
display: flex;
flex-direction: column;
}
a {
color: inherit;
text-decoration: none;
}
/* Custom scrollbars */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: rgba(0, 200, 255, 0.25); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(0, 200, 255, 0.4); }

10
src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@ -0,0 +1,152 @@
/* ── 404 Not Found Page ───────────────────────────────────────────────────── */
.nfp-container {
width: 100%;
max-width: 860px;
padding: 2.5rem 1.5rem 3rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
text-align: center;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.nfp-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
/* Glitch animation on the big 404 numeral */
@keyframes glitch {
0%, 88%, 100% { transform: none; text-shadow: -2px 0 #00ffcc, 2px 0 #ff4455; }
90% { transform: translate(-2px, 0); color: #ff4455; }
92% { transform: translate(2px, 0); color: #00ffcc; }
94% { transform: translate(-1px, 0); }
96% { transform: none; }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
.nfp-code {
font-size: clamp(5.5rem, 18vw, 10.5rem);
font-weight: 900;
letter-spacing: 0.06em;
line-height: 1;
color: transparent;
-webkit-text-stroke: 2px var(--accent);
text-shadow: -2px 0 #00ffcc, 2px 0 #ff4455, 0 0 60px rgba(0,255,204,0.25);
animation:
fadeInScale 0.7s ease-out both,
glitch 5s ease-in-out 1.5s infinite;
}
.nfp-title {
font-size: clamp(1.1rem, 3vw, 1.6rem);
font-weight: 700;
color: var(--text);
animation: fadeInScale 0.7s 0.15s ease-out both;
}
.nfp-subtitle {
font-size: clamp(0.82rem, 2vw, 1rem);
color: var(--cyan-dim);
animation: fadeInScale 0.7s 0.3s ease-out both;
}
.nfp-kubectl {
display: inline-block;
font-size: 0.8rem;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.35rem 0.8rem;
animation: fadeInScale 0.7s 0.45s ease-out both;
}
.nfp-kubectl .err { color: var(--danger); }
/* ── Game section ─────────────────────────────────────────────────────────── */
.nfp-game-section {
width: 100%;
animation: fadeInScale 0.7s 0.55s ease-out both;
}
.nfp-game-hint {
margin-top: 0.55rem;
font-size: 0.72rem;
color: var(--text-muted);
letter-spacing: 0.03em;
}
/* ── Terminal log ─────────────────────────────────────────────────────────── */
.nfp-log-section {
width: 100%;
display: flex;
justify-content: center;
animation: fadeInScale 0.7s 0.65s ease-out both;
}
/* ── Footer buttons ───────────────────────────────────────────────────────── */
.nfp-footer {
display: flex;
gap: 0.9rem;
flex-wrap: wrap;
justify-content: center;
animation: fadeInScale 0.7s 0.75s ease-out both;
}
.nfp-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.55rem 1.4rem;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
border: 1px solid transparent;
}
.nfp-btn--primary {
background: rgba(0, 255, 204, 0.1);
border-color: rgba(0, 255, 204, 0.4);
color: var(--accent);
}
.nfp-btn--primary:hover {
background: rgba(0, 255, 204, 0.18);
border-color: var(--accent);
box-shadow: 0 0 18px var(--accent-glow);
}
.nfp-btn--secondary {
background: rgba(255,255,255,0.05);
border-color: rgba(255,255,255,0.14);
color: var(--text-muted);
}
.nfp-btn--secondary:hover {
background: rgba(255,255,255,0.09);
border-color: rgba(255,255,255,0.28);
color: var(--text);
}
/* ── Responsive ───────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.nfp-container { gap: 1.5rem; padding: 1.5rem 1rem 2rem; }
.nfp-footer { gap: 0.65rem; }
.nfp-btn { font-size: 0.8rem; padding: 0.5rem 1.1rem; }
}

View File

@ -0,0 +1,58 @@
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
import DinoGame from '../../components/DinoGame/DinoGame.jsx'
import TerminalLog from '../../components/TerminalLog/TerminalLog.jsx'
import './NotFoundPage.css'
/**
* 404 Not Found the main interactive error page.
* Features the Kubernetes pod runner game and a live cluster-diagnostics terminal.
*/
export default function NotFoundPage() {
return (
<PageLayout>
<div className="nfp-container">
{/* ── Header ──────────────────────────────────────────────────────── */}
<header className="nfp-header">
<h1 className="nfp-code" aria-label="Error 404">404</h1>
<h2 className="nfp-title">Page Not Found</h2>
<p className="nfp-subtitle">
This route was lost somewhere in the cluster.
</p>
<code className="nfp-kubectl">
$ kubectl get page: <span className="err">NotFound</span>
</code>
</header>
{/* ── Mini-game ───────────────────────────────────────────────────── */}
<section className="nfp-game-section" aria-label="Mini-game">
<DinoGame />
<p className="nfp-game-hint">
space / / tap jump &nbsp;·&nbsp; dodge the cluster faults
</p>
</section>
{/* ── Live "diagnostics" terminal ─────────────────────────────────── */}
<section className="nfp-log-section" aria-label="Cluster diagnostics log">
<TerminalLog title="cluster-diagnostics — 404" />
</section>
{/* ── Navigation ──────────────────────────────────────────────────── */}
<footer className="nfp-footer">
<a href="/" className="nfp-btn nfp-btn--primary">
Go Home
</a>
<a
href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/"
className="nfp-btn nfp-btn--secondary"
target="_blank"
rel="noopener noreferrer"
>
kubectl describe pod
</a>
</footer>
</div>
</PageLayout>
)
}

View File

@ -0,0 +1,56 @@
/* 502 Bad Gateway — shares the same visual language as the 404 page */
.bgp-container {
width: 100%;
max-width: 700px;
padding: 2.5rem 1.5rem 3rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
text-align: center;
}
.bgp-header { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; }
.bgp-code {
font-size: clamp(5rem, 18vw, 9rem);
font-weight: 900;
letter-spacing: 0.06em;
line-height: 1;
color: transparent;
-webkit-text-stroke: 2px var(--warning);
text-shadow: -2px 0 var(--warning), 2px 0 var(--danger), 0 0 60px rgba(255,170,0,0.25);
}
.bgp-title { font-size: clamp(1.1rem, 3vw, 1.5rem); font-weight: 700; color: var(--text); }
.bgp-subtitle { font-size: 0.9rem; color: var(--cyan-dim); }
.bgp-kubectl {
font-size: 0.8rem; color: var(--text-muted);
background: var(--surface); border: 1px solid var(--border);
border-radius: 4px; padding: 0.35rem 0.8rem;
}
.bgp-kubectl .err { color: var(--danger); }
.bgp-log-section { width: 100%; display: flex; justify-content: center; }
.bgp-footer { display: flex; gap: 0.9rem; flex-wrap: wrap; justify-content: center; }
.bgp-btn {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.55rem 1.4rem; border-radius: 6px;
font-family: var(--font-mono); font-size: 0.85rem; font-weight: 600;
text-decoration: none; cursor: pointer;
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
border: 1px solid transparent;
}
.bgp-btn--primary {
background: rgba(255,170,0,0.1); border-color: rgba(255,170,0,0.4); color: var(--warning);
}
.bgp-btn--primary:hover {
background: rgba(255,170,0,0.18); border-color: var(--warning); box-shadow: 0 0 18px rgba(255,170,0,0.3);
}
.bgp-btn--secondary {
background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.14); color: var(--text-muted);
}
.bgp-btn--secondary:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.28); color: var(--text); }

View File

@ -0,0 +1,37 @@
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
import TerminalLog from '../../components/TerminalLog/TerminalLog.jsx'
import './BadGatewayPage.css'
/**
* 502 Bad Gateway placeholder.
* TODO: add a 502-themed mini-game or animation.
*/
export default function BadGatewayPage() {
return (
<PageLayout>
<div className="bgp-container">
<header className="bgp-header">
<h1 className="bgp-code" aria-label="Error 502">502</h1>
<h2 className="bgp-title">Bad Gateway</h2>
<p className="bgp-subtitle">
The upstream service isn't responding. The proxy is confused.
</p>
<code className="bgp-kubectl">
$ curl -I https://app.lab <span className="err">502 Bad Gateway</span>
</code>
</header>
<section className="bgp-log-section" aria-label="Gateway diagnostics">
<TerminalLog title="gateway-diagnostics — 502" />
</section>
<footer className="bgp-footer">
<a href="/" className="bgp-btn bgp-btn--primary"> Go Home</a>
<a href="/maintenance" className="bgp-btn bgp-btn--secondary">
Check maintenance status
</a>
</footer>
</div>
</PageLayout>
)
}

View File

@ -0,0 +1,78 @@
/* Home / landing page */
.home-container {
width: 100%;
max-width: 680px;
padding: 3rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 2.2rem;
text-align: center;
}
.home-header { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; }
.home-logo {
font-size: 4rem;
text-shadow: 0 0 30px var(--accent-glow);
}
.home-title {
font-size: clamp(2rem, 6vw, 3rem);
font-weight: 900;
color: transparent;
-webkit-text-stroke: 2px var(--accent);
text-shadow: 0 0 40px var(--accent-glow);
}
.home-subtitle { font-size: 1rem; color: var(--cyan-dim); }
.home-status {
font-size: 0.78rem; color: var(--text-muted);
background: var(--surface); border: 1px solid var(--border);
border-radius: 4px; padding: 0.3rem 0.8rem;
}
/* Service grid */
.home-services {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.8rem;
width: 100%;
}
.home-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1rem 0.8rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: var(--text);
transition: border-color 0.18s, box-shadow 0.18s;
}
.home-card:hover {
border-color: var(--accent);
box-shadow: 0 0 16px var(--accent-dim);
}
.home-card-name { font-size: 0.9rem; font-weight: 600; }
.home-card-badge {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 3px;
letter-spacing: 0.04em;
}
.home-card-badge--running { background: rgba(0,255,136,0.12); color: #00ff88; }
.home-card-badge--pending { background: rgba(68,153,255,0.12); color: #4499ff; }
.home-card-badge--error { background: rgba(255,68,85,0.12); color: var(--danger); }
.home-footer { margin-top: 0.5rem; }
.home-footer-text { font-size: 0.82rem; color: var(--text-muted); }
.home-link { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
.home-link:hover { color: var(--cyan); }

View File

@ -0,0 +1,43 @@
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
import './HomePage.css'
/**
* Home / landing page placeholder.
* TODO: add service list, status dashboard, or project links.
*/
export default function HomePage() {
return (
<PageLayout>
<div className="home-container">
<header className="home-header">
<div className="home-logo" aria-hidden="true"></div>
<h1 className="home-title">ErrorLab</h1>
<p className="home-subtitle">Home Lab / DevOps Dashboard</p>
<code className="home-status">cluster: online &nbsp;·&nbsp; nodes: healthy</code>
</header>
{/* Placeholder service cards — replace with real data */}
<section className="home-services" aria-label="Services">
{[
{ name: 'App', status: 'running', path: '#' },
{ name: 'Monitoring', status: 'running', path: '#' },
{ name: 'CI/CD', status: 'running', path: '#' },
{ name: 'Storage', status: 'pending', path: '#' },
].map(svc => (
<a key={svc.name} href={svc.path} className={`home-card home-card--${svc.status}`}>
<span className="home-card-name">{svc.name}</span>
<span className={`home-card-badge home-card-badge--${svc.status}`}>{svc.status}</span>
</a>
))}
</section>
<footer className="home-footer">
<span className="home-footer-text">
Something broken? &nbsp;
<a href="/404" className="home-link">See the 404 game</a>
</span>
</footer>
</div>
</PageLayout>
)
}

View File

@ -0,0 +1,74 @@
/* Maintenance page */
.maint-container {
width: 100%;
max-width: 600px;
padding: 3rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.8rem;
text-align: center;
}
.maint-header { display: flex; flex-direction: column; align-items: center; gap: 0.7rem; }
.maint-icon { font-size: 3.5rem; }
.maint-title {
font-size: clamp(1.5rem, 4vw, 2.2rem);
font-weight: 800;
color: var(--warning);
text-shadow: 0 0 30px rgba(255,170,0,0.3);
}
.maint-subtitle { font-size: 1rem; color: var(--text); min-height: 1.6em; }
.maint-status {
font-size: 0.78rem; color: var(--text-muted);
background: var(--surface); border: 1px solid var(--border);
border-radius: 4px; padding: 0.35rem 0.8rem;
}
.maint-status .info { color: var(--cyan); }
/* Infinite looping progress bar */
.maint-progress {
width: 100%;
max-width: 400px;
height: 4px;
background: var(--surface);
border-radius: 2px;
overflow: hidden;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
.maint-progress-bar {
height: 100%;
width: 25%;
background: linear-gradient(90deg, transparent, var(--warning), transparent);
animation: indeterminate 1.8s linear infinite;
}
.maint-note { font-size: 0.82rem; color: var(--text-muted); }
.maint-footer { display: flex; justify-content: center; }
.maint-btn {
padding: 0.55rem 1.6rem;
border-radius: 6px;
border: 1px solid rgba(255,170,0,0.35);
background: rgba(255,170,0,0.1);
color: var(--warning);
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.18s, border-color 0.18s, box-shadow 0.18s;
}
.maint-btn:hover {
background: rgba(255,170,0,0.18);
border-color: var(--warning);
box-shadow: 0 0 16px rgba(255,170,0,0.28);
}

View File

@ -0,0 +1,51 @@
import { useState, useEffect } from 'react'
import PageLayout from '../../components/PageLayout/PageLayout.jsx'
import './MaintenancePage.css'
/**
* Maintenance placeholder.
* TODO: add real duration, progress bar, or status feed.
*/
export default function MaintenancePage() {
const [dots, setDots] = useState('.')
// Animate the "working on it" ellipsis
useEffect(() => {
const id = setInterval(() => {
setDots(d => d.length >= 3 ? '.' : d + '.')
}, 600)
return () => clearInterval(id)
}, [])
return (
<PageLayout>
<div className="maint-container">
<header className="maint-header">
<div className="maint-icon" aria-hidden="true">🔧</div>
<h1 className="maint-title">Under Maintenance</h1>
<p className="maint-subtitle">
The lab is being upgraded{dots}
</p>
<code className="maint-status">
$ kubectl rollout status deploy/lab <span className="info">Waiting</span>
</code>
</header>
{/* Animated progress bar placeholder */}
<div className="maint-progress" role="progressbar" aria-label="Maintenance in progress">
<div className="maint-progress-bar" />
</div>
<p className="maint-note">
Services will be back shortly. Check back in a few minutes.
</p>
<footer className="maint-footer">
<button className="maint-btn" onClick={() => window.location.reload()}>
Refresh
</button>
</footer>
</div>
</PageLayout>
)
}

8
vite.config.js Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
// Each page can be built independently later by pointing `build.rollupOptions.input`
// to its specific entry. For now, all pages share a single SPA dev server.
})