- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
511 lines
14 KiB
Go
511 lines
14 KiB
Go
package pages
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"switchboard-core/providers"
|
|
"switchboard-core/store"
|
|
)
|
|
|
|
// ── Types for template data ──────────────────
|
|
|
|
// ModelOption is a flat model for template dropdowns.
|
|
type ModelOption struct {
|
|
ProviderConfigID string `json:"provider_config_id"`
|
|
ProviderName string `json:"provider_name"`
|
|
ModelID string `json:"model_id"`
|
|
DisplayName string `json:"display_name"`
|
|
ModelType string `json:"model_type"`
|
|
}
|
|
|
|
// ProviderOption for template dropdowns.
|
|
type ProviderOption struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// ProviderDetail is an enriched provider for the providers table.
|
|
type ProviderDetail struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Provider string `json:"provider"`
|
|
Scope string `json:"scope"`
|
|
Endpoint string `json:"endpoint"`
|
|
ModelCount int `json:"model_count"`
|
|
}
|
|
|
|
// 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"`
|
|
Providers []ProviderOption `json:"providers"`
|
|
ProviderDetails []ProviderDetail `json:"provider_details,omitempty"`
|
|
ProviderTypes []ProviderTypeOption `json:"provider_types,omitempty"`
|
|
Models []ModelOption `json:"models"`
|
|
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"`
|
|
}
|
|
|
|
// NotesPageData provides context for the notes surface shell.
|
|
type NotesPageData struct {
|
|
NoteID string `json:"NoteID,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
|
|
}
|
|
|
|
// ProjectsPageData is what the projects surface receives.
|
|
type ProjectsPageData struct {
|
|
ProjectID string `json:"project_id"`
|
|
}
|
|
|
|
func (e *Engine) registerLoaders() {
|
|
e.RegisterLoader("admin", e.adminLoader)
|
|
e.RegisterLoader("chat", e.chatLoader)
|
|
e.RegisterLoader("notes", e.notesLoader)
|
|
e.RegisterLoader("settings", e.settingsLoader)
|
|
e.RegisterLoader("team-admin", e.teamAdminLoader)
|
|
e.RegisterLoader("projects", e.projectsLoader)
|
|
}
|
|
|
|
// ListDataProviders returns the keys of all registered data providers.
|
|
// 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),
|
|
}
|
|
|
|
// ── Providers (global scope) ─────────────
|
|
if s.Providers != nil {
|
|
configs, err := s.Providers.ListGlobal(ctx)
|
|
if err != nil {
|
|
log.Printf("[pages/admin] Failed to list providers: %v", err)
|
|
} else {
|
|
for _, pc := range configs {
|
|
name := pc.Name
|
|
if name == "" {
|
|
name = pc.Provider
|
|
}
|
|
data.Providers = append(data.Providers, ProviderOption{
|
|
ID: pc.ID,
|
|
Name: name,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Teams ────────────────────────────────
|
|
if s.Teams != nil {
|
|
teams, err := s.Teams.List(ctx)
|
|
if err != nil {
|
|
log.Printf("[pages/admin] Failed to list teams: %v", err)
|
|
} else {
|
|
for _, t := range teams {
|
|
data.Teams = append(data.Teams, TeamOption{
|
|
ID: t.ID,
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
MemberCount: t.MemberCount,
|
|
IsActive: t.IsActive,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Full model catalog with model_type ───
|
|
// This is what fixes bug #1: all models from all providers,
|
|
// including embedding models, with their model_type intact.
|
|
if s.Catalog != nil {
|
|
entries, err := s.Catalog.ListAllGlobal(ctx)
|
|
if err != nil {
|
|
log.Printf("[pages/admin] Failed to list catalog: %v", err)
|
|
} else {
|
|
provNames := make(map[string]string, len(data.Providers))
|
|
for _, p := range data.Providers {
|
|
provNames[p.ID] = p.Name
|
|
}
|
|
for _, entry := range entries {
|
|
mt := entry.ModelType
|
|
if mt == "" {
|
|
mt = "chat"
|
|
}
|
|
dn := entry.DisplayName
|
|
if dn == "" {
|
|
dn = entry.ModelID
|
|
}
|
|
data.Models = append(data.Models, ModelOption{
|
|
ProviderConfigID: entry.ProviderConfigID,
|
|
ProviderName: provNames[entry.ProviderConfigID],
|
|
ModelID: entry.ModelID,
|
|
DisplayName: dn,
|
|
ModelType: mt,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Section-specific data ────────────────
|
|
switch section {
|
|
case "roles":
|
|
data.Roles = e.loadRolesConfig(ctx, s)
|
|
case "routing":
|
|
if s.RoutingPolicies != nil {
|
|
policies, err := s.RoutingPolicies.ListAll(ctx)
|
|
if err == nil {
|
|
data.Policies = policies
|
|
}
|
|
}
|
|
case "providers":
|
|
data.ProviderTypes = loadProviderTypes()
|
|
data.ProviderDetails = e.loadProviderDetails(ctx, s, data.Providers, data.Models)
|
|
case "users":
|
|
data.Users = e.loadUsers(ctx, s)
|
|
}
|
|
|
|
// v0.38.3: Discover extension config sections for admin surface
|
|
data.ConfigSections = configSectionsForSurface(ctx, s, "admin")
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// loadRolesConfig reads current role assignments.
|
|
func (e *Engine) loadRolesConfig(ctx context.Context, s store.Stores) []RoleConfig {
|
|
roleNames := []string{"utility", "embedding"}
|
|
roles := make([]RoleConfig, 0, len(roleNames))
|
|
|
|
if s.GlobalConfig == nil {
|
|
for _, name := range roleNames {
|
|
roles = append(roles, RoleConfig{Name: name})
|
|
}
|
|
return roles
|
|
}
|
|
|
|
raw, err := s.GlobalConfig.Get(ctx, "model_roles")
|
|
if err != nil || raw == nil {
|
|
for _, name := range roleNames {
|
|
roles = append(roles, RoleConfig{Name: name})
|
|
}
|
|
return roles
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ── Chat loader ──────────────────────────────
|
|
|
|
func (e *Engine) chatLoader(c *gin.Context, s store.Stores) (any, error) {
|
|
chatID := c.Param("chatID")
|
|
return &ChatPageData{ChatID: chatID}, nil
|
|
}
|
|
|
|
// ── 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 {
|
|
types := providers.ListTypes()
|
|
out := make([]ProviderTypeOption, 0, len(types))
|
|
for _, t := range types {
|
|
out = append(out, ProviderTypeOption{ID: t.ID, Name: t.Name})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// loadProviderDetails enriches providers with model counts.
|
|
func (e *Engine) loadProviderDetails(ctx context.Context, s store.Stores, provs []ProviderOption, models []ModelOption) []ProviderDetail {
|
|
// Count models per provider
|
|
counts := make(map[string]int)
|
|
for _, m := range models {
|
|
counts[m.ProviderConfigID]++
|
|
}
|
|
|
|
// Get full provider configs for endpoint/scope
|
|
if s.Providers == nil {
|
|
return nil
|
|
}
|
|
configs, err := s.Providers.ListGlobal(ctx)
|
|
if err != nil {
|
|
log.Printf("[pages/admin] Failed to list provider details: %v", err)
|
|
return nil
|
|
}
|
|
|
|
out := make([]ProviderDetail, 0, len(configs))
|
|
for _, pc := range configs {
|
|
name := pc.Name
|
|
if name == "" {
|
|
name = pc.Provider
|
|
}
|
|
out = append(out, ProviderDetail{
|
|
ID: pc.ID,
|
|
Name: name,
|
|
Provider: pc.Provider,
|
|
Scope: pc.Scope,
|
|
Endpoint: pc.Endpoint,
|
|
ModelCount: counts[pc.ID],
|
|
})
|
|
}
|
|
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
|
|
}
|
|
|
|
// ── Notes loader ─────────────────────────────
|
|
|
|
func (e *Engine) notesLoader(c *gin.Context, s store.Stores) (any, error) {
|
|
noteID := c.Param("noteId")
|
|
return &NotesPageData{NoteID: noteID}, nil
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
func (e *Engine) projectsLoader(c *gin.Context, s store.Stores) (any, error) {
|
|
projectID := c.Param("id")
|
|
return &ProjectsPageData{ProjectID: projectID}, 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
|
|
}
|