commit d19183923c834ae505c8c4b894ad21a886a84c90 Author: Hera Zhao Date: Mon Apr 6 22:13:06 2026 +0000 Initial template: Vue 3 + FastAPI + SQLite full-stack with K8s deployment Extracted from oil project — business logic removed, auth/db/deploy infrastructure generalized with APP_NAME placeholders. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..bed5e47 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,22 @@ +name: Deploy Production +on: + push: + branches: [main] + +jobs: + test: + runs-on: test + steps: + - uses: actions/checkout@v4 + - name: Unit tests + run: cd frontend && npm ci && npx vitest run + - name: Build check + run: cd frontend && npm run build + + deploy: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Deploy Production + run: python3 scripts/deploy-preview.py deploy-prod diff --git a/.gitea/workflows/preview.yml b/.gitea/workflows/preview.yml new file mode 100644 index 0000000..d418d99 --- /dev/null +++ b/.gitea/workflows/preview.yml @@ -0,0 +1,50 @@ +name: PR Preview +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +jobs: + test: + if: github.event.action != 'closed' + runs-on: test + steps: + - uses: actions/checkout@v4 + - name: Unit tests + run: cd frontend && npm ci && npx vitest run + + deploy-preview: + if: github.event.action != 'closed' + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Deploy Preview + run: python3 scripts/deploy-preview.py deploy ${{ github.event.pull_request.number }} + - name: Comment PR + env: + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} + run: | + PR_ID="${{ github.event.pull_request.number }}" + curl -sf -X POST \ + "https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \ + -H "Authorization: token ${GIT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"Preview: https://pr-${PR_ID}.APP_NAME.oci.euphon.net\n\nDB is a copy of production.\"}" || true + + teardown-preview: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Teardown + run: python3 scripts/deploy-preview.py teardown ${{ github.event.pull_request.number }} + - name: Comment PR + env: + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} + run: | + PR_ID="${{ github.event.pull_request.number }}" + curl -sf -X POST \ + "https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \ + -H "Authorization: token ${GIT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"Preview torn down.\"}" || true diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..096bc06 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,57 @@ +name: Test +on: [push] + +jobs: + unit-test: + runs-on: test + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: cd frontend && npm ci + + - name: Run unit tests + run: | + cd frontend + npx vitest run --reporter=verbose 2>&1 | tee /tmp/vitest-${{ github.sha }}.log + + e2e-test: + runs-on: test + needs: unit-test + steps: + - uses: actions/checkout@v4 + + - name: Install frontend + run: cd frontend && npm ci + + - name: Install backend + run: python3 -m venv /tmp/ci-venv-$$ && . /tmp/ci-venv-$$/bin/activate && pip install -q -r backend/requirements.txt + + - name: Start servers + run: | + . /tmp/ci-venv-*/bin/activate + DB_PATH=/tmp/ci_app_${{ github.run_id }}.db FRONTEND_DIR=/dev/null \ + nohup uvicorn backend.main:app --port 8000 > /tmp/backend.log 2>&1 & + cd frontend && nohup npx vite --port 5173 > /tmp/frontend.log 2>&1 & + sleep 4 + curl -sf http://localhost:8000/api/health + curl -sf -o /dev/null http://localhost:5173/ + + - name: Run E2E tests + run: | + cd frontend + npx cypress run --config video=false 2>&1 | tee /tmp/cypress-${{ github.sha }}.log + + - name: Cleanup + if: always() + run: | + pkill -f "uvicorn backend" || true + pkill -f "node.*vite" || true + rm -f /tmp/ci_app_${{ github.run_id }}.db + + build-check: + runs-on: test + steps: + - uses: actions/checkout@v4 + - name: Build frontend + run: cd frontend && npm ci && npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c471527 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store +*.pyc +__pycache__/ +*.db +backups/ + +# Frontend +frontend/node_modules/ +frontend/dist/ + +# IDE +.vscode/ +.idea/ + +# Env +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc86df4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:20-slim AS frontend-build + +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM python:3.12-slim + +WORKDIR /app + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ ./backend/ +COPY --from=frontend-build /build/dist ./frontend/ + +ENV DB_PATH=/data/app.db +ENV FRONTEND_DIR=/app/frontend + +EXPOSE 8000 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac728ff --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Base Template + +Vue 3 + FastAPI + SQLite full-stack template with K8s deployment. + +## Tech Stack + +- **Frontend**: Vue 3 + Vite + Pinia + Vue Router +- **Backend**: FastAPI + SQLite (WAL mode) + uvicorn +- **Testing**: Vitest (unit) + Cypress (E2E) +- **Deploy**: Docker multi-stage build → K8s (k3s + Traefik) +- **CI/CD**: Gitea Actions (test → deploy, PR preview) + +## Quick Start + +```bash +# Frontend +cd frontend && npm install && npm run dev + +# Backend (in another terminal) +pip install -r backend/requirements.txt +DB_PATH=./dev.db uvicorn backend.main:app --reload --port 8000 +``` + +Frontend dev server proxies `/api` to `localhost:8000`. + +## Setup for New Project + +1. Replace all `APP_NAME` placeholders in `deploy/`, `scripts/`, and `.gitea/workflows/` +2. Add your tables to `backend/database.py` → `init_db()` +3. Add your routes to `backend/main.py` +4. Add your pages to `frontend/src/views/` and register in `frontend/src/router/index.js` + +## Testing + +```bash +cd frontend +npm run test:unit # Vitest +npm run test:e2e # Cypress (requires both servers running) +npm test # Both +``` + +## Deploy + +```bash +# Production +python3 scripts/deploy-preview.py deploy-prod + +# PR preview +python3 scripts/deploy-preview.py deploy +python3 scripts/deploy-preview.py teardown +``` + +## Project Structure + +``` +├── frontend/ +│ ├── src/ +│ │ ├── composables/useApi.js # HTTP client with auth +│ │ ├── stores/auth.js # Auth state (Pinia) +│ │ ├── router/index.js # Routes +│ │ ├── views/ # Page components +│ │ └── assets/styles.css # Design tokens + base styles +│ ├── cypress/ # E2E tests +│ └── vite.config.js +├── backend/ +│ ├── main.py # FastAPI app + routes +│ ├── auth.py # Auth dependencies +│ └── database.py # SQLite init + helpers +├── deploy/ # K8s manifests (replace APP_NAME) +├── scripts/deploy-preview.py # Deploy automation +├── .gitea/workflows/ # CI/CD pipelines +└── Dockerfile # Multi-stage build +``` diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..22935c6 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,37 @@ +from fastapi import Request, Depends, HTTPException + +from backend.database import get_db + +ANON_USER = {"id": None, "role": "viewer", "username": "anonymous", "display_name": "匿名"} + + +def get_current_user(request: Request): + """Extract user from Bearer token. Returns anonymous if no/invalid token.""" + token = request.headers.get("Authorization", "").removeprefix("Bearer ").strip() + if not token: + return ANON_USER + conn = get_db() + user = conn.execute( + "SELECT id, username, role, display_name, password FROM users WHERE token = ?", + (token,), + ).fetchone() + conn.close() + if not user: + return ANON_USER + return dict(user) + + +def require_role(*roles): + """Dependency that checks the user has one of the given roles.""" + def checker(user=Depends(get_current_user)): + if user["role"] not in roles: + raise HTTPException(403, "权限不足") + return user + return checker + + +def require_login(user=Depends(get_current_user)): + """Dependency that requires any authenticated user.""" + if user["id"] is None: + raise HTTPException(401, "请先登录") + return user diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..560afe2 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,62 @@ +import sqlite3 +import os +import secrets + +DB_PATH = os.environ.get("DB_PATH", "/data/app.db") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + """Initialize database schema. Add your tables here.""" + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = get_db() + c = conn.cursor() + c.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT, + token TEXT UNIQUE NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + display_name TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + target_name TEXT, + detail TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + """) + + # Seed admin user if no users exist + count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] + if count == 0: + admin_token = os.environ.get("ADMIN_TOKEN", secrets.token_hex(24)) + c.execute( + "INSERT INTO users (username, token, role, display_name) VALUES (?, ?, ?, ?)", + ("admin", admin_token, "admin", "Admin"), + ) + print(f"[INIT] Admin user created. Token: {admin_token}") + + conn.commit() + conn.close() + + +def log_audit(conn, user_id, action, target_type=None, target_id=None, target_name=None, detail=None): + conn.execute( + "INSERT INTO audit_log (user_id, action, target_type, target_id, target_name, detail) " + "VALUES (?, ?, ?, ?, ?, ?)", + (user_id, action, target_type, str(target_id) if target_id else None, target_name, detail), + ) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..9b9760a --- /dev/null +++ b/backend/main.py @@ -0,0 +1,115 @@ +from fastapi import FastAPI, HTTPException, Depends +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from typing import Optional +import hashlib +import secrets +import os +import threading +import time as _time + +from backend.database import get_db, init_db, log_audit +from backend.auth import get_current_user, require_role, require_login + +app = FastAPI(title="App API") + + +# ── Periodic WAL checkpoint ─────────────────────────── +def _wal_checkpoint_loop(): + while True: + _time.sleep(300) + try: + conn = get_db() + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.close() + except: + pass + +threading.Thread(target=_wal_checkpoint_loop, daemon=True).start() + + +# ── Models ──────────────────────────────────────────── +class LoginRequest(BaseModel): + username: str + password: str + +class RegisterRequest(BaseModel): + username: str + password: str + display_name: str = "" + + +# ── Health & Version ────────────────────────────────── +APP_VERSION = "0.1.0" + +@app.get("/api/health") +def health(): + return {"status": "ok"} + +@app.get("/api/version") +def version(): + return {"version": APP_VERSION} + + +# ── Auth endpoints ──────────────────────────────────── +def _hash_password(pw: str) -> str: + return hashlib.sha256(pw.encode()).hexdigest() + +@app.get("/api/me") +def get_me(user=Depends(get_current_user)): + return { + "id": user.get("id"), + "username": user["username"], + "role": user["role"], + "display_name": user.get("display_name", ""), + } + +@app.post("/api/login") +def login(body: LoginRequest): + conn = get_db() + user = conn.execute( + "SELECT id, username, token, password, role, display_name FROM users WHERE username = ?", + (body.username,), + ).fetchone() + conn.close() + if not user: + raise HTTPException(401, "用户名或密码错误") + user = dict(user) + if user.get("password") and user["password"] != _hash_password(body.password): + raise HTTPException(401, "用户名或密码错误") + return {"token": user["token"]} + +@app.post("/api/register", status_code=201) +def register(body: RegisterRequest): + conn = get_db() + existing = conn.execute("SELECT id FROM users WHERE username = ?", (body.username,)).fetchone() + if existing: + conn.close() + raise HTTPException(409, "用户名已存在") + token = secrets.token_hex(24) + pw_hash = _hash_password(body.password) + conn.execute( + "INSERT INTO users (username, password, token, role, display_name) VALUES (?, ?, ?, ?, ?)", + (body.username, pw_hash, token, "viewer", body.display_name or body.username), + ) + conn.commit() + conn.close() + return {"token": token} + + +# ── User management (admin) ────────────────────────── +@app.get("/api/users") +def list_users(user=Depends(require_role("admin"))): + conn = get_db() + rows = conn.execute("SELECT id, username, role, display_name, token, created_at FROM users ORDER BY id").fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ── Static files (frontend) ────────────────────────── +@app.on_event("startup") +def on_startup(): + init_db() + frontend_dir = os.environ.get("FRONTEND_DIR", "frontend/dist") + if os.path.isdir(frontend_dir): + app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3df3349 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +aiosqlite==0.20.0 diff --git a/deploy/backup-cronjob.yaml b/deploy/backup-cronjob.yaml new file mode 100644 index 0000000..2ca83bb --- /dev/null +++ b/deploy/backup-cronjob.yaml @@ -0,0 +1,38 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: hourly-backup + namespace: APP_NAME +spec: + schedule: "0 * * * *" + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 2 + jobTemplate: + spec: + template: + spec: + containers: + - name: backup + image: registry.oci.euphon.net/APP_NAME:latest + command: + - sh + - -c + - | + BACKUP_DIR=/data/backups + mkdir -p $BACKUP_DIR + DATE=$(date +%Y%m%d_%H%M%S) + sqlite3 /data/app.db ".backup '$BACKUP_DIR/app_${DATE}.db'" + echo "Backup done: $BACKUP_DIR/app_${DATE}.db" + # Keep last 48 backups (2 days of hourly) + ls -t $BACKUP_DIR/app_*.db | tail -n +49 | xargs rm -f 2>/dev/null + echo "Backups retained: $(ls $BACKUP_DIR/app_*.db | wc -l)" + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + persistentVolumeClaim: + claimName: APP_NAME-data + restartPolicy: OnFailure + imagePullSecrets: + - name: regcred diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..d233ab9 --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: APP_NAME + namespace: APP_NAME +spec: + replicas: 1 + selector: + matchLabels: + app: APP_NAME + template: + metadata: + labels: + app: APP_NAME + spec: + imagePullSecrets: + - name: regcred + containers: + - name: APP_NAME + image: registry.oci.euphon.net/APP_NAME:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + env: + - name: DB_PATH + value: /data/app.db + - name: FRONTEND_DIR + value: /app/frontend + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: APP_NAME-secrets + key: admin-token + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + volumes: + - name: data + persistentVolumeClaim: + claimName: APP_NAME-data diff --git a/deploy/ingress.yaml b/deploy/ingress.yaml new file mode 100644 index 0000000..33e490b --- /dev/null +++ b/deploy/ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: APP_NAME + namespace: APP_NAME + annotations: + traefik.ingress.kubernetes.io/router.tls.certresolver: le +spec: + ingressClassName: traefik + rules: + - host: APP_NAME.oci.euphon.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: APP_NAME + port: + number: 80 + tls: + - hosts: + - APP_NAME.oci.euphon.net diff --git a/deploy/namespace.yaml b/deploy/namespace.yaml new file mode 100644 index 0000000..3c337ea --- /dev/null +++ b/deploy/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: APP_NAME diff --git a/deploy/pvc.yaml b/deploy/pvc.yaml new file mode 100644 index 0000000..ded0436 --- /dev/null +++ b/deploy/pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: APP_NAME-data + namespace: APP_NAME +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/deploy/service.yaml b/deploy/service.yaml new file mode 100644 index 0000000..f0b9510 --- /dev/null +++ b/deploy/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: APP_NAME + namespace: APP_NAME +spec: + selector: + app: APP_NAME + ports: + - port: 80 + targetPort: 8000 diff --git a/frontend/cypress.config.js b/frontend/cypress.config.js new file mode 100644 index 0000000..3732a66 --- /dev/null +++ b/frontend/cypress.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:5173', + supportFile: 'cypress/support/e2e.js', + specPattern: 'cypress/e2e/**/*.cy.{js,ts}', + viewportWidth: 1280, + viewportHeight: 800, + video: true, + videoCompression: false, + }, +}) diff --git a/frontend/cypress/e2e/smoke.cy.js b/frontend/cypress/e2e/smoke.cy.js new file mode 100644 index 0000000..16bd37e --- /dev/null +++ b/frontend/cypress/e2e/smoke.cy.js @@ -0,0 +1,10 @@ +describe('Smoke test', () => { + it('loads the home page', () => { + cy.visit('/') + cy.contains('Welcome') + }) + + it('health check API responds', () => { + cy.request('GET', '/api/health').its('status').should('eq', 200) + }) +}) diff --git a/frontend/cypress/support/e2e.js b/frontend/cypress/support/e2e.js new file mode 100644 index 0000000..e8a2a53 --- /dev/null +++ b/frontend/cypress/support/e2e.js @@ -0,0 +1,23 @@ +Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('ResizeObserver')) return false + return true +}) + +// Login as admin via token injection +Cypress.Commands.add('loginAsAdmin', () => { + cy.request('GET', '/api/users').then((res) => { + const admin = res.body.find(u => u.role === 'admin') + if (admin) { + cy.window().then(win => { + win.localStorage.setItem('auth_token', admin.token) + }) + } + }) +}) + +// Login with a specific token +Cypress.Commands.add('loginWithToken', (token) => { + cy.window().then(win => { + win.localStorage.setItem('auth_token', token) + }) +}) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..bb0f2fb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + App + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0765c24 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "cy:open": "cypress open", + "cy:run": "cypress run", + "test:e2e": "cypress run", + "test:unit": "vitest run", + "test": "vitest run && cypress run" + }, + "dependencies": { + "pinia": "^2.3.1", + "vue": "^3.5.32", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.5", + "@vue/test-utils": "^2.4.6", + "cypress": "^15.13.0", + "jsdom": "^29.0.1", + "vite": "^8.0.4", + "vitest": "^4.1.2" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..c2efbb1 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/assets/styles.css b/frontend/src/assets/styles.css new file mode 100644 index 0000000..b7ce0e2 --- /dev/null +++ b/frontend/src/assets/styles.css @@ -0,0 +1,149 @@ +/* ── Design tokens ─────────────────────────────────── */ +:root { + --color-primary: #4a7c59; + --color-primary-dark: #3a6349; + --color-accent: #c9a84c; + --color-bg: #faf6f0; + --color-bg-card: #ffffff; + --color-text: #333333; + --color-text-muted: #888888; + --color-border: #e0d8cc; + --color-danger: #d32f2f; + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.12); + --radius: 8px; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', sans-serif; +} + +/* ── Reset ────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-sans); + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; +} + +/* ── Layout ───────────────────────────────────────── */ +.app-header { + background: var(--color-primary); + color: white; + padding: 12px 20px; +} + +.header-inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 20px; +} + +.header-title { + font-size: 18px; + font-weight: 600; + white-space: nowrap; +} + +.nav-tabs { + display: flex; + gap: 4px; + flex: 1; +} + +.nav-tab { + padding: 6px 14px; + border-radius: var(--radius); + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + font-size: 14px; + transition: background 0.2s; +} + +.nav-tab:hover { background: rgba(255, 255, 255, 0.15); } +.nav-tab.active { background: rgba(255, 255, 255, 0.25); color: white; } + +.header-right { + display: flex; + align-items: center; + gap: 10px; + font-size: 14px; + white-space: nowrap; +} + +.app-main { + max-width: 1200px; + margin: 20px auto; + padding: 0 20px; +} + +/* ── Components ───────────────────────────────────── */ +.page { padding: 20px 0; } + +.btn-primary { + background: var(--color-primary); + color: white; + border: none; + padding: 8px 20px; + border-radius: var(--radius); + cursor: pointer; + font-size: 14px; +} + +.btn-primary:hover { background: var(--color-primary-dark); } + +.btn-link { + background: none; + border: none; + color: inherit; + cursor: pointer; + text-decoration: underline; + font-size: inherit; +} + +.form-group { + margin-bottom: 14px; +} + +.form-group label { + display: block; + font-size: 14px; + margin-bottom: 4px; + color: var(--color-text-muted); +} + +.form-group input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius); + font-size: 14px; +} + +.error { color: var(--color-danger); font-size: 13px; } + +.card { + background: var(--color-bg-card); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + padding: 16px; +} + +.toast { + position: fixed; + top: 20px; + right: 20px; + background: #333; + color: white; + padding: 10px 20px; + border-radius: var(--radius); + z-index: 9999; + font-size: 14px; +} + +/* ── Responsive ───────────────────────────────────── */ +@media (max-width: 640px) { + .header-inner { flex-wrap: wrap; } + .nav-tabs { order: 3; width: 100%; overflow-x: auto; } +} diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js new file mode 100644 index 0000000..90df0eb --- /dev/null +++ b/frontend/src/composables/useApi.js @@ -0,0 +1,51 @@ +const API_BASE = '' // same origin, uses vite proxy in dev + +export function getToken() { + return localStorage.getItem('auth_token') || '' +} + +export function setToken(token) { + if (token) localStorage.setItem('auth_token', token) + else localStorage.removeItem('auth_token') +} + +function buildHeaders(extra = {}) { + const headers = { 'Content-Type': 'application/json', ...extra } + const token = getToken() + if (token) headers['Authorization'] = 'Bearer ' + token + return headers +} + +async function request(path, opts = {}) { + const headers = buildHeaders(opts.headers) + const res = await fetch(API_BASE + path, { ...opts, headers }) + return res +} + +async function requestJSON(path, opts = {}) { + const res = await request(path, opts) + if (!res.ok) { + let msg = `${res.status}` + try { + const body = await res.json() + msg = body.detail || body.message || msg + } catch {} + const err = new Error(msg) + err.status = res.status + throw err + } + return res.json() +} + +function apiFn(path, opts = {}) { + return request(path, opts) +} + +apiFn.raw = request +apiFn.get = (path) => requestJSON(path) +apiFn.post = (path, body) => requestJSON(path, { method: 'POST', body: JSON.stringify(body) }) +apiFn.put = (path, body) => requestJSON(path, { method: 'PUT', body: JSON.stringify(body) }) +apiFn.del = (path) => requestJSON(path, { method: 'DELETE' }) +apiFn.delete = (path) => requestJSON(path, { method: 'DELETE' }) + +export const api = apiFn diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..7a0895c --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './assets/styles.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..1bc7f5b --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,21 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + name: 'Home', + component: () => import('../views/Home.vue'), + }, + { + path: '/login', + name: 'Login', + component: () => import('../views/Login.vue'), + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..e6c7ad7 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,80 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { api, setToken } from '../composables/useApi' + +const DEFAULT_USER = { + id: null, + role: 'viewer', + username: 'anonymous', + display_name: '匿名', +} + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('auth_token') || '') + const user = ref({ ...DEFAULT_USER }) + + const isLoggedIn = computed(() => user.value.id !== null) + const isAdmin = computed(() => user.value.role === 'admin') + const canEdit = computed(() => + ['editor', 'admin'].includes(user.value.role) + ) + + async function initToken() { + const params = new URLSearchParams(window.location.search) + const urlToken = params.get('token') + if (urlToken) { + token.value = urlToken + setToken(urlToken) + const url = new URL(window.location) + url.searchParams.delete('token') + window.history.replaceState({}, '', url) + } + if (token.value) { + await loadMe() + } + } + + async function loadMe() { + try { + const data = await api.get('/api/me') + user.value = { + id: data.id, + role: data.role, + username: data.username, + display_name: data.display_name, + } + } catch { + logout() + } + } + + async function login(username, password) { + const data = await api.post('/api/login', { username, password }) + token.value = data.token + setToken(data.token) + await loadMe() + } + + async function register(username, password, displayName) { + const data = await api.post('/api/register', { + username, + password, + display_name: displayName, + }) + token.value = data.token + setToken(data.token) + await loadMe() + } + + function logout() { + token.value = '' + setToken(null) + user.value = { ...DEFAULT_USER } + } + + return { + token, user, + isLoggedIn, isAdmin, canEdit, + initToken, loadMe, login, register, logout, + } +}) diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..f746121 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,6 @@ + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..e2ec22a --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,39 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..6debdf5 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': 'http://localhost:8000', + } + }, + build: { + outDir: 'dist' + }, + test: { + environment: 'jsdom', + globals: true, + } +}) diff --git a/scripts/deploy-preview.py b/scripts/deploy-preview.py new file mode 100644 index 0000000..0649c7f --- /dev/null +++ b/scripts/deploy-preview.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Deploy or teardown a PR preview environment on local k3s. + +Usage: + python3 scripts/deploy-preview.py deploy + python3 scripts/deploy-preview.py teardown + python3 scripts/deploy-preview.py deploy-prod +""" + +import subprocess +import sys +import json +import tempfile +import textwrap +from pathlib import Path + +# ── Configuration ───────────────────────────────────── +# Change these for your project: +REGISTRY = "registry.oci.euphon.net" +APP_NAME = "APP_NAME" +BASE_DOMAIN = f"{APP_NAME}.oci.euphon.net" +PROD_NS = APP_NAME + + +def run(cmd: list[str] | str, *, check=True, capture=False) -> subprocess.CompletedProcess: + if isinstance(cmd, str): + cmd = ["sh", "-c", cmd] + display = " ".join(cmd) if isinstance(cmd, list) else cmd + print(f" $ {display}") + r = subprocess.run(cmd, text=True, capture_output=capture) + if capture and r.stdout.strip(): + for line in r.stdout.strip().splitlines()[:5]: + print(f" {line}") + if check and r.returncode != 0: + print(f" FAILED (exit {r.returncode})") + if capture and r.stderr.strip(): + print(f" {r.stderr.strip()[:200]}") + sys.exit(1) + return r + + +def kubectl(*args, capture=False, check=True) -> subprocess.CompletedProcess: + return run(["sudo", "k3s", "kubectl", *args], capture=capture, check=check) + + +def docker(*args, check=True) -> subprocess.CompletedProcess: + return run(["docker", *args], check=check) + + +def write_temp(content: str, suffix=".yaml") -> Path: + f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) + f.write(content) + f.close() + return Path(f.name) + + +# ─── Deploy PR Preview ───────────────────────────────── + +def deploy(pr_id: str): + ns = f"{APP_NAME}-pr-{pr_id}" + host = f"pr-{pr_id}.{BASE_DOMAIN}" + image = f"{REGISTRY}/{APP_NAME}:pr-{pr_id}" + + print(f"\n{'='*60}") + print(f" Deploying: https://{host}") + print(f" Namespace: {ns}") + print(f"{'='*60}\n") + + # 1. Copy production DB + print("[1/5] Copying production database...") + Path("data").mkdir(exist_ok=True) + prod_pod = kubectl( + "get", "pods", "-n", PROD_NS, + "-l", f"app={APP_NAME}", + "--field-selector=status.phase=Running", + "-o", "jsonpath={.items[0].metadata.name}", + capture=True, check=False + ).stdout.strip() + + if prod_pod: + kubectl("cp", f"{PROD_NS}/{prod_pod}:/data/app.db", "data/app.db") + else: + print(" WARNING: No running prod pod, using empty DB") + Path("data/app.db").touch() + + # 2. Build and push image + print("[2/5] Building Docker image...") + dockerfile = textwrap.dedent(f"""\ + FROM node:20-slim AS frontend-build + WORKDIR /build + COPY frontend/package.json frontend/package-lock.json ./ + RUN npm ci + COPY frontend/ ./ + RUN npm run build + + FROM python:3.12-slim + WORKDIR /app + COPY backend/requirements.txt . + RUN pip install --no-cache-dir -r requirements.txt + COPY backend/ ./backend/ + COPY --from=frontend-build /build/dist ./frontend/ + COPY data/app.db /data/app.db + ENV DB_PATH=/data/app.db + ENV FRONTEND_DIR=/app/frontend + EXPOSE 8000 + CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] + """) + df = write_temp(dockerfile, suffix=".Dockerfile") + docker("build", "-f", str(df), "-t", image, ".") + df.unlink() + docker("push", image) + + # 3. Create namespace + regcred + print("[3/5] Creating namespace...") + run(f"sudo k3s kubectl create namespace {ns} --dry-run=client -o yaml | sudo k3s kubectl apply -f -") + + r = kubectl("get", "secret", "regcred", "-n", PROD_NS, "-o", "json", capture=True) + secret = json.loads(r.stdout) + secret["metadata"] = {"name": "regcred", "namespace": ns} + p = write_temp(json.dumps(secret), suffix=".json") + kubectl("apply", "-f", str(p)) + p.unlink() + + # 4. Apply manifests + print("[4/5] Applying K8s resources...") + manifests = textwrap.dedent(f"""\ + apiVersion: apps/v1 + kind: Deployment + metadata: + name: {APP_NAME} + namespace: {ns} + spec: + replicas: 1 + selector: + matchLabels: + app: {APP_NAME} + template: + metadata: + labels: + app: {APP_NAME} + spec: + imagePullSecrets: + - name: regcred + containers: + - name: app + image: {image} + imagePullPolicy: Always + ports: + - containerPort: 8000 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + --- + apiVersion: v1 + kind: Service + metadata: + name: {APP_NAME} + namespace: {ns} + spec: + selector: + app: {APP_NAME} + ports: + - port: 80 + targetPort: 8000 + --- + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: {APP_NAME} + namespace: {ns} + annotations: + traefik.ingress.kubernetes.io/router.tls.certresolver: le + spec: + ingressClassName: traefik + tls: + - hosts: + - {host} + rules: + - host: {host} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {APP_NAME} + port: + number: 80 + """) + p = write_temp(manifests) + kubectl("apply", "-f", str(p)) + p.unlink() + + # 5. Restart and wait + print("[5/5] Restarting deployment...") + kubectl("rollout", "restart", f"deploy/{APP_NAME}", "-n", ns) + kubectl("rollout", "status", f"deploy/{APP_NAME}", "-n", ns, "--timeout=120s") + + run("rm -rf data/app.db", check=False) + + print(f"\n{'='*60}") + print(f" Preview live: https://{host}") + print(f"{'='*60}\n") + + +# ─── Teardown ────────────────────────────────────────── + +def teardown(pr_id: str): + ns = f"{APP_NAME}-pr-{pr_id}" + image = f"{REGISTRY}/{APP_NAME}:pr-{pr_id}" + + print(f"\n Tearing down: {ns}") + kubectl("delete", "namespace", ns, "--ignore-not-found") + docker("rmi", image, check=False) + print(" Done.\n") + + +# ─── Deploy Production ───────────────────────────────── + +def deploy_prod(): + image = f"{REGISTRY}/{APP_NAME}:latest" + + print(f"\n{'='*60}") + print(f" Deploying production: https://{BASE_DOMAIN}") + print(f"{'='*60}\n") + + docker("build", "-t", image, ".") + docker("push", image) + kubectl("rollout", "restart", f"deploy/{APP_NAME}", "-n", PROD_NS) + kubectl("rollout", "status", f"deploy/{APP_NAME}", "-n", PROD_NS, "--timeout=120s") + + print(f"\n Production deployed: https://{BASE_DOMAIN}\n") + + +# ─── Main ────────────────────────────────────────────── + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + action = sys.argv[1] + if action == "deploy" and len(sys.argv) >= 3: + deploy(sys.argv[2]) + elif action == "teardown" and len(sys.argv) >= 3: + teardown(sys.argv[2]) + elif action == "deploy-prod": + deploy_prod() + else: + print(__doc__) + sys.exit(1)