Skip to content
Closed
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ sparkDash is a real-time web dashboard for one or more **NVIDIA DGX Spark (GB10)
- [Configuration](#configuration)
- [Security](#security)
- [Scripts](#scripts)
- [Automatic updates](#automatic-updates)
- [How it works](#how-it-works)
- [Contributing](#contributing)
- [License](#license)
Expand Down Expand Up @@ -265,6 +266,24 @@ Choice is stored in `localStorage`.
| `npm run docker:dev:build` | Dev Compose with rebuild |
| `./deploy.sh` | Recreate container; `--build`, `--frontend` flags |

## Automatic updates

Install the optional daily self-update service:

```bash
npm run self-update:install
```

It runs daily at **03:15** using a launchd agent on macOS or a systemd user
timer on Linux. Set `SPARKDASH_UPDATE_TIME=HH:MM` while installing to choose a
different time.

The updater is deliberately conservative: it only updates a clean `main`
branch and only accepts fast-forward changes from `origin/main`. When an update
exists it runs `npm ci`, builds the frontend, and redeploys with Docker Compose.
Tracked local modifications, another checked-out branch, or divergent history
cause a safe skip rather than overwriting work.

---

## How it works
Expand Down
26 changes: 26 additions & 0 deletions docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
services:
sparkdash:
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/arm64
container_name: sparkDash
restart: unless-stopped
command: ["node", "--watch", "server/index.js"]
ports:
- "5555:5555"
volumes:
- ./server:/app/server
- ./dist:/app/dist:ro
- ./config:/app/config
environment:
- PORT=5555
- LLM_PORT=8888
- NODE_ENV=production
networks:
- sparkdash-net

networks:
sparkdash-net:
driver: bridge
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"docker:prod": "docker compose up -d",
"docker:rebuild": "docker compose up --build -d",
"frontend": "npm run build",
"test": "echo \"Error: no test specified\" && exit 1"
"self-update:install": "./scripts/install-self-update.sh",
"test": "./test/self-update.test.sh && node --test test/*.test.mjs"
},
"keywords": ["nvidia", "dgx-spark", "gb10", "monitoring", "dashboard", "gpu", "llm"],
"author": "Mia'a AI Lab",
Expand Down
65 changes: 65 additions & 0 deletions scripts/install-self-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail

REPO="$(cd "$(dirname "$0")/.." && pwd)"
SCHEDULE="${SPARKDASH_UPDATE_TIME:-03:15}"
RESTART_CMD="${SPARKDASH_RESTART_CMD:-docker compose up --build -d}"
HOUR="${SCHEDULE%:*}"
MINUTE="${SCHEDULE#*:}"

if [[ "$(uname -s)" == "Darwin" ]]; then
LABEL="com.sparkdash.self-update"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
LOG_DIR="$REPO/logs"
mkdir -p "$HOME/Library/LaunchAgents" "$LOG_DIR"
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key><array><string>$REPO/scripts/self-update.sh</string></array>
<key>WorkingDirectory</key><string>$REPO</string>
<key>EnvironmentVariables</key><dict>
<key>SPARKDASH_RESTART_CMD</key><string>$RESTART_CMD</string>
</dict>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>$((10#$HOUR))</integer><key>Minute</key><integer>$((10#$MINUTE))</integer></dict>
<key>StandardOutPath</key><string>$LOG_DIR/self-update.log</string>
<key>StandardErrorPath</key><string>$LOG_DIR/self-update-error.log</string>
</dict></plist>
EOF
plutil -lint "$PLIST"
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PLIST"
echo "Installed $LABEL for daily update at $SCHEDULE"
exit 0
fi

if command -v systemctl >/dev/null 2>&1; then
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$UNIT_DIR"
cat > "$UNIT_DIR/sparkdash-self-update.service" <<EOF
[Unit]
Description=Update and redeploy sparkDash
[Service]
Type=oneshot
WorkingDirectory=$REPO
Environment="SPARKDASH_RESTART_CMD=$RESTART_CMD"
ExecStart=$REPO/scripts/self-update.sh
EOF
cat > "$UNIT_DIR/sparkdash-self-update.timer" <<EOF
[Unit]
Description=Run sparkDash updater daily
[Timer]
OnCalendar=*-*-* $SCHEDULE:00
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now sparkdash-self-update.timer
echo "Installed systemd user timer for daily update at $SCHEDULE"
exit 0
fi

echo "Unsupported service manager: expected launchd or systemd" >&2
exit 1
72 changes: 72 additions & 0 deletions scripts/public-auth.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import crypto from "node:crypto";

function b64url(value) {
return Buffer.from(value).toString("base64url");
}

export function createSignedValue(value, secret) {
const payload = b64url(JSON.stringify(value));
const signature = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
return `${payload}.${signature}`;
}

export function readSignedValue(token, secret) {
if (!token || typeof token !== "string") return null;
const [payload, signature, extra] = token.split(".");
if (!payload || !signature || extra) return null;
const expected = crypto.createHmac("sha256", secret).update(payload).digest();
let actual;
try { actual = Buffer.from(signature, "base64url"); } catch { return null; }
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) return null;
try { return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); } catch { return null; }
}

export function normalizeAllowlist(value) {
return new Set(String(value || "").split(/[\s,]+/).map((email) => email.trim().toLowerCase()).filter(Boolean));
}

export function isAllowedEmail(email, allowed) {
return typeof email === "string" && allowed.has(email.trim().toLowerCase());
}

export function safeReturnPath(value) {
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return "/";
return value;
}

export function parseCookies(value) {
const result = {};
for (const pair of String(value || "").split(";")) {
const index = pair.indexOf("=");
if (index < 0) continue;
result[pair.slice(0, index).trim()] = decodeURIComponent(pair.slice(index + 1).trim());
}
return result;
}

export function secureCookie(name, value, maxAge) {
return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`;
}

export function clearCookie(name) {
return `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
}

export function randomToken() {
return crypto.randomBytes(32).toString("base64url");
}

export function sessionFromRequest(req, secret, now = Math.floor(Date.now() / 1000)) {
const token = parseCookies(req.headers.cookie).sparkdash_session;
const session = readSignedValue(token, secret);
return session && session.exp > now && typeof session.email === "string" ? session : null;
}

export function renderLogin(message = "Sign in with an approved Google account to view sparkDash.") {
const escaped = message.replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]);
return `<!doctype html><html><head><meta name="viewport" content="width=device-width"><title>sparkDash sign in</title><style>body{margin:0;background:#090b10;color:#eef2ff;font:16px system-ui;display:grid;place-items:center;min-height:100vh}.card{width:min(420px,85vw);padding:32px;border:1px solid #293044;border-radius:16px;background:#111520}h1{margin:0 0 12px}p{color:#9ba7bd;line-height:1.5}a{display:block;text-align:center;margin-top:24px;padding:12px;border-radius:9px;background:#6d5dfc;color:white;text-decoration:none;font-weight:650}</style></head><body><main class="card"><h1>spark<span style="color:#8b7cff">Dash</span></h1><p>${escaped}</p><a href="/auth/login">Continue with Google</a></main></body></html>`;
}

export function renderDenied(email) {
return renderLogin(`${email || "This Google account"} is not on the approved viewer list.`);
}
111 changes: 111 additions & 0 deletions scripts/public-readonly-proxy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import http from "node:http";
import net from "node:net";
import {
clearCookie,
createSignedValue,
isAllowedEmail,
normalizeAllowlist,
parseCookies,
randomToken,
readSignedValue,
renderDenied,
renderLogin,
safeReturnPath,
secureCookie,
sessionFromRequest,
} from "./public-auth.mjs";

const upstreamHost = process.env.SPARKDASH_UPSTREAM_HOST || "127.0.0.1";
const upstreamPort = Number(process.env.SPARKDASH_UPSTREAM_PORT || 5555);
const listenPort = Number(process.env.SPARKDASH_PUBLIC_PORT || 5556);
const publicOrigin = (process.env.SPARKDASH_PUBLIC_ORIGIN || "").replace(/\/$/, "");
const clientId = process.env.GOOGLE_CLIENT_ID || "";
const clientSecret = process.env.GOOGLE_CLIENT_SECRET || "";
const sessionSecret = process.env.SPARKDASH_SESSION_SECRET || "";
const allowedEmails = normalizeAllowlist(process.env.SPARKDASH_ALLOWED_EMAILS);
const configured = publicOrigin && clientId && clientSecret && sessionSecret.length >= 32 && allowedEmails.size > 0;

function html(res, status, body, headers = {}) {
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...headers });
res.end(body);
}

function redirect(res, location, cookies = []) {
res.writeHead(302, { location, "cache-control": "no-store", ...(cookies.length ? { "set-cookie": cookies } : {}) });
res.end();
}

function proxyHttp(req, res) {
const method = req.method || "GET";
if (method !== "GET" && method !== "HEAD") {
res.writeHead(405, { "content-type": "application/json", allow: "GET, HEAD" });
return res.end(JSON.stringify({ error: "Public dashboard is read-only" }));
}
const proxy = http.request({ host: upstreamHost, port: upstreamPort, method, path: req.url,
headers: { ...req.headers, host: `${upstreamHost}:${upstreamPort}` } }, (upstream) => {
res.writeHead(upstream.statusCode || 502, upstream.headers);
upstream.pipe(res);
});
proxy.on("error", () => { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "Dashboard unavailable" })); });
req.pipe(proxy);
}

