diff --git a/.gitignore b/.gitignore index 67ce7cb..9b1b118 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ client/node_modules/ .env.local .env.*.local infra/docker/.env.prod +infra/deploy/devtoprod.config.json # DB backups (may contain data) db/DataBackup/ diff --git a/Documentation/FAC.md b/Documentation/FAC.md index c9a9009..b5a74d6 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -217,7 +217,10 @@ npm run dev:discover-influences # discovery only, no curated insert | `npm run dev:fetch-images` | Search/download missing painting files (alias: `search-missing-paintings`) | | `npm run dev:fetch-artist-images` | Download or link artist portraits | | `npm run dev:sync-image-paths` | Align DB paths with files on disk; import new rows | -| `npm run dev:regenerate-thumbnails` | Rebuild all thumbs from full images | +| `npm run dev:regenerate-thumbnails` | Rebuild painting thumbs from full images | +| `npm run dev:regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) | +| `npm run devtoprod:thumbnails` | Both of the above — run on dev before promote backup/sync | +| `npm run devtoprod:release` | Config-driven full promote (see [deploy-dev-to-prod.md](deploy-dev-to-prod.md#one-command-release-automated)) | **Local paths:** `data/images/portraits/`, `data/images/paintings/`, `data/images/paintings/thumbs/` @@ -230,11 +233,13 @@ SMB share **`Gallery`** → `/mnt/BasePool/Applications/Gallery` on TrueNAS. ```powershell net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER +npm run devtoprod:release # full promote from infra/deploy/devtoprod.config.json +npm run devtoprod:thumbnails # rebuild thumb files + DB paths on dev (before backup) npm run devtoprod:images # dev repo → TrueNAS (promote / first deploy) npm run prodto:dev:images # TrueNAS → dev repo ``` -Type `yes` when prompted. Robocopy exit codes **0–7** = success. +Type `yes` when prompted (or set `autoConfirm: true` in release config). Robocopy exit codes **0–7** = success. Deploy scripts print a final **`===== SUCCESS =====`** or **`===== FAILED =====`** banner. --- @@ -291,13 +296,25 @@ Expect JSON with `min_year` / `max_year`. HTML shell only from `curl` on `/` is ~Weekly (or when explicitly releasing to prod). Not part of daily dev. Full runbook with per-change decision matrix and rollback: [deploy-dev-to-prod.md](deploy-dev-to-prod.md). +**One command (recommended):** copy `infra/deploy/devtoprod.config.example.json` → `infra/deploy/devtoprod.config.json`, edit SMB/git settings, then: + +```powershell +npm run devtoprod:release +# or: deploy-dev-to-prod.cmd +``` + +Dry-run: `npm run devtoprod:release -- -DryRun`. The script pauses for a manual **gallery-web** restart on TrueNAS before verify. + +**Manual steps** (partial releases): + 1. Test on https://devgallery.mysuperlab.netcraze.pro -2. `npm run dev:db:backup` -3. `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_dev_data_....txt` (if DB/catalog changed) -4. `npm run devtoprod:images` (if images changed) -5. `npm run prod:docker:publish` (if code changed) -6. Restart **gallery-web** on TrueNAS -7. Verify https://gallery.mysuperlab.netcraze.pro +2. `npm run devtoprod:thumbnails` (if paintings/portraits changed — rebuild thumb files + DB paths on dev) +3. `npm run dev:db:backup` +4. `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_dev_data_....txt` (if DB/catalog changed) +5. `npm run devtoprod:images` (if images changed) +6. `npm run prod:docker:publish` (if code changed) +7. Restart **gallery-web** on TrueNAS +8. Verify https://gallery.mysuperlab.netcraze.pro --- diff --git a/Documentation/basics.md b/Documentation/basics.md index 677b672..2dd21e4 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -399,13 +399,13 @@ When debug mode is on, a panel at the bottom-left shows the image search query, | Button | Painting detail | Artist bio | |--------|-----------------|------------| | **Checked** | Sets `checkup_checked` via `PATCH …/checkup-flags` | Same for artist portrait flags | -| **Fix it** | Replaces local image from top search result | Replaces portrait | -| **More** | Modal with up to **20** results (resolution shown when known) | Same | +| **Fix it** | Replaces full image from search result; **regenerates painting thumb** (~400px JPEG) from that file | Replaces portrait; **regenerates timeline thumb** (256px) | +| **More** | Modal with up to **20** results (resolution shown when known); thumb regenerated from chosen full image | Same | | **Clear** | Deletes files, clears DB paths, empty frame | Clears portrait slot | -| **Upload** | Local file picker → disk + thumbnail | Local file → portrait | +| **Upload** | Local file picker → full image + **auto-generated painting thumb** | Local file → portrait + **auto-generated portrait thumb** | | **Remove entry** | **Painting detail only** — deletes row from DB, removes image files, refreshes 3D gallery, navigates to next/previous work in catalog (or back to gallery if last work). No confirmation dialog. | — | -After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately. +After **Fix it**, **More**, **Upload**, or **Clear**, the main view, gallery textures (paintings), and timeline portrait (artists) update without a full page reload. Painting and portrait thumbs under `data/images/*/thumbs/` are rebuilt on the server whenever a curator replaces the full image. **Remove entry** refetches artist (and movement gallery when relevant) from the API and remounts the 3D hall so the deleted frame disappears immediately. Reviewed portraits show a gold border on the bio page; reviewed paintings use gold frames in the 3D hall. **Back to Gallery** returns to the live hall session, not a stale snapshot. @@ -428,7 +428,7 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md# | Document | Contents | |----------|----------| | [setup.md](setup.md) | Install, database, npm scripts | -| [deploy-dev-to-prod.md](deploy-dev-to-prod.md) | Release runbook: promote dev → prod | +| [deploy-dev-to-prod.md](deploy-dev-to-prod.md) | Release runbook + one-command `devtoprod:release` | | [DB_structure.md](DB_structure.md) | Tables and relationships | | [API.md](API.md) | REST endpoints | | [data-and-images.md](data-and-images.md) | Image pipeline and seeding | diff --git a/Documentation/data-and-images.md b/Documentation/data-and-images.md index dca2079..b899d38 100644 --- a/Documentation/data-and-images.md +++ b/Documentation/data-and-images.md @@ -479,9 +479,9 @@ When **Debug mode** is on (home header) or from the **Checkup** page: 1. **Search** — `GET /api/paintings/:id/debug-image-search` (or `…/debug-portrait-search` for artists) tries Google Custom Search (if `GOOGLE_CSE_API_KEY` + `GOOGLE_CSE_CX` are set in `.env`), Google Arts & Culture, Google Images scrape, then DuckDuckGo (`searchGoogleImagesFirst` / `searchArtistPortraitFirst` in `scripts/image-fetcher.js`). 2. **More** — `GET …/debug-image-search/more` or `…/debug-portrait-search/more` returns up to 20 ranked candidates (`searchPaintingImagesMany` / `searchArtistPortraitMany`). The modal shows each thumbnail with **resolution** when the search API provides dimensions; otherwise the client probes via `GET /api/debug/image-proxy`. -3. **Fix** — `POST …/fix-image` or `…/fix-portrait` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` / `replaceArtistPortraitFromUrl` in `server/image-service.js`, regenerates thumbnails with `sharp`, and sets `checkup_fixed` + `checkup_checked`. +3. **Fix** — `POST …/fix-image` or `…/fix-portrait` downloads the chosen URL via `downloadImageForFix` → `replacePaintingImageFromUrl` / `replaceArtistPortraitFromUrl` in `server/image-service.js`. The server **always regenerates thumbnails from the saved full image** (`writePaintingThumb` / `writePortraitThumb` via `sharp` — not the search-result thumb URL), updates `thumbnail_path` / `portrait_thumb_path`, and sets `checkup_fixed` + `checkup_checked`. 4. **Clear** — `POST …/clear-image` or `…/clear-portrait` deletes local file(s), nulls DB paths, sets both flags. Cleared slots stay empty in the UI (no placeholder; `checkup_fixed` prevents on-demand refetch for paintings). -5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`. +5. **Upload** — `POST …/upload-image` or `…/upload-portrait` accepts a base64-encoded file in JSON (Express body limit **20 MB**; decoded image max **15 MB**), validates with `sharp`, writes to the standard filename under `data/images/`, and regenerates the matching thumbnail the same way as **Fix it**. 6. **Remove entry** (painting detail only) — `DELETE /api/paintings/:id` via `deletePainting()` in `server/image-service.js`: deletes image files, removes the DB row (cascade on influence/annotation tables), refetches artist/movement gallery data, remounts the 3D hall, and navigates to the next or previous catalog work with no confirmation dialog. ### Debug panel (painting detail and artist bio) @@ -491,10 +491,10 @@ With debug mode on, `PaintingDetail.tsx` and `ArtistBio.tsx` show a bottom-left | Button | API (paintings / portraits) | Effect | |--------|----------------------------|--------| | **Checked** | `PATCH …/checkup-flags` `{ "checked": true }` | Marks reviewed; gold frame (paintings) or gold portrait border (artists) | -| **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, sets both flags, refreshes detail + gallery / timeline | -| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace | +| **Fix it** | `POST …/fix-image` / `…/fix-portrait` | Saves top search result to disk, **regenerates thumb from full image**, sets both flags, refreshes detail + gallery / timeline | +| **More** | `GET …/debug-*-search/more` then fix endpoint | Modal with 20 clickable results (resolution label under each thumb); pick one to replace (thumb regenerated from downloaded full) | | **Clear** | `POST …/clear-image` / `…/clear-portrait` | Removes file(s), empty frame in UI | -| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save like **Fix it** | +| **Upload** | `POST …/upload-image` / `…/upload-portrait` | Local file picker → save full image + **auto-generated thumb** | | **Remove entry** | `DELETE /api/paintings/:id` | **Paintings only** — permanent delete + gallery refresh + catalog navigation | The client passes `searchUrl`, `source`, and `thumbUrl` from search results to improve download reliability. After a fix, clear, upload, or remove, `HomePage` updates the gallery session and appends a revision query on texture URLs so replaced files reload even when the path is unchanged. diff --git a/Documentation/deploy-dev-to-prod.md b/Documentation/deploy-dev-to-prod.md index 3419b18..739f258 100644 --- a/Documentation/deploy-dev-to-prod.md +++ b/Documentation/deploy-dev-to-prod.md @@ -1,6 +1,6 @@ # Deploy dev → prod (release runbook) -Step-by-step guide for promoting the **development** version of Gallery to **production**. Covers code, database schema, database data, and image files. +Step-by-step guide for promoting the **development** version of Gallery to **production**. Covers code, database schema, database data, thumbnail generation, and image files. > **Default is dev.** This runbook is for a **scheduled release** (~weekly, or when you explicitly decide to ship). Day-to-day work stays on dev — see [environments.md](environments.md#development-first-workflow-default). If the one-time prod install is not done yet, follow [environments.md → One-time setup](environments.md#one-time-setup-full-walkthrough) and [infra/docker/DEPLOY-truenas.md](../infra/docker/DEPLOY-truenas.md) first. @@ -16,17 +16,84 @@ All commands run on the **dev PC** from the repo root (`C:\Users\SNAP\Nextcloud\ --- +## One-command release (automated) + +For a typical weekly release, use the orchestrator instead of running each step manually. It reads settings from a local config file, runs the enabled steps in order, prints a **SUCCESS/FAILED** banner at the end of each sub-command, and stops on the first failure. + +### Setup (once) + +1. Copy the example config: + + ```powershell + Copy-Item infra/deploy/devtoprod.config.example.json infra/deploy/devtoprod.config.json + ``` + +2. Edit [`infra/deploy/devtoprod.config.json`](../infra/deploy/devtoprod.config.json) (gitignored — safe for SMB password): + - `profile`: `full` | `code` | `data` (base step set; individual `steps` overrides win) + - `autoConfirm`: `true` skips restore/image-sync prompts (`CONFIRM_PROD=1`, `-SkipConfirm`) + - `git.message`, `git.branch` — used when `gitCommitPush` is enabled + - `schemaChanged`: `true` enables prod schema migration on `full` profile + - `smb.user` / `smb.password` — optional; maps `\\host\share` before image sync + - `backupFile` — optional fixed path; otherwise uses the newest `gallery_dev_data_*.txt` after backup + +3. Prerequisites still apply: Docker Desktop running, `docker login gitea.mysuperlab.netcraze.pro`, [`infra/docker/.env.prod`](../infra/docker/.env.prod) present. + +### Run + +```powershell +npm run devtoprod:release +``` + +Or double-click [`deploy-dev-to-prod.cmd`](../deploy-dev-to-prod.cmd) in the repo root. + +Dry-run (print steps only, no changes): + +```powershell +npm run devtoprod:release -- -DryRun +``` + +### What the orchestrator does + +| Config step | Maps to runbook | +|-------------|-----------------| +| `validateBuild` | Step 0 — `prod:build` + dev `/api/bounds` check | +| `gitCommitPush` | Step 1 — `git add`, commit, push | +| `thumbnails` | Step 2 — `devtoprod:thumbnails` | +| `backupDev` | Step 3 — `dev:db:backup` | +| `backupProd` | Step 3 (rollback) — `prod:db:backup` | +| `migrateProdSchema` | Step 4 — `dev:migrate` on `gallery_prod` | +| `restoreProd` | Step 5 — `devtoprod:db:restore` | +| `syncImages` | Step 6 — `devtoprod:images` | +| `dockerPublish` | Step 7 — `prod:docker:publish` | +| `truenasRestartPause` | Step 8 — **manual** pause; script waits for Enter after you restart **gallery-web** | +| `verify` | Step 9 — curl LAN + public `/api/bounds` | + +Final output is always a release banner: + +``` +================================================================ + RELEASE SUCCEEDED - production promoted + Steps: validateBuild, gitCommitPush, ... + Next: open https://gallery.mysuperlab.netcraze.pro/ +================================================================ +``` + +Use the manual steps below when you need a **partial** release or want to inspect each step individually. + +--- + ## What changed → which steps to run Run only the steps that match what you changed. Steps are independent except that **code** needs a rebuilt image and **schema changes** must be applied before a **data** restore. | You changed… | Required steps | |--------------|----------------| -| Application code (client/server) | 0 → 1 → 6 → 7 → 8 | -| DB schema (new `db/migrate-*.sql`, `schema.sql`) | 0 → 1 → 2 → **3** → (4 if data too) → 7 → 8 | -| Catalog data (movements, artists, paintings, influences, bios) | 0 → 2 → 4 → 8 | -| Image files (new/replaced paintings, portraits, thumbs) | 0 → 5 → 8 | -| Everything (typical weekly release) | 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 | +| Application code (client/server) | 0 → 1 → 7 → 8 → 9 | +| DB schema (new `db/migrate-*.sql`, `schema.sql`) | 0 → 1 → 3 → **4** → (5 if data too) → 8 → 9 | +| Catalog data (movements, artists, paintings, influences, bios) | 0 → 2 → 3 → 5 → 9 | +| Image files (new/replaced paintings, portraits) | 0 → 2 → 3 → 5 → 6 → 9 | +| Thumbnails only (rebuild from existing full images) | 0 → 2 → 3 → 5 → 6 → 9 | +| Everything (typical weekly release) | 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 | --- @@ -79,7 +146,39 @@ git push origin main --- -## Step 2 — Back up the dev database +## Step 2 — Regenerate thumbnails on dev (before backup / image sync) + +**Where:** Dev PC + +Rebuilds thumbnail **files** under `data/images/` and updates **`thumbnail_path`** / **`portrait_thumb_path`** in **`gallery_dev`**. Run this on dev **before** backing up the database and syncing images so prod receives both the files and the correct DB paths. + +```powershell +npm run devtoprod:thumbnails +``` + +This runs, in order: + +| Command | Output | DB column | +|---------|--------|-----------| +| `dev:regenerate-thumbnails` | `data/images/paintings/thumbs/*_thumb.jpg` | `paintings.thumbnail_path` | +| `dev:regenerate-portrait-thumbs` | `data/images/portraits/thumbs/*_thumb.jpg` | `artists.portrait_thumb_path` | + +Skip when no painting or portrait images changed since the last release and thumbs are already up to date. + +**Curator debug fixes on dev** already regenerate thumbnails per work — no extra step for individual edits: + +| Debug action | Thumbnail regenerated | +|--------------|----------------------| +| **Fix it** / **More** / **Upload** on painting detail or Checkup | `paintings/thumbs/{Artist}_{Title}_thumb.jpg` (~400px) + `thumbnail_path` in DB | +| **Fix it** / **More** / **Upload** on artist bio | `portraits/thumbs/{Artist}_thumb.jpg` (256px) + `portrait_thumb_path` in DB | + +Use `devtoprod:thumbnails` for **bulk** backfill (missing thumbs, imports, or releases where images changed outside the debug panel). + +> **Order matters:** thumbnail generation updates dev DB paths. If you regenerate **after** Step 3 (backup), run a **fresh backup** (Step 3 again) before Step 5 (restore), or prod will point at thumb paths that were not in the backup. + +--- + +## Step 3 — Back up the dev database **Where:** Dev PC @@ -89,7 +188,7 @@ Creates a data-only dump (INSERT statements) under `db/DataBackup/`. npm run dev:db:backup ``` -Output (note the exact path — you pass it to Step 4): +Output (note the exact path — you pass it to Step 5): ``` Backup written: db/DataBackup/gallery_dev_data_YYYYMMDD_HHMMSS.txt @@ -100,11 +199,11 @@ Archive written: db/DataBackup/gallery_dev_data_YYYYMMDD_HHMMSS.zip --- -## Step 3 — Apply schema changes to prod (only if schema changed) +## Step 4 — Apply schema changes to prod (only if schema changed) **Where:** Dev PC -The data restore in Step 4 only inserts rows — it does **not** create tables or add columns. If this release added a migration (`db/migrate-*.sql`) or changed `db/schema.sql`, the prod schema must be updated **first** so the new columns/tables exist. +The data restore in Step 5 only inserts rows — it does **not** create tables or add columns. If this release added a migration (`db/migrate-*.sql`) or changed `db/schema.sql`, the prod schema must be updated **first** so the new columns/tables exist. `server/migrate.js` is idempotent (schema and migrations use `IF NOT EXISTS` / additive `ALTER`s). Point it at prod for one run by overriding `DB_NAME` (all other credentials come from `.env`; the `gallery` user has rights to both databases): @@ -120,11 +219,11 @@ Expect `schema.sql` + each `migrate-*.sql` to log `OK`. Re-running is safe. --- -## Step 4 — Restore dev data into prod (only if data/DB changed) +## Step 5 — Restore dev data into prod (only if data/DB changed) **Where:** Dev PC -Loads the Step 2 backup into `gallery_prod`. This **TRUNCATES all public tables** in prod first, then inserts dev's rows — prod becomes an exact copy of dev's data. +Loads the Step 3 backup into `gallery_prod`. This **TRUNCATES all public tables** in prod first, then inserts dev's rows — prod becomes an exact copy of dev's data. ```powershell npm run devtoprod:db:restore -- --file db/DataBackup/gallery_dev_data_YYYYMMDD_HHMMSS.txt @@ -149,11 +248,11 @@ Multi-line values (e.g. artist bios with embedded newlines) are parsed as whole --- -## Step 5 — Sync images to prod (only if image files changed) +## Step 6 — Sync images to prod (only if image files changed) **Where:** Dev PC (SMB share mapped — see Prerequisites) -Copies `data/images/` (paintings, portraits, and `thumbs/`) from the repo to the TrueNAS volume. +Copies `data/images/` (paintings, portraits, `paintings/thumbs/`, and `portraits/thumbs/`) from the repo to the TrueNAS volume. **Map the share in this Windows session first** (a fresh terminal has no mapping — robocopy will fail with `ERROR 5 Access is denied` otherwise): @@ -172,11 +271,9 @@ Type `yes` when prompted. Robocopy exit codes **0–7** = success. Destination: Alternatively, let the script map the share by exporting credentials first: `$env:SMB_USER="…"; $env:SMB_PASSWORD="…"` before running. -> Thumbnails are files, generated on dev (`npm run dev:regenerate-thumbnails` / `dev:regenerate-portrait-thumbs`) and shipped here — prod does **not** regenerate them. Regenerate on dev **before** this step if needed. - --- -## Step 6 — Build and push the Docker image (only if code changed) +## Step 7 — Build and push the Docker image (only if code changed) **Where:** Dev PC — **PowerShell as Administrator**, Docker Desktop running @@ -193,7 +290,7 @@ npm run prod:docker:publish --- -## Step 7 — Restart gallery-web on TrueNAS (after code or schema changes) +## Step 8 — Restart gallery-web on TrueNAS (after code or schema changes) **Where:** TrueNAS — Web UI @@ -204,7 +301,7 @@ Data-only or image-only releases (no new image) do not require a restart, but a --- -## Step 8 — Verify production +## Step 9 — Verify production **Where:** Dev PC / browser @@ -229,11 +326,11 @@ Optional on TrueNAS shell: `bash infra/docker/truenas-verify.sh`. | Problem | Action | |---------|--------| -| Bad **data** release | Re-run Step 4 with the pre-release prod backup: `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_prod_data_.txt` | +| Bad **data** release | Re-run Step 5 with the pre-release prod backup: `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_prod_data_.txt` | | Bad **code** release | Rebuild from the last good commit: `git checkout `, `npm run prod:docker:publish`, restart `gallery-web` | | Bad **images** | Re-sync from a known-good dev copy, or pull prod back to dev with `npm run prodto:dev:images` to compare | -Keep at least the most recent `gallery_prod_data_*.txt` from Step 2 so a data rollback is always possible. +Keep at least the most recent `gallery_prod_data_*.txt` from Step 3 so a data rollback is always possible. --- @@ -241,35 +338,39 @@ Keep at least the most recent `gallery_prod_data_*.txt` from Step 2 so a data ro | Step | Command | Runs on | |------|---------|---------| +| **All (config-driven)** | `npm run devtoprod:release` or `deploy-dev-to-prod.cmd` | Dev PC | | 0 Validate | `npm run prod:build` | Dev PC | | 1 Ship code | `git add . ; git commit -m "…" ; git push origin main` | Dev PC | -| 2 Backup dev | `npm run dev:db:backup` | Dev PC | -| 2 Backup prod (rollback point) | `npm run prod:db:backup` | Dev PC | -| 3 Migrate prod schema | `$env:DB_NAME="gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME` | Dev PC | -| 4 Restore data → prod | `npm run devtoprod:db:restore -- --file .txt` | Dev PC | -| 5 Sync images → prod | `npm run devtoprod:images` | Dev PC (SMB) | -| 6 Build + push image | `npm run prod:docker:publish` | Dev PC (Admin) | -| 7 Restart app | Apps → gallery-web → Restart | TrueNAS UI | -| 8 Verify | `curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds` | Dev PC | +| 2 Thumbnails | `npm run devtoprod:thumbnails` | Dev PC | +| 3 Backup dev | `npm run dev:db:backup` | Dev PC | +| 3 Backup prod (rollback point) | `npm run prod:db:backup` | Dev PC | +| 4 Migrate prod schema | `$env:DB_NAME="gallery_prod"; npm run dev:migrate; Remove-Item Env:\DB_NAME` | Dev PC | +| 5 Restore data → prod | `npm run devtoprod:db:restore -- --file .txt` | Dev PC | +| 6 Sync images → prod | `npm run devtoprod:images` | Dev PC (SMB) | +| 7 Build + push image | `npm run prod:docker:publish` | Dev PC (Admin) | +| 8 Restart app | Apps → gallery-web → Restart | TrueNAS UI | +| 9 Verify | `curl.exe -sk https://gallery.mysuperlab.netcraze.pro/api/bounds` | Dev PC | --- ## Safety guards (built in) - Prod DB scripts read **only** `infra/docker/.env.prod`; dev scripts refuse `_prod` database names. -- Prod restore/backup require typing `yes` (or `CONFIRM_PROD=1`). -- `npm run dev:migrate` targets whatever `DB_NAME` is set — always `Remove-Item Env:\DB_NAME` after Step 3 so later commands stay on dev. +- Prod restore/backup require typing `yes` (or `CONFIRM_PROD=1` / `autoConfirm` in release config). +- `npm run dev:migrate` targets whatever `DB_NAME` is set — always `Remove-Item Env:\DB_NAME` after Step 4 so later commands stay on dev. +- Deploy-related npm scripts and PowerShell helpers print a final **`===== SUCCESS: … =====`** or **`===== FAILED: … =====`** banner as the last line of output. ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| -| `/api/bounds` returns `{"min_year":null,"max_year":null}` on prod | `gallery_prod` has schema but no data | Run Step 4 (restore); confirm rows with a count query on `art_movements` / `paintings` | -| `robocopy ... ERROR 5 (0x00000005) Access is denied` on Step 5 | SMB share not mapped/authenticated in this Windows session | `net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER`, verify `Test-Path`, then re-run. If still denied after auth, fix TrueNAS dataset ACL (`chown -R 1001:1001`, grant the SMB user write) | +| `/api/bounds` returns `{"min_year":null,"max_year":null}` on prod | `gallery_prod` has schema but no data | Run Step 5 (restore); confirm rows with a count query on `art_movements` / `paintings` | +| `robocopy ... ERROR 5 (0x00000005) Access is denied` on Step 6 | SMB share not mapped/authenticated in this Windows session | `net use \\192.168.10.122\Gallery /user:YOUR_TRUENAS_USER`, verify `Test-Path`, then re-run. If still denied after auth, fix TrueNAS dataset ACL (`chown -R 1001:1001`, grant the SMB user write) | | Restore: `permission denied to set parameter "session_replication_role"` | DB role is not superuser | Handled automatically (multi-pass insert). For the fast path, superuser runs `GRANT SET ON PARAMETER session_replication_role TO gallery;` | | Restore: `unterminated quoted string` | Old parser split multi-line values (bios) | Fixed — statements are accumulated until quotes balance; update to latest `scripts/restore-db-data.js` | | `git push` → `Failed to authenticate user` (Gitea) | Git Credential Manager cached an expired token | Clear it: `"protocol=https`nhost=gitea.mysuperlab.netcraze.pro`n" \| git credential reject`, then push again to re-prompt | -| Prod tables empty after a failed restore | Restore truncates **before** inserting; a mid-run error leaves tables empty | Dev is untouched — just re-run Step 4 | +| Prod tables empty after a failed restore | Restore truncates **before** inserting; a mid-run error leaves tables empty | Dev is untouched — just re-run Step 5 | +| Timeline portraits slow or missing on prod | Thumbs not regenerated or not synced | Run Step 2 on dev, then Step 3 (fresh backup), Step 5, and Step 6 | | `502 / 504` on the public prod URL | Keenetic upstream wrong (IP/port/protocol) | `192.168.10.122:5173`, protocol **http**; verify LAN `curl.exe -s http://192.168.10.122:5173/api/bounds` first | ## Related docs diff --git a/Documentation/environments.md b/Documentation/environments.md index b83a5c2..dd1247f 100644 --- a/Documentation/environments.md +++ b/Documentation/environments.md @@ -305,17 +305,22 @@ See also [Drunkmeyou gitea-https-keenetic-npm-setup.md](../../Drunkmeyou/Documen ## Promote dev → prod (scheduled release) -Run this when you are ready to ship dev to production — **not** after every small change. Typical cadence: **about once a week**. Detailed step-by-step runbook (per-change decision matrix, schema migration, rollback): [deploy-dev-to-prod.md](deploy-dev-to-prod.md). +Run this when you are ready to ship dev to production — **not** after every small change. Typical cadence: **about once a week**. + +**One command (recommended):** copy [`infra/deploy/devtoprod.config.example.json`](../infra/deploy/devtoprod.config.example.json) to `infra/deploy/devtoprod.config.json`, edit it, then `npm run devtoprod:release` or `deploy-dev-to-prod.cmd`. See [deploy-dev-to-prod.md → One-command release](deploy-dev-to-prod.md#one-command-release-automated). + +**Manual steps:** detailed runbook (per-change decision matrix, schema migration, rollback): [deploy-dev-to-prod.md](deploy-dev-to-prod.md). **Where:** Dev PC unless noted 1. Finish and test on https://devgallery.mysuperlab.netcraze.pro -2. `npm run dev:db:backup` -3. `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_dev_data_....txt` (type `yes`) — skip if only code changed and prod DB should stay as-is -4. `npm run devtoprod:images` — skip if no new/changed images -5. `npm run prod:docker:publish` — required when application code changed -6. **TrueNAS Web UI** → restart **gallery-web** -7. Verify https://gallery.mysuperlab.netcraze.pro +2. `npm run devtoprod:thumbnails` — skip if no painting/portrait images changed +3. `npm run dev:db:backup` +4. `npm run devtoprod:db:restore -- --file db/DataBackup/gallery_dev_data_....txt` (type `yes`) — skip if only code changed and prod DB should stay as-is +5. `npm run devtoprod:images` — skip if no new/changed images +6. `npm run prod:docker:publish` — required when application code changed +7. **TrueNAS Web UI** → restart **gallery-web** +8. Verify https://gallery.mysuperlab.netcraze.pro --- @@ -333,6 +338,8 @@ Run this when you are ready to ship dev to production — **not** after every sm | Command | Where | Direction | |---------|-------|-----------| +| `npm run devtoprod:release` | Dev PC PowerShell | Full config-driven promote (see [deploy-dev-to-prod.md](deploy-dev-to-prod.md#one-command-release-automated)) | +| `npm run devtoprod:thumbnails` | Dev PC PowerShell | Rebuild painting + portrait thumbs on dev before promote | | `npm run devtoprod:images` | Dev PC PowerShell | Dev → TrueNAS volume | | `npm run prodto:dev:images` | Dev PC PowerShell | TrueNAS → dev repo | diff --git a/deploy-dev-to-prod.cmd b/deploy-dev-to-prod.cmd new file mode 100644 index 0000000..26a6d3f --- /dev/null +++ b/deploy-dev-to-prod.cmd @@ -0,0 +1,3 @@ +@echo off +cd /d "%~dp0" +npm run devtoprod:release %* diff --git a/infra/deploy/devtoprod.config.example.json b/infra/deploy/devtoprod.config.example.json new file mode 100644 index 0000000..2232209 --- /dev/null +++ b/infra/deploy/devtoprod.config.example.json @@ -0,0 +1,36 @@ +{ + "_comment": "Copy to devtoprod.config.json (gitignored) and edit before running npm run devtoprod:release", + "profile": "full", + "autoConfirm": true, + "steps": { + "validateBuild": true, + "gitCommitPush": true, + "thumbnails": true, + "backupDev": true, + "backupProd": true, + "migrateProdSchema": false, + "restoreProd": true, + "syncImages": true, + "dockerPublish": true, + "truenasRestartPause": true, + "verify": true + }, + "git": { + "branch": "main", + "message": "Release: weekly deploy", + "stageAll": true + }, + "schemaChanged": false, + "backupFile": "", + "smb": { + "host": "192.168.10.122", + "share": "Gallery", + "user": "", + "password": "" + }, + "verify": { + "devUrl": "https://devgallery.mysuperlab.netcraze.pro/api/bounds", + "lanUrl": "http://192.168.10.122:5173/api/bounds", + "publicUrl": "https://gallery.mysuperlab.netcraze.pro/api/bounds" + } +} diff --git a/infra/docker/build-push-lan.ps1 b/infra/docker/build-push-lan.ps1 index a7b6f68..d45ed4e 100644 --- a/infra/docker/build-push-lan.ps1 +++ b/infra/docker/build-push-lan.ps1 @@ -6,6 +6,8 @@ param( ) $ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "..\scripts\lib\Deploy-CliResult.ps1") + $Registry = "gitea.mysuperlab.netcraze.pro" $Image = "$Registry/danilka/gallery-web" @@ -17,7 +19,7 @@ function Test-DockerRunning { 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 + Write-DeployCliResult -Script 'build-push-lan' -Success $false -Summary 'Docker is not running. Start Docker Desktop and retry.' exit 1 } @@ -25,7 +27,10 @@ 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 } + if ($LASTEXITCODE -ne 0) { + Write-DeployCliResult -Script 'build-push-lan' -Success $false -Summary "Docker build failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } Write-Host "Build complete." -ForegroundColor Green } else { Write-Host "Skipping build (-SkipBuild)." -ForegroundColor Yellow @@ -33,4 +38,10 @@ if (-not $SkipBuild) { Write-Host "" & "$PSScriptRoot/push-lan.ps1" -Tag $Tag -SkipHosts:$SkipHosts -exit $LASTEXITCODE +if ($LASTEXITCODE -ne 0) { + Write-DeployCliResult -Script 'build-push-lan' -Success $false -Summary "Docker push failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-DeployCliResult -Script 'build-push-lan' -Success $true -Summary 'Docker image built and pushed to Gitea.' -Details @("Image: ${Image}:${Tag}") +exit 0 diff --git a/infra/scripts/deploy-dev-to-prod.ps1 b/infra/scripts/deploy-dev-to-prod.ps1 new file mode 100644 index 0000000..9c117cd --- /dev/null +++ b/infra/scripts/deploy-dev-to-prod.ps1 @@ -0,0 +1,388 @@ +# Dev to prod release orchestrator. +# Usage (from repo root): +# npm run devtoprod:release +# npm run devtoprod:release -- -DryRun +# npm run devtoprod:release -- -Config infra/deploy/devtoprod.config.json +# +# Requires infra/deploy/devtoprod.config.json (copy from devtoprod.config.example.json). + +param( + [string]$Config = (Join-Path $PSScriptRoot "..\deploy\devtoprod.config.json"), + [switch]$DryRun +) + +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "lib\Deploy-CliResult.ps1") + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +Set-Location $RepoRoot + +$BannerWidth = 64 +$CompletedSteps = [System.Collections.Generic.List[string]]::new() +$FailedStep = $null + +function Write-ReleaseBanner { + param([bool]$Success, [string]$FailedAt = '') + + Write-Host '' + Write-Host ('=' * $BannerWidth) + if ($Success) { + Write-Host ' RELEASE SUCCEEDED - production promoted' + Write-Host (' Steps: ' + ($CompletedSteps -join ', ')) + Write-Host ' Next: open https://gallery.mysuperlab.netcraze.pro/' + } else { + Write-Host " RELEASE FAILED at step: $FailedAt" + if ($CompletedSteps.Count -gt 0) { + Write-Host (' Completed: ' + ($CompletedSteps -join ', ')) + } + Write-Host " Failed: $FailedAt" + } + Write-Host ('=' * $BannerWidth) +} + +function Get-StepEnabled { + param([hashtable]$Steps, [string]$Name) + if ($Steps.ContainsKey($Name)) { + return [bool]$Steps[$Name] + } + return $false +} + +function Invoke-Npm { + param([string]$Script, [string[]]$ExtraArgs = @()) + $args = @('run', $Script) + if ($ExtraArgs.Count -gt 0) { + $args += '--' + $args += $ExtraArgs + } + & npm @args + return $LASTEXITCODE +} + +function Invoke-Step { + param( + [string]$Name, + [scriptblock]$Action + ) + + if ($DryRun) { + Write-Host "[dry-run] Would run: $Name" + $script:CompletedSteps.Add($Name) + return $true + } + + Write-Host '' + Write-Host "--- Step: $Name ---" -ForegroundColor Cyan + try { + $code = & $Action + if ($null -eq $code) { + $code = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 } + } + if ($code -ne 0) { + $script:FailedStep = $Name + return $false + } + $script:CompletedSteps.Add($Name) + return $true + } catch { + Write-Host $_.Exception.Message -ForegroundColor Red + $script:FailedStep = $Name + return $false + } +} + +function Get-ProfileSteps { + param([string]$Profile, [bool]$SchemaChanged) + + $base = @{ + validateBuild = $false + gitCommitPush = $false + thumbnails = $false + backupDev = $false + backupProd = $false + migrateProdSchema = $false + restoreProd = $false + syncImages = $false + dockerPublish = $false + truenasRestartPause = $false + verify = $false + } + + switch ($Profile) { + 'code' { + $base.validateBuild = $true + $base.gitCommitPush = $true + $base.dockerPublish = $true + $base.truenasRestartPause = $true + $base.verify = $true + } + 'data' { + $base.validateBuild = $true + $base.thumbnails = $true + $base.backupDev = $true + $base.backupProd = $true + $base.restoreProd = $true + $base.syncImages = $true + $base.verify = $true + } + default { + $base.validateBuild = $true + $base.gitCommitPush = $true + $base.thumbnails = $true + $base.backupDev = $true + $base.backupProd = $true + $base.restoreProd = $true + $base.syncImages = $true + $base.dockerPublish = $true + $base.truenasRestartPause = $true + $base.verify = $true + if ($SchemaChanged) { + $base.migrateProdSchema = $true + } + } + } + + return $base +} + +function Merge-Steps { + param([hashtable]$ProfileSteps, [object]$ConfigSteps) + + if ($null -eq $ConfigSteps) { return $ProfileSteps } + foreach ($key in $ConfigSteps.PSObject.Properties.Name) { + $ProfileSteps[$key] = [bool]$ConfigSteps.$key + } + return $ProfileSteps +} + +function Get-LatestDevBackup { + $backupDir = Join-Path $RepoRoot 'db\DataBackup' + if (-not (Test-Path $backupDir)) { + throw "Backup directory not found: $backupDir" + } + $latest = Get-ChildItem -Path $backupDir -Filter 'gallery_dev_data_*.txt' | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if (-not $latest) { + throw 'No gallery_dev_data_*.txt backup found in db/DataBackup/' + } + return $latest.FullName +} + +function Test-BoundsJson { + param([string]$Url, [switch]$Insecure) + + $curlArgs = @('-s', '-f') + if ($Insecure) { $curlArgs += '-k' } + $curlArgs += $Url + $body = & curl.exe @curlArgs 2>$null + if ($LASTEXITCODE -ne 0 -or -not $body) { + return $false + } + try { + $json = $body | ConvertFrom-Json + return ($null -ne $json.min_year -and $null -ne $json.max_year) + } catch { + return $false + } +} + +# --- Load config --- +if (-not (Test-Path $Config)) { + Write-Host "Config not found: $Config" -ForegroundColor Red + Write-Host 'Copy infra/deploy/devtoprod.config.example.json to infra/deploy/devtoprod.config.json and edit it.' + Write-ReleaseBanner -Success $false -FailedAt 'loadConfig' + exit 1 +} + +$raw = Get-Content -LiteralPath $Config -Raw -Encoding UTF8 +$cfg = $raw | ConvertFrom-Json + +$profile = if ($cfg.profile) { $cfg.profile } else { 'full' } +$schemaChanged = [bool]$cfg.schemaChanged +$steps = Merge-Steps (Get-ProfileSteps -Profile $profile -SchemaChanged $schemaChanged) $cfg.steps + +if ($cfg.autoConfirm) { + $env:CONFIRM_PROD = '1' +} + +if ($cfg.smb.user) { $env:SMB_USER = $cfg.smb.user } +if ($cfg.smb.password) { $env:SMB_PASSWORD = $cfg.smb.password } + +$smbHost = if ($cfg.smb.host) { $cfg.smb.host } else { '192.168.10.122' } +$smbShare = if ($cfg.smb.share) { $cfg.smb.share } else { 'Gallery' } +$imageDest = "\\$smbHost\$smbShare\data\images" + +$script:backupFile = '' +if ($cfg.backupFile -and $cfg.backupFile.Trim()) { + $script:backupFile = (Resolve-Path (Join-Path $RepoRoot $cfg.backupFile) -ErrorAction Stop).Path +} + +Write-Host "Gallery dev -> prod release" +Write-Host "Config: $Config" +Write-Host "Profile: $profile" +if ($DryRun) { Write-Host 'Mode: DRY RUN (no changes)' -ForegroundColor Yellow } + +$enabled = @($steps.Keys | Where-Object { $steps[$_] } | Sort-Object) +Write-Host ('Steps: ' + ($enabled -join ', ')) + +# --- Steps --- +if (Get-StepEnabled $steps 'validateBuild') { + $ok = Invoke-Step 'validateBuild' { + $code = Invoke-Npm 'prod:build' + if ($code -ne 0) { return $code } + + $devUrl = if ($cfg.verify.devUrl) { $cfg.verify.devUrl } else { 'https://devgallery.mysuperlab.netcraze.pro/api/bounds' } + if (-not (Test-BoundsJson -Url $devUrl -Insecure)) { + Write-Host "Dev API check failed: $devUrl" -ForegroundColor Red + return 1 + } + Write-Host "Dev API OK: $devUrl" + return 0 + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'gitCommitPush') { + $ok = Invoke-Step 'gitCommitPush' { + $branch = if ($cfg.git.branch) { $cfg.git.branch } else { 'main' } + $message = if ($cfg.git.message) { $cfg.git.message } else { 'Release: deploy' } + $stageAll = if ($null -ne $cfg.git.stageAll) { [bool]$cfg.git.stageAll } else { $true } + + if ($stageAll) { + git add -A + if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE } + } + + $status = git status --porcelain + if (-not $status) { + Write-Host 'Git: working tree clean, skipping commit.' + } else { + git commit -m $message + if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE } + } + + git push origin $branch + if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE } + return 0 + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'thumbnails') { + $ok = Invoke-Step 'thumbnails' { + $code = Invoke-Npm 'devtoprod:thumbnails' + return $code + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'backupDev') { + $ok = Invoke-Step 'backupDev' { + $code = Invoke-Npm 'dev:db:backup' + if ($code -ne 0) { return $code } + if (-not $script:backupFile) { + $script:backupFile = Get-LatestDevBackup + Write-Host "Using latest dev backup: $($script:backupFile)" + } + return 0 + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'backupProd') { + $ok = Invoke-Step 'backupProd' { + $code = Invoke-Npm 'prod:db:backup' + return $code + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'migrateProdSchema') { + $ok = Invoke-Step 'migrateProdSchema' { + $env:DB_NAME = 'gallery_prod' + try { + $code = Invoke-Npm 'dev:migrate' + return $code + } finally { + Remove-Item Env:\DB_NAME -ErrorAction SilentlyContinue + } + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'restoreProd') { + $ok = Invoke-Step 'restoreProd' { + if (-not $script:backupFile) { + if ($cfg.backupFile -and $cfg.backupFile.Trim()) { + $script:backupFile = (Resolve-Path (Join-Path $RepoRoot $cfg.backupFile)).Path + } else { + $script:backupFile = Get-LatestDevBackup + } + } + $rel = $script:backupFile.Replace($RepoRoot, '').TrimStart('\', '/').Replace('\', '/') + Write-Host "Restore file: $rel" + $code = Invoke-Npm 'devtoprod:db:restore' @('--file', $rel) + return $code + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'syncImages') { + $ok = Invoke-Step 'syncImages' { + $syncScript = Join-Path $PSScriptRoot 'sync-images-to-prod.ps1' + if ($cfg.autoConfirm) { + & $syncScript -Dest $imageDest -SkipConfirm + } else { + & $syncScript -Dest $imageDest + } + return $LASTEXITCODE + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'dockerPublish') { + $ok = Invoke-Step 'dockerPublish' { + $code = Invoke-Npm 'prod:docker:publish' + return $code + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +if (Get-StepEnabled $steps 'truenasRestartPause') { + if ($DryRun) { + Write-Host '[dry-run] Would pause for TrueNAS gallery-web restart' + $CompletedSteps.Add('truenasRestartPause') + } else { + Write-Host '' + Write-Host '--- Manual: restart gallery-web on TrueNAS ---' -ForegroundColor Yellow + Write-Host '1. TrueNAS Web UI -> Apps -> gallery-web -> Restart' + Write-Host '2. Wait until status is Running' + Read-Host 'Press Enter after restarting gallery-web on TrueNAS' + $CompletedSteps.Add('truenasRestartPause') + } +} + +if (Get-StepEnabled $steps 'verify') { + $ok = Invoke-Step 'verify' { + $lanUrl = if ($cfg.verify.lanUrl) { $cfg.verify.lanUrl } else { 'http://192.168.10.122:5173/api/bounds' } + $publicUrl = if ($cfg.verify.publicUrl) { $cfg.verify.publicUrl } else { 'https://gallery.mysuperlab.netcraze.pro/api/bounds' } + + if (-not (Test-BoundsJson -Url $lanUrl)) { + Write-Host "LAN verify failed: $lanUrl" -ForegroundColor Red + return 1 + } + Write-Host "LAN API OK: $lanUrl" + + if (-not (Test-BoundsJson -Url $publicUrl -Insecure)) { + Write-Host "Public verify failed: $publicUrl" -ForegroundColor Red + return 1 + } + Write-Host "Public API OK: $publicUrl" + return 0 + } + if (-not $ok) { Write-ReleaseBanner -Success $false -FailedAt $FailedStep; exit 1 } +} + +Write-ReleaseBanner -Success $true +exit 0 diff --git a/infra/scripts/lib/Deploy-CliResult.ps1 b/infra/scripts/lib/Deploy-CliResult.ps1 new file mode 100644 index 0000000..bf78d1d --- /dev/null +++ b/infra/scripts/lib/Deploy-CliResult.ps1 @@ -0,0 +1,29 @@ +# Standardized final SUCCESS/FAILED banner for deploy PowerShell scripts. +$script:DeployCliResultWidth = 72 + +function Write-DeployCliResult { + param( + [Parameter(Mandatory = $true)] + [string]$Script, + + [Parameter(Mandatory = $true)] + [bool]$Success, + + [string]$Summary = '', + + [string[]]$Details = @() + ) + + $label = if ($Success) { 'SUCCESS' } else { 'FAILED' } + $banner = "===== ${label}: $Script =====" + $pad = [Math]::Max(0, $script:DeployCliResultWidth - $banner.Length) + $line = $banner + ('=' * $pad) + + Write-Host '' + Write-Host $line + if ($Summary) { Write-Host $Summary } + foreach ($detail in $Details) { + if ($detail) { Write-Host $detail } + } + Write-Host ('=' * $script:DeployCliResultWidth) +} diff --git a/infra/scripts/sync-images-to-prod.ps1 b/infra/scripts/sync-images-to-prod.ps1 index 93d4a3e..7de60fc 100644 --- a/infra/scripts/sync-images-to-prod.ps1 +++ b/infra/scripts/sync-images-to-prod.ps1 @@ -12,6 +12,8 @@ param( ) $ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "lib\Deploy-CliResult.ps1") + $Source = (Resolve-Path $Source -ErrorAction Stop).Path Write-Host "Source: $Source" @@ -20,8 +22,8 @@ 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 + Write-DeployCliResult -Script 'sync-images-to-prod' -Success $false -Summary 'Image sync aborted by user.' + exit 1 } } @@ -34,7 +36,7 @@ if ($env:SMB_USER -and $env:SMB_PASSWORD) { Write-Warning "Map the share first, e.g.:" Write-Warning (' net use ' + $smbRoot + ' /user:YOUR_TRUENAS_USER') Write-Warning 'or set $env:SMB_USER and $env:SMB_PASSWORD before running this script.' - Write-Error 'SMB share not reachable - aborting before robocopy.' + Write-DeployCliResult -Script 'sync-images-to-prod' -Success $false -Summary 'SMB share not reachable - aborting before robocopy.' exit 1 } @@ -49,8 +51,9 @@ try { 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" + Write-DeployCliResult -Script 'sync-images-to-prod' -Success $false -Summary "robocopy failed with exit code $code" exit $code } -Write-Host "Image sync complete (robocopy exit $code)." +Write-DeployCliResult -Script 'sync-images-to-prod' -Success $true -Summary 'Image sync complete.' -Details @("robocopy exit code: $code", "Dest: $Dest") +exit 0 diff --git a/package.json b/package.json index 1d4c61a..9ef9fa1 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "prod:docker:push-only": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/docker/build-push-lan.ps1 -SkipBuild", "prod:db:backup": "node scripts/backup-db-data.js --prod", "devtoprod:db:restore": "node scripts/restore-db-data.js --prod", + "devtoprod:thumbnails": "node scripts/run-devtoprod-thumbnails.js", "devtoprod:images": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/sync-images-to-prod.ps1", + "devtoprod:release": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/deploy-dev-to-prod.ps1", "prodto:dev:db": "node scripts/sync-prod-to-dev.js", "prodto:dev:images": "powershell -NoProfile -ExecutionPolicy Bypass -File infra/scripts/sync-images-from-prod.ps1", "infra:db:split-dev-prod": "node scripts/split-dev-prod-databases.js" diff --git a/scripts/backup-db-data.js b/scripts/backup-db-data.js index 1d286c9..4903334 100644 --- a/scripts/backup-db-data.js +++ b/scripts/backup-db-data.js @@ -9,6 +9,7 @@ const { loadProdPgConfig, rootDir, } = require('./db-env'); +const { printCliResult } = require('./lib/cli-result'); const { Client } = pg; @@ -124,12 +125,27 @@ async function main() { { stdio: 'inherit' }, ); + const relTxtPath = path.relative(rootDir, txtPath).replace(/\\/g, '/'); console.log(`Backup written: ${txtPath}`); console.log(`Archive written: ${zipPath}`); console.log(`Tables: ${tablesRes.rowCount}, rows: ${totalRows}`); + + printCliResult({ + script: 'backup-db-data', + ok: true, + summary: `Backup complete for "${dbName}".`, + details: [ + `File: ${relTxtPath}`, + `Tables: ${tablesRes.rowCount}, rows: ${totalRows}`, + ], + }); } main().catch((error) => { console.error(error); - process.exit(1); + printCliResult({ + script: 'backup-db-data', + ok: false, + summary: error.message || String(error), + }); }); diff --git a/scripts/lib/cli-result.js b/scripts/lib/cli-result.js new file mode 100644 index 0000000..ff47d7c --- /dev/null +++ b/scripts/lib/cli-result.js @@ -0,0 +1,24 @@ +/** + * Print a standardized final SUCCESS/FAILED banner and exit. + * Always the last stdout before process exit in deploy scripts. + */ +const WIDTH = 72; + +function printCliResult({ script, ok, summary, details = [] }) { + const label = ok ? 'SUCCESS' : 'FAILED'; + const banner = `===== ${label}: ${script} =====`; + const pad = Math.max(0, WIDTH - banner.length); + const line = banner + '='.repeat(pad); + + console.log(''); + console.log(line); + if (summary) console.log(summary); + for (const detail of details) { + if (detail) console.log(detail); + } + console.log('='.repeat(WIDTH)); + + process.exit(ok ? 0 : 1); +} + +module.exports = { printCliResult }; diff --git a/scripts/regenerate-portrait-thumbs.js b/scripts/regenerate-portrait-thumbs.js index ba87d49..eb6713b 100644 --- a/scripts/regenerate-portrait-thumbs.js +++ b/scripts/regenerate-portrait-thumbs.js @@ -7,6 +7,7 @@ const fs = require('fs'); const path = require('path'); const pool = require('../server/db'); const { generateThumbnailFromFull } = require('./image-fetcher'); +const { printCliResult } = require('./lib/cli-result'); const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || './data/images'); const PORTRAIT_THUMB_WIDTH = 256; @@ -70,7 +71,32 @@ async function generatePortraitThumb(row) { } await pool.end(); + + if (errors.length) { + printCliResult({ + script: 'regenerate-portrait-thumbs', + ok: false, + summary: `Portrait thumbnail regeneration finished with ${errors.length} error(s).`, + details: [ + `Generated: ${generated}, skipped: ${skipped}`, + ...errors.slice(0, 5).map((e) => ` #${e.id} ${e.name}: ${e.error}`), + ], + }); + } + + printCliResult({ + script: 'regenerate-portrait-thumbs', + ok: true, + summary: 'Portrait thumbnail regeneration complete.', + details: [`Generated: ${generated}, skipped: ${skipped}`], + }); })().catch((err) => { console.error(err); - pool.end().finally(() => process.exit(1)); + pool.end().finally(() => { + printCliResult({ + script: 'regenerate-portrait-thumbs', + ok: false, + summary: err.message || String(err), + }); + }); }); diff --git a/scripts/regenerate-thumbnails.js b/scripts/regenerate-thumbnails.js index 45068e9..9a6a083 100644 --- a/scripts/regenerate-thumbnails.js +++ b/scripts/regenerate-thumbnails.js @@ -7,6 +7,7 @@ const path = require('path'); const sharp = require('sharp'); const pool = require('../server/db'); const { generateThumbnailFromFull } = require('./image-fetcher'); +const { printCliResult } = require('./lib/cli-result'); const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || './data/images'); @@ -91,7 +92,33 @@ async function aspectRatio(filePath) { } await pool.end(); + + if (errors.length) { + printCliResult({ + script: 'regenerate-thumbnails', + ok: false, + summary: `Painting thumbnail regeneration finished with ${errors.length} error(s).`, + details: [ + `Regenerated: ${regenerated}, skipped: ${skipped}`, + ...errors.slice(0, 5).map((e) => ` #${e.id} ${e.title}: ${e.error}`), + ], + }); + } + + printCliResult({ + script: 'regenerate-thumbnails', + ok: true, + summary: 'Painting thumbnail regeneration complete.', + details: [ + `Regenerated: ${regenerated}, skipped: ${skipped}`, + `Aspect-ratio mismatches before fix: ${mismatchesBefore}`, + ], + }); })().catch((err) => { console.error(err); - process.exit(1); + printCliResult({ + script: 'regenerate-thumbnails', + ok: false, + summary: err.message || String(err), + }); }); diff --git a/scripts/restore-db-data.js b/scripts/restore-db-data.js index fb04c37..e3acf38 100644 --- a/scripts/restore-db-data.js +++ b/scripts/restore-db-data.js @@ -14,6 +14,7 @@ const { loadDevPgConfig, loadProdPgConfig, } = require('./db-env'); +const { printCliResult } = require('./lib/cli-result'); const { Client } = pg; @@ -185,9 +186,20 @@ async function main() { await client.end(); console.log(`Restore complete: ${restored} statements into "${dbName}".`); + + printCliResult({ + script: 'restore-db-data', + ok: true, + summary: `Restore complete into "${dbName}".`, + details: [`Statements restored: ${restored}`], + }); } main().catch((error) => { console.error(error); - process.exit(1); + printCliResult({ + script: 'restore-db-data', + ok: false, + summary: error.message || String(error), + }); }); diff --git a/scripts/run-devtoprod-thumbnails.js b/scripts/run-devtoprod-thumbnails.js new file mode 100644 index 0000000..40b7df5 --- /dev/null +++ b/scripts/run-devtoprod-thumbnails.js @@ -0,0 +1,45 @@ +/** + * Run painting + portrait thumbnail regeneration and print one combined result. + */ +const { spawnSync } = require('child_process'); +const path = require('path'); +const { printCliResult } = require('./lib/cli-result'); + +const rootDir = path.join(__dirname, '..'); +const scripts = [ + { name: 'regenerate-thumbnails', file: 'regenerate-thumbnails.js' }, + { name: 'regenerate-portrait-thumbs', file: 'regenerate-portrait-thumbs.js' }, +]; + +function runScript(script) { + const result = spawnSync(process.execPath, [path.join(__dirname, script.file)], { + cwd: rootDir, + stdio: 'inherit', + env: process.env, + }); + return result.status ?? 1; +} + +let failed = null; +for (const script of scripts) { + const code = runScript(script); + if (code !== 0) { + failed = script.name; + break; + } +} + +if (failed) { + printCliResult({ + script: 'devtoprod-thumbnails', + ok: false, + summary: `Thumbnail regeneration failed at ${failed}.`, + }); +} + +printCliResult({ + script: 'devtoprod-thumbnails', + ok: true, + summary: 'Painting and portrait thumbnail regeneration complete.', + details: ['Both regenerate-thumbnails and regenerate-portrait-thumbs succeeded.'], +}); diff --git a/server/image-service.js b/server/image-service.js index 7c543d4..1ee8933 100644 --- a/server/image-service.js +++ b/server/image-service.js @@ -201,11 +201,21 @@ async function writePortraitThumb(fullPath, safeBase) { try { await generateThumbnailFromFull(fullPath, thumbDest, PORTRAIT_THUMB_WIDTH); return path.join('portraits', 'thumbs', `${safeBase}_thumb.jpg`).replace(/\\/g, '/'); - } catch { + } catch (err) { + console.warn(`Portrait thumb generation failed for ${safeBase}:`, err.message); return null; } } +/** Regenerate painting thumb from the saved full image (debug fix/upload, ~400px JPEG). */ +async function writePaintingThumb(fullPath, safeBase) { + const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs'); + if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true }); + const thumbDest = path.join(thumbsDir, `${safeBase}_thumb.jpg`); + await generateThumbnailFromFull(fullPath, thumbDest); + return path.join('paintings', 'thumbs', `${safeBase}_thumb.jpg`).replace(/\\/g, '/'); +} + async function updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath) { await pool.query( `UPDATE artists SET portrait_path = $1, portrait_thumb_path = $2 WHERE id = $3`, @@ -315,16 +325,15 @@ async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) { const fullExt = pickExtFromMime(mimeType); const fullDest = path.join(paintingsDir, safeBase + fullExt); - const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg'); unlinkPaintingFiles(row, safeBase); fs.writeFileSync(fullDest, buffer); - let thumbnailPath = null; + let thumbnailPath; try { - await generateThumbnailFromFull(fullDest, thumbDest); - thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/'); - } catch { + thumbnailPath = await writePaintingThumb(fullDest, safeBase); + } catch (err) { + console.warn(`Painting thumb generation failed for painting ${paintingId}:`, err.message); thumbnailPath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/'); } @@ -407,20 +416,16 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) { const fullExt = pickExt(imageUrl); const fullDest = path.join(paintingsDir, safeBase + fullExt); - const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg'); - unlinkIfExists(row.image_path ? path.join(IMAGE_DIR, row.image_path) : null); - unlinkIfExists(row.thumbnail_path ? path.join(IMAGE_DIR, row.thumbnail_path) : null); - unlinkIfExists(fullDest); - unlinkIfExists(thumbDest); + unlinkPaintingFiles(row, safeBase); await downloadImageForFix(imageUrl, fullDest, context); - let thumbnailPath = null; + let thumbnailPath; try { - await generateThumbnailFromFull(fullDest, thumbDest); - thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/'); - } catch { + thumbnailPath = await writePaintingThumb(fullDest, safeBase); + } catch (err) { + console.warn(`Painting thumb generation failed for painting ${paintingId}:`, err.message); thumbnailPath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/'); }