| 1 | diff --git a/README.md b/README.md |
| 2 | index 7b3b2ee..7a1e6b7 100644 |
| 3 | --- a/README.md |
| 4 | +++ b/README.md |
| 5 | @@ -51,7 +51,7 @@ See [Git collaboration](docs/git-collaboration.md) for issues, pull requests, co |
| 6 | |
| 7 | See [Installed app](docs/app.md) for installing the relay as an app, sharing files to it, opening Nostr links and device notifications. |
| 8 | |
| 9 | -The `/tools` page reports whether the browser supports native WebMCP controls. See [WebMCP tools](docs/webmcp.md) for browser-agent integration and authorization behavior. |
| 10 | +**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. |
| 11 | |
| 12 | ## Deployment |
| 13 | |
| 14 | diff --git a/docs/webmcp.md b/docs/webmcp.md |
| 15 | index f20b3f9..5b5fe0d 100644 |
| 16 | --- a/docs/webmcp.md |
| 17 | +++ b/docs/webmcp.md |
| 18 | @@ -1,9 +1,9 @@ |
| 19 | # Browser tools |
| 20 | |
| 21 | -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. |
| 22 | +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. |
| 23 | |
| 24 | 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. |
| 25 | |
| 26 | 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. |
| 27 | |
| 28 | -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. |
| 29 | +Read the current policy or connection list before changing settings. Job intervals are measured in hours; zero runs once. |
| 30 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 31 | index dd44e54..c47c10f 100644 |
| 32 | --- a/internal/daemon/services.go |
| 33 | +++ b/internal/daemon/services.go |
| 34 | @@ -123,7 +123,7 @@ func (t *Tenant) initServices(ctx context.Context) error { |
| 35 | if err != nil { |
| 36 | return err |
| 37 | } |
| 38 | - t.ui, err = webui.New(backend{tenant: t}, webui.Options{Actor: t.resolveUIActor}) |
| 39 | + t.ui, err = webui.New(backend{tenant: t}, webui.Options{Actor: t.resolveUIActor, Version: t.app.cfg.Version, Revision: t.app.cfg.Revision}) |
| 40 | if err != nil { |
| 41 | return err |
| 42 | } |
| 43 | diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go |
| 44 | index 379e64a..f45446e 100644 |
| 45 | --- a/internal/webui/browse_test.go |
| 46 | +++ b/internal/webui/browse_test.go |
| 47 | @@ -49,7 +49,7 @@ func TestBrowseRoutesUseObjectContractsAndRenderData(t *testing.T) { |
| 48 | {"/repo?owner=alice&repo=notes&view=file", "browserepo", "one"}, |
| 49 | {"/files", "browsefiles", "deadbeef"}, |
| 50 | {"/file?hash=deadbeef", "browsefile", "blob"}, |
| 51 | - {"/manage/status", "browsestatus", "manage this relay"}, |
| 52 | + {"/manage/health", "browsestatus", "manage this relay"}, |
| 53 | } |
| 54 | for _, test := range tests { |
| 55 | recorder := httptest.NewRecorder() |
| 56 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 57 | index d9ddef3..80ba1d8 100644 |
| 58 | --- a/internal/webui/manage.html |
| 59 | +++ b/internal/webui/manage.html |
| 60 | @@ -193,20 +193,35 @@ |
| 61 | |
| 62 | {{define "health"}}<section> |
| 63 | <h1>Health</h1> |
| 64 | +<table id="facts"> |
| 65 | +<tr><th>relay</th><td><code>{{or .Version "dev"}}</code>{{with .Revision}} <small>{{.}}</small>{{end}}</td></tr> |
| 66 | +<tr><th>scripts</th><td><code>{{scriptsVersion}}</code></td></tr> |
| 67 | +<tr><th>browser tools</th><td><span id="webmcp-status" role="status">Needs JavaScript to report.</span></td></tr> |
| 68 | +</table> |
| 69 | +<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> |
| 70 | +<script>(() => { |
| 71 | + const update = () => { |
| 72 | + const state = window.tinyWebMCP; |
| 73 | + const node = document.getElementById("webmcp-status"); |
| 74 | + if (!node) return; |
| 75 | + node.textContent = !state?.supported ? "WebMCP is unavailable in this browser." |
| 76 | + : state.errors.length ? "Some tools could not register: " + state.errors.join("; ") |
| 77 | + : "ready, " + state.registered.length + " tools registered" + (state.lastOperation ? ", last: " + state.lastOperation : ""); |
| 78 | + }; |
| 79 | + window.addEventListener("tiny:webmcp", update); |
| 80 | + update(); |
| 81 | + window.tinyWebMCP?.ready.then(update); |
| 82 | +})();</script> |
| 83 | +<h3>Service</h3> |
| 84 | +{{if .Error}}{{template "alert" .Error}}{{else}}{{template "jsonView" .Event}}{{end}} |
| 85 | +<p><a href="/manage/sync">Jobs and delivery</a> | <a href="/manage/data">Storage and backups</a></p> |
| 86 | +<h3>Reports</h3> |
| 87 | <rpc-form method="stats"><button>Relay stats</button></rpc-form> |
| 88 | <rpc-form method="listaudit"><label>Before sequence <input name="param" type="number" value="0"></label><button>Audit log</button></rpc-form> |
| 89 | <rpc-form method="supportedmethods"><button>Supported methods</button></rpc-form> |
| 90 | </section> |
| 91 | {{end}} |
| 92 | |
| 93 | -{{define "status"}}<section> |
| 94 | -<h1>Service status</h1> |
| 95 | -{{if .Error}}{{template "alert" .Error}} |
| 96 | -{{else}}{{template "jsonView" .Event}} |
| 97 | -<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> |
| 98 | -{{end}}</section> |
| 99 | -{{end}} |
| 100 | - |
| 101 | {{define "owner"}}<section> |
| 102 | <h1>Owner operations</h1> |
| 103 | <p>Destructive actions ask the signer. The browser never receives or submits a private key.</p> |
| 104 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 105 | index 4788e82..6945843 100644 |
| 106 | --- a/internal/webui/page.html |
| 107 | +++ b/internal/webui/page.html |
| 108 | @@ -47,13 +47,11 @@ |
| 109 | {{else if eq .Tab "sync"}}{{template "sync" .}} |
| 110 | {{else if eq .Tab "views"}}{{template "views" .}} |
| 111 | {{else if eq .Tab "health"}}{{template "health" .}} |
| 112 | -{{else if eq .Tab "status"}}{{template "status" .}} |
| 113 | {{else if eq .Tab "owner"}}{{template "owner" .}} |
| 114 | {{else if eq .Tab "repos"}}{{template "repos" .}} |
| 115 | {{else if eq .Tab "repo"}}{{template "repo" .}} |
| 116 | {{else if eq .Tab "files"}}{{template "files" .}} |
| 117 | {{else if eq .Tab "file"}}{{template "file" .}} |
| 118 | -{{else if eq .Tab "tools"}}{{template "tools" .}} |
| 119 | {{else if eq .Tab "event"}}{{template "event" .}} |
| 120 | {{else if eq .Tab "view"}}{{template "view" .}} |
| 121 | {{else if eq .Tab "invite"}}{{template "invite" .}} |
| 122 | @@ -81,13 +79,11 @@ |
| 123 | <p>Private relay. Sign in with a Nostr signer to continue.</p> |
| 124 | {{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> |
| 125 | <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> |
| 126 | -<p>← <a href="/repos">all repositories</a></p> |
| 127 | {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header> |
| 128 | -<nav>{{range $item := manageItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav> |
| 129 | -<p>← <a href="/">relay</a></p> |
| 130 | +{{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}} |
| 131 | {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header> |
| 132 | -<nav>{{range $item := relayItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav> |
| 133 | -{{end}}<p id="rail-tools"><button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}} |
| 134 | +{{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}} |
| 135 | +{{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}} |
| 136 | |
| 137 | {{/* The panel holds facts and filters for the current page. */}} |
| 138 | {{define "panel"}}{{$kind := railKind .Tab}}{{if .Private}}{{template "panel-signin" .}} |
| 139 | diff --git a/internal/webui/public.html b/internal/webui/public.html |
| 140 | index 225ae4a..9809789 100644 |
| 141 | --- a/internal/webui/public.html |
| 142 | +++ b/internal/webui/public.html |
| 143 | @@ -131,36 +131,6 @@ |
| 144 | </section> |
| 145 | {{end}} |
| 146 | |
| 147 | -{{define "tools"}}<section> |
| 148 | -<h1>Browser tools</h1> |
| 149 | -<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> |
| 150 | -<p id="webmcp-status" role="status">Checking browser support...</p> |
| 151 | -<ul id="webmcp-tools"></ul> |
| 152 | -<p id="webmcp-operation" role="status"></p> |
| 153 | -<details> |
| 154 | -<summary>Enable WebMCP</summary> |
| 155 | -<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> |
| 156 | -</details> |
| 157 | -<script>(() => { |
| 158 | - const update = () => { |
| 159 | - const state = window.tinyWebMCP; |
| 160 | - document.getElementById("webmcp-status").textContent = !state?.supported |
| 161 | - ? "WebMCP is unavailable in this browser." |
| 162 | - : state.errors.length ? "Some tools could not register: " + state.errors.join("; ") : state.registered.length + " tools available."; |
| 163 | - document.getElementById("webmcp-tools").replaceChildren(...(state?.registered || []).map(name => { |
| 164 | - const item = document.createElement("li"); |
| 165 | - item.textContent = name; |
| 166 | - return item; |
| 167 | - })); |
| 168 | - document.getElementById("webmcp-operation").textContent = state?.lastOperation || ""; |
| 169 | - }; |
| 170 | - window.addEventListener("tiny:webmcp", update); |
| 171 | - update(); |
| 172 | - window.tinyWebMCP?.ready.then(update); |
| 173 | -})();</script> |
| 174 | -</section> |
| 175 | -{{end}} |
| 176 | - |
| 177 | {{define "private"}}<section> |
| 178 | <h1>Private messages</h1> |
| 179 | <p>Sign in with a Nostr client to view addressed private events.</p> |
| 180 | diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go |
| 181 | index eaf2116..f2ce18a 100644 |
| 182 | --- a/internal/webui/shell_test.go |
| 183 | +++ b/internal/webui/shell_test.go |
| 184 | @@ -299,7 +299,7 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) { |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | - for _, path := range []string{"/manage/people", "/manage/owner", "/manage/status", "/manage/data"} { |
| 189 | + for _, path := range []string{"/manage/people", "/manage/owner", "/manage/health", "/manage/data"} { |
| 190 | recorder := httptest.NewRecorder() |
| 191 | app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) |
| 192 | body := recorder.Body.String() |
| 193 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 194 | index 45a481b..f6ef063 100644 |
| 195 | --- a/internal/webui/style.css |
| 196 | +++ b/internal/webui/style.css |
| 197 | @@ -37,11 +37,12 @@ img{max-width:100%} |
| 198 | #who{color:var(--muted)}#who b,#who nostr-key{color:var(--ink)} |
| 199 | #who button{border:0;background:none;padding:0;color:var(--accent);font:inherit}#who button:hover{text-decoration:underline} |
| 200 | #railbox{box-sizing:border-box;padding-bottom:44px} |
| 201 | -#rail-tools{position:absolute;right:0;bottom:0;margin:0;padding:10px 2ch 10px 2.5ch} |
| 202 | +#rail nav+nav{margin-top:14px} |
| 203 | +#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} |
| 204 | /* The install hint sits on the footer's centre line so it never moves the prompt or the session. */ |
| 205 | #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} |
| 206 | share-link{display:inline-block}share-link:empty{display:none} |
| 207 | -#theme{border:0;background:none;padding:0;color:var(--muted);font:12px var(--mono)}#theme:hover{color:var(--ink);text-decoration:underline} |
| 208 | +#theme{border:0;background:none;padding:0;color:var(--muted);font:inherit}#theme:hover{color:var(--ink);text-decoration:underline} |
| 209 | |
| 210 | /* Rail */ |
| 211 | #rail header{height:60px;box-sizing:border-box;border-bottom:1px solid var(--line);font:13px var(--mono);display:table;width:100%} |
| 212 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 213 | index e07e6cb..fd6dee0 100644 |
| 214 | --- a/internal/webui/templates.go |
| 215 | +++ b/internal/webui/templates.go |
| 216 | @@ -93,11 +93,32 @@ var iconSVG []byte |
| 217 | var iconMonoSVG []byte |
| 218 | |
| 219 | // navItem is one rail entry. Tab matches PageData.Tab for the active state. |
| 220 | -type navItem struct{ Label, Href, Tab string } |
| 221 | +// navItem is one rail entry. Group orders entries by what they are for; the |
| 222 | +// rail leaves a small gap between groups. |
| 223 | +type navItem struct { |
| 224 | + Label, Href, Tab string |
| 225 | + Group int |
| 226 | +} |
| 227 | + |
| 228 | +// relayNav: entry points, then conversation, then collaborative artifacts, |
| 229 | +// then what the relay publishes and stores, then what is yours. |
| 230 | +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}} |
| 231 | |
| 232 | -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"}} |
| 233 | +// manageNav: who is here, what they may do, what the relay is, what it does |
| 234 | +// over time, and how it is doing. |
| 235 | +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}} |
| 236 | |
| 237 | -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"}} |
| 238 | +// navGroups splits a rail list into its groups, in order. |
| 239 | +func navGroups(items []navItem) [][]navItem { |
| 240 | + var groups [][]navItem |
| 241 | + for _, item := range items { |
| 242 | + if len(groups) == 0 || groups[len(groups)-1][0].Group != item.Group { |
| 243 | + groups = append(groups, nil) |
| 244 | + } |
| 245 | + groups[len(groups)-1] = append(groups[len(groups)-1], item) |
| 246 | + } |
| 247 | + return groups |
| 248 | +} |
| 249 | |
| 250 | // railKind picks the sitemap for a tab: one repository, management, or the relay. |
| 251 | func railKind(tab string) string { |
| 252 | @@ -207,6 +228,7 @@ func parseTemplates() (*template.Template, error) { |
| 253 | "repoView": repoView, |
| 254 | "relayItems": func() []navItem { return relayNav }, |
| 255 | "manageItems": func() []navItem { return manageNav }, |
| 256 | + "navGroups": navGroups, |
| 257 | "add": func(a, b int) int { return a + b }, |
| 258 | "strs": func(values ...string) []string { return values }, |
| 259 | "repoCommitURL": repoCommitURL, |
| 260 | diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js |
| 261 | index e524569..1f3da64 100644 |
| 262 | --- a/internal/webui/webmcp.js |
| 263 | +++ b/internal/webui/webmcp.js |
| 264 | @@ -128,7 +128,7 @@ |
| 265 | object({...repository, id: hash, view: {type: "string", enum: ["tree", "file", "history", "commit", "activity", "issues", "prs", "issue", "pr"]}}, ["owner", "repo"]), {}, input => open(repoURL(input))); |
| 266 | 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.", |
| 267 | object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash))); |
| 268 | - register("tiny.open_status", "Open the relay status page in this tab.", object(), {}, () => open("/manage/status")); |
| 269 | + register("tiny.open_status", "Open the relay health page in this tab: build versions, service status and browser tool readiness.", object(), {}, () => open("/manage/health")); |
| 270 | register("tiny.open_files", "Open the Files page in this tab, where files and folders upload and shared items wait.", object(), {}, () => open("/files")); |
| 271 | 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.", |
| 272 | object({target: {type: "string", minLength: 1, maxLength: 512}}, ["target"]), {}, input => open("/open?target=" + encodeURIComponent(input.target))); |
| 273 | diff --git a/internal/webui/webmcp_test.go b/internal/webui/webmcp_test.go |
| 274 | index ad2c5f1..5220f10 100644 |
| 275 | --- a/internal/webui/webmcp_test.go |
| 276 | +++ b/internal/webui/webmcp_test.go |
| 277 | @@ -3,6 +3,7 @@ package webui |
| 278 | import ( |
| 279 | "context" |
| 280 | "encoding/json" |
| 281 | + "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 282 | "net/http" |
| 283 | "net/http/httptest" |
| 284 | "net/url" |
| 285 | @@ -98,14 +99,25 @@ func TestPagesAdvertiseWebMCPScript(t *testing.T) { |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | -func TestToolsPageExplainsNativeFallback(t *testing.T) { |
| 290 | - app, err := New(&fakeBackend{}, Options{}) |
| 291 | +func TestHealthPageReportsVersionsAndBrowserTools(t *testing.T) { |
| 292 | + backend := &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))} |
| 293 | + app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }, Version: "1.4.0", Revision: "abc1234"}) |
| 294 | if err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | recorder := httptest.NewRecorder() |
| 298 | - app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/tools", nil)) |
| 299 | - if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "WebMCP is unavailable") || !strings.Contains(recorder.Body.String(), "signed forms") { |
| 300 | - t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) |
| 301 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/manage/health", nil)) |
| 302 | + body := recorder.Body.String() |
| 303 | + for _, want := range []string{"<code>1.4.0</code>", "abc1234", "<code>" + scriptsVersion + "</code>", `id="webmcp-status"`, "WebMCP is unavailable", "signed forms"} { |
| 304 | + if recorder.Code != http.StatusOK || !strings.Contains(body, want) { |
| 305 | + t.Fatalf("health page missing %q: status=%d", want, recorder.Code) |
| 306 | + } |
| 307 | + } |
| 308 | + for _, old := range []string{"/tools", "/manage/status"} { |
| 309 | + moved := httptest.NewRecorder() |
| 310 | + app.ServeHTTP(moved, httptest.NewRequest(http.MethodGet, old, nil)) |
| 311 | + if moved.Code != http.StatusMovedPermanently || moved.Header().Get("Location") != "/manage/health" { |
| 312 | + t.Fatalf("%s: status=%d location=%q", old, moved.Code, moved.Header().Get("Location")) |
| 313 | + } |
| 314 | } |
| 315 | } |
| 316 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 317 | index ac7cc56..458120d 100644 |
| 318 | --- a/internal/webui/webui.go |
| 319 | +++ b/internal/webui/webui.go |
| 320 | @@ -46,18 +46,24 @@ type ActorResolver func(*http.Request) (string, error) |
| 321 | type QRProvider func(string) ([]byte, error) |
| 322 | |
| 323 | type App struct { |
| 324 | - backend Backend |
| 325 | - actor ActorResolver |
| 326 | - qr QRProvider |
| 327 | - tmpl *template.Template |
| 328 | + backend Backend |
| 329 | + actor ActorResolver |
| 330 | + qr QRProvider |
| 331 | + tmpl *template.Template |
| 332 | + version string |
| 333 | + revision string |
| 334 | } |
| 335 | |
| 336 | type Options struct { |
| 337 | Actor ActorResolver |
| 338 | QR QRProvider |
| 339 | + // Version and Revision identify the relay build on the health page. |
| 340 | + Version, Revision string |
| 341 | } |
| 342 | |
| 343 | type PageData struct { |
| 344 | + Version string |
| 345 | + Revision string |
| 346 | Title string |
| 347 | URL string |
| 348 | Slug string |
| 349 | @@ -96,7 +102,7 @@ func New(backend Backend, options Options) (*App, error) { |
| 350 | if qr == nil { |
| 351 | qr = defaultQR |
| 352 | } |
| 353 | - return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl}, nil |
| 354 | + return &App{backend: backend, actor: options.Actor, qr: qr, tmpl: tmpl, version: options.Version, revision: options.Revision}, nil |
| 355 | } |
| 356 | |
| 357 | func defaultQR(text string) ([]byte, error) { |
| 358 | @@ -147,7 +153,12 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { |
| 359 | a.webMCPQuery(writer, request) |
| 360 | return |
| 361 | } |
| 362 | - 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") { |
| 363 | + if request.URL.Path == "/manage/status" || request.URL.Path == "/tools" { |
| 364 | + // Service status and the browser tools report both live on Health now. |
| 365 | + http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently) |
| 366 | + return |
| 367 | + } |
| 368 | + 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") { |
| 369 | a.browse(writer, request) |
| 370 | return |
| 371 | } |
| 372 | @@ -511,7 +522,7 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) { |
| 373 | method = "browsefiles" |
| 374 | case "/file": |
| 375 | method = "browsefile" |
| 376 | - case "/manage/status": |
| 377 | + case "/manage/health": |
| 378 | method = "browsestatus" |
| 379 | } |
| 380 | params := []json.RawMessage{} |
| 381 | @@ -648,8 +659,6 @@ func browseTab(path string) string { |
| 382 | return "files" |
| 383 | case "/file": |
| 384 | return "file" |
| 385 | - case "/manage/status": |
| 386 | - return "status" |
| 387 | default: |
| 388 | return "health" |
| 389 | } |
| 390 | @@ -686,13 +695,14 @@ func (a *App) render(writer http.ResponseWriter, request *http.Request, data Pag |
| 391 | } |
| 392 | 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) |
| 393 | data.Owner = actor != "" && actor == data.Policy.Owner |
| 394 | + data.Version, data.Revision = a.version, a.revision |
| 395 | data.Methods = supportedMethods |
| 396 | if data.Query == nil { |
| 397 | data.Query = request.URL.Query() |
| 398 | } |
| 399 | data.Path = strings.TrimPrefix(request.URL.Path, data.Base) |
| 400 | a.privatePageData(&data, request, actor) |
| 401 | - if !data.Private && data.Actor == "" && railKind(data.Tab) == "manage" && data.Tab != "tools" { |
| 402 | + if !data.Private && data.Actor == "" && railKind(data.Tab) == "manage" { |
| 403 | // Management pages are for signed-in people only. Guests see the |
| 404 | // sign-in page at the same address and come back after signing in. |
| 405 | data.Tab, data.Notice = "signin", "Sign in to manage this relay." |
| 406 | @@ -924,7 +934,7 @@ func tabForPath(path string) string { |
| 407 | return "files" |
| 408 | case "file": |
| 409 | return "file" |
| 410 | - case "tools", "signin", "sites": |
| 411 | + case "signin", "sites": |
| 412 | return path |
| 413 | case "people", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner": |
| 414 | return path |