From 430d62c25eb8f02abc147212a94805144b9eeccd Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Mon, 13 Jul 2026 13:02:42 +0400 Subject: [PATCH] feat(miner-ui): add a systemd unit for running miner-ui as a persistent service apps/gittensory-miner-ui had no CLI subcommand or process-manager config for running it durably -- only a foreground dev server. Its vite-*-api.ts plugins already wire into configurePreviewServer as well as configureServer, so `npm run build && npm run preview` already serves the dashboard and its local-SQLite-backed data routes with no new server code needed. Adds systemd/gittensory-miner-ui.service.example as a companion unit to the existing gittensory-miner.service.example (the loop daemon), documents it in the miner-ui README, and cross-references it from the main miner's DEPLOYMENT.md bare-host walkthrough. Closes #4852 --- apps/gittensory-miner-ui/README.md | 14 ++++++ packages/gittensory-miner/DEPLOYMENT.md | 2 + systemd/gittensory-miner-ui.service.example | 56 +++++++++++++++++++++ test/unit/miner-ui-systemd-launcher.test.ts | 49 ++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 systemd/gittensory-miner-ui.service.example create mode 100644 test/unit/miner-ui-systemd-launcher.test.ts diff --git a/apps/gittensory-miner-ui/README.md b/apps/gittensory-miner-ui/README.md index 0bd0bb4ba..531508c01 100644 --- a/apps/gittensory-miner-ui/README.md +++ b/apps/gittensory-miner-ui/README.md @@ -26,3 +26,17 @@ automatically on every subsequent same-origin `fetch()` call, so none of the cli to know about it. A request from another local process, or from a different page/origin the user has open (including a DNS-rebinding attempt), has no way to obtain the cookie and is rejected. There is nothing to configure — this is always on for both `vite dev` and `vite preview`. + +## Running as a persistent service + +`npm run dev` is a foreground dev server; it doesn't survive a terminal closing or a reboot. For a +fleet/bare-host operator who wants the dashboard durably available, `npm run build` followed by +`npm run preview` serves the built dashboard **and** its local-SQLite-backed API routes (the +`vite-*-api.ts` plugins register for both `configureServer` and `configurePreviewServer`, so nothing +extra is needed beyond the build step) on port `4174` by default. + +[`systemd/gittensory-miner-ui.service.example`](../../systemd/gittensory-miner-ui.service.example) at +the repo root is a ready-to-adapt persistent unit for this — a companion to +`gittensory-miner.service.example` (the loop daemon), not a replacement for it. Its header comment +carries the full install steps. Like the loop daemon, this is a `Type=simple` service, not a `.timer` +job — the dashboard is a long-running HTTP server, not a periodic batch task. diff --git a/packages/gittensory-miner/DEPLOYMENT.md b/packages/gittensory-miner/DEPLOYMENT.md index 322e31eef..19a939387 100644 --- a/packages/gittensory-miner/DEPLOYMENT.md +++ b/packages/gittensory-miner/DEPLOYMENT.md @@ -142,6 +142,8 @@ sudo systemctl enable --now gittensory-miner.service Because `loop` is a **long-running daemon that schedules its own cycles**, it is a persistent `Type=simple` service (with `Restart=on-failure`) — **not** a oneshot unit driven by a `.timer`, unlike the periodic `loopover-docker-prune.*.example` hygiene job in [`systemd/`](../../systemd/). Keep `GITHUB_TOKEN` (and any coding-agent credentials) in a root-owned `0600` `EnvironmentFile`, never in the unit file. Follow the loop with `journalctl -u gittensory-miner -f`; `systemctl stop` sends SIGTERM, which the loop handles cleanly at its next kill-switch check. +Want the dashboard too? [`systemd/gittensory-miner-ui.service.example`](../../systemd/gittensory-miner-ui.service.example) is a companion unit that serves `apps/gittensory-miner-ui` persistently over the same local state — see that app's [README](../../apps/gittensory-miner-ui/README.md#running-as-a-persistent-service). + ## Invariants - Core miner bookkeeping (claims, plans, queues, ledgers) works offline after install. diff --git a/systemd/gittensory-miner-ui.service.example b/systemd/gittensory-miner-ui.service.example new file mode 100644 index 000000000..3ac72f3e4 --- /dev/null +++ b/systemd/gittensory-miner-ui.service.example @@ -0,0 +1,56 @@ +# Runs the miner-ui dashboard (apps/gittensory-miner-ui) as a persistent local service via `vite preview` +# (port 4174 by default -- see apps/gittensory-miner-ui/vite.config.ts), so a fleet/bare-host operator gets a +# durable dashboard instead of a dev-server-only experience. This is a companion service to +# gittensory-miner.service.example, not a replacement -- the loop daemon still does the actual discover -> +# attempt -> manage work; this unit only serves the read-only dashboard over it. +# +# WHY vite preview, NOT a new CLI subcommand: apps/gittensory-miner-ui is a private workspace app (no +# published bin), and its Vite plugins (vite-ledgers-api.ts, vite-portfolio-queue-api.ts, vite-run-state-api.ts) +# already wire their local-SQLite-backed API routes into BOTH `configureServer` (dev) and +# `configurePreviewServer` (preview), so `npm run preview` already serves the built dashboard AND its data +# routes with no extra server code needed. +# +# WHY A PERSISTENT SERVICE, NOT A .timer: like gittensory-miner.service.example, this is a long-running HTTP +# server, not a periodic batch job -- a Type=simple service supervised by systemd is the right shape, the +# same way loopover-docker-prune.*.example (a genuine periodic job) is NOT. +# +# Install (adjust the placeholders below to your host; run from a full monorepo checkout, not a global +# npm install -- this app has no published package): +# git clone /var/lib/gittensory-miner/src +# cd /var/lib/gittensory-miner/src && npm ci +# npm --workspace @loopover/ui-miner run build +# sudo cp systemd/gittensory-miner-ui.service.example /etc/systemd/system/gittensory-miner-ui.service +# sudo $EDITOR /etc/systemd/system/gittensory-miner-ui.service # fix User/WorkingDirectory/env +# sudo systemctl daemon-reload +# sudo systemctl enable --now gittensory-miner-ui.service +# journalctl -u gittensory-miner-ui -f # follow the dashboard server's output +# +# The dashboard reads the SAME local SQLite stores gittensory-miner.service.example's loop daemon writes to +# (see packages/gittensory-miner/README.md's "Local storage" table) -- point GITTENSORY_MINER_CONFIG_DIR at +# the same directory as the loop service's unit if you run both on one host. + +[Unit] +Description=Gittensory miner-ui dashboard (read-only, local) +# Loopback-only by default (see the miner extension's host-permission scoping); no network dependency to +# wait on, but keep the ordering consistent with the loop service it complements. +After=network-online.target + +[Service] +Type=simple +# REQUIRED: run as a dedicated non-root user with read access to the miner's local state directory. +User=gittensory +# REQUIRED: absolute path to the monorepo checkout built in the install steps above. +WorkingDirectory=/var/lib/gittensory-miner/src +# REQUIRED: absolute path to the workspace-scoped npm binary (see `which npm`; typically /usr/bin or +# /usr/local/bin, or an nvm/asdf shim path). `vite preview` binds 127.0.0.1 by default -- do not add +# `--host` unless you have put real auth in front of this port; the dashboard has none of its own. +ExecStart=/usr/bin/npm --workspace @loopover/ui-miner run preview +# A crashed dashboard should come back; it holds no in-memory state of its own to lose. +Restart=on-failure +RestartSec=10 +# Optional overrides (see packages/gittensory-miner/README.md's "Local storage" table for the full list): +# Environment=GITTENSORY_MINER_CONFIG_DIR=/var/lib/gittensory-miner/config +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/test/unit/miner-ui-systemd-launcher.test.ts b/test/unit/miner-ui-systemd-launcher.test.ts new file mode 100644 index 000000000..041ca73de --- /dev/null +++ b/test/unit/miner-ui-systemd-launcher.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = join(process.cwd()); +const SERVICE_PATH = join(REPO_ROOT, "systemd/gittensory-miner-ui.service.example"); +const MINER_UI_README_PATH = join(REPO_ROOT, "apps/gittensory-miner-ui/README.md"); +const DEPLOYMENT_PATH = join(REPO_ROOT, "packages/gittensory-miner/DEPLOYMENT.md"); +const MINER_UI_PACKAGE_JSON_PATH = join(REPO_ROOT, "apps/gittensory-miner-ui/package.json"); + +describe("miner-ui persistent-service launcher (#4852)", () => { + it("ships a systemd unit with every directive the main miner's unit requires", () => { + const unit = readFileSync(SERVICE_PATH, "utf8"); + expect(unit).toContain("[Unit]"); + expect(unit).toContain("[Service]"); + expect(unit).toContain("[Install]"); + expect(unit).toContain("Type=simple"); + expect(unit).toContain("ExecStart="); + expect(unit).toContain("WorkingDirectory="); + expect(unit).toContain("User="); + expect(unit).toContain("Restart=on-failure"); + expect(unit).toContain("WantedBy=multi-user.target"); + }); + + it("ExecStart targets the real @loopover/ui-miner workspace, not a stale/renamed package name", () => { + const unit = readFileSync(SERVICE_PATH, "utf8"); + const packageName = JSON.parse(readFileSync(MINER_UI_PACKAGE_JSON_PATH, "utf8")).name; + expect(unit).toContain(`--workspace ${packageName} run preview`); + }); + + it("does not force --host (the dashboard has no auth of its own)", () => { + const unit = readFileSync(SERVICE_PATH, "utf8"); + expect(unit).not.toMatch(/ExecStart=.*--host/); + }); + + it("documents the persistent-service launcher in the miner-ui README", () => { + const readme = readFileSync(MINER_UI_README_PATH, "utf8"); + expect(readme).toContain("## Running as a persistent service"); + expect(readme).toContain("systemd/gittensory-miner-ui.service.example"); + expect(readme).toContain("npm run build"); + expect(readme).toContain("npm run preview"); + }); + + it("cross-references the miner-ui service from the main miner's Bare-host deployment doc", () => { + const deployment = readFileSync(DEPLOYMENT_PATH, "utf8"); + expect(deployment).toContain("gittensory-miner-ui.service.example"); + expect(deployment).toContain("apps/gittensory-miner-ui/README.md#running-as-a-persistent-service"); + }); +});