async function googleCallback(req, res, url) {
const cookies = parseCookies(req.headers.cookie);
const state = readSignedValue(cookies.sparkdash_oauth, sessionSecret);
if (!state || state.state !== url.searchParams.get("state") || state.exp < Date.now() / 1000) {
return html(res, 400, renderLogin("The sign-in request expired. Please try again."));
}
const code = url.searchParams.get("code");
if (!code) return html(res, 400, renderLogin("Google did not return an authorization code."));
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ code, client_id: clientId, client_secret: clientSecret,
redirect_uri: `${publicOrigin}/auth/callback`, grant_type: "authorization_code" }),
});
if (!tokenResponse.ok) return html(res, 502, renderLogin("Google sign-in failed. Please try again."));
const tokens = await tokenResponse.json();
const infoResponse = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
headers: { authorization: `Bearer ${tokens.access_token}` },
});
if (!infoResponse.ok) return html(res, 502, renderLogin("Google identity verification failed."));
const profile = await infoResponse.json();
if (profile.email_verified !== true || !isAllowedEmail(profile.email, allowedEmails)) {
return html(res, 403, renderDenied(profile.email), { "set-cookie": clearCookie("sparkdash_oauth") });
}
const session = createSignedValue({ email: profile.email.toLowerCase(), exp: Math.floor(Date.now() / 1000) + 28800 }, sessionSecret);
redirect(res, safeReturnPath(state.returnTo), [secureCookie("sparkdash_session", session, 28800), clearCookie("sparkdash_oauth")]);
}

