ScuttleBot

Merge pull request #141 from ConflictHQ/feature/115-web-ui-topology feat: topology panel, task channels, ROE editor

noreply 2026-04-05 16:28 trunk merge
Commit 900677e50c00eb26a35853b100198a6518b92aa8edb0d4a9a0249b5f458a75a0
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
1414
ProvisionChannel(ch topology.ChannelConfig) error
1515
DropChannel(channel string)
1616
Policy() *topology.Policy
1717
GrantAccess(nick, channel, level string)
1818
RevokeAccess(nick, channel string)
19
+ ListChannels() []topology.ChannelInfo
1920
}
2021
2122
type provisionChannelRequest struct {
2223
Name string `json:"name"`
2324
Topic string `json:"topic,omitempty"`
@@ -96,12 +97,13 @@
9697
Ephemeral bool `json:"ephemeral,omitempty"`
9798
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
9899
}
99100
100101
type topologyResponse struct {
101
- StaticChannels []string `json:"static_channels"`
102
- Types []channelTypeInfo `json:"types"`
102
+ StaticChannels []string `json:"static_channels"`
103
+ Types []channelTypeInfo `json:"types"`
104
+ ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
103105
}
104106
105107
// handleDropChannel handles DELETE /v1/topology/channels/{channel}.
106108
// Drops the ChanServ registration of an ephemeral channel.
107109
func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -151,7 +153,8 @@
151153
}
152154
153155
writeJSON(w, http.StatusOK, topologyResponse{
154156
StaticChannels: staticNames,
155157
Types: typeInfos,
158
+ ActiveChannels: s.topoMgr.ListChannels(),
156159
})
157160
}
158161
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18 RevokeAccess(nick, channel string)
 
19 }
20
21 type provisionChannelRequest struct {
22 Name string `json:"name"`
23 Topic string `json:"topic,omitempty"`
@@ -96,12 +97,13 @@
96 Ephemeral bool `json:"ephemeral,omitempty"`
97 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
98 }
99
100 type topologyResponse struct {
101 StaticChannels []string `json:"static_channels"`
102 Types []channelTypeInfo `json:"types"`
 
103 }
104
105 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
106 // Drops the ChanServ registration of an ephemeral channel.
107 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -151,7 +153,8 @@
151 }
152
153 writeJSON(w, http.StatusOK, topologyResponse{
154 StaticChannels: staticNames,
155 Types: typeInfos,
 
156 })
157 }
158
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18 RevokeAccess(nick, channel string)
19 ListChannels() []topology.ChannelInfo
20 }
21
22 type provisionChannelRequest struct {
23 Name string `json:"name"`
24 Topic string `json:"topic,omitempty"`
@@ -96,12 +97,13 @@
97 Ephemeral bool `json:"ephemeral,omitempty"`
98 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
99 }
100
101 type topologyResponse struct {
102 StaticChannels []string `json:"static_channels"`
103 Types []channelTypeInfo `json:"types"`
104 ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
105 }
106
107 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
108 // Drops the ChanServ registration of an ephemeral channel.
109 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -151,7 +153,8 @@
153 }
154
155 writeJSON(w, http.StatusOK, topologyResponse{
156 StaticChannels: staticNames,
157 Types: typeInfos,
158 ActiveChannels: s.topoMgr.ListChannels(),
159 })
160 }
161
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
1414
ProvisionChannel(ch topology.ChannelConfig) error
1515
DropChannel(channel string)
1616
Policy() *topology.Policy
1717
GrantAccess(nick, channel, level string)
1818
RevokeAccess(nick, channel string)
19
+ ListChannels() []topology.ChannelInfo
1920
}
2021
2122
type provisionChannelRequest struct {
2223
Name string `json:"name"`
2324
Topic string `json:"topic,omitempty"`
@@ -96,12 +97,13 @@
9697
Ephemeral bool `json:"ephemeral,omitempty"`
9798
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
9899
}
99100
100101
type topologyResponse struct {
101
- StaticChannels []string `json:"static_channels"`
102
- Types []channelTypeInfo `json:"types"`
102
+ StaticChannels []string `json:"static_channels"`
103
+ Types []channelTypeInfo `json:"types"`
104
+ ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
103105
}
104106
105107
// handleDropChannel handles DELETE /v1/topology/channels/{channel}.
106108
// Drops the ChanServ registration of an ephemeral channel.
107109
func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -151,7 +153,8 @@
151153
}
152154
153155
writeJSON(w, http.StatusOK, topologyResponse{
154156
StaticChannels: staticNames,
155157
Types: typeInfos,
158
+ ActiveChannels: s.topoMgr.ListChannels(),
156159
})
157160
}
158161
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18 RevokeAccess(nick, channel string)
 
