clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

merge service status into Health with build versions and browser tool readiness, and group the rails

Dami committed 7fccd10
commit
7fccd10734ba4b84eff68ef0b34e2b48fc79d08f
parent
15e81574622cba751c6ddaa36e3adbe1d82d472a
author
Dami <dami.etoile@gmail.com>
13 files changed, +99 -73
MREADME.md2+-
Mdocs/webmcp.md4++--
Minternal/daemon/services.go2+-
Minternal/webui/browse_test.go2+-
Minternal/webui/manage.html31++++++++++++++++++++++--------
Minternal/webui/page.html10+++-------
Minternal/webui/public.html30----------------------------
Minternal/webui/shell_test.go2+-
Minternal/webui/style.css5+++--
Minternal/webui/templates.go28+++++++++++++++++++++++---
Minternal/webui/webmcp.js2+-
Minternal/webui/webmcp_test.go22++++++++++++++++-----
Minternal/webui/webui.go32++++++++++++++++++++----------
M README.md +1 -1
M docs/webmcp.md +2 -2
@@ -1,9 +1,9 @@
11 # Browser tools22 3-Open **Manage > Tools** to see the tools available to your browser agent. The agent can browse repositories, files, issues and pull requests, inspect service status, manage background jobs, create backups, update relay settings, open Nostr links and the Files page, read this device's notification state and, as the owner, send a test notification. Turning notifications on stays a manual step, since the browser asks the person for permission.3+Every page registers tools for your browser agent; **Manage > Health** reports whether they are ready, and Chrome's Application panel lists them with their calls. The agent can browse repositories, files, issues and pull requests, inspect service status, manage background jobs, create backups, update relay settings, open Nostr links and the Files page, read this device's notification state and, as the owner, send a test notification. Turning notifications on stays a manual step, since the browser asks the person for permission.44 55 Choose **Sign in** in the main navigation and connect your Nostr signer. Reading protected information uses your browser session. Management actions request a signature and follow your account permissions. A queued backup or job has not finished until its status says so.66 77 In Chrome, enable **WebMCP for testing** at `chrome://flags/#enable-webmcp-testing` and relaunch. Other browsers can use the same pages and forms. See [Chrome's WebMCP guide](https://developer.chrome.com/docs/ai/webmcp) for availability.88 9-The Tools page lists registered tools and the most recent operation. Read the current policy or connection list before changing settings. Job intervals are measured in hours; zero runs once.9+Read the current policy or connection list before changing settings. Job intervals are measured in hours; zero runs once.
M internal/daemon/services.go +1 -1
M internal/webui/browse_test.go +1 -1
M internal/webui/manage.html +23 -8
@@ -193,20 +193,35 @@
193193 194194 {{define "health"}}<section>195195 <h1>Health</h1>196+<table id="facts">197+<tr><th>relay</th><td><code>{{or .Version "dev"}}</code>{{with .Revision}} <small>{{.}}</small>{{end}}</td></tr>198+<tr><th>scripts</th><td><code>{{scriptsVersion}}</code></td></tr>199+<tr><th>browser tools</th><td><span id="webmcp-status" role="status">Needs JavaScript to report.</span></td></tr>200+</table>201+<details><summary>About browser tools</summary><p>Browser agents use WebMCP tools registered on every page; Chrome's Application panel lists them and their calls. In Chrome, open <code>chrome://flags/#enable-webmcp-testing</code>, enable WebMCP for testing and relaunch. Your usual pages and signed forms work in other browsers.</p></details>202+<script>(() => {203+ const update = () => {204+ const state = window.tinyWebMCP;205+ const node = document.getElementById("webmcp-status");206+ if (!node) return;207+ node.textContent = !state?.supported ? "WebMCP is unavailable in this browser."208+ : state.errors.length ? "Some tools could not register: " + state.errors.join("; ")209+ : "ready, " + state.registered.length + " tools registered" + (state.lastOperation ? ", last: " + state.lastOperation : "");210+ };211+ window.addEventListener("tiny:webmcp", update);212+ update();213+ window.tinyWebMCP?.ready.then(update);214+})();</script>215+<h3>Service</h3>216+{{if .Error}}{{template "alert" .Error}}{{else}}{{template "jsonView" .Event}}{{end}}217+<p><a href="/manage/sync">Jobs and delivery</a> | <a href="/manage/data">Storage and backups</a></p>218+<h3>Reports</h3>196219 <rpc-form method="stats"><button>Relay stats</button></rpc-form>197220 <rpc-form method="listaudit"><label>Before sequence <input name="param" type="number" value="0"></label><button>Audit log</button></rpc-form>198221 <rpc-form method="supportedmethods"><button>Supported methods</button></rpc-form>199222 </section>200223 {{end}}201224 202-{{define "status"}}<section>203-<h1>Service status</h1>204-{{if .Error}}{{template "alert" .Error}}205-{{else}}{{template "jsonView" .Event}}206-<p><a href="/manage/sync">Jobs and delivery</a> | <a href="/manage/data">Storage and backups</a> | <a href="/manage/health">Relay health</a></p>207-{{end}}</section>208-{{end}}209-210225 {{define "owner"}}<section>211226 <h1>Owner operations</h1>212227 <p>Destructive actions ask the signer. The browser never receives or submits a private key.</p>
M internal/webui/page.html +3 -7
@@ -47,13 +47,11 @@
4747 {{else if eq .Tab "sync"}}{{template "sync" .}}4848 {{else if eq .Tab "views"}}{{template "views" .}}4949 {{else if eq .Tab "health"}}{{template "health" .}}50-{{else if eq .Tab "status"}}{{template "status" .}}5150 {{else if eq .Tab "owner"}}{{template "owner" .}}5251 {{else if eq .Tab "repos"}}{{template "repos" .}}5352 {{else if eq .Tab "repo"}}{{template "repo" .}}5453 {{else if eq .Tab "files"}}{{template "files" .}}5554 {{else if eq .Tab "file"}}{{template "file" .}}56-{{else if eq .Tab "tools"}}{{template "tools" .}}5755 {{else if eq .Tab "event"}}{{template "event" .}}5856 {{else if eq .Tab "view"}}{{template "view" .}}5957 {{else if eq .Tab "invite"}}{{template "invite" .}}
@@ -81,13 +79,11 @@
8179 <p>Private relay. Sign in with a Nostr signer to continue.</p>8280 {{else if eq $kind "repo"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/repos" title="{{.Query.Get "owner"}}">{{shortID (npub (.Query.Get "owner"))}}</a>/<b>{{.Query.Get "repo"}}</b></span></header>8381 <nav>{{$v := repoView .Query}}<a href="{{repoURL .Query "home" ""}}"{{if eq $v "home"}} aria-current="page"{{end}}>/home</a><a href="{{repoURL .Query "tree" ""}}"{{if or (eq $v "tree") (eq $v "file")}} aria-current="page"{{end}}>/code</a><a href="{{repoURL .Query "history" ""}}"{{if or (eq $v "history") (eq $v "commit")}} aria-current="page"{{end}}>/history</a><a href="{{repoURL .Query "issues" ""}}"{{if or (eq $v "issues") (eq $v "issue")}} aria-current="page"{{end}}>/issues</a><a href="{{repoURL .Query "prs" ""}}"{{if or (eq $v "prs") (eq $v "pr")}} aria-current="page"{{end}}>/prs</a><a href="{{repoURL .Query "activity" ""}}"{{if eq $v "activity"}} aria-current="page"{{end}}>/activity</a></nav>84-<p>&larr; <a href="/repos">all repositories</a></p>8582 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>86-<nav>{{range $item := manageItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>87-<p>&larr; <a href="/">relay</a></p>83+{{range $group := navGroups manageItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}8884 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>89-<nav>{{range $item := relayItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>90-{{end}}<p id="rail-tools"><button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}85+{{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}86+{{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if eq $kind "manage"}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}9187 9288 {{/* The panel holds facts and filters for the current page. */}}9389 {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}}
M internal/webui/public.html +0 -30
@@ -131,36 +131,6 @@
131131 </section>132132 {{end}}133133 134-{{define "tools"}}<section>135-<h1>Browser tools</h1>136-<p>Use a browser agent to browse repositories and files, check relay health, manage jobs and create backups. Connect your signer to manage the relay.</p>137-<p id="webmcp-status" role="status">Checking browser support...</p>138-<ul id="webmcp-tools"></ul>139-<p id="webmcp-operation" role="status"></p>140-<details>141-<summary>Enable WebMCP</summary>142-<p>In Chrome, open <code>chrome://flags/#enable-webmcp-testing</code>, enable WebMCP for testing and relaunch. Your usual pages and signed forms work in other browsers.</p>143-</details>144-<script>(() => {145- const update = () => {146- const state = window.tinyWebMCP;147- document.getElementById("webmcp-status").textContent = !state?.supported148- ? "WebMCP is unavailable in this browser."149- : state.errors.length ? "Some tools could not register: " + state.errors.join("; ") : state.registered.length + " tools available.";150- document.getElementById("webmcp-tools").replaceChildren(...(state?.registered || []).map(name => {151- const item = document.createElement("li");152- item.textContent = name;153- return item;154- }));155- document.getElementById("webmcp-operation").textContent = state?.lastOperation || "";156- };157- window.addEventListener("tiny:webmcp", update);158- update();159- window.tinyWebMCP?.ready.then(update);160-})();</script>161-</section>162-{{end}}163-164134 {{define "private"}}<section>165135 <h1>Private messages</h1>166136 <p>Sign in with a Nostr client to view addressed private events.</p>
M internal/webui/shell_test.go +1 -1
M internal/webui/style.css +3 -2
@@ -37,11 +37,12 @@ img{max-width:100%}
3737 #who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)}3838 #who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline}3939 #railbox{box-sizing:border-box;padding-bottom:44px}40-#rail-tools{position:absolute;right:0;bottom:0;margin:0;padding:10px 2ch 10px 2.5ch}40+#rail nav+nav{margin-top:14px}41+#rail-tools{position:absolute;left:0;right:0;bottom:0;margin:0;padding:10px 2ch 10px 2.5ch;font:12px var(--mono)}#rail-tools a{float:left;color:var(--muted)}#rail-tools a:hover{color:var(--ink)}#rail-tools button{float:right}4142 /* The install hint sits on the footer's centre line so it never moves the prompt or the session. */4243 #install{position:absolute;left:50%;top:0;transform:translateX(-50%);border:0;background:none;padding:0;color:var(--muted);font:12px var(--mono);line-height:27px;cursor:pointer}#install:hover{color:var(--ink);text-decoration:underline}#install[hidden]{display:none}4344 share-link{display:inline-block}share-link:empty{display:none}44-#theme{border:0;background:none;padding:0;color:var(--muted);font:12px var(--mono)}#theme:hover{color:var(--ink);text-decoration:underline}45+#theme{border:0;background:none;padding:0;color:var(--muted);font:inherit}#theme:hover{color:var(--ink);text-decoration:underline}4546 4647 /* Rail */4748 #rail header{height:60px;box-sizing:border-box;border-bottom:1px solid var(--line);font:13px var(--mono);display:table;width:100%}
M internal/webui/templates.go +25 -3
@@ -93,11 +93,32 @@ var iconSVG []byte
9393 var iconMonoSVG []byte9494 9595 // navItem is one rail entry. Tab matches PageData.Tab for the active state.96-type navItem struct{ Label, Href, Tab string }96+// navItem is one rail entry. Group orders entries by what they are for; the97+// rail leaves a small gap between groups.98+type navItem struct {99+ Label, Href, Tab string100+ Group int101+}102+103+// relayNav: entry points, then conversation, then collaborative artifacts,104+// then what the relay publishes and stores, then what is yours.105+var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/inbox", "/inbox", "inbox", 1}, {"/repos", "/repos", "repos", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}97106 98-var relayNav = []navItem{{"/home", "/", "home"}, {"/search", "/search", "search"}, {"/repos", "/repos", "repos"}, {"/files", "/files", "files"}, {"/sites", "/sites", "sites"}, {"/articles", "/articles", "articles"}, {"/inbox", "/inbox", "inbox"}, {"/outbox", "/outbox", "outbox"}, {"/manage", "/manage/people", "manage"}}107+// manageNav: who is here, what they may do, what the relay is, what it does108+// over time, and how it is doing.109+var manageNav = []navItem{{"/people", "/manage/people", "people", 0}, {"/moderation", "/manage/moderation", "moderation", 1}, {"/rules", "/manage/rules", "rules", 1}, {"/identity", "/manage/identity", "identity", 2}, {"/connect", "/manage/connect", "connect", 2}, {"/owner", "/manage/owner", "owner", 2}, {"/sync", "/manage/sync", "sync", 3}, {"/data", "/manage/data", "data", 3}, {"/views", "/manage/views", "views", 3}, {"/health", "/manage/health", "health", 4}}99110 100-var manageNav = []navItem{{"/people", "/manage/people", "people"}, {"/moderation", "/manage/moderation", "moderation"}, {"/rules", "/manage/rules", "rules"}, {"/identity", "/manage/identity", "identity"}, {"/connect", "/manage/connect", "connect"}, {"/data", "/manage/data", "data"}, {"/sync", "/manage/sync", "sync"}, {"/views", "/manage/views", "views"}, {"/health", "/manage/health", "health"}, {"/owner", "/manage/owner", "owner"}, {"/status", "/manage/status", "status"}, {"/tools", "/tools", "tools"}}111+// navGroups splits a rail list into its groups, in order.112+func navGroups(items []navItem) [][]navItem {113+ var groups [][]navItem114+ for _, item := range items {115+ if len(groups) == 0 || groups[len(groups)-1][0].Group != item.Group {116+ groups = append(groups, nil)117+ }118+ groups[len(groups)-1] = append(groups[len(groups)-1], item)119+ }120+ return groups121+}101122 102123 // railKind picks the sitemap for a tab: one repository, management, or the relay.103124 func railKind(tab string) string {
@@ -207,6 +228,7 @@ func parseTemplates() (*template.Template, error) {
207228 "repoView": repoView,208229 "relayItems": func() []navItem { return relayNav },209230 "manageItems": func() []navItem { return manageNav },231+ "navGroups": navGroups,210232 "add": func(a, b int) int { return a + b },211233 "strs": func(values ...string) []string { return values },212234 "repoCommitURL": repoCommitURL,
M internal/webui/webmcp.js +1 -1
M internal/webui/webmcp_test.go +17 -5
@@ -3,6 +3,7 @@ package webui
33 import (44 "context"55 "encoding/json"6+ "github.com/FelineStateMachine/tinyrelay/internal/policy"67 "net/http"78 "net/http/httptest"89 "net/url"
@@ -98,14 +99,25 @@ func TestPagesAdvertiseWebMCPScript(t *testing.T) {
9899 }99100 }100101 101-func TestToolsPageExplainsNativeFallback(t *testing.T) {102- app, err := New(&fakeBackend{}, Options{})102+func TestHealthPageReportsVersionsAndBrowserTools(t *testing.T) {103+ backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}104+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }, Version: "1.4.0", Revision: "abc1234"})103105 if err != nil {104106 t.Fatal(err)105107 }106108 recorder := httptest.NewRecorder()107- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/tools", nil))108- if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "WebMCP is unavailable") || !strings.Contains(recorder.Body.String(), "signed forms") {109- t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())109+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/health", nil))110+ body := recorder.Body.String()111+ for _, want := range []string{"<code>1.4.0</code>", "abc1234", "<code>" + scriptsVersion + "</code>", `id="webmcp-status"`, "WebMCP is unavailable", "signed forms"} {112+ if recorder.Code != http.StatusOK || !strings.Contains(body, want) {113+ t.Fatalf("health page missing %q: status=%d", want, recorder.Code)114+ }115+ }116+ for _, old := range []string{"/tools", "/manage/status"} {117+ moved := httptest.NewRecorder()118+ app.ServeHTTP(moved, httptest.NewRequest(http.MethodGet, old, nil))119+ if moved.Code != http.StatusMovedPermanently || moved.Header().Get("Location") != "/manage/health" {120+ t.Fatalf("%s: status=%d location=%q", old, moved.Code, moved.Header().Get("Location"))121+ }110122 }111123 }
M internal/webui/webui.go +21 -11
@@ -46,18 +46,24 @@ type ActorResolver func(*http.Request) (string, error)
4646 type QRProvider func(string) ([]byte, error)4747 4848 type App struct {49- backend Backend50- actor ActorResolver51- qr QRProvider52- tmpl *template.Template49+ backend Backend50+ actor ActorResolver51+ qr QRProvider52+ tmpl *template.Template53+ version string54+ revision string5355 }5456 5557 type Options struct {5658 Actor ActorResolver5759 QR QRProvider60+ // Version and Revision identify the relay build on the health page.61+ Version, Revision string5862 }5963 6064 type PageData struct {65+ Version string66+ Revision string6167 Title string6268 URL string6369 Slug string
@@ -96,7 +102,7 @@ func New(backend Backend, options Options) (*App, error) {
96102 if qr == nil {97103 qr = defaultQR98104 }99- return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl}, nil105+ return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl, version: options.Version, revision: options.Revision}, nil100106 }101107 102108 func defaultQR(text string) ([]byte, error) {
@@ -147,7 +153,12 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
147153 a.webMCPQuery(writer, request)148154 return149155 }150- if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/manage/status") {156+ if request.URL.Path == "/manage/status" || request.URL.Path == "/tools" {157+ // Service status and the browser tools report both live on Health now.158+ http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)159+ return160+ }161+ if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/manage/health") {151162 a.browse(writer, request)152163 return153164 }
@@ -511,7 +522,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
511522 method = "browsefiles"512523 case "/file":513524 method = "browsefile"514- case "/manage/status":525+ case "/manage/health":515526 method = "browsestatus"516527 }517528 params := []json.RawMessage{}
@@ -648,8 +659,6 @@ func browseTab(path string) string {
648659 return "files"649660 case "/file":650661 return "file"651- case "/manage/status":652- return "status"653662 default:654663 return "health"655664 }
@@ -686,13 +695,14 @@ func (a *App) render(writer http.ResponseWriter, request *http.Request, data Pag
686695 }687696 data.URL, data.Slug, data.Identity, data.Policy, data.Actor, data.Base = a.backend.URL(), a.backend.Slug(), a.backend.Identity(), a.backend.Policy(), actor, requestPrefix(request)688697 data.Owner = actor != "" && actor == data.Policy.Owner698+ data.Version, data.Revision = a.version, a.revision689699 data.Methods = supportedMethods690700 if data.Query == nil {691701 data.Query = request.URL.Query()692702 }693703 data.Path = strings.TrimPrefix(request.URL.Path, data.Base)694704 a.privatePageData(&data, request, actor)695- if !data.Private && data.Actor == "" && railKind(data.Tab) == "manage" && data.Tab != "tools" {705+ if !data.Private && data.Actor == "" && railKind(data.Tab) == "manage" {696706 // Management pages are for signed-in people only. Guests see the697707 // sign-in page at the same address and come back after signing in.698708 data.Tab, data.Notice = "signin", "Sign in to manage this relay."
@@ -924,7 +934,7 @@ func tabForPath(path string) string {
924934 return "files"925935 case "file":926936 return "file"927- case "tools", "signin", "sites":937+ case "signin", "sites":928938 return path929939 case "people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":930940 return path