Add dev/prod environments with TrueNAS Docker production deploy.

Split PostgreSQL into gallery_dev and gallery_prod, add Docker/Gitea deploy tooling,
SMB image sync, pgAdmin split script, dev:web on Keenetic :5173, and operator docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-04 15:15:19 +03:00
co-authored by Cursor
parent 02d238b043
commit 2edf577faf
34 changed files with 1742 additions and 80 deletions
+10 -5
View File
@@ -1,17 +1,22 @@
# Development — copy to .env (not committed)
DB_HOST=192.168.10.122
DB_PORT=5432
DB_USER=gallery
DB_PASSWORD=gallery
DB_NAME=Gallery
DB_PASSWORD=YOUR_POSTGRES_PASSWORD
DB_NAME=gallery_dev
# Production: LAN http://192.168.10.70:3520 and reverse-proxy at gallery.mysuperlab.netcraze.pro
PORT=3520
# Public dev URL (Keenetic → 192.168.10.70:5173)
# Local-only coding: npm run dev:server + dev:client on 3520 / 5173 instead
PORT=3451
HOST=0.0.0.0
PUBLIC_URL=http://gallery.mysuperlab.netcraze.pro
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
TRUST_PROXY=true
IMAGE_DIR=./data/images
# Production uses infra/docker/.env.prod → gallery_prod at gallery.mysuperlab.netcraze.pro:5173
# See Documentation/environments.md
# Optional — enable extra museum search in fetch-missing-images / search-missing-paintings
# SMITHSONIAN_API_KEY=
# HARVARD_ART_API_KEY=
+4
View File
@@ -4,6 +4,10 @@ client/node_modules/
.env
.env.local
.env.*.local
infra/docker/.env.prod
# DB backups (may contain data)
db/DataBackup/
# Recovery / temp files from local restore
_extracted/
+18 -5
View File
@@ -1,16 +1,29 @@
# Art Gallery — REST API
Base URL:
Base URL (paths are the same on every host; only the origin changes):
- **Production (public):** `http://gallery.mysuperlab.netcraze.pro`
- **Production (LAN):** `http://192.168.10.70:3520`
- **Development (direct):** `http://localhost:3520` or `http://localhost:3001` depending on `PORT` in `.env`
- **Development (Vite proxy):** `http://localhost:5173` (same paths)
| Context | Base URL |
|---------|----------|
| **Production (public)** | `https://gallery.mysuperlab.netcraze.pro` |
| **Production (LAN)** | `http://192.168.10.122:5173` |
| **Development (public)** | `https://devgallery.mysuperlab.netcraze.pro` |
| **Development (LAN)** | `http://192.168.10.70:5173` |
| **Local Vite proxy** | `http://localhost:5173` (proxies `/api` and `/images` to API on `:3451`) |
| **Local API only** | `http://localhost:3451` (when using `npm run dev:web`) |
See [environments.md](environments.md) for Keenetic rules, databases, and deploy.
All JSON responses use `Content-Type: application/json`. Errors return `{ "error": "message" }` with an appropriate HTTP status.
Static images are served at `/images/<relative-path>` from `IMAGE_DIR`.
**Quick check:**
```powershell
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
curl.exe -sk https://devgallery.mysuperlab.netcraze.pro/api/bounds
```
---
## `GET /api/bounds`
+8 -2
View File
@@ -2,14 +2,20 @@
PostgreSQL schema for the virtual gallery. Canonical DDL lives in **`db/schema.sql`**; **`server/migrate.js`** (`npm run migrate`) applies that file plus idempotent incremental scripts in `db/migrate-*.sql`. This document describes the logical model.
Connection settings come from `.env` (see [setup.md](setup.md)).
Connection settings come from `.env` (dev) or `infra/docker/.env.prod` (prod scripts). See [environments.md](environments.md) and [setup.md](setup.md).
### One-time split (legacy `Gallery` → `gallery_prod` + `gallery_dev`)
Run [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) in **pgAdmin** on the dev PC (postgres superuser). Alternative: `npm run db:split-databases` with `PGUSER=postgres`.
## Overview
| Item | Typical value |
|------|----------------|
| Engine | PostgreSQL 14+ |
| Database | `Gallery` |
| Database (dev) | `gallery_dev` |
| Database (prod) | `gallery_prod` |
| Legacy name | `Gallery` (one-time split → prod + dev) |
| App user | `gallery` |
| Time fields | Integer years (negative = BCE) |
+22 -11
View File
@@ -71,32 +71,43 @@ Gallery/
├── data/images/ # Local portraits and paintings (+ thumbs/)
├── db/ # schema.sql, setup-admin.sql, migrate-*.sql
├── server/migrate.js # npm run migrate — schema + incremental migrations
├── deploy/ # Production nginx + systemd examples
├── deploy/ # Legacy nginx + systemd examples (optional)
├── infra/docker/ # Production Dockerfile, TrueNAS compose, deploy scripts
├── Documentation/ # This folder
│ └── environments.md # Dev/prod URLs, DB split, sync, deploy
└── .env # DB and port config (not committed)
```
## Runtime modes
### Production-style (single process)
### Production (TrueNAS Docker)
Production runs in **`gallery-web`** on TrueNAS port **5173**, database **`gallery_prod`**, public URL **https://gallery.mysuperlab.netcraze.pro**. Images: `/mnt/BasePool/Applications/Gallery/data/images` (SMB share **`Gallery`**). See [environments.md](environments.md).
### Public development (`dev:web`)
```bash
npm run start:prod # build client + serve on PORT (default 3520)
# or: npm run build && npm run start
npm run dev:web # Vite :5173 + API :3451 — https://devgallery.mysuperlab.netcraze.pro
```
Serves `/api/*`, `/images/*`, and the built SPA from `client/dist` if it exists.
Uses database **`gallery_dev`** on the same PostgreSQL host.
**Deployed URLs:** public http://gallery.mysuperlab.netcraze.pro · LAN http://192.168.10.70:3520 — see [setup.md](setup.md#production-deployment).
### Development (two processes)
### Production-style single process (local)
```bash
npm run dev:server # API on PORT from .env (3520 production, 3001 typical dev)
npm run dev:client # Vite on :5173, proxies /api and /images to PORT
npm run start:prod # build client + serve on PORT from .env
```
Use the Vite URL during frontend work for HMR. When the public domain is proxied to Vite (see [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf)), both `dev:server` and `dev:client` must stay running or visitors see **503**.
Serves `/api/*`, `/images/*`, and the built SPA from `client/dist`.
### Local HMR (two processes)
```bash
npm run dev:server # API on PORT from .env
npm run dev:client # Vite on :5173, proxies /api and /images
```
Use for fast frontend iteration without Keenetic. Legacy nginx config in [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf) proxied the public domain to Vite `:5173`.
## User navigation flow
+10
View File
@@ -22,6 +22,15 @@ data/images/
File names are sanitised `{Artist}_{Title}.{ext}`. The image service can rediscover files on disk even when DB paths are empty (`server/image-service.js``syncPaintingFromDisk`).
### Dev vs production image storage
| Environment | Path on disk | Sync |
|-------------|--------------|------|
| **Development** | `./data/images/` in repo | Working copy on dev PC |
| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` |
Promote dev → prod files: `npm run images:sync-to-prod` (after `net use \\192.168.10.122\Gallery`). Refresh dev from prod: `npm run images:sync-from-prod`. See [environments.md](environments.md).
## Scripts overview
| Script | npm command | Role |
@@ -37,6 +46,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
| `update-influences.js` | `npm run update-influences` | Applies influence graph; creates missing artists/works |
| `fetch-missing-images.js` | `npm run fetch-images` | Downloads files for paintings missing on disk |
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run images:sync-*` | Robocopy via SMB `\\192.168.10.122\Gallery` |
| `regenerate-thumbnails.js` | `npm run regenerate-thumbnails` | Rebuild thumbs from full images via `sharp` |
| `audit-painting-images.js` | `npm run audit-painting-images` | Detect thumb/full aspect-ratio mismatches |
| `find-duplicate-paintings.js` | `npm run find-duplicates` | Report exact and near-duplicate catalog rows |
+316
View File
@@ -0,0 +1,316 @@
# Development and production environments
Gallery uses **one PostgreSQL server** on TrueNAS (`192.168.10.122`) with **two databases**. The **dev PC** (`192.168.10.70`) runs `npm run dev:web` on port **5173**, published at **`https://devgallery.mysuperlab.netcraze.pro`** via Keenetic.
| Environment | Database | Public URL | App host |
|-------------|----------|------------|----------|
| **Development** | `gallery_dev` | `https://devgallery.mysuperlab.netcraze.pro` | Dev PC `192.168.10.70:5173` |
| **Production** | `gallery_prod` | `https://gallery.mysuperlab.netcraze.pro` | TrueNAS container `192.168.10.122:5173` |
---
## Where to run what (quick reference)
| Step | Machine | Interface | Privilege | What |
|------|---------|-----------|-----------|------|
| Stop dev servers | Dev PC `192.168.10.70` | PowerShell (normal) | your user | Close `dev:web` / `dev:server` / `dev:client` terminals |
| **DB split (one-time)** | Dev PC | **pgAdmin** → Query Tool on `postgres` | **postgres superuser** | [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) step by step |
| Dev migrate / dev:web | Dev PC | PowerShell (normal) | your user | `npm run migrate`, `npm run dev:web` |
| Image dir on TrueNAS | TrueNAS `192.168.10.122` | **Shell** (SSH or UI → System Settings → Shell) | root / sudo | `bash infra/docker/truenas-setup.sh` |
| Copy images to prod | Dev PC | PowerShell (normal) | SMB `\\192.168.10.122\Gallery` | `net use` then `npm run images:sync-to-prod` |
| Build + push Docker image | Dev PC | **PowerShell as Administrator** | admin (for LAN hosts entry) | `npm run docker:publish` |
| Install prod app | TrueNAS | **Web UI** → Apps → Custom App | admin | Paste `infra/docker/compose.truenas.yaml` |
| Verify prod | Dev PC or TrueNAS | PowerShell / browser | any | `curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds` |
You do **not** need `psql` on TrueNAS. Database work is done from **pgAdmin on the dev PC** connected to `192.168.10.122:5432`.
---
## One-time setup (full walkthrough)
### Step A — Stop Gallery on the dev PC
**Where:** Dev PC — PowerShell or open terminal tabs (no admin needed)
Close anything using ports **5173**, **3451**, or **3520**:
- Stop `npm run dev:web`, `npm run dev:server`, `npm run dev:client`
- If prod container already runs on TrueNAS: **TrueNAS Web UI** → Apps → **gallery-web****Stop**
### Step B — Split the database (pgAdmin)
**Where:** Dev PC — **pgAdmin** (not TrueNAS shell)
1. Open **pgAdmin**.
2. Add/connect to server:
- **Host:** `192.168.10.122`
- **Port:** `5432`
- **Maintenance database:** `postgres`
- **Username:** `postgres` (superuser — **not** `gallery`)
- **Password:** your postgres password
3. Tree: **Servers** → your server → **Databases** → click **`postgres`**
4. **Tools****Query Tool** (or right-click `postgres` → Query Tool)
5. **File****Open**`Gallery\db\split-dev-prod-pgadmin.sql`
6. Run **each STEP separately** (highlight from `-- STEP N` through that section, press **F5** / Execute):
| Step | Action |
|------|--------|
| **STEP 0** | Pre-flight — should show database `Gallery` |
| **STEP 1** | Terminate connections |
| **STEP 2** | `ALTER DATABASE "Gallery" RENAME TO gallery_prod` (use lowercase variant only if STEP 0 showed `gallery`) |
| **STEP 3** | `CREATE DATABASE gallery_dev WITH TEMPLATE gallery_prod` (wait ~12 min) |
| **STEP 4** | `GRANT` to user `gallery` |
| **STEP 5** | Verify — should show `gallery_prod` and `gallery_dev` |
| **STEP 6** | **New Query Tool on `gallery_dev`** (not postgres!) — `SELECT current_database()` then `SELECT count(*) FROM paintings` |
If STEP 2 fails with “database already exists”, STEP 0 likely already shows `gallery_prod` — skip STEP 2 and continue from STEP 3 (skip STEP 3 too if `gallery_dev` exists).
**Alternative (dev PC with psql installed):**
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql
```
**Alternative (Node, postgres password in env):**
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
$env:PGHOST="192.168.10.122"; $env:PGUSER="postgres"; $env:PGPASSWORD="YOUR_POSTGRES_PASSWORD"
npm run db:split-databases
```
### Step C — Configure dev PC and test
**Where:** Dev PC — PowerShell (normal), repo root
1. Edit `.env` (copy from `.env.example` if needed):
```env
DB_NAME=gallery_dev
PORT=3451
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
```
2. Run:
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
npm run migrate
npm run dev:web
```
3. Open **https://devgallery.mysuperlab.netcraze.pro** (or http://localhost:5173 on the dev PC)
### Step D — Prepare prod image folder on TrueNAS
**Where:** TrueNAS — **Shell** (SSH to `192.168.10.122`, or TrueNAS UI → **System Settings** → **Shell**)
```bash
# Copy script to TrueNAS first, or paste commands manually:
mkdir -p /mnt/BasePool/Applications/Gallery/data/images/portraits
mkdir -p /mnt/BasePool/Applications/Gallery/data/images/paintings/thumbs
chown -R 1001:1001 /mnt/BasePool/Applications/Gallery
chmod -R u+rwX,g+rwX /mnt/BasePool/Applications/Gallery
```
Or from a checkout on TrueNAS: `bash infra/docker/truenas-setup.sh`
### Step E — Copy images dev → prod volume
**Where:** Dev PC — PowerShell (normal), repo root
Requires SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on TrueNAS.
**Connect to the share first** (once per Windows session), then sync:
```powershell
# Map share (use your TrueNAS SMB user/password)
net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
npm run images:sync-to-prod
```
UNC destination: `\\192.168.10.122\Gallery\data\images`
Type `yes` when prompted. First run copies ~1000+ files (several minutes).
If `net use` fails, open `\\192.168.10.122\Gallery` in File Explorer and sign in, then retry.
### Step F — Build and push Docker image
**Where:** Dev PC — **PowerShell as Administrator** (for fast LAN push to Gitea)
Prerequisites: **Docker Desktop running**, logged in to Gitea.
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
docker login gitea.mysuperlab.netcraze.pro
npm run docker:publish
```
Use normal PowerShell with `-SkipHosts` if you already added `192.168.10.122 gitea.mysuperlab.netcraze.pro` to `C:\Windows\System32\drivers\etc\hosts`.
### Step G — Install production on TrueNAS
**Where:** TrueNAS — **Web UI** (browser)
1. **Apps** → **Discover Apps** → **Custom App** → **Install via Docker Compose**
2. Paste contents of `infra/docker/compose.truenas.yaml` from the repo
3. Replace `YOUR_POSTGRES_PASSWORD` with the `gallery` user password
4. **Apps** → **Settings** → register Gitea registry (`gitea.mysuperlab.netcraze.pro`, token with `read:package`)
5. Deploy → wait for **gallery-web** to show **Running**
### Step H — Verify production
**Where:** Dev PC — PowerShell (normal) or any browser
```powershell
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
curl.exe -s http://192.168.10.122:5173/api/bounds
```
Open **https://gallery.mysuperlab.netcraze.pro/** — timeline and sample painting images should load.
---
## Environment files
| File | Git | Purpose |
|------|-----|---------|
| [`.env`](../.env) | ignored | **Dev** — `DB_NAME=gallery_dev`, `PORT=3451` |
| [`.env.example`](../.env.example) | tracked | Dev template |
| [`infra/docker/.env.prod`](../infra/docker/.env.prod) | ignored | **Prod** — migrate/restore prod scripts |
| [`infra/docker/.env.prod.example`](../infra/docker/.env.prod.example) | tracked | Prod template |
| [`infra/docker/compose.truenas.yaml`](../infra/docker/compose.truenas.yaml) | tracked | TrueNAS Custom App |
Never point dev `.env` at `gallery_prod`. Prod scripts refuse dev env files.
---
## Dev public access (Keenetic)
**Where:** Keenetic router Web UI (not dev PC)
| Field | Value |
|-------|-------|
| Domain | `devgallery.mysuperlab.netcraze.pro` |
| Upstream IP | `192.168.10.70` |
| Upstream port | **`5173`** |
| **Protocol to device** | **`http`** (not https) |
| Preserve Host | ON |
Vite on the dev PC speaks **plain HTTP** only. Keenetic terminates HTTPS from the browser, then must forward **HTTP** to `192.168.10.70:5173`.
If **Protocol to device** is `https`, Keenetic tries TLS against Vite → **502 Bad Gateway** (`Server: Web server`).
**Verify from dev PC** (servers must be running: `npm run dev:web`):
```powershell
# PowerShell: curl is an alias — use curl.exe for -k, or Invoke-WebRequest
curl.exe -s -o NUL -w "HTTP %{http_code}`n" http://192.168.10.70:5173/
curl.exe -sk -o NUL -w "HTTP %{http_code}`n" https://devgallery.mysuperlab.netcraze.pro/
# Or native PowerShell (skip cert check):
Invoke-WebRequest -Uri https://devgallery.mysuperlab.netcraze.pro/ -SkipCertificateCheck | Select-Object StatusCode
```
Expect **HTTP 200** (not 502).
**`.env`:** use the same scheme as the browser URL. If Keenetic serves HTTPS publicly:
```env
PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro
TRUST_PROXY=true
```
Restart `npm run dev:web` after changing `PUBLIC_URL`.
---
## Production public access (Keenetic)
**Where:** Keenetic router Web UI
| Field | Value |
|-------|-------|
| Domain | `gallery.mysuperlab.netcraze.pro` |
| Upstream IP | **`192.168.10.122`** (TrueNAS — not the dev PC) |
| Upstream port | **`5173`** |
| **Protocol to device** | **`http`** (container speaks HTTP, not HTTPS) |
| Preserve Host | ON |
The prod container listens on plain HTTP on port **5173**. Keenetic terminates HTTPS from the browser and must forward **HTTP** to TrueNAS.
**Verify the app first (bypass Keenetic):**
```powershell
curl.exe -s http://192.168.10.122:5173/api/bounds
```
Expect JSON immediately (~200). If this works but the public URL returns **502/504**, the Keenetic rule is wrong (IP, port, or `https` to device).
**Then verify public URL:**
```powershell
curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds
```
See also [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../Drunkmeyou/Documentation/gitea-https-keenetic-npm-setup.md) — same Keenetic HTTPS → HTTP upstream pattern.
---
## Daily development
**Where:** Dev PC — PowerShell (normal), repo root
| Task | Command |
|------|---------|
| Public dev URL (Keenetic) | `npm run dev:web` |
| Fast local HMR (no Keenetic) | `npm run dev:server` + `npm run dev:client` |
| Refresh dev DB from prod | `npm run db:sync-from-prod` |
| Pull prod images to dev | `npm run images:sync-from-prod` |
---
## Promote dev → prod
**Where:** Dev PC unless noted
1. `npm run db:backup`
2. Test on https://devgallery.mysuperlab.netcraze.pro
3. `npm run db:restore:prod -- --file db/DataBackup/gallery_dev_data_....txt` (type `yes`)
4. `npm run images:sync-to-prod`
5. **TrueNAS Web UI** → restart **gallery-web** (or `npm run docker:publish` if code changed)
---
## Database scripts
| Command | Where | Purpose |
|---------|-------|---------|
| pgAdmin + `split-dev-prod-pgadmin.sql` | Dev PC pgAdmin | One-time split (recommended) |
| `npm run db:split-databases` | Dev PC PowerShell | Same split (needs `PGUSER=postgres`) |
| `npm run db:sync-from-prod` | Dev PC PowerShell | Clone prod → dev |
| `npm run db:backup` | Dev PC PowerShell | Dev backup |
| `npm run db:restore:prod` | Dev PC PowerShell | Restore into prod |
## Image sync
| Command | Where | Direction |
|---------|-------|-----------|
| `npm run images:sync-to-prod` | Dev PC PowerShell | Dev → TrueNAS volume |
| `npm run images:sync-from-prod` | Dev PC PowerShell | TrueNAS → dev repo |
## Safety guards
- Prod restore reads only `infra/docker/.env.prod`
- Dev backup refuses `_prod` database names without `--prod`
- Destructive prod ops require typing `yes` or `CONFIRM_PROD=1`
## Legacy deployment
Node on dev PC `:3520` + nginx → Vite `:5173` is optional; see [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf). Production should use TrueNAS Docker on `:5173`.
Deploy details: [`infra/docker/DEPLOY-truenas.md`](../infra/docker/DEPLOY-truenas.md).
+30 -43
View File
@@ -20,10 +20,10 @@ cp .env.example .env
| `DB_PORT` | Port (default `5432`) |
| `DB_USER` | Database user |
| `DB_PASSWORD` | Database password |
| `DB_NAME` | Database name (`Gallery`) |
| `PORT` | API listen port (default `3001`; production uses `3520`) |
| `DB_NAME` | Database name (`gallery_dev` for dev; prod uses `gallery_prod`) |
| `PORT` | API listen port (`3451` for `dev:web`; prod container uses `5173`) |
| `HOST` | Bind address (default `0.0.0.0` — required for LAN access) |
| `PUBLIC_URL` | Optional public URL shown at startup (e.g. `http://gallery.mysuperlab.netcraze.pro`) |
| `PUBLIC_URL` | Public URL (dev: `https://devgallery.mysuperlab.netcraze.pro`; prod: `https://gallery.mysuperlab.netcraze.pro`) |
| `TRUST_PROXY` | Set to `true` when behind nginx/reverse proxy (honours `X-Forwarded-*`) |
| `IMAGE_DIR` | Root for cached images (default `./data/images`) |
@@ -89,14 +89,22 @@ Image fetch can take hours if you run it for the entire catalog. The first line
| Command | Purpose |
|---------|---------|
| `npm run dev:web` | **Public dev stack** — Vite `:5173`, API `:3451` (Keenetic → devgallery…) |
| `npm run docker:publish` | Build + push prod image to Gitea |
| `npm run images:sync-to-prod` | Copy `data/images/` → TrueNAS via SMB `Gallery` share |
| `npm run images:sync-from-prod` | Copy prod images → dev repo |
| `npm run db:sync-from-prod` | Clone `gallery_prod``gallery_dev` |
| `npm run db:backup` / `db:restore:prod` | Dev backup / promote to prod |
| `npm run build` | Build production SPA into `client/dist` |
| `npm run start` | API + static SPA on `HOST`:`PORT` |
| `npm run start:prod` | Build client, then start server |
| `npm run server` | Alias for `start` |
| `npm run dev:server` | API with nodemon reload |
| `npm run dev:server` | API with nodemon reload (local `:3520` / `:5173` workflow) |
| `npm run dev:client` | Vite dev server on :5173 |
| `npm run dev` | Alias for `start` |
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands.
**Production frontend:** build the client, then start the server:
```bash
@@ -105,53 +113,30 @@ npm run build
npm run start
```
Open http://localhost:3520 (or your configured `HOST`/`PORT`).
Open http://localhost:5173 (Vite) or http://localhost:3451 (API only).
## Production deployment
This install is intended to run at:
Production runs as **`gallery-web`** on TrueNAS at **https://gallery.mysuperlab.netcraze.pro** (Keenetic → `:5173`). Full guide: [environments.md](environments.md) and [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md).
| Access | URL |
|--------|-----|
| Public (reverse proxy) | http://gallery.mysuperlab.netcraze.pro |
| LAN direct | http://192.168.10.70:3520 |
| Production (Keenetic) | https://gallery.mysuperlab.netcraze.pro |
| Development (Keenetic) | https://devgallery.mysuperlab.netcraze.pro |
| LAN direct (prod container) | http://192.168.10.122:5173 |
| LAN direct (dev PC) | http://192.168.10.70:5173 |
### 1. Configure `.env`
Quick deploy checklist:
Copy `.env.example``.env` and set at least:
1. One-time DB split in **pgAdmin** on dev PC: [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql)
2. Dev `.env``DB_NAME=gallery_dev`, `PORT=3451`, `PUBLIC_URL=https://devgallery.mysuperlab.netcraze.pro`
3. `net use \\192.168.10.122\Gallery``npm run images:sync-to-prod`
4. `npm run docker:publish` → TrueNAS Custom App from `infra/docker/compose.truenas.yaml`
5. Keenetic: both domains → `:5173`, protocol to device **`http`**, correct IP per environment
```env
PORT=3520
HOST=0.0.0.0
PUBLIC_URL=http://gallery.mysuperlab.netcraze.pro
TRUST_PROXY=true
```
### Legacy deployment (optional)
`HOST=0.0.0.0` lets the app accept connections on the machines LAN IP (`192.168.10.70`). The client uses relative `/api` and `/images` paths, so no frontend URL changes are needed.
### 2. Build and start
```bash
npm install
cd client && npm install && cd ..
npm run start:prod
```
Or use the systemd unit in [`deploy/gallery.service`](../deploy/gallery.service) (adjust `User`, `WorkingDirectory`, and `EnvironmentFile`).
### 3. Reverse proxy (public domain)
Point `gallery.mysuperlab.netcraze.pro` at the host running the app. Example nginx config: [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf).
**Development (default in repo):** nginx forwards to **Vite on `127.0.0.1:5173`**. Run both `npm run dev:server` (API on `3520`) and `npm run dev:client` (`5173`). Vite proxies `/api` and `/images` to the API. If either process stops, the public hostname may return **503** (reverse proxy cannot reach upstream).
**Production (built SPA):** change nginx `proxy_pass` to `http://127.0.0.1:3520` after `npm run build` and `npm run start` — Node serves `client/dist` and the API on one port. Prefer the systemd unit in [`deploy/gallery.service`](../deploy/gallery.service) so the process restarts automatically.
Keep `TRUST_PROXY=true` in `.env` so Express sees the correct client IP and scheme.
### 4. Firewall
Allow inbound **TCP 3520** on the gallery host if clients reach it directly on the LAN (`192.168.10.70:3520`). The public hostname only needs **80/443** on the reverse-proxy host.
Node on the dev PC at `:3520` with nginx → Vite `:5173` is superseded by TrueNAS Docker prod and `npm run dev:web` for public dev. See [`deploy/nginx-gallery.conf`](../deploy/nginx-gallery.conf) and [`deploy/gallery.service`](../deploy/gallery.service) only if you need a local nginx/systemd setup.
## Maintenance scripts
@@ -226,14 +211,16 @@ Gitea: [Danilka/Art-gallery](https://gitea.mysuperlab.netcraze.pro/Danilka/Art-g
git clone https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery.git
```
After clone: copy `.env.example``.env`, install dependencies, run `npm run setup` against your Postgres instance, then the post-seed steps above.
After clone: copy `.env.example``.env`, install dependencies, run [one-time DB split](Documentation/environments.md#step-b--split-the-database-pgadmin), then post-seed steps. Deploy: [environments.md](Documentation/environments.md).
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| Empty timeline | DB not seeded | `npm run seed` |
| 503 on public URL | Vite or API not running behind nginx | Start `npm run dev:server` + `npm run dev:client`, or switch nginx to production `:3520` |
| 502 / 504 on public URL | Keenetic rule wrong (IP, port, or `https` to device) | Dev → `192.168.10.70:5173`; prod → `192.168.10.122:5173`; protocol **`http`** — see [environments.md](environments.md) |
| 503 on public URL | Dev servers not running | `npm run dev:web` (or `dev:server` + `dev:client` for local HMR) |
| **`manifest unknown`** on TrueNAS deploy | Image not in Gitea | `npm run docker:publish` on dev PC first |
| 500 on all `/api/*` | Wrong `.env` or Postgres down | Check connection, logs |
| “Biographical information not yet available” | Bios not fetched | `npm run fetch-artist-bios` |
| Placeholder portraits on timeline | `portrait_path` not set | `npm run fetch-artist-images` |
+23 -11
View File
@@ -7,6 +7,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
| Document | Purpose |
|----------|---------|
| [Documentation/basics.md](Documentation/basics.md) | Architecture, layout, user flow |
| [Documentation/environments.md](Documentation/environments.md) | Dev/prod URLs, DB split, deploy, sync |
| [Documentation/setup.md](Documentation/setup.md) | Install, env, npm scripts |
| [Documentation/DB_structure.md](Documentation/DB_structure.md) | PostgreSQL tables |
| [Documentation/API.md](Documentation/API.md) | REST endpoints |
@@ -19,7 +20,7 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
## Setup
1. Copy `.env.example` to `.env` and set database credentials.
1. Copy `.env.example` to `.env` and set database credentials (`DB_NAME=gallery_dev` after [one-time split](Documentation/environments.md)).
2. Install dependencies:
```bash
@@ -52,25 +53,36 @@ Interactive virtual art gallery: zoomable historical timeline with era click-to-
npm run fetch-images -- --artist="Claude Monet" # one artist in catalog order
```
5. Build the client and start the server:
5. **Development (public URL):**
```bash
npm run start:prod
npm run dev:web
```
**Production URLs:**
- Public: http://gallery.mysuperlab.netcraze.pro (via reverse proxy)
- LAN: http://192.168.10.70:3520
Open https://devgallery.mysuperlab.netcraze.pro (or http://localhost:5173 locally).
See [Documentation/setup.md](Documentation/setup.md#production-deployment) for nginx/systemd configs in `deploy/`.
**Production:** https://gallery.mysuperlab.netcraze.pro — see [Documentation/environments.md](Documentation/environments.md) and [infra/docker/DEPLOY-truenas.md](infra/docker/DEPLOY-truenas.md).
## Deployed URLs
| Environment | URL | Host |
|-------------|-----|------|
| Development | https://devgallery.mysuperlab.netcraze.pro | Dev PC `:5173` (Vite) + `:3451` (API) |
| Production | https://gallery.mysuperlab.netcraze.pro | TrueNAS Docker `:5173` |
Operator guide: [Documentation/environments.md](Documentation/environments.md).
## Development
Run API and Vite dev server separately:
**Public dev (Keenetic):**
```bash
npm run dev:server # API — PORT from .env (3520 or 3001)
npm run dev:client # http://localhost:5173 (proxies /api and /images)
npm run dev:web # Vite :5173, API :3451
```
Both processes must run when the public domain is proxied to Vite (`deploy/nginx-gallery.conf`).
**Local HMR:**
```bash
npm run dev:server # API — PORT from .env
npm run dev:client # http://localhost:5173 (proxies /api and /images)
```
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineConfig(({ mode }) => {
plugins: [react()],
server: {
host: true,
allowedHosts: ['gallery.mysuperlab.netcraze.pro', 'localhost'],
allowedHosts: ['devgallery.mysuperlab.netcraze.pro', 'gallery.mysuperlab.netcraze.pro', 'localhost'],
proxy: {
'/api': apiTarget,
'/images': apiTarget,
+126
View File
@@ -0,0 +1,126 @@
-- =============================================================================
-- Gallery: one-time split Gallery → gallery_prod + gallery_dev
-- =============================================================================
--
-- WHERE TO RUN: pgAdmin on your **dev PC** (192.168.10.70)
-- NOT on TrueNAS shell — psql is not required on TrueNAS.
--
-- BEFORE YOU START (dev PC, normal PowerShell in repo folder):
-- 1. Stop Gallery if running: close terminals with dev:web / dev:server / dev:client
-- 2. Stop the prod container on TrueNAS if it already exists (Apps → gallery-web → Stop)
--
-- pgAdmin SETUP:
-- 1. Open pgAdmin
-- 2. Register server (if needed):
-- Host: 192.168.10.122
-- Port: 5432
-- Maintenance database: postgres
-- Username: postgres ← must be superuser, NOT gallery
-- Password: (your postgres password)
-- 3. In the tree: Servers → (your server) → Databases → **postgres**
-- 4. Right-click **postgres** → Query Tool
-- 5. File → Open → select this file (db/split-dev-prod-pgadmin.sql)
-- 6. Run in order: highlight STEP 0 → Execute (F5), then STEP 1, STEP 2, …
--
-- IMPORTANT: Run each STEP block separately (highlight one STEP section, F5).
-- CREATE DATABASE (STEP 3) must NOT run inside a transaction with other steps.
--
-- AFTER SUCCESS:
-- On dev PC (normal PowerShell, repo root):
-- npm run migrate
-- npm run dev:web
-- Open http://devgallery.mysuperlab.netcraze.pro
--
-- =============================================================================
-- =============================================================================
-- STEP 0 — Pre-flight (read-only). Run this first.
-- Expected before split: one row with datname = Gallery
-- Expected after split: gallery_prod and gallery_dev
-- =============================================================================
SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database
WHERE datname IN ('Gallery', 'gallery', 'gallery_prod', 'gallery_dev')
ORDER BY datname;
-- =============================================================================
-- STEP 1 — Disconnect all clients from Gallery databases
-- Run on database: postgres
-- =============================================================================
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname IN ('Gallery', 'gallery', 'gallery_prod', 'gallery_dev')
AND pid <> pg_backend_pid();
-- =============================================================================
-- STEP 2 — Rename legacy database → gallery_prod
-- Run on database: postgres
-- Skip this step if STEP 0 already shows gallery_prod (split already done).
-- Run ONLY ONE of the two ALTER lines below (whichever matches STEP 0):
-- =============================================================================
-- Use this if STEP 0 showed datname = Gallery (capital G):
ALTER DATABASE "Gallery" RENAME TO gallery_prod;
-- OR use this if STEP 0 showed datname = gallery (all lowercase) — comment out the line above:
-- ALTER DATABASE gallery RENAME TO gallery_prod;
-- =============================================================================
-- STEP 3 — Clone gallery_dev from gallery_prod
-- Run on database: postgres
-- Skip if gallery_dev already exists in STEP 0.
-- Takes 12 minutes for ~1000 paintings. Do not run twice.
-- =============================================================================
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'gallery_prod'
AND pid <> pg_backend_pid();
CREATE DATABASE gallery_dev WITH TEMPLATE gallery_prod;
-- =============================================================================
-- STEP 4 — Grants for app user
-- Run on database: postgres
-- =============================================================================
GRANT ALL PRIVILEGES ON DATABASE gallery_dev TO gallery;
GRANT ALL PRIVILEGES ON DATABASE gallery_prod TO gallery;
-- =============================================================================
-- STEP 5 — Verify databases (read-only)
-- Run on database: postgres
-- Expected: gallery_prod and gallery_dev (Gallery gone)
-- =============================================================================
SELECT datname, pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database
WHERE datname IN ('Gallery', 'gallery', 'gallery_prod', 'gallery_dev')
ORDER BY datname;
-- =============================================================================
-- STEP 6 — Verify row count
--
-- *** IMPORTANT: Query Tool must be connected to gallery_dev, NOT postgres ***
--
-- In pgAdmin:
-- 1. Close this Query Tool tab (or ignore postgres connection)
-- 2. Tree: Servers → your server → Databases → **gallery_dev**
-- 3. Right-click **gallery_dev** → Query Tool ← new tab, new connection
-- 4. Paste and run ONLY the two lines below (F5)
--
-- If current_database() is not gallery_dev, you will get "relation paintings does not exist"
-- =============================================================================
SELECT current_database() AS connected_to; -- must show: gallery_dev
SELECT count(*) AS painting_count FROM paintings; -- expect ~1088
+27
View File
@@ -0,0 +1,27 @@
-- One-time dev/prod database split on TrueNAS PostgreSQL.
--
-- Preferred: pgAdmin on dev PC — open db/split-dev-prod-pgadmin.sql (step-by-step).
-- Alternative (if psql is installed): psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql
--
-- NOTE: CREATE DATABASE cannot run inside a DO block; this file uses standalone statements.
-- Disconnect clients
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname IN ('Gallery', 'gallery', 'gallery_prod', 'gallery_dev')
AND pid <> pg_backend_pid();
-- Rename legacy → gallery_prod (run ONE of these; comment out the other)
ALTER DATABASE "Gallery" RENAME TO gallery_prod;
-- ALTER DATABASE gallery RENAME TO gallery_prod;
-- Disconnect from prod before template clone
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'gallery_prod' AND pid <> pg_backend_pid();
-- Clone dev from prod (skip if gallery_dev already exists)
CREATE DATABASE gallery_dev WITH TEMPLATE gallery_prod;
GRANT ALL PRIVILEGES ON DATABASE gallery_dev TO gallery;
GRANT ALL PRIVILEGES ON DATABASE gallery_prod TO gallery;
+2 -1
View File
@@ -1,4 +1,5 @@
# systemd unit — adjust User, WorkingDirectory, and EnvironmentFile paths.
# LEGACY — superseded by TrueNAS Docker prod (https://gallery.mysuperlab.netcraze.pro:5173).
# systemd unit for running Node directly on a Linux host — not used in current homelab setup.
# Install:
# sudo cp deploy/gallery.service /etc/systemd/system/gallery.service
# sudo systemctl daemon-reload
+4 -1
View File
@@ -1,5 +1,8 @@
# LEGACY — optional nginx on dev PC. Production uses TrueNAS Docker on :5173
# (see Documentation/environments.md and infra/docker/DEPLOY-truenas.md).
#
# Reverse proxy for gallery.mysuperlab.netcraze.pro → Vite dev client on :5173.
# Vite proxies /api and /images to the Node API on :3520 (see client/vite.config.ts).
# Vite proxies /api and /images to the Node API on :3451 (dev:web) or :3520 (legacy local dev).
#
# Dev stack: npm run dev:server && npm run dev:client
# Production (built SPA on Node only): point location / at http://127.0.0.1:3520 instead.
+14
View File
@@ -0,0 +1,14 @@
# Copy to infra/docker/.env.prod on TrueNAS or build PC (do not commit).
# Used by compose.prod.yaml and npm run db:migrate:prod / db:restore:prod.
DB_HOST=192.168.10.122
DB_PORT=5432
DB_USER=gallery
DB_PASSWORD=YOUR_POSTGRES_PASSWORD
DB_NAME=gallery_prod
PORT=5173
HOST=0.0.0.0
PUBLIC_URL=https://gallery.mysuperlab.netcraze.pro
TRUST_PROXY=true
IMAGE_DIR=/app/data/images
+149
View File
@@ -0,0 +1,149 @@
# Deploy Gallery on TrueNAS Scale
Production deployment for the **Express API + built Vite SPA** container. PostgreSQL stays on the host at `192.168.10.122:5432`. Public URL: **https://gallery.mysuperlab.netcraze.pro** (Keenetic → TrueNAS `:5173`, protocol to device **http**).
See also [Documentation/environments.md](../../Documentation/environments.md).
## Architecture
```text
Browser (HTTPS)
→ Keenetic (KeenDNS, SSL termination)
→ gallery-web container on TrueNAS (:5173, HTTP)
→ PostgreSQL (192.168.10.122:5432) → gallery_prod
→ /mnt/BasePool/Applications/Gallery/data/images (volume)
→ SMB share Gallery → \\192.168.10.122\Gallery (image sync from dev PC)
```
## Prerequisites
| Item | Notes |
|------|-------|
| TrueNAS Scale 25.04+ | Apps → Custom App support |
| PostgreSQL | `gallery_prod` (dev: `gallery_dev` on same host) — split via [pgAdmin script](../../db/split-dev-prod-pgadmin.sql) |
| Gitea registry | `gitea.mysuperlab.netcraze.pro` — image **`danilka/gallery-web:latest`** pushed before deploy |
| SMB share | **`Gallery`** at `/mnt/BasePool/Applications/Gallery` for `images:sync-to-prod` |
| Keenetic | `gallery.mysuperlab.netcraze.pro``192.168.10.122:5173`, protocol **`http`**, Preserve Host ON |
If deploy fails with **`manifest unknown`**, the image is not in Gitea yet — complete [§1 Build and push](#1-build-and-push-image-dev-machine) first.
## 1. Build and push image (dev machine)
**Where:** Dev PC — **PowerShell as Administrator** (LAN push hosts entry), **Docker Desktop running**
```powershell
cd C:\Users\SNAP\Nextcloud\Personal\Repo\Gallery
docker login gitea.mysuperlab.netcraze.pro
npm run docker:publish
```
Push only (already built): `npm run docker:publish:push-only`
Image: `gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest`
### Gitea registry tokens
| Machine | Token scope |
|---------|-------------|
| Build PC | `write:package` |
| TrueNAS pull | `read:package` |
Register on TrueNAS: **Apps → Configuration → Sign in to a Docker registry** — URL `https://gitea.mysuperlab.netcraze.pro`, username `danilka` (lowercase for registry).
### Registry token URL fix
If `docker push` fails with internal HTTP token URL, set Gitea `ROOT_URL` to `https://gitea.mysuperlab.netcraze.pro/` and restart Gitea. Full walkthrough: [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../../Drunkmeyou/Documentation/gitea-https-keenetic-npm-setup.md).
### Offline fallback (no registry)
On **dev PC**: `.\infra\docker\save-for-truenas.ps1` → copy `gallery-web.tar` via SMB `Gallery` share.
On **TrueNAS shell**: `sudo bash truenas-load-image.sh /path/to/gallery-web.tar`
In Custom App YAML: `pull_policy: if_not_present`, then redeploy.
## 2. Prepare TrueNAS storage
**Where:** TrueNAS — **Shell**, as **root**
```bash
mkdir -p /mnt/BasePool/Applications/Gallery/data/images/portraits
mkdir -p /mnt/BasePool/Applications/Gallery/data/images/paintings/thumbs
chown -R 1001:1001 /mnt/BasePool/Applications/Gallery
chmod -R u+rwX,g+rwX /mnt/BasePool/Applications/Gallery
```
+46
View File
@@ -0,0 +1,46 @@
# Production image for Gallery (Express API + built Vite SPA).
# Build from repository root:
# docker build -f infra/docker/Dockerfile -t gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest .
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY client/package.json client/package-lock.json ./client/
RUN npm ci --omit=dev
WORKDIR /app/client
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY client/package.json client/package-lock.json ./client/
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/client/node_modules ./client/node_modules
COPY server ./server
COPY scripts ./scripts
COPY db ./db
COPY client ./client
RUN npm run build --prefix client
FROM node:20-alpine AS runner
ENV NODE_ENV=production
ENV PORT=5173
ENV HOST=0.0.0.0
ENV IMAGE_DIR=/app/data/images
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs gallery
WORKDIR /app
COPY package.json package-lock.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY server ./server
COPY scripts ./scripts
COPY db ./db
COPY --from=builder /app/client/dist ./client/dist
RUN mkdir -p /app/data/images && chown -R gallery:nodejs /app
USER gallery
EXPOSE 5173
CMD ["node", "server/index.js"]
+36
View File
@@ -0,0 +1,36 @@
# Build and push the production web image to Gitea over LAN (fast path).
param(
[string]$Tag = "latest",
[switch]$SkipHosts,
[switch]$SkipBuild
)
$ErrorActionPreference = "Stop"
$Registry = "gitea.mysuperlab.netcraze.pro"
$Image = "$Registry/danilka/gallery-web"
function Test-DockerRunning {
docker info 2>&1 | Out-Null
return $LASTEXITCODE -eq 0
}
Write-Host "=== Build + LAN push to Gitea ===" -ForegroundColor Cyan
if (-not (Test-DockerRunning)) {
Write-Host "Docker is not running. Start Docker Desktop and retry." -ForegroundColor Red
exit 1
}
if (-not $SkipBuild) {
Write-Host ""
Write-Host "Building ${Image}:${Tag} ..."
docker build -f infra/docker/Dockerfile -t "${Image}:${Tag}" .
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Build complete." -ForegroundColor Green
} else {
Write-Host "Skipping build (-SkipBuild)." -ForegroundColor Yellow
}
Write-Host ""
& "$PSScriptRoot/push-lan.ps1" -Tag $Tag -SkipHosts:$SkipHosts
exit $LASTEXITCODE
+19
View File
@@ -0,0 +1,19 @@
# Manual docker compose on a host with infra/docker/.env.prod present.
# Prefer compose.truenas.yaml for TrueNAS Custom App UI.
services:
web:
build:
context: ../..
dockerfile: infra/docker/Dockerfile
image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest
container_name: gallery-web
restart: unless-stopped
ports:
- "5173:5173"
env_file:
- .env.prod
volumes:
- /mnt/BasePool/Applications/Gallery/data/images:/app/data/images
extra_hosts:
- "host.docker.internal:host-gateway"
+34
View File
@@ -0,0 +1,34 @@
# Paste into TrueNAS: Apps → Custom App → Install via Docker Compose
#
# BEFORE deploy:
# 1. npm run docker:publish on dev PC (image must exist in Gitea)
# 2. Replace YOUR_POSTGRES_PASSWORD below
# 3. Gitea pull credentials on TrueNAS (read:package token)
# 4. mkdir + chown image dir; npm run images:sync-to-prod
# 5. Keenetic: gallery.mysuperlab.netcraze.pro → 192.168.10.122:5173, protocol http
#
# See infra/docker/DEPLOY-truenas.md and Documentation/environments.md
services:
web:
image: gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest
container_name: gallery-web
restart: unless-stopped
pull_policy: always
ports:
- "5173:5173"
environment:
NODE_ENV: production
PORT: "5173"
HOST: "0.0.0.0"
DB_HOST: "192.168.10.122"
DB_PORT: "5432"
DB_USER: gallery
DB_PASSWORD: gallery
DB_NAME: gallery_prod
PUBLIC_URL: https://gallery.mysuperlab.netcraze.pro
TRUST_PROXY: "true"
IMAGE_DIR: /app/data/images
volumes:
- /mnt/BasePool/Applications/Gallery/data/images:/app/data/images
extra_hosts:
- "host.docker.internal:host-gateway"
+64
View File
@@ -0,0 +1,64 @@
# Push Docker image to Gitea over LAN (avoids KeenDNS hairpin slow upload).
param(
[string]$Tag = "latest",
[switch]$SkipHosts
)
$ErrorActionPreference = "Stop"
$Registry = "gitea.mysuperlab.netcraze.pro"
$LanIp = "192.168.10.122"
$Image = "$Registry/danilka/gallery-web"
$HostsPath = "$env:SystemRoot\System32\drivers\etc\hosts"
$HostsMarker = "# gallery-gitea-lan-push"
function Test-Admin {
$current = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
return $current.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-GiteaDnsAddress {
try {
return [System.Net.Dns]::GetHostAddresses($Registry) |
Where-Object { $_.AddressFamily -eq 'InterNetwork' } |
Select-Object -First 1 -ExpandProperty IPAddressToString
} catch {
return $null
}
}
Write-Host "=== Gitea LAN push ===" -ForegroundColor Cyan
$resolved = Get-GiteaDnsAddress
Write-Host "DNS resolves $Registry -> $resolved"
if ($resolved -and $resolved -ne $LanIp) {
Write-Host "Public/hairpin path detected (slow push). LAN override recommended." -ForegroundColor Yellow
}
if (-not $SkipHosts) {
$hostsContent = Get-Content $HostsPath -Raw -ErrorAction SilentlyContinue
$hasEntry = $hostsContent -match "gitea\.mysuperlab\.netcraze\.pro"
if (-not $hasEntry) {
if (-not (Test-Admin)) {
Write-Host ""
Write-Host "Re-run as Administrator to add hosts entry, or add manually:" -ForegroundColor Yellow
Write-Host " $LanIp $Registry"
Write-Host " Then: ipconfig /flushdns"
exit 1
}
Add-Content -Path $HostsPath -Value "`n$HostsMarker`n$LanIp $Registry" -Encoding ASCII
ipconfig /flushdns | Out-Null
Write-Host "Added hosts: $LanIp -> $Registry" -ForegroundColor Green
} else {
Write-Host "Hosts entry for gitea already present." -ForegroundColor Green
}
}
$after = Get-GiteaDnsAddress
Write-Host "After override, resolves to: $after"
Write-Host ""
Write-Host "Pushing ${Image}:${Tag} ..."
docker push "${Image}:${Tag}"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host ""
Write-Host "Done. Remove hosts entry ($HostsMarker) when finished if you no longer need LAN override." -ForegroundColor Cyan
+17
View File
@@ -0,0 +1,17 @@
# Save image to tar for fast SMB copy to TrueNAS (offline registry bypass).
param(
[string]$Tag = "latest",
[string]$OutFile = "gallery-web.tar"
)
$ErrorActionPreference = "Stop"
$Image = "gitea.mysuperlab.netcraze.pro/danilka/gallery-web:${Tag}"
Write-Host "Saving $Image to $OutFile ..."
docker save -o $OutFile $Image
$sizeMb = [math]::Round((Get-Item $OutFile).Length / 1MB, 1)
Write-Host "Saved $OutFile ($sizeMb MB)"
Write-Host ""
Write-Host "On TrueNAS shell:"
Write-Host " sudo docker load -i /path/to/$OutFile"
Write-Host " sudo docker tag gitea.mysuperlab.netcraze.pro/danilka/gallery-web:$Tag gitea.mysuperlab.netcraze.pro/danilka/gallery-web:latest"
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Load a docker save tarball on TrueNAS and tag for the Gallery Custom App.
set -euo pipefail
TAR_PATH="${1:-}"
TAG="${2:-latest}"
IMAGE="gitea.mysuperlab.netcraze.pro/danilka/gallery-web"
if [[ -z "$TAR_PATH" || ! -f "$TAR_PATH" ]]; then
echo "Usage: sudo bash truenas-load-image.sh /path/to/gallery-web.tar [tag]"
exit 1
fi
docker load -i "$TAR_PATH"
docker tag "${IMAGE}:${TAG}" "${IMAGE}:latest"
docker images "${IMAGE}"
echo "Set Custom App pull_policy to IfNotPresent, then restart gallery-web."
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Run on TrueNAS shell once before first deploy.
set -euo pipefail
IMAGE_ROOT="/mnt/BasePool/Applications/Gallery/data/images"
mkdir -p "${IMAGE_ROOT}/portraits" "${IMAGE_ROOT}/paintings/thumbs"
chown -R 1001:1001 "/mnt/BasePool/Applications/Gallery"
chmod -R u+rwX,g+rwX "/mnt/BasePool/Applications/Gallery"
echo "Created ${IMAGE_ROOT} (owner uid 1001 = gallery user in container)"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Post-deploy checks — run on TrueNAS shell after Custom App is running.
set -euo pipefail
PUBLIC_URL="${PUBLIC_URL:-http://gallery.mysuperlab.netcraze.pro}"
LOCAL_URL="${LOCAL_URL:-http://127.0.0.1:5173}"
echo "== Local container =="
curl -sf -o /dev/null -w "HTTP %{http_code}\n" "${LOCAL_URL}/" || echo "FAIL: container not reachable on :5173"
echo "== API bounds =="
curl -sf "${LOCAL_URL}/api/bounds" || echo "FAIL: /api/bounds"
echo "== Public URL =="
curl -sf -o /dev/null -w "HTTP %{http_code}\n" "${PUBLIC_URL}/" || echo "FAIL: public URL not reachable"
echo "== Container status =="
docker ps --filter name=gallery-web --format '{{.Names}} {{.Status}}' || true
echo "== Image volume =="
ls -la /mnt/BasePool/Applications/Gallery/data/images/paintings 2>/dev/null | head -5 || echo "WARN: paintings dir missing"
echo "Done. Open ${PUBLIC_URL}/ in a browser."
+42
View File
@@ -0,0 +1,42 @@
# Sync production image files from TrueNAS to dev repo.
#
# Usage:
# npm run images:sync-from-prod
# .\infra\scripts\sync-images-from-prod.ps1
param(
[string]$Source = "\\192.168.10.122\Gallery\data\images",
[string]$Dest = (Join-Path $PSScriptRoot "..\..\data\images"),
[switch]$SkipConfirm
)
$ErrorActionPreference = "Stop"
if (-not (Test-Path $Dest)) {
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
}
$Dest = (Resolve-Path $Dest).Path
Write-Host "Source: $Source"
Write-Host "Dest: $Dest"
if (-not $SkipConfirm) {
$confirm = Read-Host "Copy all files from prod (skip older)? Type yes"
if ($confirm -ne "yes") {
Write-Host "Aborted."
exit 0
}
}
if (-not (Test-Path $Source)) {
Write-Error "Source not found: $Source"
exit 1
}
robocopy $Source $Dest /E /XO /R:2 /W:3 /NFL /NDL /NJH /NJS
$code = $LASTEXITCODE
if ($code -ge 8) {
Write-Error "robocopy failed with exit code $code"
exit $code
}
Write-Host "Image sync complete (robocopy exit $code)."
+49
View File
@@ -0,0 +1,49 @@
# Sync dev image files to TrueNAS production volume.
#
# Usage (from repo root):
# npm run images:sync-to-prod
# Copies data/images/ → \\192.168.10.122\Gallery\data\images
# Map the share first if needed: net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER
param(
[string]$Source = (Join-Path $PSScriptRoot "..\..\data\images"),
[string]$Dest = "\\192.168.10.122\Gallery\data\images",
[switch]$SkipConfirm
)
$ErrorActionPreference = "Stop"
$Source = (Resolve-Path $Source -ErrorAction Stop).Path
Write-Host "Source: $Source"
Write-Host "Dest: $Dest"
if (-not $SkipConfirm) {
$confirm = Read-Host "Copy all files (skip older)? Type yes"
if ($confirm -ne "yes") {
Write-Host "Aborted."
exit 0
}
}
$smbRoot = "\\192.168.10.122\Gallery"
if ($env:SMB_USER -and $env:SMB_PASSWORD) {
Write-Host "Mapping $sbmRoot ..."
net use $sbmRoot /user:$env:SMB_USER $env:SMB_PASSWORD 2>&1 | Out-Host
}
try {
if (-not (Test-Path $Dest)) {
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
}
} catch {
Write-Host "Note: could not pre-create dest (will rely on robocopy): $($_.Exception.Message)"
}
robocopy $Source $Dest /E /XO /R:2 /W:3 /NFL /NDL /NJH /NJS
$code = $LASTEXITCODE
if ($code -ge 8) {
Write-Error "robocopy failed with exit code $code"
exit $code
}
Write-Host "Image sync complete (robocopy exit $code)."
+11
View File
@@ -32,8 +32,19 @@
"start": "node server/index.js",
"start:prod": "npm run build && node server/index.js",
"server": "node server/index.js",
"dev:web": "node scripts/dev-web.js",
"dev:server": "nodemon server/index.js",
"dev:client": "npm run dev --prefix client",
"docker:publish": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/docker/build-push-lan.ps1",
"docker:publish:push-only": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/docker/build-push-lan.ps1 -SkipBuild",
"db:split-databases": "node scripts/split-dev-prod-databases.js",
"db:sync-from-prod": "node scripts/sync-prod-to-dev.js",
"db:backup": "node scripts/backup-db-data.js",
"db:backup:prod": "node scripts/backup-db-data.js --prod",
"db:restore": "node scripts/restore-db-data.js",
"db:restore:prod": "node scripts/restore-db-data.js --prod",
"images:sync-to-prod": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/sync-images-to-prod.ps1",
"images:sync-from-prod": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/sync-images-from-prod.ps1",
"dev": "node server/index.js",
"setup": "node server/migrate.js && node scripts/seed-wikipedia.js"
},
+135
View File
@@ -0,0 +1,135 @@
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const pg = require('pg');
const {
assertProdDatabase,
assertDevDatabase,
loadDevPgConfig,
loadProdPgConfig,
rootDir,
} = require('./db-env');
const { Client } = pg;
const backupDir = path.join(rootDir, 'db', 'DataBackup');
const fromProd = process.argv.includes('--prod');
const config = fromProd ? loadProdPgConfig() : loadDevPgConfig();
if (fromProd) {
assertProdDatabase(config.database);
} else {
assertDevDatabase(config.database);
if (config.database.endsWith('_prod')) {
console.error('Refusing backup of production database without --prod. Use: npm run db:backup:prod');
process.exit(1);
}
}
function sqlLiteral(value) {
if (value === null || value === undefined) return 'NULL';
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE';
if (value instanceof Date) return `'${value.toISOString()}'`;
if (typeof value === 'number' || typeof value === 'bigint') return String(value);
if (typeof value === 'object') {
return `'${JSON.stringify(value).replace(/'/g, "''")}'`;
}
return `'${String(value).replace(/'/g, "''")}'`;
}
function timestampSlug(date = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
'_',
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join('');
}
async function main() {
fs.mkdirSync(backupDir, { recursive: true });
const client = new Client(config);
await client.connect();
const dbNameRes = await client.query('SELECT current_database() AS name');
const dbName = dbNameRes.rows[0].name;
const tablesRes = await client.query(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name`,
);
const lines = [
'-- Gallery PostgreSQL data backup',
`-- Generated: ${new Date().toISOString()}`,
`-- Database: ${dbName}`,
'-- Format: INSERT statements (data only)',
'',
];
let totalRows = 0;
for (const { table_name: tableName } of tablesRes.rows) {
const colsRes = await client.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position`,
[tableName],
);
const columns = colsRes.rows.map((row) => row.column_name);
const dataRes = await client.query(`SELECT * FROM "${tableName}"`);
const rowCount = dataRes.rowCount ?? 0;
totalRows += rowCount;
lines.push(`-- TABLE ${tableName} (${rowCount} rows)`);
if (rowCount === 0) {
lines.push('');
continue;
}
const quotedCols = columns.map((c) => `"${c}"`).join(', ');
for (const row of dataRes.rows) {
const values = columns.map((col) => sqlLiteral(row[col])).join(', ');
lines.push(`INSERT INTO "${tableName}" (${quotedCols}) VALUES (${values});`);
}
lines.push('');
}
await client.end();
const slug = timestampSlug();
const txtName = `${dbName}_data_${slug}.txt`;
const zipName = `${dbName}_data_${slug}.zip`;
const txtPath = path.join(backupDir, txtName);
const zipPath = path.join(backupDir, zipName);
fs.writeFileSync(txtPath, lines.join('\n'), 'utf8');
execFileSync(
'powershell',
[
'-NoProfile',
'-Command',
`Compress-Archive -LiteralPath '${txtPath.replace(/'/g, "''")}' -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`,
],
{ stdio: 'inherit' },
);
console.log(`Backup written: ${txtPath}`);
console.log(`Archive written: ${zipPath}`);
console.log(`Tables: ${tablesRes.rowCount}, rows: ${totalRows}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+103
View File
@@ -0,0 +1,103 @@
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const rootDir = path.resolve(__dirname, '..');
const PROD_DB_SUFFIX = '_prod';
const DEV_DB_NAME = 'gallery_dev';
const PROD_DB_NAME = 'gallery_prod';
const LEGACY_DB_NAMES = ['Gallery', 'gallery'];
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
const content = fs.readFileSync(filePath, 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"'))
|| (value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
}
function pgConfigFromEnv() {
const host = process.env.DB_HOST || '192.168.10.122';
const port = Number(process.env.DB_PORT || 5432);
const user = process.env.DB_USER;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME;
if (!user || !password || !database) {
throw new Error('DB_USER, DB_PASSWORD, and DB_NAME are required');
}
return { host, port, user, password, database };
}
function assertProdDatabase(dbName) {
if (!dbName.endsWith(PROD_DB_SUFFIX) && dbName !== PROD_DB_NAME) {
throw new Error(
`Refusing prod operation on database "${dbName}". `
+ `Target must be "${PROD_DB_NAME}" or end with "${PROD_DB_SUFFIX}".`,
);
}
return dbName;
}
function assertDevDatabase(dbName) {
if (dbName === PROD_DB_NAME || dbName.endsWith(PROD_DB_SUFFIX)) {
throw new Error(
`Refusing dev operation on production database "${dbName}". `
+ `Use "${DEV_DB_NAME}" for development.`,
);
}
return dbName;
}
function loadDevPgConfig() {
loadEnvFile(path.join(rootDir, '.env'));
const config = pgConfigFromEnv();
assertDevDatabase(config.database);
return config;
}
function loadProdPgConfig() {
loadEnvFile(path.join(rootDir, 'infra', 'docker', '.env.prod'));
const config = pgConfigFromEnv();
assertProdDatabase(config.database);
return config;
}
async function confirmProdAction(message) {
if (process.env.CONFIRM_PROD === '1') return;
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((resolve) => {
rl.question(`${message}\nType "yes" to continue: `, resolve);
});
rl.close();
if (answer.trim().toLowerCase() !== 'yes') {
throw new Error('Aborted.');
}
}
module.exports = {
PROD_DB_SUFFIX,
DEV_DB_NAME,
PROD_DB_NAME,
LEGACY_DB_NAMES,
rootDir,
loadEnvFile,
pgConfigFromEnv,
assertProdDatabase,
assertDevDatabase,
loadDevPgConfig,
loadProdPgConfig,
confirmProdAction,
};
+43
View File
@@ -0,0 +1,43 @@
/**
* Start public dev stack: Vite on :5173, API on :3451 (or PORT from .env).
* Keenetic: devgallery.mysuperlab.netcraze.pro → 192.168.10.70:5173
*/
const { spawn } = require('child_process');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const apiPort = process.env.DEV_API_PORT || process.env.PORT || '3451';
const vitePort = process.env.DEV_WEB_PORT || '5173';
const env = { ...process.env, PORT: apiPort };
function run(label, command, args, cwd) {
const child = spawn(command, args, {
cwd,
env,
shell: true,
stdio: 'inherit',
});
child.on('exit', (code) => {
if (code && code !== 0) {
console.error(`${label} exited with code ${code}`);
}
});
return child;
}
console.log(`Gallery dev:web — UI http://0.0.0.0:${vitePort} API http://127.0.0.1:${apiPort}`);
console.log(`Set PUBLIC_URL=http://devgallery.mysuperlab.netcraze.pro in .env for Keenetic`);
const api = run('API', 'npx', ['nodemon', 'server/index.js'], path.join(__dirname, '..'));
const client = run('Vite', 'npm', ['run', 'dev', '--prefix', 'client', '--', '--port', vitePort, '--host'], path.join(__dirname, '..'));
function shutdown() {
api.kill('SIGTERM');
client.kill('SIGTERM');
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
+104
View File
@@ -0,0 +1,104 @@
/**
* Restore a data-only backup (INSERT dumps from npm run db:backup) into a database.
*
* Dev: node scripts/restore-db-data.js --file db/DataBackup/gallery_dev_data_....txt
* Prod: npm run db:restore:prod -- --file db/DataBackup/gallery_dev_data_....txt
*/
const fs = require('fs');
const path = require('path');
const pg = require('pg');
const {
assertProdDatabase,
assertDevDatabase,
confirmProdAction,
loadDevPgConfig,
loadProdPgConfig,
} = require('./db-env');
const { Client } = pg;
function parseArgs(argv) {
const fileIdx = argv.indexOf('--file');
if (fileIdx === -1 || !argv[fileIdx + 1]) {
throw new Error('--file <path> is required');
}
return {
filePath: path.resolve(argv[fileIdx + 1]),
prod: argv.includes('--prod'),
};
}
function extractInsertStatements(content) {
return content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.startsWith('INSERT INTO '));
}
async function truncatePublicTables(client) {
const tablesRes = await client.query(
`SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name`,
);
const names = tablesRes.rows.map((r) => `"${r.table_name}"`).join(', ');
if (!names) return;
await client.query(`TRUNCATE TABLE ${names} RESTART IDENTITY CASCADE`);
}
async function main() {
const { filePath, prod } = parseArgs(process.argv.slice(2));
if (!fs.existsSync(filePath)) {
throw new Error(`Backup file not found: ${filePath}`);
}
const config = prod ? loadProdPgConfig() : loadDevPgConfig();
const dbName = config.database;
const inserts = extractInsertStatements(fs.readFileSync(filePath, 'utf8'));
if (inserts.length === 0) {
throw new Error('No INSERT statements found in backup file');
}
const prompt = prod
? `RESTORE ${inserts.length} rows into PRODUCTION "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.`
: `Restore ${inserts.length} rows into "${dbName}" from:\n ${filePath}\nThis TRUNCATES all public tables first.`;
if (prod) {
assertProdDatabase(dbName);
} else {
assertDevDatabase(dbName);
}
await confirmProdAction(prompt);
const client = new Client(config);
await client.connect();
console.log(`Truncating public tables in "${dbName}"...`);
await truncatePublicTables(client);
console.log(`Restoring ${inserts.length} INSERT statements...`);
await client.query('SET session_replication_role = replica');
let restored = 0;
try {
for (const statement of inserts) {
await client.query(statement);
restored += 1;
if (restored % 500 === 0) {
console.log(` ${restored}/${inserts.length}`);
}
}
} finally {
await client.query('SET session_replication_role = DEFAULT');
}
await client.end();
console.log(`Restore complete: ${restored} statements into "${dbName}".`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+143
View File
@@ -0,0 +1,143 @@
/**
* One-time split of legacy Gallery → gallery_prod + gallery_dev on TrueNAS PostgreSQL.
*
* Usage:
* PGPASSWORD=... PGHOST=192.168.10.122 PGUSER=postgres npm run db:split-databases
*
* Options:
* --copy pg_dump legacy → gallery_prod instead of rename
* --skip-dev Do not create gallery_dev
*/
const { execFileSync } = require('child_process');
const pg = require('pg');
const {
DEV_DB_NAME,
LEGACY_DB_NAMES,
PROD_DB_NAME,
} = require('./db-env');
const { Client } = pg;
const args = new Set(process.argv.slice(2));
const useCopy = args.has('--copy');
const skipDev = args.has('--skip-dev');
const host = process.env.PGHOST ?? process.env.DB_HOST ?? '192.168.10.122';
const port = Number(process.env.PGPORT ?? process.env.DB_PORT ?? 5432);
const user = process.env.PGUSER ?? process.env.DB_USER ?? 'postgres';
const password = process.env.PGPASSWORD ?? process.env.DB_PASSWORD;
if (!password) {
console.error('PGPASSWORD (or DB_PASSWORD) is required');
process.exit(1);
}
function quoteIdent(ident) {
return `"${ident.replace(/"/g, '""')}"`;
}
async function databaseExists(client, name) {
const res = await client.query(
'SELECT 1 FROM pg_database WHERE datname = $1',
[name],
);
return res.rowCount > 0;
}
async function findLegacyName(client) {
for (const name of LEGACY_DB_NAMES) {
if (await databaseExists(client, name)) return name;
}
return null;
}
async function createDatabaseIfMissing(admin, name) {
if (await databaseExists(admin, name)) {
console.log(`Database "${name}" already exists`);
return false;
}
await admin.query(`CREATE DATABASE ${quoteIdent(name)}`);
console.log(`Created database "${name}"`);
return true;
}
async function terminateConnections(admin, dbName) {
await admin.query(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[dbName],
);
}
async function cloneDevFromProd(admin) {
if (await databaseExists(admin, DEV_DB_NAME)) {
console.log(`"${DEV_DB_NAME}" already exists — skipping dev clone`);
return;
}
await terminateConnections(admin, PROD_DB_NAME);
console.log(`Creating "${DEV_DB_NAME}" from TEMPLATE "${PROD_DB_NAME}"...`);
await admin.query(
`CREATE DATABASE ${quoteIdent(DEV_DB_NAME)} WITH TEMPLATE ${quoteIdent(PROD_DB_NAME)} OWNER ${quoteIdent(user)}`,
);
console.log(`Created "${DEV_DB_NAME}" as a full copy of "${PROD_DB_NAME}"`);
}
async function main() {
const admin = new Client({ host, port, user, password, database: 'postgres' });
await admin.connect();
const legacyName = await findLegacyName(admin);
const hasProd = await databaseExists(admin, PROD_DB_NAME);
if (hasProd) {
console.log(`"${PROD_DB_NAME}" already exists — skipping prod migration`);
} else if (legacyName) {
if (useCopy) {
await createDatabaseIfMissing(admin, PROD_DB_NAME);
console.log(`Copying ${legacyName}${PROD_DB_NAME} via pg_dump...`);
const dump = execFileSync(
'pg_dump',
['-h', host, '-p', String(port), '-U', user, '-d', legacyName, '--no-owner', '--no-acl'],
{ env: { ...process.env, PGPASSWORD: password }, encoding: 'buffer' },
);
execFileSync(
'psql',
['-h', host, '-p', String(port), '-U', user, '-d', PROD_DB_NAME, '-v', 'ON_ERROR_STOP=1'],
{ env: { ...process.env, PGPASSWORD: password }, input: dump, stdio: ['pipe', 'inherit', 'inherit'] },
);
console.log(`Copied ${legacyName}${PROD_DB_NAME}`);
} else {
await terminateConnections(admin, legacyName);
await admin.query(`ALTER DATABASE ${quoteIdent(legacyName)} RENAME TO ${quoteIdent(PROD_DB_NAME)}`);
console.log(`Renamed ${legacyName}${PROD_DB_NAME}`);
}
} else {
console.warn(`No legacy database (${LEGACY_DB_NAMES.join(' / ')}) or "${PROD_DB_NAME}" found`);
await createDatabaseIfMissing(admin, PROD_DB_NAME);
console.log('Run npm run migrate against gallery_prod to apply schema');
}
if (!skipDev) {
await cloneDevFromProd(admin);
console.log(`Next on dev PC: set .env DB_NAME=${DEV_DB_NAME}, then npm run migrate`);
}
await admin.end();
console.log('Done. TrueNAS compose.truenas.yaml should use DB_NAME=gallery_prod.');
}
main().catch((error) => {
if (error.code === '42501') {
console.error(
'Permission denied — the gallery user cannot rename databases.\n'
+ 'Use pgAdmin on the dev PC (recommended):\n'
+ ' Open db/split-dev-prod-pgadmin.sql → connect as postgres → run each STEP (F5)\n'
+ 'Or: PGUSER=postgres PGPASSWORD=... npm run db:split-databases\n'
+ 'Or: psql -h 192.168.10.122 -U postgres -d postgres -f db/split-dev-prod.sql',
);
}
console.error(error.message || error);
process.exit(1);
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Clone gallery_prod → gallery_dev (PostgreSQL TEMPLATE).
* Requires superuser or CREATEDB on the Postgres host.
*/
const path = require('path');
const { execFileSync } = require('child_process');
const pg = require('pg');
const {
DEV_DB_NAME,
PROD_DB_NAME,
loadProdPgConfig,
rootDir,
} = require('./db-env');
const { Client } = pg;
function quoteIdent(ident) {
return `"${ident.replace(/"/g, '""')}"`;
}
async function main() {
const prodConfig = loadProdPgConfig();
const { host, port, user, password } = prodConfig;
const admin = new Client({ host, port, user, password, database: 'postgres' });
await admin.connect();
console.log(`Terminating connections to "${DEV_DB_NAME}"...`);
await admin.query(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[DEV_DB_NAME],
);
const exists = await admin.query(
'SELECT 1 FROM pg_database WHERE datname = $1',
[DEV_DB_NAME],
);
if (exists.rowCount > 0) {
console.log(`Dropping "${DEV_DB_NAME}"...`);
await admin.query(`DROP DATABASE ${quoteIdent(DEV_DB_NAME)}`);
}
console.log(`Terminating connections to "${PROD_DB_NAME}"...`);
await admin.query(
`SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[PROD_DB_NAME],
);
console.log(`Cloning "${PROD_DB_NAME}" → "${DEV_DB_NAME}" (TEMPLATE)...`);
await admin.query(
`CREATE DATABASE ${quoteIdent(DEV_DB_NAME)} WITH TEMPLATE ${quoteIdent(PROD_DB_NAME)} OWNER ${quoteIdent(user)}`,
);
await admin.end();
const verify = new Client({ host, port, user, password, database: DEV_DB_NAME });
await verify.connect();
const tables = await verify.query(
`SELECT count(*)::int AS n FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`,
);
const paintings = await verify.query('SELECT count(*)::int AS n FROM paintings');
await verify.end();
console.log(`Done. "${DEV_DB_NAME}" is a copy of "${PROD_DB_NAME}" (${tables.rows[0].n} tables, ${paintings.rows[0].n} paintings).`);
console.log('Syncing image files from production...');
execFileSync('powershell', ['-NoProfile', '-File', path.join(rootDir, 'infra', 'scripts', 'sync-images-from-prod.ps1'), '-SkipConfirm'], {
cwd: rootDir,
stdio: 'inherit',
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});