Purged 29,966 lines of stale chat-switchboard documentation: - 41 docs/ files (ICD specs, design docs, archive, workflow docs) - 5 root MD files (CHANGESET, TURNOVER, ICD-DRIFT-AUDIT, etc.) New documentation: - docs/ARCHITECTURE.md — kernel components, design principles, data layer - ROADMAP.md — v0.1.0 through v0.5.0 MVP with decision log - CHANGELOG.md — fresh, starting from v0.1.0 fork - README.md — rewritten for switchboard-core Also in this commit: - config.go: stripped 7 dropped fields, DB default → switchboard_core - pages/loaders.go: stripped provider/model/notes/projects loaders - pages/pages.go: stripped persona store lookup - handlers/workflows.go: stripped persona tool grants - main.go: stripped team provider routes, avatar routes Production code: zero references to deleted packages, stores, or models. -29,966/+352 lines across 73 files.
297 lines
8.4 KiB
Go
297 lines
8.4 KiB
Go
package pages
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// TeamOption for template dropdowns.
|
|
type TeamOption struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
MemberCount int `json:"member_count,omitempty"`
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
|
|
// ProviderTypeOption for the provider type dropdown.
|
|
type ProviderTypeOption struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// UserRow is a user for the admin users table.
|
|
type UserRow struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"display_name"`
|
|
Role string `json:"role"`
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
|
|
// RoleConfig holds a role's current settings.
|
|
type RoleConfig struct {
|
|
Name string `json:"name"`
|
|
Primary *RoleSelection `json:"primary"`
|
|
Fallback *RoleSelection `json:"fallback"`
|
|
}
|
|
|
|
// RoleSelection is provider + model pair for a role slot.
|
|
type RoleSelection struct {
|
|
ProviderID string `json:"provider_config_id"`
|
|
ModelID string `json:"model_id"`
|
|
}
|
|
|
|
// ── Page data structs ────────────────────────
|
|
//
|
|
// v0.25.0: Each loader function is a "data provider" keyed by surface ID.
|
|
// The surface manifest's DataRequires field references these keys.
|
|
// Currently 1:1 (one loader per surface). Future: composite loaders
|
|
// that assemble data from multiple providers for dashboard-style surfaces.
|
|
|
|
// AdminPageData is what the admin surface templates receive.
|
|
type AdminPageData struct {
|
|
Section string `json:"section"`
|
|
Category string `json:"category"`
|
|
ProviderTypes []ProviderTypeOption `json:"provider_types,omitempty"`
|
|
Teams []TeamOption `json:"teams"`
|
|
Users []UserRow `json:"users,omitempty"`
|
|
Roles []RoleConfig `json:"roles"`
|
|
Policies any `json:"policies,omitempty"`
|
|
ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3
|
|
}
|
|
|
|
// ChatPageData is what the chat surface templates receive.
|
|
type ChatPageData struct {
|
|
ChatID string `json:"chat_id,omitempty"`
|
|
}
|
|
|
|
// SettingsPageData is what the settings surface templates receive.
|
|
// Feature gates (BYOK, personas) moved to sw.auth.policies in v0.37.19.
|
|
type SettingsPageData struct {
|
|
Section string `json:"section"`
|
|
ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3
|
|
}
|
|
|
|
// ── Loader registration ──────────────────────
|
|
|
|
// TeamAdminPageData is what the team-admin surface receives.
|
|
type TeamAdminPageData struct {
|
|
Section string `json:"section"`
|
|
ConfigSections []ConfigSectionEntry `json:"config_sections,omitempty"` // v0.38.3
|
|
}
|
|
|
|
func (e *Engine) registerLoaders() {
|
|
e.RegisterLoader("admin", e.adminLoader)
|
|
e.RegisterLoader("settings", e.settingsLoader)
|
|
e.RegisterLoader("team-admin", e.teamAdminLoader)
|
|
}
|
|
|
|
// Used for manifest validation — DataRequires entries must match a key here.
|
|
func (e *Engine) ListDataProviders() []string {
|
|
keys := make([]string, 0, len(e.loaders))
|
|
for k := range e.loaders {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// ── Admin loader ─────────────────────────────
|
|
// Pre-loads ALL dropdown data. Fixes bugs #1 (model roles) and #2 (team scope).
|
|
|
|
func (e *Engine) adminLoader(c *gin.Context, s store.Stores) (any, error) {
|
|
ctx := context.Background()
|
|
section := c.Param("section")
|
|
if section == "" {
|
|
section = "users"
|
|
}
|
|
|
|
data := &AdminPageData{
|
|
Section: section,
|
|
Category: sectionCategory(section),
|
|
}
|
|
|
|
rolesMap, ok := raw["roles"].(map[string]any)
|
|
if !ok {
|
|
rolesMap = raw
|
|
}
|
|
|
|
for _, name := range roleNames {
|
|
rc := RoleConfig{Name: name}
|
|
if roleData, ok := rolesMap[name].(map[string]any); ok {
|
|
if primary, ok := roleData["primary"].(map[string]any); ok {
|
|
rc.Primary = &RoleSelection{
|
|
ProviderID: fmt.Sprintf("%v", primary["provider_config_id"]),
|
|
ModelID: fmt.Sprintf("%v", primary["model_id"]),
|
|
}
|
|
}
|
|
if fallback, ok := roleData["fallback"].(map[string]any); ok {
|
|
rc.Fallback = &RoleSelection{
|
|
ProviderID: fmt.Sprintf("%v", fallback["provider_config_id"]),
|
|
ModelID: fmt.Sprintf("%v", fallback["model_id"]),
|
|
}
|
|
}
|
|
}
|
|
roles = append(roles, rc)
|
|
}
|
|
|
|
return roles
|
|
}
|
|
|
|
// ── Admin helpers ────────────────────────────
|
|
|
|
// sectionCategory maps an admin section to its category tab.
|
|
func sectionCategory(section string) string {
|
|
switch section {
|
|
case "users", "teams", "groups":
|
|
return "people"
|
|
case "providers", "models", "personas", "roles", "knowledgeBases", "memory":
|
|
return "ai"
|
|
case "health", "routing", "capabilities":
|
|
return "routing"
|
|
case "settings", "storage", "packages", "channels", "broadcast":
|
|
return "system"
|
|
case "usage", "audit", "stats":
|
|
return "monitoring"
|
|
default:
|
|
return "ai" // default landing
|
|
}
|
|
}
|
|
|
|
// loadProviderTypes returns the registered provider type metadata.
|
|
func loadProviderTypes() []ProviderTypeOption {
|
|
out := make([]ProviderTypeOption, 0, len(types))
|
|
for _, t := range types {
|
|
out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// loadUsers returns the user list for the admin users table.
|
|
func (e *Engine) loadUsers(ctx context.Context, s store.Stores) []UserRow {
|
|
if s.Users == nil {
|
|
return nil
|
|
}
|
|
users, _, err := s.Users.List(ctx, store.ListOptions{Limit: 500, Sort: "username", Order: "asc"})
|
|
if err != nil {
|
|
log.Printf("[pages/admin] Failed to list users: %v", err)
|
|
return nil
|
|
}
|
|
out := make([]UserRow, 0, len(users))
|
|
for _, u := range users {
|
|
out = append(out, UserRow{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Email: u.Email,
|
|
DisplayName: u.DisplayName,
|
|
Role: u.Role,
|
|
IsActive: u.IsActive,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── Settings loader ──────────────────────────
|
|
// v0.22.7: Reads feature gates from GlobalConfig to control
|
|
// which nav links/tabs are visible (BYOK, User Personas).
|
|
|
|
func (e *Engine) teamAdminLoader(c *gin.Context, s store.Stores) (any, error) {
|
|
section := c.Param("section")
|
|
if section == "" {
|
|
section = "members"
|
|
}
|
|
return &TeamAdminPageData{
|
|
Section: section,
|
|
ConfigSections: configSectionsForSurface(context.Background(), s, "team-admin"),
|
|
}, nil
|
|
}
|
|
|
|
func (e *Engine) settingsLoader(c *gin.Context, s store.Stores) (any, error) {
|
|
section := c.Param("section")
|
|
if section == "" {
|
|
section = "general"
|
|
}
|
|
|
|
return &SettingsPageData{
|
|
Section: section,
|
|
ConfigSections: configSectionsForSurface(context.Background(), s, "settings"),
|
|
}, nil
|
|
}
|
|
|
|
// ── Config section discovery (v0.38.3) ───────
|
|
//
|
|
// Packages declare a config_section in their manifest. This helper scans
|
|
// enabled packages and returns entries whose surfaces array includes the
|
|
// target surface. No new tables or endpoints — purely manifest-driven.
|
|
|
|
func configSectionsForSurface(ctx context.Context, s store.Stores, surfaceID string) []ConfigSectionEntry {
|
|
if s.Packages == nil {
|
|
return nil
|
|
}
|
|
pkgs, err := s.Packages.List(ctx)
|
|
if err != nil {
|
|
log.Printf("[pages] config sections: failed to list packages: %v", err)
|
|
return nil
|
|
}
|
|
|
|
var sections []ConfigSectionEntry
|
|
for _, pkg := range pkgs {
|
|
if !pkg.Enabled || pkg.Manifest == nil {
|
|
continue
|
|
}
|
|
csRaw, ok := pkg.Manifest["config_section"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
cs, ok := csRaw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Check if this section targets the requested surface
|
|
surfacesRaw, _ := cs["surfaces"].([]any)
|
|
if len(surfacesRaw) == 0 {
|
|
continue
|
|
}
|
|
found := false
|
|
for _, v := range surfacesRaw {
|
|
if s, ok := v.(string); ok && s == surfaceID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
|
|
label, _ := cs["label"].(string)
|
|
if label == "" {
|
|
label = pkg.Title
|
|
}
|
|
icon, _ := cs["icon"].(string)
|
|
component, _ := cs["component"].(string)
|
|
if component == "" {
|
|
component = "js/config.js"
|
|
}
|
|
category, _ := cs["category"].(string)
|
|
if category == "" {
|
|
category = "system"
|
|
}
|
|
|
|
sections = append(sections, ConfigSectionEntry{
|
|
PackageID: pkg.ID,
|
|
Label: label,
|
|
Icon: icon,
|
|
Component: component,
|
|
Category: category,
|
|
})
|
|
}
|
|
return sections
|
|
}
|