Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/gittensory-miner-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions packages/gittensory-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 56 additions & 0 deletions systemd/gittensory-miner-ui.service.example
Original file line number Diff line number Diff line change
@@ -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 <your fork/checkout> /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
49 changes: 49 additions & 0 deletions test/unit/miner-ui-systemd-launcher.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading