Add admin UI
Some checks failed
CI / Test (pull_request) Failing after 1m33s
CI / Lint (pull_request) Failing after 13s
CI / Build (pull_request) Has been skipped
CI / Security Scan (pull_request) Failing after 4m47s
CI / Build and Push Docker Image (pull_request) Has been skipped

This commit is contained in:
2026-03-05 23:08:34 +00:00
parent 667217e66b
commit 7025ec746c
31 changed files with 5905 additions and 3 deletions

252
internal/admin/handlers.go Normal file
View File

@@ -0,0 +1,252 @@
package admin
import (
"fmt"
"net/http"
"runtime"
"strings"
"time"
"github.com/ajac-zero/latticelm/internal/config"
)
// SystemInfoResponse contains system information.
type SystemInfoResponse struct {
Version string `json:"version"`
BuildTime string `json:"build_time"`
GitCommit string `json:"git_commit"`
GoVersion string `json:"go_version"`
Platform string `json:"platform"`
Uptime string `json:"uptime"`
}
// HealthCheckResponse contains health check results.
type HealthCheckResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
Checks map[string]HealthCheck `json:"checks"`
}
// HealthCheck represents a single health check.
type HealthCheck struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
// ConfigResponse contains the sanitized configuration.
type ConfigResponse struct {
Server config.ServerConfig `json:"server"`
Providers map[string]SanitizedProvider `json:"providers"`
Models []config.ModelEntry `json:"models"`
Auth SanitizedAuthConfig `json:"auth"`
Conversations config.ConversationConfig `json:"conversations"`
Logging config.LoggingConfig `json:"logging"`
RateLimit config.RateLimitConfig `json:"rate_limit"`
Observability config.ObservabilityConfig `json:"observability"`
}
// SanitizedProvider is a provider entry with secrets masked.
type SanitizedProvider struct {
Type string `json:"type"`
APIKey string `json:"api_key"`
Endpoint string `json:"endpoint,omitempty"`
APIVersion string `json:"api_version,omitempty"`
Project string `json:"project,omitempty"`
Location string `json:"location,omitempty"`
}
// SanitizedAuthConfig is auth config with secrets masked.
type SanitizedAuthConfig struct {
Enabled bool `json:"enabled"`
Issuer string `json:"issuer"`
Audience string `json:"audience"`
}
// ProviderInfo contains provider information.
type ProviderInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Models []string `json:"models"`
Status string `json:"status"`
}
// handleSystemInfo returns system information.
func (s *AdminServer) handleSystemInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed")
return
}
uptime := time.Since(s.startTime)
info := SystemInfoResponse{
Version: s.buildInfo.Version,
BuildTime: s.buildInfo.BuildTime,
GitCommit: s.buildInfo.GitCommit,
GoVersion: s.buildInfo.GoVersion,
Platform: runtime.GOOS + "/" + runtime.GOARCH,
Uptime: formatDuration(uptime),
}
writeSuccess(w, info)
}
// handleSystemHealth returns health check results.
func (s *AdminServer) handleSystemHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed")
return
}
checks := make(map[string]HealthCheck)
overallStatus := "healthy"
// Server check
checks["server"] = HealthCheck{
Status: "healthy",
Message: "Server is running",
}
// Provider check
models := s.registry.Models()
if len(models) > 0 {
checks["providers"] = HealthCheck{
Status: "healthy",
Message: "Providers configured",
}
} else {
checks["providers"] = HealthCheck{
Status: "unhealthy",
Message: "No providers configured",
}
overallStatus = "unhealthy"
}
// Conversation store check
checks["conversation_store"] = HealthCheck{
Status: "healthy",
Message: "Store accessible",
}
response := HealthCheckResponse{
Status: overallStatus,
Timestamp: time.Now().Format(time.RFC3339),
Checks: checks,
}
writeSuccess(w, response)
}
// handleConfig returns the sanitized configuration.
func (s *AdminServer) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed")
return
}
// Sanitize providers
sanitizedProviders := make(map[string]SanitizedProvider)
for name, provider := range s.cfg.Providers {
sanitizedProviders[name] = SanitizedProvider{
Type: provider.Type,
APIKey: maskSecret(provider.APIKey),
Endpoint: provider.Endpoint,
APIVersion: provider.APIVersion,
Project: provider.Project,
Location: provider.Location,
}
}
// Sanitize DSN in conversations config
convConfig := s.cfg.Conversations
if convConfig.DSN != "" {
convConfig.DSN = maskSecret(convConfig.DSN)
}
response := ConfigResponse{
Server: s.cfg.Server,
Providers: sanitizedProviders,
Models: s.cfg.Models,
Auth: SanitizedAuthConfig{
Enabled: s.cfg.Auth.Enabled,
Issuer: s.cfg.Auth.Issuer,
Audience: s.cfg.Auth.Audience,
},
Conversations: convConfig,
Logging: s.cfg.Logging,
RateLimit: s.cfg.RateLimit,
Observability: s.cfg.Observability,
}
writeSuccess(w, response)
}
// handleProviders returns the list of configured providers.
func (s *AdminServer) handleProviders(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed")
return
}
// Build provider info map
providerModels := make(map[string][]string)
models := s.registry.Models()
for _, m := range models {
providerModels[m.Provider] = append(providerModels[m.Provider], m.Model)
}
// Build provider list
var providers []ProviderInfo
for name, entry := range s.cfg.Providers {
providers = append(providers, ProviderInfo{
Name: name,
Type: entry.Type,
Models: providerModels[name],
Status: "active",
})
}
writeSuccess(w, providers)
}
// maskSecret masks a secret string for display.
func maskSecret(secret string) string {
if secret == "" {
return ""
}
if len(secret) <= 8 {
return "********"
}
// Show first 4 and last 4 characters
return secret[:4] + "..." + secret[len(secret)-4:]
}
// formatDuration formats a duration in a human-readable format.
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
d -= m * time.Minute
s := d / time.Second
var parts []string
if h > 0 {
parts = append(parts, formatPart(int(h), "hour"))
}
if m > 0 {
parts = append(parts, formatPart(int(m), "minute"))
}
if s > 0 || len(parts) == 0 {
parts = append(parts, formatPart(int(s), "second"))
}
return strings.Join(parts, " ")
}
func formatPart(value int, unit string) string {
if value == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", value, unit)
}

