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:
co-authored by
Cursor
parent
02d238b043
commit
2edf577faf
+10
-5
@@ -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,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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 ~1–2 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
@@ -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 machine’s 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` |
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 1–2 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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -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)"
|
||||
@@ -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."
|
||||
@@ -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)."
|
||||
@@ -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)."
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user