const server = http.createServer(async (req, res) => {
const url = new URL(req.url || "/", publicOrigin || "http://localhost");
if (!configured) return html(res, 503, renderLogin("Google SSO is not configured yet."));
if (url.pathname === "/auth/login") {
const state = randomToken();
const signed = createSignedValue({ state, returnTo: safeReturnPath(url.searchParams.get("returnTo") || req.headers.referer?.replace(publicOrigin, "") || "/"), exp: Math.floor(Date.now() / 1000) + 600 }, sessionSecret);
const auth = new URL("https://accounts.google.com/o/oauth2/v2/auth");
auth.search = new URLSearchParams({ client_id: clientId, redirect_uri: `${publicOrigin}/auth/callback`, response_type: "code", scope: "openid email", state, prompt: "select_account" });
return redirect(res, auth.toString(), [secureCookie("sparkdash_oauth", signed, 600)]);
}
if (url.pathname === "/auth/callback") {
try { return await googleCallback(req, res, url); }
catch { return html(res, 502, renderLogin("Google sign-in could not be completed.")); }
}
if (url.pathname === "/auth/logout") return redirect(res, "/", [clearCookie("sparkdash_session")]);
const session = sessionFromRequest(req, sessionSecret);
if (!session || !isAllowedEmail(session.email, allowedEmails)) return html(res, 401, renderLogin());
proxyHttp(req, res);
});

server.on("upgrade", (req, socket, head) => {
if (!configured || req.url !== "/ws" || !sessionFromRequest(req, sessionSecret)) return socket.destroy();
const upstream = net.connect(upstreamPort, upstreamHost, () => {
let headers = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;
for (const [name, value] of Object.entries(req.headers)) if (name.toLowerCase() !== "host") headers += `${name}: ${value}\r\n`;
headers += `host: ${upstreamHost}:${upstreamPort}\r\n\r\n`;
upstream.write(headers); if (head.length) upstream.write(head); socket.pipe(upstream).pipe(socket);
});
upstream.on("error", () => socket.destroy());
});

server.listen(listenPort, "127.0.0.1", () => console.log(`sparkDash Google SSO read-only proxy on http://127.0.0.1:${listenPort}`));
43 changes: 43 additions & 0 deletions scripts/self-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail

# Timers start with a minimal PATH; include common Homebrew/local locations.
export PATH="${PATH:-}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

REPO="${SPARKDASH_REPO:-$(cd "$(dirname "$0")/.." && pwd)}"
REMOTE="${SPARKDASH_REMOTE:-origin}"
BRANCH="${SPARKDASH_BRANCH:-main}"
RESTART_CMD="${SPARKDASH_RESTART_CMD:-docker compose up --build -d}"

cd "$REPO"

if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
echo "sparkDash update skipped: tracked local changes are present" >&2
exit 2
fi

current_branch="$(git branch --show-current)"
if [[ "$current_branch" != "$BRANCH" ]]; then
echo "sparkDash update skipped: expected branch $BRANCH, found $current_branch" >&2
exit 2
fi

git fetch "$REMOTE" "$BRANCH"
local_head="$(git rev-parse HEAD)"
remote_head="$(git rev-parse "$REMOTE/$BRANCH")"

if [[ "$local_head" == "$remote_head" ]]; then
echo "sparkDash is already current"
exit 0
fi

if ! git merge-base --is-ancestor "$local_head" "$remote_head"; then
echo "sparkDash update skipped: $REMOTE/$BRANCH is not a fast-forward" >&2
exit 2
fi

git merge --ff-only "$remote_head"
npm ci
npm run build
bash -lc "$RESTART_CMD"
echo "sparkDash updated to $remote_head"
Loading