feat: add hosted dashboard configuration and distribution plan

Added a settings modal to the dashboard (index.html) to allow configuration of hashtag, namespace, and relays via URL query parameters and localStorage. This enables the dashboard to be hosted as a static site and used by any project.

Also added `docs/architecture/distribution-plan.md` outlining the strategy for NPM, Binary, and Hosted Dashboard distribution.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-17 03:26:33 +00:00
1 parent 1d8af21365
commit 268248b4eb
5 files changed
+266 -25

No files matched your search

+178 -25
View File
@@ -76,6 +76,13 @@
<div class="flex items-center gap-2 ml-auto">
<span id="eventCount" class="text-xs text-muted mono">0 events</span>
<button
id="settingsBtn"
type="button"
class="rounded-md bg-surface border border-border px-3 py-1.5 text-sm font-medium text-text hover:bg-surface-alt focus:outline-none focus:ring-2 focus:ring-accent transition-colors"
>
Settings
</button>
<button
id="refreshBtn"
type="button"
@@ -164,6 +171,65 @@
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content">
<span class="close-modal" id="closeSettings">&times;</span>
<h2 class="text-xl font-semibold mb-4 text-text-strong">Dashboard Settings</h2>
<form id="settingsForm" class="space-y-4">
<div>
<label for="settingHashtag" class="block text-sm font-medium text-text mb-1">Hashtag (Required)</label>
<input
type="text"
id="settingHashtag"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="e.g. my-project-lock"
required
>
<p class="text-xs text-muted mt-1">Unique identifier for this project's locks.</p>
</div>
<div>
<label for="settingNamespace" class="block text-sm font-medium text-text mb-1">Namespace</label>
<input
type="text"
id="settingNamespace"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="torch"
>
<p class="text-xs text-muted mt-1">Default: torch</p>
</div>
<div>
<label for="settingRelays" class="block text-sm font-medium text-text mb-1">Relays (Comma-separated)</label>
<input
type="text"
id="settingRelays"
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="wss://relay.damus.io, wss://nos.lol"
>
</div>
<div class="pt-4 flex justify-end gap-3">
<button
type="button"
id="cancelSettings"
class="rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium text-text hover:bg-surface-alt"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-strong"
>
Save & Reload
</button>
</div>
</form>
</div>
</div>
<script type="module">
import { KIND_APP_DATA } from '../src/constants.mjs';
import { createElement, escapeHtml } from './domUtils.js';
@@ -171,31 +237,95 @@
// -----------------------------------------------------------------------
// Modal Logic
// -----------------------------------------------------------------------
const modal = document.getElementById("docsModal");
const btn = document.getElementById("openDocs");
const span = document.getElementsByClassName("close-modal")[0];
const docsModal = document.getElementById("docsModal");
const settingsModal = document.getElementById("settingsModal");
const openDocsBtn = document.getElementById("openDocs");
const openSettingsBtn = document.getElementById("settingsBtn");
const closeDocsBtn = document.getElementsByClassName("close-modal")[0];
const closeSettingsBtn = document.getElementById("closeSettings");
const contentDiv = document.getElementById("markdown-content");
let docsLoaded = false;
if (btn) {
btn.onclick = function(e) {
// Docs Modal
if (openDocsBtn) {
openDocsBtn.onclick = function(e) {
e.preventDefault();
modal.classList.add("show");
docsModal.classList.add("show");
if (!docsLoaded) {
fetchDocs();
}
}
}
if (span) {
span.onclick = function() {
modal.classList.remove("show");
if (closeDocsBtn) {
closeDocsBtn.onclick = function() {
docsModal.classList.remove("show");
}
}
// Settings Modal
if (openSettingsBtn) {
openSettingsBtn.onclick = function(e) {
e.preventDefault();
// Pre-fill values
document.getElementById('settingHashtag').value = HASHTAG;
document.getElementById('settingNamespace').value = DASHBOARD_CONFIG.namespace;
document.getElementById('settingRelays').value = RELAYS.join(', ');
settingsModal.classList.add("show");
}
}
if (closeSettingsBtn) {
closeSettingsBtn.onclick = function() {
settingsModal.classList.remove("show");
}
}
document.getElementById('cancelSettings').onclick = function() {
settingsModal.classList.remove("show");
}
// Handle Settings Save
document.getElementById('settingsForm').onsubmit = function(e) {
e.preventDefault();
const newHashtag = document.getElementById('settingHashtag').value.trim();
const newNamespace = document.getElementById('settingNamespace').value.trim();
const newRelays = document.getElementById('settingRelays').value.split(',').map(r => r.trim()).filter(Boolean);
if (!newHashtag) {
alert('Hashtag is required.');
return;
}
const prefs = {
hashtag: newHashtag,
namespace: newNamespace,
relays: newRelays
};
// Save to LocalStorage
localStorage.setItem('torch_dashboard_prefs', JSON.stringify(prefs));
// Update URL to reflect new settings (so link is shareable)
const url = new URL(window.location);
url.searchParams.set('hashtag', newHashtag);
url.searchParams.set('namespace', newNamespace);
url.searchParams.set('relays', newRelays.join(','));
window.history.pushState({}, '', url);
// Reload to apply
window.location.reload();
}
window.onclick = function(event) {
if (event.target == modal) {
modal.classList.remove("show");
if (event.target == docsModal) {
docsModal.classList.remove("show");
}
if (event.target == settingsModal) {
settingsModal.classList.remove("show");
}
}
@@ -263,23 +393,46 @@
function parseDashboardConfig(torchConfig = {}) {
const params = new URLSearchParams(window.location.search);
// 1. LocalStorage Preferences
let localPrefs = {};
try {
localPrefs = JSON.parse(localStorage.getItem('torch_dashboard_prefs')) || {};
} catch {}
const dashboardConfig = torchConfig.dashboard || {};
const lockConfig = torchConfig.nostrLock || {};
const configuredNamespace =
(dashboardConfig.namespace || lockConfig.namespace || DASHBOARD_DEFAULTS.namespace);
const namespace =
(params.get('namespace') || configuredNamespace).trim() || DASHBOARD_DEFAULTS.namespace;
const hashtag =
(params.get('hashtag') || dashboardConfig.hashtag || DASHBOARD_DEFAULTS.hashtag).trim() ||
`${namespace}-agent-lock`;
const relaysParam = (params.get('relays') || '').trim();
const relays = relaysParam
? relaysParam
.split(',')
.map((relay) => relay.trim())
.filter(Boolean)
: [...(dashboardConfig.relays || lockConfig.relays || DASHBOARD_DEFAULTS.relays)];
// Hierarchy: URL Param > LocalStorage > Config File > Default
// Namespace
const configNamespace = dashboardConfig.namespace || lockConfig.namespace || DASHBOARD_DEFAULTS.namespace;
const namespace = (
params.get('namespace') ||
localPrefs.namespace ||
configNamespace
).trim() || DASHBOARD_DEFAULTS.namespace;
// Hashtag
const configHashtag = dashboardConfig.hashtag || DASHBOARD_DEFAULTS.hashtag;
const hashtag = (
params.get('hashtag') ||
localPrefs.hashtag ||
configHashtag ||
`${namespace}-agent-lock`
).trim();
// Relays
const paramsRelays = params.get('relays') ? params.get('relays').split(',') : null;
const localRelays = localPrefs.relays;
const configRelays = dashboardConfig.relays || lockConfig.relays || DASHBOARD_DEFAULTS.relays;
let relays = (paramsRelays || localRelays || configRelays)
.map(r => r.trim())
.filter(Boolean);
if (relays.length === 0) relays = DASHBOARD_DEFAULTS.relays;
const defaultCadenceView = normalizeCadence(dashboardConfig.defaultCadenceView);
const defaultStatusView = normalizeStatus(dashboardConfig.defaultStatusView);
+26
View File
@@ -0,0 +1,26 @@
> [email protected] dashboard:serve
> node bin/torch-lock.mjs dashboard
node:internal/modules/package_json_reader:314
throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);
^
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'nostr-tools' imported from /app/src/lib.mjs
at Object.getPackageJSONURL (node:internal/modules/package_json_reader:314:9)
at packageResolve (node:internal/modules/esm/resolve:767:81)
at moduleResolve (node:internal/modules/esm/resolve:853:18)
at defaultResolve (node:internal/modules/esm/resolve:983:11)
at #cachedDefaultResolve (node:internal/modules/esm/loader:731:20)
at ModuleLoader.resolve (node:internal/modules/esm/loader:708:38)
at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:310:38)
at ModuleJob._link (node:internal/modules/esm/module_job:182:49) {
code: 'ERR_MODULE_NOT_FOUND'
}
Node.js v22.22.0
npm notice
npm notice New minor version of npm available! 11.7.0 -> 11.10.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v11.10.0
npm notice To update run: npm install -g [email protected]
npm notice
+8
View File
@@ -0,0 +1,8 @@
> [email protected] dashboard:serve
> node bin/torch-lock.mjs dashboard
Dashboard running at http://localhost:4173/dashboard/
Serving files from /app
Using configuration from /app
Terminated
+7
View File
@@ -0,0 +1,7 @@
> [email protected] dashboard:serve
> node bin/torch-lock.mjs dashboard
Dashboard running at http://localhost:4173/dashboard/
Serving files from /app
Using configuration from /app
+47
View File
@@ -0,0 +1,47 @@
# TORCH Distribution Strategy: Hybrid "Drop-in"
## Goal
To make TORCH the universal "operating system for AI agents" across all languages and environments, we must remove the dependency on Node.js/NPM for end users while maintaining a robust development experience for contributors.
## The Problem
Currently, TORCH is distributed as a tarball that requires `npm install`. This assumes:
1. The user has Node.js installed.
2. The user is comfortable with `package.json` and `node_modules`.
3. The project is a JavaScript/TypeScript project.
For Python, Go, or Rust developers, this is friction. A "drop-in" tool should work without language-specific prerequisites.
## The Solution: Hybrid Distribution
We will support three primary distribution methods to cover all use cases:
### 1. The Official NPM Package (Current)
**Target Audience:** Node.js / TypeScript developers.
**Experience:** `npm install torch-lock` (or similar).
**Pros:** seamless integration with existing JS toolchains, easy updates via dependabot/renovate.
### 2. Standalone Binary CLI (New)
**Target Audience:** Python, Rust, Go, and non-Node developers.
**Experience:** Download a single binary (`torch`) from GitHub Releases and add it to `$PATH`.
**Implementation:**
- Use Node.js Single Executable Applications (SEA) or `pkg` to compile the JS source into binaries for Linux, macOS, and Windows.
- **Zero Dependencies:** No `npm`, no `node_modules`.
- **Fast:** Instant startup, no install process.
### 3. Hosted Dashboard (New)
**Target Audience:** All users (especially those using the binary).
**Experience:** A static web app hosted at a public URL (e.g., `dashboard.torch.sh` or GitHub Pages).
**Workflow:**
1. User runs `torch lock ...` locally.
2. CLI prints: `View active locks: https://dashboard.torch.sh/?hashtag=my-project-hash`
3. User clicks the link to see the dashboard.
**Features:**
- **Stateless:** The dashboard is a static HTML/JS file.
- **Client-side Logic:** It connects directly to Nostr relays from the browser.
- **URL Configuration:** Configuration (hashtag, namespace, relays) is passed via URL query parameters, making links shareable.
- **Local Persistence:** Settings are saved to `localStorage` for returning visitors.
## Benefits
- **Universal Access:** Removes the Node.js barrier to entry.
- **True "Drop-in":** The binary + hosted dashboard requires *zero* local setup beyond downloading one file.
- **Maintainability:** The core logic remains in JavaScript/Node.js, shared across all three distribution methods.