Add admin UI
Some checks failed
Some checks failed
This commit is contained in:
252
internal/admin/handlers.go
Normal file
252
internal/admin/handlers.go
Normal 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)
|
||||
}
|
||||
45
internal/admin/response.go
Normal file
45
internal/admin/response.go
Normal 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
17
internal/admin/routes.go
Normal 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
59
internal/admin/server.go
Normal 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
62
internal/admin/static.go
Normal 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))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user