ScuttleBot
Merge pull request #141 from ConflictHQ/feature/115-web-ui-topology feat: topology panel, task channels, ROE editor
Commit
900677e50c00eb26a35853b100198a6518b92aa8edb0d4a9a0249b5f458a75a0
Parent
68677f99845f236…
9 files changed
+5
-2
+5
-2
+2
+2
+18
-5
+167
+167
+32
+32
~
internal/api/channels_topology.go
~
internal/api/channels_topology.go
~
internal/api/channels_topology_test.go
~
internal/api/channels_topology_test.go
~
internal/api/policies.go
~
internal/api/ui/index.html
~
internal/api/ui/index.html
~
internal/topology/topology.go
~
internal/topology/topology.go
| --- internal/api/channels_topology.go | ||
| +++ internal/api/channels_topology.go | ||
| @@ -14,10 +14,11 @@ | ||
| 14 | 14 | ProvisionChannel(ch topology.ChannelConfig) error |
| 15 | 15 | DropChannel(channel string) |
| 16 | 16 | Policy() *topology.Policy |
| 17 | 17 | GrantAccess(nick, channel, level string) |
| 18 | 18 | RevokeAccess(nick, channel string) |
| 19 | + ListChannels() []topology.ChannelInfo | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | type provisionChannelRequest struct { |
| 22 | 23 | Name string `json:"name"` |
| 23 | 24 | Topic string `json:"topic,omitempty"` |
| @@ -96,12 +97,13 @@ | ||
| 96 | 97 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 97 | 98 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 98 | 99 | } |
| 99 | 100 | |
| 100 | 101 | 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"` | |
| 103 | 105 | } |
| 104 | 106 | |
| 105 | 107 | // handleDropChannel handles DELETE /v1/topology/channels/{channel}. |
| 106 | 108 | // Drops the ChanServ registration of an ephemeral channel. |
| 107 | 109 | func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) { |
| @@ -151,7 +153,8 @@ | ||
| 151 | 153 | } |
| 152 | 154 | |
| 153 | 155 | writeJSON(w, http.StatusOK, topologyResponse{ |
| 154 | 156 | StaticChannels: staticNames, |
| 155 | 157 | Types: typeInfos, |
| 158 | + ActiveChannels: s.topoMgr.ListChannels(), | |
| 156 | 159 | }) |
| 157 | 160 | } |
| 158 | 161 |
| --- 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 @@ | ||
| 14 | 14 | ProvisionChannel(ch topology.ChannelConfig) error |
| 15 | 15 | DropChannel(channel string) |
| 16 | 16 | Policy() *topology.Policy |
| 17 | 17 | GrantAccess(nick, channel, level string) |
| 18 | 18 | RevokeAccess(nick, channel string) |
| 19 | + ListChannels() []topology.ChannelInfo | |
| 19 | 20 | } |
| 20 | 21 | |
| 21 | 22 | type provisionChannelRequest struct { |
| 22 | 23 | Name string `json:"name"` |
| 23 | 24 | Topic string `json:"topic,omitempty"` |
| @@ -96,12 +97,13 @@ | ||
| 96 | 97 | Ephemeral bool `json:"ephemeral,omitempty"` |
| 97 | 98 | TTLSeconds int64 `json:"ttl_seconds,omitempty"` |
| 98 | 99 | } |
| 99 | 100 | |
| 100 | 101 | 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"` | |
| 103 | 105 | } |
| 104 | 106 | |
| 105 | 107 | // handleDropChannel handles DELETE /v1/topology/channels/{channel}. |
| 106 | 108 | // Drops the ChanServ registration of an ephemeral channel. |
| 107 | 109 | func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) { |
| @@ -151,7 +153,8 @@ | ||
| 151 | 153 | } |
| 152 | 154 | |
| 153 | 155 | writeJSON(w, http.StatusOK, topologyResponse{ |
| 154 | 156 | StaticChannels: staticNames, |
| 155 | 157 | Types: typeInfos, |
| 158 | + ActiveChannels: s.topoMgr.ListChannels(), | |
| 156 | 159 | }) |
| 157 | 160 | } |
| 158 | 161 |
| --- 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 @@ | ||
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 51 | 51 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 52 | 52 | } |
| 53 | + | |
| 54 | +func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil } | |
| 53 | 55 | |
| 54 | 56 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 55 | 57 | type stubProvisioner struct { |
| 56 | 58 | accounts map[string]string |
| 57 | 59 | } |
| 58 | 60 |
| --- 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 @@ | ||
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | func (s *stubTopologyManager) RevokeAccess(nick, channel string) { |
| 51 | 51 | s.revokes = append(s.revokes, accessCall{Nick: nick, Channel: channel}) |
| 52 | 52 | } |
| 53 | + | |
| 54 | +func (s *stubTopologyManager) ListChannels() []topology.ChannelInfo { return nil } | |
| 53 | 55 | |
| 54 | 56 | // stubProvisioner is a minimal AccountProvisioner for agent registration tests. |
| 55 | 57 | type stubProvisioner struct { |
| 56 | 58 | accounts map[string]string |
| 57 | 59 | } |
| 58 | 60 |
| --- 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 |
+18
-5
| --- internal/api/policies.go | ||
| +++ internal/api/policies.go | ||
| @@ -68,18 +68,31 @@ | ||
| 68 | 68 | AWSSecretKey string `json:"aws_secret_key,omitempty"` |
| 69 | 69 | Allow []string `json:"allow,omitempty"` |
| 70 | 70 | Block []string `json:"block,omitempty"` |
| 71 | 71 | Default bool `json:"default,omitempty"` |
| 72 | 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 | +} | |
| 73 | 85 | |
| 74 | 86 | // Policies is the full mutable settings blob, persisted to policies.json. |
| 75 | 87 | 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"` | |
| 81 | 94 | } |
| 82 | 95 | |
| 83 | 96 | // defaultBehaviors lists every built-in bot with conservative defaults (disabled). |
| 84 | 97 | var defaultBehaviors = []BehaviorConfig{ |
| 85 | 98 | { |
| 86 | 99 |
| --- 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 |
+167
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -485,10 +485,40 @@ | ||
| 485 | 485 | <button class="sm primary" onclick="quickJoin()">join</button> |
| 486 | 486 | </div> |
| 487 | 487 | </div> |
| 488 | 488 | <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div> |
| 489 | 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> | |
| 490 | 520 | </div> |
| 491 | 521 | </div> |
| 492 | 522 | |
| 493 | 523 | <!-- CHAT --> |
| 494 | 524 | <div class="tab-pane" id="pane-chat"> |
| @@ -1756,10 +1786,19 @@ | ||
| 1756 | 1786 | allChannels = (data.channels || []).sort(); |
| 1757 | 1787 | renderChanList(); |
| 1758 | 1788 | } catch(e) { |
| 1759 | 1789 | document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>'; |
| 1760 | 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) {} | |
| 1761 | 1800 | } |
| 1762 | 1801 | |
| 1763 | 1802 | function renderChanList() { |
| 1764 | 1803 | const q = (document.getElementById('chan-search').value||'').toLowerCase(); |
| 1765 | 1804 | const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q)); |
| @@ -1794,10 +1833,138 @@ | ||
| 1794 | 1833 | await loadChanTab(); |
| 1795 | 1834 | renderChanSidebar((await api('GET','/v1/channels')).channels||[]); |
| 1796 | 1835 | } catch(e) { alert('Join failed: '+e.message); } |
| 1797 | 1836 | } |
| 1798 | 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 | +} | |
| 1799 | 1966 | |
| 1800 | 1967 | // --- chat --- |
| 1801 | 1968 | let chatChannel = null, chatSSE = null; |
| 1802 | 1969 | |
| 1803 | 1970 | async function loadChannels() { |
| 1804 | 1971 |
| --- 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 |
+167
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -485,10 +485,40 @@ | ||
| 485 | 485 | <button class="sm primary" onclick="quickJoin()">join</button> |
| 486 | 486 | </div> |
| 487 | 487 | </div> |
| 488 | 488 | <div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div> |
| 489 | 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> | |
| 490 | 520 | </div> |
| 491 | 521 | </div> |
| 492 | 522 | |
| 493 | 523 | <!-- CHAT --> |
| 494 | 524 | <div class="tab-pane" id="pane-chat"> |
| @@ -1756,10 +1786,19 @@ | ||
| 1756 | 1786 | allChannels = (data.channels || []).sort(); |
| 1757 | 1787 | renderChanList(); |
| 1758 | 1788 | } catch(e) { |
| 1759 | 1789 | document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>'; |
| 1760 | 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) {} | |
| 1761 | 1800 | } |
| 1762 | 1801 | |
| 1763 | 1802 | function renderChanList() { |
| 1764 | 1803 | const q = (document.getElementById('chan-search').value||'').toLowerCase(); |
| 1765 | 1804 | const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q)); |
| @@ -1794,10 +1833,138 @@ | ||
| 1794 | 1833 | await loadChanTab(); |
| 1795 | 1834 | renderChanSidebar((await api('GET','/v1/channels')).channels||[]); |
| 1796 | 1835 | } catch(e) { alert('Join failed: '+e.message); } |
| 1797 | 1836 | } |
| 1798 | 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 | +} | |
| 1799 | 1966 | |
| 1800 | 1967 | // --- chat --- |
| 1801 | 1968 | let chatChannel = null, chatSSE = null; |
| 1802 | 1969 | |
| 1803 | 1970 | async function loadChannels() { |
| 1804 | 1971 |
| --- 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 @@ | ||
| 316 | 316 | |
| 317 | 317 | func (m *Manager) chanserv(format string, args ...any) { |
| 318 | 318 | msg := fmt.Sprintf(format, args...) |
| 319 | 319 | m.client.Cmd.Message("ChanServ", msg) |
| 320 | 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 | +} | |
| 321 | 353 | |
| 322 | 354 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 323 | 355 | func ValidateName(name string) error { |
| 324 | 356 | if !strings.HasPrefix(name, "#") { |
| 325 | 357 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 326 | 358 |
| --- 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 @@ | ||
| 316 | 316 | |
| 317 | 317 | func (m *Manager) chanserv(format string, args ...any) { |
| 318 | 318 | msg := fmt.Sprintf(format, args...) |
| 319 | 319 | m.client.Cmd.Message("ChanServ", msg) |
| 320 | 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 | +} | |
| 321 | 353 | |
| 322 | 354 | // ValidateName checks that a channel name follows scuttlebot conventions. |
| 323 | 355 | func ValidateName(name string) error { |
| 324 | 356 | if !strings.HasPrefix(name, "#") { |
| 325 | 357 | return fmt.Errorf("topology: channel name must start with #: %q", name) |
| 326 | 358 |
| --- 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 |