Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7852e2f5fe | ||
|
|
cbf056838f | ||
|
|
ec742a3f72 | ||
|
|
1caef76f82 | ||
|
|
fe26ca3ff3 | ||
|
|
a0d7f42227 | ||
|
|
47917bb63b | ||
|
|
fb68df3ba9 | ||
|
|
ffcd4232d6 | ||
|
|
af1a06b0a3 | ||
|
|
2a708b01b1 | ||
|
|
641b6b8cb8 | ||
|
|
b92f897ba2 | ||
|
|
d02aaa0d87 | ||
|
|
2920669362 | ||
|
|
388d8992f8 | ||
|
|
eeca532057 | ||
|
|
0252dfe756 | ||
|
|
0af8d8a398 | ||
|
|
91008d84fe | ||
|
|
8707c0d8fe | ||
|
|
d2572757cd | ||
|
|
31a89c505d | ||
|
|
fd7b29a5f8 | ||
|
|
8a1b83b965 | ||
|
|
e5e17d75ae | ||
|
|
97012a78f6 | ||
|
|
efd3b00075 | ||
|
|
bf5b9918d9 | ||
|
|
3265cf1fad | ||
|
|
29b9a4b0dc | ||
|
|
d755ab7ce1 | ||
|
|
cc58f16230 | ||
|
|
741f01e897 | ||
|
|
4d720ee03c | ||
|
|
0842788a28 | ||
|
|
fdc7a48ef4 | ||
|
|
0f1da6c178 | ||
|
|
e47bee16bb | ||
|
|
eb3dc4e88c | ||
|
|
669e20a35c | ||
|
|
683fee3bd9 | ||
|
|
caeb4eb0b3 | ||
|
|
61c7519bea | ||
|
|
19dc0347df | ||
|
|
01a85ebcd0 | ||
|
|
f5c2c05f6e | ||
|
|
f832479e52 | ||
|
|
baeb54aeb5 | ||
|
|
d5091d1a73 | ||
|
|
89af4eb1f6 | ||
|
|
c5a2b44807 | ||
|
|
13bd3695cb | ||
|
|
00e33623f7 | ||
|
|
3b76d063a6 | ||
|
|
a45d792563 | ||
|
|
fe1b358ed2 | ||
|
|
88790d1a0d | ||
|
|
410a1e466c | ||
|
|
cc714b427a | ||
|
|
445ef13846 | ||
|
|
db4acbb46e | ||
|
|
76f0420d64 | ||
|
|
a2954e4408 | ||
|
|
440ce4e556 | ||
|
|
a647b5451f | ||
|
|
74223984dc | ||
|
|
ba9c61d808 | ||
|
|
506e14783c | ||
|
|
96d3e3f602 | ||
|
|
25d0a13050 | ||
|
|
5d18af932d | ||
|
|
a537c55793 | ||
|
|
d9ea93a2e7 | ||
|
|
fec8dd3c5d | ||
|
|
bcefb0f8ff | ||
|
|
8baf151d84 | ||
|
|
9a8c721111 | ||
|
|
75eada7a59 | ||
|
|
cea88eaa57 | ||
|
|
acbe54fcb7 | ||
|
|
3e69b72513 | ||
|
|
ad65d80d41 | ||
|
|
1ef405be0a | ||
|
|
89526af344 | ||
|
|
27447ce029 | ||
|
|
8dc187483c | ||
|
|
e617ed176a | ||
|
|
2af244b634 | ||
|
|
047cd9f3dd | ||
|
|
867113d5d4 | ||
|
|
c389c3893d | ||
|
|
2fead134e6 | ||
|
|
1daada076c | ||
|
|
b3a6531d7b | ||
|
|
b5ea8f1c0e | ||
|
|
8a911f2cf6 | ||
|
|
5a79090942 | ||
|
|
cbffb565ec | ||
|
|
54b7c67878 | ||
|
|
ecadef904d | ||
|
|
2a1aef1c1b | ||
|
|
bbb7d66067 | ||
|
|
25f4d74209 | ||
|
|
fabd160250 | ||
|
|
f1571a1a62 | ||
|
|
c4496e710b | ||
|
|
745d0b7247 | ||
|
|
fe1ee8a6fe | ||
|
|
6cbcb50191 | ||
|
|
c35fc764bc | ||
|
|
cbaa115d56 | ||
|
|
3bac126ace | ||
|
|
ca6aa97eb2 | ||
|
|
8c47c3c158 | ||
|
|
2650d325ec |
No files matched your search
@@ -13,12 +13,34 @@ package-lock.json
|
|||||||
yarn.lock
|
yarn.lock
|
||||||
bun.lockb
|
bun.lockb
|
||||||
.npmrc.bak
|
.npmrc.bak
|
||||||
# NOTE: .env is intentionally NOT ignored here — the build loads it (only inside
|
# Installation secrets must never enter any image layer (including migrations).
|
||||||
# a build RUN layer) to produce NEXT_PUBLIC_* + validated build-time values.
|
.env
|
||||||
# It is not copied into the runtime image (the runner stage copies only
|
.env.*
|
||||||
# .next/standalone, public/, and .next/static).
|
**/.env
|
||||||
# .env
|
**/.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
|
# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
|
||||||
public/nitro-assets
|
public/nitro-assets
|
||||||
public/swf
|
public/swf
|
||||||
|
|
||||||
|
.deploy.lock
|
||||||
|
logs
|
||||||
|
|
||||||
|
# Other installation data and local tool artifacts
|
||||||
|
public/cache
|
||||||
|
public/tmp
|
||||||
|
db_backup_*.sql
|
||||||
|
*.log
|
||||||
|
.codex
|
||||||
|
.agents
|
||||||
|
|
||||||
|
.docker-install
|
||||||
|
.docker-install.tmp.*
|
||||||
|
.env.install.*
|
||||||
|
build-reports
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
blob-report
|
||||||
@@ -23,12 +23,15 @@ HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
|
|||||||
# --- HOTEL & URLS ---
|
# --- HOTEL & URLS ---
|
||||||
HOTEL_NAME=EPIC WEB CONTROL
|
HOTEL_NAME=EPIC WEB CONTROL
|
||||||
APP_URL=http://localhost:3002
|
APP_URL=http://localhost:3002
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3002
|
|
||||||
AUTH_URL=http://localhost:3002
|
AUTH_URL=http://localhost:3002
|
||||||
|
|
||||||
# --- IMAGER ---
|
# --- IMAGER ---
|
||||||
IMAGING_UPSTREAM_URL=http://127.0.0.1:3030/imaging
|
# Avatar imager: Polaris-imager (avatar-imaging-pixinode) serves /avatarimage on 8082.
|
||||||
NEXT_PUBLIC_IMAGER_URL=http://localhost:3002/imaging
|
IMAGING_UPSTREAM_URL=http://127.0.0.1:8082/avatarimage
|
||||||
|
# Runtime values: changing these only requires recreating the container.
|
||||||
|
IMAGER_URL=http://127.0.0.1:8082/avatarimage
|
||||||
|
BADGE_URL=/swf/c_images/album1584
|
||||||
|
# Legacy NEXT_PUBLIC_IMAGER_URL / NEXT_PUBLIC_BADGE_URL are still read at runtime.
|
||||||
|
|
||||||
# --- SECURITY & HASHING ---
|
# --- SECURITY & HASHING ---
|
||||||
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
AUTH_SECRET=your-super-secret-auth-key-change-this-min-32-chars
|
||||||
@@ -74,8 +77,8 @@ PAYPAL_API=https://api-m.sandbox.paypal.com
|
|||||||
# --- LOGGING ---
|
# --- LOGGING ---
|
||||||
LOG_LEVEL=error
|
LOG_LEVEL=error
|
||||||
|
|
||||||
# --- FLARESOLVERR (Cloudflare bypass for clone sources) ---
|
# --- BYPARR (Cloudflare bypass for clone sources) ---
|
||||||
FLARESOLVERR_URL=http://localhost:8191
|
BYPARR_URL=http://localhost:8191
|
||||||
|
|
||||||
# Catalog Studio export: dedicated clean clone on Beta-3 with Git push credentials.
|
# Catalog Studio export: dedicated clean clone on Beta-3 with Git push credentials.
|
||||||
CATALOG_GIT_CHECKOUT=
|
CATALOG_GIT_CHECKOUT=
|
||||||
|
|||||||
@@ -53,13 +53,28 @@ jobs:
|
|||||||
BCRYPT_ROUNDS: 4
|
BCRYPT_ROUNDS: 4
|
||||||
run: |
|
run: |
|
||||||
if [ -x /usr/bin/time ]; then
|
if [ -x /usr/bin/time ]; then
|
||||||
/usr/bin/time -f 'Tests: %e seconds; peak process RSS: %M KiB' pnpm test --maxWorkers=2
|
/usr/bin/time -f 'Tests: %e seconds; peak process RSS: %M KiB' pnpm test:coverage --maxWorkers=4
|
||||||
else
|
else
|
||||||
time pnpm test --maxWorkers=2
|
time pnpm test:coverage --maxWorkers=4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Knip (unused files/exports)
|
# Compare against reviewed Linux references; updates are explicit.
|
||||||
run: pnpm knip
|
- name: Install UI test browser
|
||||||
|
run: pnpm exec playwright install chromium
|
||||||
|
|
||||||
|
- name: Accessibility and UI regression checks
|
||||||
|
run: pnpm test:ui
|
||||||
|
|
||||||
|
- name: Upload UI results
|
||||||
|
if: always()
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||||
|
with:
|
||||||
|
name: ui-results
|
||||||
|
path: |
|
||||||
|
e2e/ui/__screenshots__/linux/
|
||||||
|
playwright-report/ui/
|
||||||
|
test-results/ui/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Docker build & deploy
|
# Docker build & deploy
|
||||||
@@ -82,3 +97,35 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DEPLOY_BRANCH: ${{ gitea.ref_name }}
|
DEPLOY_BRANCH: ${{ gitea.ref_name }}
|
||||||
run: bash scripts/ci-deploy.sh
|
run: bash scripts/ci-deploy.sh
|
||||||
|
|
||||||
|
- name: Upload JavaScript size report
|
||||||
|
if: always()
|
||||||
|
uses: https://gitea.com/actions/gitea-upload-artifact@62ac910c5d3dfa85c7cb2df15afe2e342b2407c2
|
||||||
|
with:
|
||||||
|
name: javascript-size-report
|
||||||
|
path: build-reports/
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
# Publish only after checks and the production deployment have succeeded.
|
||||||
|
# Serial execution also avoids two builds competing on the self-hosted runner.
|
||||||
|
publish-container:
|
||||||
|
needs: deploy
|
||||||
|
if: gitea.event_name == 'push' && (gitea.ref_name == 'main' || gitea.ref_name == 'master')
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
|
||||||
|
- name: Build, verify portability and publish
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_SERVER: ${{ gitea.server_url }}
|
||||||
|
REGISTRY_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }}
|
||||||
|
REGISTRY_USER: ${{ secrets.CONTAINER_REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||||
|
run: bash scripts/publish-container.sh
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Publish portable container
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: ${{ gitea.repository }}
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
- name: Install and verify
|
||||||
|
run: |
|
||||||
|
node scripts/check-node-toolchain.mjs
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm biome:lint
|
||||||
|
pnpm test:coverage
|
||||||
|
env:
|
||||||
|
SKIP_ENV_VALIDATION: 1
|
||||||
|
NODE_ENV: test
|
||||||
|
DATABASE_URL: mysql://test:[email protected]:9/test
|
||||||
|
AUTH_SECRET: container-test-secret-at-least-32-characters
|
||||||
|
- name: Build, verify portability and publish
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_SERVER: ${{ gitea.server_url }}
|
||||||
|
REGISTRY_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }}
|
||||||
|
REGISTRY_USER: ${{ secrets.CONTAINER_REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||||
|
run: bash scripts/publish-container.sh
|
||||||
@@ -18,7 +18,8 @@ prod.log
|
|||||||
db_backup_*.sql
|
db_backup_*.sql
|
||||||
|
|
||||||
# Local project documentation
|
# Local project documentation
|
||||||
/docs/
|
/docs/*
|
||||||
|
!/docs/cms-upgrade-2026-09.md
|
||||||
|
|
||||||
# Local gitea binary symlink
|
# Local gitea binary symlink
|
||||||
gitea
|
gitea
|
||||||
@@ -33,11 +34,21 @@ public/tmp/
|
|||||||
|
|
||||||
# Test coverage reports
|
# Test coverage reports
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# Playwright test artifacts
|
|
||||||
test-results/
|
|
||||||
playwright-report/
|
|
||||||
blob-report/
|
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
/public/vendor/tinymce/
|
/public/vendor/tinymce/
|
||||||
|
|
||||||
|
# Shared deployment lock (never application source)
|
||||||
|
.deploy.lock
|
||||||
|
|
||||||
|
# Browser verification artifacts
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
|
||||||
|
# Local Docker installation metadata
|
||||||
|
.docker-install
|
||||||
|
.docker-install.tmp.*
|
||||||
|
.env.install.*
|
||||||
|
build-reports/
|
||||||
|
!/docs/performance-budgets.md
|
||||||
@@ -1 +0,0 @@
|
|||||||
pnpm exec lint-staged
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pnpm typecheck
|
|
||||||
pnpm test
|
|
||||||
@@ -1,43 +1,63 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# node:alpine = latest Node within the supported LTS major (tracks the newest
|
# Pin the runtime to the supported engine; update both stages deliberately.
|
||||||
# patch automatically; currently v26.x, which satisfies package.json's
|
FROM node:26.8.2-alpine AS migrations
|
||||||
# engines ">=26.8.1 <27").
|
|
||||||
FROM node:alpine AS builder
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git
|
# Keep the bootstrap aligned with package.json packageManager.
|
||||||
# entirely (there is no .git in the build context). Defaults to "unknown".
|
# The apk cache is persisted in a BuildKit cache mount so git is not
|
||||||
ARG NEXT_DEPLOYMENT_ID="unknown"
|
# re-downloaded on every build.
|
||||||
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
RUN --mount=type=cache,target=/var/cache/apk \
|
||||||
# pnpm install is always pinned to the version in package.json's
|
apk add --no-cache git \
|
||||||
# `packageManager` field (pnpm auto-selects it on install), so this global
|
&& npm install -g [email protected]
|
||||||
# install only needs to exist as a bootstrap and follows the active major.
|
# The pnpm store is kept in a BuildKit cache mount that persists across builds
|
||||||
RUN apk add --no-cache git \
|
# on the builder. This is what stops disk usage from growing unbounded: the
|
||||||
&& npm install -g pnpm@latest
|
# downloaded dependency store is shared and reused instead of being copied into
|
||||||
|
# a fresh image layer on every build. Unlike an image layer it is also prunable
|
||||||
|
# independently, so a hard cap (see ci-deploy.sh) keeps it bounded.
|
||||||
|
ENV PNPM_HOME=/pnpm PNPM_STORE=/pnpm/store
|
||||||
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
|
||||||
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
|
||||||
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
|
||||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||||
# pnpm fetch: download all deps into $PNPM_STORE first, so only the lockfile
|
# pnpm fetch: download all deps into the shared cache-mounted store.
|
||||||
# change (not source changes) invalidates the network-heavy download layer.
|
RUN --mount=type=cache,target=/pnpm \
|
||||||
RUN pnpm fetch --ignore-scripts
|
pnpm fetch --ignore-scripts
|
||||||
RUN pnpm install --frozen-lockfile --ignore-scripts --offline
|
# Install offline from the cache-mounted store; the store itself stays in the
|
||||||
|
# build cache between builds.
|
||||||
|
RUN --mount=type=cache,target=/pnpm \
|
||||||
|
pnpm install --frozen-lockfile --ignore-scripts --offline
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm run build
|
FROM migrations AS builder
|
||||||
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
|
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
|
||||||
|
# Fixture values exist only for this build command; production secrets are runtime-only.
|
||||||
|
# Cache Next.js build output and webpack caches so rebuilds only redo the
|
||||||
|
# changed parts.
|
||||||
|
RUN --mount=type=cache,target=/app/.next/cache \
|
||||||
|
DATABASE_URL="mysql://build:[email protected]:9/build" \
|
||||||
|
HOTEL_NAME="Build fixture" APP_URL="http://localhost:3002" \
|
||||||
|
AUTH_SECRET="build-fixture-not-for-runtime-use-000000000000" \
|
||||||
|
pnpm run build \
|
||||||
|
&& PERFORMANCE_COMMIT_SHA="$NEXT_DEPLOYMENT_ID" node scripts/performance-report.mjs --output-dir build-reports
|
||||||
|
|
||||||
FROM node:alpine AS runner
|
FROM node:26.8.2-alpine AS runner
|
||||||
|
ARG NEXT_DEPLOYMENT_ID="unknown"
|
||||||
|
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
PORT=3002 \
|
PORT=3002 \
|
||||||
HOSTNAME=0.0.0.0
|
HOSTNAME=0.0.0.0
|
||||||
RUN apk add --no-cache tini \
|
RUN apk add --no-cache tini curl \
|
||||||
&& addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs \
|
&& addgroup -g 33 -S nextjs && adduser -u 33 -S -G nextjs nextjs \
|
||||||
&& mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/Gamedata \
|
&& mkdir -p /app/storage /app/public/nitro-assets /app/public/swf /var/www/Gamedata \
|
||||||
&& chown -R 33:33 /app/storage /app/public /var/www/Gamedata
|
&& chown -R 33:33 /app/storage /app/public /var/www/Gamedata
|
||||||
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
|
COPY --from=builder --chown=nextjs:nextjs /app/public ./public
|
||||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=nextjs:nextjs /app/build-reports ./build-reports
|
||||||
|
COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migrations
|
||||||
|
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3002
|
EXPOSE 3002
|
||||||
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
# Self-contained healthcheck so `docker run` (ci-deploy) also gets Docker-level
|
||||||
@@ -45,4 +65,4 @@ EXPOSE 3002
|
|||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
CMD ["node", "-e", "fetch('http://127.0.0.1:'+(process.env.PORT||'3002')+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
ENTRYPOINT ["/sbin/tini", "--"]
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "docker-start.mjs"]
|
||||||
@@ -16,7 +16,7 @@ Features a premium animated homepage (typewriter hero, floating orbs, scroll cou
|
|||||||
| yarn | >= 4.x | Supported alternative |
|
| yarn | >= 4.x | Supported alternative |
|
||||||
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
| MySQL / MariaDB | 8.0+ / 10.6+ | Shared with the emulator |
|
||||||
| Docker | 24+ | Optional — for containerized deployment |
|
| Docker | 24+ | Optional — for containerized deployment |
|
||||||
| DragonflyDB | 1.x+ | Optional — caching, rate limiting, SSE |
|
| Valkey | 8.x+ | Optional — caching, rate limiting, SSE |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ HOTEL_NAME=YourHotel
|
|||||||
APP_URL=http://localhost:3002
|
APP_URL=http://localhost:3002
|
||||||
```
|
```
|
||||||
|
|
||||||
See `.env.example` for all optional variables (RCON, email, DragonflyDB, OAuth, PayPal, etc.).
|
See `.env.example` for all optional variables (RCON, email, Valkey, OAuth, PayPal, etc.).
|
||||||
|
|
||||||
### 4. Run CMS Migrations
|
### 4. Run CMS Migrations
|
||||||
|
|
||||||
@@ -137,79 +137,224 @@ pm2 stop next 2>/dev/null || true
|
|||||||
|
|
||||||
# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
|
# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
|
||||||
# container user can write imports/uploads to them.
|
# container user can write imports/uploads to them.
|
||||||
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
|
sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
|
||||||
|
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
|
||||||
|
|
||||||
# 4. Build and start
|
# 4. Build, migrate and start a release tied to the Git commit.
|
||||||
docker compose up -d --build
|
# Requires Linux, Bash, Git, flock and Docker Compose v2; no host Node/pnpm.
|
||||||
|
bash scripts/docker-update.sh
|
||||||
|
|
||||||
# 5. Run migrations. The slim runtime image has no source/tsx, so run migrations
|
# 5. Follow logs
|
||||||
# on the HOST (against the same database) before/after starting the container.
|
|
||||||
pnpm db:migrate # or: npm run db:migrate / yarn db:migrate
|
|
||||||
|
|
||||||
# 6. Check logs
|
|
||||||
docker compose logs -f cms
|
docker compose logs -f cms
|
||||||
```
|
```
|
||||||
|
|
||||||
The CMS will be available at `http://localhost:3002`.
|
The CMS listens on port 3002 using Linux host networking. Existing database,
|
||||||
|
Redis, emulator and shared gamedata services must already be configured.
|
||||||
|
Create the mounted directories before installation; do not mount the checkout,
|
||||||
|
`.next` or `node_modules` over `/app` inside the production container.
|
||||||
|
|
||||||
> **Reverse proxy:** This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (`/client/`, `/nitro-client/`) and `/gamedata/` directly from `/var/www/Octane/dist` and `/var/www/Gamedata`, and proxies `/` to the CMS on `127.0.0.1:3002`. Point `APP_URL`/`NEXT_PUBLIC_APP_URL` at the public site URL.
|
### Updating a Docker clone
|
||||||
|
|
||||||
### Automatic Updates
|
From your clone, run:
|
||||||
|
|
||||||
Updating is fully scripted — no need to touch Docker or nginx by hand. The
|
|
||||||
script `scripts/docker-update.sh` pulls the latest `main`, runs CMS migrations
|
|
||||||
on the host, rebuilds the image, recreates the container and waits for a healthy
|
|
||||||
status. It also makes sure a stale host-side PM2 CMS (`pm2 stop next`) stays
|
|
||||||
stopped so it can't clash on port 3002.
|
|
||||||
|
|
||||||
**Automatically (recommended):** a nightly cron job is already configured on a
|
|
||||||
production server that installed this setup. It runs the script every night at
|
|
||||||
03:30 and appends to `logs/docker-update.cron.log`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
crontab -e
|
# Also verifies that your public domain serves the expected commit:
|
||||||
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1
|
CMS_PUBLIC_URL=https://your-hotel.example bash scripts/docker-update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**Manually** — to update right now (same steps as the cron runs):
|
The script follows the **current branch's configured Git upstream**. It refuses
|
||||||
|
local uncommitted/untracked work, pulls fast-forward only and restarts itself if
|
||||||
|
the updater changed. It does not reset or delete local work. Configure the
|
||||||
|
upstream to your fork/branch if that is where you receive updates.
|
||||||
|
|
||||||
|
It builds a commit-tagged image and runs migrations in the matching builder
|
||||||
|
container, using the clone's `.env`; no host dependency installation is needed.
|
||||||
|
Only after build and migrations succeed does it recreate the CMS service. It
|
||||||
|
compares the running image ID, image revision and `/api/health` release with the
|
||||||
|
expected Git commit. With `CMS_PUBLIC_URL`, it also checks the domain through
|
||||||
|
your proxy/CDN. A healthy response from another release is an error.
|
||||||
|
|
||||||
|
`git pull` alone updates source files, not an existing container. `docker compose
|
||||||
|
restart` restarts the same image. `docker compose pull` downloads registry images;
|
||||||
|
it does not fetch changes to this Git-built CMS. Use the updater for releases.
|
||||||
|
A clone does **not** install an automatic scheduler. If desired, explicitly add a
|
||||||
|
cron entry with the absolute path of **your** checkout; keep logs in its `logs`
|
||||||
|
directory. Do not configure both CI and Compose updates for the same instance.
|
||||||
|
The updater refuses an active `epicnext-cms-app` CI-managed container.
|
||||||
|
|
||||||
|
Logs: `logs/docker-update.log`. After cutover, a startup, image or HTTP check
|
||||||
|
failure automatically recreates the CMS with its previous image and verifies its
|
||||||
|
local HTTP release and database health. The command still returns nonzero: a
|
||||||
|
rollback is not a successful update. Recovery uses the current Compose configuration
|
||||||
|
and `.env`; it restores application code, not configuration edits or database migrations.
|
||||||
|
The previous image must carry a valid release label. First installations have no
|
||||||
|
previous release: a failed candidate remains available for diagnosis. A failed
|
||||||
|
rollback is explicitly logged with a retained `epicnext-cms:rollback-...` recovery tag.
|
||||||
|
Public routing must be checked separately after rollback. SIGKILL or host power loss
|
||||||
|
cannot run the rollback handler.
|
||||||
|
|
||||||
|
After success, the updater keeps the two most recent distinct releases tracked in
|
||||||
|
`logs/docker-release-history.log`. Cleanup only removes older recorded CMS image
|
||||||
|
tags, without force; images referenced by other containers (including stopped ones)
|
||||||
|
are preserved and retried on later updates. Untracked historical images, build cache,
|
||||||
|
other services and persistent volumes are not pruned. Retain the history file between
|
||||||
|
updates. Before production migrations, retain your normal database backup.
|
||||||
|
|
||||||
|
### Portable images on the Gitea registry
|
||||||
|
|
||||||
|
Docker builds no longer load your installation's `.env`. The builder uses disposable
|
||||||
|
fixture values; the runtime reads `HOTEL_NAME`, `APP_URL`, `AUTH_SECRET`, database and
|
||||||
|
asset settings when the container starts. Missing/invalid required runtime values
|
||||||
|
stop startup with the setting names, without printing credentials.
|
||||||
|
|
||||||
|
Configure `IMAGER_URL` with the actual avatar renderer endpoint and `BADGE_URL` with
|
||||||
|
the badge directory URL (or a local path). The legacy `NEXT_PUBLIC_IMAGER_URL` and
|
||||||
|
`NEXT_PUBLIC_BADGE_URL` names remain supported at runtime. If the imager points back
|
||||||
|
to the CMS `/imaging` proxy, set `IMAGING_UPSTREAM_URL` to the actual renderer to avoid
|
||||||
|
a loop. With no imager configured, the CMS uses Habbo's public renderer. Client
|
||||||
|
requests use the existing `/api/imaging/avatar` proxy. Admin badges preserve their
|
||||||
|
local `/swf/c_images/album1584` fallback. Public URL resolution uses runtime `APP_URL`.
|
||||||
|
After changing `.env`, recreate the container; an image rebuild is not required.
|
||||||
|
`NEXT_PUBLIC_CMS_RELEASE` deliberately remains compiled into the image.
|
||||||
|
|
||||||
|
### Guided installation from this repository
|
||||||
|
|
||||||
|
On a Linux host with Docker Engine, the Compose plugin, Git and `flock` available:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /var/www/atom-nexst
|
git clone https://gitlab.epicnabbo.nl/remco/EpicNext-Cms.git
|
||||||
./scripts/docker-update.sh
|
cd EpicNext-Cms
|
||||||
# log: logs/docker-update.log
|
bash cms install
|
||||||
```
|
```
|
||||||
|
|
||||||
The script aborts safely (exit 1) if the working tree has uncommitted changes so
|
The wizard asks for your public URL, delivery method, hotel name, existing database
|
||||||
a `git pull` can never clobber local edits, and leaves the container running if
|
connection, Redis and avatar imager. It generates an authentication secret and
|
||||||
health fails so you can debug it (exit 3). Failed runs are reported in the log;
|
creates `.env` with private permissions. An existing `.env` is preserved; review
|
||||||
an exit of 0 means the CMS is healthy on the new commit.
|
its `APP_URL`, `AUTH_URL`, RCON and optional original Laravel `APP_KEY` when moving
|
||||||
|
an existing hotel. This installs the CMS, not the Habbo database, emulator, Redis
|
||||||
|
or reverse proxy. Point HTTPS at port 3002 before running the final public check.
|
||||||
|
The wizard may request sudo to prepare persistent directories for UID/GID 33;
|
||||||
|
it does not recursively change ownership of existing assets.
|
||||||
|
|
||||||
### Docker Commands
|
Choose **prebuilt** to download the images specified by this repository in
|
||||||
|
`docker-image.txt`, without entering a registry namespace or commit tag. Choose
|
||||||
|
**source** when building your own fork on the installation host. Private packages
|
||||||
|
still require `docker login` on that host. Maintainers must update `docker-image.txt`
|
||||||
|
if the publication registry or namespace changes.
|
||||||
|
|
||||||
| Command | Description |
|
After installation, update with:
|
||||||
| ------------------------------------------ | ------------------------------------ |
|
|
||||||
| `docker compose up -d --build` | Build and start in background |
|
|
||||||
| `docker compose down` | Stop and remove containers |
|
|
||||||
| `docker compose logs -f cms` | Follow CMS logs |
|
|
||||||
| `docker compose exec cms sh` | Open a shell in the CMS container |
|
|
||||||
| `docker compose restart cms` | Restart the CMS container |
|
|
||||||
| `docker compose pull && docker compose up -d --build` | Update and redeploy |
|
|
||||||
| `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) |
|
|
||||||
| `./scripts/docker-update.sh` | Full automated update (manual or cron) |
|
|
||||||
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` bulk-load container |
|
|
||||||
|
|
||||||
### How Package Manager Detection Works
|
```bash
|
||||||
|
bash cms update
|
||||||
|
```
|
||||||
|
|
||||||
The Dockerfile checks for lockfiles in this order:
|
The updater pulls the configured Git upstream, loads `.docker-install`, uses the
|
||||||
|
matching application/migration images and verifies the running release locally
|
||||||
|
and at the saved public URL. If publication for the new commit is still running,
|
||||||
|
it stops before replacing the current container; run the same command after CI
|
||||||
|
succeeds. Existing application rollback remains available on a failed cutover;
|
||||||
|
database migrations are not reversed.
|
||||||
|
|
||||||
1. **`pnpm-lock.yaml`** → uses pnpm (fastest, recommended)
|
`bash cms install --configure-only` saves configuration without preparing runtime
|
||||||
2. **`yarn.lock`** → uses yarn
|
directories or starting containers. Neither `.env` nor `.docker-install` is
|
||||||
3. **`package-lock.json`** → uses npm
|
committed or sent in Docker build contexts. Existing users of
|
||||||
4. **No lockfile** → falls back to `npm install`
|
`bash scripts/docker-update.sh` retain the previous behavior when no wizard
|
||||||
|
profile exists; explicit `CMS_IMAGE_REPOSITORY`/`CMS_PUBLIC_URL` overrides still work.
|
||||||
|
|
||||||
This means you can use any package manager on your host machine — the Docker build will automatically match.
|
To publish from Gitea:
|
||||||
|
|
||||||
> Override the detection explicitly with `docker compose build --build-arg PACKAGE_MANAGER=pnpm` (or `npm` / `yarn`).
|
Gitea packages belong to an account or organization, independently of repository
|
||||||
|
permissions. Publication defaults to the lowercase `CONTAINER_REGISTRY_USER`
|
||||||
|
namespace, so a Simo token publishes `simo/epicnext-cms` even though the Git
|
||||||
|
repository belongs to remco. Set the Actions variable
|
||||||
|
`CONTAINER_REGISTRY_NAMESPACE` only to override this (for example, an organization
|
||||||
|
where the token account has package write access). Keep the login username and
|
||||||
|
token from the same account. Changing namespace also changes the image URL used
|
||||||
|
by installations; existing remco image tags are not moved automatically.
|
||||||
|
|
||||||
|
1. In the repository's Actions secrets, configure `CONTAINER_REGISTRY_USER` and
|
||||||
|
`CONTAINER_REGISTRY_TOKEN`. Use a Gitea access token with package read/write
|
||||||
|
permission belonging to the login account. For the Simo token, set
|
||||||
|
`CONTAINER_REGISTRY_USER=Simo`; the default package namespace will be `simo`.
|
||||||
|
2. Every push to `main` or `master` automatically builds and publishes the images
|
||||||
|
after the CI checks and production deployment succeed. Pull requests do not
|
||||||
|
publish images. The publication job builds from committed source only and checks
|
||||||
|
the same application image with two runtime configurations before pushing.
|
||||||
|
Missing registry secrets fail the publication job explicitly; they do not undo
|
||||||
|
an already successful production deployment. No `latest` tag is moved.
|
||||||
|
**Publish portable container** remains available for manual retries on the
|
||||||
|
commit/branch to distribute, without redeploying production.
|
||||||
|
3. The images are `<gitea-host>/<owner>/<repository-lowercase>:<full-commit>` and
|
||||||
|
`:<full-commit>-migrations`. Only the application image runs the website; the
|
||||||
|
migrations image is used temporarily for the matching database migrations.
|
||||||
|
|
||||||
|
Publication uses checksum-pinned regctl v0.11.6 with 8 MiB blob requests to
|
||||||
|
avoid monolithic layer uploads exceeding reverse-proxy limits. Both images are
|
||||||
|
exported and uploaded sequentially; temporary archives and credentials are removed
|
||||||
|
on exit. The runner needs curl, sha256sum and temporary disk space for one Docker
|
||||||
|
image archive plus its extracted OCI layout. The remote image config digest is checked against the locally normalized archive
|
||||||
|
after each upload. A proxy must still allow the OCI registry PATCH/PUT endpoints.
|
||||||
|
|
||||||
|
For this repository the image base is
|
||||||
|
`gitlab.epicnabbo.nl/simo/epicnext-cms`. Package access is controlled by Gitea.
|
||||||
|
For private packages, run `docker login gitlab.epicnabbo.nl` on the installation
|
||||||
|
with a token that can read packages. Then update with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CMS_IMAGE_REPOSITORY=gitlab.epicnabbo.nl/simo/epicnext-cms \
|
||||||
|
CMS_PUBLIC_URL=https://your-hotel.example \
|
||||||
|
bash scripts/docker-update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The updater pulls the configured Git upstream and requires both images for that
|
||||||
|
exact commit. A missing image or failed login stops before replacing the running
|
||||||
|
CMS. Local builds remain the default when `CMS_IMAGE_REPOSITORY` is unset. Both
|
||||||
|
paths retain the existing image/HTTP checks and automatic application rollback.
|
||||||
|
Migration secrets are mounted read-only for the temporary migration container;
|
||||||
|
they are never copied into its image. Registry images currently target the Linux
|
||||||
|
architecture of the self-hosted build runner; this is not a multi-architecture release.
|
||||||
|
|
||||||
|
The portability gate checks release identity, runtime avatar/badge routing and
|
||||||
|
absence of installation environment files in the application image. It uses an
|
||||||
|
unreachable fixture database and does not replace a full live database/site smoke
|
||||||
|
test. See `scripts/verify-portable-image.mjs`. Production deployment remains verified
|
||||||
|
separately by the existing CI workflow.
|
||||||
|
|
||||||
|
References: [Gitea container registry](https://docs.gitea.com/usage/packages/container/)
|
||||||
|
and [Next.js runtime environment variables](https://nextjs.org/docs/app/guides/self-hosting).
|
||||||
|
|
||||||
|
### Diagnose an update that is not visible
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rev-parse HEAD
|
||||||
|
docker inspect --format '{{.Image}}' epicnext-cms
|
||||||
|
docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' epicnext-cms
|
||||||
|
curl -fsS http://127.0.0.1:3002/api/health
|
||||||
|
curl -fsS https://your-hotel.example/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Both HTTP responses must report the expected `release`. `unknown` means the image
|
||||||
|
was built without a commit ID. If the local endpoint is current but the public
|
||||||
|
one is old, check nginx's upstream, other CMS processes and proxy/CDN caching.
|
||||||
|
Health responses must not be cached. The release is compiled into Next.js and
|
||||||
|
cannot be changed simply by injecting a new variable into an old container.
|
||||||
|
|
||||||
|
### Docker commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `bash scripts/docker-update.sh` | Pull, build, migrate, recreate and verify |
|
||||||
|
| `docker compose logs -f cms` | View CMS logs |
|
||||||
|
| `docker compose exec cms sh` | Open a shell in the running CMS |
|
||||||
|
| `docker compose restart cms` | Restart the existing release |
|
||||||
|
| `docker compose down` | Stop services; does not update code |
|
||||||
|
| `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service |
|
||||||
|
|
||||||
|
For a manual build, export `CMS_RELEASE=$(git rev-parse HEAD)` before
|
||||||
|
`docker compose build cms`; deployment and migration verification remain your
|
||||||
|
responsibility. Docker uses the committed pnpm lockfile and pinned Node version.
|
||||||
|
The build cache can stay enabled: copying changed source invalidates the
|
||||||
|
application build layer. Deleting all Docker cache is not an update mechanism.
|
||||||
|
|
||||||
### Volumes
|
### Volumes
|
||||||
|
|
||||||
@@ -235,7 +380,8 @@ Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#av
|
|||||||
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
|
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
|
sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
|
||||||
|
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
|
||||||
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
|
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -247,7 +393,7 @@ docker compose build --build-arg RUN_USER=1000
|
|||||||
|
|
||||||
### Networking
|
### Networking
|
||||||
|
|
||||||
The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), DragonflyDB/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it.
|
The CMS container runs with `network_mode: host`. This lets the existing `127.0.0.1` references in `.env` keep working against host services — MariaDB (`3306`), Valkey/Redis (`6379`), the emulator RCON/API (`3003`/`3001`) and the avatar imager — without re-writing any environment variables. The container consequently listens **directly on the host's port 3002**, so stop any previous host-side CMS (e.g. `pm2 stop next`) that occupies that port before starting it.
|
||||||
|
|
||||||
The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
|
The **build** also runs on the host network (`build.network: host`) because this host disables Docker iptables (`/etc/docker/daemon.json`: `"iptables": false`), which would otherwise leave build containers without outbound NAT/DNS when fetching packages.
|
||||||
|
|
||||||
@@ -361,28 +507,35 @@ The script sets `FOREIGN_KEY_CHECKS=0` for the session and is safe to interrupt:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## DragonflyDB (Caching, Rate Limiting, SSE)
|
## Valkey (Caching, Rate Limiting, SSE)
|
||||||
|
|
||||||
DragonflyDB is a drop-in, Redis-compatible in-memory datastore. The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-process memory.
|
Valkey is a drop-in, Redis-compatible open-source in-memory datastore (successor to Redis OSS). The CMS connects to it via `REDIS_URL` using the `ioredis` client. It is optional: without it the CMS falls back to in-memory memory.
|
||||||
|
|
||||||
### Install (Ubuntu/Debian)
|
### Install (Ubuntu/Debian)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL -o /tmp/dragonfly_amd64.deb \
|
# Option 1: Package repository (recommended)
|
||||||
https://github.com/dragonflydb/dragonfly/releases/download/v1.40.1/dragonfly_amd64.deb
|
curl -fsSL https://packages.valkey.io/valkey-pgp-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/valkey-archive-keyring.gpg
|
||||||
apt-get install -y /tmp/dragonfly_amd64.deb
|
echo "deb [signed-by=/usr/share/keyrings/valkey-archive-keyring.gpg] https://packages.valkey.io/apt $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/valkey.list
|
||||||
systemctl enable --now dragonfly
|
apt-get update && apt-get install -y valkey
|
||||||
|
|
||||||
|
# Option 2: Direct .deb download
|
||||||
|
curl -fsSL -o /tmp/valkey_amd64.deb \
|
||||||
|
https://github.com/valkey-io/valkey/releases/download/8.1.1/valkey_8.1.1-1_amd64.deb
|
||||||
|
apt-get install -y /tmp/valkey_amd64.deb
|
||||||
|
|
||||||
|
systemctl enable --now valkey
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure
|
### Configure
|
||||||
|
|
||||||
Edit `/etc/dragonfly/dragonfly.conf`:
|
Edit `/etc/valkey/valkey.conf`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
--bind=127.0.0.1
|
bind 127.0.0.1
|
||||||
--port=6379
|
port 6379
|
||||||
--maxmemory=2gb
|
maxmemory 2gb
|
||||||
--version_check=false
|
maxmemory-policy allkeys-lru
|
||||||
```
|
```
|
||||||
|
|
||||||
### Point the CMS at it
|
### Point the CMS at it
|
||||||
@@ -588,7 +741,7 @@ For bulk-written tables (game-data JSON, imports) a containerized **`mariadb-tur
|
|||||||
|
|
||||||
### 4. Cache layers (Redis + CDN)
|
### 4. Cache layers (Redis + CDN)
|
||||||
|
|
||||||
The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (DragonflyDB-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further.
|
The CMS caches through `cached()` / `cachedQuery()` (`src/lib/cache.ts`): an in-memory fast path with a Redis (Valkey-compatible) fallback and single-flight protection against cache stampedes. Public API routes (home, leaderboard, online count, radio, photos, …) fill Redis keys per route; verify with `redis-cli DBSIZE`. With Cloudflare in front, static and `/imaging/*` responses are additionally cached edge-side (`cf-cache-status`), cutting origin load further.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -645,7 +798,7 @@ pm2 restart next
|
|||||||
| **View Transitions API** | Native browser transitions between page navigations |
|
| **View Transitions API** | Native browser transitions between page navigations |
|
||||||
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
|
| **Lenis Smooth Scroll** | Fluid, customizable scrolling |
|
||||||
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
|
| **Server-Sent Events** | Real-time radio now-playing & listeners via SSE |
|
||||||
| **DragonflyDB Caching** | Caches API responses up to 30s in DragonflyDB |
|
| **Valkey Caching** | Caches API responses up to 30s in Valkey |
|
||||||
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
| **RCON (TCP Socket)** | Live commands to the emulator (credits, badges, kick, ban) |
|
||||||
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
| **Streaming & Suspense** | Next.js App Router streaming for fast page loads |
|
||||||
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
| **Automatic Image Optimization** | `next/image` with Sharp for resizing and WebP/AVIF |
|
||||||
@@ -680,7 +833,7 @@ pm2 restart next
|
|||||||
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
│ │ ├── auth/ # NextAuth, password hashing, 2FA, SSO tickets
|
||||||
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
│ │ ├── services/ # RCON, email, currency, PayPal, alerts
|
||||||
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
│ │ ├── db.ts # Drizzle connection singleton (runtime)
|
||||||
│ │ ├── redis.ts # Cache client (ioredis → DragonflyDB)
|
│ │ ├── redis.ts # Cache client (ioredis → Valkey)
|
||||||
│ │ └── motion.ts # Framer Motion animation variants
|
│ │ └── motion.ts # Framer Motion animation variants
|
||||||
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
|
│ ├── messages/ # i18n translations (en, nl, de, fr, es, it, pt, da, no, sv)
|
||||||
│ └── env.ts # Zod-validated environment schema
|
│ └── env.ts # Zod-validated environment schema
|
||||||
@@ -727,16 +880,16 @@ pnpm jobs:worker # or: npm run jobs:worker / yarn jobs:worker
|
|||||||
|
|
||||||
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
Optional OpenAI-powered moderation for comments and guestbook posts. Set `OPENAI_API_KEY`.
|
||||||
|
|
||||||
### FlareSolverr (Cloudflare Bypass)
|
### Byparr (Cloudflare Bypass)
|
||||||
|
|
||||||
Some clone sources are protected by Cloudflare. FlareSolverr solves these challenges automatically.
|
Some clone sources are protected by Cloudflare. Byparr (a FlareSolverr successor) solves these challenges automatically.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d flaresolverr
|
docker compose up -d byparr
|
||||||
```
|
```
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
FLARESOLVERR_URL=http://localhost:8191
|
BYPARR_URL=http://localhost:8191
|
||||||
```
|
```
|
||||||
|
|
||||||
### Furniture Import with Auto-Translation
|
### Furniture Import with Auto-Translation
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": ["**", "!setup", "!*.cjs", "!coverage"]
|
"includes": ["**", "!setup", "!*.cjs", "!coverage", "!drizzle/drafts/meta"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
case "${1:-help}" in
|
||||||
|
install) shift; exec bash "$DIR/scripts/docker-install.sh" "$@" ;;
|
||||||
|
update) shift; [[ $# = 0 ]] || { echo "Usage: bash cms update" >&2; exit 1; }; exec bash "$DIR/scripts/docker-update.sh" ;;
|
||||||
|
help|--help|-h) printf '%s\n' 'bash cms install Configure and install on a Linux Docker host' 'bash cms update Update using the saved installation settings' ;;
|
||||||
|
*) echo "Unknown command. Use: bash cms install | update" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
services:
|
services:
|
||||||
cms:
|
cms:
|
||||||
|
image: epicnext-cms:${CMS_RELEASE:-local}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# The host disables Docker iptables (daemon.json: "iptables": false), so
|
args:
|
||||||
# build containers on the bridge network have no outbound NAT/DNS. Build on
|
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
|
||||||
# the host network instead so pnpm/npm/yarn can reach the registry.
|
|
||||||
network: host
|
network: host
|
||||||
container_name: epicnext-cms
|
container_name: epicnext-cms
|
||||||
# Runs on the host network so existing 127.0.0.1 refs in .env keep working:
|
|
||||||
# MariaDB (3306), DragonflyDB/Redis (6379), emulator RCON (3003) + API (3001),
|
|
||||||
# and the imaging renderer (8082). The container then listens directly on the
|
|
||||||
# host's 3002 (the same port the current host-side CMS uses).
|
|
||||||
# NOTE: stop the host CMS (next-server on 3002) first, otherwise the port is taken.
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
@@ -20,32 +15,14 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
volumes:
|
volumes:
|
||||||
# ── Write targets (runtime imports/uploads, persistent on the host) ──
|
|
||||||
# The CMS writes imported furni/figures/pets/effects here (see
|
|
||||||
# src/lib/services/furni-asset-dirs.ts). These must be RW, and owned by
|
|
||||||
# UID/GID 33 (www-data) on the host so the container user can write to them:
|
|
||||||
# sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
|
|
||||||
- ./public/nitro-assets:/app/public/nitro-assets
|
- ./public/nitro-assets:/app/public/nitro-assets
|
||||||
- ./public/swf:/app/public/swf
|
- ./public/swf:/app/public/swf
|
||||||
# Runtime uploaded media (persistent on the host).
|
|
||||||
- ./storage:/app/storage
|
- ./storage:/app/storage
|
||||||
|
|
||||||
# ── Shared gamedata (absolute path the CMS hardcodes & writes to) ──
|
|
||||||
# src/lib/services/furni-asset-dirs.ts: `DEFAULT_GAMEDATA_ROOT =
|
|
||||||
# /var/www/Gamedata`. nginx on the host also serves /gamedata/ from this
|
|
||||||
# same directory, so mount it into the container at the same absolute path.
|
|
||||||
# Must be RW so furniture/badge imports can write mirrors to it.
|
|
||||||
- /var/www/Gamedata:/var/www/Gamedata
|
- /var/www/Gamedata:/var/www/Gamedata
|
||||||
#
|
|
||||||
# ── node_modules corruption (§ host-sync) ──
|
# ── Resource limits aangepast voor 24 GB RAM ──
|
||||||
# pnpm node_modules must NEVER be a host bind-mount: shared-filesystem
|
# Verwijderd: mem_limit, memswap_limit en cpus. Next.js mag onbeperkt presteren.
|
||||||
# sync (Docker Desktop gRPC-FUSE/VirtioFS, Unison, Syncthing) corrupts
|
pids_limit: 1024 # Verhoogd van 512 zodat Node.js/Next.js oneindig veel async requests aankan
|
||||||
# pnpm's hardlinked store and .bin shims. The production image already
|
|
||||||
# bakes node_modules in at build time and is NOT bind-mounted here.
|
|
||||||
# For local dev use a *named volume* instead of a host bind:
|
|
||||||
# - node_modules:/app/node_modules
|
|
||||||
# and never share `node_modules` / `pnpm store` via the Docker bind.
|
|
||||||
# On macOS add `:cached`/`:delegated` consistency labels per mount.
|
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3002/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
@@ -53,57 +30,28 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
mem_limit: 2g
|
|
||||||
|
|
||||||
# ── FlareSolverr (Cloudflare bypass for clone sources) ──
|
# ── Byparr (Cloudflare bypass for clone sources) ──
|
||||||
# Solves Cloudflare/TLS-fingerprint blocks via a headless Chrome browser.
|
byparr:
|
||||||
# The CMS calls this on http://localhost:8191 for sources that block Node's
|
image: ghcr.io/thephaseless/byparr:latest
|
||||||
# TLS fingerprint (e.g. Leet.city). Used by src/lib/services/flare-solver.ts.
|
container_name: byparr
|
||||||
flaresolverr:
|
|
||||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
|
||||||
container_name: flaresolverr
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=INFO
|
||||||
- BROWSER_TIMEOUT=60000
|
|
||||||
- BROWSER_WORKERS=1
|
# Resource limits verwijderd: Headless Chrome heeft bij zware pagina-scrapes
|
||||||
mem_limit: 2g
|
# soms tijdelijk meer dan 1 GB RAM nodig. Nu krijgt hij alle ruimte.
|
||||||
|
pids_limit: 256
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-sf", "http://localhost:8191/health"]
|
test: ["CMD", "curl", "http://localhost:8191/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
# ── Opt-in: Octane-Renderer (Habbo avatar imager) ──
|
|
||||||
# Serves /imaging on port 3030 (the CMS proxies /imaging to it). Renders
|
|
||||||
# avatars into /var/www/Gamedata/habbo-imaging, so it needs RW access.
|
|
||||||
# Disabled by default — uncomment to run the renderer as a container.
|
|
||||||
# imager:
|
|
||||||
# build:
|
|
||||||
# context: /var/www/Octane-Renderer
|
|
||||||
# container_name: epicnext-octane-renderer
|
|
||||||
# ports:
|
|
||||||
# - "3030:3030"
|
|
||||||
# restart: unless-stopped
|
|
||||||
# volumes:
|
|
||||||
# - /var/www/Gamedata:/var/www/Gamedata
|
|
||||||
|
|
||||||
# ── MariaDB "Turbo" (heavy JSON / bulk-loads) ──
|
# ── MariaDB "Turbo" (heavy JSON / bulk-loads) ──
|
||||||
# Dedicated MariaDB tuned for >50 MB JSON imports. All tuning lives inline
|
|
||||||
# in the service `command:` (bulk_insert_buffer_size,
|
|
||||||
# innodb_flush_log_at_trx_commit=2, max_allowed_packet=512M, ...) so the
|
|
||||||
# container configures itself — no external .cnf to mount or keep in sync.
|
|
||||||
# Opt-in via profile so `docker compose up` keeps running the standalone
|
|
||||||
# stack as today.
|
|
||||||
#
|
|
||||||
# Start: docker compose --profile db up -d (= pnpm db:up)
|
|
||||||
# Note: host networking binds 127.0.0.1:3306 → STOP the host MariaDB
|
|
||||||
# first (the app's .env DATABASE_URL points at localhost:3306).
|
|
||||||
# Fixes the "Pulling schema from database..." hang: raise
|
|
||||||
# net_read/net_write_timeout (done above) + stop imports while
|
|
||||||
# running `pnpm db:generate` / drizzle-kit introspection.
|
|
||||||
mariadb-turbo:
|
mariadb-turbo:
|
||||||
image: mariadb:11
|
image: mariadb:11
|
||||||
container_name: mariadb-turbo
|
container_name: mariadb-turbo
|
||||||
@@ -111,31 +59,23 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# mariadb:11 uses MARIADB_*; MYSQL_* also works but is deprecated there.
|
|
||||||
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-root}
|
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD:-root}
|
||||||
- MARIADB_DATABASE=${MARIADB_DATABASE:-habbo}
|
- MARIADB_DATABASE=${MARIADB_DATABASE:-habbo}
|
||||||
- MARIADB_USER=${MARIADB_USER:-cms}
|
- MARIADB_USER=${MARIADB_USER:-cms}
|
||||||
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-cms}
|
- MARIADB_PASSWORD=${MARIADB_PASSWORD:-cms}
|
||||||
- MARIADB_AUTO_UPGRADE=1
|
- MARIADB_AUTO_UPGRADE=1
|
||||||
# MariaDB tunes itself — the Official image appends these flags to mysqld,
|
|
||||||
# no external .cnf file needed.
|
|
||||||
command: [
|
command: [
|
||||||
# Charset
|
|
||||||
"--character-set-server=utf8mb4",
|
"--character-set-server=utf8mb4",
|
||||||
"--collation-server=utf8mb4_unicode_ci",
|
"--collation-server=utf8mb4_unicode_ci",
|
||||||
# 50 MB JSON batches: raise the 16 MB default packet ceiling.
|
|
||||||
"--max-allowed-packet=512M",
|
"--max-allowed-packet=512M",
|
||||||
"--net-buffer-length=1M",
|
"--net-buffer-length=1M",
|
||||||
# Timeouts — fixes the "Pulling schema from database..." hang
|
|
||||||
# (drizzle-kit introspection no longer starves behind big imports).
|
|
||||||
"--connect-timeout=30",
|
"--connect-timeout=30",
|
||||||
"--wait-timeout=3600",
|
"--wait-timeout=3600",
|
||||||
"--interactive-timeout=3600",
|
"--interactive-timeout=3600",
|
||||||
"--net-read-timeout=600",
|
"--net-read-timeout=600",
|
||||||
"--net-write-timeout=600",
|
"--net-write-timeout=600",
|
||||||
# InnoDB: durability/performance trade-off for bulk writes.
|
|
||||||
"--innodb-flush-log-at-trx-commit=2",
|
"--innodb-flush-log-at-trx-commit=2",
|
||||||
"--innodb-buffer-pool-size=2G",
|
"--innodb-buffer-pool-size=2G", # Perfect ingesteld voor een 24 GB server!
|
||||||
"--innodb-buffer-pool-instances=4",
|
"--innodb-buffer-pool-instances=4",
|
||||||
"--innodb-log-file-size=1G",
|
"--innodb-log-file-size=1G",
|
||||||
"--innodb-log-buffer-size=64M",
|
"--innodb-log-buffer-size=64M",
|
||||||
@@ -143,35 +83,27 @@ services:
|
|||||||
"--innodb-autoextend-increment=512",
|
"--innodb-autoextend-increment=512",
|
||||||
"--innodb-max-dirty-pages-pct=90",
|
"--innodb-max-dirty-pages-pct=90",
|
||||||
"--bulk-insert-buffer-size=512M",
|
"--bulk-insert-buffer-size=512M",
|
||||||
# Raise so the engine nearly never double-writes during a 50 MB load.
|
|
||||||
"--innodb-doublewrite=0",
|
"--innodb-doublewrite=0",
|
||||||
# libaio/native_aio misbehaves in some containers; io_uring path is fine.
|
|
||||||
"--innodb-use-native-aio=0",
|
"--innodb-use-native-aio=0",
|
||||||
# Temp tables used when MariaDB scans JSON blobs cannot be indexed.
|
|
||||||
"--tmp-table-size=256M",
|
"--tmp-table-size=256M",
|
||||||
"--max-heap-table-size=256M",
|
"--max-heap-table-size=256M",
|
||||||
"--read-buffer-size=4M",
|
"--read-buffer-size=4M",
|
||||||
"--read-rnd-buffer-size=16M",
|
"--read-rnd-buffer-size=16M",
|
||||||
# Save ~1-2 GB RAM; bulk loads don't need the instrumentation.
|
|
||||||
"--performance-schema=OFF",
|
"--performance-schema=OFF",
|
||||||
"--skip-name-resolve",
|
"--skip-name-resolve",
|
||||||
]
|
]
|
||||||
volumes:
|
volumes:
|
||||||
# Named volume, NOT a host bind — shared-filesystem sync trashes InnoDB
|
|
||||||
# files the same way it trashes pnpm's node_modules. Named volumes live
|
|
||||||
# inside the container filesystem, so 50 MB of JSON writes never cross a
|
|
||||||
# host-sync boundary.
|
|
||||||
- mariadb-turbo-data:/var/lib/mysql
|
- mariadb-turbo-data:/var/lib/mysql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# healthcheck.sh ships in the official mariadb image.
|
|
||||||
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized || mariadb-admin ping --silent"]
|
test: ["CMD-SHELL", "healthcheck.sh --connect --innodb_initialized || mariadb-admin ping --silent"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
mem_limit: 4g
|
|
||||||
|
|
||||||
# Named volumes declared once; used by the MariaDB service above.
|
# Geoptimaliseerd: We verhogen de limiet naar 6 GB of halen hem volledig weg,
|
||||||
|
# zodat de InnoDB buffer pool en zware JSON imports nooit Out Of Memory gaan.
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mariadb-turbo-data:
|
mariadb-turbo-data:
|
||||||
driver: local
|
driver: local
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
gitlab.epicnabbo.nl/simo/epicnext-cms
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# CMS upgrade — September 2026
|
||||||
|
|
||||||
|
## Operator changes
|
||||||
|
|
||||||
|
| Area | Behavior and location |
|
||||||
|
| --- | --- |
|
||||||
|
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
|
||||||
|
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
|
||||||
|
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
|
||||||
|
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
|
||||||
|
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
|
||||||
|
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
|
||||||
|
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
|
||||||
|
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
|
||||||
|
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
|
||||||
|
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
|
||||||
|
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
|
||||||
|
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
|
||||||
|
|
||||||
|
## Deployment requirements
|
||||||
|
|
||||||
|
- Apply migration `0026_article_editor_recovery.sql` through `pnpm db:migrate` before enabling the new news editor. It adds private drafts and revision tables without modifying existing articles.
|
||||||
|
- Keep `storage` persistent and writable. Error assignments and import cancellation markers use the existing shared storage.
|
||||||
|
- Run the existing `pnpm jobs:worker` process with the installation configuration. It now publishes a heartbeat to Redis every minute. A web process alone does not establish that scheduled-news jobs are running.
|
||||||
|
- The deploy runner installs Chromium before cutover. Browser checks visit only public login/news/staff pages; they do not create production content or authenticate staff. The host must satisfy Chromium system-library requirements.
|
||||||
|
- Use the existing Gitea registry secrets. The CMS does not read or display those credentials.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Operations is a bounded operational summary: errors use at most 1,000 retained events and imports use the latest 30 owned jobs. Empty checked records do not prove that all historical work is resolved.
|
||||||
|
- Import history bounds payloads and concurrent file reads. Directory metadata scanning and worker enumeration still scale with stored history.
|
||||||
|
- Redis lease checks and file saves are separate operations. They reduce stale writes but do not provide atomic fencing across Redis, SQL and filesystem operations. An import interrupted after SQL may require local-data inspection.
|
||||||
|
- Revision history displays the latest 20 saved versions; revisions are retained in the database. New-article recovery has one private slot per staff account.
|
||||||
|
- The database connection probe is a measurement, not a performance benchmark. No throughput or latency improvement is claimed without production measurements.
|
||||||
|
- A rollback restores an application image; it does not reverse database migrations. The new tables are additive.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Local verification on 2026-09-09:
|
||||||
|
|
||||||
|
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
|
||||||
|
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
|
||||||
|
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
|
||||||
|
- Translation audit: no invalid ICU messages, variable mismatches or missing static references. Existing locale gaps and 1,560 hardcoded-text candidates still need editorial work; they are not silently marked translated.
|
||||||
|
- Headless Edge verification of actual shared components with compiled CSS and the CMS theme at widths 1,280 and 390 pixels: the switch changes state and thumb position, the dialog stays within the 720px viewport, the final button is reachable, and the background page does not scroll. This isolated fixture does not establish full authenticated-page parity.
|
||||||
|
- Deployment rollback, verified-digest publication, ownership/cancellation and concurrent news edit behavior have focused regression tests.
|
||||||
|
|
||||||
|
Docker runtime, database migration execution and authenticated browser flows still require an integration environment. Check the Gitea pipeline and live release identifier after publication. A successful local build is not production verification.
|
||||||
|
|
||||||
|
|
||||||
|
## Catalog packages
|
||||||
|
|
||||||
|
The normal catalog toolbar now opens a dedicated Catalog packages dialog.
|
||||||
|
Create a named draft from selected categories and their descendants, either to
|
||||||
|
update those categories or copy them under a chosen parent. Drafts are shared
|
||||||
|
with authorized staff; saving a draft does not modify the live catalog.
|
||||||
|
|
||||||
|
Edit category metadata and offer prices, or review bulk price changes before
|
||||||
|
applying them to the draft. The catalog preview supports category navigation,
|
||||||
|
search, real local furniture icons and rank/Club/VIP access simulation. It does
|
||||||
|
not render the game client or evaluate ancestors outside the selected package;
|
||||||
|
special layouts and that access limitation are disclosed in the preview.
|
||||||
|
|
||||||
|
Publication requires a saved draft and a fresh review. Concurrent source changes
|
||||||
|
block publication; version checks prevent one editor overwriting another.
|
||||||
|
Copying preserves the underlying category and offer fields and remaps internal
|
||||||
|
references while retaining furniture IDs and assets. The catalog writes and
|
||||||
|
published result are committed together; retrying the same published package
|
||||||
|
does not copy it again. Failures in hotel notifications, audit or Git export
|
||||||
|
scheduling after commit are reported as warnings rather than failed publication.
|
||||||
|
|
||||||
|
Apply additive migration `0027_catalog_packages.sql` before opening this tool.
|
||||||
|
Existing migration automation discovers the file. Limits are 200 categories,
|
||||||
|
500 offers and 8 MB of package data. Package source checks inspect at most 20,000
|
||||||
|
catalog categories. Publication briefly locks category rows while validating and
|
||||||
|
writing changes, so large live catalogs should be checked under realistic load.
|
||||||
|
English, Italian and Dutch copy is provided; other locales use the new English
|
||||||
|
strings pending translation. No new dependency is required.
|
||||||
|
|
||||||
|
Validation: 1,506 tests passed (six skipped), type checking, lint, translation
|
||||||
|
contracts and a production build with fixture configuration. A browser fixture
|
||||||
|
verified the real dialog at 1280 and 390 pixels; server actions were simulated.
|
||||||
|
The new database migration and package publication have not run in production.
|
||||||
|
|
||||||
|
## Housekeeping search and user overview
|
||||||
|
|
||||||
|
The existing global search now includes furniture, normal/Club catalog categories
|
||||||
|
and both ticket sources. Results respect module permissions, accept single-digit
|
||||||
|
IDs, and remain usable when one source fails. Keyboard navigation and cancellation
|
||||||
|
prevent stale search responses from replacing newer results.
|
||||||
|
|
||||||
|
User details open on an operational overview with up to five records from each
|
||||||
|
authorized source: bans, active mute, support tickets, help tickets, reports,
|
||||||
|
payments, catalog purchases and audit activity. Failed sources are distinguished
|
||||||
|
from empty results. User detail and edit pages also apply log permissions before
|
||||||
|
loading activity and exposing counters.
|
||||||
|
|
||||||
|
Italian and Dutch navigation, user management, news and support labels were
|
||||||
|
reviewed. The new search and overview copy has English, Italian and Dutch text;
|
||||||
|
other locales receive English fallback strings. This is a focused editorial pass,
|
||||||
|
not a full translation of every CMS page. No database migration or dependency
|
||||||
|
change is required. Browser checks use real components with simulated data at
|
||||||
|
1280 and 390 pixels; production database behavior still needs deployment validation.
|
||||||
|
|
||||||
|
## Operational reliability and recovery
|
||||||
|
|
||||||
|
- Audit history now records category settings (normal/Club), individual/bulk offer prices, update-mode package publication and news edits inside the write transaction. The audit screen previews and restores individual changes after locking and comparing the current recorded fields. Deleted records, hierarchy changes, LTD counters, imports and historical entries without complete snapshots cannot be restored. Restores create their own history entry. Apply migration `0028_history_snapshots.sql` before running this version: it widens audit snapshots to MEDIUMTEXT without deleting existing data.
|
||||||
|
- `/admin/operations` reuses durable import jobs, stable history pagination and owner-scoped failed-item retries. A deterministic child ID prevents duplicate retries. The shared Git export queue displays actual pending/running state and latest result; synchronous/SSE synchronization remains linked rather than represented as a durable job history.
|
||||||
|
- Catalog maintenance includes a read-only integrity report for normal/Club categories, offers, furniture references and local icons. Known sentinel IDs are preserved. Only categories pointing to a missing positive parent have an automated repair: preview lists every affected category and apply compares the locked graph before reattaching those categories at the root. No records are deleted. Other issues require an explicit manual edit. Reports display up to 200 issues with complete counts; unavailable icon storage is distinguished from missing assets.
|
||||||
|
- CMS errors support exact release and time filters plus frequency sorting. Counts refer to retained matching events, while group resolution remains current across releases.
|
||||||
|
- User/settings forms now protect unsaved edits and preserve failed submissions. User/news validation errors appear at the affected fields; settings show returned validation errors inline. Existing submission locking is retained and tested in a browser.
|
||||||
|
- `/admin/permissions/preview` shows one role's section access and known CMS grants using live ACL and the existing highest-rank policy. It never changes sessions. Additional user roles, navigation customization and record-specific authorization remain explicit limits of the preview.
|
||||||
|
|
||||||
|
New UI copy is supplied in English, Italian and Dutch; other locales receive English fallback strings. No new runtime dependencies. Browser fixtures use real UI components with simulated server responses; the database migration and production behavior have not been exercised on the live hotel.
|
||||||
|
|
||||||
|
Validation for this increment: 1,589 tests passed, six skipped; TypeScript, Biome, translation contracts and fixture production build passed. Browser checks covered user/settings/news forms, permission preview, CMS errors, integrity preview and history restore at 1280 and 390 pixels. Double submission, stale preview, blocked navigation, field focus and horizontal overflow were checked with simulated server actions. No live database writes or deployment were performed.
|
||||||
|
|
||||||
|
|
||||||
|
## September 11: public pages and staff workflows
|
||||||
|
|
||||||
|
- Docker stages now follow the exact `.nvmrc` release, enforced by the toolchain check.
|
||||||
|
- `/news` supports search, ordering by effective publication date and real pagination.
|
||||||
|
- `/events` supports upcoming/ongoing/completed filters, explicit UTC week windows, local displayed times and personal registrations.
|
||||||
|
- `/search` searches users, open rooms, published news and events with independent pagination and partial failure states.
|
||||||
|
- `/me` shows support replies, incoming friend requests, the next registered event and available referral rewards. Reply availability does not claim unread status.
|
||||||
|
- Profile privacy is managed in `/settings`. Wallet values are private by default; visitors do not receive hidden sections in HTML. Photo galleries initially show six photos and can expand to the loaded limit of 24.
|
||||||
|
- Ticket desks support waiting-for-staff and assignment filters, with elapsed time since the latest reply.
|
||||||
|
- HK table views save filters, order and visible columns per account and table (maximum 20). Existing session column preferences remain available until a named view is applied.
|
||||||
|
- Official and clone synchronization run through the existing durable import queue. Reloading restores history; interrupted uncertain writes still require inspection before repair. Successful items are not repeated.
|
||||||
|
- Publication preflight validates URL syntax/protocols and schedules, shows affected page links and keeps existing article previews. It does not claim remote URLs are reachable. Drafts remain savable. Partial event updates preserve omitted fields.
|
||||||
|
- Admin APIs return `x-operation-id`; server errors, staff audit records and import jobs share correlation context. Error and audit screens link to each other. Older records without this context remain readable.
|
||||||
|
- Public reads distinguish unavailability from empty results and real 404s, preserving independently available sections on home, dashboard, staff, photos, rankings and groups/forums.
|
||||||
|
|
||||||
|
### Data and verification
|
||||||
|
|
||||||
|
Additive migrations `0029_admin_table_views.sql` and `0030_profile_privacy.sql` run through the existing deployment migration runner. They create CMS-owned tables and do not change emulator user settings. Keep the existing shared storage volume and background jobs worker for durable imports.
|
||||||
|
|
||||||
|
Browser verification used real components with controlled data fixtures at 1280 and 390 pixels, including failure and partial-result cases. Production compilation and full lint were checked locally. No production content was created during those checks; real authenticated content and external source availability remain environment-dependent.
|
||||||
|
|
||||||
|
## Original furniture bundle recovery and progress
|
||||||
|
|
||||||
|
Catalog Studio queued imports and repairs now search other enabled Nitro sources when their initial downloads/conversion produce no local bundle. Recovery checks an exact classname, floor/wall type and positive revision against the source furnidata, then validates the bundle filename, internal name and PNG texture before writing it. It does not copy the alternative source's prices, IDs or descriptive metadata.
|
||||||
|
|
||||||
|
The recovery pass checks at most eight eligible sources, excludes the selected source, and has a 20-second network budget with four-second request limits. Catalog downloads are capped at 20 MiB; bundle downloads and attachment decompression are capped at 50 MiB. A bounded catalog cache avoids downloading full furnidata for every item. Blocked or incompatible sources can still require the original bundle to be attached manually.
|
||||||
|
|
||||||
|
Import history displays the current phase and elapsed time, the last phase on failure/interruption, and the source/revision of a recovered bundle. Phases are persisted under the existing worker lease; a retry clears old phase/provenance fields. Synchronization jobs also report their existing importer phases, but their clone-specific asset strategy is unchanged.
|
||||||
|
|
||||||
|
No additional secrets or environment variables are needed. Source definitions remain managed through the existing source configuration.
|
||||||
|
|
||||||
|
## Catalog workflow and public diagnostics
|
||||||
|
|
||||||
|
- Bulk offer edits retain the existing preview and now record complete price/category snapshots. The success notification offers an atomic batch Undo for 15 seconds; individual changes remain restorable from audit history afterward. Undo rejects changed records or missing categories rather than overwriting newer edits. No additional migration is needed beyond the existing history snapshot migration.
|
||||||
|
- Catalog Studio preserves the existing in-place search/filter/selection flow and now restores list scroll and focus after closing furniture details. Escape closes details before clearing selection. Obsolete list responses cannot replace a newer search; switching source invalidates pending review preparation.
|
||||||
|
- Import review groups furniture needing completion, conflicting records and unverified components. Each row lists missing or unknown components and suggests the next action. These are inspection recommendations; final import validation remains authoritative.
|
||||||
|
- DevOps performance has separate HK API and public-page groups, each limited to 200 recent samples for one hour. Public instrumentation measures root server page invocations on /me, news, profiles, events and search, including measured database/external work. It excludes metadata, separately rendered children, cached responses that do not invoke the page, network transfer and browser rendering. Static build invocations are excluded. Routes use fixed labels without usernames or search terms; component outcomes are distinct from HTTP status codes.
|
||||||
|
- Shared unsaved-change protection now coordinates dirty forms and protects global-search navigation. Prefix, badge and room-furniture editors protect explicit dismissal and remain open after failed saves. Browser Back is intercepted when the cancellable Navigation API is available; reload/close and links retain their existing protection. Prefix saving now waits for the real server action result before closing.
|
||||||
@@ -44,7 +44,7 @@ stack is diagnostic evidence, not an automatic root-cause determination.
|
|||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
`pnpm install --frozen-lockfile`, `pnpm deps:audit`, `pnpm typecheck`, `pnpm test`,
|
`pnpm install --frozen-lockfile`, `pnpm deps:audit`, `pnpm typecheck`, `pnpm test`,
|
||||||
`pnpm knip`, `pnpm biome:lint`, and `pnpm build` are the verification sequence.
|
`pnpm biome:lint`, and `pnpm build` are the verification sequence.
|
||||||
`pnpm analyze --output` writes a Next.js bundle analysis (not an application build).
|
`pnpm analyze --output` writes a Next.js bundle analysis (not an application build).
|
||||||
|
|
||||||
TinyMCE 8.9 is pinned in pnpm. `pnpm assets:editor` copies its runtime files and
|
TinyMCE 8.9 is pinned in pnpm. `pnpm assets:editor` copies its runtime files and
|
||||||
@@ -63,3 +63,23 @@ Obsolete global overrides were removed; a scoped esbuild override remains becaus
|
|||||||
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
||||||
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
||||||
Kit; replacing the ORM is not warranted for this tooling issue.
|
Kit; replacing the ORM is not warranted for this tooling issue.
|
||||||
|
|
||||||
|
## HK request performance
|
||||||
|
|
||||||
|
Open **DevOps → Request performance** (`/admin/devops/performance`). Access requires `DEVOPS_VIEW`; collection only retains requests that passed authentication and permission checks through `withAdmin`.
|
||||||
|
|
||||||
|
The view shows recent requests, their server duration, completed database query count/time/errors, monitored curl download count/time/errors, HTTP status and the existing operation ID. Search by route or operation ID, sort by duration or recency, and filter requests taking at least one second.
|
||||||
|
|
||||||
|
### Measurement boundaries
|
||||||
|
|
||||||
|
- Duration ends when the handler returns its response. Streaming completion, background jobs, browser rendering and public pages are not measured.
|
||||||
|
- Database spans wrap the shared mysql2 promise pool and transaction connection query/execute calls. Query parameters and SQL are never collected. Explicit transaction connection acquisition and transaction control methods are not separate spans.
|
||||||
|
- External spans currently cover `curlFetchText` and `curlDownload`; ordinary fetch calls and other integrations are not covered.
|
||||||
|
- Concurrent dependency durations can exceed wall-clock request duration. Do not subtract the sums to infer application CPU time.
|
||||||
|
- Dynamic route values and query strings are removed. No request bodies, headers, usernames, download URLs or SQL text are stored.
|
||||||
|
|
||||||
|
### Storage and operation
|
||||||
|
|
||||||
|
No new environment variables or packages are required. Redis stores at most 200 recent samples under `cms:performance:v1`, with one-hour retention. Writes are best effort, restricted to an already-ready connection and at most four pending batches; the request never waits for persistence. The view reads at most 200 entries and falls back after one second if shared storage is unavailable.
|
||||||
|
|
||||||
|
An in-process buffer preserves up to 200 samples during outages. The page explicitly labels this local mode; it is per instance and disappears on restart. Filtering applies to the retained samples, not complete traffic history. Under load or during storage outages, some shared samples may be omitted.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Informational route JavaScript budgets
|
||||||
|
|
||||||
|
Run after the existing production build; no second build or server is needed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/performance-report.mjs --next-dir .next --config scripts/performance-budgets.json --output-dir build-reports
|
||||||
|
```
|
||||||
|
|
||||||
|
The command writes `report.json` and `report.md` and prints the Markdown report. An optional `PERFORMANCE_COMMIT_SHA` environment variable records the commit declared by the build caller; the script does not infer that an existing build matches the current checkout. JSON also records `BUILD_ID`, Node/zlib versions, manifest provenance, exact file paths, sizes and source entries.
|
||||||
|
|
||||||
|
## What is measured
|
||||||
|
|
||||||
|
For each configured App Router route, resolve its exact app path using `app-path-routes-manifest.json` and `server/app-paths-manifest.json`. Read its generated `page_client-reference-manifest.js` as a JSON assignment **without executing JavaScript**. Use its sibling `page/build-manifest.json`, falling back to the root build manifest only if that sibling is absent.
|
||||||
|
|
||||||
|
The **initial entry envelope** is the union of route bootstrap `rootMainFilesTree[appPath]` (or `rootMainFiles`) and every `entryJSFiles` list in that route's client-reference manifest. This includes layout, page and boundary/loading entries. The definition follows the data exposed by the installed Next 16.3.4 Turbopack build and the `getLinkAndScriptTags` / `getRequiredScripts` renderer helpers; it is deliberately a build-artifact envelope, not a browser network trace. Conditional rendering, redirects, streaming and browser caches can change actual requests.
|
||||||
|
|
||||||
|
- Raw bytes are filesystem byte lengths of unique JavaScript assets in that envelope.
|
||||||
|
- Gzip bytes are the **sum of independent gzip level 9 compressions** of those files using the recorded Node/zlib runtime. They are not gzip of concatenated source, nor observed CDN transfer sizes.
|
||||||
|
- Deployment query strings and `/_next/` prefixes are normalized before deduplication. Shared files count once per route; each route is measured independently, with no misleading cross-route total.
|
||||||
|
- Legacy `nomodule` polyfills are measured separately, outside the modern initial budget. CSS, source maps, images, external scripts, HTML/RSC payloads and async-only chunks absent from `entryJSFiles` are excluded.
|
||||||
|
- This report makes no claims about execution cost, LCP, hydration time or real-user performance.
|
||||||
|
|
||||||
|
## Initial limits
|
||||||
|
|
||||||
|
The first limits are **baseline bytes × 1.15, rounded upward to the next 10 KiB (10,240 bytes)** independently for raw and gzip. They are provisional size alerts, not validated speed targets. Baseline: existing local production build `build-TfctsWXpff2fKS`, Next 16.3.4; its source commit was not inferred.
|
||||||
|
|
||||||
|
| Route | Baseline raw bytes | Baseline gzip bytes | Raw limit | Gzip limit |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| `/me` | 767156 | 238571 | 890880 | 276480 |
|
||||||
|
| `/news` | 765367 | 237599 | 880640 | 276480 |
|
||||||
|
| `/events` | 765851 | 237964 | 890880 | 276480 |
|
||||||
|
| `/search` | 765851 | 237964 | 890880 | 276480 |
|
||||||
|
| `/admin/catalog` | 1654898 | 492619 | 1904640 | 573440 |
|
||||||
|
| `/admin/studio/furni` | 1241312 | 391541 | 1433600 | 450560 |
|
||||||
|
|
||||||
|
Configured limits are positive integer bytes; `null` explicitly means observe-only. `scripts/performance-budgets.json` remains `mode: informational`. Exceeding a limit produces `over-budget` and a warning, with exit code 0. Missing production `BUILD_ID`, unsupported manifests, missing routes or missing referenced assets produce `unavailable` with a reason and **no partial/zero total**, also exit code 0. Malformed budget configuration or an unwritable output directory fails the command. This keeps initial CI reporting non-blocking while preventing invalid configuration from quietly disabling limits.
|
||||||
|
|
||||||
|
Synthetic tests cover shared-chunk deduplication, exact byte/gzip calculations, route bootstrap selection, missing data, safe parsing and CLI exit behavior. Run `pnpm exec vitest run --coverage.enabled=false scripts/performance-report.test.mjs`.
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1788791201804,
|
||||||
|
"tag": "0000_premium_spirit",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS website_article_drafts (
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
article_key VARCHAR(32) NOT NULL,
|
||||||
|
version INT UNSIGNED NOT NULL,
|
||||||
|
payload LONGTEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, article_key)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
CREATE TABLE IF NOT EXISTS website_article_revisions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
article_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
payload LONGTEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX article_history (article_id, id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS website_catalog_packages (
|
||||||
|
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
version INT UNSIGNED NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL,
|
||||||
|
mode VARCHAR(16) NOT NULL,
|
||||||
|
payload LONGTEXT NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX package_recent (updated_at, id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Preserve complete before/after snapshots for long news articles.
|
||||||
|
ALTER TABLE admin_audit_log MODIFY COLUMN `before` MEDIUMTEXT NULL, MODIFY COLUMN `after` MEDIUMTEXT NULL;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS website_admin_table_views (
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
path VARCHAR(191) NOT NULL,
|
||||||
|
name VARCHAR(60) NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, path, name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `website_profile_privacy` (
|
||||||
|
`user_id` int NOT NULL,
|
||||||
|
`wallet` boolean NOT NULL DEFAULT false,
|
||||||
|
`online` boolean NOT NULL DEFAULT true,
|
||||||
|
`friends` boolean NOT NULL DEFAULT true,
|
||||||
|
`photos` boolean NOT NULL DEFAULT true,
|
||||||
|
`registered` boolean NOT NULL DEFAULT true,
|
||||||
|
PRIMARY KEY (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -1,13 +1,27 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test("health endpoint is reachable", async ({ request }) => {
|
// Read-only production checks. Content mutation tests belong to a seeded staging DB.
|
||||||
const res = await request.get("/api/health");
|
test("login remains usable on a narrow viewport", async ({ page }) => {
|
||||||
expect(res.ok()).toBeTruthy();
|
await page.setViewportSize({ width: 390, height: 720 });
|
||||||
const body = await res.json();
|
const response = await page.goto("/login");
|
||||||
expect(body).toHaveProperty("status");
|
expect(response?.ok()).toBe(true);
|
||||||
|
await expect(page.locator('input[type="password"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth <= innerWidth + 1,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
await expect(page.locator("body")).not.toContainText("Application error");
|
||||||
});
|
});
|
||||||
|
test("public news is rendered without a server error", async ({ page }) => {
|
||||||
test("homepage renders", async ({ page }) => {
|
const response = await page.goto("/news");
|
||||||
await page.goto("/");
|
expect(response?.ok()).toBe(true);
|
||||||
await expect(page).toHaveTitle(/.+/);
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
|
await expect(page.locator("body")).not.toContainText("Application error");
|
||||||
|
});
|
||||||
|
test("staff page is available", async ({ page }) => {
|
||||||
|
const response = await page.goto("/staff");
|
||||||
|
expect(response?.ok()).toBe(true);
|
||||||
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Isolated UI regression checks
|
||||||
|
|
||||||
|
Run `pnpm test:ui` after `pnpm install --frozen-lockfile` and `pnpm exec playwright install chromium`.
|
||||||
|
The fixture server bundles real CMS components with local HTTP/server-action fixtures. It does not use the database or production credentials. Production smoke tests remain in the separate `pnpm test:e2e` command.
|
||||||
|
|
||||||
|
The suite covers accessible settings switches, news editor/preview, support-table filters, import history, attachment recovery and news/event recovery. Axe checks WCAG A/AA rules. The sandboxed preview cannot execute Axe; its accessible name and keyboard exit are checked separately. Automated checks do not replace a full accessibility audit.
|
||||||
|
|
||||||
|
Visual references are committed under `__screenshots__/<platform>/<viewport>`. Normal runs compare references and must not update them. For an intentional UI change, use `pnpm test:ui:update`, inspect every changed PNG and commit only accepted references. Keep the Playwright browser version aligned with the lockfile. Linux references must be captured on the Linux runner; Windows references are not interchangeable.
|
||||||
|
|
||||||
|
Fixtures contain synthetic data only. Do not add production requests, environment secrets, ignored local documents or database access to tests. CI retains test reports and failures as the `ui-results` artifact.
|
||||||
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,109 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||||
|
const file = {
|
||||||
|
name: "fixture_chair.nitro",
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
buffer: Buffer.from("isolated upload fixture"),
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/admin/attachment-harness");
|
||||||
|
await page
|
||||||
|
.getByText("Attach original file and resume", { exact: true })
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attachment upload and double-click resume send one retry and refresh history", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let retries = 0;
|
||||||
|
await page.route("**/api/admin/studio/import-attachment", async (route) => {
|
||||||
|
expect(route.request().method()).toBe("POST");
|
||||||
|
expect(route.request().postData()).toContain('name="classname"');
|
||||||
|
expect(route.request().postData()).toContain("fixture_chair");
|
||||||
|
expect(route.request().postData()).toContain(
|
||||||
|
'filename="fixture_chair.nitro"',
|
||||||
|
);
|
||||||
|
await route.fulfill({ json: { ok: true, attachmentId } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/admin/studio/import-jobs", async (route) => {
|
||||||
|
retries++;
|
||||||
|
expect(route.request().method()).toBe("PATCH");
|
||||||
|
expect(route.request().postDataJSON()).toEqual({
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
action: "retry",
|
||||||
|
attachments: { fixture_chair: attachmentId },
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
await route.fulfill({ json: { ok: true } });
|
||||||
|
});
|
||||||
|
await page.getByLabel("Choose the original .nitro file").setInputFiles(file);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Matching original file attached", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: "Resume failed imports with this file",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.dblclick();
|
||||||
|
await expect(page.getByLabel("Resumed count")).toHaveText("1");
|
||||||
|
expect(retries).toBe(1);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", {
|
||||||
|
name: "Resume failed imports with this file",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scenario of [
|
||||||
|
{
|
||||||
|
name: "owner rejection",
|
||||||
|
status: 403,
|
||||||
|
code: "forbidden",
|
||||||
|
message: "Could not attach the file.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mismatched furniture",
|
||||||
|
status: 400,
|
||||||
|
code: "mismatchedFile",
|
||||||
|
message: "This file belongs to different furniture.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "network failure",
|
||||||
|
status: 0,
|
||||||
|
code: "",
|
||||||
|
message: "Network connection failed",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
test(`attachment ${scenario.name} shows readable error without enabling resume`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let retries = 0;
|
||||||
|
await page.route("**/api/admin/studio/import-jobs", async (route) => {
|
||||||
|
retries++;
|
||||||
|
await route.fulfill({ json: { ok: true } });
|
||||||
|
});
|
||||||
|
await page.route("**/api/admin/studio/import-attachment", (route) =>
|
||||||
|
scenario.status
|
||||||
|
? route.fulfill({
|
||||||
|
status: scenario.status,
|
||||||
|
json: { ok: false, code: scenario.code },
|
||||||
|
})
|
||||||
|
: route.abort("failed"),
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByLabel("Choose the original .nitro file")
|
||||||
|
.setInputFiles(file);
|
||||||
|
await expect(page.getByRole("alert")).toContainText(scenario.message);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", {
|
||||||
|
name: "Resume failed imports with this file",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel("Resumed count")).toHaveText("0");
|
||||||
|
expect(retries).toBe(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const key = "cms:draft:v1:7:article:new";
|
||||||
|
test("browser draft survives session failure and reload, restores explicitly and clears only after save", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/fixture/actions/load-recovery", (route) =>
|
||||||
|
route.fulfill({ status: 401, body: "expired" }),
|
||||||
|
);
|
||||||
|
await page.route("**/fixture/actions/article", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ok: false, error: "Session expired" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto("/admin/articles/recovery");
|
||||||
|
const title = page.locator('input[name="title"]');
|
||||||
|
await title.fill("Keep this unfinished article");
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||||
|
.toContain("Keep this unfinished article");
|
||||||
|
await page.getByRole("button", { name: "Save article", exact: true }).click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("Session expired");
|
||||||
|
expect(await page.evaluate((k) => localStorage.getItem(k), key)).toContain(
|
||||||
|
"Keep this unfinished article",
|
||||||
|
);
|
||||||
|
page.on("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.reload();
|
||||||
|
await expect(title).toHaveValue("Community update");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Restore browser copy", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(title).toHaveValue("Keep this unfinished article");
|
||||||
|
await page.unroute("**/fixture/actions/article");
|
||||||
|
await page.route("**/fixture/actions/article", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ok: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Save article", exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||||
|
.toBeNull();
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Restore browser copy", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
|
test("pending recovery does not overwrite current edits and discard preserves them", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/admin/articles/recovery");
|
||||||
|
const title = page.locator('input[name="title"]');
|
||||||
|
await title.fill("Older draft");
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||||
|
.toContain("Older draft");
|
||||||
|
page.on("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.reload();
|
||||||
|
await title.fill("New current edits");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Discard browser copy", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(title).toHaveValue("New current edits");
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||||
|
.toContain("New current edits");
|
||||||
|
});
|
||||||
|
test("unavailable browser storage leaves the form editable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Storage.prototype.setItem = () => {
|
||||||
|
throw Error("quota");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.goto("/admin/articles/recovery");
|
||||||
|
await page.locator('input[name="title"]').fill("Still editable");
|
||||||
|
await expect(
|
||||||
|
page.getByText("Browser recovery is unavailable.", { exact: false }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.locator('input[name="title"]')).toHaveValue(
|
||||||
|
"Still editable",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("saving while recovery is pending enables recovery for subsequent edits", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/fixture/actions/article", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ok: true }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto("/admin/articles/recovery");
|
||||||
|
const title = page.locator('input[name="title"]');
|
||||||
|
await title.fill("Previous browser draft");
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||||
|
.toContain("Previous browser draft");
|
||||||
|
page.on("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.reload();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Restore browser copy", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Save article", exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||||
|
.toBeNull();
|
||||||
|
await title.fill("Edits after successful save");
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||||
|
.toContain("Edits after successful save");
|
||||||
|
});
|
||||||
|
test("unmount flush retains an edit before the autosave interval", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/admin/articles/recovery");
|
||||||
|
await page.clock.install();
|
||||||
|
await page.clock.pauseAt(new Date());
|
||||||
|
await page.locator('input[name="title"]').fill("Immediate final edit");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Close editor fixture", exact: true })
|
||||||
|
.click();
|
||||||
|
expect(await page.evaluate((k) => localStorage.getItem(k), key)).toContain(
|
||||||
|
"Immediate final edit",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("event title and dates recover after reload and clear after successful update", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const eventKey = "cms:draft:v1:7:event:12";
|
||||||
|
await page.route("**/fixture/actions/update-event", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ ok: true, data: { id: 12 } }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto("/admin/events/recovery");
|
||||||
|
const title = page.locator("#title");
|
||||||
|
const dates = page.locator('input[type="datetime-local"]');
|
||||||
|
await title.fill("Recovered community event");
|
||||||
|
await dates.nth(0).fill("2030-02-03T14:30");
|
||||||
|
await dates.nth(1).fill("2030-02-03T16:00");
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), eventKey))
|
||||||
|
.toContain("2030-02-03T14:30:00.000Z");
|
||||||
|
page.on("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.reload();
|
||||||
|
await expect(title).toHaveValue("Original event");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Restore browser copy", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(title).toHaveValue("Recovered community event");
|
||||||
|
await expect(dates.nth(0)).toHaveValue("2030-02-03T14:30");
|
||||||
|
await expect(dates.nth(1)).toHaveValue("2030-02-03T16:00");
|
||||||
|
const save = page.waitForRequest("**/fixture/actions/update-event");
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
expect((await save).postDataJSON()).toMatchObject({
|
||||||
|
id: 12,
|
||||||
|
title: "Recovered community event",
|
||||||
|
startsAt: "2030-02-03T14:30:00.000Z",
|
||||||
|
endsAt: "2030-02-03T16:00:00.000Z",
|
||||||
|
});
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate((k) => localStorage.getItem(k), eventKey))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import tailwind from "@tailwindcss/postcss";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import postcss from "postcss";
|
||||||
|
|
||||||
|
const root = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../..",
|
||||||
|
);
|
||||||
|
const fixtures = path.join(root, "e2e/ui/fixtures");
|
||||||
|
const port = Number(process.env.UI_TEST_PORT || 3107);
|
||||||
|
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||||
|
throw Error("Invalid UI_TEST_PORT");
|
||||||
|
const actions = new Set([
|
||||||
|
"@/actions/import-furni",
|
||||||
|
"@/actions/events",
|
||||||
|
"@/actions/admin-settings",
|
||||||
|
"@/actions/admin-table-views",
|
||||||
|
"@/actions/article-recovery",
|
||||||
|
"@/actions/admin-media",
|
||||||
|
]);
|
||||||
|
const bundle = await build({
|
||||||
|
entryPoints: [path.join(fixtures, "entry.tsx")],
|
||||||
|
bundle: true,
|
||||||
|
write: false,
|
||||||
|
outdir: path.join(root, "test-results/ui-fixture"),
|
||||||
|
format: "iife",
|
||||||
|
jsx: "automatic",
|
||||||
|
alias: { "@": path.join(root, "src") },
|
||||||
|
define: { "process.env.NODE_ENV": '"production"' },
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "server-contracts",
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve(
|
||||||
|
{ filter: /^next\/navigation$|^next\/link$|^@\/actions\// },
|
||||||
|
(args) => {
|
||||||
|
if (args.path === "next/navigation")
|
||||||
|
return { path: path.join(fixtures, "navigation.ts") };
|
||||||
|
if (args.path === "next/link")
|
||||||
|
return { path: path.join(fixtures, "next-link.tsx") };
|
||||||
|
if (actions.has(args.path))
|
||||||
|
return { path: path.join(fixtures, "server-actions.ts") };
|
||||||
|
throw Error(`Unexpected server action in UI fixture: ${args.path}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
build.onResolve(
|
||||||
|
{ filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ },
|
||||||
|
(args) => {
|
||||||
|
throw Error(
|
||||||
|
`Database or server dependency forbidden in UI fixture: ${args.path}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const globalCss = (
|
||||||
|
await postcss([tailwind({ base: root })]).process(
|
||||||
|
await readFile(path.join(root, "src/app/globals.css"), "utf8"),
|
||||||
|
{ from: path.join(root, "src/app/globals.css") },
|
||||||
|
)
|
||||||
|
).css;
|
||||||
|
const css =
|
||||||
|
globalCss +
|
||||||
|
"\n" +
|
||||||
|
bundle.outputFiles
|
||||||
|
.filter((file) => file.path.endsWith(".css"))
|
||||||
|
.map((file) => file.text)
|
||||||
|
.join("\n");
|
||||||
|
const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text;
|
||||||
|
const html =
|
||||||
|
'<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="' +
|
||||||
|
"a".repeat(64) +
|
||||||
|
'"><title>UI verification fixture</title><link rel="stylesheet" href="/fixture/styles.css"></head><body><div id="root"></div><script src="/fixture/app.js"></script></body></html>';
|
||||||
|
const cover =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="720" height="160" viewBox="0 0 720 160"><rect width="720" height="160" fill="#334155"/><circle cx="620" cy="80" r="48" fill="#f59e0b"/></svg>';
|
||||||
|
const jobs = new Map();
|
||||||
|
const views = [];
|
||||||
|
const json = (res, value, status = 200) => {
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify(value));
|
||||||
|
};
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url, "http://127.0.0.1");
|
||||||
|
if (url.pathname === "/fixture/reset") {
|
||||||
|
jobs.clear();
|
||||||
|
views.length = 0;
|
||||||
|
return json(res, { ok: true });
|
||||||
|
}
|
||||||
|
if (url.pathname === "/health") return json(res, { ready: true });
|
||||||
|
if (url.pathname === "/fixture/styles.css") {
|
||||||
|
res.setHeader("content-type", "text/css");
|
||||||
|
return res.end(css);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/fixture/app.js") {
|
||||||
|
res.setHeader("content-type", "application/javascript");
|
||||||
|
return res.end(js);
|
||||||
|
}
|
||||||
|
if (url.pathname === "/fixture/cover.svg") {
|
||||||
|
res.setHeader("content-type", "image/svg+xml");
|
||||||
|
return res.end(cover);
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/vendor/tinymce/")) {
|
||||||
|
const assetRoot = path.join(root, "node_modules/tinymce");
|
||||||
|
const asset = path.resolve(
|
||||||
|
assetRoot,
|
||||||
|
url.pathname.slice("/vendor/tinymce/".length),
|
||||||
|
);
|
||||||
|
if (!asset.startsWith(assetRoot + path.sep)) {
|
||||||
|
res.writeHead(400);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
res.setHeader(
|
||||||
|
"content-type",
|
||||||
|
asset.endsWith(".css")
|
||||||
|
? "text/css"
|
||||||
|
: asset.endsWith(".js")
|
||||||
|
? "application/javascript"
|
||||||
|
: asset.endsWith(".woff")
|
||||||
|
? "font/woff"
|
||||||
|
: "application/octet-stream",
|
||||||
|
);
|
||||||
|
return res.end(await readFile(asset));
|
||||||
|
}
|
||||||
|
let body = {};
|
||||||
|
if (req.method === "POST" || req.method === "PATCH") {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of req) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > 1000000) throw Error("Fixture payload too large");
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/admin/studio/import-jobs") {
|
||||||
|
if (req.method === "PATCH") {
|
||||||
|
const job = jobs.get(body.id);
|
||||||
|
if (job) job.state = "cancelled";
|
||||||
|
return json(res, { ok: true, job });
|
||||||
|
}
|
||||||
|
return json(res, {
|
||||||
|
ok: true,
|
||||||
|
jobs: [...jobs.values()],
|
||||||
|
nextCursor: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/admin/import/official/sync-all") {
|
||||||
|
if (!jobs.has(body.id))
|
||||||
|
jobs.set(body.id, {
|
||||||
|
id: body.id,
|
||||||
|
userId: 7,
|
||||||
|
createdAt: "2030-01-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2030-01-01T12:00:00.000Z",
|
||||||
|
state: "queued",
|
||||||
|
translate: false,
|
||||||
|
syncKind: "official",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
classname: "fixture_chair",
|
||||||
|
name: "Fixture chair",
|
||||||
|
description: "",
|
||||||
|
type: "flooritem",
|
||||||
|
category: "other",
|
||||||
|
revision: 1,
|
||||||
|
state: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
return json(res, { ok: true, job: jobs.get(body.id) });
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/media") return json(res, { files: [] });
|
||||||
|
if (url.pathname.startsWith("/fixture/actions/")) {
|
||||||
|
const name = url.pathname.split("/").at(-1);
|
||||||
|
if (name === "load-recovery")
|
||||||
|
return json(res, { version: 0, draft: null, revisions: [] });
|
||||||
|
if (name === "autosave" || name === "clear-recovery")
|
||||||
|
return json(res, { ok: true, version: 1 });
|
||||||
|
if (name === "list-views")
|
||||||
|
return json(res, { ok: true, data: { views } });
|
||||||
|
if (name === "save-view") {
|
||||||
|
views.push(body.view ?? body);
|
||||||
|
return json(res, { ok: true });
|
||||||
|
}
|
||||||
|
return json(res, { ok: true, data: {} });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
"/admin/unsaved-harness",
|
||||||
|
"/admin/attachment-harness",
|
||||||
|
"/admin/studio-harness",
|
||||||
|
"/admin/jobs-harness",
|
||||||
|
"/admin/settings",
|
||||||
|
"/admin/articles/new",
|
||||||
|
"/admin/articles/recovery",
|
||||||
|
"/admin/events/recovery",
|
||||||
|
"/admin/tickets/desk",
|
||||||
|
"/admin/import/official",
|
||||||
|
].includes(url.pathname)
|
||||||
|
) {
|
||||||
|
res.setHeader("content-type", "text/html");
|
||||||
|
return res.end(html);
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end("Not found");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end("Fixture error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.listen(port, "127.0.0.1", () =>
|
||||||
|
console.log(`Isolated UI fixture ready at http://127.0.0.1:${port}`),
|
||||||
|
);
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"])
|
||||||
|
process.on(signal, () => server.close(() => process.exit(0)));
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ImportAttachmentRecovery } from "@/components/admin/studio/import-attachment-recovery";
|
||||||
|
export function AttachmentHarness() {
|
||||||
|
const [resumed, setResumed] = useState(0);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ImportAttachmentRecovery
|
||||||
|
jobId="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
classname="fixture_chair"
|
||||||
|
disabled={false}
|
||||||
|
onResumed={async () => {
|
||||||
|
setResumed((value) => value + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<output aria-label="Resumed count">{resumed}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { EventForm } from "@/app/admin/events/event-form";
|
||||||
|
import { CmsSettingsForm } from "@/app/admin/settings/cms-settings-form";
|
||||||
|
import { TicketsTable } from "@/app/admin/tickets/tickets-table";
|
||||||
|
import { ArticleForm } from "@/components/admin/article-form";
|
||||||
|
import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue";
|
||||||
|
import { StudioClient } from "@/components/admin/studio/studio-client";
|
||||||
|
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||||
|
import { buildFurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
|
import { themePaletteCss } from "@/lib/theme-css";
|
||||||
|
import { PRESETS } from "@/lib/theme-presets";
|
||||||
|
import messages from "@/messages/en.json";
|
||||||
|
import { AttachmentHarness } from "./attachment-harness";
|
||||||
|
import { FurnitureJobsHarness } from "./furniture-jobs-harness";
|
||||||
|
import { fixtureAction } from "./server-actions";
|
||||||
|
import { UnsavedHarness } from "./unsaved-harness";
|
||||||
|
|
||||||
|
const theme = document.createElement("style");
|
||||||
|
theme.textContent = themePaletteCss(
|
||||||
|
":root.dark",
|
||||||
|
PRESETS["Atom (golden)"].dark,
|
||||||
|
);
|
||||||
|
document.head.appendChild(theme);
|
||||||
|
const route = window.location.pathname;
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
if (!root) throw Error("Fixture root missing");
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
subject: "Account assistance",
|
||||||
|
creator: "SampleMember",
|
||||||
|
creatorId: 7,
|
||||||
|
category: "general",
|
||||||
|
messages: 2,
|
||||||
|
status: "open",
|
||||||
|
priority: "normal",
|
||||||
|
assignee: "Moderator",
|
||||||
|
date: "01/01/2030",
|
||||||
|
awaitingStaff: true,
|
||||||
|
replyAgeMinutes: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
subject: "Event question",
|
||||||
|
creator: "AnotherMember",
|
||||||
|
creatorId: 8,
|
||||||
|
category: "events",
|
||||||
|
messages: 1,
|
||||||
|
status: "waiting",
|
||||||
|
priority: "high",
|
||||||
|
assignee: "—",
|
||||||
|
date: "01/01/2030",
|
||||||
|
awaitingStaff: false,
|
||||||
|
replyAgeMinutes: 15,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const saveArticle = async (form: FormData): Promise<ArticleSaveResult> =>
|
||||||
|
fixtureAction("article", Object.fromEntries(form));
|
||||||
|
|
||||||
|
function ArticleFixture() {
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{route.includes("recovery") && (
|
||||||
|
<button type="button" onClick={() => setVisible(false)}>
|
||||||
|
Close editor fixture
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{visible && (
|
||||||
|
<ArticleForm
|
||||||
|
userId={route.includes("recovery") ? "7" : undefined}
|
||||||
|
action={saveArticle}
|
||||||
|
articleKey="new"
|
||||||
|
defaultValues={{
|
||||||
|
title: "Community update",
|
||||||
|
slug: "community-update",
|
||||||
|
image: "/fixture/cover.svg",
|
||||||
|
shortStory: "A short update for the community.",
|
||||||
|
fullStory:
|
||||||
|
"<p>Join the upcoming event and explore the hotel together.</p>",
|
||||||
|
status: "draft",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<NextIntlClientProvider
|
||||||
|
locale="en"
|
||||||
|
timeZone="UTC"
|
||||||
|
now={new Date("2030-01-01T12:00:00Z")}
|
||||||
|
messages={messages}
|
||||||
|
>
|
||||||
|
<main data-admin className="min-w-0 mx-auto max-w-6xl p-5">
|
||||||
|
<h1 className="mb-5 text-2xl font-bold">
|
||||||
|
{route.includes("settings")
|
||||||
|
? "CMS settings"
|
||||||
|
: route.includes("tickets")
|
||||||
|
? "Support desk"
|
||||||
|
: route.includes("articles")
|
||||||
|
? "News editor"
|
||||||
|
: "Furniture synchronization"}
|
||||||
|
</h1>
|
||||||
|
{route.includes("unsaved-harness") ? (
|
||||||
|
<UnsavedHarness />
|
||||||
|
) : route.includes("studio-harness") ? (
|
||||||
|
<StudioClient
|
||||||
|
source={buildFurniImportSource("it")}
|
||||||
|
initialTree={[]}
|
||||||
|
defaultTranslate={false}
|
||||||
|
/>
|
||||||
|
) : route.includes("attachment-harness") ? (
|
||||||
|
<AttachmentHarness />
|
||||||
|
) : route.includes("events/recovery") ? (
|
||||||
|
<EventForm
|
||||||
|
userId="7"
|
||||||
|
eventTypes={[
|
||||||
|
{ id: 1, name: "Community", slug: "community", color: "#123456" },
|
||||||
|
]}
|
||||||
|
defaultValues={{
|
||||||
|
id: 12,
|
||||||
|
title: "Original event",
|
||||||
|
description: "Event details",
|
||||||
|
typeId: 1,
|
||||||
|
status: "draft",
|
||||||
|
isRecurring: 0,
|
||||||
|
startsAt: new Date("2030-01-01T12:00:00Z"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : route.includes("jobs-harness") ? (
|
||||||
|
<FurnitureJobsHarness />
|
||||||
|
) : route.includes("settings") ? (
|
||||||
|
<CmsSettingsForm
|
||||||
|
canEdit
|
||||||
|
values={{ default_dark: "0", cms_hotel_name: "Example Hotel" }}
|
||||||
|
/>
|
||||||
|
) : route.includes("tickets") ? (
|
||||||
|
<TicketsTable
|
||||||
|
data={{ rows, total: 2, page: 1, perPage: 20, lastPage: 1 }}
|
||||||
|
/>
|
||||||
|
) : route.includes("articles") ? (
|
||||||
|
<ArticleFixture />
|
||||||
|
) : (
|
||||||
|
<FurnitureSyncQueue kind="official" />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
<Toaster />
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { FurnitureJobHistory } from "@/components/admin/studio/furniture-jobs";
|
||||||
|
import { useFurnitureJobs } from "@/components/admin/studio/use-furniture-jobs";
|
||||||
|
|
||||||
|
export function FurnitureJobsHarness() {
|
||||||
|
const [completions, setCompletions] = useState(0);
|
||||||
|
const history = useFurnitureJobs(
|
||||||
|
() => setCompletions((value) => value + 1),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section aria-label="Import history hook">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void history.refresh();
|
||||||
|
void history.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh twice
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={history.loading || !history.nextCursor}
|
||||||
|
onClick={() => void history.navigate("next")}
|
||||||
|
>
|
||||||
|
Next page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={history.loading || history.page === 1}
|
||||||
|
onClick={() => void history.navigate("previous")}
|
||||||
|
>
|
||||||
|
Previous page
|
||||||
|
</button>
|
||||||
|
{window.location.search.includes("progress") && (
|
||||||
|
<FurnitureJobHistory state={history} expanded />
|
||||||
|
)}
|
||||||
|
<output aria-label="Page">{history.page}</output>
|
||||||
|
<output aria-label="Completions">{completions}</output>
|
||||||
|
<output aria-label="Loading">{String(history.loading)}</output>
|
||||||
|
<output aria-label="Jobs">
|
||||||
|
{history.jobs.map((job) => `${job.id}:${job.state}`).join(",")}
|
||||||
|
</output>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const router = {
|
||||||
|
push: (url: string) => {
|
||||||
|
window.location.href = url;
|
||||||
|
},
|
||||||
|
replace: (url: string) => {
|
||||||
|
window.location.replace(url);
|
||||||
|
},
|
||||||
|
refresh: () => {},
|
||||||
|
};
|
||||||
|
export const useRouter = () => router;
|
||||||
|
export const usePathname = () => window.location.pathname;
|
||||||
|
export const useSearchParams = () =>
|
||||||
|
new URLSearchParams(window.location.search);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { AnchorHTMLAttributes } from "react";
|
||||||
|
export default function FixtureLink({
|
||||||
|
prefetch: _prefetch,
|
||||||
|
...props
|
||||||
|
}: AnchorHTMLAttributes<HTMLAnchorElement> & { prefetch?: boolean }) {
|
||||||
|
return <a {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export async function fixtureAction(name: string, input: unknown = {}) {
|
||||||
|
const response = await fetch(`/fixture/actions/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw Error(`Fixture action failed: ${response.status}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
export const saveManagedSettings = (input: unknown) =>
|
||||||
|
fixtureAction("settings", input);
|
||||||
|
export const listTableViews = (input: unknown) =>
|
||||||
|
fixtureAction("list-views", input);
|
||||||
|
export const saveTableView = (input: unknown) =>
|
||||||
|
fixtureAction("save-view", input);
|
||||||
|
export const deleteTableView = (input: unknown) =>
|
||||||
|
fixtureAction("delete-view", input);
|
||||||
|
export const loadArticleRecovery = (key: unknown) =>
|
||||||
|
fixtureAction("load-recovery", { key });
|
||||||
|
export const autosaveArticle = (...input: unknown[]) =>
|
||||||
|
fixtureAction("autosave", input);
|
||||||
|
export const clearArticleRecovery = (...input: unknown[]) =>
|
||||||
|
fixtureAction("clear-recovery", input);
|
||||||
|
export const uploadMediaAndReturn = () => fixtureAction("upload");
|
||||||
|
|
||||||
|
export const createEvent = (input: unknown) =>
|
||||||
|
fixtureAction("create-event", input);
|
||||||
|
export const updateEvent = (input: unknown) =>
|
||||||
|
fixtureAction("update-event", input);
|
||||||
|
|
||||||
|
export const deleteImportedFurni = (input: unknown) =>
|
||||||
|
fixtureAction("delete-imported-furni", input);
|
||||||
|
export const setFurnidataTranslateEnabled = (input: unknown) =>
|
||||||
|
fixtureAction("furnidata-translation", input);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { PrefixDialog } from "@/app/admin/prefixes/prefix-dialog";
|
||||||
|
import {
|
||||||
|
confirmUnsavedNavigation,
|
||||||
|
useUnsavedChanges,
|
||||||
|
} from "@/hooks/use-unsaved-changes";
|
||||||
|
export function UnsavedHarness() {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [failSave, setFailSave] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [navigated, setNavigated] = useState(false);
|
||||||
|
const guard = useUnsavedChanges(value !== "", "Discard unsaved changes?");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Working value
|
||||||
|
<input
|
||||||
|
aria-label="Working value"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<a href="/admin/unsaved-destination">Leave editor</a>
|
||||||
|
<a href="#same-page">Same page</a>
|
||||||
|
<a href="mailto:[email protected]">Email</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmUnsavedNavigation()) setNavigated(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Programmatic navigation
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
guard.markSaved();
|
||||||
|
setValue("");
|
||||||
|
setSaved(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save working value
|
||||||
|
</button>
|
||||||
|
{saved && <p>Working value saved</p>}
|
||||||
|
{navigated && <p>Navigation allowed</p>}
|
||||||
|
<button type="button" onClick={() => setOpen(true)}>
|
||||||
|
Open prefix
|
||||||
|
</button>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={failSave}
|
||||||
|
onChange={(event) => setFailSave(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Fail prefix save
|
||||||
|
</label>
|
||||||
|
<PrefixDialog
|
||||||
|
isOpen={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
editPrefix={null}
|
||||||
|
onSave={async () => {
|
||||||
|
if (failSave) return false;
|
||||||
|
setSaved(true);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
furnitureCursorFixture,
|
||||||
|
furnitureJobFixture,
|
||||||
|
} from "../../src/test/furniture-jobs-fixtures";
|
||||||
|
|
||||||
|
test("history hook deduplicates refreshes, pages and reports completion once", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let requests = 0;
|
||||||
|
let completed = false;
|
||||||
|
await page.route("**/api/admin/studio/import-jobs?*", async (route) => {
|
||||||
|
requests++;
|
||||||
|
const secondPage = new URL(route.request().url()).searchParams.has(
|
||||||
|
"before",
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
ok: true,
|
||||||
|
jobs: [
|
||||||
|
{
|
||||||
|
...furnitureJobFixture,
|
||||||
|
id: secondPage
|
||||||
|
? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
|
: furnitureJobFixture.id,
|
||||||
|
state: completed ? "completed" : "queued",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: secondPage ? null : furnitureCursorFixture,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/admin/jobs-harness");
|
||||||
|
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
|
||||||
|
furnitureJobFixture.id,
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||||
|
const initial = requests;
|
||||||
|
await page.getByRole("button", { name: "Refresh twice" }).click();
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||||
|
expect(requests).toBe(initial + 1);
|
||||||
|
await page.getByRole("button", { name: "Next page" }).click();
|
||||||
|
await expect(page.getByLabel("Page", { exact: true })).toHaveText("2");
|
||||||
|
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
|
||||||
|
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Previous page" }).click();
|
||||||
|
await expect(page.getByLabel("Page", { exact: true })).toHaveText("1");
|
||||||
|
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
|
||||||
|
furnitureJobFixture.id,
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||||
|
completed = true;
|
||||||
|
await page.getByRole("button", { name: "Refresh twice" }).click();
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
|
||||||
|
await expect(page.getByLabel("Completions", { exact: true })).toHaveText("1");
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||||
|
await page.getByRole("button", { name: "Refresh twice" }).click();
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||||
|
await expect(page.getByLabel("Completions", { exact: true })).toHaveText("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("history polling pauses while hidden and refreshes on visibility return", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
let requests = 0;
|
||||||
|
await page.clock.install();
|
||||||
|
await page.route("**/api/admin/studio/import-jobs?*", (route) => {
|
||||||
|
requests++;
|
||||||
|
return route.fulfill({
|
||||||
|
json: { ok: true, jobs: [furnitureJobFixture], nextCursor: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/admin/jobs-harness");
|
||||||
|
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
|
||||||
|
furnitureJobFixture.id,
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => "hidden",
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
const hiddenCount = requests;
|
||||||
|
await page.clock.fastForward(60000);
|
||||||
|
expect(requests).toBe(hiddenCount);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => "visible",
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
});
|
||||||
|
await expect.poll(() => requests).toBe(hiddenCount + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("history shows the current phase and validated recovery source", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.route("**/api/admin/studio/import-jobs?*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
ok: true,
|
||||||
|
jobs: [
|
||||||
|
{
|
||||||
|
...furnitureJobFixture,
|
||||||
|
state: "running",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...furnitureJobFixture.items[0],
|
||||||
|
state: "running",
|
||||||
|
phase: "writing_db",
|
||||||
|
phaseStartedAt: new Date().toISOString(),
|
||||||
|
recoveredSource: {
|
||||||
|
sourceId: "configured",
|
||||||
|
sourceName: "Configured source",
|
||||||
|
revision: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.goto("/admin/jobs-harness?progress");
|
||||||
|
await page.locator("details details summary").click();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Current phase: Saving SQL and catalog"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText(/Bundle recovered from Configured source/),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth > innerWidth,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("Organize Imports Dialog validation limits and modes workflow", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname === "/api/admin/catalog/import-groups") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
name: "Test Group",
|
||||||
|
caption: "Test Group",
|
||||||
|
icon: 1,
|
||||||
|
layout: "default_3x3",
|
||||||
|
total: 5,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
itemId: 1,
|
||||||
|
itemName: "chair",
|
||||||
|
catalogItemId: null,
|
||||||
|
alreadyPlaced: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/admin/catalog/pages") {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
pages: [{ id: 101, caption: "Existing Category", parentId: 0 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
route.request().method() === "POST" &&
|
||||||
|
url.pathname.includes("organize")
|
||||||
|
) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
created: [
|
||||||
|
{ pageId: 102, caption: "Test Group", moved: 0, added: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
json: { error: "Not found" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there's no direct harness route, we can test component behavior or test via catalog admin route if available.
|
||||||
|
// For now, let's verify error handling and limits in the mock test or navigate to catalog.
|
||||||
|
await page.goto("/admin/catalog");
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const items = Array.from({ length: 60 }, (_, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
classname: `chair_${index}`,
|
||||||
|
name: `Chair ${index}`,
|
||||||
|
description: "Fixture furniture",
|
||||||
|
type: "flooritem",
|
||||||
|
revision: 1,
|
||||||
|
category: "other",
|
||||||
|
alreadyImported: false,
|
||||||
|
nitroExists: false,
|
||||||
|
iconUrl: "/fixture/cover.svg",
|
||||||
|
}));
|
||||||
|
async function mockStudio(page: Page) {
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname.endsWith("/inspect"))
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: (route.request().postDataJSON().classnames as string[]).map(
|
||||||
|
(classname) => ({
|
||||||
|
classname,
|
||||||
|
sql: [{ id: 1, spriteId: 1, name: "Chair", type: "s" }],
|
||||||
|
catalog: [],
|
||||||
|
furnidata: [],
|
||||||
|
furnidataReadable: true,
|
||||||
|
nitro: { exists: true, bytes: 42 },
|
||||||
|
icon: { exists: false, bytes: 0 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (url.pathname.endsWith("/source-assets"))
|
||||||
|
return route.fulfill({ json: { items: [] } });
|
||||||
|
if (url.pathname.endsWith("/furni"))
|
||||||
|
return route.fulfill({
|
||||||
|
json: url.searchParams.has("action")
|
||||||
|
? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0 }
|
||||||
|
: {
|
||||||
|
items,
|
||||||
|
meta: {
|
||||||
|
currentPage: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
total: items.length,
|
||||||
|
perPage: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (url.pathname.endsWith("/clone"))
|
||||||
|
return route.fulfill({ json: { sources: [] } });
|
||||||
|
if (url.pathname.endsWith("/import-jobs"))
|
||||||
|
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
json: { error: "Unknown fixture endpoint" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test("closing furniture restores its scroll, focus, search, filter and selection", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockStudio(page);
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await page.getByRole("textbox", { name: "Search furniture" }).fill("chair");
|
||||||
|
await page.waitForResponse(
|
||||||
|
(res) => new URL(res.url()).searchParams.get("search") === "chair",
|
||||||
|
);
|
||||||
|
await page.getByRole("combobox", { name: "Furniture type" }).click();
|
||||||
|
await page.getByRole("option", { name: "Floor items", exact: true }).click();
|
||||||
|
const trigger = page.getByRole("button", {
|
||||||
|
name: "View Chair 30",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await trigger.scrollIntoViewIfNeeded();
|
||||||
|
await page
|
||||||
|
.getByRole("checkbox", { name: "Select chair_30", exact: true })
|
||||||
|
.check();
|
||||||
|
const list = page.getByTestId("studio-furniture-list");
|
||||||
|
const top = await list.evaluate((element) => element.scrollTop);
|
||||||
|
await trigger.click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Close furniture details", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
expect(await list.evaluate((element) => element.scrollTop)).toBe(top);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: "Search furniture" }),
|
||||||
|
).toHaveValue("chair");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("combobox", { name: "Furniture type" }),
|
||||||
|
).toContainText("Floor");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("checkbox", { name: "Select chair_30", exact: true }),
|
||||||
|
).toBeChecked();
|
||||||
|
await trigger.click();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Close furniture details", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("checkbox", { name: "Select chair_30", exact: true }),
|
||||||
|
).toBeChecked();
|
||||||
|
});
|
||||||
|
test("review proposes completing existing furniture and filters actionable groups", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockStudio(page);
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Import chair_0", exact: true })
|
||||||
|
.click();
|
||||||
|
const review = page.getByRole("dialog", {
|
||||||
|
name: "Review furniture import",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
review.getByText("Suggested: complete existing furniture", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
review.getByText("Missing: Catalog offer, Furnidata, Local icon"),
|
||||||
|
).toBeVisible();
|
||||||
|
await review
|
||||||
|
.getByRole("button", { name: "Conflicts (0)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
review.getByText("No furniture in this group.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await review
|
||||||
|
.getByRole("button", { name: "To complete (1)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
review.getByText("Suggested: complete existing furniture", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await review
|
||||||
|
.getByRole("button", { name: "Continue to confirmation", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole("dialog", { name: "Confirm furniture import", exact: true })
|
||||||
|
.getByRole("button", { name: "Start import (1)", exact: true }),
|
||||||
|
).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an older search response cannot replace newer furniture", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockStudio(page);
|
||||||
|
await page.route("**/api/admin/import/furni?*", async (route) => {
|
||||||
|
const search = new URL(route.request().url()).searchParams.get("search");
|
||||||
|
if (!search) return route.fallback();
|
||||||
|
if (search === "old")
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
...items[0],
|
||||||
|
name: search === "old" ? "Old response" : "New response",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { currentPage: 1, lastPage: 1, total: 1, perPage: 100 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
const search = page.getByRole("textbox", { name: "Search furniture" });
|
||||||
|
const oldRequest = page.waitForRequest(
|
||||||
|
(request) => new URL(request.url()).searchParams.get("search") === "old",
|
||||||
|
);
|
||||||
|
await search.fill("old");
|
||||||
|
await oldRequest;
|
||||||
|
await search.fill("new");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "View New response", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "View New response", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "View Old response", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
classname: "fixture_chair",
|
||||||
|
name: "Fixture chair",
|
||||||
|
description: "Synthetic chair",
|
||||||
|
type: "flooritem",
|
||||||
|
revision: 1,
|
||||||
|
category: "other",
|
||||||
|
alreadyImported: true,
|
||||||
|
nitroExists: true,
|
||||||
|
iconUrl: "/fixture/cover.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
classname: "fixture_table",
|
||||||
|
name: "Fixture table",
|
||||||
|
description: "Synthetic table",
|
||||||
|
type: "flooritem",
|
||||||
|
revision: 1,
|
||||||
|
category: "other",
|
||||||
|
alreadyImported: false,
|
||||||
|
nitroExists: false,
|
||||||
|
iconUrl: "/fixture/cover.svg",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test("Studio opens and dismisses detail, Nitro editor and import review", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const mutations: string[] = [];
|
||||||
|
let metadataRequests = 0;
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
if (url.pathname === "/api/admin/studio/inspect") {
|
||||||
|
const { classnames } = request.postDataJSON() as { classnames: string[] };
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: classnames.map((classname) => ({
|
||||||
|
classname,
|
||||||
|
sql: [],
|
||||||
|
catalog: [],
|
||||||
|
furnidata: [],
|
||||||
|
furnidataReadable: true,
|
||||||
|
nitro: { exists: false, bytes: 0 },
|
||||||
|
icon: { exists: false, bytes: 0 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/admin/studio/source-assets") {
|
||||||
|
const body = request.postDataJSON() as {
|
||||||
|
items: Array<{ classname: string }>;
|
||||||
|
};
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: body.items.map(({ classname }) => ({
|
||||||
|
classname,
|
||||||
|
state: "available",
|
||||||
|
revision: 1,
|
||||||
|
alternatives: [],
|
||||||
|
attempts: [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.method() !== "GET") {
|
||||||
|
mutations.push(`${request.method()} ${url.pathname}`);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 405,
|
||||||
|
json: { error: "Fixture forbids mutations" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname.endsWith("/nitro-editor")) {
|
||||||
|
metadataRequests++;
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
metadata: {
|
||||||
|
name: "fixture_chair",
|
||||||
|
logicType: "furniture_basic",
|
||||||
|
visualizationType: "furniture_static",
|
||||||
|
},
|
||||||
|
flags: { canstandon: false, cansiton: false, canlayon: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/admin/import/furni") {
|
||||||
|
return route.fulfill({
|
||||||
|
json:
|
||||||
|
url.searchParams.get("action") === "stats"
|
||||||
|
? { totalInDb: 1, inCatalog: 1, notInCatalog: 0, missingNitro: 0 }
|
||||||
|
: {
|
||||||
|
items,
|
||||||
|
meta: { currentPage: 1, lastPage: 1, total: 2, perPage: 20 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/admin/import/clone")
|
||||||
|
return route.fulfill({ json: { sources: [] } });
|
||||||
|
if (url.pathname === "/api/admin/studio/import-jobs")
|
||||||
|
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
json: { error: "Unknown fixture endpoint" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/admin/studio-harness");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "View Fixture chair", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(metadataRequests).toBe(0);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "View Fixture chair", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Close furniture details", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Synthetic chair", { exact: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Close furniture details", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Close furniture details", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Edit nitro fixture_chair", exact: true })
|
||||||
|
.click();
|
||||||
|
const editor = page.getByRole("dialog", { name: "Edit Nitro", exact: true });
|
||||||
|
await expect(editor).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
editor.getByRole("switch", { name: "Can Sit On", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(metadataRequests).toBe(1);
|
||||||
|
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(editor).toHaveCount(0);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Import fixture_table", exact: true })
|
||||||
|
.click();
|
||||||
|
const review = page.getByRole("dialog", {
|
||||||
|
name: "Review furniture import",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(review).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
review.getByText("Fixture table", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
review.getByRole("button", {
|
||||||
|
name: "Continue to confirmation",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeEnabled();
|
||||||
|
await review.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(review).toHaveCount(0);
|
||||||
|
expect(mutations).toEqual([]);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/admin/unsaved-harness");
|
||||||
|
});
|
||||||
|
test("cancelled programmatic navigation preserves changes and save clears protection", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByLabel("Working value").fill("Keep this");
|
||||||
|
page.once("dialog", (dialog) => dialog.dismiss());
|
||||||
|
await page.getByRole("button", { name: "Programmatic navigation" }).click();
|
||||||
|
await expect(page.getByText("Navigation allowed")).toHaveCount(0);
|
||||||
|
await expect(page.getByLabel("Working value")).toHaveValue("Keep this");
|
||||||
|
await page.getByRole("button", { name: "Save working value" }).click();
|
||||||
|
let prompts = 0;
|
||||||
|
page.on("dialog", async (dialog) => {
|
||||||
|
prompts++;
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Programmatic navigation" }).click();
|
||||||
|
await expect(page.getByText("Navigation allowed")).toBeVisible();
|
||||||
|
expect(prompts).toBe(0);
|
||||||
|
});
|
||||||
|
test("cancelled link navigation and same-page anchors preserve the editor", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByLabel("Working value").fill("Keep this");
|
||||||
|
let prompts = 0;
|
||||||
|
page.on("dialog", async (dialog) => {
|
||||||
|
prompts++;
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await page.getByRole("link", { name: "Leave editor" }).click();
|
||||||
|
await expect(page).toHaveURL(/unsaved-harness$/);
|
||||||
|
await page.getByRole("link", { name: "Same page" }).click();
|
||||||
|
await expect(page).toHaveURL(/#same-page$/);
|
||||||
|
expect(prompts).toBe(1);
|
||||||
|
});
|
||||||
|
test("prefix cancel and Escape preserve edits; accepting discard resets reopening", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByRole("button", { name: "Open prefix" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Prefix text", exact: true })
|
||||||
|
.fill("VIP");
|
||||||
|
page.once("dialog", (dialog) => dialog.dismiss());
|
||||||
|
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: "Prefix text", exact: true }),
|
||||||
|
).toHaveValue("VIP");
|
||||||
|
page.once("dialog", (dialog) => dialog.dismiss());
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
page.once("dialog", (dialog) => dialog.accept());
|
||||||
|
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "Open prefix" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: "Prefix text", exact: true }),
|
||||||
|
).toHaveValue("");
|
||||||
|
});
|
||||||
|
test("successful prefix save closes without a discard prompt", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByRole("button", { name: "Open prefix" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Username", exact: true })
|
||||||
|
.fill("Alice");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Prefix text", exact: true })
|
||||||
|
.fill("VIP");
|
||||||
|
let prompts = 0;
|
||||||
|
page.on("dialog", async (dialog) => {
|
||||||
|
prompts++;
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||||
|
expect(prompts).toBe(0);
|
||||||
|
});
|
||||||
|
test("supported browser back can be cancelled without changing the address or draft", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.skip(
|
||||||
|
!(await page.evaluate(() => "navigation" in window)),
|
||||||
|
"Navigation API unavailable",
|
||||||
|
);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
history.pushState({}, "", "/admin/unsaved-harness?step=2"),
|
||||||
|
);
|
||||||
|
await page.getByLabel("Working value").fill("Keep this");
|
||||||
|
const prompt = page.waitForEvent("dialog");
|
||||||
|
await page.evaluate(() => history.back());
|
||||||
|
await (await prompt).dismiss();
|
||||||
|
await expect(page).toHaveURL(/step=2$/);
|
||||||
|
await expect(page.getByLabel("Working value")).toHaveValue("Keep this");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed prefix save keeps the editor and dirty protection", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByLabel("Fail prefix save").check();
|
||||||
|
await page.getByRole("button", { name: "Open prefix" }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Username", exact: true })
|
||||||
|
.fill("Alice");
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Prefix text", exact: true })
|
||||||
|
.fill("VIP");
|
||||||
|
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
page.once("dialog", (dialog) => dialog.dismiss());
|
||||||
|
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("textbox", { name: "Prefix text", exact: true }),
|
||||||
|
).toHaveValue("VIP");
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
async function accessible(page: Page, sandboxPreview = false) {
|
||||||
|
const builder = new AxeBuilder({ page });
|
||||||
|
// A sandbox without allow-scripts cannot run axe; its accessible name is asserted separately.
|
||||||
|
if (sandboxPreview) builder.exclude("iframe[sandbox]");
|
||||||
|
const result = await builder
|
||||||
|
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||||
|
.analyze();
|
||||||
|
expect(
|
||||||
|
result.violations.map(({ id, impact, nodes }) => ({
|
||||||
|
id,
|
||||||
|
impact,
|
||||||
|
nodes: nodes.map((node) => ({
|
||||||
|
target: node.target,
|
||||||
|
summary: node.failureSummary,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, baseURL, request }) => {
|
||||||
|
await request.post("/fixture/reset");
|
||||||
|
await page.clock.setFixedTime(new Date("2030-01-01T12:00:00Z"));
|
||||||
|
await page.route("**/*", (route) =>
|
||||||
|
new URL(route.request().url()).origin === baseURL
|
||||||
|
? route.continue()
|
||||||
|
: route.abort(),
|
||||||
|
);
|
||||||
|
page.on("pageerror", (error) => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings switches have names and remain keyboard operable", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/admin/settings");
|
||||||
|
const toggle = page.getByRole("switch", {
|
||||||
|
name: "Dark mode by default",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(toggle).not.toBeChecked();
|
||||||
|
await toggle.focus();
|
||||||
|
await page.keyboard.press("Space");
|
||||||
|
await expect(toggle).toBeChecked();
|
||||||
|
await accessible(page);
|
||||||
|
const group = page.locator("section").filter({ has: toggle });
|
||||||
|
await expect(group).toHaveScreenshot("settings-switches.png");
|
||||||
|
const response = page.waitForResponse((response) =>
|
||||||
|
response.url().endsWith("/fixture/actions/settings"),
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Save settings", exact: true })
|
||||||
|
.click();
|
||||||
|
expect((await response).ok()).toBe(true);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Save settings", exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("article form and preview dialog pass accessibility with focus return", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/admin/articles/new");
|
||||||
|
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.locator("form")
|
||||||
|
.evaluate((form) => form.scrollWidth <= form.clientWidth),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await accessible(page);
|
||||||
|
await page.locator('input[name="title"]').fill("Accessible community update");
|
||||||
|
const preview = page.getByRole("button", { name: "Preview", exact: true });
|
||||||
|
await preview.click();
|
||||||
|
const dialog = page.getByRole("dialog");
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toHaveAccessibleName("Article preview");
|
||||||
|
await expect(dialog.locator("iframe")).toHaveAttribute("title", /.+/);
|
||||||
|
await accessible(page, true);
|
||||||
|
await expect(dialog).toHaveScreenshot("article-preview.png");
|
||||||
|
// Keyboard events inside the sandbox do not bubble to the parent dialog.
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: "Close", exact: true }),
|
||||||
|
).toBeFocused();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
await expect(preview).toBeFocused();
|
||||||
|
await expect(page.getByRole("main")).toHaveScreenshot("article-form.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("support table filters and saved-view popover stay accessible", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/admin/tickets/desk");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: "Account assistance" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await accessible(page);
|
||||||
|
await expect(page.getByRole("main")).toHaveScreenshot("ticket-table.png");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Assigned to me", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/filter_assignee=mine/);
|
||||||
|
await page.getByRole("button", { name: "Saved views", exact: true }).click();
|
||||||
|
await expect(page.getByRole("textbox", { name: "View name" })).toBeVisible();
|
||||||
|
await accessible(page);
|
||||||
|
await expect(page.getByRole("main")).toHaveScreenshot(
|
||||||
|
"ticket-saved-views.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queued synchronization survives reload without duplicate submission", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/admin/import/official");
|
||||||
|
const requests: string[] = [];
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.method() === "POST" && request.url().endsWith("/sync-all"))
|
||||||
|
requests.push(request.postData() || "");
|
||||||
|
});
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Start synchronization", exact: true })
|
||||||
|
.dblclick();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("status").filter({ hasText: "Synchronization saved" }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
await page.reload();
|
||||||
|
await page.locator("details details > summary").first().click();
|
||||||
|
await expect(page.getByText("fixture_chair", { exact: true })).toBeVisible();
|
||||||
|
await accessible(page);
|
||||||
|
await expect(page.getByRole("main")).toHaveScreenshot("sync-history.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("article editor fits a narrow mobile card without horizontal overflow", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 360, height: 844 });
|
||||||
|
await page.goto("/admin/articles/new");
|
||||||
|
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page
|
||||||
|
.locator("form")
|
||||||
|
.evaluate((form) => form.scrollWidth <= form.clientWidth),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
document.documentElement.scrollWidth <=
|
||||||
|
document.documentElement.clientWidth,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
|
||||||
"entry": [
|
|
||||||
"src/app/**/page.{ts,tsx}",
|
|
||||||
"src/app/**/layout.{ts,tsx}",
|
|
||||||
"src/app/**/route.{ts,tsx}",
|
|
||||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
|
||||||
"scripts/migrate-aes-cbc-to-gcm.ts",
|
|
||||||
"scripts/build-languages-full.ts",
|
|
||||||
"scripts/translate-furnidata-full.ts",
|
|
||||||
"scripts/align-db-ids-with-furnidata.ts",
|
|
||||||
"scripts/furni-diagnose-now.ts"
|
|
||||||
],
|
|
||||||
"project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"],
|
|
||||||
"ignoreDependencies": ["pino-pretty"],
|
|
||||||
"ignoreBinaries": ["sendmail"]
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=26.8.1 <27"
|
"node": ">=26.8.2 <27"
|
||||||
},
|
},
|
||||||
"packageManager": "[email protected]+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
"packageManager": "[email protected]+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,11 +14,10 @@
|
|||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"biome:lint": "biome check .",
|
"biome:lint": "biome check .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"knip": "knip --include files,dependencies,devDependencies,unlisted,binaries",
|
|
||||||
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
"diag:permissions": "tsx scripts/diagnose-permission-page.ts",
|
||||||
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
"jobs:worker": "node --conditions=react-server --import tsx scripts/jobs-worker.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run --coverage.enabled=false",
|
||||||
"test:e2e": "playwright test",
|
"test:coverage": "vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:introspect": "drizzle-kit introspect",
|
"db:introspect": "drizzle-kit introspect",
|
||||||
@@ -32,16 +31,15 @@
|
|||||||
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
"gamedata:compress": "node scripts/compress-gamedata.mjs",
|
||||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||||
"prepare": "node scripts/prepare-hooks.mjs",
|
|
||||||
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||||
"deps:audit": "pnpm audit --audit-level=high",
|
"deps:audit": "pnpm audit --audit-level=high",
|
||||||
"analyze": "next experimental-analyze",
|
"analyze": "next experimental-analyze",
|
||||||
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
"i18n:check": "node scripts/audit-cms-translations.mjs --check",
|
||||||
"i18n:audit": "node scripts/audit-cms-translations.mjs"
|
"i18n:audit": "node scripts/audit-cms-translations.mjs",
|
||||||
},
|
"test:e2e": "playwright test",
|
||||||
"lint-staged": {
|
"test:ui": "playwright test --config playwright.ui.config.ts",
|
||||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
|
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||||
"*.{ts,tsx}": "node scripts/check-admin-colors.mjs"
|
"performance:report": "node scripts/performance-report.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.8.0",
|
"@base-ui/react": "1.8.0",
|
||||||
@@ -50,6 +48,7 @@
|
|||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
"@formatjs/icu-messageformat-parser": "3.5.17",
|
"@formatjs/icu-messageformat-parser": "3.5.17",
|
||||||
"@hookform/resolvers": "5.9.1",
|
"@hookform/resolvers": "5.9.1",
|
||||||
|
"@tanstack/react-query": "5.102.8",
|
||||||
"@tanstack/react-virtual": "3.14.10",
|
"@tanstack/react-virtual": "3.14.10",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
@@ -67,7 +66,7 @@
|
|||||||
"motion": "13.2.0",
|
"motion": "13.2.0",
|
||||||
"music-metadata": "11.15.0",
|
"music-metadata": "11.15.0",
|
||||||
"mysql2": "3.24.3",
|
"mysql2": "3.24.3",
|
||||||
"next": "16.3.4",
|
"next": "16.3.5",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.14.2",
|
"next-intl": "4.14.2",
|
||||||
"otplib": "13.5.0",
|
"otplib": "13.5.0",
|
||||||
@@ -84,9 +83,10 @@
|
|||||||
"zod": "4.5.4"
|
"zod": "4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@babel/parser": "7.29.8",
|
"@babel/parser": "7.29.8",
|
||||||
"@biomejs/biome": "2.5.12",
|
"@biomejs/biome": "2.5.12",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
@@ -95,14 +95,14 @@
|
|||||||
"@types/react-dom": "19.2.7",
|
"@types/react-dom": "19.2.7",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"husky": "9.1.7",
|
"esbuild": "0.28.2",
|
||||||
"knip": "6.34.0",
|
"msw": "2.15.0",
|
||||||
"lint-staged": "17.4.1",
|
|
||||||
"pino-pretty": "13.1.3",
|
"pino-pretty": "13.1.3",
|
||||||
"postcss": "8.5.28",
|
"postcss": "8.5.28",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"tsx": "4.23.13",
|
"tsx": "4.23.13",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
|
"vite": "8.3.0",
|
||||||
"vitest": "5.0.0"
|
"vitest": "5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
fullyParallel: true,
|
testIgnore: "**/ui/**",
|
||||||
retries: process.env.CI ? 2 : 0,
|
fullyParallel: false,
|
||||||
reporter: process.env.CI ? "github" : "list",
|
workers: 1,
|
||||||
use: { baseURL, trace: "on-first-retry" },
|
retries: 1,
|
||||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
timeout: 30000,
|
||||||
? undefined
|
reporter: [["list"], ["html", { open: "never" }]],
|
||||||
: {
|
use: {
|
||||||
command: "pnpm dev --port 3000",
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
|
||||||
url: "http://127.0.0.1:3000/api/health",
|
trace: "retain-on-failure",
|
||||||
reuseExistingServer: !process.env.CI,
|
},
|
||||||
timeout: 120_000,
|
|
||||||
},
|
|
||||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
const port = Number(process.env.UI_TEST_PORT || 3107);
|
||||||
|
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||||
|
throw Error("Invalid UI_TEST_PORT");
|
||||||
|
const baseURL = `http://127.0.0.1:${port}`;
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e/ui",
|
||||||
|
testMatch: "**/*.spec.ts",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
timeout: 45000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10000,
|
||||||
|
toHaveScreenshot: {
|
||||||
|
animations: "disabled",
|
||||||
|
caret: "hide",
|
||||||
|
maxDiffPixelRatio: 0.002,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
snapshotPathTemplate: `{testDir}/__screenshots__/${process.platform}/{projectName}/{testFilePath}/{arg}{ext}`,
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "playwright-report/ui", open: "never" }],
|
||||||
|
],
|
||||||
|
outputDir: "test-results/ui",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
locale: "en-US",
|
||||||
|
timezoneId: "UTC",
|
||||||
|
colorScheme: "dark",
|
||||||
|
contextOptions: { reducedMotion: "reduce" },
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
launchOptions: process.env.UI_TEST_BROWSER_PATH
|
||||||
|
? { executablePath: process.env.UI_TEST_BROWSER_PATH }
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "desktop",
|
||||||
|
use: { browserName: "chromium", viewport: { width: 1280, height: 900 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mobile",
|
||||||
|
use: {
|
||||||
|
browserName: "chromium",
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
isMobile: true,
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
hasTouch: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "node e2e/ui/fixture-server.mjs",
|
||||||
|
url: `${baseURL}/health`,
|
||||||
|
timeout: 60000,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
env: { UI_TEST_PORT: String(port) },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,8 +3,9 @@ allowBuilds:
|
|||||||
sharp: true
|
sharp: true
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
"@swc/core": true
|
"@swc/core": true
|
||||||
|
msw: false
|
||||||
|
|
||||||
minimumReleaseAge: 1440
|
minimumReleaseAge: 60
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
# drizzle-kit still uses an obsolete development-server dependency.
|
# drizzle-kit still uses an obsolete development-server dependency.
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
||||||
"extends": ["config:recommended", "helpers:disableTypesNodeMajor"],
|
|
||||||
"labels": ["dependencies"],
|
|
||||||
"rangeStrategy": "bump",
|
|
||||||
"dependencyDashboard": true,
|
|
||||||
"dependencyDashboardTitle": "Dependency Dashboard",
|
|
||||||
"commitMessagePrefix": "chore(deps):",
|
|
||||||
"commitMessageAction": "update",
|
|
||||||
"schedule": ["at any time"],
|
|
||||||
"timezone": "Europe/Amsterdam",
|
|
||||||
"rebaseWhen": "conflicted",
|
|
||||||
"prCreation": "immediate",
|
|
||||||
"prConcurrentLimit": 5,
|
|
||||||
"packageRules": [
|
|
||||||
{
|
|
||||||
"matchDepTypes": ["devDependencies"],
|
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
|
||||||
"groupName": "Development tools",
|
|
||||||
"automerge": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchPackageNames": [
|
|
||||||
"@base-ui/react",
|
|
||||||
"lucide-react",
|
|
||||||
"motion",
|
|
||||||
"@dnd-kit/**"
|
|
||||||
],
|
|
||||||
"matchUpdateTypes": ["minor", "patch"],
|
|
||||||
"groupName": "Interface libraries",
|
|
||||||
"automerge": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchPackageNames": ["vitest", "@vitest/coverage-v8"],
|
|
||||||
"groupName": "Vitest",
|
|
||||||
"automerge": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchUpdateTypes": ["major"],
|
|
||||||
"automerge": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matchPackageNames": ["node", "pnpm"],
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"minimumReleaseAge": "3 days",
|
|
||||||
"automerge": false
|
|
||||||
}
|
|
||||||
@@ -28,6 +28,23 @@ assert.equal(
|
|||||||
"@types/node must match the pinned Node.js major",
|
"@types/node must match the pinned Node.js major",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nodeImages = [
|
||||||
|
...readProjectFile("Dockerfile").matchAll(
|
||||||
|
/^FROM\s+(?:--platform=\S+\s+)?node:([^\s]+)\s*/gim,
|
||||||
|
),
|
||||||
|
].map((match) => match[1]);
|
||||||
|
assert.ok(
|
||||||
|
nodeImages.length > 0,
|
||||||
|
"Dockerfile must declare a pinned Node.js base image",
|
||||||
|
);
|
||||||
|
for (const image of nodeImages) {
|
||||||
|
assert.equal(
|
||||||
|
image,
|
||||||
|
`${pinnedVersion}-alpine`,
|
||||||
|
"every Docker Node.js stage must match .nvmrc",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!process.argv.includes("--static")) {
|
if (!process.argv.includes("--static")) {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
process.versions.node,
|
process.versions.node,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ sha="$(git rev-parse HEAD)"
|
|||||||
image="epicnext-cms:$sha"
|
image="epicnext-cms:$sha"
|
||||||
previous_name=""
|
previous_name=""
|
||||||
previous_image=""
|
previous_image=""
|
||||||
|
secondary_name=""
|
||||||
|
secondary_backup="epicnext-cms-rollback-secondary"
|
||||||
|
secondary_backup_created=0
|
||||||
backup_name="epicnext-cms-rollback"
|
backup_name="epicnext-cms-rollback"
|
||||||
cutover_started=0
|
cutover_started=0
|
||||||
candidate_attempted=0
|
candidate_attempted=0
|
||||||
@@ -49,8 +52,12 @@ finish() {
|
|||||||
if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then
|
if [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then
|
||||||
echo "Deployment failed; restoring previous container" >&2
|
echo "Deployment failed; restoring previous container" >&2
|
||||||
docker logs epicnext-cms-app --tail 50 >&2 || true
|
docker logs epicnext-cms-app --tail 50 >&2 || true
|
||||||
|
if command -v ss >/dev/null 2>&1; then ss -ltnp 'sport = :3002' >&2 || true; fi
|
||||||
if [ "$candidate_attempted" -eq 1 ]; then docker rm -f epicnext-cms-app || true; fi
|
if [ "$candidate_attempted" -eq 1 ]; then docker rm -f epicnext-cms-app || true; fi
|
||||||
if [ "$backup_created" -eq 1 ]; then docker rename "$backup_name" "$previous_name" || true; fi
|
if [ "$backup_created" -eq 1 ]; then docker rename "$backup_name" "$previous_name" || true; fi
|
||||||
|
if [ "$secondary_backup_created" -eq 1 ]; then
|
||||||
|
docker rename "$secondary_backup" "$secondary_name" || true
|
||||||
|
fi
|
||||||
if [ -n "$previous_name" ]; then
|
if [ -n "$previous_name" ]; then
|
||||||
if docker start "$previous_name" && healthy; then
|
if docker start "$previous_name" && healthy; then
|
||||||
echo "Rollback verified: $previous_image"
|
echo "Rollback verified: $previous_image"
|
||||||
@@ -60,6 +67,7 @@ finish() {
|
|||||||
else
|
else
|
||||||
echo "No previous container exists; rollback is unavailable" >&2
|
echo "No previous container exists; rollback is unavailable" >&2
|
||||||
fi
|
fi
|
||||||
|
if [ "$secondary_backup_created" -eq 1 ]; then docker start "$secondary_name" || true; fi
|
||||||
fi
|
fi
|
||||||
exit "$status"
|
exit "$status"
|
||||||
}
|
}
|
||||||
@@ -68,28 +76,52 @@ trap 'exit 130' INT
|
|||||||
trap 'exit 143' TERM
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
check_current
|
check_current
|
||||||
|
# Read-only ownership evidence before any build or container cutover.
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
listeners="$(ss -ltnp 'sport = :3002' 2>/dev/null || true)"
|
||||||
|
printf '%s\n' "$listeners"
|
||||||
|
while read -r listener_pid; do
|
||||||
|
[ -n "$listener_pid" ] || continue
|
||||||
|
printf 'Port owner PID=%s cwd=' "$listener_pid"
|
||||||
|
readlink "/proc/$listener_pid/cwd" || true
|
||||||
|
cat "/proc/$listener_pid/cgroup" 2>/dev/null || true
|
||||||
|
ps -o pid=,ppid=,user=,comm= -p "$listener_pid" || true
|
||||||
|
done < <(printf '%s' "$listeners" | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u)
|
||||||
|
fi
|
||||||
|
for managed_name in epicnext-cms epicnext-cms-app; do
|
||||||
|
docker inspect --format '{{.Name}} running={{.State.Running}} pid={{.State.Pid}} image={{.Image}}' "$managed_name" 2>/dev/null || true
|
||||||
|
done
|
||||||
cp "$deploy_dir/.env" .env
|
cp "$deploy_dir/.env" .env
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
# Prepare browser before cutover so installation failures cannot interrupt the site.
|
|
||||||
pnpm exec playwright install chromium
|
pnpm exec playwright install chromium
|
||||||
|
|
||||||
echo "Building $image"
|
echo "Building $image"
|
||||||
# Throw away the previous build cache before each build so disk usage doesn't
|
|
||||||
# grow unbounded across deployments. The current release image (`epicnext-cms`)
|
|
||||||
# is still reused as a base layer via `--cache-from`; only the accumulated
|
|
||||||
# BuildKit intermediate cache is discarded.
|
|
||||||
docker builder prune -af --filter "until=1h" --keep-storage=0 2>/dev/null || true
|
|
||||||
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
|
||||||
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
|
||||||
check_current
|
check_current
|
||||||
|
# Read reports from the already-built image; do not start an extra application.
|
||||||
|
report_container=""
|
||||||
|
if report_container="$(docker create --entrypoint /bin/true "$image")" && [ -n "$report_container" ]; then
|
||||||
|
mkdir -p build-reports
|
||||||
|
if docker cp "$report_container:/app/build-reports/." build-reports && [ -s build-reports/report.md ]; then
|
||||||
|
cat build-reports/report.md
|
||||||
|
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||||
|
cat build-reports/report.md >> "$GITHUB_STEP_SUMMARY" || echo "Warning: could not append performance summary" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: build performance report unavailable" >&2
|
||||||
|
fi
|
||||||
|
docker rm "$report_container" >/dev/null
|
||||||
|
else
|
||||||
|
echo "Warning: could not extract build performance report" >&2
|
||||||
|
fi
|
||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
check_current
|
check_current
|
||||||
|
|
||||||
# Prefer the active CI container, or the active legacy compose container.
|
# Prefer the active CI container, or the active legacy compose container.
|
||||||
for name in epicnext-cms-app epicnext-cms; do
|
for name in epicnext-cms epicnext-cms-app; do
|
||||||
if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then
|
if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then
|
||||||
previous_name="$name"
|
if [ -z "$previous_name" ]; then previous_name="$name"; else secondary_name="$name"; fi
|
||||||
break
|
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -z "$previous_name" ]; then
|
if [ -z "$previous_name" ]; then
|
||||||
@@ -106,11 +138,18 @@ if [ -n "$previous_name" ]; then
|
|||||||
docker tag "$previous_image" epicnext-cms:previous
|
docker tag "$previous_image" epicnext-cms:previous
|
||||||
fi
|
fi
|
||||||
# Remove a stopped leftover CI container when the compose container is active.
|
# Remove a stopped leftover CI container when the compose container is active.
|
||||||
if [ "$previous_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then
|
if [ "$previous_name" != epicnext-cms-app ] && [ "$secondary_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then
|
||||||
docker rm epicnext-cms-app
|
docker rm epicnext-cms-app
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cutover_started=1
|
cutover_started=1
|
||||||
|
# Both legacy Compose and CI containers can exist after earlier failed updates.
|
||||||
|
# Preserve each before releasing the shared host port; never kill an arbitrary PID.
|
||||||
|
if [ -n "$secondary_name" ]; then
|
||||||
|
docker stop "$secondary_name"
|
||||||
|
docker rename "$secondary_name" "$secondary_backup"
|
||||||
|
secondary_backup_created=1
|
||||||
|
fi
|
||||||
if [ -n "$previous_name" ]; then
|
if [ -n "$previous_name" ]; then
|
||||||
docker stop "$previous_name"
|
docker stop "$previous_name"
|
||||||
docker rename "$previous_name" "$backup_name"
|
docker rename "$previous_name" "$backup_name"
|
||||||
@@ -128,7 +167,7 @@ candidate_attempted=1
|
|||||||
ENV_ARGS+=(-e "$key")
|
ENV_ARGS+=(-e "$key")
|
||||||
done < "$deploy_dir/.env"
|
done < "$deploy_dir/.env"
|
||||||
docker run -d --name epicnext-cms-app --restart always --net=host \
|
docker run -d --name epicnext-cms-app --restart always --net=host \
|
||||||
"${ENV_ARGS[@]}" \
|
"${ENV_ARGS[@]}" -e PORT=3002 -e HOSTNAME=0.0.0.0 \
|
||||||
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
|
||||||
-v "$deploy_dir/public/swf:/app/public/swf" \
|
-v "$deploy_dir/public/swf:/app/public/swf" \
|
||||||
-v "$deploy_dir/storage:/app/storage" \
|
-v "$deploy_dir/storage:/app/storage" \
|
||||||
@@ -136,19 +175,24 @@ candidate_attempted=1
|
|||||||
"$image"
|
"$image"
|
||||||
)
|
)
|
||||||
healthy
|
healthy
|
||||||
|
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
|
||||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
|
||||||
# Publish the latest alias only after health and browser checks pass.
|
# Publish the latest alias only after HTTP and browser checks pass.
|
||||||
docker tag "$image" epicnext-cms:latest
|
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
|
||||||
|
docker tag "$verified_image" "epicnext-cms:verified-$sha"
|
||||||
|
docker tag "$verified_image" epicnext-cms:latest
|
||||||
cutover_started=0
|
cutover_started=0
|
||||||
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
|
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
|
||||||
|
if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || true; fi
|
||||||
|
|
||||||
echo "Deployment verified: $sha"
|
echo "Deployment verified: $sha"
|
||||||
# Retain the current and previous releases; do not remove arbitrary named tags.
|
# Retain the current and previous releases; do not remove arbitrary named tags.
|
||||||
while IFS= read -r tag; do
|
while IFS= read -r tag; do
|
||||||
if [[ "$tag" =~ ^epicnext-cms:[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ]; then
|
if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
|
||||||
tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)"
|
tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)"
|
||||||
if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi
|
if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi
|
||||||
fi
|
fi
|
||||||
done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' epicnext-cms)
|
done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' epicnext-cms)
|
||||||
docker builder prune -af --filter "until=72h" --keep-storage=2g || true
|
# Reclaim build cache, unreferenced images and long-stopped containers. Never
|
||||||
docker image prune -f --filter "until=168h" || true
|
# volumes; retention boundaries are enforced inside docker-prune.sh.
|
||||||
|
bash "$deploy_dir/scripts/docker-prune.sh" || true
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import "./load-env";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [dp] = (await db.execute(sql`
|
||||||
|
SELECT CAST(parent_id AS CHAR) parentId, CAST(order_num AS CHAR) orderNum, COUNT(*) c, GROUP_CONCAT(id) ids
|
||||||
|
FROM catalog_pages
|
||||||
|
GROUP BY parent_id, order_num
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
`)) as unknown as [Array<Record<string, unknown>>, unknown];
|
||||||
|
console.log("=== catalog_pages duplicate (parent, order_num) groups ===");
|
||||||
|
console.log(JSON.stringify(dp));
|
||||||
|
|
||||||
|
const [children] = (await db.execute(sql`
|
||||||
|
SELECT CAST(parent_id AS CHAR) parentId, COUNT(*) c
|
||||||
|
FROM catalog_pages
|
||||||
|
WHERE parent_id <> 0
|
||||||
|
GROUP BY parent_id ORDER BY c DESC LIMIT 8
|
||||||
|
`)) as unknown as [Array<Record<string, unknown>>, unknown];
|
||||||
|
console.log("=== most children per parent ===");
|
||||||
|
console.log(JSON.stringify(children));
|
||||||
|
|
||||||
|
const [pages] = (await db.execute(sql`
|
||||||
|
SELECT CAST(id AS CHAR) id, CAST(parent_id AS CHAR) parentId, CAST(order_num AS CHAR) orderNum, caption_save caption, enabled
|
||||||
|
FROM catalog_pages WHERE id IN (353,354,355,356,357,358)
|
||||||
|
`)) as unknown as [Array<Record<string, unknown>>, unknown];
|
||||||
|
console.log("=== pages 353-358 ===");
|
||||||
|
console.log(JSON.stringify(pages, null, 1));
|
||||||
|
await db.$client?.end?.();
|
||||||
|
}
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Read installation metadata as data, never execute it as a shell script.
|
||||||
|
load_docker_config() {
|
||||||
|
local root="$1" key value mode="" public_url="" repository=""
|
||||||
|
[[ -f "$root/.docker-install" ]] || return 0
|
||||||
|
while IFS='=' read -r key value || [[ -n "$key" ]]; do
|
||||||
|
value="${value%$'\r'}"
|
||||||
|
case "$key" in
|
||||||
|
''|'#'*) continue ;;
|
||||||
|
MODE) [[ "$value" = prebuilt || "$value" = source ]] || return 1; mode="$value" ;;
|
||||||
|
PUBLIC_URL) [[ "$value" =~ ^https?://[^[:space:]]+$ && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || return 1; public_url="$value" ;;
|
||||||
|
*) echo "Unknown installation setting: $key" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done < "$root/.docker-install"
|
||||||
|
[[ -n "$mode" && -n "$public_url" ]] || return 1
|
||||||
|
if [[ "$mode" = prebuilt && ! ${CMS_IMAGE_REPOSITORY+x} ]]; then
|
||||||
|
IFS= read -r repository < "$root/docker-image.txt"
|
||||||
|
repository="${repository%$'\r'}"
|
||||||
|
[[ "$repository" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || return 1
|
||||||
|
export CMS_IMAGE_REPOSITORY="$repository"
|
||||||
|
fi
|
||||||
|
CMS_INSTALL_MODE="$mode"
|
||||||
|
export CMS_PUBLIC_URL="${CMS_PUBLIC_URL-$public_url}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Guided setup for the existing Linux/host-network Compose deployment.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$DIR"
|
||||||
|
configure_only=0
|
||||||
|
case "${1:-}" in '') ;; --configure-only) configure_only=1 ;; *) echo "Usage: bash cms install [--configure-only]" >&2; exit 1 ;; esac
|
||||||
|
[[ $# -le 1 ]] || exit 1
|
||||||
|
umask 077
|
||||||
|
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
[[ "$(uname -s)" = Linux ]] || fail "Use a Linux Docker host. This Compose deployment uses host networking."
|
||||||
|
for command in docker git flock od tr mktemp; do command -v "$command" >/dev/null || fail "Install required command: $command"; done
|
||||||
|
docker info >/dev/null 2>&1 || fail "Docker is not reachable. Start Docker and check your account permissions."
|
||||||
|
docker compose version >/dev/null 2>&1 || fail "Install the Docker Compose plugin."
|
||||||
|
exec 9>"$DIR/.deploy.lock"
|
||||||
|
flock -w 30 9 || fail "Another installation or update is running."
|
||||||
|
[[ ! -L .env && ! -L .docker-install ]] || fail "Configuration files must not be symbolic links."
|
||||||
|
[[ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" != true ]] || fail "This host is managed by CI. Do not create a second Compose installation."
|
||||||
|
source "$DIR/scripts/docker-config.sh"
|
||||||
|
load_docker_config "$DIR" || fail "Existing .docker-install is invalid; correct it before continuing."
|
||||||
|
read_value() {
|
||||||
|
local label="$1" default="${2:-}" secret="${3:-0}" value
|
||||||
|
printf '%s' "$label" >&2
|
||||||
|
[[ -z "$default" ]] || printf ' [%s]' "$default" >&2
|
||||||
|
printf ': ' >&2
|
||||||
|
if [[ "$secret" = 1 ]]; then
|
||||||
|
IFS= read -r -s value || fail "Input cancelled."
|
||||||
|
printf '\n' >&2
|
||||||
|
else IFS= read -r value || fail "Input cancelled."; fi
|
||||||
|
REPLY="${value:-$default}"
|
||||||
|
}
|
||||||
|
safe_value() { [[ -n "$1" && "$1" != *"'"* && "$1" != *'$'* && "$1" != *'\'* && ! "$1" =~ [[:cntrl:]] ]]; }
|
||||||
|
http_url() { safe_value "$1" && [[ "$1" =~ ^https?://[^[:space:]]+$ && "$1" != *'@'* && "$1" != *'#'* ]]; }
|
||||||
|
uri_encode() {
|
||||||
|
local LC_ALL=C text="$1" i char
|
||||||
|
for ((i=0;i<${#text};i++)); do
|
||||||
|
char="${text:i:1}"
|
||||||
|
case "$char" in [a-zA-Z0-9.~_-]) printf '%s' "$char" ;; *) printf '%%%02X' "'$char" ;; esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
printf '%s\n' 'EpicNext-Cms installation' 'Requires an existing Habbo database and Redis. Configure HTTPS/reverse proxy to this host on port 3002.'
|
||||||
|
read_value 'Public CMS URL (e.g. https://hotel.example)' "${CMS_PUBLIC_URL:-}"
|
||||||
|
public_url="${REPLY%/}"
|
||||||
|
http_url "$public_url" && [[ "$public_url" != *'?'* ]] || fail "Enter an HTTP(S) URL without credentials, query or fragment."
|
||||||
|
read_value 'Delivery method: prebuilt or source' "${CMS_INSTALL_MODE:-prebuilt}"
|
||||||
|
mode="$REPLY"
|
||||||
|
[[ "$mode" = prebuilt || "$mode" = source ]] || fail "Choose prebuilt or source."
|
||||||
|
if [[ "$mode" = prebuilt ]]; then
|
||||||
|
IFS= read -r repository < docker-image.txt
|
||||||
|
repository="${repository%$'\r'}"
|
||||||
|
[[ "$repository" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || fail "Invalid docker-image.txt."
|
||||||
|
printf 'Image location is provided by the repository: %s\n' "$repository"
|
||||||
|
fi
|
||||||
|
env_tmp=""; profile_tmp=""
|
||||||
|
cleanup() { [[ -z "$env_tmp" ]] || rm -f -- "$env_tmp"; [[ -z "$profile_tmp" ]] || rm -f -- "$profile_tmp"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
if [[ -e .env ]]; then
|
||||||
|
[[ -f .env ]] || fail ".env must be a regular file."
|
||||||
|
printf '%s\n' 'Existing .env preserved. Check APP_URL/AUTH_URL and database settings there if needed.'
|
||||||
|
else
|
||||||
|
read_value 'Hotel name'; hotel="$REPLY"; safe_value "$hotel" || fail "Invalid hotel name (avoid quotes, dollar signs and backslashes)."
|
||||||
|
read_value 'Database host' '127.0.0.1'; db_host="$REPLY"
|
||||||
|
[[ "$db_host" =~ ^[a-zA-Z0-9.-]+$ ]] || fail "Invalid database hostname."
|
||||||
|
read_value 'Database port' '3306'; db_port="$REPLY"
|
||||||
|
[[ "$db_port" =~ ^[0-9]{1,5}$ ]] && ((10#$db_port>0 && 10#$db_port<=65535)) || fail "Invalid database port."
|
||||||
|
read_value 'Existing database name'; db_name="$REPLY"; [[ -n "$db_name" ]] || fail "Database name is required."
|
||||||
|
read_value 'Database user'; db_user="$REPLY"; [[ -n "$db_user" ]] || fail "Database user is required."
|
||||||
|
read_value 'Database password (hidden)' '' 1; db_password="$REPLY"
|
||||||
|
database_url="mysql://$(uri_encode "$db_user"):$(uri_encode "$db_password")@$db_host:$db_port/$(uri_encode "$db_name")?charset=utf8mb4"
|
||||||
|
unset db_password
|
||||||
|
read_value 'Redis URL (hidden; percent-encode special characters in credentials)' 'redis://127.0.0.1:6379' 1; redis_url="$REPLY"
|
||||||
|
safe_value "$redis_url" && [[ "$redis_url" =~ ^rediss?://[^[:space:]]+$ ]] || fail "Invalid Redis URL."
|
||||||
|
read_value 'Avatar imager URL'; imager_url="$REPLY"; http_url "$imager_url" || fail "Invalid imager URL."
|
||||||
|
auth_secret="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
|
||||||
|
[[ "$auth_secret" =~ ^[0-9a-f]{64}$ ]] || fail "Could not generate the authentication secret."
|
||||||
|
env_tmp="$(mktemp "$DIR/.env.install.XXXXXX")"
|
||||||
|
{
|
||||||
|
printf '%s\n' 'NODE_ENV=production' 'PORT=3002' 'NEXT_TELEMETRY_DISABLED=1' 'AUTH_TRUST_HOST=true' 'CONVERT_PASSWORDS=true'
|
||||||
|
printf "HOTEL_NAME='%s'\nAPP_URL='%s'\nAUTH_URL='%s'\n" "$hotel" "$public_url" "$public_url"
|
||||||
|
printf "DATABASE_URL='%s'\nREDIS_URL='%s'\nAUTH_SECRET='%s'\n" "$database_url" "$redis_url" "$auth_secret"
|
||||||
|
printf "IMAGER_URL='%s'\nIMAGING_UPSTREAM_URL='%s'\nBADGE_URL='/swf/c_images/album1584'\n" "$imager_url" "$imager_url"
|
||||||
|
printf '%s\n' 'RCON_HOST=127.0.0.1' 'RCON_PORT=3003' '# Existing Laravel 2FA: add the original APP_KEY when migrating.'
|
||||||
|
} > "$env_tmp"
|
||||||
|
# Atomic creation fails rather than overwriting a concurrently created .env.
|
||||||
|
ln "$env_tmp" "$DIR/.env" || fail ".env already exists; nothing was overwritten."
|
||||||
|
unset auth_secret database_url redis_url
|
||||||
|
fi
|
||||||
|
profile_tmp="$(mktemp "$DIR/.docker-install.tmp.XXXXXX")"
|
||||||
|
printf 'MODE=%s\nPUBLIC_URL=%s\n' "$mode" "$public_url" > "$profile_tmp"
|
||||||
|
mv -f -- "$profile_tmp" "$DIR/.docker-install"; profile_tmp=""
|
||||||
|
printf '%s\n' 'Configuration saved. Credentials remain in .env; installation settings are excluded from Git.'
|
||||||
|
if [[ "$configure_only" = 1 ]]; then printf '%s\n' 'No container was started. Next: bash cms install'; exit 0; fi
|
||||||
|
as_root() { if [[ "$EUID" = 0 ]]; then "$@"; else command -v sudo >/dev/null || fail "sudo is required to prepare runtime directories."; sudo "$@"; fi; }
|
||||||
|
for path in "$DIR/public/nitro-assets" "$DIR/public/swf" "$DIR/storage" /var/www/Gamedata; do
|
||||||
|
[[ ! -L "$path" ]] || fail "Runtime directory is a symlink: $path. Configure its permissions manually."
|
||||||
|
if [[ ! -d "$path" ]]; then as_root install -d -o 33 -g 33 -m 0755 "$path"; fi
|
||||||
|
# Do not recursively change ownership of existing assets.
|
||||||
|
[[ "$(stat -c '%u:%g' "$path")" = 33:33 ]] || as_root chown 33:33 "$path"
|
||||||
|
done
|
||||||
|
cleanup
|
||||||
|
trap - EXIT
|
||||||
|
exec 9>&-
|
||||||
|
# Avoid a stale value exported while reading previous installation settings.
|
||||||
|
unset CMS_IMAGE_REPOSITORY CMS_PUBLIC_URL
|
||||||
|
exec bash "$DIR/scripts/docker-update.sh"
|
||||||
@@ -124,7 +124,13 @@ check_dep git
|
|||||||
|
|
||||||
doing "5. Port 3002 state (host CMS clash)"
|
doing "5. Port 3002 state (host CMS clash)"
|
||||||
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
|
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
|
||||||
err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container"
|
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms 2>/dev/null || true)" = true ]; then
|
||||||
|
ok "existing Compose CMS is running; use docker-update.sh to recreate and verify its release"
|
||||||
|
elif [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
|
||||||
|
wrn "CI manages the running CMS; update through CI instead of starting Compose"
|
||||||
|
else
|
||||||
|
err "port $CMS_PORT is occupied by another process; identify it before starting the CMS"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
ok "port $CMS_PORT free"
|
ok "port $CMS_PORT free"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Reclaim Docker's unused cache so host storage stays bounded.
|
||||||
|
#
|
||||||
|
# Modes:
|
||||||
|
# (default) — gentle, age-windowed (keeps rollback + rebuild speed):
|
||||||
|
# - BuildKit cache older than 72h, hard-capped at 4 GB (Debian /pnpm store
|
||||||
|
# is shared across builds; everything newer speeds up rebuilds).
|
||||||
|
# - Images referenced by NO container and older than 7 days.
|
||||||
|
# - Containers stopped for more than 24h.
|
||||||
|
# --force — emergency mode ("never let the disk max out"): drops every age
|
||||||
|
# window and reclaims all unused bytes Docker can free:
|
||||||
|
# - ALL unreferenced build cache,
|
||||||
|
# - ALL unreferenced images (no 7-day grace),
|
||||||
|
# - ALL stopped containers.
|
||||||
|
# Trade-off: waiting rebuilds re-fetch deps/images later.
|
||||||
|
#
|
||||||
|
# Volumes are NEVER pruned in either mode: mariadb-turbo-data is a database.
|
||||||
|
# Idempotent; exits 0 when Docker is unavailable.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
LOG_DIR="${LOG_DIR:-$DIR/logs}"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG_FILE="$LOG_DIR/docker-prune.log"
|
||||||
|
now() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||||
|
|
||||||
|
FORCE=0
|
||||||
|
if [[ "${1:-}" == "--force" ]]; then
|
||||||
|
FORCE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || {
|
||||||
|
printf '[%s] docker CLI unavailable; nothing to prune\n' "$(now)" >>"$LOG_FILE"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '\n[%s] === docker prune start%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||||
|
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||||
|
|
||||||
|
if (( FORCE )); then
|
||||||
|
docker builder prune -af >>"$LOG_FILE" 2>&1 || true
|
||||||
|
docker image prune -af >>"$LOG_FILE" 2>&1 || true
|
||||||
|
docker container prune -f >>"$LOG_FILE" 2>&1 || true
|
||||||
|
else
|
||||||
|
docker builder prune -af --filter "until=72h" --max-used-space=4g >>"$LOG_FILE" 2>&1 || true
|
||||||
|
docker image prune -af --filter "until=168h" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
docker container prune -f --filter "until=24h" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n[%s] === docker prune complete%s ===\n' "$(now)" "$( (( FORCE )) && printf ' (FORCE)' )" >>"$LOG_FILE"
|
||||||
|
docker system df >>"$LOG_FILE" 2>&1 || true
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Fail before listening if an installation has no valid runtime configuration.
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
export function validateRuntime(settings) {
|
||||||
|
const invalid = [];
|
||||||
|
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
|
||||||
|
invalid.push("HOTEL_NAME");
|
||||||
|
if (
|
||||||
|
!settings.AUTH_SECRET ||
|
||||||
|
settings.AUTH_SECRET.length < 32 ||
|
||||||
|
settings.AUTH_SECRET.startsWith("build-fixture-")
|
||||||
|
)
|
||||||
|
invalid.push("AUTH_SECRET");
|
||||||
|
for (const [key, protocols] of [
|
||||||
|
["DATABASE_URL", ["mysql:"]],
|
||||||
|
["APP_URL", ["http:", "https:"]],
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
if (!protocols.includes(new URL(settings[key]).protocol))
|
||||||
|
invalid.push(key);
|
||||||
|
} catch {
|
||||||
|
invalid.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (invalid.length)
|
||||||
|
throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
try {
|
||||||
|
validateRuntime(process.env);
|
||||||
|
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
|
||||||
|
for (const signal of ["SIGTERM", "SIGINT"])
|
||||||
|
process.on(signal, () => child.kill(signal));
|
||||||
|
child.on("error", () => {
|
||||||
|
console.error("CMS process could not start");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
process.exitCode = code ?? 1;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,119 +1,156 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# Update a Linux Docker Compose clone from its configured Git upstream.
|
||||||
# docker-update.sh — Automatic daily update for the Dockerized EpicNext-CMS.
|
set -Eeuo pipefail
|
||||||
#
|
|
||||||
# Steps:
|
|
||||||
# 1. Verify the working tree is clean (uncommitted changes abort).
|
|
||||||
# 2. git pull (fast-forward only).
|
|
||||||
# 3. Run CMS migrations on the HOST (the slim runtime container has no source).
|
|
||||||
# 4. docker compose build (auto-detects pnpm/yarn/npm via lockfile).
|
|
||||||
# 5. docker compose up -d && wait for a healthy container.
|
|
||||||
# 6. Record everything in update.log.
|
|
||||||
#
|
|
||||||
# Exit codes: 0 ok, 1 update skipped, 2 build/deploy failed, 3 health failed.
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
cd "$DIR" || exit 2
|
cd "$DIR"
|
||||||
|
|
||||||
# Share the CI lock before pulling or touching the live application.
|
|
||||||
exec 9>"$DIR/.deploy.lock"
|
exec 9>"$DIR/.deploy.lock"
|
||||||
flock -w 1800 9 || exit 2
|
flock -w 1800 9
|
||||||
|
|
||||||
LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
|
LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
|
||||||
PM2_APP="${PM2_APP:-next}" # host-side CMS that must stay stopped (port 3002)
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
|
||||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
|
die() { log "ERROR: $*"; exit 1; }
|
||||||
die() { log "ERROR: $*"; exit "${2:-2}"; }
|
migration_image=""
|
||||||
|
remote_migration_image=""
|
||||||
touch "$LOG_FILE"
|
previous_image=""
|
||||||
|
previous_release=""
|
||||||
log "=== Start docker-update ==="
|
rollback_tag=""
|
||||||
|
cutover=0
|
||||||
# --- 0. Preflight: verify this VPS is ready (permissions, ports, deps) ---
|
probe='const r=await fetch(process.argv[1],{cache:"no-store",signal:AbortSignal.timeout(5000)});const d=await r.json();if(!r.ok||d.database!==true||d.release!==process.argv[2]){console.error(JSON.stringify({http:r.status,database:d.database,release:d.release,expected:process.argv[2]}));process.exit(1)}'
|
||||||
if ! "$DIR/scripts/docker-preflight.sh"; then
|
verify_container() {
|
||||||
die "preflight failed — fix issues first (see '--fix' flag)" 1
|
local target="$1" release="$2" attempt
|
||||||
fi
|
for attempt in $(seq 1 30); do
|
||||||
log "preflight OK"
|
if docker exec "$target" node --input-type=module -e "$probe" "http://127.0.0.1:3002/api/health" "$release" >>"$LOG_FILE" 2>&1; then return 0; fi
|
||||||
|
sleep 3
|
||||||
# --- 0b. Guard: uncommitted changes would break git pull / taint deploys ---
|
done
|
||||||
if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then
|
return 1
|
||||||
die "working tree has uncommitted changes; commit or stash first" 1
|
}
|
||||||
fi
|
finish() {
|
||||||
|
local status=$? restored
|
||||||
# --- 1. Pull latest ---
|
trap - EXIT
|
||||||
git pull --ff-only --quiet 2>>"$LOG_FILE"
|
if [[ "$status" != 0 && "$cutover" = 1 ]]; then
|
||||||
pull_status=$?
|
docker compose logs --tail 100 cms >>"$LOG_FILE" 2>&1 || true
|
||||||
if [ $pull_status -ne 0 ]; then
|
if [[ -n "$previous_image" ]]; then
|
||||||
die "git pull failed (status $pull_status)" 1
|
log "Update failed; restoring previous image $previous_image. Database migrations are not reversed."
|
||||||
fi
|
if CMS_RELEASE="$rollback_tag" docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1; then
|
||||||
log "git pull OK: $(git rev-parse --short HEAD)"
|
restored="$(docker compose ps -q cms 2>>"$LOG_FILE" || true)"
|
||||||
|
if [[ -n "$restored" && "$(docker inspect --format '{{.Image}}' "$restored")" = "$previous_image" ]] && verify_container "$restored" "$previous_release"; then
|
||||||
# --- 2. Host-side migrations (idempotent; only applies CMS-owned tables) ---
|
log "Rollback verified locally: $previous_release. Check public routing separately."
|
||||||
if [ -f pnpm-lock.yaml ] && command -v pnpm >/dev/null 2>&1; then
|
else
|
||||||
pnpm db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (pnpm) failed"
|
log "ERROR: rollback verification failed. Inspect $LOG_FILE; previous image retained as epicnext-cms:$rollback_tag."
|
||||||
elif command -v npm >/dev/null 2>&1; then
|
fi
|
||||||
npm run db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (npm) failed"
|
else
|
||||||
else
|
log "ERROR: rollback could not recreate CMS. Previous image retained as epicnext-cms:$rollback_tag."
|
||||||
die "no package manager found for migrations" 2
|
fi
|
||||||
fi
|
else
|
||||||
log "db:migrate OK"
|
log "First installation failed: no previous image exists to restore. Candidate retained for diagnosis."
|
||||||
|
fi
|
||||||
# --- 3. Node major gate (patches auto, major upgrades need review) ---
|
|
||||||
# `node:alpine` floats within, then across, Node majors. Patches/minors are
|
|
||||||
# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for
|
|
||||||
# native addons / Next compatibility, so require an explicit review before it
|
|
||||||
# goes live. Compare the major of the deployed runtime image vs the floating
|
|
||||||
# tag; abort (not deploy) when they differ.
|
|
||||||
deployed_major="$(docker inspect --format '{{.Config.Image}}' epicnext-cms 2>/dev/null || true)"
|
|
||||||
# Resolve the currently-deployed Node major from its image.
|
|
||||||
if [ -n "$deployed_major" ] && docker image inspect "$deployed_major" >/dev/null 2>&1; then
|
|
||||||
deployed_major="$(docker run --rm --entrypoint sh "$deployed_major" -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
|
|
||||||
fi
|
|
||||||
float_major="$(docker run --rm --entrypoint sh node:alpine -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
|
|
||||||
if [ -n "$deployed_major" ] && [ -n "$float_major" ] && [ "$deployed_major" != "$float_major" ]; then
|
|
||||||
die "Node major change detected (deployed v$deployed_major, floating tag v$float_major). Major upgrades require review; update engines/Dockerfile deliberately first." 1
|
|
||||||
fi
|
|
||||||
log "Node major gate OK (major=${float_major:-?})"
|
|
||||||
|
|
||||||
# --- 4. Rebuild the image ---
|
|
||||||
# Reset the BuildKit cache first so the build doesn't accumulate unbounded
|
|
||||||
# layers on disk across daily rebuilds.
|
|
||||||
docker builder prune -af --filter "until=1h" --keep-storage=0 2>>"$LOG_FILE" || true
|
|
||||||
docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
|
|
||||||
log "docker compose build OK"
|
|
||||||
|
|
||||||
# --- 5. Recreate the container ---
|
|
||||||
docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
|
|
||||||
log "docker compose up OK"
|
|
||||||
|
|
||||||
# --- 6. Wait for health (up to ~4 min) ---
|
|
||||||
healthy=0
|
|
||||||
for i in $(seq 1 16); do
|
|
||||||
status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)"
|
|
||||||
case "$status" in
|
|
||||||
healthy) healthy=1; break ;;
|
|
||||||
unhealthy) break ;;
|
|
||||||
esac
|
|
||||||
sleep 15
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$healthy" -eq 1 ]; then
|
|
||||||
log "CMS healthy after update (commit $(git rev-parse --short HEAD))"
|
|
||||||
else
|
|
||||||
log "WARNING: container not healthy (status='${status:-unknown}')"
|
|
||||||
# Leave the container running so it can be debugged; report failure exit.
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 7. Make sure the stale host-side PM2 CMS stays stopped ---
|
|
||||||
if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then
|
|
||||||
if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then
|
|
||||||
pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"
|
|
||||||
fi
|
fi
|
||||||
fi
|
if [[ -n "$migration_image" ]]; then docker image rm "$migration_image" >>"$LOG_FILE" 2>&1 || true; fi
|
||||||
|
if [[ -n "$remote_migration_image" ]]; then docker image rm "$remote_migration_image" >>"$LOG_FILE" 2>&1 || true; fi
|
||||||
|
# Keep the recovery tag on failure for manual recovery, including same-commit rebuilds.
|
||||||
|
if [[ ( "$status" = 0 || "$cutover" = 0 ) && -n "$rollback_tag" ]]; then docker image rm "epicnext-cms:$rollback_tag" >>"$LOG_FILE" 2>&1 || true; fi
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
trap finish EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
|
||||||
|
|
||||||
log "=== docker-update finished OK ==="
|
# An existing CI deployment is a different owner of the same host port.
|
||||||
exit 0
|
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
|
||||||
|
die "This host is managed by CI (epicnext-cms-app). Update through CI, not a second Compose deployment."
|
||||||
|
fi
|
||||||
|
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
|
||||||
|
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
|
||||||
|
script_before="$(git hash-object scripts/docker-update.sh)"
|
||||||
|
git pull --ff-only >>"$LOG_FILE" 2>&1
|
||||||
|
if [ "$script_before" != "$(git hash-object scripts/docker-update.sh)" ]; then
|
||||||
|
log "Updater changed; restarting the newly pulled script."
|
||||||
|
exec 9>&-
|
||||||
|
exec bash "$DIR/scripts/docker-update.sh"
|
||||||
|
fi
|
||||||
|
# Load after pulling so image location changes follow the repository.
|
||||||
|
source "$DIR/scripts/docker-config.sh"
|
||||||
|
load_docker_config "$DIR" || die "Invalid .docker-install: expected MODE=prebuilt or source and PUBLIC_URL=http(s)://your-host."
|
||||||
|
export CMS_RELEASE="$(git rev-parse HEAD)"
|
||||||
|
[[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit."
|
||||||
|
[[ -f .env ]] || die "Create .env before installing or updating."
|
||||||
|
docker info >/dev/null
|
||||||
|
docker compose config --quiet
|
||||||
|
previous_container="$(docker compose ps -q cms)"
|
||||||
|
if [[ -n "$previous_container" ]]; then
|
||||||
|
previous_image="$(docker inspect --format '{{.Image}}' "$previous_container")"
|
||||||
|
previous_release="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$previous_image")"
|
||||||
|
[[ "$previous_release" =~ ^[0-9a-f]{40}$ ]] || die "Previous image lacks a valid release label; automatic rollback cannot be verified."
|
||||||
|
rollback_tag="rollback-$CMS_RELEASE-$$"
|
||||||
|
docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
|
||||||
|
fi
|
||||||
|
log "Preparing release $CMS_RELEASE from $DIR"
|
||||||
|
# The migrations stage contains matching source and locked dependencies.
|
||||||
|
# No Node/package manager installation on the host is required.
|
||||||
|
migration_image="epicnext-cms-migrations:$CMS_RELEASE"
|
||||||
|
if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
||||||
|
[[ "$CMS_IMAGE_REPOSITORY" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || die "Invalid CMS_IMAGE_REPOSITORY; use registry/owner/image without a tag."
|
||||||
|
log "Pulling prebuilt application and matching migrations for $CMS_RELEASE"
|
||||||
|
docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Application image unavailable. Publication for this commit may still be running; retry bash cms update after CI succeeds. For private packages, log in to the registry first."
|
||||||
|
remote_migration_image="$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations"
|
||||||
|
docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1 || die "Matching migrations image unavailable. Current CMS is unchanged; retry after publication succeeds."
|
||||||
|
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" "epicnext-cms:$CMS_RELEASE"
|
||||||
|
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" "$migration_image"
|
||||||
|
else
|
||||||
|
docker build --network=host --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
|
||||||
|
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
|
||||||
|
fi
|
||||||
|
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
|
||||||
|
revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$expected_image")"
|
||||||
|
[[ "$revision" = "$CMS_RELEASE" ]] || die "Built image has revision $revision, expected $CMS_RELEASE."
|
||||||
|
migration_mounts=(--mount "type=bind,source=$DIR/.env,target=/app/.env,readonly")
|
||||||
|
if [[ -f "$DIR/.env.local" ]]; then migration_mounts+=(--mount "type=bind,source=$DIR/.env.local,target=/app/.env.local,readonly"); fi
|
||||||
|
docker run --rm --network host "${migration_mounts[@]}" --entrypoint pnpm "$migration_image" db:migrate >>"$LOG_FILE" 2>&1
|
||||||
|
log "Build and migrations completed; recreating only the CMS service."
|
||||||
|
cutover=1
|
||||||
|
docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1
|
||||||
|
container="$(docker compose ps -q cms)"
|
||||||
|
[[ -n "$container" ]] || die "Compose did not start the CMS container."
|
||||||
|
actual_image="$(docker inspect --format '{{.Image}}' "$container")"
|
||||||
|
[[ "$actual_image" = "$expected_image" ]] || die "Running image $actual_image differs from built image $expected_image."
|
||||||
|
verify_container "$container" "$CMS_RELEASE" || die "HTTP health/release verification failed."
|
||||||
|
if [[ -n "${CMS_PUBLIC_URL:-}" ]]; then
|
||||||
|
[[ "$CMS_PUBLIC_URL" = https://* || "$CMS_PUBLIC_URL" = http://* ]] || die "CMS_PUBLIC_URL must be an HTTP(S) URL."
|
||||||
|
docker exec "$container" node --input-type=module -e "$probe" "${CMS_PUBLIC_URL%/}/api/health?release=$CMS_RELEASE" "$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Public domain serves another release or is unhealthy. Check reverse proxy/CDN destination."
|
||||||
|
log "Public URL verified: $CMS_PUBLIC_URL"
|
||||||
|
else
|
||||||
|
log "Public domain was not checked. Set CMS_PUBLIC_URL to verify reverse proxy/CDN routing as well."
|
||||||
|
fi
|
||||||
|
log "Verified release $CMS_RELEASE, image $actual_image, container $container"
|
||||||
|
cutover=0
|
||||||
|
# Record only this checkout's successful release tags; never prune Docker globally.
|
||||||
|
history="$DIR/logs/docker-release-history.log"
|
||||||
|
mkdir -p "$DIR/logs"
|
||||||
|
touch "$history"
|
||||||
|
mapfile -t releases < <(printf '%s\n' "$CMS_RELEASE" "$previous_release"; cat "$history")
|
||||||
|
kept=()
|
||||||
|
pending=()
|
||||||
|
for release in "${releases[@]}"; do
|
||||||
|
[[ "$release" =~ ^[0-9a-f]{40}$ ]] || continue
|
||||||
|
[[ " ${kept[*]} ${pending[*]} " != *" $release "* ]] || continue
|
||||||
|
if [[ "${#kept[@]}" -lt 2 ]]; then kept+=("$release"); continue; fi
|
||||||
|
# Even stopped containers belonging to other deployments protect an image.
|
||||||
|
if users="$(docker ps -aq --filter "ancestor=epicnext-cms:$release")" && [[ -z "$users" ]] && docker image rm "epicnext-cms:$release" >>"$LOG_FILE" 2>&1; then
|
||||||
|
if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
|
||||||
|
if remote_users="$(docker ps -aq --filter "ancestor=$CMS_IMAGE_REPOSITORY:$release")" && [[ -z "$remote_users" ]]; then
|
||||||
|
docker image rm "$CMS_IMAGE_REPOSITORY:$release" >>"$LOG_FILE" 2>&1 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
log "Removed superseded release tag $release"
|
||||||
|
else
|
||||||
|
pending+=("$release")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s\n' "${kept[@]}" "${pending[@]}" > "$history.tmp"
|
||||||
|
mv "$history.tmp" "$history"
|
||||||
|
log "Keeping the two latest releases; in-use images and persistent volumes are preserved."
|
||||||
|
# Reclaim build cache + unreferenced images + long-stopped containers only;
|
||||||
|
# the scoped retention is enforced inside docker-prune.sh (never volumes).
|
||||||
|
bash "$DIR/scripts/docker-prune.sh" >>"$LOG_FILE" 2>&1 || log "Docker prune reported an error (see $LOG_FILE)"
|
||||||
|
log "Pruned unused Docker cache."
|
||||||
@@ -8,8 +8,7 @@
|
|||||||
// drizzle-kit (`pnpm db:generate` / studio / introspect) is draft/browse tooling only.
|
// drizzle-kit (`pnpm db:generate` / studio / introspect) is draft/browse tooling only.
|
||||||
//
|
//
|
||||||
// Usage: pnpm db:schema:generate
|
// Usage: pnpm db:schema:generate
|
||||||
import "dotenv/config";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -31,6 +30,13 @@ const ROOT = resolve(__dirname, "..");
|
|||||||
const SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
|
const SCHEMA_TS = resolve(ROOT, "src/db/schema.ts");
|
||||||
const OUT = SCHEMA_TS;
|
const OUT = SCHEMA_TS;
|
||||||
|
|
||||||
|
// Load .env like the TS scripts do (process.loadEnvFile never overrides
|
||||||
|
// variables already present in the shell).
|
||||||
|
for (const name of [".env.local", ".env"]) {
|
||||||
|
const file = resolve(ROOT, name);
|
||||||
|
if (existsSync(file)) process.loadEnvFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Parse existing Drizzle schema (naming source of truth) ----------
|
// ---------- Parse existing Drizzle schema (naming source of truth) ----------
|
||||||
const schemaSource = readFileSync(SCHEMA_TS, "utf-8");
|
const schemaSource = readFileSync(SCHEMA_TS, "utf-8");
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
URL="${FLARESOLVERR_URL:-http://localhost:8191}"
|
URL="${BYPARR_URL:-${FLARESOLVERR_URL:-http://localhost:8191}}"
|
||||||
MAX_RETRIES="${FLARESOLVERR_MAX_RETRIES:-30}"
|
MAX_RETRIES="${BYPARR_MAX_RETRIES:-30}"
|
||||||
RETRY_INTERVAL="${FLARESOLVERR_RETRY_INTERVAL:-2}"
|
RETRY_INTERVAL="${BYPARR_RETRY_INTERVAL:-2}"
|
||||||
|
|
||||||
echo "Waiting for FlareSolverr at ${URL}..."
|
echo "Waiting for Byparr at ${URL}..."
|
||||||
|
|
||||||
for i in $(seq 1 "$MAX_RETRIES"); do
|
for i in $(seq 1 "$MAX_RETRIES"); do
|
||||||
if curl -sf "${URL}/health" >/dev/null 2>&1; then
|
if curl -sf "${URL}/health" >/dev/null 2>&1; then
|
||||||
echo "FlareSolverr is healthy."
|
echo "Byparr is healthy."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "Attempt ${i}/${MAX_RETRIES} - FlareSolverr not ready, retrying in ${RETRY_INTERVAL}s..."
|
echo "Attempt ${i}/${MAX_RETRIES} - Byparr not ready, retrying in ${RETRY_INTERVAL}s..."
|
||||||
sleep "$RETRY_INTERVAL"
|
sleep "$RETRY_INTERVAL"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "ERROR: FlareSolverr did not become healthy after ${MAX_RETRIES} attempts."
|
echo "ERROR: Byparr did not become healthy after ${MAX_RETRIES} attempts."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { drainFurnitureImports } from "../src/lib/services/furni-job-worker";
|
||||||
import "./load-env";
|
import "./load-env";
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
import { and, eq, lt, lte, sql } from "drizzle-orm";
|
import { and, eq, lt, lte, sql } from "drizzle-orm";
|
||||||
@@ -10,8 +11,17 @@ import {
|
|||||||
} from "../src/lib/db";
|
} from "../src/lib/db";
|
||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
import { redis } from "../src/lib/redis";
|
import { redis } from "../src/lib/redis";
|
||||||
import { emulatorOffline, healthDegraded } from "../src/lib/services/alert";
|
import {
|
||||||
|
diskPressure,
|
||||||
|
emulatorOffline,
|
||||||
|
healthDegraded,
|
||||||
|
} from "../src/lib/services/alert";
|
||||||
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
import { runCatalogExport } from "../src/lib/services/catalog-git-export";
|
||||||
|
import {
|
||||||
|
DISK_THRESHOLDS,
|
||||||
|
diskLevel,
|
||||||
|
parseDfOutput,
|
||||||
|
} from "../src/lib/services/disk-usage";
|
||||||
import { invalidateNewsCache } from "../src/lib/services/news-cache";
|
import { invalidateNewsCache } from "../src/lib/services/news-cache";
|
||||||
import { rcon } from "../src/lib/services/rcon";
|
import { rcon } from "../src/lib/services/rcon";
|
||||||
|
|
||||||
@@ -87,6 +97,73 @@ async function checkOpsHealth(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-side: probe filesystem fill levels via `df -P -B1` and raise a
|
||||||
|
* diskPressure() alert per mount once it crosses 85/90/95% (cooldown-gated per
|
||||||
|
* mount+level so a stuck fill level does not spam Discord/email).
|
||||||
|
*/
|
||||||
|
async function checkDiskUsage(): Promise<void> {
|
||||||
|
const { execFile } = await import("node:child_process");
|
||||||
|
|
||||||
|
let dfOut: string;
|
||||||
|
try {
|
||||||
|
dfOut = await new Promise<string>((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
"df",
|
||||||
|
["-P", "-B1"],
|
||||||
|
{
|
||||||
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
(err, stdout) => (err ? reject(err) : resolve(stdout)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
captureWorkerError(err, "Disk probe failed (is df available?)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mounts = parseDfOutput(dfOut);
|
||||||
|
if (mounts.length === 0) return;
|
||||||
|
|
||||||
|
for (const usage of mounts) {
|
||||||
|
const level = diskLevel(usage.percent);
|
||||||
|
if (level === 0) continue;
|
||||||
|
if (canAlert(`disk-${usage.mount}-${level}`)) {
|
||||||
|
logger.warn("Raising disk usage alert", {
|
||||||
|
module: "jobs",
|
||||||
|
mount: usage.mount,
|
||||||
|
percent: usage.percent,
|
||||||
|
});
|
||||||
|
await diskPressure(usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-healing: never let a mount max out. Safe prune from the warning
|
||||||
|
// mark, forced prune (drop age windows) from the error mark. Cooldown is
|
||||||
|
// keyed separately so a stuck fill level does not re-prune every run
|
||||||
|
// while still escalating to the forced path on real pressure.
|
||||||
|
if (usage.percent >= DISK_THRESHOLDS.error) {
|
||||||
|
if (canAlert(`disk-prune-force-${usage.mount}`)) {
|
||||||
|
logger.warn("Disk near full — forcing Docker cache reclaim", {
|
||||||
|
module: "jobs",
|
||||||
|
mount: usage.mount,
|
||||||
|
percent: usage.percent,
|
||||||
|
});
|
||||||
|
await pruneDockerCache(true);
|
||||||
|
}
|
||||||
|
} else if (usage.percent >= DISK_THRESHOLDS.warning) {
|
||||||
|
if (canAlert(`disk-prune-${usage.mount}`)) {
|
||||||
|
logger.info("Disk at high water mark — running safe Docker prune", {
|
||||||
|
module: "jobs",
|
||||||
|
mount: usage.mount,
|
||||||
|
percent: usage.percent,
|
||||||
|
});
|
||||||
|
await pruneDockerCache(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function backupEmulatorJar(): Promise<void> {
|
async function backupEmulatorJar(): Promise<void> {
|
||||||
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
||||||
|
|
||||||
@@ -231,6 +308,42 @@ async function cleanupOldSessions(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Host-side: reclaim Docker's unused cache (build cache, unreferenced images,
|
||||||
|
* stopped containers). Volumes and in-use images are never touched. No-op when
|
||||||
|
* docker or the prune script is unavailable. With force=true the age windows
|
||||||
|
* are dropped (docker-prune.sh --force) so every unused byte is reclaimed —
|
||||||
|
* the emergency path for a nearly-full disk. */
|
||||||
|
async function pruneDockerCache(force = false): Promise<void> {
|
||||||
|
const { access } = await import("node:fs/promises");
|
||||||
|
const { resolve } = await import("node:path");
|
||||||
|
const { spawn } = await import("node:child_process");
|
||||||
|
const script = resolve(process.cwd(), "scripts", "docker-prune.sh");
|
||||||
|
try {
|
||||||
|
await access(script);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolvePromise) => {
|
||||||
|
const child = spawn("bash", [script, ...(force ? ["--force"] : [])], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
child.on("error", (err) =>
|
||||||
|
captureWorkerError(
|
||||||
|
err,
|
||||||
|
"Docker prune could not start (is bash on PATH?)",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0)
|
||||||
|
captureWorkerError(
|
||||||
|
new Error(`docker-prune.sh exited ${code}`),
|
||||||
|
"Docker prune failed",
|
||||||
|
);
|
||||||
|
resolvePromise();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function publishScheduledArticles(): Promise<void> {
|
async function publishScheduledArticles(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -260,7 +373,18 @@ async function publishScheduledArticles(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reportWorkerHeartbeat(): Promise<void> {
|
||||||
|
if (!redis) return;
|
||||||
|
await redis
|
||||||
|
.set("cms:jobs-worker:heartbeat", new Date().toISOString(), "EX", 600)
|
||||||
|
.catch((error) => captureWorkerError(error, "WorkerHeartbeat"));
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
new Cron("* * * * *", () => {
|
||||||
|
void drainFurnitureImports();
|
||||||
|
});
|
||||||
|
void drainFurnitureImports();
|
||||||
new Cron("* * * * *", () => {
|
new Cron("* * * * *", () => {
|
||||||
runCatalogExport().catch((e) =>
|
runCatalogExport().catch((e) =>
|
||||||
captureWorkerError(e, "Catalog export failed"),
|
captureWorkerError(e, "Catalog export failed"),
|
||||||
@@ -293,11 +417,25 @@ async function main() {
|
|||||||
});
|
});
|
||||||
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
||||||
|
|
||||||
|
new Cron("0 5 * * *", () => {
|
||||||
|
pruneDockerCache().catch((e) =>
|
||||||
|
captureWorkerError(e, "Docker prune error"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
logger.info("Scheduled: Docker cache prune (daily 05:00)", {
|
||||||
|
module: "jobs",
|
||||||
|
});
|
||||||
|
|
||||||
new Cron("*/5 * * * *", () => {
|
new Cron("*/5 * * * *", () => {
|
||||||
checkOpsHealth().catch((e) => captureWorkerError(e, "Health check error"));
|
checkOpsHealth().catch((e) => captureWorkerError(e, "Health check error"));
|
||||||
});
|
});
|
||||||
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
|
logger.info("Scheduled: ops health probe (every 5 min)", { module: "jobs" });
|
||||||
|
|
||||||
|
new Cron("*/5 * * * *", () => {
|
||||||
|
checkDiskUsage().catch((e) => captureWorkerError(e, "Disk check error"));
|
||||||
|
});
|
||||||
|
logger.info("Scheduled: disk usage probe (every 5 min)", { module: "jobs" });
|
||||||
|
|
||||||
new Cron("* * * * *", () => {
|
new Cron("* * * * *", () => {
|
||||||
publishScheduledArticles().catch((e) =>
|
publishScheduledArticles().catch((e) =>
|
||||||
captureWorkerError(e, "ScheduledArticlePublish"),
|
captureWorkerError(e, "ScheduledArticlePublish"),
|
||||||
@@ -307,11 +445,16 @@ async function main() {
|
|||||||
module: "jobs",
|
module: "jobs",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
new Cron("* * * * *", () => {
|
||||||
|
void reportWorkerHeartbeat();
|
||||||
|
});
|
||||||
|
await reportWorkerHeartbeat();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
backupEmulatorJar(),
|
backupEmulatorJar(),
|
||||||
cleanupOldLogs(),
|
cleanupOldLogs(),
|
||||||
cleanupOldSessions(),
|
cleanupOldSessions(),
|
||||||
checkOpsHealth(),
|
checkOpsHealth(),
|
||||||
|
checkDiskUsage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"mode": "informational",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"route": "/me",
|
||||||
|
"maxInitialRawBytes": 890880,
|
||||||
|
"maxInitialGzipBytes": 276480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/news",
|
||||||
|
"maxInitialRawBytes": 880640,
|
||||||
|
"maxInitialGzipBytes": 276480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/events",
|
||||||
|
"maxInitialRawBytes": 890880,
|
||||||
|
"maxInitialGzipBytes": 276480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/search",
|
||||||
|
"maxInitialRawBytes": 890880,
|
||||||
|
"maxInitialGzipBytes": 276480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/admin/catalog",
|
||||||
|
"maxInitialRawBytes": 1904640,
|
||||||
|
"maxInitialGzipBytes": 573440
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/admin/studio/furni",
|
||||||
|
"maxInitialRawBytes": 1433600,
|
||||||
|
"maxInitialGzipBytes": 450560
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
const LIMIT_KEYS = ["route", "maxInitialRawBytes", "maxInitialGzipBytes"];
|
||||||
|
export function validateBudgets(config) {
|
||||||
|
if (
|
||||||
|
config?.schemaVersion !== 1 ||
|
||||||
|
config.mode !== "informational" ||
|
||||||
|
!Array.isArray(config.routes) ||
|
||||||
|
config.routes.length === 0
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
"Budget config requires schemaVersion 1, mode informational and nonempty routes.",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Object.keys(config).some(
|
||||||
|
(key) => !["schemaVersion", "mode", "routes"].includes(key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Unknown budget config field.");
|
||||||
|
const seen = new Set();
|
||||||
|
for (const entry of config.routes) {
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
typeof entry.route !== "string" ||
|
||||||
|
!/^\/(?:[^?#\s]*)$/.test(entry.route) ||
|
||||||
|
seen.has(entry.route) ||
|
||||||
|
Object.keys(entry).some((key) => !LIMIT_KEYS.includes(key))
|
||||||
|
)
|
||||||
|
throw new Error("Invalid or duplicate budget route.");
|
||||||
|
seen.add(entry.route);
|
||||||
|
for (const key of LIMIT_KEYS.slice(1))
|
||||||
|
if (
|
||||||
|
entry[key] !== null &&
|
||||||
|
(!Number.isSafeInteger(entry[key]) || entry[key] <= 0)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Invalid ${key} for ${entry.route}: use positive integer bytes or null.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse Next's generated JSON assignment, never execute the manifest as JavaScript. */
|
||||||
|
export function parseClientManifest(source, appPath) {
|
||||||
|
const assignment =
|
||||||
|
/globalThis\.__RSC_MANIFEST\[("(?:[^"\\]|\\.)*")\]\s*=\s*/g;
|
||||||
|
const match = assignment.exec(source);
|
||||||
|
if (!match || JSON.parse(match[1]) !== appPath)
|
||||||
|
throw new Error("Unsupported client-reference manifest assignment.");
|
||||||
|
const value = source
|
||||||
|
.slice(match.index + match[0].length)
|
||||||
|
.trim()
|
||||||
|
.replace(/;$/, "");
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAsset(value) {
|
||||||
|
if (typeof value !== "string")
|
||||||
|
throw new Error("Non-string JavaScript asset.");
|
||||||
|
const clean = value.split(/[?#]/, 1)[0].replace(/^\/_next\//, "");
|
||||||
|
if (
|
||||||
|
!clean.startsWith("static/") ||
|
||||||
|
clean.includes("\\") ||
|
||||||
|
clean
|
||||||
|
.split("/")
|
||||||
|
.some((part) => part === ".." || part === "." || part === "") ||
|
||||||
|
!clean.endsWith(".js")
|
||||||
|
)
|
||||||
|
throw new Error(`Unsupported JavaScript asset: ${value}`);
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filesFrom(values) {
|
||||||
|
if (!Array.isArray(values))
|
||||||
|
throw new Error("Unsupported JavaScript chunk list.");
|
||||||
|
return values.map(normalizeAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset,
|
||||||
|
}) {
|
||||||
|
const entries = clientManifest?.entryJSFiles;
|
||||||
|
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
||||||
|
throw new Error(
|
||||||
|
"entryJSFiles is unavailable; this manifest layout is not supported.",
|
||||||
|
);
|
||||||
|
const sourceEntries = Object.keys(entries);
|
||||||
|
if (
|
||||||
|
!sourceEntries.some((key) =>
|
||||||
|
key.replaceAll("\\", "/").endsWith(`/app${appPath}`),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Route page entry is absent from entryJSFiles.");
|
||||||
|
const bootstrap = filesFrom(
|
||||||
|
buildManifest.rootMainFilesTree?.[appPath] ?? buildManifest.rootMainFiles,
|
||||||
|
);
|
||||||
|
if (bootstrap.length === 0)
|
||||||
|
throw new Error("Initial bootstrap chunks are unavailable.");
|
||||||
|
const origins = new Map();
|
||||||
|
const add = (file, origin) => {
|
||||||
|
const sources = origins.get(file) ?? new Set();
|
||||||
|
sources.add(origin);
|
||||||
|
origins.set(file, sources);
|
||||||
|
};
|
||||||
|
for (const file of bootstrap) add(file, "bootstrap");
|
||||||
|
for (const [entry, values] of Object.entries(entries))
|
||||||
|
for (const file of filesFrom(values)) add(file, entry);
|
||||||
|
const size = (file, sources) => {
|
||||||
|
const bytes = readAsset(file);
|
||||||
|
return {
|
||||||
|
path: file,
|
||||||
|
rawBytes: bytes.length,
|
||||||
|
gzipBytes: gzipSync(bytes, { level: 9 }).length,
|
||||||
|
sources: [...sources].sort(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const chunks = [...origins]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([file, sources]) => size(file, sources));
|
||||||
|
const polyfills = [...new Set(filesFrom(buildManifest.polyfillFiles ?? []))]
|
||||||
|
.filter((file) => !origins.has(file) && !file.endsWith(".module.js"))
|
||||||
|
.sort()
|
||||||
|
.map((file) => size(file, ["legacy-nomodule-polyfill"]));
|
||||||
|
const rawBytes = chunks.reduce((total, file) => total + file.rawBytes, 0);
|
||||||
|
const gzipBytes = chunks.reduce((total, file) => total + file.gzipBytes, 0);
|
||||||
|
const exceeded = [];
|
||||||
|
if (
|
||||||
|
budget.maxInitialRawBytes !== null &&
|
||||||
|
rawBytes > budget.maxInitialRawBytes
|
||||||
|
)
|
||||||
|
exceeded.push("raw");
|
||||||
|
if (
|
||||||
|
budget.maxInitialGzipBytes !== null &&
|
||||||
|
gzipBytes > budget.maxInitialGzipBytes
|
||||||
|
)
|
||||||
|
exceeded.push("gzip");
|
||||||
|
return {
|
||||||
|
route: budget.route,
|
||||||
|
appPath,
|
||||||
|
status: exceeded.length ? "over-budget" : "measured",
|
||||||
|
initial: { chunkCount: chunks.length, rawBytes, gzipBytes, chunks },
|
||||||
|
legacyPolyfills: {
|
||||||
|
rawBytes: polyfills.reduce((n, f) => n + f.rawBytes, 0),
|
||||||
|
gzipBytes: polyfills.reduce((n, f) => n + f.gzipBytes, 0),
|
||||||
|
chunks: polyfills,
|
||||||
|
},
|
||||||
|
budget: {
|
||||||
|
maxInitialRawBytes: budget.maxInitialRawBytes,
|
||||||
|
maxInitialGzipBytes: budget.maxInitialGzipBytes,
|
||||||
|
},
|
||||||
|
exceeded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonFile(file) {
|
||||||
|
return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
||||||
|
}
|
||||||
|
function within(root, relative) {
|
||||||
|
const resolved = path.resolve(root, relative);
|
||||||
|
if (!resolved.startsWith(path.resolve(root) + path.sep))
|
||||||
|
throw new Error("Manifest path escapes the build directory.");
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
export function collectReport(nextDir, config, metadata = {}) {
|
||||||
|
validateBudgets(config);
|
||||||
|
let routeMap, appMap, manifestError;
|
||||||
|
let buildId = null;
|
||||||
|
try {
|
||||||
|
buildId = readFileSync(path.join(nextDir, "BUILD_ID"), "utf8").trim();
|
||||||
|
if (!buildId)
|
||||||
|
throw new Error("A completed production BUILD_ID is required.");
|
||||||
|
routeMap = jsonFile(path.join(nextDir, "app-path-routes-manifest.json"));
|
||||||
|
appMap = jsonFile(path.join(nextDir, "server/app-paths-manifest.json"));
|
||||||
|
} catch (error) {
|
||||||
|
manifestError = error.message;
|
||||||
|
}
|
||||||
|
const routes = config.routes.map((budget) => {
|
||||||
|
try {
|
||||||
|
if (manifestError) throw new Error(manifestError);
|
||||||
|
const matches = Object.entries(routeMap)
|
||||||
|
.filter(([, route]) => route === budget.route)
|
||||||
|
.map(([appPath]) => appPath);
|
||||||
|
if (matches.length !== 1)
|
||||||
|
throw new Error(
|
||||||
|
`Expected one build entry for ${budget.route}, found ${matches.length}.`,
|
||||||
|
);
|
||||||
|
const appPath = matches[0],
|
||||||
|
serverEntry = appMap[appPath];
|
||||||
|
if (typeof serverEntry !== "string" || !serverEntry.endsWith("/page.js"))
|
||||||
|
throw new Error("Unsupported server app entry.");
|
||||||
|
const clientFile = within(
|
||||||
|
path.join(nextDir, "server"),
|
||||||
|
serverEntry.replace(/\.js$/, "_client-reference-manifest.js"),
|
||||||
|
);
|
||||||
|
const clientManifest = parseClientManifest(
|
||||||
|
readFileSync(clientFile, "utf8"),
|
||||||
|
appPath,
|
||||||
|
);
|
||||||
|
let buildManifest,
|
||||||
|
buildManifestSource = `server/${serverEntry.replace(/\.js$/, "/build-manifest.json")}`;
|
||||||
|
try {
|
||||||
|
buildManifest = jsonFile(within(nextDir, buildManifestSource));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== "ENOENT") throw error;
|
||||||
|
buildManifestSource = "build-manifest.json";
|
||||||
|
buildManifest = jsonFile(path.join(nextDir, buildManifestSource));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => readFileSync(within(nextDir, file)),
|
||||||
|
}),
|
||||||
|
manifests: {
|
||||||
|
client: path.relative(nextDir, clientFile).split(path.sep).join("/"),
|
||||||
|
build: buildManifestSource,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
route: budget.route,
|
||||||
|
status: "unavailable",
|
||||||
|
reason: error.message,
|
||||||
|
initial: null,
|
||||||
|
budget: {
|
||||||
|
maxInitialRawBytes: budget.maxInitialRawBytes,
|
||||||
|
maxInitialGzipBytes: budget.maxInitialGzipBytes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
mode: "informational",
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
buildId,
|
||||||
|
commitSha: metadata.commitSha ?? null,
|
||||||
|
commitProvenance:
|
||||||
|
"Optional PERFORMANCE_COMMIT_SHA supplied by the build caller; not inferred from current checkout.",
|
||||||
|
nodeVersion: process.version,
|
||||||
|
zlibVersion: process.versions.zlib,
|
||||||
|
manifestFormat: "Next App Router client-reference entryJSFiles",
|
||||||
|
definition:
|
||||||
|
"Initial entry envelope: deduplicated rootMainFiles bootstrap plus all entryJSFiles in this route's client-reference manifest, including boundary/loading entries. This is emitted file size, not measured browser traffic or a load-time benchmark.",
|
||||||
|
gzip: "Sum of each unique JavaScript file independently compressed with Node gzip level 9. Excludes HTTP headers and shared-cache reuse.",
|
||||||
|
excluded:
|
||||||
|
"CSS, source maps, images, RSC/HTML payloads, external scripts, async-only chunks absent from entryJSFiles; legacy nomodule polyfills are reported separately.",
|
||||||
|
routes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownReport(report) {
|
||||||
|
const lines = [
|
||||||
|
"# Route JavaScript size report",
|
||||||
|
"",
|
||||||
|
report.definition,
|
||||||
|
"",
|
||||||
|
report.gzip,
|
||||||
|
"",
|
||||||
|
`Build: ${report.buildId ?? "unavailable"}. Declared commit: ${report.commitSha ?? "not supplied"}. Mode: informational; exceeded budgets and unavailable metrics do not fail CI.`,
|
||||||
|
"",
|
||||||
|
"| Route | State | Unique JS | Raw bytes | Gzip bytes | Raw budget | Gzip budget |",
|
||||||
|
"| --- | --- | ---: | ---: | ---: | ---: | ---: |",
|
||||||
|
];
|
||||||
|
for (const row of report.routes)
|
||||||
|
lines.push(
|
||||||
|
`| ${row.route} | ${row.status} | ${row.initial?.chunkCount ?? "—"} | ${row.initial?.rawBytes ?? "—"} | ${row.initial?.gzipBytes ?? "—"} | ${row.budget.maxInitialRawBytes ?? "observe"} | ${row.budget.maxInitialGzipBytes ?? "observe"} |`,
|
||||||
|
);
|
||||||
|
lines.push("", report.excluded, "");
|
||||||
|
for (const row of report.routes)
|
||||||
|
if (row.reason)
|
||||||
|
lines.push(
|
||||||
|
`- ${row.route}: unavailable — ${row.reason.replaceAll("\n", " ")}`,
|
||||||
|
);
|
||||||
|
lines.push(
|
||||||
|
"",
|
||||||
|
"Exact file lists, provenance and legacy polyfill sizes are in report.json.",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function main(argv = process.argv.slice(2)) {
|
||||||
|
const options = {
|
||||||
|
"--next-dir": ".next",
|
||||||
|
"--config": "scripts/performance-budgets.json",
|
||||||
|
"--output-dir": ".next/performance-report",
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i += 2) {
|
||||||
|
if (!Object.hasOwn(options, argv[i]) || !argv[i + 1])
|
||||||
|
throw new Error(
|
||||||
|
"Usage: node scripts/performance-report.mjs [--next-dir DIR] [--config FILE] [--output-dir DIR]",
|
||||||
|
);
|
||||||
|
options[argv[i]] = argv[i + 1];
|
||||||
|
}
|
||||||
|
const config = validateBudgets(jsonFile(options["--config"]));
|
||||||
|
const report = collectReport(options["--next-dir"], config, {
|
||||||
|
commitSha: process.env.PERFORMANCE_COMMIT_SHA || null,
|
||||||
|
});
|
||||||
|
mkdirSync(options["--output-dir"], { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(options["--output-dir"], "report.json"),
|
||||||
|
`${JSON.stringify(report, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
path.join(options["--output-dir"], "report.md"),
|
||||||
|
markdownReport(report),
|
||||||
|
);
|
||||||
|
console.log(markdownReport(report));
|
||||||
|
for (const row of report.routes)
|
||||||
|
if (row.status !== "measured")
|
||||||
|
console.warn(
|
||||||
|
`Performance warning: ${row.route} ${row.status}${row.reason ? `: ${row.reason}` : ""}`,
|
||||||
|
);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Performance report configuration/output error: ${error.message}`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
collectReport,
|
||||||
|
markdownReport,
|
||||||
|
measureRoute,
|
||||||
|
normalizeAsset,
|
||||||
|
parseClientManifest,
|
||||||
|
validateBudgets,
|
||||||
|
} from "./performance-report.mjs";
|
||||||
|
|
||||||
|
const budget = {
|
||||||
|
route: "/news",
|
||||||
|
maxInitialRawBytes: 1000,
|
||||||
|
maxInitialGzipBytes: 1000,
|
||||||
|
};
|
||||||
|
const config = { schemaVersion: 1, mode: "informational", routes: [budget] };
|
||||||
|
const appPath = "/(site)/news/page";
|
||||||
|
const buildManifest = {
|
||||||
|
rootMainFiles: ["static/chunks/runtime.js", "static/chunks/shared.js"],
|
||||||
|
polyfillFiles: ["static/chunks/polyfill.js"],
|
||||||
|
};
|
||||||
|
const clientManifest = {
|
||||||
|
entryJSFiles: {
|
||||||
|
"[project]/src/app/layout": ["static/chunks/shared.js"],
|
||||||
|
"[project]/src/app/error": ["static/chunks/boundary.js"],
|
||||||
|
"[project]/src/app/(site)/news/page": [
|
||||||
|
"/_next/static/chunks/shared.js?dpl=old",
|
||||||
|
"static/chunks/page.js",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
clientModules: { dynamic: { chunks: ["static/chunks/dynamic-only.js"] } },
|
||||||
|
};
|
||||||
|
const files = {
|
||||||
|
"static/chunks/runtime.js": Buffer.from("const runtime = true;"),
|
||||||
|
"static/chunks/shared.js": Buffer.from("shared".repeat(50)),
|
||||||
|
"static/chunks/boundary.js": Buffer.from("boundary"),
|
||||||
|
"static/chunks/page.js": Buffer.from("page"),
|
||||||
|
"static/chunks/polyfill.js": Buffer.from("legacy"),
|
||||||
|
};
|
||||||
|
const folders = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const folder of folders.splice(0))
|
||||||
|
rmSync(folder, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
function fixture() {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "route-js-report-"));
|
||||||
|
folders.push(dir);
|
||||||
|
const write = (relative, value) => {
|
||||||
|
const file = path.join(dir, relative);
|
||||||
|
mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
file,
|
||||||
|
typeof value === "string" || Buffer.isBuffer(value)
|
||||||
|
? value
|
||||||
|
: JSON.stringify(value),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
write("BUILD_ID", "fixture-build");
|
||||||
|
write("app-path-routes-manifest.json", { [appPath]: "/news" });
|
||||||
|
write("server/app-paths-manifest.json", {
|
||||||
|
[appPath]: "app/(site)/news/page.js",
|
||||||
|
});
|
||||||
|
write(
|
||||||
|
"server/app/(site)/news/page_client-reference-manifest.js",
|
||||||
|
`globalThis.__RSC_MANIFEST = globalThis.__RSC_MANIFEST || {};\nglobalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = ${JSON.stringify(clientManifest)};`,
|
||||||
|
);
|
||||||
|
write("server/app/(site)/news/page/build-manifest.json", buildManifest);
|
||||||
|
for (const [file, bytes] of Object.entries(files)) write(file, bytes);
|
||||||
|
return { dir, write };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("route JS measurement", () => {
|
||||||
|
it("requires a completed production build instead of measuring development output", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
rmSync(path.join(dir, "BUILD_ID"));
|
||||||
|
expect(collectReport(dir, config).routes[0]).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
initial: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("does not guess between multiple app paths for one route", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write("app-path-routes-manifest.json", {
|
||||||
|
[appPath]: "/news",
|
||||||
|
"/(other)/news/page": "/news",
|
||||||
|
});
|
||||||
|
expect(collectReport(dir, config).routes[0]).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
initial: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates normalized shared chunks within each route and sums per-file gzip", () => {
|
||||||
|
const row = measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
});
|
||||||
|
const initial = Object.entries(files).filter(
|
||||||
|
([file]) => !file.includes("polyfill"),
|
||||||
|
);
|
||||||
|
expect(row.initial.chunkCount).toBe(4);
|
||||||
|
expect(row.initial.rawBytes).toBe(
|
||||||
|
initial.reduce((n, [, bytes]) => n + bytes.length, 0),
|
||||||
|
);
|
||||||
|
expect(row.initial.gzipBytes).toBe(
|
||||||
|
initial.reduce(
|
||||||
|
(n, [, bytes]) => n + gzipSync(bytes, { level: 9 }).length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
row.initial.chunks.find((f) => f.path.endsWith("shared.js")).sources,
|
||||||
|
).toHaveLength(3);
|
||||||
|
expect(row.initial.chunks.some((f) => f.path.includes("dynamic"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(row.legacyPolyfills.rawBytes).toBe(6);
|
||||||
|
});
|
||||||
|
it("uses route-specific bootstrap instead of unrelated global bootstrap", () => {
|
||||||
|
const row = measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest: {
|
||||||
|
...buildManifest,
|
||||||
|
rootMainFiles: ["static/chunks/not-used.js"],
|
||||||
|
rootMainFilesTree: { [appPath]: ["static/chunks/runtime.js"] },
|
||||||
|
},
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
});
|
||||||
|
expect(row.initial.chunkCount).toBe(4);
|
||||||
|
});
|
||||||
|
it("reports exceeded budgets without throwing", () => {
|
||||||
|
expect(
|
||||||
|
measureRoute({
|
||||||
|
budget: { ...budget, maxInitialRawBytes: 1, maxInitialGzipBytes: 1 },
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
}),
|
||||||
|
).toMatchObject({ status: "over-budget", exceeded: ["raw", "gzip"] });
|
||||||
|
});
|
||||||
|
it("parses generated data without executing trailing JavaScript", () => {
|
||||||
|
expect(
|
||||||
|
parseClientManifest(
|
||||||
|
`globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {"entryJSFiles":{}};`,
|
||||||
|
appPath,
|
||||||
|
),
|
||||||
|
).toEqual({ entryJSFiles: {} });
|
||||||
|
expect(() =>
|
||||||
|
parseClientManifest(
|
||||||
|
`globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {}; globalThis.executed = true;`,
|
||||||
|
appPath,
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
"../secrets.js",
|
||||||
|
"static/../secrets.js",
|
||||||
|
"https://cdn.test/a.js",
|
||||||
|
"static\\secret.js",
|
||||||
|
"static/chunks/a.css",
|
||||||
|
])("rejects unsupported asset paths %s", (file) => {
|
||||||
|
expect(() => normalizeAsset(file)).toThrow();
|
||||||
|
});
|
||||||
|
it("reports exact emitted bytes with build provenance", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
expect(collectReport(dir, config, { commitSha: "abc1234" })).toMatchObject({
|
||||||
|
buildId: "fixture-build",
|
||||||
|
commitSha: "abc1234",
|
||||||
|
routes: [{ status: "measured", initial: { chunkCount: 4 } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("keeps unavailable manifests separate from a zero metric", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
rmSync(
|
||||||
|
path.join(
|
||||||
|
dir,
|
||||||
|
"server/app/(site)/news/page_client-reference-manifest.js",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const row = collectReport(dir, config).routes[0];
|
||||||
|
expect(row.status).toBe("unavailable");
|
||||||
|
expect(row.initial).toBeNull();
|
||||||
|
});
|
||||||
|
it("does not publish partial totals when one referenced asset is missing", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
rmSync(path.join(dir, "static/chunks/page.js"));
|
||||||
|
expect(collectReport(dir, config).routes[0]).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
initial: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("falls back to the root build manifest only when the route manifest is absent", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write("build-manifest.json", buildManifest);
|
||||||
|
rmSync(path.join(dir, "server/app/(site)/news/page/build-manifest.json"));
|
||||||
|
expect(collectReport(dir, config).routes[0].manifests.build).toBe(
|
||||||
|
"build-manifest.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("marks unsupported entryJSFiles layouts unavailable", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write(
|
||||||
|
"server/app/(site)/news/page_client-reference-manifest.js",
|
||||||
|
`globalThis.__RSC_MANIFEST[${JSON.stringify(appPath)}] = {};`,
|
||||||
|
);
|
||||||
|
expect(collectReport(dir, config).routes[0].status).toBe("unavailable");
|
||||||
|
});
|
||||||
|
it("does not deduplicate shared files across independent cold route totals", () => {
|
||||||
|
const row = measureRoute({
|
||||||
|
budget,
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
});
|
||||||
|
const another = measureRoute({
|
||||||
|
budget: { ...budget, route: "/other" },
|
||||||
|
appPath,
|
||||||
|
buildManifest,
|
||||||
|
clientManifest,
|
||||||
|
readAsset: (file) => files[file],
|
||||||
|
});
|
||||||
|
expect(another.initial.rawBytes).toBe(row.initial.rawBytes);
|
||||||
|
});
|
||||||
|
it("rejects invalid threshold schemas instead of silently dropping limits", () => {
|
||||||
|
for (const limit of [-1, 0, 1.5, "100", undefined])
|
||||||
|
expect(() =>
|
||||||
|
validateBudgets({
|
||||||
|
...config,
|
||||||
|
routes: [{ ...budget, maxInitialRawBytes: limit }],
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
validateBudgets({ ...config, routes: [budget, budget] }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() => validateBudgets({ ...config, mode: "enforce" })).toThrow();
|
||||||
|
expect(() => validateBudgets({ ...config, typo: true })).toThrow();
|
||||||
|
});
|
||||||
|
it("emits warnings and artifacts with exit zero when a build is unavailable", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write("budgets.json", config);
|
||||||
|
const run = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"scripts/performance-report.mjs",
|
||||||
|
"--next-dir",
|
||||||
|
path.join(dir, "missing"),
|
||||||
|
"--config",
|
||||||
|
path.join(dir, "budgets.json"),
|
||||||
|
"--output-dir",
|
||||||
|
path.join(dir, "reports"),
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
expect(run.status).toBe(0);
|
||||||
|
expect(run.stderr).toContain("Performance warning:");
|
||||||
|
expect(run.stdout).toContain("unavailable");
|
||||||
|
});
|
||||||
|
it("exits nonzero for invalid configuration", () => {
|
||||||
|
const { dir, write } = fixture();
|
||||||
|
write("budgets.json", {
|
||||||
|
...config,
|
||||||
|
routes: [{ ...budget, maxInitialRawBytes: -1 }],
|
||||||
|
});
|
||||||
|
const run = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"scripts/performance-report.mjs",
|
||||||
|
"--next-dir",
|
||||||
|
dir,
|
||||||
|
"--config",
|
||||||
|
path.join(dir, "budgets.json"),
|
||||||
|
"--output-dir",
|
||||||
|
path.join(dir, "reports"),
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
expect(run.status).toBe(1);
|
||||||
|
});
|
||||||
|
it("labels markdown with the envelope definition, never as loading time", () => {
|
||||||
|
const { dir } = fixture();
|
||||||
|
const markdown = markdownReport(collectReport(dir, config));
|
||||||
|
expect(markdown).toContain("not measured browser traffic");
|
||||||
|
expect(markdown).toContain("Raw bytes");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
// Production builds and CI do not need Git hooks or dev-only executables.
|
|
||||||
if (process.env.NODE_ENV !== "production" && !process.env.CI) {
|
|
||||||
const { default: husky } = await import("husky");
|
|
||||||
husky();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
: "${REGISTRY_SERVER:?Missing Gitea server URL}"
|
||||||
|
: "${REGISTRY_REPOSITORY:?Missing owner/repository}"
|
||||||
|
: "${REGISTRY_USER:?Configure CONTAINER_REGISTRY_USER}"
|
||||||
|
: "${REGISTRY_TOKEN:?Configure CONTAINER_REGISTRY_TOKEN with package write access}"
|
||||||
|
sha="$(git rev-parse HEAD)"
|
||||||
|
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
|
||||||
|
registry="${REGISTRY_SERVER#https://}"
|
||||||
|
registry="${registry%/}"
|
||||||
|
[[ "$REGISTRY_SERVER" = https://* && "$registry" != */* ]] || { echo "Registry must use HTTPS at the Gitea server root" >&2; exit 1; }
|
||||||
|
repository="${REGISTRY_REPOSITORY,,}"
|
||||||
|
[[ "$repository" =~ ^[a-z0-9._-]+/[a-z0-9._-]+$ ]] || exit 1
|
||||||
|
# Gitea packages belong to a user/organization, independently of repository ACLs.
|
||||||
|
# A collaborator token cannot publish to another user's personal namespace.
|
||||||
|
namespace="${REGISTRY_NAMESPACE:-$REGISTRY_USER}"
|
||||||
|
namespace="${namespace,,}"
|
||||||
|
[[ "$namespace" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || { echo "Invalid registry namespace; use a Gitea username or organization" >&2; exit 1; }
|
||||||
|
repository="$namespace/${repository#*/}"
|
||||||
|
image="$registry/$repository:$sha"
|
||||||
|
# Isolate credentials from the self-hosted runner's normal Docker configuration.
|
||||||
|
export DOCKER_CONFIG
|
||||||
|
DOCKER_CONFIG="$(mktemp -d)"
|
||||||
|
context="$(mktemp -d)"
|
||||||
|
trap 'rm -rf -- "$DOCKER_CONFIG" "$context"' EXIT
|
||||||
|
# Build only the committed source, never untracked files from a shared runner.
|
||||||
|
git archive HEAD | tar -x -C "$context"
|
||||||
|
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
|
||||||
|
unset REGISTRY_TOKEN
|
||||||
|
# On the shared runner, publish the exact image already verified by deployment.
|
||||||
|
local_image="epicnext-cms:$sha"
|
||||||
|
local_revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$local_image" 2>/dev/null || true)"
|
||||||
|
local_id="$(docker image inspect --format '{{.Id}}' "$local_image" 2>/dev/null || true)"
|
||||||
|
verified_id="$(docker image inspect --format '{{.Id}}' "epicnext-cms:verified-$sha" 2>/dev/null || true)"
|
||||||
|
if [[ "$local_revision" = "$sha" && -n "$local_id" && "$local_id" = "$verified_id" ]]; then
|
||||||
|
docker tag "$verified_id" "$image"
|
||||||
|
echo "Reusing verified release image $local_image"
|
||||||
|
else
|
||||||
|
docker build --network=host --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" "$context"
|
||||||
|
fi
|
||||||
|
docker build --network=host --target migrations -t "$image-migrations" "$context"
|
||||||
|
node scripts/verify-portable-image.mjs "$image" "$sha"
|
||||||
|
# Publish only after the same application image passed both runtime configurations.
|
||||||
|
# Bound each blob request below reverse-proxy upload limits. Pin the uploader
|
||||||
|
# and verify its checksum before giving it access to the temporary Docker login.
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64) arch=amd64; checksum=8e0e62a497fcdb8048d18aa927a139613176ba0531f412bc541044e28f9856bd ;;
|
||||||
|
aarch64|arm64) arch=arm64; checksum=a9b71a3ee79b2d1dbbd7d51fd5e8fa214722c192864235d3d8764463c751a1ff ;;
|
||||||
|
*) echo "Unsupported registry uploader architecture" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
curl --fail --silent --show-error --location --retry 3 --connect-timeout 15 --max-time 120 \
|
||||||
|
"https://github.com/regclient/regclient/releases/download/v0.11.6/regctl-linux-$arch" -o "$context/regctl"
|
||||||
|
printf '%s %s\n' "$checksum" "$context/regctl" | sha256sum --check --status
|
||||||
|
chmod 700 "$context/regctl"
|
||||||
|
export REGCTL_CONFIG="$DOCKER_CONFIG/regctl.json"
|
||||||
|
regctl() { "$context/regctl" "$@"; }
|
||||||
|
regctl registry set "$registry" --blob-chunk 8388608 --blob-max 8388608
|
||||||
|
for target in "$image-migrations" "$image"; do
|
||||||
|
echo "Publishing $target with blob requests up to 8 MiB"
|
||||||
|
docker image save --output "$context/image.tar" "$target"
|
||||||
|
# Normalize the saved archive locally before copying it unchanged to Gitea.
|
||||||
|
# Docker engine IDs and OCI index IDs are not interchangeable with config IDs.
|
||||||
|
local_ref="ocidir://$context/oci:verified"
|
||||||
|
regctl image import "$local_ref" "$context/image.tar"
|
||||||
|
expected_config="$(regctl manifest get "$local_ref" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
|
||||||
|
[[ "$expected_config" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "Invalid local image config digest" >&2; exit 1; }
|
||||||
|
regctl image copy "$local_ref" "$target"
|
||||||
|
remote_config="$(regctl manifest get "$target" --platform "linux/$arch" --format '{{.GetConfig.Digest}}')"
|
||||||
|
[[ "$remote_config" = "$expected_config" ]] || { echo "Published image config does not match verified local image" >&2; exit 1; }
|
||||||
|
rm -f -- "$context/image.tar"
|
||||||
|
rm -rf -- "$context/oci"
|
||||||
|
done
|
||||||
|
echo "Published application and migrations: $image"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const messagesDir = "/var/www/atom-nexst/src/messages";
|
||||||
|
const enPath = path.join(messagesDir, "en.json");
|
||||||
|
const enData = JSON.parse(fs.readFileSync(enPath, "utf8"));
|
||||||
|
|
||||||
|
function deepMerge(target, source) {
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
if (
|
||||||
|
source[key] &&
|
||||||
|
typeof source[key] === "object" &&
|
||||||
|
!Array.isArray(source[key])
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!target[key] ||
|
||||||
|
typeof target[key] !== "object" ||
|
||||||
|
Array.isArray(target[key])
|
||||||
|
) {
|
||||||
|
target[key] = {};
|
||||||
|
}
|
||||||
|
deepMerge(target[key], source[key]);
|
||||||
|
} else {
|
||||||
|
if (!(key in target)) {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(messagesDir)
|
||||||
|
.filter((f) => f.endsWith(".json") && f !== "en.json");
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(messagesDir, file);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
deepMerge(data, enData);
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
||||||
|
console.log(`Synced ${file}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error syncing ${file}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("Translation sync complete!");
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
export async function verifyDeployedRelease(
|
||||||
|
url,
|
||||||
|
expected,
|
||||||
|
{ attempts = 30, interval = 3000, report = console.error } = {},
|
||||||
|
) {
|
||||||
|
if (!/^[a-f0-9]{40}$/.test(expected))
|
||||||
|
throw new Error("Expected a full Git commit SHA");
|
||||||
|
const target = new URL(url);
|
||||||
|
if (!["http:", "https:"].includes(target.protocol))
|
||||||
|
throw new Error("Expected an HTTP(S) health URL");
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
|
let observed;
|
||||||
|
try {
|
||||||
|
target.searchParams.set("expectedRelease", expected);
|
||||||
|
target.searchParams.set("probe", String(attempt));
|
||||||
|
const response = await fetch(target, {
|
||||||
|
cache: "no-store",
|
||||||
|
redirect: "error",
|
||||||
|
headers: { "Cache-Control": "no-cache" },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
const body = await response.json();
|
||||||
|
observed = {
|
||||||
|
http: response.status,
|
||||||
|
status: body.status ?? null,
|
||||||
|
database: body.database ?? null,
|
||||||
|
release: body.release ?? null,
|
||||||
|
};
|
||||||
|
if (response.ok && body.database === true && body.release === expected)
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
observed = {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
report(JSON.stringify({ attempt, attempts, expected, observed }));
|
||||||
|
if (attempt < attempts) await delay(interval);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
"Expected release never became healthy. Check container startup, port ownership and the HTTP destination.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await verifyDeployedRelease(process.argv[2], process.argv[3]);
|
||||||
|
console.log(`HTTP release verified: ${process.argv[3]}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Runs only on a Linux Docker host. No production DB, network or volumes are used.
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
import { verifyRuntimeMetadata } from "./verify-runtime-metadata.mjs";
|
||||||
|
|
||||||
|
const [image, release] = process.argv.slice(2);
|
||||||
|
if (!image || !/^[0-9a-f]{40}$/.test(release ?? ""))
|
||||||
|
throw new Error("Usage: verify-portable-image.mjs IMAGE COMMIT");
|
||||||
|
const docker = (...args) =>
|
||||||
|
execFileSync("docker", args, { encoding: "utf8", timeout: 60000 }).trim();
|
||||||
|
const id = docker("image", "inspect", "--format", "{{.Id}}", image);
|
||||||
|
const inspect = JSON.parse(docker("image", "inspect", image))[0];
|
||||||
|
if (
|
||||||
|
(inspect.Config.Env ?? []).some((value) =>
|
||||||
|
/^(DATABASE_URL|AUTH_SECRET|HOTEL_NAME)=/.test(value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Image contains installation configuration");
|
||||||
|
docker(
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--entrypoint",
|
||||||
|
"node",
|
||||||
|
image,
|
||||||
|
"-e",
|
||||||
|
'for(const p of [".env",".env.local",".env.production",".env.production.local"]){if(require("fs").existsSync(p))throw Error("Environment file in image: "+p)}',
|
||||||
|
);
|
||||||
|
const png = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9xkAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
const requests = [];
|
||||||
|
const upstream = createServer((req, res) => {
|
||||||
|
requests.push(req.url);
|
||||||
|
res.writeHead(200, { "content-type": "image/png" });
|
||||||
|
res.end(png);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve));
|
||||||
|
const origin = `http://127.0.0.1:${upstream.address().port}`;
|
||||||
|
try {
|
||||||
|
for (const hotel of ["alpha", "beta"]) {
|
||||||
|
const reservation = createServer();
|
||||||
|
await new Promise((resolve) => reservation.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = reservation.address().port;
|
||||||
|
await new Promise((resolve) => reservation.close(resolve));
|
||||||
|
const name = `cms-portability-${process.pid}-${hotel}`;
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
try {
|
||||||
|
docker(
|
||||||
|
"run",
|
||||||
|
"--detach",
|
||||||
|
"--name",
|
||||||
|
name,
|
||||||
|
"--network",
|
||||||
|
"host",
|
||||||
|
"--env",
|
||||||
|
`PORT=${port}`,
|
||||||
|
"--env",
|
||||||
|
"HOSTNAME=127.0.0.1",
|
||||||
|
"--env",
|
||||||
|
`HOTEL_NAME=Fixture ${hotel}`,
|
||||||
|
"--env",
|
||||||
|
`APP_URL=${base}`,
|
||||||
|
"--env",
|
||||||
|
`AUTH_SECRET=portability-${hotel}-not-a-production-secret-000000`,
|
||||||
|
"--env",
|
||||||
|
"DATABASE_URL=mysql://fixture:[email protected]:9/fixture",
|
||||||
|
"--env",
|
||||||
|
"DATABASE_CONNECT_TIMEOUT_MS=500",
|
||||||
|
"--env",
|
||||||
|
"RCON_PORT=9",
|
||||||
|
"--env",
|
||||||
|
"RCON_TIMEOUT_MS=100",
|
||||||
|
"--env",
|
||||||
|
"RCON_MAX_RETRIES=1",
|
||||||
|
"--env",
|
||||||
|
"AUTH_TRUST_HOST=true",
|
||||||
|
"--env",
|
||||||
|
`IMAGER_URL=${origin}/${hotel}/avatar`,
|
||||||
|
"--env",
|
||||||
|
`BADGE_URL=${origin}/${hotel}/badges`,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
let ready = false;
|
||||||
|
for (let attempt = 0; attempt < 30; attempt++) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${base}/api/health`, {
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
if ((await r.json()).release === release) {
|
||||||
|
ready = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
await delay(1000);
|
||||||
|
}
|
||||||
|
if (!ready) throw new Error(`${hotel}: expected HTTP release not served`);
|
||||||
|
await verifyRuntimeMetadata(base, hotel);
|
||||||
|
const avatar = await fetch(
|
||||||
|
`${base}/api/imaging/avatar?figure=hd-180-1&img_format=png`,
|
||||||
|
{ signal: AbortSignal.timeout(15000) },
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!avatar.ok ||
|
||||||
|
!requests.some((url) => url.startsWith(`/${hotel}/avatar?`))
|
||||||
|
)
|
||||||
|
throw new Error(`${hotel}: wrong avatar upstream`);
|
||||||
|
const badge = await fetch(`${base}/api/imaging/badge?code=ADM`, {
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (badge.headers.get("location") !== `${origin}/${hotel}/badges/ADM.gif`)
|
||||||
|
throw new Error(`${hotel}: wrong badge URL`);
|
||||||
|
console.log(
|
||||||
|
`Verified ${hotel}: same image ${id}, runtime avatar and badge configuration, release ${release}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(docker("logs", name, "--tail", "40"));
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
docker("rm", "--force", name);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => upstream.close(resolve));
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Metadata handlers can resolve runtime settings without a working application DB.
|
||||||
|
export async function verifyRuntimeMetadata(base, hotel) {
|
||||||
|
const manifest = await fetch(`${base}/manifest.webmanifest`, {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!manifest.ok)
|
||||||
|
throw new Error(`${hotel}: manifest HTTP ${manifest.status}`);
|
||||||
|
if ((await manifest.json()).name !== `Fixture ${hotel}`)
|
||||||
|
throw new Error(`${hotel}: manifest contains build-time settings`);
|
||||||
|
for (const path of ["/robots.txt", "/sitemap.xml"]) {
|
||||||
|
const response = await fetch(base + path, {
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!response.ok)
|
||||||
|
throw new Error(`${hotel}: ${path} HTTP ${response.status}`);
|
||||||
|
if (!(await response.text()).includes(base))
|
||||||
|
throw new Error(`${hotel}: ${path} contains build-time domain`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ const state = vi.hoisted(() => ({
|
|||||||
log: vi.fn(() => "news-error-1"),
|
log: vi.fn(() => "news-error-1"),
|
||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/features/history/server", () => ({
|
||||||
|
historySnapshot: async () => ({}),
|
||||||
|
recordHistory: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
requirePermission: async () => ({ id: 1, username: "staff" }),
|
requirePermission: async () => ({ id: 1, username: "staff" }),
|
||||||
}));
|
}));
|
||||||
@@ -27,12 +31,16 @@ vi.mock("next/navigation", () => ({
|
|||||||
throw Error(`redirect ${url}`);
|
throw Error(`redirect ${url}`);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/db", async () => ({
|
vi.mock("@/lib/db", async () => {
|
||||||
...(await import("@/db/schema")),
|
const database = {
|
||||||
db: {
|
|
||||||
select: () => ({
|
select: () => ({
|
||||||
from: () => ({
|
from: () => ({
|
||||||
where: () => ({ limit: async () => state.rows.shift() ?? [] }),
|
where: () => ({
|
||||||
|
limit: () => {
|
||||||
|
const result = Promise.resolve(state.rows.shift() ?? []);
|
||||||
|
return Object.assign(result, { for: () => result });
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
insert: () => ({ values: state.insert }),
|
insert: () => ({ values: state.insert }),
|
||||||
@@ -42,9 +50,13 @@ vi.mock("@/lib/db", async () => ({
|
|||||||
return { where: async () => [{ affectedRows: 1 }] };
|
return { where: async () => [{ affectedRows: 1 }] };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
},
|
transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> =>
|
||||||
}));
|
fn(database),
|
||||||
|
};
|
||||||
|
return { ...(await import("@/db/schema")), db: database };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { articleEditToken } from "@/lib/article-edit-token";
|
||||||
import { createArticle, updateArticle } from "./admin-articles";
|
import { createArticle, updateArticle } from "./admin-articles";
|
||||||
|
|
||||||
function form(extra: Record<string, string> = {}) {
|
function form(extra: Record<string, string> = {}) {
|
||||||
@@ -89,11 +101,25 @@ describe("news saves", () => {
|
|||||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
it("preserves the slug and clears old scheduling for immediate publication", async () => {
|
||||||
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]];
|
const existing = {
|
||||||
|
title: "Old",
|
||||||
|
slug: "test-news",
|
||||||
|
image: "",
|
||||||
|
shortStory: "",
|
||||||
|
fullStory: "",
|
||||||
|
status: "draft",
|
||||||
|
publishAt: null,
|
||||||
|
publishedAt: null,
|
||||||
|
};
|
||||||
|
state.rows = [[existing]];
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
await updateArticle(
|
await updateArticle(
|
||||||
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }),
|
form({
|
||||||
|
id: "1",
|
||||||
|
baseToken: articleEditToken(existing),
|
||||||
|
publishAt: "2099-01-01T00:00:00Z",
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
).ok,
|
).ok,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
@@ -106,6 +132,27 @@ describe("news saves", () => {
|
|||||||
);
|
);
|
||||||
expect(state.invalidate).toHaveBeenCalledOnce();
|
expect(state.invalidate).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
it("rejects an outdated editor without inserting a revision or overwriting the article", async () => {
|
||||||
|
state.rows = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Changed by another editor",
|
||||||
|
slug: "test-news",
|
||||||
|
image: "",
|
||||||
|
shortStory: "",
|
||||||
|
fullStory: "",
|
||||||
|
status: "published",
|
||||||
|
publishAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
await updateArticle(form({ id: "1", baseToken: "outdated" })),
|
||||||
|
).toEqual({ ok: false, error: "editConflict" });
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
expect(state.invalidate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
it("rejects invalid scheduled dates before writing", async () => {
|
it("rejects invalid scheduled dates before writing", async () => {
|
||||||
expect(
|
expect(
|
||||||
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
(await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import { and, eq, ne } from "drizzle-orm";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
|
||||||
|
import { historySnapshot, recordHistory } from "@/features/history/server";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { articleEditToken } from "@/lib/article-edit-token";
|
||||||
import {
|
import {
|
||||||
ArticleInputError,
|
ArticleInputError,
|
||||||
type ArticleSaveResult,
|
type ArticleSaveResult,
|
||||||
|
articleInputField,
|
||||||
readArticleInput,
|
readArticleInput,
|
||||||
} from "@/lib/article-input";
|
} from "@/lib/article-input";
|
||||||
import {
|
import {
|
||||||
@@ -23,11 +27,15 @@ import { PERMS } from "@/lib/permissions";
|
|||||||
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
import { invalidateNewsCache } from "@/lib/services/news-cache";
|
||||||
import { notify } from "@/lib/services/webhook";
|
import { notify } from "@/lib/services/webhook";
|
||||||
|
|
||||||
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> {
|
async function uniqueSlug(
|
||||||
|
title: string,
|
||||||
|
excludeId?: bigint,
|
||||||
|
connection: Pick<typeof db, "select"> = db,
|
||||||
|
): Promise<string> {
|
||||||
const base = slugify(title);
|
const base = slugify(title);
|
||||||
let slug = base;
|
let slug = base;
|
||||||
for (let n = 2; ; n++) {
|
for (let n = 2; ; n++) {
|
||||||
const [existing] = await db
|
const [existing] = await connection
|
||||||
.select({ id: WebsiteArticles.id })
|
.select({ id: WebsiteArticles.id })
|
||||||
.from(WebsiteArticles)
|
.from(WebsiteArticles)
|
||||||
.where(
|
.where(
|
||||||
@@ -47,8 +55,14 @@ async function saveFailure(
|
|||||||
operation: string,
|
operation: string,
|
||||||
): Promise<ArticleSaveResult> {
|
): Promise<ArticleSaveResult> {
|
||||||
const t = await getTranslations("pages.admin.articles.form");
|
const t = await getTranslations("pages.admin.articles.form");
|
||||||
if (error instanceof ArticleInputError)
|
if (error instanceof ArticleInputError) {
|
||||||
return { ok: false, error: t(error.code) };
|
const field = articleInputField(error.code);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: t(error.code),
|
||||||
|
...(field ? { fieldErrors: { [field]: [t(error.code)] } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
const reference = logger.error("News save failed", {
|
const reference = logger.error("News save failed", {
|
||||||
module: "news",
|
module: "news",
|
||||||
operation,
|
operation,
|
||||||
@@ -105,39 +119,57 @@ export async function updateArticle(
|
|||||||
if (!id) return { ok: false, error: t("articleNotFound") };
|
if (!id) return { ok: false, error: t("articleNotFound") };
|
||||||
let input: ReturnType<typeof readArticleInput>;
|
let input: ReturnType<typeof readArticleInput>;
|
||||||
let becamePublished = false;
|
let becamePublished = false;
|
||||||
let slug: string;
|
let slug = "";
|
||||||
try {
|
try {
|
||||||
input = readArticleInput(formData);
|
input = readArticleInput(formData);
|
||||||
const [existing] = await db
|
await db.transaction(async (tx) => {
|
||||||
.select({
|
const [existing] = await tx
|
||||||
slug: WebsiteArticles.slug,
|
.select()
|
||||||
status: WebsiteArticles.status,
|
.from(WebsiteArticles)
|
||||||
publishedAt: WebsiteArticles.publishedAt,
|
.where(eq(WebsiteArticles.id, id))
|
||||||
})
|
.limit(1)
|
||||||
.from(WebsiteArticles)
|
.for("update");
|
||||||
.where(eq(WebsiteArticles.id, id))
|
if (!existing) throw new ArticleInputError("articleNotFound");
|
||||||
.limit(1);
|
if (formData.get("baseToken") !== articleEditToken(existing))
|
||||||
if (!existing) return { ok: false, error: t("articleNotFound") };
|
throw new ArticleInputError("editConflict");
|
||||||
const { rawSlug, ...fields } = input;
|
const historyId = String(id);
|
||||||
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
const historyBefore = await historySnapshot(tx, "news", historyId);
|
||||||
slug =
|
await tx.insert(ArticleRevisions).values({
|
||||||
requestedSlug === existing.slug
|
articleId: id,
|
||||||
? existing.slug
|
userId: staff.id,
|
||||||
: await uniqueSlug(requestedSlug, id);
|
payload: JSON.stringify({
|
||||||
becamePublished =
|
title: existing.title,
|
||||||
fields.status === "published" && existing.status !== "published";
|
slug: existing.slug,
|
||||||
await db
|
image: existing.image,
|
||||||
.update(WebsiteArticles)
|
shortStory: existing.shortStory,
|
||||||
.set({
|
fullStory: existing.fullStory,
|
||||||
...fields,
|
status: existing.status,
|
||||||
slug,
|
publishAt: existing.publishAt?.toISOString() ?? "",
|
||||||
publishedAt:
|
baseToken: "",
|
||||||
fields.status === "published"
|
}),
|
||||||
? (existing.publishedAt ?? new Date())
|
});
|
||||||
: null,
|
const { rawSlug, ...fields } = input;
|
||||||
updatedAt: new Date(),
|
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
|
||||||
})
|
slug =
|
||||||
.where(eq(WebsiteArticles.id, id));
|
requestedSlug === existing.slug
|
||||||
|
? existing.slug
|
||||||
|
: await uniqueSlug(requestedSlug, id, tx);
|
||||||
|
becamePublished =
|
||||||
|
fields.status === "published" && existing.status !== "published";
|
||||||
|
await tx
|
||||||
|
.update(WebsiteArticles)
|
||||||
|
.set({
|
||||||
|
...fields,
|
||||||
|
slug,
|
||||||
|
publishedAt:
|
||||||
|
fields.status === "published"
|
||||||
|
? (existing.publishedAt ?? new Date())
|
||||||
|
: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(WebsiteArticles.id, id));
|
||||||
|
await recordHistory(tx, "news", historyId, staff.id, historyBefore);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return saveFailure(error, "update");
|
return saveFailure(error, "update");
|
||||||
}
|
}
|
||||||
@@ -170,6 +202,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
|
|||||||
await tx
|
await tx
|
||||||
.delete(WebsiteArticleComments)
|
.delete(WebsiteArticleComments)
|
||||||
.where(eq(WebsiteArticleComments.articleId, id));
|
.where(eq(WebsiteArticleComments.articleId, id));
|
||||||
|
await tx
|
||||||
|
.delete(ArticleRevisions)
|
||||||
|
.where(eq(ArticleRevisions.articleId, id));
|
||||||
|
await tx
|
||||||
|
.delete(ArticleDrafts)
|
||||||
|
.where(eq(ArticleDrafts.articleKey, String(id)));
|
||||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: true,
|
||||||
|
queries: [] as { sql: string; params: unknown[] }[],
|
||||||
|
rows: [] as unknown[],
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
getApiAdminContext: async () => ({
|
||||||
|
session: { user: { id: 42, rank: 1 } },
|
||||||
|
permissions: {},
|
||||||
|
}),
|
||||||
|
canAccess: () => state.allowed,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/authorization-events", () => ({
|
||||||
|
logAuthorizationEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
||||||
|
vi.mock("@/lib/report-error", () => ({ reportError: vi.fn() }));
|
||||||
|
vi.mock("@/lib/foundation/security", () => ({
|
||||||
|
extractClientIpAsync: async () => "127.0.0.1",
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => {
|
||||||
|
const execute = async (sql: SQL) => {
|
||||||
|
const query = new MySqlDialect().sqlToQuery(sql);
|
||||||
|
state.queries.push(query);
|
||||||
|
return [state.rows];
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
execute,
|
||||||
|
transaction: async (fn: (tx: { execute: typeof execute }) => unknown) =>
|
||||||
|
fn({ execute }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteTableView,
|
||||||
|
listTableViews,
|
||||||
|
saveTableView,
|
||||||
|
} from "./admin-table-views";
|
||||||
|
|
||||||
|
describe("saved table views access and ownership", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
state.allowed = true;
|
||||||
|
state.rows = [];
|
||||||
|
state.queries = [];
|
||||||
|
});
|
||||||
|
it("denies ordinary signed-in users without staff permissions before SQL", async () => {
|
||||||
|
state.allowed = false;
|
||||||
|
expect((await listTableViews({ path: "/admin/users" })).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await saveTableView({
|
||||||
|
path: "/admin/users",
|
||||||
|
name: "Mine",
|
||||||
|
state: { params: {}, hidden: [] },
|
||||||
|
})
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await deleteTableView({ path: "/admin/users", name: "Mine" })).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(state.queries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it("scopes reads and deletes to the session owner, table, and name", async () => {
|
||||||
|
await listTableViews({ path: "/admin/users" });
|
||||||
|
expect(state.queries[0].params).toEqual([42, "/admin/users"]);
|
||||||
|
expect(state.queries[0].sql).toContain("WHERE user_id = ? AND path = ?");
|
||||||
|
await deleteTableView({ path: "/admin/users", name: "Mine" });
|
||||||
|
expect(state.queries[1].params).toEqual([42, "/admin/users", "Mine"]);
|
||||||
|
expect(state.queries[1].sql).toContain(
|
||||||
|
"WHERE user_id = ? AND path = ? AND name = ?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("serializes changes on the owner row and stores only validated state", async () => {
|
||||||
|
const view = {
|
||||||
|
path: "/admin/users",
|
||||||
|
name: "Mine",
|
||||||
|
state: { params: { search: "Alice" }, hidden: ["mail"] },
|
||||||
|
};
|
||||||
|
expect((await saveTableView(view)).ok).toBe(true);
|
||||||
|
expect(state.queries[0]).toMatchObject({
|
||||||
|
sql: "SELECT id FROM users WHERE id = ? FOR UPDATE",
|
||||||
|
params: [42],
|
||||||
|
});
|
||||||
|
expect(state.queries[2].params).toEqual([
|
||||||
|
42,
|
||||||
|
"/admin/users",
|
||||||
|
"Mine",
|
||||||
|
JSON.stringify(view.state),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("rejects invalid stored states without returning them as usable views", async () => {
|
||||||
|
state.rows = [
|
||||||
|
{ name: "Broken", state: '{"params":{"token":"secret"},"hidden":[]}' },
|
||||||
|
];
|
||||||
|
expect(await listTableViews({ path: "/admin/users" })).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { views: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use server";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { namedTableView, tableViewPath } from "@/lib/admin/table-view-state";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { adminAction } from "@/lib/safe-action";
|
||||||
|
|
||||||
|
const tableViewPermissions = [
|
||||||
|
PERMS.ADMIN_DASHBOARD,
|
||||||
|
PERMS.MOD_DASHBOARD,
|
||||||
|
PERMS.MODERATION_VIEW,
|
||||||
|
PERMS.MOD_CFH_VIEW,
|
||||||
|
PERMS.MOD_ACTIONS,
|
||||||
|
PERMS.MOD_BANS_VIEW,
|
||||||
|
PERMS.MOD_TICKETS_VIEW,
|
||||||
|
PERMS.MOD_TEAM_VIEW,
|
||||||
|
PERMS.MOD_USERS_VIEW,
|
||||||
|
PERMS.TICKETS_VIEW,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const listTableViews = adminAction(
|
||||||
|
{
|
||||||
|
permission: tableViewPermissions,
|
||||||
|
schema: z.object({ path: tableViewPath }),
|
||||||
|
},
|
||||||
|
async ({ session, data }) => {
|
||||||
|
const [rows] = await db.execute(
|
||||||
|
sql`SELECT name, state FROM website_admin_table_views WHERE user_id = ${Number(session.user.id)} AND path = ${data.path} ORDER BY name LIMIT 20`,
|
||||||
|
);
|
||||||
|
const views = (
|
||||||
|
rows as unknown as { name: string; state: string }[]
|
||||||
|
).flatMap((row) => {
|
||||||
|
try {
|
||||||
|
const parsed = namedTableView.safeParse({
|
||||||
|
name: row.name,
|
||||||
|
state: JSON.parse(row.state),
|
||||||
|
});
|
||||||
|
return parsed.success ? [parsed.data] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { ok: true, data: { views } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export const saveTableView = adminAction(
|
||||||
|
{
|
||||||
|
permission: tableViewPermissions,
|
||||||
|
schema: namedTableView.extend({ path: tableViewPath }),
|
||||||
|
rateLimitKey: "table-view-save",
|
||||||
|
rateLimitMax: 30,
|
||||||
|
rateLimitWindowMs: 60000,
|
||||||
|
},
|
||||||
|
async ({ session, data }) => {
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.execute(
|
||||||
|
sql`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`,
|
||||||
|
);
|
||||||
|
const [rows] = await tx.execute(
|
||||||
|
sql`SELECT name FROM website_admin_table_views WHERE user_id = ${userId} AND path = ${data.path}`,
|
||||||
|
);
|
||||||
|
const names = rows as unknown as { name: string }[];
|
||||||
|
if (
|
||||||
|
names.length >= 20 &&
|
||||||
|
!names.some(
|
||||||
|
(row) =>
|
||||||
|
row.name.toLocaleLowerCase() === data.name.toLocaleLowerCase(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Maximum 20 saved views per table");
|
||||||
|
await tx.execute(
|
||||||
|
sql`INSERT INTO website_admin_table_views (user_id,path,name,state) VALUES (${userId},${data.path},${data.name},${JSON.stringify(data.state)}) ON DUPLICATE KEY UPDATE state = VALUES(state)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
export const deleteTableView = adminAction(
|
||||||
|
{
|
||||||
|
permission: tableViewPermissions,
|
||||||
|
schema: z.object({ path: tableViewPath, name: z.string().min(1).max(60) }),
|
||||||
|
},
|
||||||
|
async ({ session, data }) => {
|
||||||
|
await db.execute(
|
||||||
|
sql`DELETE FROM website_admin_table_views WHERE user_id = ${Number(session.user.id)} AND path = ${data.path} AND name = ${data.name}`,
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
version: 0,
|
||||||
|
update: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
permission: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const tx = {
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (value: unknown) => {
|
||||||
|
state.insert(table, value);
|
||||||
|
return { onDuplicateKeyUpdate: async () => {} };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({ for: async () => [{ version: state.version }] }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (value: unknown) => {
|
||||||
|
state.update(table, value);
|
||||||
|
return { where: async () => {} };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...(await import("@/db/schema")),
|
||||||
|
db: { transaction: async (fn: (value: typeof tx) => unknown) => fn(tx) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { ArticleDrafts } from "@/db/article-editor";
|
||||||
|
import { autosaveArticle } from "./article-recovery";
|
||||||
|
|
||||||
|
function draft() {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("title", "Incomplete title");
|
||||||
|
form.set("status", "published");
|
||||||
|
form.set("fullStory", "Work in progress");
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.version = 0;
|
||||||
|
state.permission.mockResolvedValue({ id: 17 });
|
||||||
|
});
|
||||||
|
it("stores incomplete editor content only in a private draft table even when publication is selected", async () => {
|
||||||
|
expect(await autosaveArticle("new", 0, draft())).toEqual({
|
||||||
|
ok: true,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
expect(state.permission).toHaveBeenCalledWith("news.edit");
|
||||||
|
expect(state.insert).toHaveBeenCalledWith(
|
||||||
|
ArticleDrafts,
|
||||||
|
expect.objectContaining({ userId: 17, articleKey: "new" }),
|
||||||
|
);
|
||||||
|
expect(state.update).toHaveBeenCalledOnce();
|
||||||
|
expect(state.update).toHaveBeenCalledWith(
|
||||||
|
ArticleDrafts,
|
||||||
|
expect.objectContaining({ version: 1 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("does not replace a more recent draft from another tab", async () => {
|
||||||
|
state.version = 4;
|
||||||
|
expect(await autosaveArticle("new", 3, draft())).toEqual({ ok: false });
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("does not access storage when the editor permission is denied", async () => {
|
||||||
|
state.permission.mockRejectedValue(new Error("denied"));
|
||||||
|
await expect(autosaveArticle("new", 0, draft())).rejects.toThrow("denied");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("rejects excessive article content before writing", async () => {
|
||||||
|
const form = draft();
|
||||||
|
form.set("fullStory", "x".repeat(500_001));
|
||||||
|
await expect(autosaveArticle("new", 0, form)).rejects.toThrow("draftInvalid");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||