Adds Monitoring Setup docs and deploy-scp troubleshooting/debug fixes #2
@@ -109,7 +109,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -130,16 +130,16 @@ jobs:
|
||||
|
||||
# --- Gate 4: frontend build ---
|
||||
# Validates the frontend compiles on its own, independent of any environment. The actual
|
||||
# per-environment deployable artifact is produced later by `dotnet publish` (publish-test /
|
||||
# publish-production jobs below), which triggers the same frontend build internally via
|
||||
# SlpModularCms.Api.csproj's BuildAndCopyAdminFrontend MSBuild target, with the environment's
|
||||
# Vite variables set on the process so that target picks them up.
|
||||
# per-environment deployable artifact is produced later by each publish job's own "Build admin
|
||||
# frontend" step (publish-test / publish-production, below), which builds frontend/ again with
|
||||
# that environment's Vite variables set and copies the result into wwwroot/admin before
|
||||
# `dotnet publish` runs — see that step's comment for why this isn't an MSBuild target instead.
|
||||
frontend-build:
|
||||
needs: frontend-prepare
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -217,10 +217,8 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: pnpm run lint
|
||||
|
||||
# Publishes the Api project for the test environment. The admin SPA is built as part of this
|
||||
# publish (BuildAndCopyAdminFrontend, BeforeTargets="Publish"), so the VITE_* variables below are
|
||||
# set on this step's environment, not passed as CLI arguments — pnpm build (invoked by MSBuild)
|
||||
# reads them the same way `vite build` always does.
|
||||
# Publishes the Api project for the test environment. The admin SPA is built by this job's own
|
||||
# "Build admin frontend" step below, ahead of `dotnet publish` — see that step's comment for why.
|
||||
#
|
||||
# REF-U5-01: the Umami-origin drift check this gate performs exists because U3's own startup check
|
||||
# (BR-U3-22) can't work — the backend can never see VITE_UMAMI_WEBSITE_ID at runtime. It compares
|
||||
@@ -236,7 +234,7 @@ jobs:
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -253,13 +251,29 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish (test)
|
||||
working-directory: src/SlpModularCms.Api
|
||||
# Builds the admin SPA and lands it in wwwroot/admin *before* dotnet publish runs, as its own
|
||||
# step rather than an MSBuild target hooked to Build/Publish. That was tried and reliably
|
||||
# failed two different ways (see SlpModularCms.Api.csproj's comment on the topic): the SDK's
|
||||
# wwwroot static-web-asset item set is fixed at project evaluation time, before any target
|
||||
# runs, so files a target creates afterward never make it into the publish output; and forcing
|
||||
# them in as an explicit Content item collided with the SDK's own static-web-asset resolution.
|
||||
# Physically existing on disk before dotnet publish/build ever runs is the only thing that
|
||||
# worked, verified locally against a clean obj/bin and a fresh node_modules.
|
||||
- name: Build admin frontend
|
||||
working-directory: frontend
|
||||
env:
|
||||
VITE_APP_ENV: test
|
||||
VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }}
|
||||
VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_TEST }}
|
||||
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
mkdir -p ../src/SlpModularCms.Api/wwwroot/admin
|
||||
cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/
|
||||
|
||||
- name: Publish (test)
|
||||
working-directory: src/SlpModularCms.Api
|
||||
run: >
|
||||
dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false
|
||||
-o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_TEST }}
|
||||
@@ -283,7 +297,7 @@ jobs:
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -300,13 +314,23 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish (production)
|
||||
working-directory: src/SlpModularCms.Api
|
||||
# See publish-test's "Build admin frontend" step for why this runs as its own step ahead of
|
||||
# dotnet publish rather than an MSBuild target.
|
||||
- name: Build admin frontend
|
||||
working-directory: frontend
|
||||
env:
|
||||
VITE_APP_ENV: production
|
||||
VITE_UMAMI_SCRIPT_URL: ${{ vars.VITE_UMAMI_SCRIPT_URL }}
|
||||
VITE_UMAMI_WEBSITE_ID: ${{ vars.VITE_UMAMI_WEBSITE_ID_PRODUCTION }}
|
||||
VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
mkdir -p ../src/SlpModularCms.Api/wwwroot/admin
|
||||
cp -r dist/. ../src/SlpModularCms.Api/wwwroot/admin/
|
||||
|
||||
- name: Publish (production)
|
||||
working-directory: src/SlpModularCms.Api
|
||||
run: >
|
||||
dotnet publish -c Release -r ${{ env.PUBLISH_RID }} --self-contained false
|
||||
-o ${{ github.workspace }}/${{ env.ARTIFACT_NAME_PRODUCTION }}
|
||||
|
||||
@@ -56,14 +56,23 @@ jobs:
|
||||
# The backup script itself lives on the host (created once during Operations host setup) and
|
||||
# is only invoked here — no database connection string or credential is ever known to this
|
||||
# workflow, keeping D-16 ("runtime secrets live in host environment variables") intact.
|
||||
- name: Back up database (${{ inputs.environment }})
|
||||
# Gitea Actions does not resolve `${{ inputs.* }}` inside a step's `name:` (unlike inside
|
||||
# `run:`, where it works fine — see the echo below) — kept static rather than showing the
|
||||
# literal, unresolved `${{ inputs.environment }}` text in the log.
|
||||
- name: Back up database
|
||||
if: ${{ inputs.run_db_backup }}
|
||||
env:
|
||||
SSH_USER: ${{ secrets.PI_MAIN_USERNAME }}
|
||||
SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }}
|
||||
SSH_PORT: ${{ secrets.PI_MAIN_PORT }}
|
||||
SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }}
|
||||
run: |
|
||||
echo "Environment: ${{ inputs.environment }}"
|
||||
sudo apt-get update && sudo apt-get install -y sshpass
|
||||
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
||||
-p ${{ secrets.PI_MAIN_PORT }} \
|
||||
sshpass -p "$SSH_PASS" ssh \
|
||||
-p "$SSH_PORT" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"bash ~/scripts/backup-slpsoftware-db.sh ${{ inputs.environment }}"
|
||||
|
||||
# Uploads the published output to a fresh, timestamped release directory rather than
|
||||
@@ -74,20 +83,27 @@ jobs:
|
||||
# runner with "failed to attach to container: unable to upgrade to tcp, received 409", a known
|
||||
# limitation of Podman's Docker-compatible API for the attach/log-streaming that container
|
||||
# actions rely on (D-05). A plain scp command needs no nested container.
|
||||
- name: Upload release to ${{ inputs.environment }} (${{ inputs.transport }})
|
||||
# Same Gitea Actions limitation as the step above — static name, value logged via echo instead.
|
||||
- name: Upload release
|
||||
env:
|
||||
SSH_USER: ${{ secrets.PI_MAIN_USERNAME }}
|
||||
SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }}
|
||||
SSH_PORT: ${{ secrets.PI_MAIN_PORT }}
|
||||
SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }}
|
||||
run: |
|
||||
echo "Environment: ${{ inputs.environment }}, transport: ${{ inputs.transport }}"
|
||||
sudo apt-get update && sudo apt-get install -y sshpass
|
||||
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
|
||||
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
||||
-p ${{ secrets.PI_MAIN_PORT }} \
|
||||
sshpass -p "$SSH_PASS" ssh \
|
||||
-p "$SSH_PORT" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"mkdir -p $RELEASE_DIR"
|
||||
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" scp \
|
||||
-P ${{ secrets.PI_MAIN_PORT }} \
|
||||
sshpass -p "$SSH_PASS" scp \
|
||||
-P "$SSH_PORT" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-r ${{ inputs.artifact_name }}/* \
|
||||
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }}:"$RELEASE_DIR"/
|
||||
"$SSH_USER@$SSH_HOST:$RELEASE_DIR/"
|
||||
|
||||
# The customer's public website (wwwroot/web) must survive every CMS deploy (FR-08, ASM-01).
|
||||
# It lives outside the swapped release directory in a persistent shared/ folder, and is
|
||||
@@ -95,29 +111,71 @@ jobs:
|
||||
# first-ever deploy, before any website workspace has published anything there (U1 already
|
||||
# tolerates a missing wwwroot/web at startup). The publish output's own wwwroot/web (empty, or
|
||||
# containing only the placeholder page) is removed before the symlink is created, so it never
|
||||
# shadows the persistent content.
|
||||
# shadows the persistent content. `mkdir -p $RELEASE_DIR/wwwroot` guards against `ln -s` failing
|
||||
# with "No such file or directory": dotnet publish only emits a wwwroot/ folder at all when the
|
||||
# source project has one with actual content, so a release built before any frontend content
|
||||
# exists can land with no wwwroot/ directory whatsoever, not merely an empty wwwroot/web/.
|
||||
- name: Link persistent website content
|
||||
env:
|
||||
SSH_USER: ${{ secrets.PI_MAIN_USERNAME }}
|
||||
SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }}
|
||||
SSH_PORT: ${{ secrets.PI_MAIN_PORT }}
|
||||
SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }}
|
||||
run: |
|
||||
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
|
||||
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
||||
-p ${{ secrets.PI_MAIN_PORT }} \
|
||||
sshpass -p "$SSH_PASS" ssh \
|
||||
-p "$SSH_PORT" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"mkdir -p ${{ inputs.deploy_path }}/shared/wwwroot-web && \
|
||||
mkdir -p $RELEASE_DIR/wwwroot && \
|
||||
rm -rf $RELEASE_DIR/wwwroot/web && \
|
||||
ln -s ../../../shared/wwwroot-web $RELEASE_DIR/wwwroot/web"
|
||||
|
||||
# Optional plugin modules (future modular support): a compiled SlpModularCms.Modules.*.dll
|
||||
# dropped into shared/modules survives every release, the same persistence pattern as
|
||||
# shared/wwwroot-web above — except there's no cross-account permission dance here, since
|
||||
# shared/modules lives entirely under gitea-workflow's own tree (unlike wwwroot-web, which
|
||||
# reaches into webadmin's). ModuleOrchestrator.DiscoverModules() already scans its own base
|
||||
# directory on disk for matching assemblies at startup (SlpModularCms.Core/Hosting/
|
||||
# ModuleOrchestrator.cs) and loads whatever it finds via reflection — no code change needed,
|
||||
# only somewhere for the DLL to still be after the next deploy replaces releases/{timestamp}.
|
||||
# `ls ... 2>/dev/null` piped to a `while read` (rather than a bare glob loop) keeps this
|
||||
# POSIX-sh safe and correct when shared/modules is empty, which is the common case today.
|
||||
- name: Link persistent modules
|
||||
env:
|
||||
SSH_USER: ${{ secrets.PI_MAIN_USERNAME }}
|
||||
SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }}
|
||||
SSH_PORT: ${{ secrets.PI_MAIN_PORT }}
|
||||
SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }}
|
||||
run: |
|
||||
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
|
||||
sshpass -p "$SSH_PASS" ssh \
|
||||
-p "$SSH_PORT" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"mkdir -p ${{ inputs.deploy_path }}/shared/modules && \
|
||||
ls ${{ inputs.deploy_path }}/shared/modules/*.dll 2>/dev/null | while read -r f; do \
|
||||
name=\$(basename \"\$f\"); \
|
||||
ln -sf \"../../shared/modules/\$name\" \"$RELEASE_DIR/\$name\"; \
|
||||
done"
|
||||
|
||||
# Atomic release switch (FR-06, D-27): `ln -sfn` replaces the `current` symlink target in a
|
||||
# single filesystem operation, so there is no moment where `current` points at a half-written
|
||||
# directory. The process is then restarted so it picks up the new assemblies — a running .NET
|
||||
# process holds on to the ones it already loaded.
|
||||
- name: Switch current release and restart service
|
||||
env:
|
||||
SSH_USER: ${{ secrets.PI_MAIN_USERNAME }}
|
||||
SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }}
|
||||
SSH_PORT: ${{ secrets.PI_MAIN_PORT }}
|
||||
SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }}
|
||||
run: |
|
||||
RELEASE_DIR="${{ inputs.deploy_path }}/releases/${{ steps.release.outputs.timestamp }}"
|
||||
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
||||
-p ${{ secrets.PI_MAIN_PORT }} \
|
||||
sshpass -p "$SSH_PASS" ssh \
|
||||
-p "$SSH_PORT" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"ln -sfn $RELEASE_DIR ${{ inputs.deploy_path }}/current && \
|
||||
systemctl --user restart ${{ inputs.service_name }}"
|
||||
|
||||
@@ -142,9 +200,14 @@ jobs:
|
||||
# a re-point-and-restart away without rebuilding. Only runs after a passing health check —
|
||||
# pruning after a failed check could leave the only other release as the sole survivor.
|
||||
- name: Prune old releases
|
||||
env:
|
||||
SSH_USER: ${{ secrets.PI_MAIN_USERNAME }}
|
||||
SSH_PASS: ${{ secrets.PI_MAIN_PASSWORD }}
|
||||
SSH_PORT: ${{ secrets.PI_MAIN_PORT }}
|
||||
SSH_HOST: ${{ secrets.PI_MAIN_ADDRESS }}
|
||||
run: |
|
||||
sshpass -p "${{ secrets.PI_MAIN_PASSWORD }}" ssh \
|
||||
-p ${{ secrets.PI_MAIN_PORT }} \
|
||||
sshpass -p "$SSH_PASS" ssh \
|
||||
-p "$SSH_PORT" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
${{ secrets.PI_MAIN_USERNAME }}@${{ secrets.PI_MAIN_ADDRESS }} \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"cd ${{ inputs.deploy_path }}/releases && ls -1t | tail -n +3 | xargs -r rm -rf"
|
||||
|
||||
@@ -179,8 +179,15 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 +
|
||||
- [x] Build and Test — **EXECUTE** — Complete 2026-07-28. 609 unit tests (372 backend + 237 frontend), 0 vulnerable packages, 0 build errors. Plus live-host integration verification: static content/SPA-fallback/header-scoping, real master↔slave communication proving the persistent key ring survives a process restart, and W3C trace-id correlation in real log output. Artifacts in `construction/build-and-test/`
|
||||
|
||||
### OPERATIONS
|
||||
- [ ] Deployment Setup — **EXECUTE**
|
||||
- [ ] Monitoring Setup — **EXECUTE**
|
||||
- [x] Deployment Setup — **EXECUTE** — Complete 2026-07-28, extended 2026-07-29 (MariaDB migration,
|
||||
reverse proxy topology correction, artifact-upload 413 fix in `deployment-instructions.md` § 1.7.3,
|
||||
missing `Observability__Environment` host var added § 1.5). Artifacts: `operations/deployment/`
|
||||
- [x] Monitoring Setup — **EXECUTE** — Complete 2026-07-29. Plan answered (Sentry project reused,
|
||||
UptimeRobot account reused, Umami website entries created by user, 20-events/5-min default alert
|
||||
threshold). User has applied the real values in Gitea variables, UptimeRobot and Sentry directly
|
||||
(DSN, Umami website IDs, alert rules, 6 monitors) — not verified by execution from this session
|
||||
(no access to those external accounts), taken on the user's report. Artifacts:
|
||||
`operations/plans/monitoring-setup-plan.md`, `operations/monitoring/monitoring-instructions.md`
|
||||
- [ ] Production Readiness Validation — **EXECUTE** (includes the `dotnet-appsettings` compliance gate)
|
||||
|
||||
## Execution Plan Summary
|
||||
@@ -190,9 +197,13 @@ Execution rounds (Q4 = B): **R1** = U1 + U2 · **R2** = U3 + U4 · **R3** = U5 +
|
||||
|
||||
## Current Status
|
||||
- **Lifecycle Phase**: OPERATIONS
|
||||
- **Current Stage**: Build and Test **complete** 2026-07-28 (all 7 units, full-suite + live-host integration verification). CONSTRUCTION phase is now closed
|
||||
- **Next Stage**: Deployment Setup (Operations Configuration = Yes, decided at Requirements Analysis)
|
||||
- **Status**: Rounds 1–4 all done, nothing pushed. Entering Operations phase
|
||||
- **Current Stage**: Deployment Setup complete (2026-07-28, extended 2026-07-29). Monitoring Setup
|
||||
complete 2026-07-29 — user applied real values in Gitea, UptimeRobot and Sentry
|
||||
- **Next Stage**: Production Readiness Validation (includes the `dotnet-appsettings` compliance gate)
|
||||
- **Status**: CI pipeline built and merging via an open PR; `workflow_dispatch` manual testing of
|
||||
`deploy-test`/`deploy-production` (and therefore a real end-to-end run touching the monitors/alerts
|
||||
just configured) blocked until that PR merges to `master` (Gitea Actions only shows manual-dispatch
|
||||
workflows that exist on the default branch)
|
||||
|
||||
## Round 2 Design Record (2026-07-28)
|
||||
- Functional Design U3 + U4 complete and committed (`357d395`)
|
||||
|
||||
+124
-3
@@ -148,6 +148,28 @@ sudo chgrp -R webshared /mnt/storage1/www/html/slpsoftware/<env>
|
||||
and make sure webadmin's FTP server creates new uploads group-readable (`g+rx`, not just
|
||||
owner-readable) — a one-time permission setup, not something either pipeline touches per deploy.
|
||||
|
||||
**`shared/modules` is the equivalent mechanism for optional plugin modules** (future modular
|
||||
support) — but simpler, since it lives entirely under `gitea-workflow`'s own tree, with no
|
||||
cross-account permission dance:
|
||||
|
||||
👤 **pi-main / `gitea-workflow`:**
|
||||
```bash
|
||||
mkdir -p ~/apps/slpsoftware/<env>/shared/modules
|
||||
```
|
||||
|
||||
`deploy-scp.yaml`'s "Link persistent modules" step already runs this `mkdir -p` on every deploy, so
|
||||
this line is only useful if you want the directory to exist before the very first deploy — it isn't
|
||||
required. To add a module: build a `SlpModularCms.Modules.<Name>.dll` against the same version of
|
||||
`SlpModularCms.Core` the running app was built against (mismatched contracts are caught per-module
|
||||
by `ModuleOrchestrator`'s existing try/catch — logged and skipped, not a crash), `scp`/`sftp` it as
|
||||
`gitea-workflow` into `~/apps/slpsoftware/<env>/shared/modules/`, then either wait for the next
|
||||
deploy or restart the service by hand:
|
||||
```bash
|
||||
systemctl --user restart slpsoftware-<env>.service
|
||||
```
|
||||
Either way, `ModuleOrchestrator.DiscoverModules()` picks it up from the release directory the next
|
||||
time the process starts — no CI run required to add a module this way.
|
||||
|
||||
### 1.5 Runtime Configuration File
|
||||
One file per environment, **outside** the release directory so it survives every switch:
|
||||
|
||||
@@ -182,6 +204,14 @@ MasterModule__MasterUrl=https://<this-environment-domain>
|
||||
# continues (see Observability section in README.md).
|
||||
Observability__SentryDsn=<sentry-dsn-or-empty>
|
||||
|
||||
# REQUIRED if the DSN above is set, and must differ between this file and the other environment's.
|
||||
# D-19 uses one Sentry project for both test and production, distinguished only by this tag.
|
||||
# ASPNETCORE_ENVIRONMENT is deliberately "Production" for both environments (see note below), so
|
||||
# without this explicit override, ObservabilityOptions.Environment falls back to
|
||||
# ASPNETCORE_ENVIRONMENT and every event — test and production alike — would be tagged "Production",
|
||||
# silently defeating D-19's whole point of telling them apart in Sentry.
|
||||
Observability__Environment=<test-or-production>
|
||||
|
||||
# Optional — both only needed if this instance uses Umami analytics (VITE_UMAMI_SCRIPT_URL set for
|
||||
# the frontend build). Leave both lines out entirely if you don't use Umami; there is no other
|
||||
# origin either one needs by default.
|
||||
@@ -362,6 +392,82 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.7.3 Artifact Upload Body Size (413 on `publish-test`/`publish-production`)
|
||||
|
||||
The Gitea instance itself (`gitea.slpsoftware.nl`) sits behind the same proxy Pi as every other
|
||||
domain in § 1.7 — Actions' own web UI/API traffic is proxied through it exactly like
|
||||
`test.slpsoftware.nl` and `slpsoftware.nl` are. Gitea Actions' artifact upload
|
||||
(`actions/upload-artifact`) sends the build output in chunks; if a chunk exceeds nginx's
|
||||
`client_max_body_size` (default 1m), nginx itself rejects it with `413 Request Entity Too Large`
|
||||
before the request ever reaches Gitea — surfacing in the `publish-test`/`publish-production` job log
|
||||
as repeated `A 413 status code has been received, will attempt to retry the upload` followed by
|
||||
`Retry limit has been reached` on individual files once the action's retries are exhausted.
|
||||
|
||||
**pi-main is not involved** — § 1.7 already established it runs no nginx and holds no certificates
|
||||
for any of these domains; this is purely a proxy Pi setting.
|
||||
|
||||
**Fix — set once, globally, not per server block.** `client_max_body_size` is inherited
|
||||
(`http` → `server` → `location`); setting it in the top-level `http {}` block covers every current
|
||||
and future domain's server blocks — both the `listen 80` and `listen 443` blocks certbot manages —
|
||||
without needing to repeat it each time § 1.7.1's procedure is run for a new domain.
|
||||
|
||||
🌐 **proxy Pi / root:**
|
||||
```nginx
|
||||
# /etc/nginx/nginx.conf, inside the http { } block:
|
||||
http {
|
||||
client_max_body_size 512m;
|
||||
...
|
||||
}
|
||||
```
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
#### 1.7.4 Never Re-Run `deploy-test`/`deploy-production` Alone — Gitea Platform Limitation
|
||||
|
||||
`deploy-test` and `deploy-production` in `continuous_integration.yaml` are not ordinary jobs — each
|
||||
one *calls* the reusable `deploy-scp.yaml` workflow (`uses: ./.gitea/workflows/deploy-scp.yaml`).
|
||||
Re-running **only** one of these two jobs after a failure (Gitea's per-job "re-run" action) is a
|
||||
known Gitea Actions limitation, not a bug in either workflow file here: Gitea does not cleanly resume
|
||||
a `workflow_call` job inside its original run — it re-executes the call in a way that loses access to
|
||||
that run's already-uploaded artifact. The `Download build artifact` step then fails immediately with:
|
||||
```
|
||||
List Artifacts - Error is not retryable
|
||||
Status Code: 404
|
||||
Error: List Artifacts failed: Artifact service responded with 404
|
||||
```
|
||||
even though `publish-test`/`publish-production` genuinely succeeded and uploaded the artifact
|
||||
moments earlier in the same run (retention had not expired — this is not the same failure mode as
|
||||
§ 1.7.3, and not a retention issue at all).
|
||||
|
||||
**Fix: re-run the entire workflow, not just this job** — but see § 1.7.5 immediately below before
|
||||
doing that: Gitea's "re-run all jobs" on an *existing* run is not the same as triggering a genuinely
|
||||
new run, and has its own, different failure mode.
|
||||
|
||||
#### 1.7.5 "Re-Run All Jobs" Can Silently Reuse Stale Secrets/Variables — Trigger a New Run Instead
|
||||
|
||||
Symptom: a secret (e.g. `PI_MAIN_PASSWORD`) was wrong, causing `sshpass`/`ssh` to fail with
|
||||
`Permission denied, please try again.` (`sshpass` exit code 5 — the password itself was rejected,
|
||||
not a connectivity or config problem). The secret is corrected in Gitea's UI and confirmed working
|
||||
via a manual SSH test with the same value. **"Re-run all jobs" is used on the existing, already-failed
|
||||
run — and it fails again, identically**, as if the fix never happened.
|
||||
|
||||
This matches a documented behaviour of Actions-style re-run implementations, seen concretely in
|
||||
GitHub's own tooling ([cli/cli#13522](https://github.com/cli/cli/issues/13522)): re-running an
|
||||
*existing* run can replay against secrets/variables as they were **when that run was first created**,
|
||||
not their current values — particularly for a job that calls a reusable workflow with
|
||||
`secrets: inherit` (exactly what `deploy-test`/`deploy-production` do here). The documented contract
|
||||
("secrets are fetched at the time of the re-run") does not hold in practice for this case. A genuinely
|
||||
**new** run (fresh push, or `workflow_dispatch`) always fetches current values correctly — only
|
||||
re-running an existing run risks the stale snapshot.
|
||||
|
||||
**Net effect of § 1.7.4 + § 1.7.5 together**: neither of Gitea's two "re-run" options is fully safe
|
||||
for `deploy-test`/`deploy-production` — "re-run this job alone" loses the artifact, "re-run all jobs"
|
||||
can keep stale secrets. **The one reliable option is to trigger a brand new run** (an actual push, or
|
||||
`workflow_dispatch`'s "Run workflow" button) rather than using either re-run action on a failed run,
|
||||
whenever a secret or variable was just changed to fix that failure. Re-running an existing run is
|
||||
only safe when nothing about its secrets/variables changed since it was created.
|
||||
|
||||
### 1.8 Database Backup Credentials (§ 4 depends on this)
|
||||
|
||||
👤 **pi-main / `gitea-workflow`:**
|
||||
@@ -414,9 +520,9 @@ Already built (U5/U6) — this is the read-only walkthrough for whoever operates
|
||||
1. Push to `master`, or a manual `workflow_dispatch` → the six gates run
|
||||
2. `publish-test` runs (and `publish-production`, only if `workflow_dispatch` with the flag)
|
||||
3. `deploy-test` (always, if gates pass) calls `deploy-scp.yaml`, which uploads into a new
|
||||
`releases/{timestamp}/`, links `shared/wwwroot-web` in, switches `current`, restarts
|
||||
`slpsoftware-test.service`, verifies `https://test.slpsoftware.nl/health`, then prunes old
|
||||
releases (only test — `run_db_backup: false`)
|
||||
`releases/{timestamp}/`, links `shared/wwwroot-web` and any `shared/modules/*.dll` in, switches
|
||||
`current`, restarts `slpsoftware-test.service`, verifies `https://test.slpsoftware.nl/health`,
|
||||
then prunes old releases (only test — `run_db_backup: false`)
|
||||
4. `deploy-production` (only with the flag) does the same, plus a database backup first
|
||||
(`run_db_backup: true`) — see § 4
|
||||
|
||||
@@ -450,6 +556,21 @@ fi
|
||||
source "$CREDENTIALS_FILE"
|
||||
: "${DB_HOST:?}" "${DB_PORT:?}" "${DB_NAME:?}" "${DB_USER:?}" "${DB_PASSWORD:?}"
|
||||
|
||||
# A brand-new environment's very first production deploy runs this step before the app has ever
|
||||
# started, so before EF Core's migrations have had a chance to create the database — there is
|
||||
# nothing to back up yet, and that's fine, not a failure. Distinguish that specific case ("Unknown
|
||||
# database", MariaDB error 1049) from every other failure (wrong credentials, network issue,
|
||||
# permissions): only the former is safe to skip, since skipping any OTHER error would silently mask
|
||||
# a real backup failure against a database that may hold real data.
|
||||
USE_ERROR=$(mariadb -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASSWORD" -e "USE \`$DB_NAME\`;" 2>&1 >/dev/null) || true
|
||||
if echo "$USE_ERROR" | grep -q "Unknown database"; then
|
||||
echo "Database '$DB_NAME' does not exist yet — nothing to back up (expected on a first deploy). Skipping."
|
||||
exit 0
|
||||
elif [[ -n "$USE_ERROR" ]]; then
|
||||
echo "Could not verify database '$DB_NAME' exists: $USE_ERROR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_DIR="$HOME/backups/slpsoftware/${ENVIRONMENT}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
TIMESTAMP=$(date -u +%Y%m%d%H%M%S)
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# Monitoring Instructions
|
||||
|
||||
All three services below were built into the application during Construction (U4) — this document
|
||||
only wires them to the real accounts and writes down the configuration that necessarily lives
|
||||
outside this repository (Sentry alert rules, UptimeRobot monitors, Umami website entries).
|
||||
|
||||
**Who does what, where** — reuses the labels from `deployment-instructions.md` § "Who runs what,
|
||||
where", plus the external services themselves:
|
||||
- 👤 **pi-main / `gitea-workflow`** — the environment's `shared/env` file (§ 1.5 of
|
||||
`deployment-instructions.md`)
|
||||
- ☁️ **Gitea web UI** — repository Variables (Settings → Actions → Variables)
|
||||
- ☁️ **Sentry web UI** — project settings and alert rules
|
||||
- ☁️ **UptimeRobot web UI** — monitors and alert contacts
|
||||
- ☁️ **Umami web UI** — website entries
|
||||
|
||||
## 1. Sentry
|
||||
|
||||
One project for the whole CMS (D-19); `Observability__Environment` (host-side, § 1.5) and
|
||||
`VITE_APP_ENV` (build-time) are what tell test and production events apart in that one project —
|
||||
there is no separate DSN per environment.
|
||||
|
||||
### 1.1 Gitea variable
|
||||
|
||||
☁️ **Gitea web UI** — Settings → Actions → Variables:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `VITE_SENTRY_DSN` | the project's DSN (same value used by both `publish-test` and `publish-production`) |
|
||||
|
||||
### 1.2 Host configuration
|
||||
|
||||
Already documented in `deployment-instructions.md` § 1.5 — `Observability__SentryDsn` and
|
||||
`Observability__Environment` in each environment's `shared/env` file. `Observability__Environment`
|
||||
**must** differ between the two files (`test` / `production`); see that section's note on why
|
||||
`ASPNETCORE_ENVIRONMENT` can't be reused for this.
|
||||
|
||||
### 1.3 Alert rules (FR-19)
|
||||
|
||||
The six `security_event` tag values already emitted by `SecurityEvents.cs` — alert rules must filter
|
||||
on the **tag**, not the message text (`nfr-design-patterns.md` Pattern 6):
|
||||
|
||||
| `security_event` tag value | Rule type | Threshold |
|
||||
|---|---|---|
|
||||
| `failed_login` | Count-based | > 20 events in 5 minutes |
|
||||
| `authorization_denied` | Count-based | > 20 events in 5 minutes |
|
||||
| `master_api_key_rejected` | Count-based | > 20 events in 5 minutes |
|
||||
| `admin_bypass_rejected` | Count-based | > 20 events in 5 minutes |
|
||||
| `rate_limit_triggered` | Count-based | > 20 events in 5 minutes |
|
||||
| `migration_failure` | Always-fire | any occurrence (already `Critical` level — the process is exiting) |
|
||||
|
||||
20-in-5-minutes is a starting default (no production traffic history exists yet to tune against) —
|
||||
revisit once real traffic volume is known.
|
||||
|
||||
☁️ **Sentry web UI** — Alerts → Create Alert Rule, once per row above:
|
||||
1. Condition: `tags.security_event equals <value>`
|
||||
2. For the five count-based rules: "when greater than 20 events, in 5 minutes" (or the platform's
|
||||
equivalent rate-window phrasing)
|
||||
3. For `migration_failure`: no count condition — fire on every matching event
|
||||
4. Action: notify via whichever channel/integration alerts should reach (same destination used
|
||||
elsewhere for this project is fine — nothing here requires a dedicated channel)
|
||||
|
||||
**Note (DEV-01, accepted deviation)**: Sentry's plan in use retains events for roughly 30 days,
|
||||
short of the 90-day SECURITY-14 minimum. Already accepted as a cost decision at Requirements
|
||||
Analysis — nothing to configure here, just to be aware of if investigating an old event.
|
||||
|
||||
## 2. UptimeRobot
|
||||
|
||||
Six monitors — `/health`, `/`, `/admin` × test/production (D-23). What each does and does not prove
|
||||
(FR-17):
|
||||
|
||||
| URL | Proves | Does NOT prove |
|
||||
|---|---|---|
|
||||
| `https://test.slpsoftware.nl/health` | The test process is alive (liveness only, D-21) | Database connectivity, migrations applied — no DB check by design (Q17=A) |
|
||||
| `https://test.slpsoftware.nl/` | Proxy Pi → pi-main routing and static hosting work for test | The API or admin SPA work at all |
|
||||
| `https://test.slpsoftware.nl/admin` | The admin SPA is served and its assets aren't blocked by CSP | Login/auth actually succeeds — this only checks the shell loads |
|
||||
| `https://slpsoftware.nl/health` | Same as above, production | Same as above, production |
|
||||
| `https://slpsoftware.nl/` | Same as above, production | Same as above, production |
|
||||
| `https://slpsoftware.nl/admin` | Same as above, production | Same as above, production |
|
||||
|
||||
☁️ **UptimeRobot web UI** — Add New Monitor, once per row above:
|
||||
- Monitor Type: **HTTP(s)** (not Keyword/Port/Ping) — checks only the response status code (2xx/3xx),
|
||||
the same liveness signal `deploy-scp.yaml`'s own `curl -f` health check already relies on
|
||||
- Interval: 5 minutes is enough for all six — `/health` recovers within a process restart
|
||||
(`Restart=on-failure`, `RestartSec=5` in the systemd units), no need for a tighter interval
|
||||
- Alert Contact: the existing contact already configured on this account
|
||||
|
||||
**Free-plan note**: 6 monitors at 5-minute intervals fits comfortably within the free plan's 50-monitor
|
||||
cap and 5-minute minimum interval — no paid tier needed for this feature's monitoring. One gap the
|
||||
free plan leaves open: no SSL-certificate-expiry monitoring. Certbot's systemd timer renews
|
||||
automatically (`deployment-instructions.md` § 1.7.1), so the risk is low, but a silently failed
|
||||
renewal would only be caught reactively — once an HTTP(s) check itself starts failing — rather than
|
||||
warned about in advance. Accepted as a known gap, not built around, consistent with this project's
|
||||
other documented deviations (e.g. DEV-01's log-retention gap).
|
||||
|
||||
## 3. Umami
|
||||
|
||||
Reuses the existing self-hosted instance at `analytics.slpsoftware.nl` (D-24/ASM-05). This section
|
||||
covers only the **admin SPA's** two website entries — the public website's Umami wiring (also
|
||||
required by D-25) belongs to whichever website workspace serves `wwwroot/web/` (FR-09/FR-16), not to
|
||||
this deploy pipeline.
|
||||
|
||||
☁️ **Umami web UI** — Websites → Add website, twice (once per environment), domain set to the
|
||||
admin SPA's real URL (`test.slpsoftware.nl/admin`, `slpsoftware.nl/admin`) — note the resulting
|
||||
website ID for each.
|
||||
|
||||
☁️ **Gitea web UI** — Settings → Actions → Variables:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `VITE_UMAMI_SCRIPT_URL` | `https://analytics.slpsoftware.nl/script.js` (shared — same instance, both environments) |
|
||||
| `VITE_UMAMI_WEBSITE_ID_TEST` | the test website's ID from the step above |
|
||||
| `VITE_UMAMI_WEBSITE_ID_PRODUCTION` | the production website's ID from the step above |
|
||||
|
||||
**These must stay in sync with two other Gitea variables that already exist from U5**
|
||||
(`SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION`) and with the matching
|
||||
`SecurityHeaders__AllowedScriptOrigins__0` / `_AllowedConnectOrigins__0` lines in each environment's
|
||||
host `shared/env` file (`deployment-instructions.md` § 1.5) — all four must name the same
|
||||
`https://analytics.slpsoftware.nl` origin, or either the CI gate (REF-U5-01) fails the build, or the
|
||||
script loads in CI but is silently blocked by CSP on the real host (the gate cannot see the host
|
||||
file, only the Gitea variable side of this).
|
||||
|
||||
## 4. Gitea Variables — Full Summary
|
||||
|
||||
For reference, every Actions variable this document and `deployment-instructions.md` § 1.9 together
|
||||
require:
|
||||
|
||||
| Variable | Scope | Set by |
|
||||
|---|---|---|
|
||||
| `VITE_SENTRY_DSN` | shared | § 1.1 above |
|
||||
| `VITE_UMAMI_SCRIPT_URL` | shared | § 3 above |
|
||||
| `VITE_UMAMI_WEBSITE_ID_TEST` | test | § 3 above |
|
||||
| `VITE_UMAMI_WEBSITE_ID_PRODUCTION` | production | § 3 above |
|
||||
| `SECURITY_ALLOWED_SCRIPT_ORIGINS_TEST` / `_PRODUCTION` | per environment | already set at Deployment Setup — must match § 3's Umami origin |
|
||||
| `DEPLOY_PATH_*`, `SERVICE_NAME_*`, `HEALTH_CHECK_URL_*` | per environment | `deployment-instructions.md` § 1.9 (unrelated to monitoring, listed there) |
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Monitoring Setup Plan
|
||||
|
||||
## Context already established (not re-asked)
|
||||
|
||||
The observability *stack* and *what it must cover* were already decided in Requirements Analysis and
|
||||
built during Construction (U4) — this stage is about configuring the real external services and
|
||||
writing the operational documentation, not re-deciding the approach:
|
||||
|
||||
- **Stack**: Sentry (backend `Sentry.AspNetCore` + frontend `@sentry/react`), self-hosted Umami
|
||||
analytics, UptimeRobot uptime monitoring (D-18, D-24, requirements.md § "Monitoring and observability")
|
||||
- **Sentry projects**: **one** project for the whole CMS, `environment` tag distinguishes
|
||||
test/production (D-19) — so one DSN, reused by both environments and both frontend/backend
|
||||
- **Sentry is optional by design**: an absent DSN disables it cleanly, console logging stays active
|
||||
(BR-U4-08) — nothing breaks if a value is left blank temporarily
|
||||
- **Alertable events already exist in code**: six `security_event` tag values are emitted today —
|
||||
`failed_login`, `authorization_denied`, `master_api_key_rejected`, `admin_bypass_rejected`,
|
||||
`rate_limit_triggered`, `migration_failure` (`SecurityEvents.cs`). Alert rules must filter on the
|
||||
`security_event` tag, not message text (`nfr-design-patterns.md` Pattern 6) — this is explicitly
|
||||
named as **Operations — Monitoring Setup** work in `logical-components.md` (FR-19)
|
||||
- **UptimeRobot scope**: `/health`, `/` (public website) and `/admin`, per environment — 6 monitors
|
||||
total (D-23)
|
||||
- **Umami scope**: reuse the existing self-hosted instance at `analytics.slpsoftware.nl`, add new
|
||||
website entries for this CMS rather than standing up a new instance (D-24/ASM-05). Both the admin
|
||||
SPA (via `VITE_UMAMI_WEBSITE_ID_<ENV>`, built by this pipeline) and the public website (via the
|
||||
separate website-workspace, FR-09/FR-16, out of this pipeline's scope) are measured (D-25) — this
|
||||
stage only configures the **admin SPA's** two website entries; the public website's Umami wiring
|
||||
belongs to whichever website workspace serves it
|
||||
- **Health endpoint**: liveness only, no DB check, already built and already on the
|
||||
`AvailabilityMiddleware` bypass list (D-21) — nothing left to build, only to point UptimeRobot at
|
||||
- **Log retention deviation**: accepted knowingly (DEV-01) — Sentry's ~30-day retention against the
|
||||
90-day SECURITY-14 minimum. No action here beyond documenting it.
|
||||
|
||||
## Question 1: Include Monitoring Setup?
|
||||
|
||||
Given U4 built all the alertable-event plumbing specifically so that Operations could wire up real
|
||||
alert rules and monitors on top of it, declining this stage would leave that code inert — events
|
||||
would be emitted into a Sentry project that doesn't exist yet, with no alert rule reading the tag,
|
||||
and no UptimeRobot monitor watching `/health`. Recommended: A.
|
||||
|
||||
A) Yes — configure the real services and document it (Recommended)
|
||||
B) No — monitoring is handled elsewhere or not needed
|
||||
C) Not sure — suggest an approach and I'll decide
|
||||
|
||||
X) Other (please describe after [Answer]: tag below)
|
||||
|
||||
[Answer]:A
|
||||
|
||||
## Question 2: Monitoring Method
|
||||
|
||||
Confirms the stack already decided in Requirements Analysis — asked per the mandatory format, not
|
||||
because it's genuinely open.
|
||||
|
||||
A) Sentry + self-hosted Umami + UptimeRobot, exactly as scoped above (Recommended)
|
||||
B) Something else entirely — describe below
|
||||
|
||||
X) Other (please describe after [Answer]: tag below)
|
||||
|
||||
[Answer]:A
|
||||
|
||||
---
|
||||
|
||||
The remaining questions are the genuinely open items — real account/service facts that only you
|
||||
know, which nothing in Construction could decide for you.
|
||||
|
||||
## Question 3: Sentry Project
|
||||
|
||||
Does a Sentry project for this CMS already exist (e.g. created ad hoc while testing U4 locally), or
|
||||
does this stage need to walk through creating one from scratch?
|
||||
|
||||
A) Already exists — I'll provide the DSN after [Answer]: below
|
||||
B) Doesn't exist yet — walk me through creating one (org, project platform picks for .NET + React,
|
||||
where to find the DSN afterward)
|
||||
C) Not yet, and I'd rather set it up myself later — document it as a TODO with exactly which Gitea
|
||||
secrets/variables need the DSN once it exists
|
||||
|
||||
X) Other (please describe after [Answer]: tag below)
|
||||
|
||||
[Answer]:A — DSN to be added directly to the Gitea secret/variable once shared (kept out of this doc)
|
||||
|
||||
## Question 4: Sentry Alert Rule Thresholds
|
||||
|
||||
FR-19 requires alert rules for repeated auth failures and authorization violations, filtering on the
|
||||
`security_event` tag (values: `failed_login`, `authorization_denied`, `master_api_key_rejected`,
|
||||
`admin_bypass_rejected`, `rate_limit_triggered`; `migration_failure` is `Critical`-level and worth
|
||||
its own always-fire rule regardless of count). What threshold should the repeated-failure rules use?
|
||||
|
||||
A) A single reasonable default for all of them (e.g. 20 occurrences in 5 minutes) — document it as a
|
||||
starting point, tune later from real traffic (Recommended)
|
||||
B) I'll specify exact thresholds per event type — describe below
|
||||
C) No count threshold — alert on every occurrence of each of the six (noisier, but nothing is ever
|
||||
missed)
|
||||
|
||||
X) Other (please describe after [Answer]: tag below)
|
||||
|
||||
[Answer]:A
|
||||
|
||||
## Question 5: UptimeRobot Account and Alert Contacts
|
||||
|
||||
6 monitors are needed (`/health`, `/`, `/admin` × test/production). Do you have an UptimeRobot
|
||||
account already (e.g. from the reference `SlpSoftware` project), and where should alerts go?
|
||||
|
||||
A) Existing account, reuse it — alert contact(s): describe after [Answer]: below (email, and/or
|
||||
any other channel already configured there)
|
||||
B) Need a new account/monitor group set up from scratch — walk me through it
|
||||
C) Not sure yet — document the 6 monitors' exact URLs and recommended check interval as a checklist,
|
||||
I'll create them myself
|
||||
|
||||
X) Other (please describe after [Answer]: tag below)
|
||||
|
||||
[Answer]:A — existing account, alert contact to be confirmed
|
||||
|
||||
## Question 6: Umami Website Entries (Admin SPA)
|
||||
|
||||
Two new website entries are needed on the existing `analytics.slpsoftware.nl` instance — one per
|
||||
environment — to obtain the `VITE_UMAMI_WEBSITE_ID_TEST`/`_PRODUCTION` values the CI workflow already
|
||||
expects as Gitea variables (`continuous_integration.yaml` reads `vars.VITE_UMAMI_WEBSITE_ID_TEST`/
|
||||
`_PRODUCTION`).
|
||||
|
||||
A) I have access to the Umami instance — I'll create the two entries and provide the website IDs
|
||||
after [Answer]: below
|
||||
B) Walk me through creating them (Umami's own UI/API steps)
|
||||
C) Not yet — document it as a TODO with the exact variable names the pipeline expects, I'll fill
|
||||
them in before the first real deploy
|
||||
|
||||
X) Other (please describe after [Answer]: tag below)
|
||||
|
||||
[Answer]:A — website IDs to be added directly to Gitea variables once created (kept out of this doc)
|
||||
@@ -16,7 +16,7 @@ beforeEach(() => {
|
||||
async function openDialog() {
|
||||
mockAuthenticated();
|
||||
renderApp('/cms');
|
||||
await screen.findByTestId('cms-add-button', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('cms-add-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(screen.getByTestId('cms-add-button'));
|
||||
expect(screen.getByTestId('add-cms-name')).toBeInTheDocument();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ beforeEach(() => {
|
||||
async function openSetStatusDialog() {
|
||||
mockAuthenticated();
|
||||
renderApp('/cms');
|
||||
const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 5000 });
|
||||
const triggers = await screen.findAllByTestId('cms-instance-actions-trigger', {}, { timeout: 10000 });
|
||||
await userEvent.click(triggers[0]);
|
||||
await userEvent.click(await screen.findByTestId('cms-instance-set-status'));
|
||||
expect(screen.getByTestId('set-status-select')).toBeInTheDocument();
|
||||
|
||||
@@ -14,7 +14,7 @@ beforeEach(() => {
|
||||
async function openDialog() {
|
||||
mockAuthenticated();
|
||||
renderApp('/users');
|
||||
await screen.findByTestId('users-invite-button', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('users-invite-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(screen.getByTestId('users-invite-button'));
|
||||
expect(screen.getByTestId('invite-dialog-email')).toBeInTheDocument();
|
||||
}
|
||||
|
||||
@@ -12,21 +12,21 @@ describe('AccessDeniedPage', () => {
|
||||
it('renders the 403 title and message', async () => {
|
||||
renderApp('/403');
|
||||
|
||||
expect(await screen.findByTestId('access-denied-title', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('access-denied-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('access-denied-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the back to dashboard button', async () => {
|
||||
renderApp('/403');
|
||||
|
||||
expect(await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('access-denied-back-button', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to login when back button is clicked as guest', async () => {
|
||||
mockGuest();
|
||||
renderApp('/403');
|
||||
|
||||
const button = await screen.findByTestId('access-denied-back-button', {}, { timeout: 5000 });
|
||||
const button = await screen.findByTestId('access-denied-back-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||
|
||||
@@ -20,7 +20,7 @@ describe('CmsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/cms');
|
||||
|
||||
expect(await screen.findByTestId('cms-title', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('cms-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cms-add-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('CmsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/cms');
|
||||
|
||||
const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 5000 });
|
||||
const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 10000 });
|
||||
expect(rows.length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('CmsPage', () => {
|
||||
server.use(http.get(CMS_URL, () => HttpResponse.json([])));
|
||||
renderApp('/cms');
|
||||
|
||||
expect(await screen.findByTestId('cms-empty-state', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('cms-empty-state', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cms-empty-add-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('CmsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/cms');
|
||||
|
||||
await screen.findByTestId('cms-add-button', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('cms-add-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(screen.getByTestId('cms-add-button'));
|
||||
|
||||
expect(await screen.findByTestId('add-cms-name')).toBeInTheDocument();
|
||||
@@ -55,7 +55,7 @@ describe('CmsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/cms');
|
||||
|
||||
await screen.findByTestId('cms-add-button', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('cms-add-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(screen.getByTestId('cms-add-button'));
|
||||
|
||||
await userEvent.type(screen.getByTestId('add-cms-name'), 'New CMS');
|
||||
@@ -63,7 +63,7 @@ describe('CmsPage', () => {
|
||||
await userEvent.type(screen.getByTestId('add-cms-apikey-input'), 'secret-key');
|
||||
await userEvent.click(screen.getByTestId('add-cms-submit'));
|
||||
|
||||
const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 5000 });
|
||||
const rows = await screen.findAllByTestId('cms-instance-row', {}, { timeout: 10000 });
|
||||
expect(rows.length).toBe(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,21 +12,21 @@ describe('NotFoundPage', () => {
|
||||
it('renders for an unknown route', async () => {
|
||||
renderApp('/this-route-does-not-exist');
|
||||
|
||||
expect(await screen.findByTestId('not-found-title', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('not-found-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('not-found-message')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the back to dashboard button', async () => {
|
||||
renderApp('/some/nonexistent/path');
|
||||
|
||||
expect(await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('not-found-back-button', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to login when back button is clicked as guest', async () => {
|
||||
mockGuest();
|
||||
renderApp('/this-does-not-exist');
|
||||
|
||||
const button = await screen.findByTestId('not-found-back-button', {}, { timeout: 5000 });
|
||||
const button = await screen.findByTestId('not-found-back-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(button);
|
||||
|
||||
expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument();
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('ProfilePage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/profile');
|
||||
|
||||
expect(await screen.findByTestId('profile-title', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('profile-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('profile-name-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('profile-email-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('profile-role-badge')).toBeInTheDocument();
|
||||
@@ -61,7 +61,7 @@ describe('ProfilePage', () => {
|
||||
await userEvent.type(nameInput, 'New Name');
|
||||
await userEvent.click(screen.getByTestId('profile-save-button'));
|
||||
|
||||
expect(await screen.findByText(/profile updated/i, {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/profile updated/i, {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error for invalid email', async () => {
|
||||
|
||||
@@ -16,21 +16,21 @@ describe('SettingsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
expect(await screen.findByTestId('settings-title', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('settings-title', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the current availability status badge', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
expect(await screen.findByTestId('availability-badge', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('availability-badge', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all three availability mode buttons', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('availability-mode-selector', {}, { timeout: 10000 });
|
||||
expect(screen.getByTestId('mode-option-Available')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mode-option-Maintenance')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mode-option-NotAvailable')).toBeInTheDocument();
|
||||
@@ -40,7 +40,7 @@ describe('SettingsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
await screen.findByTestId('availability-mode-selector', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('availability-mode-selector', {}, { timeout: 10000 });
|
||||
const maintenanceBtn = screen.getByTestId('mode-option-Maintenance');
|
||||
await userEvent.click(maintenanceBtn);
|
||||
|
||||
@@ -51,10 +51,10 @@ describe('SettingsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('availability-save-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(screen.getByTestId('availability-save-button'));
|
||||
|
||||
expect(await screen.findByText(/availability updated/i, {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/availability updated/i, {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error toast when save fails', async () => {
|
||||
@@ -66,10 +66,10 @@ describe('SettingsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('availability-save-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(screen.getByTestId('availability-save-button'));
|
||||
|
||||
expect(await screen.findByText(/something went wrong/i, {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/something went wrong/i, {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the availability controls and shows a banner when master-controlled', async () => {
|
||||
@@ -86,7 +86,7 @@ describe('SettingsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mode-option-Available')).toBeDisabled();
|
||||
expect(screen.getByTestId('mode-option-Maintenance')).toBeDisabled();
|
||||
expect(screen.getByTestId('mode-option-NotAvailable')).toBeDisabled();
|
||||
@@ -98,7 +98,7 @@ describe('SettingsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('availability-save-button', {}, { timeout: 10000 });
|
||||
expect(screen.queryByTestId('availability-master-controlled-banner')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('availability-save-button')).not.toBeDisabled();
|
||||
});
|
||||
@@ -112,17 +112,17 @@ describe('SettingsPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('availability-save-button', {}, { timeout: 10000 });
|
||||
await userEvent.click(screen.getByTestId('availability-save-button'));
|
||||
|
||||
expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all placeholder sections', async () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/settings');
|
||||
|
||||
await screen.findByTestId('settings-title', {}, { timeout: 5000 });
|
||||
await screen.findByTestId('settings-title', {}, { timeout: 10000 });
|
||||
expect(screen.getByTestId('placeholder-settings.modules.title')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('placeholder-settings.systemConfig.title')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('placeholder-settings.branding.title')).toBeInTheDocument();
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('UsersPage', () => {
|
||||
mockAuthenticated();
|
||||
renderApp('/users');
|
||||
|
||||
expect(await screen.findByTestId('users-page', {}, { timeout: 5000 })).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('users-page', {}, { timeout: 10000 })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('users-invite-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export default defineConfig(({ command }) => ({
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
testTimeout: 15000,
|
||||
testTimeout: 20000,
|
||||
css: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #141414; color: #ededed; }
|
||||
code { background: #262626; }
|
||||
code { background: #262626; color: #141414; }
|
||||
a { color: #ff6b6b; }
|
||||
}
|
||||
main { max-width: 34rem; }
|
||||
@@ -28,6 +28,7 @@
|
||||
p { margin: 0 0 1rem; }
|
||||
code {
|
||||
background: #ececec;
|
||||
color: #1a1a1a;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
|
||||
@@ -35,22 +35,21 @@
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Builds the CMS admin SPA (frontend/) and copies its output into wwwroot/admin so it's served
|
||||
at the /admin path (see Program.cs). Only runs on `dotnet publish`, not on every dev build.
|
||||
The public website ('/') lives outside this repo and is deployed into wwwroot separately.
|
||||
The admin SPA (frontend/) is served at /admin (see Program.cs) from wwwroot/admin, but is
|
||||
deliberately NOT built by an MSBuild target hooked to Build/Publish here. Both attempts tried
|
||||
(a plain <Copy> after a BeforeTargets="Publish" pnpm build, and an explicit <Content> item with
|
||||
a custom TargetPath) were verified locally to fail: the SDK's wwwroot static-web-asset item set
|
||||
is fixed at project evaluation time, before any target runs, so files a target creates afterward
|
||||
are silently absent from the publish output; and forcing them in as a Content item collided with
|
||||
the SDK's own static-web-asset resolution ("Two assets found targeting the same path with
|
||||
incompatible asset kinds: admin/admin/..."), reproducibly, even from a clean obj/bin and a fresh
|
||||
node_modules. The only combination that published wwwroot/admin correctly was the files already
|
||||
physically existing on disk *before* dotnet publish/build ever runs — see the CI workflow
|
||||
(continuous_integration.yaml), which builds frontend/ and copies frontend/dist into
|
||||
wwwroot/admin as its own step, ahead of `dotnet publish`. Do the same locally before publishing:
|
||||
run `pnpm install` then `pnpm build` inside frontend/, then copy
|
||||
`frontend/dist/*` into `wwwroot/admin/` (that folder is gitignored, so nothing to clean up
|
||||
afterward), then `dotnet publish` picks it up via the SDK's ordinary wwwroot handling.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<AdminFrontendDir>$(MSBuildProjectDirectory)/../../frontend</AdminFrontendDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="BuildAndCopyAdminFrontend" BeforeTargets="Publish">
|
||||
<Message Importance="high" Text="Building CMS admin frontend ($(AdminFrontendDir))..." />
|
||||
<Exec Command="pnpm install --frozen-lockfile" WorkingDirectory="$(AdminFrontendDir)" />
|
||||
<Exec Command="pnpm build" WorkingDirectory="$(AdminFrontendDir)" />
|
||||
<ItemGroup>
|
||||
<AdminFrontendFiles Include="$(AdminFrontendDir)/dist/**/*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(AdminFrontendFiles)" DestinationFolder="$(MSBuildProjectDirectory)/wwwroot/admin/%(RecursiveDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user