clone

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

list each agent's callbacks on the agents page with pause, resume and remove

Dami committed 9786a0a
commit
9786a0a67f51102ab0179f79d4074a68c22f987d
parent
cef34c70332a419493c111ed56539339936e907e
author
Dami <dami.etoile@gmail.com>
6 files changed, +83 -5
Minternal/webui/agents_test.go30+++++++++++++++++--
Minternal/webui/manage.html1+
Minternal/webui/page.html2+-
Minternal/webui/style.css3++
Minternal/webui/templates.go4+++
Minternal/webui/webui.go48+++++++++++++++++++++++++++++-
M internal/webui/agents_test.go +27 -3
@@ -44,6 +44,12 @@ func (b *agentsBackend) Query(_ context.Context, method string, params []json.Ra
4444 switch method {4545 case "listagents":4646 return agents, nil47+ case "listcallbacks":48+ return []any{49+ map[string]any{"id": "cb-active", "owner": agentActive, "host": "hooks.example", "filter": map[string]any{"kinds": []int{1621, 1111}, "#p": []string{agentActive}}, "created": now - 3600, "lastDelivery": now - 60, "lastStatus": "ok", "failures": 0, "paused": false},50+ map[string]any{"id": "cb-paused", "owner": agentActive, "host": "worker.example:8443", "filter": map[string]any{"kinds": []int{9}}, "created": now - 3600, "lastDelivery": 0, "lastStatus": "paused after 20 failures: HTTP 500", "failures": 20, "paused": true},51+ map[string]any{"id": "cb-other", "owner": strings.Repeat("c", 64), "host": "elsewhere.example", "filter": map[string]any{"kinds": []int{1}}, "created": now, "lastDelivery": 0, "lastStatus": "", "failures": 0, "paused": false},52+ }, nil4753 case "browseagent":4854 var query struct {4955 Agent string `json:"agent"`
@@ -104,7 +110,11 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
104110 `<select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select>`,105111 `<textarea name="repos"`,106112 `<input name="expires" type="date" value="` + dateAfter(90) + `" required>`,107- `<h4>Agents</h4><table><tr><th>active</th><td>1</td></tr><tr><th>paused</th><td>1</td></tr><tr><th>revoked</th><td>1</td></tr></table>`,113+ `<h4>Agents</h4><table><tr><th>active</th><td>1</td></tr><tr><th>paused</th><td>1</td></tr><tr><th>revoked</th><td>1</td></tr><tr><th>callbacks</th><td>2 active, 1 paused</td></tr></table>`,114+ `<tr><th>callbacks</th><td><agent-callback data-state="active"><code>hooks.example</code> kinds 1621, 1111 | <span data-state="active" title="ok">active</span> | delivered <time datetime="`,115+ `<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value="&quot;cb-active&quot;"><button>Pause</button></rpc-form><rpc-form method="removecallback" refresh><input type="hidden" name="param" value="&quot;cb-active&quot;"><button>Remove</button></rpc-form></agent-callback>`,116+ `<agent-callback data-state="paused"><code>worker.example:8443</code> kinds 9 | <span data-state="paused" title="paused after 20 failures: HTTP 500">paused</span> <rpc-form method="resumecallback" refresh><input type="hidden" name="param" value="&quot;cb-paused&quot;"><button>Resume</button></rpc-form>`,117+ `<tr><th>callbacks</th><td>none</td></tr>`,108118 `<code>/mcp</code> 2026-07-28`,109119 `<rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form>`,110120 `<rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form>`,
@@ -119,7 +129,10 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) {
119129 if strings.Contains(revokedCard, "<rpc-form") || !strings.Contains(revokedCard, `<span data-state="revoked">revoked</span>`) {120130 t.Errorf("revoked card should show state without controls: %s", revokedCard)121131 }122- if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner {132+ if strings.Contains(body, "elsewhere.example") {133+ t.Error("a callback of a key without a card was shown")134+ }135+ if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" listcallbacks:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner {123136 t.Fatalf("backend calls = %s", got)124137 }125138 if !strings.Contains(string(backend.params[0]), agentActive) {
@@ -155,7 +168,7 @@ func TestAgentsPageWithoutAgentsAndForGuests(t *testing.T) {
155168 if recorder.Code != http.StatusOK || !strings.Contains(body, "No agents yet.") || !strings.Contains(body, "<agent-grant>") || strings.Contains(body, "<agent-card") {156169 t.Fatalf("empty agents page: %d %s", recorder.Code, body[:min(600, len(body))])157170 }158- if backend.call != "listagents:"+backend.policy.Owner {171+ if backend.call != "listcallbacks:"+backend.policy.Owner {159172 t.Fatalf("empty list should not query an agent: %s", backend.call)160173 }161174
@@ -193,4 +206,15 @@ func TestAgentStateHelpers(t *testing.T) {
193206 if counts["active"] != 1 || counts["paused"] != 1 || counts["revoked"] != 0 {194207 t.Errorf("counts = %v", counts)195208 }209+ callbacks := []any{210+ map[string]any{"owner": agentActive, "paused": false, "filter": map[string]any{"kinds": []any{float64(1621), float64(1111)}}},211+ map[string]any{"owner": agentActive, "paused": true, "filter": map[string]any{}},212+ map[string]any{"owner": agentPaused, "paused": false},213+ }214+ if mine := callbacksFor(callbacks, agentActive); len(mine) != 2 || callbackKinds(mine[0]) != "1621, 1111" || callbackKinds(mine[1]) != "" || callbackState(mine[1]) != "paused" {215+ t.Errorf("callbacksFor = %v", mine)216+ }217+ if counts := callbackCounts(callbacks); counts["active"] != 2 || counts["paused"] != 1 {218+ t.Errorf("callback counts = %v", counts)219+ }196220 }
M internal/webui/manage.html +1 -0
@@ -50,6 +50,7 @@
5050 <tr><th>expires</th><td><time datetime="{{datetime (index $a "expires")}}">{{when (index $a "expires")}}</time></td></tr>5151 <tr><th>owner</th><td>{{template "nostrKey" (str (index $a "owner"))}}</td></tr>5252 {{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}}53+<tr><th>callbacks</th><td>{{range $c := callbacksFor $.Callbacks $pk}}{{$cm := asMap $c}}{{$cs := callbackState $c}}{{$cid := str (index $cm "id")}}<agent-callback data-state="{{$cs}}"><code>{{str (index $cm "host")}}</code> kinds {{callbackKinds $c}} | <span data-state="{{$cs}}" title="{{str (index $cm "lastStatus")}}">{{$cs}}</span>{{with index $cm "lastDelivery"}}{{if when .}} | delivered <time datetime="{{datetime .}}">{{when .}}</time>{{end}}{{end}} {{if eq $cs "paused"}}<rpc-form method="resumecallback" refresh><input type="hidden" name="param" value="&quot;{{$cid}}&quot;"><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value="&quot;{{$cid}}&quot;"><button>Pause</button></rpc-form>{{end}}<rpc-form method="removecallback" refresh><input type="hidden" name="param" value="&quot;{{$cid}}&quot;"><button>Remove</button></rpc-form></agent-callback>{{else}}none{{end}}</td></tr>5354 </table>5455 <p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value="&quot;{{$pk}}&quot;"><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value="&quot;{{$pk}}&quot;"><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value="&quot;{{$pk}}&quot;"><button>Revoke</button></rpc-form>{{end}}<a href="/manage/agents?agent={{$pk}}">recent activity</a></p>5556 </agent-card>
M internal/webui/page.html +1 -1
@@ -140,7 +140,7 @@
140140 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>141141 <section><h4>Result</h4><p><small>Each form shows its result below the form. Every call is signed by your connected signer.</small></p></section>{{end}}142142 143-{{define "panel-agents"}}{{$counts := agentCounts .Feed}}<section><h4>Agents</h4><table><tr><th>active</th><td>{{index $counts "active"}}</td></tr><tr><th>paused</th><td>{{index $counts "paused"}}</td></tr><tr><th>revoked</th><td>{{index $counts "revoked"}}</td></tr></table></section>143+{{define "panel-agents"}}{{$counts := agentCounts .Feed}}{{$callbacks := callbackCounts .Callbacks}}<section><h4>Agents</h4><table><tr><th>active</th><td>{{index $counts "active"}}</td></tr><tr><th>paused</th><td>{{index $counts "paused"}}</td></tr><tr><th>revoked</th><td>{{index $counts "revoked"}}</td></tr><tr><th>callbacks</th><td>{{index $callbacks "active"}} active, {{index $callbacks "paused"}} paused</td></tr></table></section>144144 <section><h4>Machine access</h4><table><tr><th>mcp</th><td><code>/mcp</code> 2026-07-28</td></tr><tr><th>auth</th><td>NIP-98 per request</td></tr><tr><th>rest</th><td><code>/events</code> <code>/query</code></td></tr></table></section>145145 <section><h4>Kill switch</h4><rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form><rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form><p><small>Pausing keeps grants and rejects writes until resumed.</small></p></section>146146 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>{{end}}
M internal/webui/style.css +3 -0
M internal/webui/templates.go +4 -0
M internal/webui/webui.go +47 -1
@@ -92,6 +92,8 @@ type PageData struct {
9292 Connections []any9393 // Rooms lists the rooms the viewer may see; the rooms rail renders it.9494 Rooms []any95+ // Callbacks lists the event callbacks shown beside each agent.96+ Callbacks []any9597 }9698 9799 func New(backend Backend, options Options) (*App, error) {
@@ -922,6 +924,11 @@ func (a *App) agentsPage(request *http.Request, actor string, data *PageData) {
922924 return923925 }924926 data.Feed = browseRows(result)927+ // Callbacks are shown per agent; a failure here leaves the row empty928+ // rather than hiding the agents.929+ if callbacks, err := a.backend.Query(request.Context(), "listcallbacks", nil, actor); err == nil {930+ data.Callbacks = browseRows(callbacks)931+ }925932 selected := request.URL.Query().Get("agent")926933 if selected == "" {927934 // Without a choice, show the first agent that can still publish.
@@ -1034,13 +1041,52 @@ func agentCounts(agents []any) map[string]int {
10341041 return counts10351042 }10361043 1044+// callbacksFor picks the callbacks one key registered.1045+func callbacksFor(callbacks []any, pubkey string) []any {1046+ var out []any1047+ for _, callback := range callbacks {1048+ if plainString(valueMap(callback)["owner"]) == pubkey {1049+ out = append(out, callback)1050+ }1051+ }1052+ return out1053+}1054+1055+// callbackState is active or paused; a paused callback carries the reason1056+// in its lastStatus.1057+func callbackState(callback any) string {1058+ if valueMap(callback)["paused"] == true {1059+ return "paused"1060+ }1061+ return "active"1062+}1063+1064+// callbackKinds lists the kinds a callback's filter names.1065+func callbackKinds(callback any) string {1066+ kinds, _ := valueMap(valueMap(callback)["filter"])["kinds"].([]any)1067+ parts := make([]string, 0, len(kinds))1068+ for _, kind := range kinds {1069+ parts = append(parts, plainString(kind))1070+ }1071+ return strings.Join(parts, ", ")1072+}1073+1074+// callbackCounts tallies callbacks by state for the panel.1075+func callbackCounts(callbacks []any) map[string]int {1076+ counts := map[string]int{"active": 0, "paused": 0}1077+ for _, callback := range callbacks {1078+ counts[callbackState(callback)]++1079+ }1080+ return counts1081+}1082+10371083 // dateAfter is the date field value for a day count from today, in UTC.10381084 func dateAfter(days int) string {10391085 return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02")10401086 }10411087 10421088 var supportedMethods = []string{1043- "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains",1089+ "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback",10441090 }10451091 10461092 func (a *App) card(writer http.ResponseWriter) {