Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef2705d08 | ||
|
|
faad47f0cf | ||
|
|
47ba1e9740 | ||
|
|
6d817867f2 | ||
|
|
877900af98 | ||
|
|
640838d5a6 | ||
|
|
6abd45eeff | ||
|
|
e94dcb84fd | ||
|
|
96d9fbd246 | ||
|
|
5938fcd555 | ||
|
|
e4b0439e6e | ||
|
|
9dd27c4b9b |
@@ -98,6 +98,8 @@ make lint # Run linters
|
||||
- modernc.org/sqlite (pure Go), migration 009_webhooks.sql (003-webhooks-k8s-runner)
|
||||
- Go 1.25+ (per go.mod) + mark3labs/mcp-go (MCP tools), go-chi/chi (HTTP), spf13/cobra (CLI), modernc.org/sqlite (storage), TFMV/hnsw (vectors) (004-embeddings-retention-inbox)
|
||||
- SQLite (modernc.org/sqlite, pure Go) — single DB file in `--data` directory (004-embeddings-retention-inbox)
|
||||
- Go 1.25+ (per go.mod) + spf13/cobra (CLI), go-chi/chi (HTTP), mark3labs/mcp-go (MCP) (006-admin-cli-docker-fixes)
|
||||
- modernc.org/sqlite (pure Go, zero CGO) (006-admin-cli-docker-fixes)
|
||||
|
||||
## Recent Changes
|
||||
- 002-mcp-auth-ux-polish: Added Go 1.23+ + ory/fosite (OAuth 2.1), mark3labs/mcp-go (MCP server), go-chi/chi (HTTP), Svelte 5 + Tailwind (Web UI)
|
||||
|
||||
+2
-3
@@ -18,9 +18,8 @@ COPY --from=web-builder /app/web/build internal/web/dist/
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o /synapbus ./cmd/synapbus/
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM scratch
|
||||
COPY --from=go-builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=go-builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
FROM alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates tzdata && touch /.dockerenv
|
||||
COPY --from=go-builder /synapbus /synapbus
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Autonomous Implementation Summary
|
||||
|
||||
**Feature**: Admin CLI & Docker Fixes
|
||||
**Branch**: `006-admin-cli-docker-fixes`
|
||||
**Date**: 2026-03-15
|
||||
**Status**: COMPLETE — 8 of 8 tasks implemented, all tests pass, binary builds
|
||||
|
||||
## What Was Built
|
||||
|
||||
### 1. Alpine Docker Base Image (T06)
|
||||
|
||||
**Problem**: `scratch` base image has no shell — `kubectl exec` into the pod can't run admin CLI commands.
|
||||
|
||||
**Solution**: Changed `FROM scratch` to `FROM alpine:3.19` in the runtime stage. Alpine provides `/bin/sh` and a working process environment. TLS certs and timezone data are now installed via `apk` instead of copied from the builder stage.
|
||||
|
||||
**File Modified**: `Dockerfile`
|
||||
|
||||
### 2. `synapbus channels create` CLI Command (T02, T04)
|
||||
|
||||
**Problem**: No CLI command to create channels — had to use REST API with session cookies.
|
||||
|
||||
**Solution**: Added `channels.create` admin socket handler and `synapbus channels create` cobra command:
|
||||
- `--name` (required): Channel name
|
||||
- `--description` (optional): Channel description
|
||||
- Creates channel via the channel service with `created_by: "system"`, type `"standard"`
|
||||
- Returns channel details as JSON
|
||||
|
||||
**Files Modified**: `cmd/synapbus/admin.go`, `internal/admin/socket.go`
|
||||
|
||||
### 3. `synapbus channels join` CLI Command (T03, T05)
|
||||
|
||||
**Problem**: No CLI command to add agents to channels.
|
||||
|
||||
**Solution**: Added `channels.join` admin socket handler and `synapbus channels join` cobra command:
|
||||
- `--channel` (required): Channel name to join
|
||||
- `--agent` (required): Agent name to add
|
||||
- Looks up channel by name, calls `JoinChannel` (idempotent)
|
||||
- Reports `"joined"` or `"already_member"` status
|
||||
|
||||
**Files Modified**: `cmd/synapbus/admin.go`, `internal/admin/socket.go`
|
||||
|
||||
### 4. Absolute Default Socket Path (T01)
|
||||
|
||||
**Problem**: Default `./data/synapbus.sock` is confusing in containers where CWD varies.
|
||||
|
||||
**Solution**: Changed default socket path from `./data/synapbus.sock` to `/data/synapbus.sock` in both the `--socket` flag definition and the `SYNAPBUS_SOCKET` env var comparison.
|
||||
|
||||
**File Modified**: `cmd/synapbus/admin.go`
|
||||
|
||||
## Tests Added (T07)
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `TestChannelsCreateCommandRegistered` | Verifies `channels create` subcommand exists |
|
||||
| `TestChannelsCreateRequiredFlags` | Verifies `--name` is required, `--description` is optional |
|
||||
| `TestChannelsJoinCommandRegistered` | Verifies `channels join` subcommand exists |
|
||||
| `TestChannelsJoinRequiredFlags` | Verifies `--channel` and `--agent` are both required |
|
||||
| `TestDefaultSocketPath` | Verifies default is `/data/synapbus.sock` |
|
||||
|
||||
## Verification Results (T08)
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `go build ./...` | PASS |
|
||||
| `go test ./...` | ALL PASS (24 packages, 0 failures) |
|
||||
| Zero CGO | Confirmed (CGO_ENABLED=0 in Dockerfile) |
|
||||
| No regressions | All 14 existing CLI tests still pass |
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `Dockerfile` | `FROM scratch` → `FROM alpine:3.19` + `apk add --no-cache ca-certificates tzdata` |
|
||||
| `cmd/synapbus/admin.go` | Default socket `/data/synapbus.sock`, `channels create` + `channels join` commands |
|
||||
| `cmd/synapbus/admin_test.go` | 5 new tests for commands, flags, and default socket |
|
||||
| `internal/admin/socket.go` | `channels.create` + `channels.join` handlers, `channels` import |
|
||||
|
||||
## CLI Commands Added
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `synapbus channels create --name X [--description Y]` | Create a new channel |
|
||||
| `synapbus channels join --channel X --agent Y` | Add an agent to a channel |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```bash
|
||||
# In Kubernetes (now works with alpine base)
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels create --name news-feed --description "News feed"
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels join --channel news-feed --agent research-mcpproxy
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list
|
||||
|
||||
# Local development
|
||||
synapbus --socket ./data/synapbus.sock channels create --name test-channel
|
||||
synapbus --socket ./data/synapbus.sock channels join --channel test-channel --agent my-agent
|
||||
```
|
||||
+50
-3
@@ -17,7 +17,7 @@ var adminSocket string
|
||||
// adminRequest sends a command over the Unix socket and returns the parsed response.
|
||||
func adminRequest(command string, args interface{}) (map[string]interface{}, error) {
|
||||
socket := adminSocket
|
||||
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "./data/synapbus.sock" {
|
||||
if s := os.Getenv("SYNAPBUS_SOCKET"); s != "" && socket == "/tmp/synapbus.sock" {
|
||||
socket = s
|
||||
}
|
||||
|
||||
@@ -561,7 +561,54 @@ func addAdminCommands(rootCmd *cobra.Command) {
|
||||
channelsShowCmd.Flags().StringVar(&channelsShowName, "name", "", "Channel name")
|
||||
channelsShowCmd.MarkFlagRequired("name")
|
||||
|
||||
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd)
|
||||
var (
|
||||
channelsCreateName string
|
||||
channelsCreateDesc string
|
||||
)
|
||||
channelsCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new channel",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
resp, err := adminRequest("channels.create", map[string]string{
|
||||
"name": channelsCreateName,
|
||||
"description": channelsCreateDesc,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(resp["data"])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
channelsCreateCmd.Flags().StringVar(&channelsCreateName, "name", "", "Channel name")
|
||||
channelsCreateCmd.Flags().StringVar(&channelsCreateDesc, "description", "", "Channel description")
|
||||
channelsCreateCmd.MarkFlagRequired("name")
|
||||
|
||||
var (
|
||||
channelsJoinChannel string
|
||||
channelsJoinAgent string
|
||||
)
|
||||
channelsJoinCmd := &cobra.Command{
|
||||
Use: "join",
|
||||
Short: "Add an agent to a channel",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
resp, err := adminRequest("channels.join", map[string]string{
|
||||
"channel": channelsJoinChannel,
|
||||
"agent": channelsJoinAgent,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printJSON(resp["data"])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
channelsJoinCmd.Flags().StringVar(&channelsJoinChannel, "channel", "", "Channel name")
|
||||
channelsJoinCmd.Flags().StringVar(&channelsJoinAgent, "agent", "", "Agent name")
|
||||
channelsJoinCmd.MarkFlagRequired("channel")
|
||||
channelsJoinCmd.MarkFlagRequired("agent")
|
||||
|
||||
channelsCmd.AddCommand(channelsListCmd, channelsShowCmd, channelsCreateCmd, channelsJoinCmd)
|
||||
|
||||
// ----- conversations commands -----
|
||||
conversationsCmd := &cobra.Command{
|
||||
@@ -919,7 +966,7 @@ func addAdminCommands(rootCmd *cobra.Command) {
|
||||
attachmentsCmd.AddCommand(attachmentsGCCmd)
|
||||
|
||||
// ----- add persistent flag and commands to root -----
|
||||
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "./data/synapbus.sock", "Path to admin Unix socket")
|
||||
rootCmd.PersistentFlags().StringVar(&adminSocket, "socket", "/tmp/synapbus.sock", "Path to admin Unix socket")
|
||||
|
||||
rootCmd.AddCommand(userCmd, agentCmd, auditCmd, backupCmd, messagesCmd, channelsCmd, conversationsCmd, embeddingsCmd, dbCmd, retentionCmd, webhookCmd, k8sCmd, attachmentsCmd)
|
||||
}
|
||||
|
||||
@@ -196,6 +196,85 @@ func TestK8sRegisterOptionalFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsCreateCommandRegistered(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
cmd := findSubcommand(root, "channels", "create")
|
||||
if cmd == nil {
|
||||
t.Fatal("channels create command not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsCreateRequiredFlags(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
cmd := findSubcommand(root, "channels", "create")
|
||||
if cmd == nil {
|
||||
t.Fatal("channels create command not found")
|
||||
}
|
||||
|
||||
// --name is required
|
||||
f := cmd.Flag("name")
|
||||
if f == nil {
|
||||
t.Fatal("flag --name not found on channels create")
|
||||
}
|
||||
ann := f.Annotations
|
||||
if ann == nil {
|
||||
t.Fatal("flag --name should be required")
|
||||
}
|
||||
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
|
||||
t.Fatal("flag --name should be required")
|
||||
}
|
||||
|
||||
// --description is optional
|
||||
df := cmd.Flag("description")
|
||||
if df == nil {
|
||||
t.Fatal("flag --description not found on channels create")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsJoinCommandRegistered(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
cmd := findSubcommand(root, "channels", "join")
|
||||
if cmd == nil {
|
||||
t.Fatal("channels join command not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsJoinRequiredFlags(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
cmd := findSubcommand(root, "channels", "join")
|
||||
if cmd == nil {
|
||||
t.Fatal("channels join command not found")
|
||||
}
|
||||
|
||||
requiredFlags := []string{"channel", "agent"}
|
||||
for _, flag := range requiredFlags {
|
||||
f := cmd.Flag(flag)
|
||||
if f == nil {
|
||||
t.Errorf("flag --%s not found on channels join", flag)
|
||||
continue
|
||||
}
|
||||
ann := f.Annotations
|
||||
if ann == nil {
|
||||
t.Errorf("flag --%s should be required", flag)
|
||||
continue
|
||||
}
|
||||
if _, ok := ann[cobra.BashCompOneRequiredFlag]; !ok {
|
||||
t.Errorf("flag --%s should be required", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSocketPath(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
f := root.PersistentFlags().Lookup("socket")
|
||||
if f == nil {
|
||||
t.Fatal("--socket persistent flag not found")
|
||||
}
|
||||
if f.DefValue != "/tmp/synapbus.sock" {
|
||||
t.Errorf("default socket path = %q, want %q", f.DefValue, "/tmp/synapbus.sock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingCommandsStillPresent(t *testing.T) {
|
||||
root := buildTestRoot()
|
||||
|
||||
|
||||
+17
-2
@@ -161,7 +161,13 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
messageRetention = mr
|
||||
}
|
||||
if adminSocketPath == "" {
|
||||
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
|
||||
// Default to /tmp in containers — PVC-backed filesystems (NFS, Ceph,
|
||||
// EBS CSI) often don't support Unix domain sockets.
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
adminSocketPath = "/tmp/synapbus.sock"
|
||||
} else {
|
||||
adminSocketPath = filepath.Join(dataDir, "synapbus.sock")
|
||||
}
|
||||
}
|
||||
|
||||
// Configure slog with JSON handler writing to stderr (stdout is for console output)
|
||||
@@ -389,6 +395,9 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
embPipeline = search.NewPipeline(embProvider, embStore, vectorIndex, searchCfg)
|
||||
embPipeline.Start(ctx)
|
||||
|
||||
// Wire pipeline into messaging so new messages auto-enqueue
|
||||
msgService.SetEmbeddingNotifier(embPipeline)
|
||||
|
||||
// Create search service with semantic support
|
||||
searchService = search.NewService(db.DB, embProvider, vectorIndex, msgService)
|
||||
slog.Info("semantic search enabled",
|
||||
@@ -529,8 +538,13 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
r.Mount("/mcp", mcpSrv.Handler())
|
||||
})
|
||||
|
||||
// Create SSE hub for real-time events
|
||||
// Create SSE hub and broadcaster for real-time events
|
||||
sseHub := api.NewSSEHub()
|
||||
sseBroadcaster := api.NewSSEBroadcaster(sseHub, agentService, channelService)
|
||||
|
||||
// Register broadcaster as a message listener so SSE events fire
|
||||
// for messages sent via MCP (agents) as well as the REST API.
|
||||
msgService.AddMessageListener(sseBroadcaster)
|
||||
|
||||
// Mount API routes (traces, export, stats, metrics, attachments, messages, agents, channels, SSE)
|
||||
sessionMiddleware := api.SessionToOwnerMiddleware(userStore, sessionStore)
|
||||
@@ -544,6 +558,7 @@ func runServe(cmd *cobra.Command, args []string) error {
|
||||
APIKeyService: apiKeyService,
|
||||
DeadLetterStore: deadLetterStore,
|
||||
SSEHub: sseHub,
|
||||
Broadcaster: sseBroadcaster,
|
||||
SessionMiddleware: sessionMiddleware,
|
||||
})
|
||||
r.Mount("/", apiRouter)
|
||||
|
||||
@@ -39,6 +39,10 @@ spec:
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- with .Values.envFrom }}
|
||||
envFrom:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
@@ -56,6 +60,8 @@ spec:
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: run
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
@@ -64,6 +70,10 @@ spec:
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
- name: run
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
sizeLimit: 1Mi
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -11,5 +11,8 @@ spec:
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
|
||||
nodePort: {{ .Values.service.nodePort }}
|
||||
{{- end }}
|
||||
selector:
|
||||
{{- include "synapbus.selectorLabels" . | nindent 4 }}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/k8s"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
@@ -162,6 +163,10 @@ func (s *AdminServer) dispatch(req Request) Response {
|
||||
return s.handleChannelsList(ctx)
|
||||
case "channels.show":
|
||||
return s.handleChannelsShow(ctx, req.Args)
|
||||
case "channels.create":
|
||||
return s.handleChannelsCreate(ctx, req.Args)
|
||||
case "channels.join":
|
||||
return s.handleChannelsJoin(ctx, req.Args)
|
||||
|
||||
// --- conversations ---
|
||||
case "conversations.list":
|
||||
@@ -887,6 +892,78 @@ func (s *AdminServer) handleChannelsShow(ctx context.Context, args json.RawMessa
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *AdminServer) handleChannelsCreate(ctx context.Context, args json.RawMessage) Response {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return Response{OK: false, Error: "invalid args: " + err.Error()}
|
||||
}
|
||||
if p.Name == "" {
|
||||
return Response{OK: false, Error: "name is required"}
|
||||
}
|
||||
|
||||
ch, err := s.services.Channels.CreateChannel(ctx, channels.CreateChannelRequest{
|
||||
Name: p.Name,
|
||||
Description: p.Description,
|
||||
Type: "standard",
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
return Response{OK: false, Error: err.Error()}
|
||||
}
|
||||
|
||||
return Response{OK: true, Data: map[string]interface{}{
|
||||
"id": ch.ID,
|
||||
"name": ch.Name,
|
||||
"description": ch.Description,
|
||||
"type": ch.Type,
|
||||
"is_private": ch.IsPrivate,
|
||||
"created_by": ch.CreatedBy,
|
||||
"created_at": ch.CreatedAt.Format(time.RFC3339),
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *AdminServer) handleChannelsJoin(ctx context.Context, args json.RawMessage) Response {
|
||||
var p struct {
|
||||
Channel string `json:"channel"`
|
||||
Agent string `json:"agent"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return Response{OK: false, Error: "invalid args: " + err.Error()}
|
||||
}
|
||||
if p.Channel == "" {
|
||||
return Response{OK: false, Error: "channel is required"}
|
||||
}
|
||||
if p.Agent == "" {
|
||||
return Response{OK: false, Error: "agent is required"}
|
||||
}
|
||||
|
||||
ch, err := s.services.Channels.GetChannelByName(ctx, p.Channel)
|
||||
if err != nil {
|
||||
return Response{OK: false, Error: fmt.Sprintf("channel not found: %s", p.Channel)}
|
||||
}
|
||||
|
||||
// Check if already a member for status reporting
|
||||
isMember, _ := s.services.Channels.IsMember(ctx, ch.ID, p.Agent)
|
||||
|
||||
if err := s.services.Channels.JoinChannel(ctx, ch.ID, p.Agent); err != nil {
|
||||
return Response{OK: false, Error: err.Error()}
|
||||
}
|
||||
|
||||
status := "joined"
|
||||
if isMember {
|
||||
status = "already_member"
|
||||
}
|
||||
|
||||
return Response{OK: true, Data: map[string]interface{}{
|
||||
"channel": p.Channel,
|
||||
"agent": p.Agent,
|
||||
"status": status,
|
||||
}}
|
||||
}
|
||||
|
||||
// ---------- conversations handlers ----------
|
||||
|
||||
func (s *AdminServer) handleConversationsList(ctx context.Context, args json.RawMessage) Response {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/synapbus/synapbus/internal/agents"
|
||||
"github.com/synapbus/synapbus/internal/channels"
|
||||
"github.com/synapbus/synapbus/internal/messaging"
|
||||
)
|
||||
|
||||
// NewMessageEvent is broadcast when a new message is sent.
|
||||
@@ -107,3 +108,24 @@ func (b *SSEBroadcaster) BroadcastChannelMessage(ctx context.Context, channelID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnMessageSent implements messaging.MessageListener so the SSEBroadcaster
|
||||
// can be wired directly into the messaging service. This ensures SSE events
|
||||
// fire for messages sent via MCP (agents) as well as the REST API.
|
||||
func (b *SSEBroadcaster) OnMessageSent(ctx context.Context, msg *messaging.Message) {
|
||||
event := NewMessageEvent{
|
||||
MessageID: msg.ID,
|
||||
FromAgent: msg.FromAgent,
|
||||
ToAgent: msg.ToAgent,
|
||||
}
|
||||
|
||||
if msg.ChannelID != nil {
|
||||
ch, err := b.channelService.GetChannel(ctx, *msg.ChannelID)
|
||||
if err == nil {
|
||||
event.Channel = ch.Name
|
||||
}
|
||||
b.BroadcastChannelMessage(ctx, *msg.ChannelID, event)
|
||||
} else {
|
||||
b.BroadcastDM(ctx, event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -315,23 +316,8 @@ func (h *MessagesHandler) SendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast real-time event to connected SSE clients
|
||||
if h.broadcaster != nil {
|
||||
event := NewMessageEvent{
|
||||
MessageID: msg.ID,
|
||||
FromAgent: msg.FromAgent,
|
||||
ToAgent: msg.ToAgent,
|
||||
}
|
||||
if msg.ChannelID != nil && h.broadcaster.channelService != nil {
|
||||
ch, chErr := h.broadcaster.channelService.GetChannel(r.Context(), *msg.ChannelID)
|
||||
if chErr == nil {
|
||||
event.Channel = ch.Name
|
||||
}
|
||||
h.broadcaster.BroadcastChannelMessage(r.Context(), *msg.ChannelID, event)
|
||||
} else {
|
||||
h.broadcaster.BroadcastDM(r.Context(), event)
|
||||
}
|
||||
}
|
||||
// SSE broadcast is handled by the MessageListener on the messaging
|
||||
// service, so it fires for both REST and MCP message paths.
|
||||
|
||||
writeJSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
@@ -414,9 +400,60 @@ func (h *MessagesHandler) SearchMessages(w http.ResponseWriter, r *http.Request)
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Parse advanced filter parameters
|
||||
channelFilter := r.URL.Query().Get("channel")
|
||||
agentFilter := r.URL.Query().Get("agent")
|
||||
afterFilter := r.URL.Query().Get("after")
|
||||
beforeFilter := r.URL.Query().Get("before")
|
||||
|
||||
var allMessages []*messaging.Message
|
||||
for _, agent := range ownedAgents {
|
||||
opts := messaging.SearchOptions{Limit: limit}
|
||||
opts := messaging.SearchOptions{
|
||||
Limit: limit,
|
||||
After: afterFilter,
|
||||
Before: beforeFilter,
|
||||
}
|
||||
|
||||
// Apply channel filters (comma-separated, prefix with - to exclude)
|
||||
if channelFilter != "" {
|
||||
channels := strings.Split(channelFilter, ",")
|
||||
var includeChannels []string
|
||||
var excludeChannels []string
|
||||
for _, ch := range channels {
|
||||
ch = strings.TrimSpace(ch)
|
||||
if ch == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(ch, "-") {
|
||||
excludeChannels = append(excludeChannels, strings.TrimPrefix(ch, "-"))
|
||||
} else {
|
||||
includeChannels = append(includeChannels, ch)
|
||||
}
|
||||
}
|
||||
opts.Channels = includeChannels
|
||||
opts.ExcludeChannels = excludeChannels
|
||||
}
|
||||
|
||||
// Apply agent filters (comma-separated, prefix with - to exclude)
|
||||
if agentFilter != "" {
|
||||
agentNames := strings.Split(agentFilter, ",")
|
||||
var includeAgents []string
|
||||
var excludeAgents []string
|
||||
for _, a := range agentNames {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(a, "-") {
|
||||
excludeAgents = append(excludeAgents, strings.TrimPrefix(a, "-"))
|
||||
} else {
|
||||
includeAgents = append(includeAgents, a)
|
||||
}
|
||||
}
|
||||
opts.Agents = includeAgents
|
||||
opts.ExcludeAgents = excludeAgents
|
||||
}
|
||||
|
||||
result, err := h.msgService.SearchMessages(r.Context(), agent.Name, query, opts)
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -31,6 +31,7 @@ type RouterConfig struct {
|
||||
K8sService *k8s.K8sService
|
||||
K8sStore k8s.K8sStore
|
||||
SSEHub *SSEHub
|
||||
Broadcaster *SSEBroadcaster
|
||||
SessionMiddleware func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
@@ -88,9 +89,10 @@ func NewRouterWithConfig(cfg RouterConfig) chi.Router {
|
||||
notificationsHandler := NewNotificationsHandler(cfg.MsgService, cfg.AgentService, cfg.ChannelService)
|
||||
|
||||
// Wire up SSE broadcaster for real-time events
|
||||
if cfg.SSEHub != nil {
|
||||
broadcaster := NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService)
|
||||
messagesHandler.SetBroadcaster(broadcaster)
|
||||
if cfg.Broadcaster != nil {
|
||||
messagesHandler.SetBroadcaster(cfg.Broadcaster)
|
||||
} else if cfg.SSEHub != nil {
|
||||
messagesHandler.SetBroadcaster(NewSSEBroadcaster(cfg.SSEHub, cfg.AgentService, cfg.ChannelService))
|
||||
}
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
|
||||
@@ -816,13 +816,28 @@ const authorizeTemplateHTML = `<!DOCTYPE html>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-width="1.8">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="#36c5f0"/>
|
||||
<path d="M2 17l10 5 10-5" stroke="#7c3aed"/>
|
||||
<path d="M2 12l10 5 10-5" stroke="url(#g)"/>
|
||||
<defs><linearGradient id="g" x1="2" y1="12" x2="22" y2="17" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#36c5f0"/><stop offset="1" stop-color="#7c3aed"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs><linearGradient id="bg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7c3aed"/><stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient></defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
|
||||
<g transform="translate(4,4)">
|
||||
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd"/>
|
||||
<circle cx="6" cy="10" r="1.5" fill="#a78bfa"/>
|
||||
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd"/>
|
||||
<circle cx="12" cy="13" r="2" fill="#67e8f9"/>
|
||||
<circle cx="5" cy="17" r="1.5" fill="#a78bfa"/>
|
||||
<circle cx="19" cy="17" r="1.3" fill="#a78bfa"/>
|
||||
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd"/>
|
||||
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3"/>
|
||||
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1>SynapBus</h1>
|
||||
<p>Agent Authorization</p>
|
||||
|
||||
@@ -471,13 +471,18 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify sender is a member
|
||||
// Verify sender is a member; auto-join public channels on first send.
|
||||
isMember, err := s.store.IsMember(ctx, channelID, fromAgent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check membership: %w", err)
|
||||
}
|
||||
if !isMember {
|
||||
return nil, ErrNotChannelMember
|
||||
if ch.IsPrivate {
|
||||
return nil, ErrNotChannelMember
|
||||
}
|
||||
if err := s.JoinChannel(ctx, channelID, fromAgent); err != nil {
|
||||
return nil, fmt.Errorf("auto-join public channel: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get members for mentions and inbox notifications
|
||||
@@ -526,10 +531,10 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
return nil, fmt.Errorf("create channel message: %w", err)
|
||||
}
|
||||
|
||||
// 2. Deliver inbox notifications to other members.
|
||||
// 2. Deliver inbox notifications only to @mentioned members.
|
||||
recipientCount := 0
|
||||
for _, m := range members {
|
||||
if m.AgentName == fromAgent {
|
||||
if m.AgentName == fromAgent || !mentionedMembers[m.AgentName] {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -537,12 +542,7 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
"channel_id": channelID,
|
||||
"channel_name": ch.Name,
|
||||
"channel_message_id": channelMsg.ID,
|
||||
}
|
||||
if len(mentionedAgentsList) > 0 {
|
||||
inboxMetaObj["mentioned_agents"] = mentionedAgentsList
|
||||
}
|
||||
if mentionedMembers[m.AgentName] {
|
||||
inboxMetaObj["mention"] = true
|
||||
"mention": true,
|
||||
}
|
||||
inboxMetaBytes, _ := json.Marshal(inboxMetaObj)
|
||||
|
||||
@@ -552,7 +552,7 @@ func (s *Service) BroadcastMessage(ctx context.Context, channelID int64, fromAge
|
||||
Metadata: string(inboxMetaBytes),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("failed to send channel notification",
|
||||
s.logger.Error("failed to send mention notification",
|
||||
"channel_id", channelID,
|
||||
"from", fromAgent,
|
||||
"to", m.AgentName,
|
||||
|
||||
@@ -565,26 +565,21 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("broadcast delivers inbox notifications", func(t *testing.T) {
|
||||
t.Run("broadcast without mentions sends no DMs", func(t *testing.T) {
|
||||
svc.JoinChannel(ctx, ch.ID, "agent-b")
|
||||
svc.JoinChannel(ctx, ch.ID, "agent-c")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "multi-member test", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "agent-a", "no-dm-test", 5, "")
|
||||
if err != nil {
|
||||
t.Fatalf("BroadcastMessage: %v", err)
|
||||
}
|
||||
|
||||
// agent-b should have an inbox notification
|
||||
// agent-b should NOT have an inbox notification (no @mention)
|
||||
inboxResult, _ := svc.msgService.ReadInbox(ctx, "agent-b", messaging.ReadOptions{IncludeRead: true})
|
||||
found := false
|
||||
for _, m := range inboxResult.Messages {
|
||||
if m.Body == "multi-member test" {
|
||||
found = true
|
||||
break
|
||||
if m.Body == "no-dm-test" {
|
||||
t.Error("agent-b should not receive inbox DM for non-mention broadcast")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("agent-b did not receive inbox notification")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sender does not receive own message", func(t *testing.T) {
|
||||
@@ -597,12 +592,29 @@ func TestService_BroadcastMessage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-member cannot broadcast", func(t *testing.T) {
|
||||
// agent-c is a member but let's test someone who isn't
|
||||
t.Run("non-member auto-joins public channel on broadcast", func(t *testing.T) {
|
||||
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "unauthorized", 5, "")
|
||||
_, err := svc.BroadcastMessage(ctx, ch.ID, "outsider", "auto-joined", 5, "")
|
||||
if err != nil {
|
||||
t.Fatalf("expected auto-join for public channel, got %v", err)
|
||||
}
|
||||
isMember, _ := svc.IsMember(ctx, ch.ID, "outsider")
|
||||
if !isMember {
|
||||
t.Error("outsider should be a member after auto-join")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-member cannot broadcast to private channel", func(t *testing.T) {
|
||||
privCh, err := svc.CreateChannel(ctx, CreateChannelRequest{
|
||||
Name: "private-test", Type: TypeStandard, IsPrivate: true, CreatedBy: "agent-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create private channel: %v", err)
|
||||
}
|
||||
seedAgent(t, svc.store.(*SQLiteChannelStore).db, "outsider2")
|
||||
_, err = svc.BroadcastMessage(ctx, privCh.ID, "outsider2", "unauthorized", 5, "")
|
||||
if !errors.Is(err, ErrNotChannelMember) {
|
||||
t.Errorf("expected ErrNotChannelMember, got %v", err)
|
||||
t.Errorf("expected ErrNotChannelMember for private channel, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -642,20 +654,11 @@ func TestService_BroadcastMessage_Mentions(t *testing.T) {
|
||||
t.Error("agent-b did not receive inbox notification")
|
||||
}
|
||||
|
||||
// agent-c was NOT mentioned — inbox notification should NOT have mention:true
|
||||
// agent-c was NOT mentioned — should NOT receive an inbox DM at all
|
||||
inboxResult, _ = svc.msgService.ReadInbox(ctx, "agent-c", messaging.ReadOptions{IncludeRead: true})
|
||||
for _, m := range inboxResult.Messages {
|
||||
if m.Body == "hey @agent-b check this" {
|
||||
var meta map[string]any
|
||||
json.Unmarshal(m.Metadata, &meta)
|
||||
if meta["mention"] == true {
|
||||
t.Error("agent-c should NOT have mention flag")
|
||||
}
|
||||
// But should still have mentioned_agents list
|
||||
if _, ok := meta["mentioned_agents"]; !ok {
|
||||
t.Error("agent-c metadata should have mentioned_agents list")
|
||||
}
|
||||
break
|
||||
t.Error("agent-c should not receive inbox DM when not @mentioned")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,14 +25,18 @@ type ReadOptions struct {
|
||||
|
||||
// SearchOptions configures message search behavior.
|
||||
type SearchOptions struct {
|
||||
FromAgent string `json:"from_agent,omitempty"`
|
||||
ToAgent string `json:"to_agent,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
MinPriority int `json:"min_priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
After string `json:"after,omitempty"`
|
||||
Before string `json:"before,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
FromAgent string `json:"from_agent,omitempty"`
|
||||
ToAgent string `json:"to_agent,omitempty"`
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
MinPriority int `json:"min_priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
After string `json:"after,omitempty"`
|
||||
Before string `json:"before,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Channels []string `json:"channels,omitempty"` // include messages in these channels
|
||||
ExcludeChannels []string `json:"exclude_channels,omitempty"` // exclude messages in these channels
|
||||
Agents []string `json:"agents,omitempty"` // include messages from/to these agents
|
||||
ExcludeAgents []string `json:"exclude_agents,omitempty"` // exclude messages from/to these agents
|
||||
}
|
||||
|
||||
@@ -12,11 +12,25 @@ import (
|
||||
"github.com/synapbus/synapbus/internal/trace"
|
||||
)
|
||||
|
||||
// EmbeddingNotifier is called when messages are created or deleted so the
|
||||
// embedding pipeline can enqueue them without a direct import dependency.
|
||||
type EmbeddingNotifier interface {
|
||||
OnMessageCreated(ctx context.Context, messageID int64, body string)
|
||||
}
|
||||
|
||||
// MessageListener is notified after every message is persisted.
|
||||
// Implementations must not block — use goroutines for slow work.
|
||||
type MessageListener interface {
|
||||
OnMessageSent(ctx context.Context, msg *Message)
|
||||
}
|
||||
|
||||
// MessagingService provides business logic for messaging operations.
|
||||
type MessagingService struct {
|
||||
store MessageStore
|
||||
tracer *trace.Tracer
|
||||
dispatcher dispatcher.EventDispatcher
|
||||
embeddings EmbeddingNotifier
|
||||
listeners []MessageListener
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
@@ -34,6 +48,16 @@ func (s *MessagingService) SetDispatcher(d dispatcher.EventDispatcher) {
|
||||
s.dispatcher = d
|
||||
}
|
||||
|
||||
// SetEmbeddingNotifier sets the embedding pipeline callback for new messages.
|
||||
func (s *MessagingService) SetEmbeddingNotifier(n EmbeddingNotifier) {
|
||||
s.embeddings = n
|
||||
}
|
||||
|
||||
// AddMessageListener registers a listener that is notified after message creation.
|
||||
func (s *MessagingService) AddMessageListener(l MessageListener) {
|
||||
s.listeners = append(s.listeners, l)
|
||||
}
|
||||
|
||||
// SendMessage creates a message, auto-creating conversations as needed.
|
||||
func (s *MessagingService) SendMessage(ctx context.Context, from, to, body string, opts SendOptions) (*Message, error) {
|
||||
// Validate inputs
|
||||
@@ -121,6 +145,16 @@ func (s *MessagingService) SendMessage(ctx context.Context, from, to, body strin
|
||||
return nil, fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
|
||||
// Enqueue for embedding (async, best-effort)
|
||||
if s.embeddings != nil {
|
||||
s.embeddings.OnMessageCreated(ctx, msg.ID, msg.Body)
|
||||
}
|
||||
|
||||
// Notify listeners (SSE, etc.)
|
||||
for _, l := range s.listeners {
|
||||
l.OnMessageSent(ctx, msg)
|
||||
}
|
||||
|
||||
s.logger.Info("message sent",
|
||||
"from", from,
|
||||
"to", to,
|
||||
|
||||
@@ -492,6 +492,57 @@ func (s *SQLiteMessageStore) buildSearchConditions(agentName, query string, opts
|
||||
args = append(args, opts.Channel)
|
||||
}
|
||||
|
||||
if len(opts.Channels) > 0 {
|
||||
placeholders := make([]string, len(opts.Channels))
|
||||
for i, ch := range opts.Channels {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, ch)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("m.channel_id IN (SELECT id FROM channels WHERE LOWER(name) IN (%s))", strings.Join(placeholders, ",")))
|
||||
}
|
||||
|
||||
if len(opts.ExcludeChannels) > 0 {
|
||||
placeholders := make([]string, len(opts.ExcludeChannels))
|
||||
for i, ch := range opts.ExcludeChannels {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, ch)
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("(m.channel_id IS NULL OR m.channel_id NOT IN (SELECT id FROM channels WHERE LOWER(name) IN (%s)))", strings.Join(placeholders, ",")))
|
||||
}
|
||||
|
||||
if len(opts.Agents) > 0 {
|
||||
placeholders := make([]string, len(opts.Agents))
|
||||
for i, a := range opts.Agents {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
// Clone placeholders for the second IN clause
|
||||
placeholders2 := make([]string, len(opts.Agents))
|
||||
for i, a := range opts.Agents {
|
||||
placeholders2[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause2 := strings.Join(placeholders2, ",")
|
||||
conditions = append(conditions, fmt.Sprintf("(m.from_agent IN (%s) OR m.to_agent IN (%s))", inClause, inClause2))
|
||||
}
|
||||
|
||||
if len(opts.ExcludeAgents) > 0 {
|
||||
placeholders := make([]string, len(opts.ExcludeAgents))
|
||||
for i, a := range opts.ExcludeAgents {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
placeholders2 := make([]string, len(opts.ExcludeAgents))
|
||||
for i, a := range opts.ExcludeAgents {
|
||||
placeholders2[i] = "?"
|
||||
args = append(args, a)
|
||||
}
|
||||
inClause2 := strings.Join(placeholders2, ",")
|
||||
conditions = append(conditions, fmt.Sprintf("m.from_agent NOT IN (%s) AND (m.to_agent = '' OR m.to_agent NOT IN (%s))", inClause, inClause2))
|
||||
}
|
||||
|
||||
if opts.After != "" {
|
||||
if t, err := time.Parse(time.RFC3339, opts.After); err == nil {
|
||||
conditions = append(conditions, "m.created_at >= ?")
|
||||
|
||||
Vendored
+11
-11
@@ -8,29 +8,29 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link href="/_app/immutable/entry/start.DpHKCwmv.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BRBotovi.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DBeLgT1-.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/SAcaBy3_.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DL-Ee-iM.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BCvik_Lu.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BdrVqzRy.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.B_lhmyMs.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/start.c_zi0RIt.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/CM33bEJp.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DcCVF7Hp.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/Dxl4bgMI.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/ZMlvm3v3.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/DGCkYcAF.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/chunks/BoudhnWu.js" rel="modulepreload">
|
||||
<link href="/_app/immutable/entry/app.BNUVkI2e.js" rel="modulepreload">
|
||||
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">
|
||||
<script>
|
||||
{
|
||||
__sveltekit_vhg0t8 = {
|
||||
__sveltekit_ioo7b2 = {
|
||||
base: ""
|
||||
};
|
||||
|
||||
const element = document.currentScript.parentElement;
|
||||
|
||||
Promise.all([
|
||||
import("/_app/immutable/entry/start.DpHKCwmv.js"),
|
||||
import("/_app/immutable/entry/app.B_lhmyMs.js")
|
||||
import("/_app/immutable/entry/start.c_zi0RIt.js"),
|
||||
import("/_app/immutable/entry/app.BNUVkI2e.js")
|
||||
]).then(([kit, app]) => {
|
||||
kit.start(app, element);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: Admin CLI & Docker Fixes
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-15
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.plan`.
|
||||
- FR-001 mentions "alpine:3.19" which is an implementation detail, but this is the explicit user request and core to the fix, so it's acceptable.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Admin Socket Contract: Channel Commands
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
**Date**: 2026-03-15
|
||||
|
||||
## Protocol
|
||||
|
||||
Unix domain socket at `/data/synapbus.sock` (default). JSON-RPC style, newline-delimited.
|
||||
|
||||
## New Commands
|
||||
|
||||
### `channels.create`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"command": "channels.create",
|
||||
"args": {
|
||||
"name": "news-feed",
|
||||
"description": "News feed channel"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `name` (string, required): Channel name. Must pass `ValidateChannelName` rules.
|
||||
- `description` (string, optional): Channel description. Defaults to empty.
|
||||
|
||||
**Success Response**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": 42,
|
||||
"name": "news-feed",
|
||||
"description": "News feed channel",
|
||||
"type": "standard",
|
||||
"is_private": false,
|
||||
"created_by": "system",
|
||||
"created_at": "2026-03-15T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response** (duplicate name):
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "channel already exists"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response** (invalid name):
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "invalid channel name: ..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `channels.join`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"command": "channels.join",
|
||||
"args": {
|
||||
"channel": "news-feed",
|
||||
"agent": "my-agent"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `channel` (string, required): Channel name to join.
|
||||
- `agent` (string, required): Agent name to add as member.
|
||||
|
||||
**Success Response**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"channel": "news-feed",
|
||||
"agent": "my-agent",
|
||||
"status": "joined"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response** (already member, idempotent):
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"channel": "news-feed",
|
||||
"agent": "my-agent",
|
||||
"status": "already_member"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response** (channel not found):
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": "channel not found: news-feed"
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### `synapbus channels create`
|
||||
|
||||
```
|
||||
Usage:
|
||||
synapbus channels create [flags]
|
||||
|
||||
Flags:
|
||||
--name string Channel name (required)
|
||||
--description string Channel description
|
||||
|
||||
Global Flags:
|
||||
--socket string Path to admin Unix socket (default "/data/synapbus.sock")
|
||||
```
|
||||
|
||||
### `synapbus channels join`
|
||||
|
||||
```
|
||||
Usage:
|
||||
synapbus channels join [flags]
|
||||
|
||||
Flags:
|
||||
--channel string Channel name (required)
|
||||
--agent string Agent name (required)
|
||||
|
||||
Global Flags:
|
||||
--socket string Path to admin Unix socket (default "/data/synapbus.sock")
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# Data Model: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
**Date**: 2026-03-15
|
||||
|
||||
## No Schema Changes
|
||||
|
||||
This feature does not introduce any new database tables, columns, or migrations. All operations use existing entities:
|
||||
|
||||
### Existing Entities Used
|
||||
|
||||
#### Channel (existing)
|
||||
- `id` (int64): Auto-increment primary key
|
||||
- `name` (string): Unique, normalized channel name
|
||||
- `description` (string): Optional description
|
||||
- `type` (string): "standard", "blackboard", "auction"
|
||||
- `is_private` (bool): Whether channel requires invite
|
||||
- `is_system` (bool): Whether channel is system-managed
|
||||
- `created_by` (string): Agent name of creator
|
||||
- `created_at` (timestamp): Creation time
|
||||
|
||||
#### Membership (existing)
|
||||
- `channel_id` (int64): FK to channels
|
||||
- `agent_name` (string): Name of member agent
|
||||
- `role` (string): "owner", "member"
|
||||
- `joined_at` (timestamp): When the agent joined
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
CLI Command Admin Socket Handler Channel Service
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
channels create --name X → channels.create {name, desc} → CreateChannel(req)
|
||||
channels join --ch X --ag Y → channels.join {channel, agent} → GetChannelByName(X)
|
||||
→ JoinChannel(id, Y)
|
||||
```
|
||||
|
||||
### Validation Rules (existing, no changes)
|
||||
- Channel names: lowercase, alphanumeric + hyphens, 1-64 chars, no leading/trailing hyphens
|
||||
- Agent names: must exist in the agent registry
|
||||
- Channel join: idempotent (re-joining a channel the agent is already in is a no-op)
|
||||
- Private channels: require a pending invite before join
|
||||
@@ -0,0 +1,74 @@
|
||||
# Implementation Plan: Admin CLI & Docker Fixes
|
||||
|
||||
**Branch**: `006-admin-cli-docker-fixes` | **Date**: 2026-03-15 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/006-admin-cli-docker-fixes/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Fix four operational issues blocking reliable admin CLI usage in containerized SynapBus: switch Docker base image from `scratch` to `alpine:3.19` so admin socket CLI works via `kubectl exec`, add `synapbus channels create` and `synapbus channels join` CLI commands backed by new admin socket handlers, and change the default socket path from relative `./data/synapbus.sock` to absolute `/data/synapbus.sock`.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Go 1.25+ (per go.mod)
|
||||
**Primary Dependencies**: spf13/cobra (CLI), go-chi/chi (HTTP), mark3labs/mcp-go (MCP)
|
||||
**Storage**: modernc.org/sqlite (pure Go, zero CGO)
|
||||
**Testing**: `go test ./...` (table-driven tests)
|
||||
**Target Platform**: linux/amd64, darwin/arm64 (Docker + local dev)
|
||||
**Project Type**: CLI / web-service (single binary)
|
||||
**Performance Goals**: Admin socket commands complete in < 1 second
|
||||
**Constraints**: Zero CGO, single binary, pure Go
|
||||
**Scale/Scope**: Single instance, admin-only operations
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Local-First, Single Binary | PASS | No new external dependencies. Alpine base image only adds shell availability. |
|
||||
| II. MCP-Native | PASS | Changes are admin CLI only, no MCP interface changes. |
|
||||
| III. Pure Go, Zero CGO | PASS | No new Go dependencies. Dockerfile still builds with `CGO_ENABLED=0`. |
|
||||
| IV. Multi-Tenant with Ownership | PASS | Admin socket is localhost-only, trusted operator context. |
|
||||
| V. Embedded OAuth 2.1 | N/A | No auth changes. |
|
||||
| VI. Semantic-Ready Storage | N/A | No storage schema changes. |
|
||||
| VII. Swarm Intelligence | N/A | No swarm pattern changes. |
|
||||
| VIII. Observable by Default | PASS | Channel create/join are traced via existing channel service. |
|
||||
| IX. Progressive Complexity | PASS | New CLI commands add no complexity for basic usage. |
|
||||
| X. Web UI | N/A | No UI changes. |
|
||||
|
||||
**Gate Result**: PASS — no violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/006-admin-cli-docker-fixes/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output (admin socket protocol)
|
||||
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
# Files modified:
|
||||
Dockerfile # scratch → alpine:3.19
|
||||
cmd/synapbus/admin.go # Add channels create/join commands, fix default socket path
|
||||
internal/admin/socket.go # Add channels.create and channels.join handlers
|
||||
cmd/synapbus/admin_test.go # Tests for new CLI commands
|
||||
|
||||
# Files unchanged but referenced:
|
||||
internal/channels/service.go # CreateChannel, JoinChannel (already exist)
|
||||
internal/channels/store.go # GetChannelByName (already exists)
|
||||
internal/admin/server.go # Services struct (already has Channels field)
|
||||
```
|
||||
|
||||
**Structure Decision**: This feature modifies 3 existing files and adds no new files. All changes fit within the existing project structure.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations to justify.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Quickstart: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
```bash
|
||||
# Build Docker image (now uses alpine base)
|
||||
docker build -t synapbus:dev .
|
||||
|
||||
# Run locally
|
||||
./synapbus serve --port 8080 --data ./data
|
||||
```
|
||||
|
||||
## New CLI Commands
|
||||
|
||||
### Create a channel
|
||||
```bash
|
||||
# With description
|
||||
synapbus channels create --name news-feed --description "News feed channel"
|
||||
|
||||
# Without description
|
||||
synapbus channels create --name alerts
|
||||
```
|
||||
|
||||
### Join an agent to a channel
|
||||
```bash
|
||||
synapbus channels join --channel news-feed --agent research-mcpproxy
|
||||
```
|
||||
|
||||
### In Kubernetes
|
||||
```bash
|
||||
# Now works because alpine base image provides /bin/sh
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels create --name news-feed
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels join --channel news-feed --agent my-agent
|
||||
kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list
|
||||
```
|
||||
|
||||
## Socket Path
|
||||
|
||||
The default socket path is now `/data/synapbus.sock` (absolute). Override with:
|
||||
|
||||
```bash
|
||||
# Environment variable
|
||||
export SYNAPBUS_SOCKET=/custom/path/synapbus.sock
|
||||
|
||||
# CLI flag
|
||||
synapbus --socket /custom/path/synapbus.sock channels list
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Verify Docker image base
|
||||
docker run --rm synapbus:dev sh -c "cat /etc/os-release"
|
||||
# Should show Alpine Linux
|
||||
|
||||
# Verify socket path default
|
||||
synapbus --help | grep socket
|
||||
# Should show: --socket string Path to admin Unix socket (default "/data/synapbus.sock")
|
||||
```
|
||||
@@ -0,0 +1,49 @@
|
||||
# Research: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
**Date**: 2026-03-15
|
||||
|
||||
## R1: Alpine vs Scratch Docker Base Image
|
||||
|
||||
**Decision**: Use `alpine:3.19` as the runtime base image.
|
||||
|
||||
**Rationale**: The `scratch` image has no shell, no `/bin/sh`, no filesystem utilities. This means `kubectl exec` cannot spawn any process other than the entrypoint binary itself. Since admin CLI commands need to connect to the Unix socket created by the running server process, exec'd processes need a working environment. Alpine adds ~7MB but provides `/bin/sh`, basic filesystem operations, and a working process environment.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `distroless/static` (Google): No shell, same problem as scratch.
|
||||
- `busybox`: Works but no package manager. Alpine is the standard minimal base.
|
||||
- `debian-slim`: ~80MB, unnecessarily large.
|
||||
|
||||
## R2: Admin Socket Protocol for Channel Operations
|
||||
|
||||
**Decision**: Add `channels.create` and `channels.join` commands to the existing admin socket dispatch table, following the exact pattern of existing commands (e.g., `agent.create`, `webhook.register`).
|
||||
|
||||
**Rationale**: The admin socket already has a well-established request/response pattern: JSON-RPC style `{command, args}` → `{ok, data, error}`. The channel service already exposes `CreateChannel` and `JoinChannel` methods. The admin server already holds a reference to the channel service via `Services.Channels`. No new wiring needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- HTTP admin API endpoint: Would require API key protection (user explicitly rejected this approach).
|
||||
- Direct database manipulation via CLI: Bypasses service layer validation, unsafe.
|
||||
|
||||
## R3: Default Socket Path
|
||||
|
||||
**Decision**: Change default from `./data/synapbus.sock` to `/data/synapbus.sock` (absolute).
|
||||
|
||||
**Rationale**: In containers, the working directory is `/` and the data volume is mounted at `/data`. The relative path `./data/synapbus.sock` resolves to `/data/synapbus.sock` from `/`, but this is confusing and fragile. An absolute default matches the Dockerfile's `--data /data` argument and the Helm chart's `volumeMount` at `/data`.
|
||||
|
||||
The `SYNAPBUS_SOCKET` environment variable and `--socket` flag still allow overriding for development (e.g., `--socket ./data/synapbus.sock` for local dev).
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep relative path: Works in containers but confusing for users.
|
||||
- Use `$SYNAPBUS_DATA_DIR/synapbus.sock` as default: Over-engineered; the socket path flag already exists.
|
||||
|
||||
## R4: `channels.create` Admin Handler Design
|
||||
|
||||
**Decision**: The `channels.create` handler accepts `{name, description}` args, calls `channelService.CreateChannel` with `created_by: "system"`, and returns the created channel as JSON.
|
||||
|
||||
**Rationale**: Admin socket commands are implicitly trusted (localhost-only, process-level access). Using `"system"` as the creator matches the pattern used for the default `#general` channel. The `description` field is optional (defaults to empty string).
|
||||
|
||||
## R5: `channels.join` Admin Handler Design
|
||||
|
||||
**Decision**: The `channels.join` handler accepts `{channel, agent}` args, looks up the channel by name via `GetChannelByName`, then calls `JoinChannel(channelID, agentName)`. Returns success message.
|
||||
|
||||
**Rationale**: The CLI uses channel names (not IDs) because operators work with names. The service's `JoinChannel` already handles idempotency (re-joining is a no-op) and private channel invite checks.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Feature Specification: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature Branch**: `006-admin-cli-docker-fixes`
|
||||
**Created**: 2026-03-15
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Fix admin socket accessibility in Docker, add channels create/join CLI commands, fix default socket path"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Admin CLI Works in Kubernetes Pods (Priority: P1)
|
||||
|
||||
An operator needs to run admin CLI commands inside a Kubernetes pod (e.g., `kubectl exec -n synapbus deploy/synapbus -- /synapbus channels list`). With the current `scratch` base image, there is no shell and the admin socket is unreachable via exec'd processes. Switching to `alpine` allows `kubectl exec` with a shell and gives the admin CLI a working environment.
|
||||
|
||||
**Why this priority**: This is a blocker — without this fix, all admin CLI operations fail after pod restart in production.
|
||||
|
||||
**Independent Test**: Build Docker image with alpine base, deploy to a test pod, run `kubectl exec ... -- /synapbus channels list` and verify it returns results.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a SynapBus pod running with the alpine-based image, **When** an operator runs `kubectl exec deploy/synapbus -- /synapbus channels list`, **Then** the command executes and returns channel data over the admin socket.
|
||||
2. **Given** a SynapBus pod running with the alpine-based image, **When** an operator runs `kubectl exec deploy/synapbus -- sh`, **Then** they get an interactive shell.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Create Channels via CLI (Priority: P1)
|
||||
|
||||
An operator needs to create channels without using the Web UI or REST API with session cookies. The `synapbus channels create` command should create a channel via the admin socket.
|
||||
|
||||
**Why this priority**: Required for automated provisioning scripts and headless setups.
|
||||
|
||||
**Independent Test**: Start SynapBus server, run `synapbus channels create --name test-channel --description "A test channel"`, then verify with `synapbus channels list`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a running SynapBus server, **When** an operator runs `synapbus channels create --name news-feed --description "News feed channel"`, **Then** the channel is created and a success response with channel details is printed.
|
||||
2. **Given** a running SynapBus server, **When** an operator runs `synapbus channels create --name news-feed` without `--description`, **Then** the channel is created with an empty description.
|
||||
3. **Given** a channel named "news-feed" already exists, **When** an operator runs `synapbus channels create --name news-feed`, **Then** an appropriate error message is displayed.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Join Agents to Channels via CLI (Priority: P1)
|
||||
|
||||
An operator needs to add agents to channels via the admin CLI so agents can post messages. The `synapbus channels join` command should add an agent to a channel's membership.
|
||||
|
||||
**Why this priority**: Agents cannot post to channels they haven't joined; this is required for initial agent setup and automation.
|
||||
|
||||
**Independent Test**: Create a channel and an agent, run `synapbus channels join --channel test-channel --agent my-agent`, then verify with `synapbus channels show --name test-channel`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a channel "test-channel" and agent "my-agent" exist, **When** an operator runs `synapbus channels join --channel test-channel --agent my-agent`, **Then** the agent is added as a member and a success response is printed.
|
||||
2. **Given** an agent is already a member of "test-channel", **When** an operator runs `synapbus channels join --channel test-channel --agent my-agent`, **Then** the operation succeeds idempotently (no error).
|
||||
3. **Given** channel "nonexistent" does not exist, **When** an operator runs `synapbus channels join --channel nonexistent --agent my-agent`, **Then** an error message indicates the channel was not found.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Absolute Default Socket Path (Priority: P2)
|
||||
|
||||
The default socket path for admin CLI commands is currently `./data/synapbus.sock` (relative). In containers where CWD varies, this is confusing. The default should be `/data/synapbus.sock` (absolute) to match the container layout.
|
||||
|
||||
**Why this priority**: Quality-of-life improvement; the current relative path works but is confusing.
|
||||
|
||||
**Independent Test**: Run `synapbus --help` and verify the default socket path shows `/data/synapbus.sock`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the `--socket` flag is not provided, **When** the CLI resolves the admin socket path, **Then** it defaults to `/data/synapbus.sock`.
|
||||
2. **Given** the `SYNAPBUS_SOCKET` environment variable is set, **When** the CLI resolves the admin socket path, **Then** it uses the environment variable value.
|
||||
3. **Given** the `--socket` flag is provided with a custom path, **When** the CLI resolves the admin socket path, **Then** it uses the custom path.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when channel name contains invalid characters? The existing `ValidateChannelName` rules apply, and the CLI reports the validation error.
|
||||
- What happens when the admin socket is not reachable? The CLI prints a connection error with "is synapbus serve running?" hint.
|
||||
- What happens when an agent name doesn't exist during channel join? The operation fails with a clear error message from the channel service.
|
||||
- What happens when the `--name` flag is missing on `channels create`? Cobra enforces the required flag and prints usage.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The Docker image MUST use `alpine:3.19` as the runtime base image instead of `scratch`.
|
||||
- **FR-002**: The system MUST provide a `synapbus channels create` CLI command with `--name` (required) and `--description` (optional) flags.
|
||||
- **FR-003**: The `channels create` command MUST send a `channels.create` request over the admin socket and display the result.
|
||||
- **FR-004**: The admin socket server MUST handle `channels.create` commands by creating a channel via the channel service.
|
||||
- **FR-005**: The system MUST provide a `synapbus channels join` CLI command with `--channel` (required) and `--agent` (required) flags.
|
||||
- **FR-006**: The `channels join` command MUST send a `channels.join` request over the admin socket and display the result.
|
||||
- **FR-007**: The admin socket server MUST handle `channels.join` commands by looking up the channel by name and adding the agent as a member.
|
||||
- **FR-008**: The default value of the `--socket` persistent flag MUST be `/data/synapbus.sock` (absolute path).
|
||||
- **FR-009**: The `SYNAPBUS_SOCKET` environment variable MUST override the default socket path when the flag is not explicitly set.
|
||||
- **FR-010**: The Docker image MUST remain minimal — only the binary, TLS certs, and timezone data should be included from the build stage.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Channel**: Named communication space with type, description, privacy flag, and member list.
|
||||
- **Agent**: Named entity (AI or human) that can be a member of channels.
|
||||
- **Admin Socket**: Unix domain socket at a known path, used by CLI commands to communicate with the running server.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Operators can execute all admin CLI commands inside a Kubernetes pod via `kubectl exec` without errors.
|
||||
- **SC-002**: `synapbus channels create --name <name>` successfully creates a channel and returns channel details within 1 second.
|
||||
- **SC-003**: `synapbus channels join --channel <name> --agent <name>` successfully adds an agent to a channel within 1 second.
|
||||
- **SC-004**: The default socket path displayed in help text is `/data/synapbus.sock`.
|
||||
- **SC-005**: The Docker image size remains under 50MB (alpine adds minimal overhead vs scratch).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Alpine 3.19 is acceptable as the runtime base image (adds ~7MB over scratch).
|
||||
- The `channels.create` admin command uses `"system"` as the `created_by` field since admin socket operations are implicitly trusted.
|
||||
- The `channels.join` admin command adds the agent with the `"member"` role (not owner).
|
||||
- Channel type defaults to `"standard"` if not specified.
|
||||
- No `--private` or `--type` flags are needed for the initial `channels create` command — they can be added later.
|
||||
- The Helm chart deployment.yaml does not need changes since it already passes `--data /data`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Tasks: Admin CLI & Docker Fixes
|
||||
|
||||
**Feature**: 006-admin-cli-docker-fixes
|
||||
**Created**: 2026-03-15
|
||||
**Plan**: [plan.md](plan.md)
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] **T01**: Change default socket path from `./data/synapbus.sock` to `/data/synapbus.sock` in `cmd/synapbus/admin.go` (line 922) and update the `SYNAPBUS_SOCKET` env var check comparison string.
|
||||
- Files: `cmd/synapbus/admin.go`
|
||||
|
||||
## Phase 2: Core — Admin Socket Handlers
|
||||
|
||||
- [x] **T02**: Add `channels.create` handler to `internal/admin/socket.go` dispatch table and implement `handleChannelsCreate` method.
|
||||
- Files: `internal/admin/socket.go`
|
||||
- Depends on: T01
|
||||
|
||||
- [x] **T03**: Add `channels.join` handler to `internal/admin/socket.go` dispatch table and implement `handleChannelsJoin` method.
|
||||
- Files: `internal/admin/socket.go`
|
||||
- Depends on: T01
|
||||
|
||||
## Phase 3: Core — CLI Commands
|
||||
|
||||
- [x] **T04**: Add `synapbus channels create` cobra command with `--name` (required) and `--description` (optional) flags in `cmd/synapbus/admin.go`.
|
||||
- Files: `cmd/synapbus/admin.go`
|
||||
- Depends on: T02
|
||||
|
||||
- [x] **T05**: Add `synapbus channels join` cobra command with `--channel` (required) and `--agent` (required) flags in `cmd/synapbus/admin.go`.
|
||||
- Files: `cmd/synapbus/admin.go`
|
||||
- Depends on: T03
|
||||
|
||||
## Phase 4: Docker
|
||||
|
||||
- [x] **T06**: Change Dockerfile runtime stage from `FROM scratch` to `FROM alpine:3.19` and add `RUN apk add --no-cache ca-certificates tzdata` (removing COPY of certs/tzdata from builder).
|
||||
- Files: `Dockerfile`
|
||||
|
||||
## Phase 5: Tests & Validation
|
||||
|
||||
- [x] **T07**: Add unit tests for `channels.create` and `channels.join` admin socket handlers.
|
||||
- Files: `cmd/synapbus/admin_test.go`
|
||||
- Depends on: T04, T05
|
||||
|
||||
- [x] **T08**: Run `make build` and `make test` to verify all changes compile and pass.
|
||||
- Depends on: T01-T07
|
||||
@@ -480,17 +480,17 @@ func TestE2E_ChannelMessaging(t *testing.T) {
|
||||
t.Errorf("send status = %v, want sent", sendResult["status"])
|
||||
}
|
||||
|
||||
// Bob reads his inbox and should see the channel message.
|
||||
inbox := bobClient.CallTool("execute", map[string]any{
|
||||
"code": `call("read_inbox", { include_read: true })`,
|
||||
// Bob reads channel messages (DMs only sent for @mentions).
|
||||
chMsgs := bobClient.CallTool("execute", map[string]any{
|
||||
"code": `call("get_channel_messages", { channel_name: "project-x", limit: 10 })`,
|
||||
})
|
||||
inboxData := unwrapCallResult(t, inbox)
|
||||
count := inboxData["count"].(float64)
|
||||
chData := unwrapCallResult(t, chMsgs)
|
||||
count := chData["count"].(float64)
|
||||
if count < 1 {
|
||||
t.Fatalf("Bob's inbox count = %v, want >= 1", count)
|
||||
t.Fatalf("channel message count = %v, want >= 1", count)
|
||||
}
|
||||
|
||||
messages := inboxData["messages"].([]any)
|
||||
messages := chData["messages"].([]any)
|
||||
found := false
|
||||
for _, m := range messages {
|
||||
msg := m.(map[string]any)
|
||||
@@ -500,7 +500,7 @@ func TestE2E_ChannelMessaging(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Bob did not receive the channel message")
|
||||
t.Error("channel message not found via get_channel_messages")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,9 +65,13 @@ export const messages = {
|
||||
send: (body: { from?: string; to?: string; body: string; priority?: number; subject?: string; channel_id?: number; conversation_id?: number; reply_to?: number }) =>
|
||||
request<any>('POST', '/api/messages', body),
|
||||
markDone: (id: number) => request<{ status: string }>('POST', `/api/messages/${id}/done`),
|
||||
search: (q: string, limit?: number) => {
|
||||
search: (q: string, opts?: { limit?: number; channel?: string; agent?: string; after?: string; before?: string }) => {
|
||||
const qs = new URLSearchParams({ q });
|
||||
if (limit) qs.set('limit', String(limit));
|
||||
if (opts?.limit) qs.set('limit', String(opts.limit));
|
||||
if (opts?.channel) qs.set('channel', opts.channel);
|
||||
if (opts?.agent) qs.set('agent', opts.agent);
|
||||
if (opts?.after) qs.set('after', opts.after);
|
||||
if (opts?.before) qs.set('before', opts.before);
|
||||
return request<{ messages: any[]; query: string; total: number }>('GET', `/api/messages/search?${qs}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,13 +16,12 @@
|
||||
const path = $page.url.pathname;
|
||||
if (path === '/') return 'Dashboard';
|
||||
if (path.startsWith('/conversations/')) return 'Thread';
|
||||
if (path === '/conversations') return 'Conversations';
|
||||
if (path === '/conversations') return 'Search';
|
||||
if (path.startsWith('/channels/')) return '#' + ($page.params.name ?? '');
|
||||
if (path === '/channels') return 'Channels';
|
||||
if (path.startsWith('/dm/')) return ($page.params.name ?? 'DM');
|
||||
if (path.startsWith('/agents/')) return ($page.params.name ?? 'Agent');
|
||||
if (path === '/agents') return 'Agents';
|
||||
if (path === '/settings/api-keys') return 'API Keys';
|
||||
if (path === '/settings') return 'Settings';
|
||||
return 'SynapBus';
|
||||
});
|
||||
@@ -34,15 +33,15 @@
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Search -->
|
||||
<form class="max-w-xs w-full" onsubmit={handleSearch}>
|
||||
<form class="w-80" onsubmit={handleSearch}>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search..."
|
||||
class="w-full pl-8 pr-3 py-1.5 text-xs bg-bg-tertiary border border-border rounded text-text-primary placeholder-text-secondary focus:border-border-active focus:ring-0 outline-none"
|
||||
placeholder="Search messages..."
|
||||
class="w-full pl-9 pr-3 py-2 text-sm bg-bg-tertiary border border-border rounded-md text-text-primary placeholder-text-secondary focus:border-border-active focus:ring-0 outline-none"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
let { body, truncate }: { body: string; truncate?: number } = $props();
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer with @mentions, #channels, and URL auto-linking.
|
||||
* No external dependencies. Input is sanitized (HTML stripped) before processing.
|
||||
*/
|
||||
|
||||
// Strip all HTML tags from raw input to prevent XSS
|
||||
function stripHtml(text: string): string {
|
||||
return text.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
// Escape HTML special characters in text content
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Process inline markdown: bold, italic, code, URLs, @mentions, #channels
|
||||
function processInline(text: string): string {
|
||||
// Inline code first (so content inside is not further processed)
|
||||
let result = '';
|
||||
const codeRegex = /`([^`]+)`/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = codeRegex.exec(text)) !== null) {
|
||||
result += processInlineNonCode(text.slice(lastIndex, match.index));
|
||||
result += `<code class="msg-inline-code">${escapeHtml(match[1])}</code>`;
|
||||
lastIndex = codeRegex.lastIndex;
|
||||
}
|
||||
result += processInlineNonCode(text.slice(lastIndex));
|
||||
return result;
|
||||
}
|
||||
|
||||
// Process inline elements that are NOT inside code spans
|
||||
function processInlineNonCode(text: string): string {
|
||||
// Extract URLs BEFORE HTML escaping to avoid breaking them
|
||||
const urlPlaceholders: string[] = [];
|
||||
let s = text.replace(
|
||||
/https?:\/\/[^\s<>()[\]"'`]+/g,
|
||||
(url) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const trailing = url.slice(cleaned.length);
|
||||
const idx = urlPlaceholders.length;
|
||||
urlPlaceholders.push(
|
||||
`<a href="${escapeHtml(cleaned)}" target="_blank" rel="noopener" class="msg-link">${escapeHtml(cleaned)}</a>${escapeHtml(trailing)}`
|
||||
);
|
||||
return `\x01URL${idx}\x01`;
|
||||
}
|
||||
);
|
||||
|
||||
// Now HTML-escape the rest
|
||||
s = escapeHtml(s);
|
||||
|
||||
// Bold: **text**
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic: *text* (but not inside **)
|
||||
s = s.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
|
||||
|
||||
// @mentions
|
||||
s = s.replace(
|
||||
/@([\w][\w.-]*)/g,
|
||||
'<a href="/dm/$1" class="msg-mention">@$1</a>'
|
||||
);
|
||||
|
||||
// #channels
|
||||
s = s.replace(
|
||||
/#([\w][\w.-]*)/g,
|
||||
'<a href="/channels/$1" class="msg-channel">$&</a>'
|
||||
);
|
||||
|
||||
// Restore URL placeholders
|
||||
s = s.replace(/\x01URL(\d+)\x01/g, (_, idx) => urlPlaceholders[parseInt(idx)]);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// Parse a full message body into HTML
|
||||
function renderMarkdown(raw: string): string {
|
||||
const sanitized = stripHtml(raw);
|
||||
|
||||
// Handle truncation on the sanitized text
|
||||
let text = sanitized;
|
||||
if (truncate && text.length > truncate) {
|
||||
text = text.slice(0, truncate) + '...';
|
||||
}
|
||||
|
||||
// Fenced code blocks: ```...```
|
||||
const blocks: string[] = [];
|
||||
const codeBlockRegex = /```(?:\w*)\n?([\s\S]*?)```/g;
|
||||
let processed = text.replace(codeBlockRegex, (_match, code) => {
|
||||
const idx = blocks.length;
|
||||
blocks.push(`<pre class="msg-code-block"><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
|
||||
return `\x00BLOCK${idx}\x00`;
|
||||
});
|
||||
|
||||
// Split into paragraphs by blank lines
|
||||
const paragraphs = processed.split(/\n{2,}/);
|
||||
const htmlParts: string[] = [];
|
||||
|
||||
for (const para of paragraphs) {
|
||||
// Check for block placeholder
|
||||
if (/^\x00BLOCK\d+\x00$/.test(para.trim())) {
|
||||
const idx = parseInt(para.trim().replace(/\x00BLOCK(\d+)\x00/, '$1'));
|
||||
htmlParts.push(blocks[idx]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lines = para.split('\n');
|
||||
|
||||
// Check if this paragraph is a list
|
||||
const isUnordered = lines.every(l => /^\s*[-*]\s/.test(l) || l.trim() === '');
|
||||
const isOrdered = lines.every(l => /^\s*\d+\.\s/.test(l) || l.trim() === '');
|
||||
|
||||
if (isUnordered && lines.some(l => l.trim() !== '')) {
|
||||
const items = lines
|
||||
.filter(l => /^\s*[-*]\s/.test(l))
|
||||
.map(l => `<li>${processInline(l.replace(/^\s*[-*]\s+/, ''))}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ul class="msg-list">${items}</ul>`);
|
||||
} else if (isOrdered && lines.some(l => l.trim() !== '')) {
|
||||
const items = lines
|
||||
.filter(l => /^\s*\d+\.\s/.test(l))
|
||||
.map(l => `<li>${processInline(l.replace(/^\s*\d+\.\s+/, ''))}</li>`)
|
||||
.join('');
|
||||
htmlParts.push(`<ol class="msg-list msg-list-ordered">${items}</ol>`);
|
||||
} else {
|
||||
// Check for headers
|
||||
const headerMatch = para.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length;
|
||||
htmlParts.push(`<h${level} class="msg-heading msg-h${level}">${processInline(headerMatch[2])}</h${level}>`);
|
||||
} else {
|
||||
// Regular paragraph - preserve line breaks within
|
||||
const inlineHtml = lines
|
||||
.map(l => {
|
||||
// Replace block placeholders inline
|
||||
if (/\x00BLOCK\d+\x00/.test(l)) {
|
||||
return l.replace(/\x00BLOCK(\d+)\x00/g, (_, idx) => blocks[parseInt(idx)]);
|
||||
}
|
||||
return processInline(l);
|
||||
})
|
||||
.join('<br>');
|
||||
htmlParts.push(`<p>${inlineHtml}</p>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return htmlParts.join('');
|
||||
}
|
||||
|
||||
let rendered = $derived(renderMarkdown(body));
|
||||
</script>
|
||||
|
||||
<div class="msg-body">
|
||||
{@html rendered}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.msg-body {
|
||||
/* Inherit parent text styling */
|
||||
}
|
||||
.msg-body :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
.msg-body :global(p + p) {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.msg-body :global(strong) {
|
||||
font-weight: 700;
|
||||
}
|
||||
.msg-body :global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
.msg-body :global(.msg-inline-code) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 4px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
.msg-body :global(.msg-code-block) {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8em;
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
white-space: pre;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.msg-body :global(.msg-code-block code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg-body :global(.msg-link) {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-link:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.msg-body :global(.msg-mention) {
|
||||
display: inline;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(124, 58, 237, 0.15);
|
||||
color: var(--accent-purple);
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-mention:hover) {
|
||||
background-color: rgba(124, 58, 237, 0.25);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-channel) {
|
||||
display: inline;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(54, 197, 240, 0.15);
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
font-size: 0.92em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-channel:hover) {
|
||||
background-color: rgba(54, 197, 240, 0.25);
|
||||
text-decoration: none;
|
||||
}
|
||||
.msg-body :global(.msg-list) {
|
||||
margin: 0.25em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
.msg-body :global(.msg-list-ordered) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.msg-body :global(.msg-list li) {
|
||||
margin: 0.1em 0;
|
||||
}
|
||||
.msg-body :global(.msg-heading) {
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
margin: 0.4em 0 0.2em;
|
||||
}
|
||||
.msg-body :global(.msg-h1) {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.msg-body :global(.msg-h2) {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.msg-body :global(.msg-h3) {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.msg-body :global(.msg-h4),
|
||||
.msg-body :global(.msg-h5),
|
||||
.msg-body :global(.msg-h6) {
|
||||
font-size: 1em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { openThread } from '$lib/stores/thread';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
type Message = {
|
||||
id: number;
|
||||
@@ -105,7 +106,7 @@
|
||||
<span class="badge bg-accent-yellow/20 text-accent-yellow">P{msg.priority}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body.length > 300 ? msg.body.slice(0, 300) + '...' : msg.body}</p>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} truncate={300} /></div>
|
||||
|
||||
<!-- Thread link -->
|
||||
{#if showConversationLink && msg.conversation_id}
|
||||
|
||||
@@ -91,19 +91,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="px-3 py-2.5 flex-shrink-0">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-bg-tertiary rounded text-text-secondary text-xs hover:bg-bg-input transition-colors"
|
||||
onclick={() => goto('/conversations')}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Search messages
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable nav -->
|
||||
<nav class="flex-1 overflow-y-auto px-2 pb-3">
|
||||
<!-- Dashboard -->
|
||||
@@ -117,15 +104,15 @@
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<!-- Conversations -->
|
||||
<!-- Search -->
|
||||
<a
|
||||
href="/conversations"
|
||||
class="sidebar-item mb-3 {isActive('/conversations') ? 'sidebar-item-active' : ''}"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Conversations
|
||||
Search
|
||||
</a>
|
||||
|
||||
<!-- Channels section -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { activeThread, closeThread } from '$lib/stores/thread';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let conversation = $state<any>(null);
|
||||
let threadMessages = $state<any[]>([]);
|
||||
@@ -162,7 +163,7 @@
|
||||
<span class="{statusClass(msg.status)} text-[10px]">{msg.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</p>
|
||||
<div class="text-xs text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
return client.onEvent((event) => {
|
||||
if (event.type === 'new_message') {
|
||||
const d = event.data;
|
||||
if (d.channel_name) {
|
||||
notifications.incrementUnread('channel', d.channel_name);
|
||||
if (d.channel) {
|
||||
notifications.incrementUnread('channel', d.channel);
|
||||
} else if (d.from_agent) {
|
||||
notifications.incrementUnread('dm', d.from_agent);
|
||||
}
|
||||
@@ -50,7 +50,11 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($user && sseClient && !sseClient.connected) {
|
||||
if ($user && !sseClient) {
|
||||
sseClient = new SSEClient();
|
||||
sseClient.connect();
|
||||
sseUnsubscribe = setupNotifications(sseClient);
|
||||
} else if ($user && sseClient && !sseClient.connected) {
|
||||
sseClient.connect();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { channels as channelsApi, messages as messagesApi, agents as agentsApi } from '$lib/api/client';
|
||||
import { openThread, closeThread } from '$lib/stores/thread';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let channel = $state<any>(null);
|
||||
let members = $state<any[]>([]);
|
||||
@@ -268,7 +269,7 @@
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
{#if msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
|
||||
|
||||
@@ -1,110 +1,450 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import { goto } from '$app/navigation';
|
||||
import { messages as messagesApi, channels as channelsApi } from '$lib/api/client';
|
||||
|
||||
let convList = $state<any[]>([]);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<any[] | null>(null);
|
||||
let loadingData = $state(true);
|
||||
let searchTotal = $state(0);
|
||||
let loading = $state(false);
|
||||
let hasSearched = $state(false);
|
||||
|
||||
// Filter state
|
||||
let timeRange = $state('any');
|
||||
let customAfter = $state('');
|
||||
let customBefore = $state('');
|
||||
let channelFilter = $state('');
|
||||
let agentFilter = $state('');
|
||||
let showFilters = $state(false);
|
||||
|
||||
// Channel list for suggestions
|
||||
let channelList = $state<string[]>([]);
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
(async () => {
|
||||
const q = $page.url.searchParams.get('q');
|
||||
if (q) {
|
||||
try {
|
||||
const res = await messagesApi.search(q);
|
||||
searchResults = res.messages;
|
||||
} catch {
|
||||
searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await convsApi.list();
|
||||
convList = res.conversations;
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
})();
|
||||
// Load channel list for suggestions
|
||||
channelsApi.list().then((res) => {
|
||||
channelList = res.channels.map((c: any) => c.name);
|
||||
}).catch(() => {});
|
||||
|
||||
// Check for initial query from URL
|
||||
const q = $page.url.searchParams.get('q');
|
||||
if (q) {
|
||||
searchQuery = q;
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getTimeFilters(): { after?: string; before?: string } {
|
||||
const now = new Date();
|
||||
switch (timeRange) {
|
||||
case '24h': {
|
||||
const d = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
return { after: d.toISOString() };
|
||||
}
|
||||
case 'week': {
|
||||
const d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
return { after: d.toISOString() };
|
||||
}
|
||||
case 'month': {
|
||||
const d = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
return { after: d.toISOString() };
|
||||
}
|
||||
case '3months': {
|
||||
const d = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
return { after: d.toISOString() };
|
||||
}
|
||||
case 'custom': {
|
||||
const result: { after?: string; before?: string } = {};
|
||||
if (customAfter) result.after = new Date(customAfter).toISOString();
|
||||
if (customBefore) result.before = new Date(customBefore).toISOString();
|
||||
return result;
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function performSearch() {
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
|
||||
loading = true;
|
||||
hasSearched = true;
|
||||
|
||||
// Update URL without navigation
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('q', q);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
|
||||
try {
|
||||
const timeFilters = getTimeFilters();
|
||||
const res = await messagesApi.search(q, {
|
||||
limit: 50,
|
||||
channel: channelFilter.trim() || undefined,
|
||||
agent: agentFilter.trim() || undefined,
|
||||
after: timeFilters.after,
|
||||
before: timeFilters.before
|
||||
});
|
||||
searchResults = res.messages;
|
||||
searchTotal = res.total;
|
||||
} catch {
|
||||
searchResults = [];
|
||||
searchTotal = 0;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
performSearch();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery = '';
|
||||
searchResults = null;
|
||||
searchTotal = 0;
|
||||
hasSearched = false;
|
||||
timeRange = 'any';
|
||||
customAfter = '';
|
||||
customBefore = '';
|
||||
channelFilter = '';
|
||||
agentFilter = '';
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('q');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined });
|
||||
}
|
||||
|
||||
function getMessageContext(msg: any): string {
|
||||
if (msg.channel_id) {
|
||||
return 'channel';
|
||||
}
|
||||
if (msg.to_agent) {
|
||||
return `DM with ${msg.to_agent}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMessageLink(msg: any): string {
|
||||
if (msg.channel_id) {
|
||||
// We don't have channel name directly, link to conversation
|
||||
return `/conversations/${msg.conversation_id}`;
|
||||
}
|
||||
if (msg.to_agent) {
|
||||
return `/dm/${msg.from_agent === 'system' ? msg.to_agent : msg.to_agent}`;
|
||||
}
|
||||
return `/conversations/${msg.conversation_id}`;
|
||||
}
|
||||
|
||||
let activeFilterCount = $derived(
|
||||
(timeRange !== 'any' ? 1 : 0) +
|
||||
(channelFilter.trim() ? 1 : 0) +
|
||||
(agentFilter.trim() ? 1 : 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<h1 class="text-xl font-bold text-text-primary font-display mb-5">Conversations</h1>
|
||||
<div class="p-5 max-w-4xl mx-auto">
|
||||
<!-- Search box -->
|
||||
<form onsubmit={handleSubmit} class="mb-4">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-secondary pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search messages..."
|
||||
class="w-full pl-12 pr-24 py-3.5 text-base bg-bg-tertiary border border-border rounded-lg text-text-primary placeholder-text-secondary focus:border-accent-blue focus:ring-1 focus:ring-accent-blue outline-none transition-colors"
|
||||
bind:value={searchQuery}
|
||||
autofocus
|
||||
/>
|
||||
{#if searchQuery || hasSearched}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearSearch}
|
||||
class="absolute right-20 top-1/2 -translate-y-1/2 text-text-secondary hover:text-text-primary transition-colors p-1"
|
||||
title="Clear search"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 px-4 py-1.5 text-sm font-medium rounded bg-accent-blue text-white hover:brightness-110 transition-all disabled:opacity-50"
|
||||
disabled={!searchQuery.trim() || loading}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if searchResults !== null}
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">
|
||||
Search Results ({searchResults.length})
|
||||
</h2>
|
||||
</div>
|
||||
{#if searchResults.length === 0}
|
||||
<div class="p-8 text-center text-text-secondary text-sm">
|
||||
No messages found for '{$page.url.searchParams.get('q')}'
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#each searchResults as msg}
|
||||
<a href="/conversations/{msg.conversation_id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
|
||||
<span class="text-xs text-text-secondary">{new Date(msg.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/80">{msg.body.slice(0, 200)}</p>
|
||||
</a>
|
||||
<!-- Filter toggle -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded transition-colors {showFilters ? 'bg-accent-blue/20 text-accent-blue' : 'bg-bg-tertiary text-text-secondary hover:text-text-primary'}"
|
||||
onclick={() => showFilters = !showFilters}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Filters
|
||||
{#if activeFilterCount > 0}
|
||||
<span class="inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-accent-blue text-white">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if activeFilterCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { timeRange = 'any'; customAfter = ''; customBefore = ''; channelFilter = ''; agentFilter = ''; }}
|
||||
class="text-xs text-text-secondary hover:text-accent-red transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filters panel -->
|
||||
{#if showFilters}
|
||||
<div class="card p-4 mb-4 space-y-4">
|
||||
<!-- Time range -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-text-secondary mb-1.5">Time range</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each [
|
||||
{ value: 'any', label: 'Any time' },
|
||||
{ value: '24h', label: 'Past 24h' },
|
||||
{ value: 'week', label: 'Past week' },
|
||||
{ value: 'month', label: 'Past month' },
|
||||
{ value: '3months', label: 'Past 3 months' },
|
||||
{ value: 'custom', label: 'Custom range' }
|
||||
] as opt}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => timeRange = opt.value}
|
||||
class="px-3 py-1 text-xs rounded transition-colors {timeRange === opt.value ? 'bg-accent-blue/20 text-accent-blue font-medium' : 'bg-bg-tertiary text-text-secondary hover:text-text-primary'}"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if timeRange === 'custom'}
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<div class="flex-1">
|
||||
<label class="block text-[10px] text-text-secondary mb-0.5">From</label>
|
||||
<input
|
||||
type="date"
|
||||
class="w-full px-2 py-1 text-xs bg-bg-input border border-border rounded text-text-primary focus:border-border-active outline-none"
|
||||
bind:value={customAfter}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-[10px] text-text-secondary mb-0.5">To</label>
|
||||
<input
|
||||
type="date"
|
||||
class="w-full px-2 py-1 text-xs bg-bg-input border border-border rounded text-text-primary focus:border-border-active outline-none"
|
||||
bind:value={customBefore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Channel filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-text-secondary mb-1.5">
|
||||
Channels
|
||||
<span class="font-normal text-text-secondary/60 ml-1">comma-separated, prefix with - to exclude</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. general, news-tech or -spam, -test"
|
||||
class="w-full px-3 py-1.5 text-sm bg-bg-input border border-border rounded text-text-primary placeholder-text-secondary/50 focus:border-border-active outline-none"
|
||||
bind:value={channelFilter}
|
||||
/>
|
||||
{#if channelList.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1.5">
|
||||
{#each channelList.slice(0, 8) as ch}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (channelFilter) {
|
||||
channelFilter = channelFilter + ', ' + ch;
|
||||
} else {
|
||||
channelFilter = ch;
|
||||
}
|
||||
}}
|
||||
class="px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
#{ch}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Agent filter -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-text-secondary mb-1.5">
|
||||
Agents
|
||||
<span class="font-normal text-text-secondary/60 ml-1">comma-separated, prefix with - to exclude</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. research-mcpproxy, social-commenter or -system"
|
||||
class="w-full px-3 py-1.5 text-sm bg-bg-input border border-border rounded text-text-primary placeholder-text-secondary/50 focus:border-border-active outline-none"
|
||||
bind:value={agentFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Apply filters button (re-search) -->
|
||||
{#if hasSearched}
|
||||
<button
|
||||
type="button"
|
||||
onclick={performSearch}
|
||||
class="btn-primary text-xs py-1.5 px-4"
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">All Conversations</h2>
|
||||
</div>
|
||||
{#if loadingData}
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Results area -->
|
||||
{#if loading}
|
||||
<div class="card">
|
||||
<div class="p-5 space-y-5">
|
||||
{#each Array(5) as _}
|
||||
<div class="space-y-2">
|
||||
<div class="skeleton h-3 w-1/3"></div>
|
||||
<div class="skeleton h-3 w-2/3"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="skeleton h-3 w-24"></div>
|
||||
<div class="skeleton h-3 w-16"></div>
|
||||
</div>
|
||||
<div class="skeleton h-3 w-3/4"></div>
|
||||
<div class="skeleton h-3 w-1/2"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if convList.length === 0}
|
||||
<div class="p-8 text-center text-text-secondary text-sm">
|
||||
No conversations yet.
|
||||
</div>
|
||||
{:else if hasSearched && searchResults !== null}
|
||||
<!-- Search result count -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-xs text-text-secondary">
|
||||
{searchTotal} result{searchTotal !== 1 ? 's' : ''} for "<span class="text-text-primary font-medium">{$page.url.searchParams.get('q')}</span>"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if searchResults.length === 0}
|
||||
<div class="card">
|
||||
<div class="p-12 text-center">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 text-text-secondary/40" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p class="text-sm text-text-secondary mb-1">No messages found</p>
|
||||
<p class="text-xs text-text-secondary/60">Try different keywords or adjust your filters</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-border">
|
||||
{#each convList as conv}
|
||||
<a href="/conversations/{conv.id}" class="block px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
{#if conv.last_agent}
|
||||
<span class="font-semibold text-sm text-text-primary">{conv.last_agent}</span>
|
||||
<span class="text-[10px] text-text-secondary">·</span>
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary">{conv.updated_at ? new Date(conv.updated_at).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/80 truncate">
|
||||
{conv.last_message ? (conv.last_message.length > 120 ? conv.last_message.slice(0, 120) + '...' : conv.last_message) : 'No messages'}
|
||||
</p>
|
||||
<div class="card divide-y divide-border">
|
||||
{#each searchResults as msg}
|
||||
<a href={getMessageLink(msg)} class="block px-5 py-3.5 hover:bg-bg-tertiary/50 transition-colors group">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<!-- Agent avatar -->
|
||||
<div class="w-6 h-6 rounded flex-shrink-0 flex items-center justify-center text-[10px] font-bold text-white" style="background-color: var(--accent-purple)">
|
||||
{msg.from_agent?.charAt(0)?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<span class="badge bg-bg-tertiary text-text-secondary flex-shrink-0 ml-3">
|
||||
{conv.message_count}
|
||||
</span>
|
||||
<span class="font-semibold text-sm text-text-primary">{msg.from_agent}</span>
|
||||
{#if msg.to_agent}
|
||||
<svg class="w-3 h-3 text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<span class="text-sm text-text-secondary">{msg.to_agent}</span>
|
||||
{/if}
|
||||
<span class="text-[10px] text-text-secondary/60 ml-auto flex-shrink-0">{formatTimestamp(msg.created_at)}</span>
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/80 leading-relaxed line-clamp-2 ml-8">
|
||||
{msg.body.length > 300 ? msg.body.slice(0, 300) + '...' : msg.body}
|
||||
</p>
|
||||
{#if getMessageContext(msg)}
|
||||
<div class="flex items-center gap-1.5 mt-1.5 ml-8">
|
||||
{#if msg.channel_id}
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary">
|
||||
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" />
|
||||
</svg>
|
||||
channel
|
||||
</span>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded bg-bg-tertiary text-text-secondary">
|
||||
DM
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Empty state - no search performed yet -->
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 mb-5 rounded-2xl bg-bg-tertiary flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-text-secondary/50" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-display font-semibold text-text-primary mb-2">Search messages</h2>
|
||||
<p class="text-sm text-text-secondary text-center max-w-md mb-6">
|
||||
Search across all your conversations, channels, and direct messages.
|
||||
Use filters to narrow results by time, channel, or agent.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchQuery = 'deployment'; performSearch(); }}
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
|
||||
>
|
||||
Try "deployment"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchQuery = 'error'; performSearch(); }}
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
|
||||
>
|
||||
Try "error"
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchQuery = 'update'; performSearch(); }}
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-bg-tertiary text-text-secondary hover:text-text-primary hover:bg-border transition-colors"
|
||||
>
|
||||
Try "update"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { conversations as convsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let conversation = $state<any>(null);
|
||||
let messagesList = $state<any[]>([]);
|
||||
@@ -151,7 +152,7 @@
|
||||
{/if}
|
||||
<span class="text-xs text-text-secondary ml-auto">{formatTime(msg.created_at)}</span>
|
||||
</div>
|
||||
<div class="text-sm text-text-primary/90 whitespace-pre-wrap leading-relaxed">{msg.body}</div>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { agents as agentsApi, messages as messagesApi } from '$lib/api/client';
|
||||
import { openThread, closeThread } from '$lib/stores/thread';
|
||||
import { notifications } from '$lib/stores/notifications';
|
||||
import MessageBody from '$lib/components/MessageBody.svelte';
|
||||
|
||||
let peerAgent = $derived($page.params.name);
|
||||
let peer = $state<any>(null);
|
||||
@@ -244,7 +245,7 @@
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded {msg.status === 'pending' ? 'bg-accent-yellow/20 text-accent-yellow' : msg.status === 'processing' ? 'bg-accent-blue/20 text-accent-blue' : 'bg-bg-tertiary text-text-secondary'}">{msg.status}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-text-primary/90 leading-relaxed whitespace-pre-wrap">{msg.body}</p>
|
||||
<div class="text-sm text-text-primary/90 leading-relaxed"><MessageBody body={msg.body} /></div>
|
||||
{#if msg.reply_count > 0}
|
||||
<button
|
||||
class="mt-1 flex items-center gap-1 text-xs text-accent-blue hover:underline"
|
||||
|
||||
@@ -64,29 +64,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
<div class="card mb-5">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Management</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<a href="/settings/api-keys" class="flex items-center justify-between px-5 py-3 hover:bg-bg-tertiary/50 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-text-primary font-medium">API Keys</p>
|
||||
<p class="text-xs text-text-secondary">Manage API keys for agent access</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { apiKeys, agents as agentsApi } from '$lib/api/client';
|
||||
|
||||
let keys = $state<any[]>([]);
|
||||
let agentList = $state<any[]>([]);
|
||||
let loadingData = $state(true);
|
||||
|
||||
// Create form state
|
||||
let showCreate = $state(false);
|
||||
let creating = $state(false);
|
||||
let createError = $state('');
|
||||
let newKeyName = $state('');
|
||||
let newKeyAgentId = $state<number | undefined>(undefined);
|
||||
let newKeyPermRead = $state(true);
|
||||
let newKeyPermWrite = $state(true);
|
||||
let newKeyPermAdmin = $state(false);
|
||||
let newKeyChannels = $state('');
|
||||
let newKeyReadOnly = $state(false);
|
||||
let newKeyExpiry = $state('');
|
||||
|
||||
// Created key display
|
||||
let createdKey = $state<{ key: any; api_key: string; mcp_config: any } | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
// Revoke
|
||||
let confirmRevokeId = $state<number | null>(null);
|
||||
let revoking = $state(false);
|
||||
|
||||
let _initialized = $state(false);
|
||||
$effect(() => {
|
||||
if (!_initialized) {
|
||||
_initialized = true;
|
||||
loadData();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loadingData = true;
|
||||
try {
|
||||
const [keysRes, agRes] = await Promise.all([
|
||||
apiKeys.list(),
|
||||
agentsApi.list()
|
||||
]);
|
||||
keys = keysRes.keys ?? [];
|
||||
agentList = agRes.agents ?? [];
|
||||
} catch {
|
||||
// handled
|
||||
} finally {
|
||||
loadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!newKeyName.trim()) {
|
||||
createError = 'Key name is required';
|
||||
return;
|
||||
}
|
||||
creating = true;
|
||||
createError = '';
|
||||
try {
|
||||
const permissions: Record<string, boolean> = {};
|
||||
if (newKeyPermRead) permissions.read = true;
|
||||
if (newKeyPermWrite) permissions.write = true;
|
||||
if (newKeyPermAdmin) permissions.admin = true;
|
||||
|
||||
const channels = newKeyChannels.trim()
|
||||
? newKeyChannels.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
const res = await apiKeys.create({
|
||||
name: newKeyName.trim(),
|
||||
agent_id: newKeyAgentId,
|
||||
permissions: Object.keys(permissions).length > 0 ? permissions : undefined,
|
||||
allowed_channels: channels,
|
||||
read_only: newKeyReadOnly || undefined,
|
||||
expires_at: newKeyExpiry || undefined
|
||||
});
|
||||
|
||||
createdKey = res;
|
||||
newKeyName = '';
|
||||
newKeyAgentId = undefined;
|
||||
newKeyPermRead = true;
|
||||
newKeyPermWrite = true;
|
||||
newKeyPermAdmin = false;
|
||||
newKeyChannels = '';
|
||||
newKeyReadOnly = false;
|
||||
newKeyExpiry = '';
|
||||
showCreate = false;
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
createError = err.message || 'Failed to create API key';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(id: number) {
|
||||
revoking = true;
|
||||
try {
|
||||
await apiKeys.revoke(id);
|
||||
confirmRevokeId = null;
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to revoke key');
|
||||
} finally {
|
||||
revoking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function getMcpConfig(apiKey: string): string {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
synapbus: {
|
||||
url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function getClaudeConfig(apiKey: string): string {
|
||||
return JSON.stringify({
|
||||
synapbus: {
|
||||
type: 'streamableHttp',
|
||||
url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080'}/mcp`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
}
|
||||
}, null, 2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-5 max-w-5xl">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/settings" class="text-text-secondary hover:text-text-primary transition-colors" aria-label="Back to settings">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-xl font-bold text-text-primary font-display">API Keys</h1>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary flex items-center gap-1.5"
|
||||
onclick={() => { showCreate = !showCreate; createdKey = null; }}
|
||||
>
|
||||
{#if showCreate}
|
||||
Cancel
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create API Key
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Created key display -->
|
||||
{#if createdKey}
|
||||
<div class="card mb-5 border-accent-yellow/30">
|
||||
<div class="px-5 py-3 border-b border-border bg-accent-yellow/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-accent-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-accent-yellow">This key will only be shown once. Save it now.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- The key -->
|
||||
<div>
|
||||
<p class="block text-xs font-medium text-text-secondary mb-1.5">API Key</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 p-3 bg-bg-primary rounded-lg text-sm font-mono text-accent-green break-all select-all border border-accent-green/20">{createdKey.api_key}</code>
|
||||
<button
|
||||
class="flex-shrink-0 p-2.5 rounded-lg bg-bg-tertiary hover:bg-border-active text-text-secondary hover:text-text-primary transition-all"
|
||||
onclick={() => copyToClipboard(createdKey?.api_key ?? '')}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{#if copied}
|
||||
<svg class="w-4 h-4 text-accent-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Config -->
|
||||
<div>
|
||||
<p class="block text-xs font-medium text-text-secondary mb-1.5">MCP Config (for MCP clients)</p>
|
||||
<pre class="p-3 bg-bg-primary rounded-lg text-xs font-mono text-text-primary/80 overflow-x-auto border border-border">{getMcpConfig(createdKey.api_key)}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Claude Code config -->
|
||||
<div>
|
||||
<p class="block text-xs font-medium text-text-secondary mb-1.5">Claude Code Config (add to ~/.claude/mcp.json)</p>
|
||||
<pre class="p-3 bg-bg-primary rounded-lg text-xs font-mono text-text-primary/80 overflow-x-auto border border-border">{getClaudeConfig(createdKey.api_key)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-border">
|
||||
<button
|
||||
class="btn-secondary text-xs"
|
||||
onclick={() => (createdKey = null)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Create form -->
|
||||
{#if showCreate}
|
||||
<form class="card p-5 mb-5" onsubmit={handleCreate}>
|
||||
<h3 class="font-semibold text-sm text-text-primary font-display mb-4">Create New API Key</h3>
|
||||
{#if createError}
|
||||
<div class="mb-3 px-3 py-2 bg-accent-red/10 rounded text-xs text-accent-red">{createError}</div>
|
||||
{/if}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="key-name" class="block text-xs font-medium text-text-secondary mb-1.5">Name</label>
|
||||
<input id="key-name" type="text" class="input" placeholder="e.g. production-researcher" bind:value={newKeyName} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-agent" class="block text-xs font-medium text-text-secondary mb-1.5">Agent (optional)</label>
|
||||
<select id="key-agent" class="input" bind:value={newKeyAgentId}>
|
||||
<option value={undefined}>No specific agent</option>
|
||||
{#each agentList as agent}
|
||||
<option value={agent.id}>{agent.display_name || agent.name} (@{agent.name})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="block text-xs font-medium text-text-secondary mb-2">Permissions</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
|
||||
<input type="checkbox" bind:checked={newKeyPermRead} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
|
||||
Read
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
|
||||
<input type="checkbox" bind:checked={newKeyPermWrite} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
|
||||
Write
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
|
||||
<input type="checkbox" bind:checked={newKeyPermAdmin} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
|
||||
Admin
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-channels" class="block text-xs font-medium text-text-secondary mb-1.5">Allowed Channels (comma-separated, leave empty for all)</label>
|
||||
<input id="key-channels" type="text" class="input" placeholder="general, research, alerts" bind:value={newKeyChannels} />
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-text-primary cursor-pointer">
|
||||
<input type="checkbox" bind:checked={newKeyReadOnly} class="rounded bg-bg-input border-border text-accent-green focus:ring-accent-green" />
|
||||
Read-only mode
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label for="key-expiry" class="block text-xs font-medium text-text-secondary mb-1.5">Expiry Date (optional)</label>
|
||||
<input id="key-expiry" type="date" class="input" bind:value={newKeyExpiry} />
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create API Key'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Keys list -->
|
||||
<div class="card">
|
||||
<div class="px-5 py-3 border-b border-border">
|
||||
<h2 class="font-semibold text-sm text-text-primary font-display">Your API Keys</h2>
|
||||
</div>
|
||||
{#if loadingData}
|
||||
<div class="p-5 space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="skeleton h-10 w-full"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if keys.length === 0}
|
||||
<div class="p-8 text-center">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-border-active" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p class="text-sm text-text-secondary">No API keys yet. Create one to get started.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Name</th>
|
||||
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Prefix</th>
|
||||
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Permissions</th>
|
||||
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Channels</th>
|
||||
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Expires</th>
|
||||
<th class="text-left px-5 py-2.5 text-xs font-medium text-text-secondary">Created</th>
|
||||
<th class="text-right px-5 py-2.5 text-xs font-medium text-text-secondary">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
{#each keys as key}
|
||||
<tr class="hover:bg-bg-tertiary/30 transition-colors">
|
||||
<td class="px-5 py-2.5 text-text-primary font-medium">{key.name}</td>
|
||||
<td class="px-5 py-2.5 font-mono text-xs text-text-secondary">{key.prefix ?? 'sb_...'}</td>
|
||||
<td class="px-5 py-2.5">
|
||||
<div class="flex gap-1">
|
||||
{#if key.permissions?.read}
|
||||
<span class="badge bg-accent-blue/20 text-accent-blue">read</span>
|
||||
{/if}
|
||||
{#if key.permissions?.write}
|
||||
<span class="badge bg-accent-green/20 text-accent-green">write</span>
|
||||
{/if}
|
||||
{#if key.permissions?.admin}
|
||||
<span class="badge bg-accent-purple/20 text-accent-purple">admin</span>
|
||||
{/if}
|
||||
{#if key.read_only}
|
||||
<span class="badge bg-accent-yellow/20 text-accent-yellow">RO</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-2.5 text-xs text-text-secondary">
|
||||
{key.allowed_channels?.length ? key.allowed_channels.join(', ') : 'All'}
|
||||
</td>
|
||||
<td class="px-5 py-2.5 text-xs text-text-secondary">{formatDate(key.expires_at)}</td>
|
||||
<td class="px-5 py-2.5 text-xs text-text-secondary">{formatDate(key.created_at)}</td>
|
||||
<td class="px-5 py-2.5 text-right">
|
||||
{#if confirmRevokeId === key.id}
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-accent-red text-white rounded hover:brightness-110 transition-all disabled:opacity-50"
|
||||
onclick={() => handleRevoke(key.id)}
|
||||
disabled={revoking}
|
||||
>
|
||||
{revoking ? '...' : 'Confirm'}
|
||||
</button>
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-bg-tertiary text-text-primary rounded hover:bg-border-active transition-all"
|
||||
onclick={() => (confirmRevokeId = null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="px-2 py-1 text-xs text-accent-red hover:bg-accent-red/10 rounded transition-colors"
|
||||
onclick={() => (confirmRevokeId = key.id)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
+25
-2
@@ -1,3 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7c3aed"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#bg)"/>
|
||||
<g transform="translate(4,4)">
|
||||
<circle cx="12" cy="5" r="1.8" fill="#c4b5fd"/>
|
||||
<circle cx="6" cy="10" r="1.5" fill="#a78bfa"/>
|
||||
<circle cx="18" cy="9" r="1.5" fill="#c4b5fd"/>
|
||||
<circle cx="12" cy="13" r="2" fill="#67e8f9"/>
|
||||
<circle cx="5" cy="17" r="1.5" fill="#a78bfa"/>
|
||||
<circle cx="19" cy="17" r="1.3" fill="#a78bfa"/>
|
||||
<circle cx="11" cy="20" r="1.3" fill="#c4b5fd"/>
|
||||
<line x1="12" y1="5" x2="6" y2="10" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="5" x2="18" y2="9" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="6" y1="10" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="18" y1="9" x2="12" y2="13" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="13" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="12" y1="13" x2="19" y2="17" stroke="white" stroke-width="0.5" opacity="0.5"/>
|
||||
<line x1="5" y1="17" x2="11" y2="20" stroke="white" stroke-width="0.5" opacity="0.3"/>
|
||||
<line x1="6" y1="10" x2="5" y2="17" stroke="white" stroke-width="0.5" opacity="0.3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 184 B After Width: | Height: | Size: 1.4 KiB |
Reference in New Issue
Block a user