1
0
forked from wrenn/wrenn

feat: channel audit logging, name cleaning, message formatting, and dashboard UI

- Add audit log entries for channel create, update, rotate_config, delete
- Clean channel names on create/update (trim, lowercase, spaces → hyphens,
  SafeName validation)
- Format chat notifications with full event details (resource, actor, team,
  timestamp) instead of one-liners
- Fix Discord split-line embeds by setting splitLines=No on shoutrrr URL
- Add channels dashboard page and sidebar navigation
This commit is contained in:
2026-04-10 01:17:03 +06:00
parent 84dd15d22b
commit 0f78982186
11 changed files with 1624 additions and 20 deletions

View File

@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.omukk.dev/wrenn/sandbox/internal/audit"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/channels"
"git.omukk.dev/wrenn/sandbox/internal/db"
@ -15,11 +16,12 @@ import (
)
type channelHandler struct {
svc *channels.Service
svc *channels.Service
audit *audit.AuditLogger
}
func newChannelHandler(svc *channels.Service) *channelHandler {
return &channelHandler{svc: svc}
func newChannelHandler(svc *channels.Service, al *audit.AuditLogger) *channelHandler {
return &channelHandler{svc: svc, audit: al}
}
type createChannelRequest struct {
@ -94,6 +96,8 @@ func (h *channelHandler) Create(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogChannelCreate(r.Context(), ac, result.Channel.ID, result.Channel.Name, result.Channel.Provider)
resp := channelToResponse(result.Channel)
if result.PlaintextSecret != "" {
resp.Secret = &result.PlaintextSecret
@ -168,6 +172,7 @@ func (h *channelHandler) Update(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogChannelUpdate(r.Context(), ac, channelID)
writeJSON(w, http.StatusOK, channelToResponse(ch))
}
@ -212,6 +217,7 @@ func (h *channelHandler) RotateConfig(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogChannelRotateConfig(r.Context(), ac, channelID)
writeJSON(w, http.StatusOK, channelToResponse(ch))
}
@ -231,5 +237,6 @@ func (h *channelHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
h.audit.LogChannelDelete(r.Context(), ac, channelID)
w.WriteHeader(http.StatusNoContent)
}

View File

@ -71,7 +71,7 @@ func New(
statsH := newStatsHandler(statsSvc)
metricsH := newSandboxMetricsHandler(queries, pool)
buildH := newBuildHandler(buildSvc, queries, pool)
channelH := newChannelHandler(channelSvc)
channelH := newChannelHandler(channelSvc, al)
// OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI)

View File

@ -281,6 +281,76 @@ func (l *AuditLogger) LogTeamRename(ctx context.Context, ac auth.AuthContext, te
})
}
// --- Channel events (scope: team) ---
func (l *AuditLogger) LogChannelCreate(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID, name, provider string) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: optText(actorID),
ActorName: actorName,
ResourceType: "channel",
ResourceID: optText(id.FormatChannelID(channelID)),
Action: "create",
Scope: "team",
Status: "success",
Metadata: marshalMeta(map[string]any{"name": name, "provider": provider}),
})
}
func (l *AuditLogger) LogChannelUpdate(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: optText(actorID),
ActorName: actorName,
ResourceType: "channel",
ResourceID: optText(id.FormatChannelID(channelID)),
Action: "update",
Scope: "team",
Status: "info",
Metadata: []byte("{}"),
})
}
func (l *AuditLogger) LogChannelRotateConfig(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: optText(actorID),
ActorName: actorName,
ResourceType: "channel",
ResourceID: optText(id.FormatChannelID(channelID)),
Action: "rotate_config",
Scope: "team",
Status: "info",
Metadata: []byte("{}"),
})
}
func (l *AuditLogger) LogChannelDelete(ctx context.Context, ac auth.AuthContext, channelID pgtype.UUID) {
actorType, actorID, actorName := actorFields(ac)
l.write(ctx, db.InsertAuditLogParams{
ID: id.NewAuditLogID(),
TeamID: ac.TeamID,
ActorType: actorType,
ActorID: optText(actorID),
ActorName: actorName,
ResourceType: "channel",
ResourceID: optText(id.FormatChannelID(channelID)),
Action: "delete",
Scope: "team",
Status: "warning",
Metadata: []byte("{}"),
})
}
// --- API key events (scope: team) ---
func (l *AuditLogger) LogAPIKeyCreate(ctx context.Context, ac auth.AuthContext, keyID pgtype.UUID, keyName string) {

View File

@ -2,30 +2,64 @@ package channels
import (
"fmt"
"strings"
"git.omukk.dev/wrenn/sandbox/internal/events"
)
// FormatMessage produces a compact notification string for chat providers.
// FormatMessage produces a human-readable notification string containing
// the event summary, resource details, actor, and timestamp.
func FormatMessage(e events.Event) string {
var b strings.Builder
b.WriteString(formatSummary(e))
fmt.Fprintf(&b, "\n\nEvent: %s", e.Event)
fmt.Fprintf(&b, "\nResource: %s %s", e.Resource.Type, e.Resource.ID)
fmt.Fprintf(&b, "\nActor: %s", formatActor(e.Actor))
fmt.Fprintf(&b, "\nTeam: %s", e.TeamID)
fmt.Fprintf(&b, "\nTime: %s", e.Timestamp)
return b.String()
}
func formatSummary(e events.Event) string {
switch e.Event {
case events.CapsuleCreated:
return fmt.Sprintf("[%s] Capsule %s created", e.Event, e.Resource.ID)
return fmt.Sprintf("Capsule %s created", e.Resource.ID)
case events.CapsuleRunning:
return fmt.Sprintf("[%s] Capsule %s is running", e.Event, e.Resource.ID)
return fmt.Sprintf("Capsule %s is running", e.Resource.ID)
case events.CapsulePaused:
return fmt.Sprintf("[%s] Capsule %s paused", e.Event, e.Resource.ID)
return fmt.Sprintf("Capsule %s paused", e.Resource.ID)
case events.CapsuleDestroyed:
return fmt.Sprintf("[%s] Capsule %s destroyed", e.Event, e.Resource.ID)
return fmt.Sprintf("Capsule %s destroyed", e.Resource.ID)
case events.SnapshotCreated:
return fmt.Sprintf("[%s] Template snapshot %s created", e.Event, e.Resource.ID)
return fmt.Sprintf("Template snapshot %s created", e.Resource.ID)
case events.SnapshotDeleted:
return fmt.Sprintf("[%s] Template snapshot %s deleted", e.Event, e.Resource.ID)
return fmt.Sprintf("Template snapshot %s deleted", e.Resource.ID)
case events.HostUp:
return fmt.Sprintf("[%s] Host %s is up", e.Event, e.Resource.ID)
return fmt.Sprintf("Host %s is up", e.Resource.ID)
case events.HostDown:
return fmt.Sprintf("[%s] Host %s is down", e.Event, e.Resource.ID)
return fmt.Sprintf("Host %s is down", e.Resource.ID)
default:
return fmt.Sprintf("[%s] %s %s", e.Event, e.Resource.Type, e.Resource.ID)
return fmt.Sprintf("%s %s", e.Resource.Type, e.Resource.ID)
}
}
func formatActor(a events.Actor) string {
switch a.Type {
case events.ActorSystem:
return "system"
case events.ActorUser:
if a.Name != "" {
return fmt.Sprintf("%s (%s)", a.Name, a.ID)
}
return a.ID
case events.ActorAPIKey:
if a.Name != "" {
return fmt.Sprintf("api_key %s (%s)", a.Name, a.ID)
}
return fmt.Sprintf("api_key %s", a.ID)
default:
return string(a.Type)
}
}

View File

@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
@ -15,6 +16,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/events"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/validate"
)
// Valid providers.
@ -72,9 +74,11 @@ type CreateResult struct {
// Create creates a new notification channel.
func (s *Service) Create(ctx context.Context, p CreateParams) (CreateResult, error) {
if p.Name == "" {
return CreateResult{}, fmt.Errorf("invalid: channel name is required")
clean, err := cleanName(p.Name)
if err != nil {
return CreateResult{}, err
}
p.Name = clean
if !validProviders[p.Provider] {
return CreateResult{}, fmt.Errorf("invalid: unsupported provider %q", p.Provider)
@ -154,9 +158,11 @@ func (s *Service) Get(ctx context.Context, channelID, teamID pgtype.UUID) (db.Ch
// Update updates a channel's name and event types.
func (s *Service) Update(ctx context.Context, channelID, teamID pgtype.UUID, name string, eventTypes []string) (db.Channel, error) {
if name == "" {
return db.Channel{}, fmt.Errorf("invalid: channel name is required")
clean, err := cleanName(name)
if err != nil {
return db.Channel{}, err
}
name = clean
if len(eventTypes) == 0 {
return db.Channel{}, fmt.Errorf("invalid: at least one event type is required")
@ -271,6 +277,18 @@ func (s *Service) Delete(ctx context.Context, channelID, teamID pgtype.UUID) err
return s.DB.DeleteChannelByTeam(ctx, db.DeleteChannelByTeamParams{ID: channelID, TeamID: teamID})
}
// cleanName normalises a channel name: trim whitespace, lowercase, replace
// spaces with hyphens, then validate against SafeName rules.
func cleanName(name string) (string, error) {
name = strings.TrimSpace(name)
name = strings.ToLower(name)
name = strings.ReplaceAll(name, " ", "-")
if err := validate.SafeName(name); err != nil {
return "", fmt.Errorf("invalid: %w", err)
}
return name, nil
}
func generateSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {

View File

@ -39,7 +39,7 @@ func discordURL(config map[string]string) (string, error) {
return "", fmt.Errorf("unexpected discord webhook URL format")
}
webhookID, token := parts[2], parts[3]
return fmt.Sprintf("discord://%s@%s", token, webhookID), nil
return fmt.Sprintf("discord://%s@%s?splitLines=No", token, webhookID), nil
}
// slackURL converts https://hooks.slack.com/services/T.../B.../XXX → slack://T.../B.../XXX