View File

@@ -0,0 +1,45 @@
package admin
import (
"encoding/json"
"net/http"
)
// APIResponse is the standard JSON response wrapper.
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
}
// APIError represents an error response.
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
// writeSuccess writes a successful JSON response.
func writeSuccess(w http.ResponseWriter, data interface{}) {
writeJSON(w, http.StatusOK, APIResponse{
Success: true,
Data: data,
})
}
// writeError writes an error JSON response.
func writeError(w http.ResponseWriter, statusCode int, code, message string) {
writeJSON(w, statusCode, APIResponse{
Success: false,
Error: &APIError{
Code: code,
Message: message,
},
})
}

17
internal/admin/routes.go Normal file
View File

@@ -0,0 +1,17 @@
package admin
import (
"net/http"
)
// RegisterRoutes wires the admin HTTP handlers onto the provided mux.
func (s *AdminServer) RegisterRoutes(mux *http.ServeMux) {
// API endpoints
mux.HandleFunc("/admin/api/v1/system/info", s.handleSystemInfo)
mux.HandleFunc("/admin/api/v1/system/health", s.handleSystemHealth)
mux.HandleFunc("/admin/api/v1/config", s.handleConfig)
mux.HandleFunc("/admin/api/v1/providers", s.handleProviders)
// Serve frontend SPA
mux.Handle("/admin/", http.StripPrefix("/admin", s.serveSPA()))
}

59
internal/admin/server.go Normal file
View File

@@ -0,0 +1,59 @@
package admin
import (
"log/slog"
"runtime"
"time"
"github.com/ajac-zero/latticelm/internal/config"
"github.com/ajac-zero/latticelm/internal/conversation"
"github.com/ajac-zero/latticelm/internal/providers"
)
// ProviderRegistry is an interface for provider registries.
type ProviderRegistry interface {
Get(name string) (providers.Provider, bool)
Models() []struct{ Provider, Model string }
ResolveModelID(model string) string
Default(model string) (providers.Provider, error)
}
// BuildInfo contains build-time information.
type BuildInfo struct {
Version string
BuildTime string
GitCommit string
GoVersion string
}
// AdminServer hosts the admin API and UI.
type AdminServer struct {
registry ProviderRegistry
convStore conversation.Store
cfg *config.Config
logger *slog.Logger
startTime time.Time
buildInfo BuildInfo
}
// New creates an AdminServer instance.
func New(registry ProviderRegistry, convStore conversation.Store, cfg *config.Config, logger *slog.Logger, buildInfo BuildInfo) *AdminServer {
return &AdminServer{
registry: registry,
convStore: convStore,
cfg: cfg,
logger: logger,
startTime: time.Now(),
buildInfo: buildInfo,
}
}
// GetBuildInfo returns a default BuildInfo if none provided.
func DefaultBuildInfo() BuildInfo {
return BuildInfo{
Version: "dev",
BuildTime: time.Now().Format(time.RFC3339),
GitCommit: "unknown",
GoVersion: runtime.Version(),
}
}

62
internal/admin/static.go Normal file
View File

@@ -0,0 +1,62 @@
package admin
import (
"embed"
"io"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:dist
var frontendAssets embed.FS
// serveSPA serves the frontend SPA with fallback to index.html for client-side routing.
func (s *AdminServer) serveSPA() http.Handler {
// Get the dist subdirectory from embedded files
distFS, err := fs.Sub(frontendAssets, "dist")
if err != nil {
s.logger.Error("failed to access frontend assets", "error", err)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Admin UI not available", http.StatusNotFound)
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Path comes in without /admin prefix due to StripPrefix
urlPath := r.URL.Path
if urlPath == "" || urlPath == "/" {
urlPath = "index.html"
} else {
// Remove leading slash
urlPath = strings.TrimPrefix(urlPath, "/")
}
// Clean the path
cleanPath := path.Clean(urlPath)
// Try to open the file
file, err := distFS.Open(cleanPath)
if err != nil {
// File not found, serve index.html for SPA routing
cleanPath = "index.html"
file, err = distFS.Open(cleanPath)
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
}
defer file.Close()
// Get file info for content type detection
info, err := file.Stat()
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Serve the file
http.ServeContent(w, r, cleanPath, info.ModTime(), file.(io.ReadSeeker))
})
}

View File

@@ -17,6 +17,7 @@ type Config struct {
Logging LoggingConfig `yaml:"logging"`
RateLimit RateLimitConfig `yaml:"rate_limit"`
Observability ObservabilityConfig `yaml:"observability"`
Admin AdminConfig `yaml:"admin"`
}
// ConversationConfig controls conversation storage.
@@ -93,6 +94,11 @@ type AuthConfig struct {
Audience string `yaml:"audience"`
}
// AdminConfig controls the admin UI.
type AdminConfig struct {
Enabled bool `yaml:"enabled"`
}
// ServerConfig controls HTTP server values.
type ServerConfig struct {
Address string `yaml:"address"`