Init repo
Master Production Deploy / production (push) Failing after 7m51s

This commit is contained in:
2026-05-16 23:51:49 +03:00
parent 9f4f99f0f9
commit df60a7b74c
41 changed files with 5107 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitea
.next
node_modules
data
coverage
.env
.env.local
npm-debug.log*
*.tsbuildinfo
tasks
+144
View File
@@ -0,0 +1,144 @@
name: Master Production Deploy
on:
push:
branches:
- master
concurrency:
group: deploy-production
cancel-in-progress: false
env:
REGISTRY: ${{ vars.CONTAINER_REGISTRY }}
WEB_IMAGE_REPO: ${{ vars.CONTAINER_REGISTRY }}/${{ github.repository_owner }}/${{ github.event.repository.name }}-web
RELEASE_ID: ${{ github.sha }}-${{ github.run_number }}
NPM_CONFIG_AUDIT: "false"
NPM_CONFIG_FUND: "false"
NPM_CONFIG_UPDATE_NOTIFIER: "false"
jobs:
production:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup npm
run: npm install -g npm@11.6.4
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Quality gate
run: npm run ci:quality
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push web image
id: build-web
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile.web
push: true
tags: ${{ env.WEB_IMAGE_REPO }}:${{ github.sha }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ env.RELEASE_ID }}
- name: Production pre-deploy checks
env:
ENVIRONMENT: production
STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }}
RELEASE_ID: ${{ env.RELEASE_ID }}
COMMIT_SHA: ${{ github.sha }}
WEB_IMAGE: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }}
PUBLIC_HOST: ${{ vars.PRODUCTION_PUBLIC_HOST }}
TRAEFIK_NETWORK: ${{ vars.PRODUCTION_TRAEFIK_NETWORK }}
SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }}
SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }}
run: node deploy/scripts/predeploy-check.mjs
- name: Deploy production
env:
ENVIRONMENT: production
STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }}
RELEASE_ID: ${{ env.RELEASE_ID }}
COMMIT_SHA: ${{ github.sha }}
WEB_IMAGE: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }}
PUBLIC_HOST: ${{ vars.PRODUCTION_PUBLIC_HOST }}
TRAEFIK_NETWORK: ${{ vars.PRODUCTION_TRAEFIK_NETWORK }}
SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }}
SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }}
SWARM_SSH_PORT: ${{ secrets.PRODUCTION_SWARM_SSH_PORT }}
REGISTRY: ${{ env.REGISTRY }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
WEB_REPLICAS: ${{ vars.PRODUCTION_WEB_REPLICAS }}
WEB_PLACEMENT_CONSTRAINT: ${{ vars.PRODUCTION_WEB_PLACEMENT_CONSTRAINT }}
TRAEFIK_ENTRYPOINTS: ${{ vars.TRAEFIK_ENTRYPOINTS }}
TRAEFIK_TLS: ${{ vars.TRAEFIK_TLS }}
REGION: ${{ vars.REGION }}
TEAM_ID: ${{ vars.TEAM_ID }}
DEPLOY_MIN_FREE_MB: ${{ vars.DEPLOY_MIN_FREE_MB }}
DEPLOY_PRUNE_BEFORE_PULL: ${{ vars.DEPLOY_PRUNE_BEFORE_PULL }}
DEPLOY_PRUNE_UNTIL: ${{ vars.DEPLOY_PRUNE_UNTIL }}
DEPLOY_CLEANUP_FAILED_IMAGES: ${{ vars.DEPLOY_CLEANUP_FAILED_IMAGES }}
VIDEOREG_DB_MEMORY_TTL_MS: ${{ vars.VIDEOREG_DB_MEMORY_TTL_MS }}
VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS: ${{ vars.VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS }}
run: deploy/scripts/deploy-over-ssh.sh
- name: Production smoke checks
id: smoke
continue-on-error: true
env:
BASE_URL: https://${{ vars.PRODUCTION_PUBLIC_HOST }}
EXPECTED_RELEASE_ID: ${{ env.RELEASE_ID }}
SMOKE_RESULT_PATH: smoke-result.json
run: node deploy/scripts/smoke-check.mjs
- name: Roll back production after failed smoke checks
if: steps.smoke.outcome == 'failure'
env:
ENVIRONMENT: production
STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }}
SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }}
SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }}
SWARM_SSH_PORT: ${{ secrets.PRODUCTION_SWARM_SSH_PORT }}
run: deploy/scripts/rollback-over-ssh.sh
- name: Fail after production smoke rollback
if: steps.smoke.outcome == 'failure'
run: exit 1
- name: Write release manifest
env:
COMMIT_SHA: ${{ github.sha }}
RELEASE_ID: ${{ env.RELEASE_ID }}
WEB_IMAGE_DIGEST: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }}
SMOKE_RESULT_PATH: smoke-result.json
WORKFLOW_RUN_ID: ${{ github.run_id }}
run: node deploy/scripts/write-release-manifest.mjs > release-manifest.json
- name: Upload release manifest
uses: actions/upload-artifact@v4
with:
name: release-manifest-${{ env.RELEASE_ID }}
path: release-manifest.json
+36
View File
@@ -0,0 +1,36 @@
name: PR Quality Gate
on:
pull_request:
branches:
- master
concurrency:
group: pr-${{ github.ref }}
cancel-in-progress: true
env:
NPM_CONFIG_AUDIT: "false"
NPM_CONFIG_FUND: "false"
NPM_CONFIG_UPDATE_NOTIFIER: "false"
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup npm
run: npm install -g npm@11.6.4
- name: Install dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Quality gate
run: npm run ci:quality
+8
View File
@@ -0,0 +1,8 @@
.next/
node_modules/
data/
coverage/
.env
.env.local
npm-debug.log*
*.tsbuildinfo
+3
View File
@@ -0,0 +1,3 @@
audit=false
fund=false
update-notifier=false
+37
View File
@@ -0,0 +1,37 @@
## videoreg.ru
Small Next.js video registry using TypeScript, App Router, FSD-oriented folders, and a primitive file-backed JSON database.
### Local development
```bash
npm install
npm run dev
```
The app runs on [http://localhost:3000](http://localhost:3000).
### Data storage
By default records are stored in `data/videoreg.json`. Override the path with:
```bash
VIDEOREG_DB_FILE=/absolute/path/videoreg.json npm run dev
```
Reads are intentionally cached aggressively:
- process memory cache for the primitive JSON database;
- Next.js cache tag for dashboard/API reads;
- long HTTP cache headers on the read API.
Writes call `revalidateTag` and update the process cache.
### Deployment
Gitea Actions runs:
- `.gitea/workflows/pr.yml`: quality gate for pull requests into `master`;
- `.gitea/workflows/master.yml`: quality gate, image build, push, and direct Swarm deploy from `master`.
The Swarm stack defaults to one replica on a manager node because the primitive database is a mounted JSON file. Use a real database before raising replicas above one or spreading the service across nodes.
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY .npmrc package.json package-lock.json ./
RUN npm install -g npm@11.6.4 \
&& npm ci --prefer-offline --no-audit --no-fund
FROM deps AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY next-env.d.ts next.config.ts tsconfig.json vitest.config.ts ./
COPY src src
COPY public public
RUN npm run ci:quality
FROM node:22-alpine AS runtime
ENV NODE_ENV=production \
APP_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
VIDEOREG_DB_FILE=/data/videoreg.json \
PORT=3000
WORKDIR /app
RUN addgroup -S nodeapp && adduser -S nodeapp -G nodeapp && mkdir -p /data && chown nodeapp:nodeapp /data
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER nodeapp
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health/ready >/dev/null || exit 1
CMD ["node", "server.js"]
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
set -Eeuo pipefail
log() {
printf '{"event":"%s","environment":"%s","stack":"%s","releaseId":"%s","commitSha":"%s"}\n' \
"$1" "${ENVIRONMENT:-unknown}" "${STACK_NAME:-unknown}" "${RELEASE_ID:-unknown}" "${COMMIT_SHA:-unknown}"
}
require_var() {
local name="$1"
if [ -z "${!name:-}" ]; then
printf 'Required environment variable %s is missing\n' "$name" >&2
exit 2
fi
}
require_var ENVIRONMENT
require_var STACK_NAME
require_var RELEASE_ID
require_var COMMIT_SHA
require_var WEB_IMAGE
require_var PUBLIC_HOST
require_var TRAEFIK_NETWORK
REMOTE_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}"
LOCK_FILE="${RELEASE_LOCK_FILE:-/tmp/${STACK_NAME}-${ENVIRONMENT}.lock}"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "Another deployment is already running for ${STACK_NAME}/${ENVIRONMENT}" >&2
exit 75
fi
cleanup_release_images() {
if [ "${DEPLOY_CLEANUP_FAILED_IMAGES:-true}" != "true" ]; then
return 0
fi
echo "Cleaning up unused release image after failed deploy." >&2
docker image rm "$WEB_IMAGE" >/dev/null 2>&1 || true
if [ -n "${COMMIT_SHA:-}" ]; then
docker image prune -af --filter "label=org.opencontainers.image.revision=${COMMIT_SHA}" >&2 || true
fi
}
cleanup_on_exit() {
local status=$?
trap - EXIT
if [ "$status" -ne 0 ]; then
cleanup_release_images
fi
exit "$status"
}
trap cleanup_on_exit EXIT
docker_disk_paths() {
local docker_root
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
if [ -n "$docker_root" ] && [ -d "$docker_root" ]; then
printf '%s\n' "$docker_root"
fi
if [ -d /var/lib/containerd ]; then
printf '%s\n' /var/lib/containerd
fi
}
prune_docker_before_pull() {
if [ "${DEPLOY_PRUNE_BEFORE_PULL:-false}" != "true" ]; then
return 0
fi
local prune_until="${DEPLOY_PRUNE_UNTIL:-168h}"
echo "Pruning unused Docker images older than ${prune_until} before pulling release image." >&2
docker image prune -af --filter "until=${prune_until}" >&2 || true
docker builder prune -af --filter "until=${prune_until}" >&2 || true
}
check_docker_disk_space() {
local min_mb="${DEPLOY_MIN_FREE_MB:-2048}"
if [[ ! "$min_mb" =~ ^[0-9]+$ ]]; then
echo "DEPLOY_MIN_FREE_MB must be an integer MiB value, got: ${min_mb}" >&2
exit 2
fi
local min_kb=$((min_mb * 1024))
local failed=0
local path available_kb available_mb
while IFS= read -r path; do
available_kb="$(df -Pk "$path" | awk 'NR == 2 { print $4 }')"
if [ -z "$available_kb" ]; then
continue
fi
if [ "$available_kb" -lt "$min_kb" ]; then
available_mb=$((available_kb / 1024))
echo "Docker storage path ${path} has ${available_mb} MiB free; need at least ${min_mb} MiB before pulling release image." >&2
failed=1
fi
done < <(docker_disk_paths | awk '!seen[$0]++')
if [ "$failed" -ne 0 ]; then
docker system df >&2 || true
exit 1
fi
}
wait_service() {
local service="$1"
local timeout="${DEPLOY_WAIT_TIMEOUT_SECONDS:-300}"
local started_at
started_at="$(date +%s)"
while true; do
local update_state
update_state="$(docker service inspect --format '{{if .UpdateStatus}}{{.UpdateStatus.State}}{{end}}' "$service" 2>/dev/null || true)"
if [ "$update_state" = "rollback_started" ] || [ "$update_state" = "rollback_completed" ] || [ "$update_state" = "paused" ]; then
docker service ps --no-trunc "$service" >&2 || true
exit 1
fi
local desired running failed
desired="$(docker service inspect --format '{{if .Spec.Mode.Replicated}}{{.Spec.Mode.Replicated.Replicas}}{{else}}1{{end}}' "$service")"
running="$(docker service ps "$service" --filter desired-state=running --format '{{.CurrentState}}' | grep -c '^Running' || true)"
failed="$(docker service ps "$service" --no-trunc --format '{{.CurrentState}} {{.Error}}' | grep -Ec 'Rejected|Failed|No such image|unhealthy' || true)"
if [ "$failed" -gt 0 ]; then
docker service ps --no-trunc "$service" >&2 || true
exit 1
fi
if [ "$running" -ge "$desired" ]; then
log "service_converged"
return 0
fi
if [ "$(( $(date +%s) - started_at ))" -ge "$timeout" ]; then
docker service ps --no-trunc "$service" >&2 || true
echo "Timed out waiting for ${service} convergence" >&2
exit 1
fi
sleep 5
done
}
log deploy_start
if [ -n "${REGISTRY:-}" ] && [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin
fi
prune_docker_before_pull
check_docker_disk_space
docker image pull "$WEB_IMAGE"
export ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE PUBLIC_HOST TRAEFIK_NETWORK
export REGION="${REGION:-}" TEAM_ID="${TEAM_ID:-}" WEB_REPLICAS="${WEB_REPLICAS:-1}"
export WEB_PLACEMENT_CONSTRAINT="${WEB_PLACEMENT_CONSTRAINT:-node.role == manager}"
export TRAEFIK_ENTRYPOINTS="${TRAEFIK_ENTRYPOINTS:-websecure}" TRAEFIK_TLS="${TRAEFIK_TLS:-true}"
export VIDEOREG_DB_MEMORY_TTL_MS="${VIDEOREG_DB_MEMORY_TTL_MS:-3600000}"
export VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS="${VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS:-3600}"
docker stack deploy --with-registry-auth --prune -c "$REMOTE_DIR/docker-stack.yml" "$STACK_NAME"
wait_service "${STACK_NAME}_web"
log deploy_converged
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -Eeuo pipefail
require_var() {
local name="$1"
if [ -z "${!name:-}" ]; then
printf 'Required environment variable %s is missing\n' "$name" >&2
exit 2
fi
}
require_var ENVIRONMENT
require_var STACK_NAME
LOCK_FILE="${RELEASE_LOCK_FILE:-/tmp/${STACK_NAME}-${ENVIRONMENT}.lock}"
exec 9>"$LOCK_FILE"
flock -n 9 || {
echo "Another deployment operation is already running for ${STACK_NAME}/${ENVIRONMENT}" >&2
exit 75
}
printf '{"event":"rollback_start","environment":"%s","stack":"%s"}\n' "$ENVIRONMENT" "$STACK_NAME"
docker service rollback "${STACK_NAME}_web" || true
printf '{"event":"rollback_requested","environment":"%s","stack":"%s"}\n' "$ENVIRONMENT" "$STACK_NAME"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -Eeuo pipefail
require_var() {
local name="$1"
if [ -z "${!name:-}" ]; then
printf 'Required environment variable %s is missing\n' "$name" >&2
exit 2
fi
}
quote() {
printf '%q' "$1"
}
require_var SWARM_MANAGER_HOST
require_var SWARM_SSH_USER
require_var ENVIRONMENT
require_var STACK_NAME
require_var RELEASE_ID
require_var COMMIT_SHA
require_var WEB_IMAGE
require_var PUBLIC_HOST
require_var TRAEFIK_NETWORK
SSH_PORT="${SWARM_SSH_PORT:-22}"
SSH_TARGET="${SWARM_SSH_USER}@${SWARM_MANAGER_HOST}"
REMOTE_DEPLOY_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}"
ssh -p "$SSH_PORT" "$SSH_TARGET" "mkdir -p $(quote "$REMOTE_DEPLOY_DIR")"
scp -P "$SSH_PORT" deploy/swarm/docker-stack.yml "$SSH_TARGET:$REMOTE_DEPLOY_DIR/docker-stack.yml"
scp -P "$SSH_PORT" deploy/remote/deploy-stack.sh "$SSH_TARGET:$REMOTE_DEPLOY_DIR/deploy-stack.sh"
remote_vars=(
ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE PUBLIC_HOST TRAEFIK_NETWORK
REGION TEAM_ID WEB_REPLICAS WEB_PLACEMENT_CONSTRAINT TRAEFIK_ENTRYPOINTS TRAEFIK_TLS REGISTRY REGISTRY_USERNAME REGISTRY_PASSWORD
RELEASE_LOCK_FILE REMOTE_DEPLOY_DIR DEPLOY_MIN_FREE_MB DEPLOY_PRUNE_BEFORE_PULL DEPLOY_PRUNE_UNTIL
DEPLOY_CLEANUP_FAILED_IMAGES VIDEOREG_DB_MEMORY_TTL_MS VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS
)
remote_command=""
for name in "${remote_vars[@]}"; do
if [ -n "${!name:-}" ]; then
remote_command+="$name=$(quote "${!name}") "
fi
done
remote_command+="bash $(quote "$REMOTE_DEPLOY_DIR/deploy-stack.sh")"
ssh -p "$SSH_PORT" "$SSH_TARGET" "$remote_command"
+32
View File
@@ -0,0 +1,32 @@
const required = [
'ENVIRONMENT',
'STACK_NAME',
'RELEASE_ID',
'COMMIT_SHA',
'WEB_IMAGE',
'PUBLIC_HOST',
'TRAEFIK_NETWORK',
'SWARM_MANAGER_HOST',
'SWARM_SSH_USER',
];
const missing = required.filter((name) => !process.env[name]);
if (missing.length > 0) {
console.error(`Missing required deploy variables: ${missing.join(', ')}`);
process.exit(1);
}
if (!/@sha256:[a-f0-9]{64}$/i.test(process.env.WEB_IMAGE ?? '')) {
console.error(`WEB_IMAGE must be pinned by immutable digest, got: ${process.env.WEB_IMAGE}`);
process.exit(1);
}
console.log(
JSON.stringify({
event: 'predeploy_check_passed',
environment: process.env.ENVIRONMENT,
stack: process.env.STACK_NAME,
releaseId: process.env.RELEASE_ID,
commitSha: process.env.COMMIT_SHA,
}),
);
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -Eeuo pipefail
require_var() {
local name="$1"
if [ -z "${!name:-}" ]; then
printf 'Required environment variable %s is missing\n' "$name" >&2
exit 2
fi
}
quote() {
printf '%q' "$1"
}
require_var SWARM_MANAGER_HOST
require_var SWARM_SSH_USER
require_var ENVIRONMENT
require_var STACK_NAME
SSH_PORT="${SWARM_SSH_PORT:-22}"
SSH_TARGET="${SWARM_SSH_USER}@${SWARM_MANAGER_HOST}"
REMOTE_DEPLOY_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}"
ssh -p "$SSH_PORT" "$SSH_TARGET" "mkdir -p $(quote "$REMOTE_DEPLOY_DIR")"
scp -P "$SSH_PORT" deploy/remote/rollback-stack.sh "$SSH_TARGET:$REMOTE_DEPLOY_DIR/rollback-stack.sh"
remote_command="ENVIRONMENT=$(quote "$ENVIRONMENT") STACK_NAME=$(quote "$STACK_NAME") "
if [ -n "${RELEASE_LOCK_FILE:-}" ]; then
remote_command+="RELEASE_LOCK_FILE=$(quote "$RELEASE_LOCK_FILE") "
fi
remote_command+="bash $(quote "$REMOTE_DEPLOY_DIR/rollback-stack.sh")"
ssh -p "$SSH_PORT" "$SSH_TARGET" "$remote_command"
+138
View File
@@ -0,0 +1,138 @@
import { writeFileSync } from 'node:fs';
const baseUrl = process.env.BASE_URL?.replace(/\/$/, '');
const expectedReleaseId = process.env.EXPECTED_RELEASE_ID;
const smokeWrite = process.env.SMOKE_WRITE === 'true';
const outputPath = process.env.SMOKE_RESULT_PATH;
const timeoutMs = Number(process.env.SMOKE_TIMEOUT_MS ?? 5000);
const retryAttempts = Number(process.env.SMOKE_RETRY_ATTEMPTS ?? 12);
const retryDelayMs = Number(process.env.SMOKE_RETRY_DELAY_MS ?? 5000);
const retryableStatuses = new Set([404, 408, 425, 429, 500, 502, 503, 504]);
if (!baseUrl) {
console.error('BASE_URL is required for smoke checks');
process.exit(2);
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function requestOnce(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
'content-type': 'application/json',
...options.headers,
},
signal: AbortSignal.timeout(timeoutMs),
});
const text = await response.text();
let body;
try {
body = text ? JSON.parse(text) : undefined;
} catch {
body = text;
}
if (!response.ok) {
const error = new Error(`${path} returned ${response.status}: ${text}`);
error.status = response.status;
throw error;
}
return body;
}
async function request(path, options = {}) {
let lastError;
for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
try {
return await requestOnce(path, options);
} catch (error) {
lastError = error;
const status = error?.status;
const retryableStatus = typeof status === 'number' && retryableStatuses.has(status);
const retryableNetworkError = error?.name === 'TimeoutError' || error?.cause?.code;
if (attempt >= retryAttempts || (!retryableStatus && !retryableNetworkError)) {
throw error;
}
await sleep(retryDelayMs);
}
}
throw lastError;
}
function assertRelease(body) {
if (!expectedReleaseId) return;
const actual = body?.metadata?.releaseId;
if (actual !== expectedReleaseId) {
throw new Error(`service reports release ${actual}, expected ${expectedReleaseId}`);
}
}
async function run() {
const startedAt = Date.now();
const checks = [];
const live = await request('/health/live');
assertRelease(live);
checks.push({ name: 'web_live', status: 'ok' });
const ready = await request('/health/ready');
assertRelease(ready);
checks.push({ name: 'web_ready', status: 'ok' });
const registry = await request('/api/videos');
if (!registry.stats || !Array.isArray(registry.videos)) {
throw new Error('Video registry API returned an invalid shape');
}
checks.push({ name: 'registry_read', status: 'ok' });
if (smokeWrite) {
await request('/api/videos', {
method: 'POST',
body: JSON.stringify({
title: `Smoke video ${Date.now()}`,
owner: 'Deploy smoke',
sourceUrl: 'https://example.com/smoke',
status: 'draft',
}),
});
checks.push({ name: 'registry_write', status: 'ok' });
}
const result = {
status: 'ok',
baseUrl,
expectedReleaseId,
checks,
durationMs: Date.now() - startedAt,
};
if (outputPath) {
writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`);
}
console.log(JSON.stringify({ event: 'smoke_check_success', ...result }));
}
run().catch((error) => {
const result = {
status: 'error',
baseUrl,
expectedReleaseId,
error: error instanceof Error ? error.message : String(error),
};
if (outputPath) {
writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`);
}
console.error(JSON.stringify({ event: 'smoke_check_failure', ...result }));
process.exit(1);
});
+30
View File
@@ -0,0 +1,30 @@
import { readFileSync } from 'node:fs';
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
const smoke = readJson(requireEnv('SMOKE_RESULT_PATH'));
const manifest = {
commitSha: requireEnv('COMMIT_SHA'),
releaseId: requireEnv('RELEASE_ID'),
webImageDigest: requireEnv('WEB_IMAGE_DIGEST'),
deployTimestamp: new Date().toISOString(),
smokeResult: smoke,
workflowRunId: requireEnv('WORKFLOW_RUN_ID'),
};
if (!/@sha256:[a-f0-9]{64}$/i.test(manifest.webImageDigest)) {
throw new Error('webImageDigest must be digest-pinned');
}
console.log(JSON.stringify(manifest, null, 2));
+58
View File
@@ -0,0 +1,58 @@
version: "3.9"
services:
web:
image: ${WEB_IMAGE}
environment:
NODE_ENV: production
APP_ENV: ${ENVIRONMENT}
PORT: "3000"
VIDEOREG_DB_FILE: /data/videoreg.json
VIDEOREG_DB_MEMORY_TTL_MS: ${VIDEOREG_DB_MEMORY_TTL_MS:-3600000}
VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS: ${VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS:-3600}
COMMIT_SHA: ${COMMIT_SHA}
RELEASE_ID: ${RELEASE_ID}
REGION: ${REGION:-}
TEAM_ID: ${TEAM_ID:-}
volumes:
- videoreg_data:/data
networks:
traefik: {}
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health/ready >/dev/null || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
deploy:
replicas: ${WEB_REPLICAS:-1}
placement:
constraints:
- ${WEB_PLACEMENT_CONSTRAINT:-node.role == manager}
update_config:
order: start-first
parallelism: 1
failure_action: rollback
monitor: 45s
rollback_config:
order: start-first
parallelism: 1
monitor: 45s
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK}"
- "traefik.http.routers.${STACK_NAME}-web.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/`)"
- "traefik.http.routers.${STACK_NAME}-web.entrypoints=${TRAEFIK_ENTRYPOINTS:-websecure}"
- "traefik.http.routers.${STACK_NAME}-web.tls=${TRAEFIK_TLS:-true}"
- "traefik.http.services.${STACK_NAME}-web.loadbalancer.server.port=3000"
stop_grace_period: 30s
volumes:
videoreg_data:
networks:
traefik:
external: true
name: ${TRAEFIK_NETWORK}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
turbopack: {
root: process.cwd(),
},
};
export default nextConfig;
+2587
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "videoreg",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"ci:quality": "npm run typecheck && npm test && npm run build"
},
"dependencies": {
"next": "16.2.4",
"react": "19.2.5",
"react-dom": "19.2.5"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.4",
"@next/swc-darwin-x64": "16.2.4",
"@next/swc-linux-arm64-gnu": "16.2.4",
"@next/swc-linux-arm64-musl": "16.2.4",
"@next/swc-linux-x64-gnu": "16.2.4",
"@next/swc-linux-x64-musl": "16.2.4",
"@next/swc-win32-arm64-msvc": "16.2.4",
"@next/swc-win32-x64-msvc": "16.2.4"
},
"devDependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@tailwindcss/postcss": "4.2.4",
"@types/node": "24.10.1",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.2.4",
"typescript": "5.9.3",
"vitest": "4.1.5"
}
}
+1
View File
@@ -0,0 +1 @@
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { getCachedVideoDashboard, VIDEO_COLLECTION_TAG } from '@/entities/video/api/videoCache';
import { addVideo } from '@/entities/video/api/videoRepository';
import { normalizeVideoDraft } from '@/entities/video/model/video';
export const revalidate = 3600;
export async function GET() {
const dashboard = await getCachedVideoDashboard();
return NextResponse.json(dashboard, {
headers: {
'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400',
},
});
}
export async function POST(request: Request) {
const body = (await request.json().catch(() => undefined)) as
| { title?: string; owner?: string; sourceUrl?: string; status?: string }
| undefined;
const video = await addVideo(normalizeVideoDraft(body ?? {}));
revalidateTag(VIDEO_COLLECTION_TAG, { expire: 0 });
revalidatePath('/');
return NextResponse.json({ video }, { status: 201 });
}
+308
View File
@@ -0,0 +1,308 @@
:root {
color-scheme: light;
--bg: #f6f6f3;
--panel: #ffffff;
--panel-muted: #efede7;
--text: #1f2528;
--muted: #667075;
--line: #d8d5cc;
--accent: #156f5b;
--accent-strong: #0c4f41;
--warning: #9a5c12;
--danger: #a33a3a;
--info: #345f9f;
--shadow: 0 10px 24px rgb(31 37 40 / 8%);
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
input,
select {
font: inherit;
}
.page-shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 24px 0 40px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid var(--line);
padding-bottom: 18px;
}
.eyebrow {
margin: 0 0 6px;
color: var(--accent);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
h1,
h2,
h3,
p {
overflow-wrap: anywhere;
}
h1 {
margin: 0;
font-size: 2rem;
line-height: 1.1;
}
h2 {
margin: 0;
font-size: 1.1rem;
line-height: 1.25;
}
h3 {
margin: 0;
font-size: 0.95rem;
line-height: 1.25;
}
.workbench {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 20px;
align-items: start;
padding-top: 22px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--line);
padding: 16px;
}
.panel-body {
padding: 16px;
}
.form-grid {
display: grid;
gap: 14px;
}
.field {
display: grid;
gap: 6px;
}
.field span {
color: var(--muted);
font-size: 0.82rem;
font-weight: 650;
}
.field input,
.field select {
min-height: 42px;
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #fff;
color: var(--text);
padding: 9px 10px;
}
.field input:focus,
.field select:focus {
border-color: var(--accent);
outline: 3px solid rgb(21 111 91 / 16%);
}
.submit-button {
min-height: 42px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
font-weight: 750;
}
.submit-button:hover {
background: var(--accent-strong);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 10px;
}
.metric {
min-height: 76px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-muted);
padding: 12px;
}
.metric span {
display: block;
color: var(--muted);
font-size: 0.76rem;
font-weight: 700;
text-transform: uppercase;
}
.metric strong {
display: block;
margin-top: 7px;
font-size: 1.45rem;
}
.video-list {
display: grid;
gap: 10px;
margin-top: 16px;
}
.video-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 14px;
}
.video-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.pill {
min-height: 24px;
border-radius: 999px;
background: var(--panel-muted);
color: var(--muted);
padding: 4px 9px;
font-size: 0.76rem;
font-weight: 700;
}
.pill.status-draft {
color: var(--warning);
}
.pill.status-published {
color: var(--accent);
}
.pill.status-archived {
color: var(--danger);
}
.cache-panel {
display: grid;
grid-template-columns: repeat(3, auto);
gap: 8px;
justify-content: end;
}
.cache-item {
min-height: 42px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 7px 10px;
}
.cache-item span {
display: block;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
}
.cache-item strong {
display: block;
margin-top: 2px;
font-size: 0.84rem;
}
.empty-state {
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--muted);
padding: 22px;
text-align: center;
}
@media (max-width: 900px) {
.topbar,
.workbench {
grid-template-columns: 1fr;
}
.topbar {
display: grid;
}
.cache-panel {
justify-content: stretch;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.page-shell {
width: min(100% - 20px, 1180px);
padding-top: 16px;
}
h1 {
font-size: 1.55rem;
}
.cache-panel,
.stats-grid,
.video-card {
grid-template-columns: 1fr;
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export function GET() {
return NextResponse.json({
status: 'ok',
service: 'videoreg-web',
metadata: {
commitSha: process.env.COMMIT_SHA ?? 'local',
releaseId: process.env.RELEASE_ID ?? 'local',
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { listVideos } from '@/entities/video/api/videoRepository';
export const dynamic = 'force-dynamic';
export async function GET() {
await listVideos();
return NextResponse.json({
status: 'ok',
service: 'videoreg-web',
metadata: {
commitSha: process.env.COMMIT_SHA ?? 'local',
releaseId: process.env.RELEASE_ID ?? 'local',
},
});
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Video Registry',
description: 'Operational video registry with primitive storage and aggressive caching.',
};
type RootLayoutProps = {
children: React.ReactNode;
};
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { getCachedVideoDashboard } from '@/entities/video/api/videoCache';
import { RegisterVideoForm } from '@/features/register-video/ui/RegisterVideoForm';
import { CachePanel } from '@/widgets/cache-panel/ui/CachePanel';
import { VideoDashboard } from '@/widgets/video-dashboard/ui/VideoDashboard';
export const revalidate = 3600;
export default async function Home() {
const dashboard = await getCachedVideoDashboard();
return (
<main className="page-shell">
<header className="topbar">
<div>
<p className="eyebrow">videoreg.ru</p>
<h1>Video Registry</h1>
</div>
<CachePanel metadata={dashboard.cache} />
</header>
<div className="workbench">
<RegisterVideoForm />
<VideoDashboard dashboard={dashboard} />
</div>
</main>
);
}
+29
View File
@@ -0,0 +1,29 @@
import 'server-only';
import { unstable_cache } from 'next/cache';
import { DB_MEMORY_TTL_MS, NEXT_CACHE_REVALIDATE_SECONDS } from '@/shared/config/env';
import { calculateVideoStats } from '@/entities/video/model/video';
import { listVideos } from './videoRepository';
export const VIDEO_COLLECTION_TAG = 'video-collection';
export const getCachedVideoDashboard = unstable_cache(
async () => {
const videos = await listVideos();
return {
videos,
stats: calculateVideoStats(videos),
cache: {
generatedAt: new Date().toISOString(),
nextRevalidateSeconds: NEXT_CACHE_REVALIDATE_SECONDS,
memoryTtlMs: DB_MEMORY_TTL_MS,
},
};
},
['video-dashboard-v1'],
{
revalidate: NEXT_CACHE_REVALIDATE_SECONDS,
tags: [VIDEO_COLLECTION_TAG],
},
);
+21
View File
@@ -0,0 +1,21 @@
import 'server-only';
import { createVideo } from '@/entities/video/model/video';
import type { VideoDraft } from '@/entities/video/model/types';
import { readPrimitiveDb, writePrimitiveDb } from '@/shared/db/primitiveJsonDb';
export async function listVideos() {
const db = await readPrimitiveDb();
return [...db.videos].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
export async function addVideo(draft: VideoDraft) {
const video = createVideo(draft);
await writePrimitiveDb((current) => ({
...current,
videos: [video, ...current.videos],
}));
return video;
}
+37
View File
@@ -0,0 +1,37 @@
export const videoStatuses = ['draft', 'published', 'archived'] as const;
export type VideoStatus = (typeof videoStatuses)[number];
export type Video = {
id: string;
title: string;
owner: string;
sourceUrl: string;
status: VideoStatus;
createdAt: string;
updatedAt: string;
};
export type VideoDraft = {
title: string;
owner: string;
sourceUrl: string;
status: VideoStatus;
};
export type VideoDashboardStats = {
total: number;
draft: number;
published: number;
archived: number;
};
export type VideoDashboardData = {
videos: Video[];
stats: VideoDashboardStats;
cache: {
generatedAt: string;
nextRevalidateSeconds: number;
memoryTtlMs: number;
};
};
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { calculateVideoStats, normalizeVideoDraft } from './video';
import type { Video } from './types';
describe('video model', () => {
it('normalizes sparse form input', () => {
expect(
normalizeVideoDraft({
title: ' Demo reel ',
owner: '',
sourceUrl: 'not a url',
status: 'unexpected',
}),
).toEqual({
title: 'Demo reel',
owner: 'Unassigned',
sourceUrl: 'https://example.com/video',
status: 'draft',
});
});
it('counts videos by status', () => {
const base = {
id: 'id',
title: 'Title',
owner: 'Ops',
sourceUrl: 'https://example.com/video',
createdAt: '2026-05-16T00:00:00.000Z',
updatedAt: '2026-05-16T00:00:00.000Z',
};
const videos: Video[] = [
{ ...base, id: 'draft', status: 'draft' },
{ ...base, id: 'published', status: 'published' },
{ ...base, id: 'archived', status: 'archived' },
{ ...base, id: 'published-2', status: 'published' },
];
expect(calculateVideoStats(videos)).toEqual({
total: 4,
draft: 1,
published: 2,
archived: 1,
});
});
});
+68
View File
@@ -0,0 +1,68 @@
import { videoStatuses, type Video, type VideoDashboardStats, type VideoDraft, type VideoStatus } from './types';
const fallbackUrl = 'https://example.com/video';
function normalizeText(value: string, fallback: string) {
const normalized = value.trim().replace(/\s+/g, ' ');
return normalized.length > 0 ? normalized : fallback;
}
function normalizeUrl(value: string) {
const normalized = value.trim();
if (normalized.length === 0) return fallbackUrl;
try {
return new URL(normalized).toString();
} catch {
return fallbackUrl;
}
}
export function isVideoStatus(value: string): value is VideoStatus {
return videoStatuses.includes(value as VideoStatus);
}
export function normalizeVideoDraft(input: {
title?: string;
owner?: string;
sourceUrl?: string;
status?: string;
}): VideoDraft {
return {
title: normalizeText(input.title ?? '', 'Untitled video'),
owner: normalizeText(input.owner ?? '', 'Unassigned'),
sourceUrl: normalizeUrl(input.sourceUrl ?? ''),
status: input.status && isVideoStatus(input.status) ? input.status : 'draft',
};
}
export function createVideoId(now = new Date()) {
const suffix = Math.random().toString(36).slice(2, 8);
return `video_${now.getTime().toString(36)}_${suffix}`;
}
export function createVideo(draft: VideoDraft, now = new Date()): Video {
const timestamp = now.toISOString();
return {
...draft,
id: createVideoId(now),
createdAt: timestamp,
updatedAt: timestamp,
};
}
export function calculateVideoStats(videos: Video[]): VideoDashboardStats {
return videos.reduce<VideoDashboardStats>(
(stats, video) => ({
...stats,
total: stats.total + 1,
[video.status]: stats[video.status] + 1,
}),
{
total: 0,
draft: 0,
published: 0,
archived: 0,
},
);
}
@@ -0,0 +1,20 @@
'use server';
import { revalidatePath, updateTag } from 'next/cache';
import { VIDEO_COLLECTION_TAG } from '@/entities/video/api/videoCache';
import { addVideo } from '@/entities/video/api/videoRepository';
import { normalizeVideoDraft } from '@/entities/video/model/video';
export async function registerVideoAction(formData: FormData) {
const draft = normalizeVideoDraft({
title: String(formData.get('title') ?? ''),
owner: String(formData.get('owner') ?? ''),
sourceUrl: String(formData.get('sourceUrl') ?? ''),
status: String(formData.get('status') ?? ''),
});
await addVideo(draft);
updateTag(VIDEO_COLLECTION_TAG);
revalidatePath('/');
}
@@ -0,0 +1,36 @@
import { registerVideoAction } from '../model/actions';
export function RegisterVideoForm() {
return (
<section className="panel" aria-labelledby="register-video-title">
<div className="panel-header">
<h2 id="register-video-title">Register video</h2>
</div>
<form className="panel-body form-grid" action={registerVideoAction}>
<label className="field">
<span>Title</span>
<input name="title" placeholder="Q2 product demo" required />
</label>
<label className="field">
<span>Owner</span>
<input name="owner" placeholder="Media ops" required />
</label>
<label className="field">
<span>Source URL</span>
<input name="sourceUrl" type="url" placeholder="https://example.com/video" required />
</label>
<label className="field">
<span>Status</span>
<select name="status" defaultValue="draft">
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</label>
<button className="submit-button" type="submit">
Add video
</button>
</form>
</section>
);
}
+5
View File
@@ -0,0 +1,5 @@
export const APP_NAME = 'videoreg';
export const DB_MEMORY_TTL_MS = Number(process.env.VIDEOREG_DB_MEMORY_TTL_MS ?? 60 * 60 * 1000);
export const NEXT_CACHE_REVALIDATE_SECONDS = Number(
process.env.VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS ?? 60 * 60,
);
+134
View File
@@ -0,0 +1,134 @@
import 'server-only';
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { DB_MEMORY_TTL_MS } from '@/shared/config/env';
import type { Video } from '@/entities/video/model/types';
type PrimitiveDbSchema = {
videos: Video[];
};
type MemorySnapshot = {
loadedAt: number;
fileMtimeMs: number;
data: PrimitiveDbSchema;
};
const defaultSeed: PrimitiveDbSchema = {
videos: [
{
id: 'video_seed_launch',
title: 'Launch recording',
owner: 'Ops',
sourceUrl: 'https://example.com/launch-recording',
status: 'published',
createdAt: '2026-05-16T09:00:00.000Z',
updatedAt: '2026-05-16T09:00:00.000Z',
},
{
id: 'video_seed_cut',
title: 'Editing backlog',
owner: 'Media',
sourceUrl: 'https://example.com/editing-backlog',
status: 'draft',
createdAt: '2026-05-16T10:00:00.000Z',
updatedAt: '2026-05-16T10:00:00.000Z',
},
],
};
let memorySnapshot: MemorySnapshot | undefined;
let writeQueue = Promise.resolve();
function getDbFilePath() {
if (process.env.VIDEOREG_DB_FILE) {
return process.env.VIDEOREG_DB_FILE;
}
return path.join(/* turbopackIgnore: true */ process.cwd(), 'data', 'videoreg.json');
}
async function ensureDbFile(filePath: string) {
await mkdir(path.dirname(filePath), { recursive: true });
try {
await stat(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
await writeFile(filePath, `${JSON.stringify(defaultSeed, null, 2)}\n`, 'utf8');
}
}
function parseSchema(raw: string): PrimitiveDbSchema {
const parsed = JSON.parse(raw) as Partial<PrimitiveDbSchema>;
return {
videos: Array.isArray(parsed.videos) ? parsed.videos : [],
};
}
async function loadFromDisk(filePath: string): Promise<MemorySnapshot> {
await ensureDbFile(filePath);
const [fileStat, raw] = await Promise.all([stat(filePath), readFile(filePath, 'utf8')]);
const snapshot = {
loadedAt: Date.now(),
fileMtimeMs: fileStat.mtimeMs,
data: parseSchema(raw),
};
memorySnapshot = snapshot;
return snapshot;
}
export async function readPrimitiveDb() {
const filePath = getDbFilePath();
const now = Date.now();
if (memorySnapshot && now - memorySnapshot.loadedAt < DB_MEMORY_TTL_MS) {
return memorySnapshot.data;
}
const fileStat = await stat(filePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined;
throw error;
});
if (memorySnapshot && fileStat && fileStat.mtimeMs === memorySnapshot.fileMtimeMs) {
memorySnapshot.loadedAt = now;
return memorySnapshot.data;
}
return (await loadFromDisk(filePath)).data;
}
export async function writePrimitiveDb(
updater: (current: PrimitiveDbSchema) => PrimitiveDbSchema | Promise<PrimitiveDbSchema>,
) {
const operation = writeQueue.then(async () => {
const filePath = getDbFilePath();
const current = await readPrimitiveDb();
const next = await updater(structuredClone(current));
const temporaryPath = `${filePath}.${process.pid}.tmp`;
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
await rename(temporaryPath, filePath);
const fileStat = await stat(filePath);
memorySnapshot = {
loadedAt: Date.now(),
fileMtimeMs: fileStat.mtimeMs,
data: next,
};
return next;
});
writeQueue = operation.then(
() => undefined,
() => undefined,
);
return operation;
}
+6
View File
@@ -0,0 +1,6 @@
export function formatDateTime(value: string) {
return new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
+25
View File
@@ -0,0 +1,25 @@
import type { VideoDashboardData } from '@/entities/video/model/types';
import { formatDateTime } from '@/shared/lib/date';
type CachePanelProps = {
metadata: VideoDashboardData['cache'];
};
export function CachePanel({ metadata }: CachePanelProps) {
return (
<aside className="cache-panel" aria-label="Cache status">
<div className="cache-item">
<span>Next cache</span>
<strong>{metadata.nextRevalidateSeconds}s</strong>
</div>
<div className="cache-item">
<span>Memory TTL</span>
<strong>{Math.round(metadata.memoryTtlMs / 1000)}s</strong>
</div>
<div className="cache-item">
<span>Generated</span>
<strong>{formatDateTime(metadata.generatedAt)}</strong>
</div>
</aside>
);
}
@@ -0,0 +1,57 @@
import type { VideoDashboardData } from '@/entities/video/model/types';
import { formatDateTime } from '@/shared/lib/date';
type VideoDashboardProps = {
dashboard: VideoDashboardData;
};
export function VideoDashboard({ dashboard }: VideoDashboardProps) {
return (
<section className="panel" aria-labelledby="video-dashboard-title">
<div className="panel-header">
<div>
<h2 id="video-dashboard-title">Registry</h2>
</div>
</div>
<div className="panel-body">
<div className="stats-grid">
<Metric label="Total" value={dashboard.stats.total} />
<Metric label="Draft" value={dashboard.stats.draft} />
<Metric label="Published" value={dashboard.stats.published} />
<Metric label="Archived" value={dashboard.stats.archived} />
</div>
<div className="video-list">
{dashboard.videos.length > 0 ? (
dashboard.videos.map((video) => (
<article className="video-card" key={video.id}>
<div>
<h3>{video.title}</h3>
<div className="video-meta">
<span className={`pill status-${video.status}`}>{video.status}</span>
<span className="pill">{video.owner}</span>
<span className="pill">{formatDateTime(video.createdAt)}</span>
</div>
</div>
<a className="pill" href={video.sourceUrl} rel="noreferrer" target="_blank">
Source
</a>
</article>
))
) : (
<div className="empty-state">No videos registered</div>
)}
</div>
</div>
</section>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="metric">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
+696
View File
@@ -0,0 +1,696 @@
# Design System Task for Car Dashcam Website
For: Designer + Frontend Design System Implementation Engineer
Stack: React + Next.js + Tailwind CSS
Architecture: Atomic Design + Design Tokens + Component-Driven Development
---
# Product Context
The website is focused on car video registrators (dashcams), accessories, comparisons, reviews, installation guides, and purchase flows.
The product positioning is:
* technologically advanced
* reliable in emergencies
* automotive premium feel
* minimal cognitive overload
* optimized for mobile-first browsing
* fast perceived loading
* visually trustworthy for expensive electronics
The visual language should combine:
* modern automotive UI
* Japanese/Korean automotive electronics aesthetics
* dashboard HUD inspiration
* premium marketplace clarity
* clean editorial layouts
Avoid:
* aggressive gaming aesthetics
* neon cyberpunk overload
* cheap AliExpress visual style
* overly corporate SaaS look
* generic ecommerce templates
The design should feel somewhere between:
* Tesla interface
* Blackmagic Design product pages
* Sony camera product pages
* premium automotive accessories catalog
* modern editorial tech magazine
---
# Resolved Direction
## Product Strategy
Phase 1 should optimize for editorial trust and comparison-first decision making, not direct ecommerce conversion.
The first proof artifact is a visual direction brief for a “Best Dashcams 2026” comparison page. Code implementation comes later, after the visual language is approved.
The page should use:
* one overall winner
* use-case winners for different buyer needs
* transparent scoring
* editorial override notes when real-world testing contradicts a raw score
Default score dimensions:
* video
* night
* parking
* reliability
* usability
* value
Use the same six dimensions across categories, but allow category-specific weights.
## Trust Model
Product facts and claims must expose three public trust states:
* verified
* claimed
* unknown
The public UI should stay simple, but the internal data model should preserve verification method, for example:
* measured in review
* confirmed from firmware docs
* confirmed from teardown
* confirmed by multiple sources
Trust labels should be quiet evidence chips with an icon and short label. Details such as method, source, and date should live in tooltips, detail panels, or source notes.
## Visual Anchor
The primary visual anchor is premium camera/product editorial, with automotive HUD cues used secondarily.
Real product and media photography should dominate. UI, HUD, and camera-interface details should appear only as restrained overlays or supporting metadata.
The default theme is light-first titanium brochure with dark product/media modules and full dark-mode parity.
## First Viewport
The first viewport of “Best Dashcams 2026” should prioritize trust and decision shortcuts:
* overall pick
* 3-5 use-case winners
* methodology access
* evidence labels
* concise scoring context
Avoid leading with a purely cinematic product hero if it delays comparison intent.
## Comparison UX
Mobile comparison should use stacked winner cards first, followed by a horizontal comparison table with sticky spec labels.
The comparison table should lead with a compact hardware block before outcome rows.
Core first rows:
* sensor
* resolution/FPS
* bitrate
* HDR
* lens/FOV
* storage
* capacitor/battery
* channels
Then show verdict, score dimensions, known issues, price, and seller information.
## Ecommerce Role
Purchase CTAs should be secondary and evidence-adjacent. They should sit near price and seller information, but must not dominate winner cards or turn the page into an affiliate marketplace.
## Visual Taste Guardrails
Use muted, pastel, old-money color behavior. Avoid raw bright, screaming, neon, or cheap saturated colors.
The lead accent family is desaturated petrol blue / steel blue.
Keep semantic amber and recording red, but make them muted and low-chroma.
Use tight geometry:
* 4-8px radii
* hairline borders
* mostly flat surfaces
* subtle tonal layering
* restrained shadows
Do not use:
* neon accents
* heavy glassmorphism
* big rounded SaaS cards
* stock gradients
* loud affiliate blocks
* crypto-dashboard styling
* gaming HUD overload
* generic ecommerce templates
## Typography Decision
Use an editorial sans for reading and UI, with technical monospace reserved for specs.
Name concrete font candidates in the spec:
* UI/editorial: Geist or Inter, with system sans fallback
* technical fragments: IBM Plex Mono or Iosevka, with system monospace fallback
---
# Primary Goals
## UX Goals
1. Make product comparison extremely easy
2. Build trust quickly
3. Reduce noise in specification-heavy interfaces
4. Support long-form technical reading
5. Make mobile usage first-class
6. Allow users to scan specs very fast
7. Visually separate verified information from marketing claims
8. Create reusable layouts for:
* reviews
* comparisons
* guides
* catalogs
* landing pages
* product detail pages
---
# Design Principles
## 1. Information Density Without Chaos
The system should support large amounts of technical data while remaining readable.
Dashcam users compare:
* bitrate
* sensor model
* HDR support
* parking mode
* CPL filters
* heat resistance
* night quality
* codecs
* Wi-Fi support
* cloud integrations
The UI must help scanning.
---
## 2. Automotive Precision
Spacing, typography, borders, and interactions should feel engineered rather than decorative.
Use:
* precise spacing scales
* subtle contrast
* restrained shadows
* clean hierarchy
* predictable interaction patterns
Avoid random rounded cards everywhere.
---
## 3. “Camera Hardware” Visual Language
Visual references:
* camera UI
* automotive instrument clusters
* photography gear
* black anodized aluminum
* dark matte surfaces
* glass reflections
* OLED dashboard typography
---
# Technical Requirements
## Stack
Implementation must use:
* Tailwind CSS
* CSS variables for tokens
* React compound components
* atomic architecture
* accessibility-first approach
* responsive-first approach
* light-first default with full dark-mode parity
* SSR-safe patterns for Next.js
---
# Atomic Architecture Structure
## Atoms
Examples:
* Button
* Badge
* Chip
* Tag
* Typography
* PriceLabel
* RatingStars
* Divider
* Input
* Select
* Checkbox
* IconButton
* Tooltip
* StatusIndicator
* BatteryIndicator
* SensorLabel
* RecordingQualityBadge
Requirements:
* variants
* sizes
* semantic states
* keyboard accessibility
* token-driven styling only
---
## Molecules
Examples:
* ProductCard
* ReviewCard
* SpecRow
* ComparisonRow
* VideoPreview
* PriceBlock
* SellerBadge
* NavigationItem
* Breadcrumbs
* SearchInput
* FilterDropdown
* StickyBuyPanel
---
## Organisms
Examples:
* ProductComparisonTable
* ReviewHero
* ProductGallery
* SpecificationSection
* VideoSamplesCarousel
* ReviewSummary
* InstallationGuide
* ProductGrid
* RecommendationRail
* StickyMobileActions
* ReviewTOC
* Header
* Footer
---
## Templates
Examples:
* Product Page
* Review Article
* Comparison Page
* Category Catalog
* Brand Page
* Landing Page
* Blog Article
* Search Results
* “Best Dashcams 2026” page
---
# Visual Direction
## Color System
### Base Palette
Primary colors should feel:
* automotive
* metallic
* technical
* trustworthy
Suggested direction:
* graphite
* gunmetal
* matte black
* titanium gray
* cold white
* deep navy accents
Accent colors:
* desaturated petrol blue / steel blue as the lead accent family
* muted low-chroma amber for warnings, heat, caution, and parking-related states
* muted low-chroma red for recording and critical states
* success green only where meaningful
Avoid rainbow interfaces and raw bright accents.
---
## Surface System
Need layered surfaces:
| Level | Usage |
| -------------- | -------------------------- |
| Surface 0 | Light titanium app background |
| Surface 1 | Main editorial cards and comparison blocks |
| Surface 2 | Elevated product/media modules |
| Surface 3 | Interactive hover surfaces |
| Surface Accent | Dark product/media blocks and premium highlights |
Light mode is the primary default.
Light mode should feel like:
* premium automotive brochure
* not plain white SaaS
Dark mode must have full parity and should preserve the same hierarchy, contrast, and trust labeling.
---
# Typography System
Typography is extremely important.
The interface must support:
* long-form reading
* technical specs
* comparisons
* editorial content
* ecommerce CTAs
Recommended direction:
Headings:
* geometric sans
* condensed possible for large titles
Body:
* highly readable
* neutral
* strong numeric clarity
Monospace:
* used for technical specs
* bitrate
* firmware
* codecs
Potential inspirations:
* Inter
* Geist
* IBM Plex Sans
* Iosevka for technical fragments
Resolved direction:
* UI/editorial: Geist or Inter, with system sans fallback
* technical fragments: IBM Plex Mono or Iosevka, with system monospace fallback
* monospace should be reserved for specs, firmware, codecs, bitrate, and compact technical metadata
---
# Layout System
## Grid
Need:
* 4px spacing base
* predictable responsive grid
* container system
* content widths for:
* article reading
* comparison tables
* dense catalogs
---
## Responsive Targets
Must support:
| Device | Priority |
| ----------------- | -------- |
| Mobile portrait | Highest |
| Mobile landscape | High |
| Tablet portrait | High |
| Desktop ultrawide | Medium |
Special focus:
* sticky bottom actions on mobile
* comparison tables on narrow screens
* thumb-friendly navigation
---
# Motion System
Motion should feel:
* mechanical
* precise
* responsive
Avoid:
* playful animations
* bouncing
* exaggerated easing
Allowed:
* subtle fades
* sliding panels
* controlled transitions
* progressive disclosure
---
# Icons
Need unified icon language.
Possible direction:
* outline icons
* automotive dashboard inspiration
* camera hardware icons
* signal indicators
* storage/media indicators
---
# Content Components
Must support:
## Review Components
* pros/cons
* verdict block
* sample footage embeds
* firmware history
* heat test results
* night comparison sliders
* speed readability examples
---
## Comparison Components
Need highly optimized comparison UX:
* sticky headers
* horizontal scroll with snap
* column pinning
* spec highlighting
* “best value” indicators
Resolved Phase 1 behavior:
* stacked winner cards first on mobile
* horizontal comparison table after cards
* sticky spec labels on narrow screens
* first visible table rows should be the compact hardware block: sensor, resolution/FPS, bitrate, HDR, lens/FOV, storage, capacitor/battery, channels
* outcome rows should follow the hardware block
---
# Ecommerce Components
Need:
* affiliate blocks
* seller comparison
* availability indicators
* price history blocks
* CTA hierarchy
* coupon presentation
Must avoid spammy affiliate aesthetics.
---
# Accessibility Requirements
Minimum:
* WCAG AA
* keyboard navigation
* visible focus states
* semantic HTML
* proper contrast
* reduced motion support
---
# Tailwind Requirements
## Must Produce
1. Tailwind token structure
2. Design token naming convention
3. Component API documentation
4. Variant strategy
5. Responsive rules
6. Dark/light theming system
7. Atomic folder structure
8. Figma component parity with React components
---
# Deliverables
## Design Deliverables
* Figma design system
* typography scale
* spacing system
* icon system
* color tokens
* component library
* responsive examples
* interaction states
* accessibility annotations
---
## Frontend Deliverables
Implementation in:
* React
* Next.js
* Tailwind CSS
Including:
* token-based theme
* reusable atomic components
* Storybook
* dark/light support
* mobile-first responsiveness
* typed component APIs
* zero hardcoded colors in components
---
# Important Constraints
The final result must NOT look like:
* generic Tailwind template
* Bootstrap ecommerce
* crypto dashboard
* gaming UI
* cheap gadget marketplace
The interface should feel:
* premium
* calm
* technical
* engineered
* trustworthy
* fast
Users should subconsciously feel:
“This is a serious automotive electronics platform.”
---
# Suggested Initial Priority
Phase 1:
* typography
* colors
* spacing
* visual direction brief for “Best Dashcams 2026”
* buttons
* cards
* navigation
* product cards
* comparison table
Phase 2:
* article system
* review system
* ecommerce modules
* filtering system
Phase 3:
* motion
* advanced comparison UX
* personalization
* saved comparisons
* dashboard-like features
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+14
View File
@@ -0,0 +1,14 @@
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});