- commit
- 7fccd10734ba4b84eff68ef0b34e2b48fc79d08f
- parent
- 15e81574622cba751c6ddaa36e3adbe1d82d472a
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 2 | +- |
| M | docs/ | 4 | ++-- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 31 | ++++++++++++++++++++++-------- |
| M | internal/ | 10 | +++------- |
| M | internal/ | 30 | ---------------------------- |
| M | internal/ | 2 | +- |
| M | internal/ | 5 | +++-- |
| M | internal/ | 28 | +++++++++++++++++++++++--- |
| M | internal/ | 2 | +- |
| M | internal/ | 22 | ++++++++++++++++----- |
| M | internal/ | 32 | ++++++++++++++++++++---------- |
@@ -51,7 +51,7 @@ See [Git collaboration](docs/git-collaboration.md) for issues, pull requests, co
5151 5252 See [Installed app](docs/app.md) for installing the relay as an app, sharing files to it, opening Nostr links and device notifications.5353 54-The `/tools` page reports whether the browser supports native WebMCP controls. See [WebMCP tools](docs/webmcp.md) for browser-agent integration and authorization behavior.54+**Manage > Health** shows the relay and script versions and whether the browser's WebMCP tools are registered. See [WebMCP tools](docs/webmcp.md) for browser-agent integration and authorization behavior.5555 5656 ## Deployment5757 @@ -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.@@ -123,7 +123,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
123123 if err != nil {124124 return err125125 }126- t.ui, err = webui.New(backend{tenant: t}, webui.Options{Actor: t.resolveUIActor})126+ t.ui, err = webui.New(backend{tenant: t}, webui.Options{Actor: t.resolveUIActor, Version: t.app.cfg.Version, Revision: t.app.cfg.Revision})127127 if err != nil {128128 return err129129 }@@ -49,7 +49,7 @@ func TestBrowseRoutesUseObjectContractsAndRenderData(t *testing.T) {
4949 {"/repo?owner=alice&repo=notes&view=file", "browserepo", "one"},5050 {"/files", "browsefiles", "deadbeef"},5151 {"/file?hash=deadbeef", "browsefile", "blob"},52- {"/manage/status", "browsestatus", "manage this relay"},52+ {"/manage/health", "browsestatus", "manage this relay"},5353 }5454 for _, test := range tests {5555 recorder := httptest.NewRecorder()@@ -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>@@ -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>← <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>← <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">← all repositories</a>{{else if eq $kind "manage"}}<a href="/">← 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" .}}@@ -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>@@ -299,7 +299,7 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
299299 if err != nil {300300 t.Fatal(err)301301 }302- for _, path := range []string{"/manage/people", "/manage/owner", "/manage/status", "/manage/data"} {302+ for _, path := range []string{"/manage/people", "/manage/owner", "/manage/health", "/manage/data"} {303303 recorder := httptest.NewRecorder()304304 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))305305 body := recorder.Body.String()@@ -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%}@@ -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,@@ -128,7 +128,7 @@
128128 object({...repository, id: hash, view: {type: "string", enum: ["tree", "file", "history", "commit", "activity", "issues", "prs", "issue", "pr"]}}, ["owner", "repo"]), {}, input => open(repoURL(input)));129129 register("tiny.open_file", "Open a stored file by its SHA-256 hash in this tab. For repository source, use tiny.open_repository with view=file.",130130 object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash)));131- register("tiny.open_status", "Open the relay status page in this tab.", object(), {}, () => open("/manage/status"));131+ register("tiny.open_status", "Open the relay health page in this tab: build versions, service status and browser tool readiness.", object(), {}, () => open("/manage/health"));132132 register("tiny.open_files", "Open the Files page in this tab, where files and folders upload and shared items wait.", object(), {}, () => open("/files"));133133 register("tiny.open_link", "Open a nostr link in this tab: an npub, nprofile, note, nevent, naddr or 64-character event id, with or without a nostr: or web+nostr: prefix.",134134 object({target: {type: "string", minLength: 1, maxLength: 512}}, ["target"]), {}, input => open("/open?target=" + encodeURIComponent(input.target)));@@ -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 }@@ -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