| 1 | diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go |
| 2 | index 531e055..d7a5418 100644 |
| 3 | --- a/internal/webui/agents_test.go |
| 4 | +++ b/internal/webui/agents_test.go |
| 5 | @@ -44,6 +44,12 @@ func (b *agentsBackend) Query(_ context.Context, method string, params []json.Ra |
| 6 | switch method { |
| 7 | case "listagents": |
| 8 | return agents, nil |
| 9 | + case "listcallbacks": |
| 10 | + return []any{ |
| 11 | + 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}, |
| 12 | + 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}, |
| 13 | + 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}, |
| 14 | + }, nil |
| 15 | case "browseagent": |
| 16 | var query struct { |
| 17 | Agent string `json:"agent"` |
| 18 | @@ -104,7 +110,11 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) { |
| 19 | `<select name="key"><option value="generate">generate here, show once</option><option value="paste">paste a public key</option></select>`, |
| 20 | `<textarea name="repos"`, |
| 21 | `<input name="expires" type="date" value="` + dateAfter(90) + `" required>`, |
| 22 | - `<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>`, |
| 23 | + `<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>`, |
| 24 | + `<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="`, |
| 25 | + `<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value=""cb-active""><button>Pause</button></rpc-form><rpc-form method="removecallback" refresh><input type="hidden" name="param" value=""cb-active""><button>Remove</button></rpc-form></agent-callback>`, |
| 26 | + `<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=""cb-paused""><button>Resume</button></rpc-form>`, |
| 27 | + `<tr><th>callbacks</th><td>none</td></tr>`, |
| 28 | `<code>/mcp</code> 2026-07-28`, |
| 29 | `<rpc-form method="pauseallagents" refresh><button>Pause all agents</button></rpc-form>`, |
| 30 | `<rpc-form method="resumeallagents" refresh><button>Resume all agents</button></rpc-form>`, |
| 31 | @@ -119,7 +129,10 @@ func TestAgentsPageRendersTableCardsActivityAndGrantForm(t *testing.T) { |
| 32 | if strings.Contains(revokedCard, "<rpc-form") || !strings.Contains(revokedCard, `<span data-state="revoked">revoked</span>`) { |
| 33 | t.Errorf("revoked card should show state without controls: %s", revokedCard) |
| 34 | } |
| 35 | - if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner { |
| 36 | + if strings.Contains(body, "elsewhere.example") { |
| 37 | + t.Error("a callback of a key without a card was shown") |
| 38 | + } |
| 39 | + if got := strings.Join(backend.calls, " "); got != "listagents:"+backend.policy.Owner+" listcallbacks:"+backend.policy.Owner+" browseagent:"+backend.policy.Owner { |
| 40 | t.Fatalf("backend calls = %s", got) |
| 41 | } |
| 42 | if !strings.Contains(string(backend.params[0]), agentActive) { |
| 43 | @@ -155,7 +168,7 @@ func TestAgentsPageWithoutAgentsAndForGuests(t *testing.T) { |
| 44 | if recorder.Code != http.StatusOK || !strings.Contains(body, "No agents yet.") || !strings.Contains(body, "<agent-grant>") || strings.Contains(body, "<agent-card") { |
| 45 | t.Fatalf("empty agents page: %d %s", recorder.Code, body[:min(600, len(body))]) |
| 46 | } |
| 47 | - if backend.call != "listagents:"+backend.policy.Owner { |
| 48 | + if backend.call != "listcallbacks:"+backend.policy.Owner { |
| 49 | t.Fatalf("empty list should not query an agent: %s", backend.call) |
| 50 | } |
| 51 | |
| 52 | @@ -193,4 +206,15 @@ func TestAgentStateHelpers(t *testing.T) { |
| 53 | if counts["active"] != 1 || counts["paused"] != 1 || counts["revoked"] != 0 { |
| 54 | t.Errorf("counts = %v", counts) |
| 55 | } |
| 56 | + callbacks := []any{ |
| 57 | + map[string]any{"owner": agentActive, "paused": false, "filter": map[string]any{"kinds": []any{float64(1621), float64(1111)}}}, |
| 58 | + map[string]any{"owner": agentActive, "paused": true, "filter": map[string]any{}}, |
| 59 | + map[string]any{"owner": agentPaused, "paused": false}, |
| 60 | + } |
| 61 | + if mine := callbacksFor(callbacks, agentActive); len(mine) != 2 || callbackKinds(mine[0]) != "1621, 1111" || callbackKinds(mine[1]) != "" || callbackState(mine[1]) != "paused" { |
| 62 | + t.Errorf("callbacksFor = %v", mine) |
| 63 | + } |
| 64 | + if counts := callbackCounts(callbacks); counts["active"] != 2 || counts["paused"] != 1 { |
| 65 | + t.Errorf("callback counts = %v", counts) |
| 66 | + } |
| 67 | } |
| 68 | diff --git a/internal/webui/manage.html b/internal/webui/manage.html |
| 69 | index d2c149c..1821581 100644 |
| 70 | --- a/internal/webui/manage.html |
| 71 | +++ b/internal/webui/manage.html |
| 72 | @@ -50,6 +50,7 @@ |
| 73 | <tr><th>expires</th><td><time datetime="{{datetime (index $a "expires")}}">{{when (index $a "expires")}}</time></td></tr> |
| 74 | <tr><th>owner</th><td>{{template "nostrKey" (str (index $a "owner"))}}</td></tr> |
| 75 | {{with index $a "lastEvent"}}{{if when .}}<tr><th>last event</th><td><time datetime="{{datetime .}}">{{when .}}</time></td></tr>{{end}}{{end}} |
| 76 | +<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=""{{$cid}}""><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Pause</button></rpc-form>{{end}}<rpc-form method="removecallback" refresh><input type="hidden" name="param" value=""{{$cid}}""><button>Remove</button></rpc-form></agent-callback>{{else}}none{{end}}</td></tr> |
| 77 | </table> |
| 78 | <p>{{if eq $state "paused"}}<rpc-form method="resumeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Resume</button></rpc-form>{{else if eq $state "active"}}<rpc-form method="pauseagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Pause</button></rpc-form>{{end}}{{if ne $state "revoked"}}<rpc-form method="revokeagent" refresh><input type="hidden" name="param" value=""{{$pk}}""><button>Revoke</button></rpc-form>{{end}}<a href="/manage/agents?agent={{$pk}}">recent activity</a></p> |
| 79 | </agent-card> |
| 80 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 81 | index 9fff089..06ae379 100644 |
| 82 | --- a/internal/webui/page.html |
| 83 | +++ b/internal/webui/page.html |
| 84 | @@ -140,7 +140,7 @@ |
| 85 | <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> |
| 86 | <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}} |
| 87 | |
| 88 | -{{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> |
| 89 | +{{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> |
| 90 | <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> |
| 91 | <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> |
| 92 | <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}} |
| 93 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 94 | index 03e01d0..f419b4a 100644 |
| 95 | --- a/internal/webui/style.css |
| 96 | +++ b/internal/webui/style.css |
| 97 | @@ -142,6 +142,9 @@ agent-card th{text-align:left;font-weight:normal;color:var(--muted);width:9em;pa |
| 98 | agent-card td code{color:var(--muted)} |
| 99 | agent-card p{margin:8px 0 0}agent-card rpc-form{display:inline-block;border:0;padding:0;margin:0 8px 0 0;vertical-align:top}agent-card rpc-form output:empty{display:none} |
| 100 | agent-card p>a{display:inline-block;font:12px var(--mono);line-height:29px;vertical-align:top} |
| 101 | +/* agent-callback: one registered callback on an agent card, with its controls. */ |
| 102 | +agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit} |
| 103 | +agent-callback rpc-form{display:inline-block;border:0;padding:0;margin:0 0 0 6px;vertical-align:middle}agent-callback rpc-form output:empty{display:none}agent-callback button{font-size:12px;padding:1px 6px} |
| 104 | #rows:has(td[data-state]){table-layout:fixed}#rows:has(td[data-state]) td:nth-child(3){width:110px}#rows:has(td[data-state]) td:last-child{width:150px} |
| 105 | [data-state=active],#rows td[data-state=active]{color:var(--accent)}[data-state=paused],#rows td[data-state=paused]{color:var(--bark)}[data-state=revoked],#rows td[data-state=revoked]{color:var(--alert)} |
| 106 | agent-grant{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-width:720px} |
| 107 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 108 | index f82f12e..eca25b1 100644 |
| 109 | --- a/internal/webui/templates.go |
| 110 | +++ b/internal/webui/templates.go |
| 111 | @@ -258,6 +258,10 @@ func parseTemplates() (*template.Template, error) { |
| 112 | "agentScope": agentScope, |
| 113 | "agentLabel": agentLabel, |
| 114 | "agentCounts": agentCounts, |
| 115 | + "callbacksFor": callbacksFor, |
| 116 | + "callbackState": callbackState, |
| 117 | + "callbackKinds": callbackKinds, |
| 118 | + "callbackCounts": callbackCounts, |
| 119 | "scopeList": scopeList, |
| 120 | "dateAfter": dateAfter, |
| 121 | "approvalViews": approvalViews, |
| 122 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 123 | index 74e4b1b..7cc649f 100644 |
| 124 | --- a/internal/webui/webui.go |
| 125 | +++ b/internal/webui/webui.go |
| 126 | @@ -92,6 +92,8 @@ type PageData struct { |
| 127 | Connections []any |
| 128 | // Rooms lists the rooms the viewer may see; the rooms rail renders it. |
| 129 | Rooms []any |
| 130 | + // Callbacks lists the event callbacks shown beside each agent. |
| 131 | + Callbacks []any |
| 132 | } |
| 133 | |
| 134 | func New(backend Backend, options Options) (*App, error) { |
| 135 | @@ -922,6 +924,11 @@ func (a *App) agentsPage(request *http.Request, actor string, data *PageData) { |
| 136 | return |
| 137 | } |
| 138 | data.Feed = browseRows(result) |
| 139 | + // Callbacks are shown per agent; a failure here leaves the row empty |
| 140 | + // rather than hiding the agents. |
| 141 | + if callbacks, err := a.backend.Query(request.Context(), "listcallbacks", nil, actor); err == nil { |
| 142 | + data.Callbacks = browseRows(callbacks) |
| 143 | + } |
| 144 | selected := request.URL.Query().Get("agent") |
| 145 | if selected == "" { |
| 146 | // Without a choice, show the first agent that can still publish. |
| 147 | @@ -1034,13 +1041,52 @@ func agentCounts(agents []any) map[string]int { |
| 148 | return counts |
| 149 | } |
| 150 | |
| 151 | +// callbacksFor picks the callbacks one key registered. |
| 152 | +func callbacksFor(callbacks []any, pubkey string) []any { |
| 153 | + var out []any |
| 154 | + for _, callback := range callbacks { |
| 155 | + if plainString(valueMap(callback)["owner"]) == pubkey { |
| 156 | + out = append(out, callback) |
| 157 | + } |
| 158 | + } |
| 159 | + return out |
| 160 | +} |
| 161 | + |
| 162 | +// callbackState is active or paused; a paused callback carries the reason |
| 163 | +// in its lastStatus. |
| 164 | +func callbackState(callback any) string { |
| 165 | + if valueMap(callback)["paused"] == true { |
| 166 | + return "paused" |
| 167 | + } |
| 168 | + return "active" |
| 169 | +} |
| 170 | + |
| 171 | +// callbackKinds lists the kinds a callback's filter names. |
| 172 | +func callbackKinds(callback any) string { |
| 173 | + kinds, _ := valueMap(valueMap(callback)["filter"])["kinds"].([]any) |
| 174 | + parts := make([]string, 0, len(kinds)) |
| 175 | + for _, kind := range kinds { |
| 176 | + parts = append(parts, plainString(kind)) |
| 177 | + } |
| 178 | + return strings.Join(parts, ", ") |
| 179 | +} |
| 180 | + |
| 181 | +// callbackCounts tallies callbacks by state for the panel. |
| 182 | +func callbackCounts(callbacks []any) map[string]int { |
| 183 | + counts := map[string]int{"active": 0, "paused": 0} |
| 184 | + for _, callback := range callbacks { |
| 185 | + counts[callbackState(callback)]++ |
| 186 | + } |
| 187 | + return counts |
| 188 | +} |
| 189 | + |
| 190 | // dateAfter is the date field value for a day count from today, in UTC. |
| 191 | func dateAfter(days int) string { |
| 192 | return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02") |
| 193 | } |
| 194 | |
| 195 | var supportedMethods = []string{ |
| 196 | - "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", |
| 197 | + "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", |
| 198 | } |
| 199 | |
| 200 | func (a *App) card(writer http.ResponseWriter) { |