19 }
20
21 type provisionChannelRequest struct {
22 Name string `json:"name"`
23 Topic string `json:"topic,omitempty"`
@@ -96,12 +97,13 @@
96 Ephemeral bool `json:"ephemeral,omitempty"`
97 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
98 }
99
100 type topologyResponse struct {
101 StaticChannels []string `json:"static_channels"`
102 Types []channelTypeInfo `json:"types"`
 
103 }
104
105 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
106 // Drops the ChanServ registration of an ephemeral channel.
107 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -151,7 +153,8 @@
151 }
152
153 writeJSON(w, http.StatusOK, topologyResponse{
154 StaticChannels: staticNames,
155 Types: typeInfos,
 
156 })
157 }
158
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -14,10 +14,11 @@
14 ProvisionChannel(ch topology.ChannelConfig) error
15 DropChannel(channel string)
16 Policy() *topology.Policy
17 GrantAccess(nick, channel, level string)
18 RevokeAccess(nick, channel string)
19 ListChannels() []topology.ChannelInfo
20 }
21
22 type provisionChannelRequest struct {
23 Name string `json:"name"`
24 Topic string `json:"topic,omitempty"`
@@ -96,12 +97,13 @@
97 Ephemeral bool `json:"ephemeral,omitempty"`
98 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
99 }
100
101 type topologyResponse struct {
102 StaticChannels []string `json:"static_channels"`
103 Types []channelTypeInfo `json:"types"`
104 ActiveChannels []topology.ChannelInfo `json:"active_channels,omitempty"`
105 }
106
107 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
108 // Drops the ChanServ registration of an ephemeral channel.
109 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
@@ -151,7 +153,8 @@
153 }
154
155 writeJSON(w, http.StatusOK, topologyResponse{
156 StaticChannels: staticNames,
157 Types: typeInfos,
158 ActiveChannels: s.topoMgr.ListChannels(),
159 })
160 }
161
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -48,10 +48,12 @@
4848
}
4949
5050
func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
5151
s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
5252
}
53
+
54
+func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
5355
5456
// stubProvisioner is a minimal AccountProvisioner for agent registration tests.
5557
type stubProvisioner struct {
5658
accounts map[string]string
5759
}
5860
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -48,10 +48,12 @@
48 }
49
50 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
51 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
52 }
 
 
53
54 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
55 type stubProvisioner struct {
56 accounts map[string]string
57 }
58
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -48,10 +48,12 @@
48 }
49
50 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
51 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
52 }
53
54 func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
55
56 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
57 type stubProvisioner struct {
58 accounts map[string]string
59 }
60
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -48,10 +48,12 @@
4848
}
4949
5050
func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
5151
s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
5252
}
53
+
54
+func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
5355
5456
// stubProvisioner is a minimal AccountProvisioner for agent registration tests.
5557
type stubProvisioner struct {
5658
accounts map[string]string
5759
}
5860
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -48,10 +48,12 @@
48 }
49
50 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
51 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
52 }
 
 
53
54 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
55 type stubProvisioner struct {
56 accounts map[string]string
57 }
58
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -48,10 +48,12 @@
48 }
49
50 func (s *stubTopologyManager) RevokeAccess(nick, channel string) {
51 s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel})
52 }
53
54 func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil }
55
56 // stubProvisioner is a minimal AccountProvisioner for agent registration tests.
57 type stubProvisioner struct {
58 accounts map[string]string
59 }
60
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -68,18 +68,31 @@
6868
AWSSecretKey string `json:"aws_secret_key,omitempty"`
6969
Allow []string `json:"allow,omitempty"`
7070
Block []string `json:"block,omitempty"`
7171
Default bool `json:"default,omitempty"`
7272
}
73
+
74
+// ROETemplate is a rules-of-engagement template.
75
+type ROETemplate struct {
76
+ Name string `json:"name"`
77
+ Description string `json:"description,omitempty"`
78
+ Channels []string `json:"channels,omitempty"`
79
+ Permissions []string `json:"permissions,omitempty"`
80
+ RateLimit struct {
81
+ MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
82
+ Burst int `json:"burst,omitempty"`
83
+ } `json:"rate_limit,omitempty"`
84
+}
7385
7486
// Policies is the full mutable settings blob, persisted to policies.json.
7587
type Policies struct {
76
- Behaviors []BehaviorConfig `json:"behaviors"`
77
- AgentPolicy AgentPolicy `json:"agent_policy"`
78
- Bridge BridgePolicy `json:"bridge"`
79
- Logging LoggingPolicy `json:"logging"`
80
- LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
88
+ Behaviors []BehaviorConfig `json:"behaviors"`
89
+ AgentPolicy AgentPolicy `json:"agent_policy"`
90
+ Bridge BridgePolicy `json:"bridge"`
91
+ Logging LoggingPolicy `json:"logging"`
92
+ LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
93
+ ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
8194
}
8295
8396
// defaultBehaviors lists every built-in bot with conservative defaults (disabled).
8497
var defaultBehaviors = []BehaviorConfig{
8598
{
8699
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -68,18 +68,31 @@
68 AWSSecretKey string `json:"aws_secret_key,omitempty"`
69 Allow []string `json:"allow,omitempty"`
70 Block []string `json:"block,omitempty"`
71 Default bool `json:"default,omitempty"`
72 }
 
 
 
 
 
 
 
 
 
 
 
 
73
74 // Policies is the full mutable settings blob, persisted to policies.json.
75 type Policies struct {
76 Behaviors []BehaviorConfig `json:"behaviors"`
77 AgentPolicy AgentPolicy `json:"agent_policy"`
78 Bridge BridgePolicy `json:"bridge"`
79 Logging LoggingPolicy `json:"logging"`
80 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
 
81 }
82
83 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
84 var defaultBehaviors = []BehaviorConfig{
85 {
86
--- internal/api/policies.go
+++ internal/api/policies.go
@@ -68,18 +68,31 @@
68 AWSSecretKey string `json:"aws_secret_key,omitempty"`
69 Allow []string `json:"allow,omitempty"`
70 Block []string `json:"block,omitempty"`
71 Default bool `json:"default,omitempty"`
72 }
73
74 // ROETemplate is a rules-of-engagement template.
75 type ROETemplate struct {
76 Name string `json:"name"`
77 Description string `json:"description,omitempty"`
78 Channels []string `json:"channels,omitempty"`
79 Permissions []string `json:"permissions,omitempty"`
80 RateLimit struct {
81 MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
82 Burst int `json:"burst,omitempty"`
83 } `json:"rate_limit,omitempty"`
84 }
85
86 // Policies is the full mutable settings blob, persisted to policies.json.
87 type Policies struct {
88 Behaviors []BehaviorConfig `json:"behaviors"`
89 AgentPolicy AgentPolicy `json:"agent_policy"`
90 Bridge BridgePolicy `json:"bridge"`
91 Logging LoggingPolicy `json:"logging"`
92 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
93 ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
94 }
95
96 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
97 var defaultBehaviors = []BehaviorConfig{
98 {
99
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485485
<button class="sm primary" onclick="quickJoin()">join</button>
486486
</div>
487487
</div>
488488
<div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489489
</div>
490
+
491
+ <!-- topology panel -->
492
+ <div class="card" id="card-topology">
493
+ <div class="card-header" onclick="toggleCard('card-topology',event)">
494
+ <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
495
+ <div class="spacer"></div>
496
+ <div style="display:flex;gap:6px;align-items:center">
497
+ <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
498
+ <button class="sm primary" onclick="provisionChannel()">provision</button>
499
+ </div>
500
+ </div>
501
+ <div class="card-body" style="padding:0">
502
+ <div id="topology-types"></div>
503
+ <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
504
+ </div>
505
+ </div>
506
+
507
+ <!-- ROE templates -->
508
+ <div class="card" id="card-roe">
509
+ <div class="card-header" onclick="toggleCard('card-roe',event)">
510
+ <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
511
+ <div class="spacer"></div>
512
+ <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
513
+ </div>
514
+ <div class="card-body">
515
+ <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
516
+ <div id="roe-list"></div>
517
+ <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
518
+ </div>
519
+ </div>
490520
</div>
491521
</div>
492522
493523
<!-- CHAT -->
494524
<div class="tab-pane" id="pane-chat">
@@ -1756,10 +1786,19 @@
17561786
allChannels = (data.channels || []).sort();
17571787
renderChanList();
17581788
} catch(e) {
17591789
document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
17601790
}
1791
+ loadTopology();
1792
+ // Load ROE templates from policies for the ROE card.
1793
+ try {
1794
+ const s = await api('GET', '/v1/settings');
1795
+ if (s && s.policies) {
1796
+ currentPolicies = s.policies;
1797
+ renderROETemplates(s.policies.roe_templates || []);
1798
+ }
1799
+ } catch(e) {}
17611800
}
17621801
17631802
function renderChanList() {
17641803
const q = (document.getElementById('chan-search').value||'').toLowerCase();
17651804
const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1794,10 +1833,138 @@
17941833
await loadChanTab();
17951834
renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
17961835
} catch(e) { alert('Join failed: '+e.message); }
17971836
}
17981837
document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1838
+
1839
+// --- topology panel (#115) + task channels (#114) ---
1840
+async function loadTopology() {
1841
+ try {
1842
+ const data = await api('GET', '/v1/topology');
1843
+ renderTopologyTypes(data.types || []);
1844
+ renderTopologyActive(data.active_channels || [], data.types || []);
1845
+ } catch(e) {
1846
+ document.getElementById('topology-types').innerHTML = '';
1847
+ document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1848
+ }
1849
+}
1850
+
1851
+function renderTopologyTypes(types) {
1852
+ if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1853
+ const rows = types.map(t => {
1854
+ const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1855
+ const tags = [];
1856
+ if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1857
+ if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1858
+ return `<tr>
1859
+ <td><strong>${esc(t.name)}</strong></td>
1860
+ <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1861
+ <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1862
+ <td style="font-size:12px">${ttl}</td>
1863
+ <td>${tags.join(' ')}</td>
1864
+ </tr>`;
1865
+ }).join('');
1866
+ document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1867
+}
1868
+
1869
+function renderTopologyActive(channels, types) {
1870
+ const el = document.getElementById('topology-active');
1871
+ const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1872
+ if (!tasks.length) {
1873
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1874
+ return;
1875
+ }
1876
+ const rows = tasks.map(c => {
1877
+ const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1878
+ const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1879
+ return `<tr>
1880
+ <td><strong>${esc(c.name)}</strong></td>
1881
+ <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1882
+ <td style="font-size:12px">${age}</td>
1883
+ <td style="font-size:12px">${ttl}</td>
1884
+ <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1885
+ </tr>`;
1886
+ }).join('');
1887
+ el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1888
+}
1889
+
1890
+function timeSince(date) {
1891
+ const s = Math.floor((new Date() - date) / 1000);
1892
+ if (s < 60) return s + 's';
1893
+ if (s < 3600) return Math.floor(s/60) + 'm';
1894
+ if (s < 86400) return Math.floor(s/3600) + 'h';
1895
+ return Math.floor(s/86400) + 'd';
1896
+}
1897
+
1898
+async function provisionChannel() {
1899
+ let ch = document.getElementById('provision-channel-input').value.trim();
1900
+ if (!ch) return;
1901
+ if (!ch.startsWith('#')) ch = '#' + ch;
1902
+ try {
1903
+ await api('POST', '/v1/channels', {name: ch});
1904
+ document.getElementById('provision-channel-input').value = '';
1905
+ loadTopology();
1906
+ loadChanTab();
1907
+ } catch(e) { alert('Provision failed: ' + e.message); }
1908
+}
1909
+
1910
+async function dropChannel(ch) {
1911
+ if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1912
+ const slug = ch.replace(/^#/,'');
1913
+ try {
1914
+ await api('DELETE', `/v1/topology/channels/${slug}`);
1915
+ loadTopology();
1916
+ loadChanTab();
1917
+ } catch(e) { alert('Drop failed: ' + e.message); }
1918
+}
1919
+
1920
+// --- ROE template editor (#118) ---
1921
+function renderROETemplates(templates) {
1922
+ const el = document.getElementById('roe-list');
1923
+ if (!templates || !templates.length) {
1924
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1925
+ return;
1926
+ }
1927
+ el.innerHTML = templates.map((t, i) => `
1928
+ <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1929
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1930
+ <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1931
+ <button class="sm danger" onclick="removeROE(${i})">remove</button>
1932
+ </div>
1933
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1934
+ <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
1935
+ <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
1936
+ </div>
1937
+ <div style="display:flex;gap:10px">
1938
+ <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
1939
+ <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
1940
+ <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
1941
+ </div>
1942
+ </div>
1943
+ `).join('');
1944
+}
1945
+
1946
+function addROETemplate() {
1947
+ if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
1948
+ currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
1949
+ renderROETemplates(currentPolicies.roe_templates);
1950
+}
1951
+function removeROE(i) {
1952
+ currentPolicies.roe_templates.splice(i, 1);
1953
+ renderROETemplates(currentPolicies.roe_templates);
1954
+}
1955
+function updateROE(i, field, val) {
1956
+ if (field === 'channels' || field === 'permissions') {
1957
+ currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
1958
+ } else {
1959
+ currentPolicies.roe_templates[i][field] = val;
1960
+ }
1961
+}
1962
+function updateROERateLimit(i, field, val) {
1963
+ if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
1964
+ currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
1965
+}
17991966
18001967
// --- chat ---
18011968
let chatChannel = null, chatSSE = null;
18021969
18031970
async function loadChannels() {
18041971
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485 <button class="sm primary" onclick="quickJoin()">join</button>
486 </div>
487 </div>
488 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490 </div>
491 </div>
492
493 <!-- CHAT -->
494 <div class="tab-pane" id="pane-chat">
@@ -1756,10 +1786,19 @@
1756 allChannels = (data.channels || []).sort();
1757 renderChanList();
1758 } catch(e) {
1759 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1760 }
 
 
 
 
 
 
 
 
 
1761 }
1762
1763 function renderChanList() {
1764 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1765 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1794,10 +1833,138 @@
1794 await loadChanTab();
1795 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1796 } catch(e) { alert('Join failed: '+e.message); }
1797 }
1798 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1799
1800 // --- chat ---
1801 let chatChannel = null, chatSSE = null;
1802
1803 async function loadChannels() {
1804
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485 <button class="sm primary" onclick="quickJoin()">join</button>
486 </div>
487 </div>
488 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489 </div>
490
491 <!-- topology panel -->
492 <div class="card" id="card-topology">
493 <div class="card-header" onclick="toggleCard('card-topology',event)">
494 <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
495 <div class="spacer"></div>
496 <div style="display:flex;gap:6px;align-items:center">
497 <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
498 <button class="sm primary" onclick="provisionChannel()">provision</button>
499 </div>
500 </div>
501 <div class="card-body" style="padding:0">
502 <div id="topology-types"></div>
503 <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
504 </div>
505 </div>
506
507 <!-- ROE templates -->
508 <div class="card" id="card-roe">
509 <div class="card-header" onclick="toggleCard('card-roe',event)">
510 <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
511 <div class="spacer"></div>
512 <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
513 </div>
514 <div class="card-body">
515 <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
516 <div id="roe-list"></div>
517 <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
518 </div>
519 </div>
520 </div>
521 </div>
522
523 <!-- CHAT -->
524 <div class="tab-pane" id="pane-chat">
@@ -1756,10 +1786,19 @@
1786 allChannels = (data.channels || []).sort();
1787 renderChanList();
1788 } catch(e) {
1789 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1790 }
1791 loadTopology();
1792 // Load ROE templates from policies for the ROE card.
1793 try {
1794 const s = await api('GET', '/v1/settings');
1795 if (s && s.policies) {
1796 currentPolicies = s.policies;
1797 renderROETemplates(s.policies.roe_templates || []);
1798 }
1799 } catch(e) {}
1800 }
1801
1802 function renderChanList() {
1803 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1804 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1794,10 +1833,138 @@
1833 await loadChanTab();
1834 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1835 } catch(e) { alert('Join failed: '+e.message); }
1836 }
1837 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1838
1839 // --- topology panel (#115) + task channels (#114) ---
1840 async function loadTopology() {
1841 try {
1842 const data = await api('GET', '/v1/topology');
1843 renderTopologyTypes(data.types || []);
1844 renderTopologyActive(data.active_channels || [], data.types || []);
1845 } catch(e) {
1846 document.getElementById('topology-types').innerHTML = '';
1847 document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1848 }
1849 }
1850
1851 function renderTopologyTypes(types) {
1852 if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1853 const rows = types.map(t => {
1854 const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1855 const tags = [];
1856 if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1857 if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1858 return `<tr>
1859 <td><strong>${esc(t.name)}</strong></td>
1860 <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1861 <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1862 <td style="font-size:12px">${ttl}</td>
1863 <td>${tags.join(' ')}</td>
1864 </tr>`;
1865 }).join('');
1866 document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1867 }
1868
1869 function renderTopologyActive(channels, types) {
1870 const el = document.getElementById('topology-active');
1871 const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1872 if (!tasks.length) {
1873 el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1874 return;
1875 }
1876 const rows = tasks.map(c => {
1877 const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1878 const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1879 return `<tr>
1880 <td><strong>${esc(c.name)}</strong></td>
1881 <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1882 <td style="font-size:12px">${age}</td>
1883 <td style="font-size:12px">${ttl}</td>
1884 <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1885 </tr>`;
1886 }).join('');
1887 el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1888 }
1889
1890 function timeSince(date) {
1891 const s = Math.floor((new Date() - date) / 1000);
1892 if (s < 60) return s + 's';
1893 if (s < 3600) return Math.floor(s/60) + 'm';
1894 if (s < 86400) return Math.floor(s/3600) + 'h';
1895 return Math.floor(s/86400) + 'd';
1896 }
1897
1898 async function provisionChannel() {
1899 let ch = document.getElementById('provision-channel-input').value.trim();
1900 if (!ch) return;
1901 if (!ch.startsWith('#')) ch = '#' + ch;
1902 try {
1903 await api('POST', '/v1/channels', {name: ch});
1904 document.getElementById('provision-channel-input').value = '';
1905 loadTopology();
1906 loadChanTab();
1907 } catch(e) { alert('Provision failed: ' + e.message); }
1908 }
1909
1910 async function dropChannel(ch) {
1911 if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1912 const slug = ch.replace(/^#/,'');
1913 try {
1914 await api('DELETE', `/v1/topology/channels/${slug}`);
1915 loadTopology();
1916 loadChanTab();
1917 } catch(e) { alert('Drop failed: ' + e.message); }
1918 }
1919
1920 // --- ROE template editor (#118) ---
1921 function renderROETemplates(templates) {
1922 const el = document.getElementById('roe-list');
1923 if (!templates || !templates.length) {
1924 el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1925 return;
1926 }
1927 el.innerHTML = templates.map((t, i) => `
1928 <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1929 <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1930 <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1931 <button class="sm danger" onclick="removeROE(${i})">remove</button>
1932 </div>
1933 <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1934 <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
1935 <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
1936 </div>
1937 <div style="display:flex;gap:10px">
1938 <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
1939 <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
1940 <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
1941 </div>
1942 </div>
1943 `).join('');
1944 }
1945
1946 function addROETemplate() {
1947 if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
1948 currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
1949 renderROETemplates(currentPolicies.roe_templates);
1950 }
1951 function removeROE(i) {
1952 currentPolicies.roe_templates.splice(i, 1);
1953 renderROETemplates(currentPolicies.roe_templates);
1954 }
1955 function updateROE(i, field, val) {
1956 if (field === 'channels' || field === 'permissions') {
1957 currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
1958 } else {
1959 currentPolicies.roe_templates[i][field] = val;
1960 }
1961 }
1962 function updateROERateLimit(i, field, val) {
1963 if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
1964 currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
1965 }
1966
1967 // --- chat ---
1968 let chatChannel = null, chatSSE = null;
1969
1970 async function loadChannels() {
1971
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485485
<button class="sm primary" onclick="quickJoin()">join</button>
486486
</div>
487487
</div>
488488
<div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489489
</div>
490
+
491
+ <!-- topology panel -->
492
+ <div class="card" id="card-topology">
493
+ <div class="card-header" onclick="toggleCard('card-topology',event)">
494
+ <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
495
+ <div class="spacer"></div>
496
+ <div style="display:flex;gap:6px;align-items:center">
497
+ <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
498
+ <button class="sm primary" onclick="provisionChannel()">provision</button>
499
+ </div>
500
+ </div>
501
+ <div class="card-body" style="padding:0">
502
+ <div id="topology-types"></div>
503
+ <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
504
+ </div>
505
+ </div>
506
+
507
+ <!-- ROE templates -->
508
+ <div class="card" id="card-roe">
509
+ <div class="card-header" onclick="toggleCard('card-roe',event)">
510
+ <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
511
+ <div class="spacer"></div>
512
+ <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
513
+ </div>
514
+ <div class="card-body">
515
+ <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
516
+ <div id="roe-list"></div>
517
+ <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
518
+ </div>
519
+ </div>
490520
</div>
491521
</div>
492522
493523
<!-- CHAT -->
494524
<div class="tab-pane" id="pane-chat">
@@ -1756,10 +1786,19 @@
17561786
allChannels = (data.channels || []).sort();
17571787
renderChanList();
17581788
} catch(e) {
17591789
document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
17601790
}
1791
+ loadTopology();
1792
+ // Load ROE templates from policies for the ROE card.
1793
+ try {
1794
+ const s = await api('GET', '/v1/settings');
1795
+ if (s && s.policies) {
1796
+ currentPolicies = s.policies;
1797
+ renderROETemplates(s.policies.roe_templates || []);
1798
+ }
1799
+ } catch(e) {}
17611800
}
17621801
17631802
function renderChanList() {
17641803
const q = (document.getElementById('chan-search').value||'').toLowerCase();
17651804
const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1794,10 +1833,138 @@
17941833
await loadChanTab();
17951834
renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
17961835
} catch(e) { alert('Join failed: '+e.message); }
17971836
}
17981837
document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1838
+
1839
+// --- topology panel (#115) + task channels (#114) ---
1840
+async function loadTopology() {
1841
+ try {
1842
+ const data = await api('GET', '/v1/topology');
1843
+ renderTopologyTypes(data.types || []);
1844
+ renderTopologyActive(data.active_channels || [], data.types || []);
1845
+ } catch(e) {
1846
+ document.getElementById('topology-types').innerHTML = '';
1847
+ document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1848
+ }
1849
+}
1850
+
1851
+function renderTopologyTypes(types) {
1852
+ if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1853
+ const rows = types.map(t => {
1854
+ const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1855
+ const tags = [];
1856
+ if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1857
+ if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1858
+ return `<tr>
1859
+ <td><strong>${esc(t.name)}</strong></td>
1860
+ <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1861
+ <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1862
+ <td style="font-size:12px">${ttl}</td>
1863
+ <td>${tags.join(' ')}</td>
1864
+ </tr>`;
1865
+ }).join('');
1866
+ document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1867
+}
1868
+
1869
+function renderTopologyActive(channels, types) {
1870
+ const el = document.getElementById('topology-active');
1871
+ const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1872
+ if (!tasks.length) {
1873
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1874
+ return;
1875
+ }
1876
+ const rows = tasks.map(c => {
1877
+ const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1878
+ const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1879
+ return `<tr>
1880
+ <td><strong>${esc(c.name)}</strong></td>
1881
+ <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1882
+ <td style="font-size:12px">${age}</td>
1883
+ <td style="font-size:12px">${ttl}</td>
1884
+ <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1885
+ </tr>`;
1886
+ }).join('');
1887
+ el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1888
+}
1889
+
1890
+function timeSince(date) {
1891
+ const s = Math.floor((new Date() - date) / 1000);
1892
+ if (s < 60) return s + 's';
1893
+ if (s < 3600) return Math.floor(s/60) + 'm';
1894
+ if (s < 86400) return Math.floor(s/3600) + 'h';
1895
+ return Math.floor(s/86400) + 'd';
1896
+}
1897
+
1898
+async function provisionChannel() {
1899
+ let ch = document.getElementById('provision-channel-input').value.trim();
1900
+ if (!ch) return;
1901
+ if (!ch.startsWith('#')) ch = '#' + ch;
1902
+ try {
1903
+ await api('POST', '/v1/channels', {name: ch});
1904
+ document.getElementById('provision-channel-input').value = '';
1905
+ loadTopology();
1906
+ loadChanTab();
1907
+ } catch(e) { alert('Provision failed: ' + e.message); }
1908
+}
1909
+
1910
+async function dropChannel(ch) {
1911
+ if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1912
+ const slug = ch.replace(/^#/,'');
1913
+ try {
1914
+ await api('DELETE', `/v1/topology/channels/${slug}`);
1915
+ loadTopology();
1916
+ loadChanTab();
1917
+ } catch(e) { alert('Drop failed: ' + e.message); }
1918
+}
1919
+
1920
+// --- ROE template editor (#118) ---
1921
+function renderROETemplates(templates) {
1922
+ const el = document.getElementById('roe-list');
1923
+ if (!templates || !templates.length) {
1924
+ el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1925
+ return;
1926
+ }
1927
+ el.innerHTML = templates.map((t, i) => `
1928
+ <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1929
+ <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1930
+ <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1931
+ <button class="sm danger" onclick="removeROE(${i})">remove</button>
1932
+ </div>
1933
+ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1934
+ <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
1935
+ <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
1936
+ </div>
1937
+ <div style="display:flex;gap:10px">
1938
+ <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
1939
+ <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
1940
+ <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
1941
+ </div>
1942
+ </div>
1943
+ `).join('');
1944
+}
1945
+
1946
+function addROETemplate() {
1947
+ if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
1948
+ currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
1949
+ renderROETemplates(currentPolicies.roe_templates);
1950
+}
1951
+function removeROE(i) {
1952
+ currentPolicies.roe_templates.splice(i, 1);
1953
+ renderROETemplates(currentPolicies.roe_templates);
1954
+}
1955
+function updateROE(i, field, val) {
1956
+ if (field === 'channels' || field === 'permissions') {
1957
+ currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
1958
+ } else {
1959
+ currentPolicies.roe_templates[i][field] = val;
1960
+ }
1961
+}
1962
+function updateROERateLimit(i, field, val) {
1963
+ if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
1964
+ currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
1965
+}
17991966
18001967
// --- chat ---
18011968
let chatChannel = null, chatSSE = null;
18021969
18031970
async function loadChannels() {
18041971
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485 <button class="sm primary" onclick="quickJoin()">join</button>
486 </div>
487 </div>
488 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490 </div>
491 </div>
492
493 <!-- CHAT -->
494 <div class="tab-pane" id="pane-chat">
@@ -1756,10 +1786,19 @@
1756 allChannels = (data.channels || []).sort();
1757 renderChanList();
1758 } catch(e) {
1759 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1760 }
 
 
 
 
 
 
 
 
 
1761 }
1762
1763 function renderChanList() {
1764 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1765 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1794,10 +1833,138 @@
1794 await loadChanTab();
1795 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1796 } catch(e) { alert('Join failed: '+e.message); }
1797 }
1798 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1799
1800 // --- chat ---
1801 let chatChannel = null, chatSSE = null;
1802
1803 async function loadChannels() {
1804
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -485,10 +485,40 @@
485 <button class="sm primary" onclick="quickJoin()">join</button>
486 </div>
487 </div>
488 <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
489 </div>
490
491 <!-- topology panel -->
492 <div class="card" id="card-topology">
493 <div class="card-header" onclick="toggleCard('card-topology',event)">
494 <h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
495 <div class="spacer"></div>
496 <div style="display:flex;gap:6px;align-items:center">
497 <input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
498 <button class="sm primary" onclick="provisionChannel()">provision</button>
499 </div>
500 </div>
501 <div class="card-body" style="padding:0">
502 <div id="topology-types"></div>
503 <div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
504 </div>
505 </div>
506
507 <!-- ROE templates -->
508 <div class="card" id="card-roe">
509 <div class="card-header" onclick="toggleCard('card-roe',event)">
510 <h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
511 <div class="spacer"></div>
512 <button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
513 </div>
514 <div class="card-body">
515 <p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
516 <div id="roe-list"></div>
517 <button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
518 </div>
519 </div>
520 </div>
521 </div>
522
523 <!-- CHAT -->
524 <div class="tab-pane" id="pane-chat">
@@ -1756,10 +1786,19 @@
1786 allChannels = (data.channels || []).sort();
1787 renderChanList();
1788 } catch(e) {
1789 document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1790 }
1791 loadTopology();
1792 // Load ROE templates from policies for the ROE card.
1793 try {
1794 const s = await api('GET', '/v1/settings');
1795 if (s && s.policies) {
1796 currentPolicies = s.policies;
1797 renderROETemplates(s.policies.roe_templates || []);
1798 }
1799 } catch(e) {}
1800 }
1801
1802 function renderChanList() {
1803 const q = (document.getElementById('chan-search').value||'').toLowerCase();
1804 const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
@@ -1794,10 +1833,138 @@
1833 await loadChanTab();
1834 renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1835 } catch(e) { alert('Join failed: '+e.message); }
1836 }
1837 document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1838
1839 // --- topology panel (#115) + task channels (#114) ---
1840 async function loadTopology() {
1841 try {
1842 const data = await api('GET', '/v1/topology');
1843 renderTopologyTypes(data.types || []);
1844 renderTopologyActive(data.active_channels || [], data.types || []);
1845 } catch(e) {
1846 document.getElementById('topology-types').innerHTML = '';
1847 document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1848 }
1849 }
1850
1851 function renderTopologyTypes(types) {
1852 if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1853 const rows = types.map(t => {
1854 const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1855 const tags = [];
1856 if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1857 if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1858 return `<tr>
1859 <td><strong>${esc(t.name)}</strong></td>
1860 <td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1861 <td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1862 <td style="font-size:12px">${ttl}</td>
1863 <td>${tags.join(' ')}</td>
1864 </tr>`;
1865 }).join('');
1866 document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1867 }
1868
1869 function renderTopologyActive(channels, types) {
1870 const el = document.getElementById('topology-active');
1871 const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1872 if (!tasks.length) {
1873 el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1874 return;
1875 }
1876 const rows = tasks.map(c => {
1877 const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1878 const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1879 return `<tr>
1880 <td><strong>${esc(c.name)}</strong></td>
1881 <td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1882 <td style="font-size:12px">${age}</td>
1883 <td style="font-size:12px">${ttl}</td>
1884 <td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1885 </tr>`;
1886 }).join('');
1887 el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1888 }
1889
1890 function timeSince(date) {
1891 const s = Math.floor((new Date() - date) / 1000);
1892 if (s < 60) return s + 's';
1893 if (s < 3600) return Math.floor(s/60) + 'm';
1894 if (s < 86400) return Math.floor(s/3600) + 'h';
1895 return Math.floor(s/86400) + 'd';
1896 }
1897
1898 async function provisionChannel() {
1899 let ch = document.getElementById('provision-channel-input').value.trim();
1900 if (!ch) return;
1901 if (!ch.startsWith('#')) ch = '#' + ch;
1902 try {
1903 await api('POST', '/v1/channels', {name: ch});
1904 document.getElementById('provision-channel-input').value = '';
1905 loadTopology();
1906 loadChanTab();
1907 } catch(e) { alert('Provision failed: ' + e.message); }
1908 }
1909
1910 async function dropChannel(ch) {
1911 if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
1912 const slug = ch.replace(/^#/,'');
1913 try {
1914 await api('DELETE', `/v1/topology/channels/${slug}`);
1915 loadTopology();
1916 loadChanTab();
1917 } catch(e) { alert('Drop failed: ' + e.message); }
1918 }
1919
1920 // --- ROE template editor (#118) ---
1921 function renderROETemplates(templates) {
1922 const el = document.getElementById('roe-list');
1923 if (!templates || !templates.length) {
1924 el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
1925 return;
1926 }
1927 el.innerHTML = templates.map((t, i) => `
1928 <div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
1929 <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
1930 <input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
1931 <button class="sm danger" onclick="removeROE(${i})">remove</button>
1932 </div>
1933 <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
1934 <div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
1935 <div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
1936 </div>
1937 <div style="display:flex;gap:10px">
1938 <div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
1939 <div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
1940 <div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
1941 </div>
1942 </div>
1943 `).join('');
1944 }
1945
1946 function addROETemplate() {
1947 if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
1948 currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
1949 renderROETemplates(currentPolicies.roe_templates);
1950 }
1951 function removeROE(i) {
1952 currentPolicies.roe_templates.splice(i, 1);
1953 renderROETemplates(currentPolicies.roe_templates);
1954 }
1955 function updateROE(i, field, val) {
1956 if (field === 'channels' || field === 'permissions') {
1957 currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
1958 } else {
1959 currentPolicies.roe_templates[i][field] = val;
1960 }
1961 }
1962 function updateROERateLimit(i, field, val) {
1963 if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
1964 currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
1965 }
1966
1967 // --- chat ---
1968 let chatChannel = null, chatSSE = null;
1969
1970 async function loadChannels() {
1971
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -316,10 +316,42 @@
316316
317317
func (m *Manager) chanserv(format string, args ...any) {
318318
msg := fmt.Sprintf(format, args...)
319319
m.client.Cmd.Message("ChanServ", msg)
320320
}
321
+
322
+// ChannelInfo describes an active provisioned channel.
323
+type ChannelInfo struct {
324
+ Name string `json:"name"`
325
+ ProvisionedAt time.Time `json:"provisioned_at"`
326
+ Type string `json:"type,omitempty"`
327
+ Ephemeral bool `json:"ephemeral,omitempty"`
328
+ TTLSeconds int64 `json:"ttl_seconds,omitempty"`
329
+}
330
+
331
+// ListChannels returns all actively provisioned channels.
332
+func (m *Manager) ListChannels() []ChannelInfo {
333
+ m.mu.Lock()
334
+ defer m.mu.Unlock()
335
+ out := make([]ChannelInfo, 0, len(m.channels))
336
+ for _, rec := range m.channels {
337
+ ci := ChannelInfo{
338
+ Name: rec.name,
339
+ ProvisionedAt: rec.provisionedAt,
340
+ }
341
+ if m.policy != nil {
342
+ ci.Type = m.policy.TypeName(rec.name)
343
+ ci.Ephemeral = m.policy.IsEphemeral(rec.name)
344
+ ttl := m.policy.TTLFor(rec.name)
345
+ if ttl > 0 {
346
+ ci.TTLSeconds = int64(ttl.Seconds())
347
+ }
348
+ }
349
+ out = append(out, ci)
350
+ }
351
+ return out
352
+}
321353
322354
// ValidateName checks that a channel name follows scuttlebot conventions.
323355
func ValidateName(name string) error {
324356
if !strings.HasPrefix(name, "#") {
325357
return fmt.Errorf("topology: channel name must start with #: %q", name)
326358
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -316,10 +316,42 @@
316
317 func (m *Manager) chanserv(format string, args ...any) {
318 msg := fmt.Sprintf(format, args...)
319 m.client.Cmd.Message("ChanServ", msg)
320 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
322 // ValidateName checks that a channel name follows scuttlebot conventions.
323 func ValidateName(name string) error {
324 if !strings.HasPrefix(name, "#") {
325 return fmt.Errorf("topology: channel name must start with #: %q", name)
326
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -316,10 +316,42 @@
316
317 func (m *Manager) chanserv(format string, args ...any) {
318 msg := fmt.Sprintf(format, args...)
319 m.client.Cmd.Message("ChanServ", msg)
320 }
321
322 // ChannelInfo describes an active provisioned channel.
323 type ChannelInfo struct {
324 Name string `json:"name"`
325 ProvisionedAt time.Time `json:"provisioned_at"`
326 Type string `json:"type,omitempty"`
327 Ephemeral bool `json:"ephemeral,omitempty"`
328 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
329 }
330
331 // ListChannels returns all actively provisioned channels.
332 func (m *Manager) ListChannels() []ChannelInfo {
333 m.mu.Lock()
334 defer m.mu.Unlock()
335 out := make([]ChannelInfo, 0, len(m.channels))
336 for _, rec := range m.channels {
337 ci := ChannelInfo{
338 Name: rec.name,
339 ProvisionedAt: rec.provisionedAt,
340 }
341 if m.policy != nil {
342 ci.Type = m.policy.TypeName(rec.name)
343 ci.Ephemeral = m.policy.IsEphemeral(rec.name)
344 ttl := m.policy.TTLFor(rec.name)
345 if ttl > 0 {
346 ci.TTLSeconds = int64(ttl.Seconds())
347 }
348 }
349 out = append(out, ci)
350 }
351 return out
352 }
353
354 // ValidateName checks that a channel name follows scuttlebot conventions.
355 func ValidateName(name string) error {
356 if !strings.HasPrefix(name, "#") {
357 return fmt.Errorf("topology: channel name must start with #: %q", name)
358
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -316,10 +316,42 @@
316316
317317
func (m *Manager) chanserv(format string, args ...any) {
318318
msg := fmt.Sprintf(format, args...)
319319
m.client.Cmd.Message("ChanServ", msg)
320320
}
321
+
322
+// ChannelInfo describes an active provisioned channel.
323
+type ChannelInfo struct {
324
+ Name string `json:"name"`
325
+ ProvisionedAt time.Time `json:"provisioned_at"`
326
+ Type string `json:"type,omitempty"`
327
+ Ephemeral bool `json:"ephemeral,omitempty"`
328
+ TTLSeconds int64 `json:"ttl_seconds,omitempty"`
329
+}
330
+
331
+// ListChannels returns all actively provisioned channels.
332
+func (m *Manager) ListChannels() []ChannelInfo {
333
+ m.mu.Lock()
334
+ defer m.mu.Unlock()
335
+ out := make([]ChannelInfo, 0, len(m.channels))
336
+ for _, rec := range m.channels {
337
+ ci := ChannelInfo{
338
+ Name: rec.name,
339
+ ProvisionedAt: rec.provisionedAt,
340
+ }
341
+ if m.policy != nil {
342
+ ci.Type = m.policy.TypeName(rec.name)
343
+ ci.Ephemeral = m.policy.IsEphemeral(rec.name)
344
+ ttl := m.policy.TTLFor(rec.name)
345
+ if ttl > 0 {
346
+ ci.TTLSeconds = int64(ttl.Seconds())
347
+ }
348
+ }
349
+ out = append(out, ci)
350
+ }
351
+ return out
352
+}
321353
322354
// ValidateName checks that a channel name follows scuttlebot conventions.
323355
func ValidateName(name string) error {
324356
if !strings.HasPrefix(name, "#") {
325357
return fmt.Errorf("topology: channel name must start with #: %q", name)
326358
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -316,10 +316,42 @@
316
317 func (m *Manager) chanserv(format string, args ...any) {
318 msg := fmt.Sprintf(format, args...)
319 m.client.Cmd.Message("ChanServ", msg)
320 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
322 // ValidateName checks that a channel name follows scuttlebot conventions.
323 func ValidateName(name string) error {
324 if !strings.HasPrefix(name, "#") {
325 return fmt.Errorf("topology: channel name must start with #: %q", name)
326
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -316,10 +316,42 @@
316
317 func (m *Manager) chanserv(format string, args ...any) {
318 msg := fmt.Sprintf(format, args...)
319 m.client.Cmd.Message("ChanServ", msg)
320 }
321
322 // ChannelInfo describes an active provisioned channel.
323 type ChannelInfo struct {
324 Name string `json:"name"`
325 ProvisionedAt time.Time `json:"provisioned_at"`
326 Type string `json:"type,omitempty"`
327 Ephemeral bool `json:"ephemeral,omitempty"`
328 TTLSeconds int64 `json:"ttl_seconds,omitempty"`
329 }
330
331 // ListChannels returns all actively provisioned channels.
332 func (m *Manager) ListChannels() []ChannelInfo {
333 m.mu.Lock()
334 defer m.mu.Unlock()
335 out := make([]ChannelInfo, 0, len(m.channels))
336 for _, rec := range m.channels {
337 ci := ChannelInfo{
338 Name: rec.name,
339 ProvisionedAt: rec.provisionedAt,
340 }
341 if m.policy != nil {
342 ci.Type = m.policy.TypeName(rec.name)
343 ci.Ephemeral = m.policy.IsEphemeral(rec.name)
344 ttl := m.policy.TTLFor(rec.name)
345 if ttl > 0 {
346 ci.TTLSeconds = int64(ttl.Seconds())
347 }
348 }
349 out = append(out, ci)
350 }
351 return out
352 }
353
354 // ValidateName checks that a channel name follows scuttlebot conventions.
355 func ValidateName(name string) error {
356 if !strings.HasPrefix(name, "#") {
357 return fmt.Errorf("topology: channel name must start with #: %q", name)
358

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button