tinyrelaycommit

Commit 9786a0a67f51

browse files at this commit | history

1diff --git a/internal/webui/agents_test.go b/internal/webui/agents_test.go
2index 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="&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>`,
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="&quot;cb-paused&quot;"><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 }
68diff --git a/internal/webui/manage.html b/internal/webui/manage.html
69index 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="&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>
77 </table>
78 <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>
79 </agent-card>
80diff --git a/internal/webui/page.html b/internal/webui/page.html
81index 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}}
93diff --git a/internal/webui/style.css b/internal/webui/style.css
94index 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}
107diff --git a/internal/webui/templates.go b/internal/webui/templates.go
108index 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,
122diff --git a/internal/webui/webui.go b/internal/webui/webui.go
123index 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) {

About

ownerc01503e8035f
visibilitypublic
head9786a0a67f51

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs