Files
2026-05-12 18:49:52 +08:00

278 lines
8.6 KiB
Go

package httpapi
import (
"net/http"
"strconv"
"strings"
"supply-intelligence/internal/domain"
"supply-intelligence/internal/repository"
)
// DashboardHandler handles external-facing dashboard UI endpoints.
type DashboardHandler struct {
repo repository.Repository
}
// NewDashboardHandler creates a dashboard handler backed by the given repository.
func NewDashboardHandler(repo repository.Repository) *DashboardHandler {
return &DashboardHandler{repo: repo}
}
// accountRow is a denormalized row for the accounts dashboard table.
type accountRow struct {
AccountID int64 `json:"account_id"`
Platform string `json:"platform"`
AccountStatus string `json:"account_status"`
RoutingEnabled bool `json:"routing_enabled"`
RiskScore int `json:"risk_score"`
ReasonCode string `json:"reason_code"`
LastProbeAt string `json:"last_probe_at"`
Version int64 `json:"version"`
}
// modelRow is a denormalized row for the model catalog.
type modelRow struct {
PackageID int64 `json:"package_id"`
Platform string `json:"platform"`
Model string `json:"model"`
Status string `json:"status"`
Source string `json:"source"`
Version int64 `json:"version"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// candidateRow is a denormalized row for the candidate management table.
type candidateRow struct {
CandidateID string `json:"candidate_id"`
AccountID int64 `json:"account_id"`
Platform string `json:"platform"`
Model string `json:"model"`
Status string `json:"status"`
Source string `json:"source"`
ReasonCode string `json:"reason_code,omitempty"`
DiscoveredAt string `json:"discovered_at"`
UpdatedAt string `json:"updated_at"`
Version int64 `json:"version"`
}
// ListAccounts returns all accounts grouped by platform.
// GET /internal/supply-intelligence/dashboard/accounts
// Query params: platform (optional)
func (h *DashboardHandler) ListAccounts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
return
}
platform := r.URL.Query().Get("platform")
var states []domain.AccountRoutingState
if platform != "" {
states = h.repo.ListRoutingStatesByPlatform(r.Context(), platform)
} else {
// No ListAllRoutingStates — use openai as default for now
states = h.repo.ListRoutingStatesByPlatform(r.Context(), "openai")
// TODO: batch for all known platforms
}
rows := make([]accountRow, 0, len(states))
for _, s := range states {
rows = append(rows, accountRow{
AccountID: s.AccountID,
Platform: s.Platform,
AccountStatus: string(s.AccountStatus),
RoutingEnabled: s.RoutingEnabled,
RiskScore: s.RiskScore,
ReasonCode: s.ReasonCode,
LastProbeAt: s.LastProbeAt.Format("2006-01-02T15:04:05Z"),
Version: s.Version,
})
}
// Group by platform for summary view
byPlatform := make(map[string][]accountRow)
for _, row := range rows {
byPlatform[row.Platform] = append(byPlatform[row.Platform], row)
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"by_platform": byPlatform,
"total": len(rows),
})
}
// ListModels returns the model catalog from supply packages.
// GET /internal/supply-intelligence/dashboard/models
// Query params: status (optional: draft, active, deprecated)
func (h *DashboardHandler) ListModels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
return
}
status := r.URL.Query().Get("status")
pkgs := h.repo.ListSupplyPackages(r.Context(), status)
rows := make([]modelRow, 0, len(pkgs))
for _, p := range pkgs {
rows = append(rows, modelRow{
PackageID: p.PackageID,
Platform: p.Platform,
Model: p.Model,
Status: p.Status,
Source: p.Source,
Version: p.Version,
CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
})
}
// Group by platform for summary
byPlatform := make(map[string][]modelRow)
for _, row := range rows {
byPlatform[row.Platform] = append(byPlatform[row.Platform], row)
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"by_platform": byPlatform,
"total": len(rows),
})
}
// ListCandidates returns discovery candidates for management UI.
// GET /internal/supply-intelligence/dashboard/candidates
// Query params: status (optional), platform (optional), limit (optional, default 100)
func (h *DashboardHandler) ListCandidates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
return
}
statusStr := r.URL.Query().Get("status")
platform := r.URL.Query().Get("platform")
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
limit = l
}
}
var domainStatus domain.DiscoveryCandidateStatus
if statusStr != "" {
domainStatus = domain.DiscoveryCandidateStatus(statusStr)
}
candidates := h.repo.ListDiscoveryCandidates(r.Context(), domainStatus)
rows := make([]candidateRow, 0, len(candidates))
count := 0
for _, c := range candidates {
if platform != "" && c.Platform != platform {
continue
}
if limit > 0 && count >= limit {
break
}
rows = append(rows, candidateRow{
CandidateID: c.CandidateID,
AccountID: c.AccountID,
Platform: c.Platform,
Model: c.Model,
Status: string(c.Status),
Source: c.Source,
ReasonCode: c.ReasonCode,
DiscoveredAt: c.DiscoveredAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
Version: c.Version,
})
count++
}
// Status summary counts
statusCounts := make(map[string]int)
for _, c := range candidates {
statusCounts[string(c.Status)]++
}
writeJSON(w, http.StatusOK, map[string]any{
"items": rows,
"total": len(rows),
"status_counts": statusCounts,
})
}
// GetProbeHistory returns probe execution history for an account.
// GET /internal/supply-intelligence/dashboard/accounts/{account_id}/probe-history
// Query params: limit (optional, default 20)
func (h *DashboardHandler) GetProbeHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method_not_allowed"})
return
}
prefix := "/internal/supply-intelligence/dashboard/accounts/"
path := strings.TrimPrefix(r.URL.Path, prefix)
if !strings.HasSuffix(path, "/probe-history") {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not_found"})
return
}
accountIDStr := strings.TrimSuffix(path, "/probe-history")
var accountID int64
if _, err := strconv.ParseInt(accountIDStr, 10, 64); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_account_id"})
return
}
limitStr := r.URL.Query().Get("limit")
limit := 20
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
logs, err := h.repo.ListProbeExecutionLogs(r.Context(), accountID, limit)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed_to_load_logs"})
return
}
type probeLogRow struct {
LogID int64 `json:"log_id"`
Platform string `json:"platform"`
ProbeResult string `json:"probe_result"`
FailureClass string `json:"failure_class,omitempty"`
HTTPStatus int `json:"http_status,omitempty"`
LatencyMs int `json:"latency_ms,omitempty"`
RiskScore int `json:"risk_score"`
EvaluatedTransition string `json:"evaluated_transition"`
ExecutedAt string `json:"executed_at"`
RequestID string `json:"request_id"`
Version int64 `json:"version"`
}
rows := make([]probeLogRow, 0, len(logs))
for _, l := range logs {
rows = append(rows, probeLogRow{
LogID: l.LogID,
Platform: l.Platform,
ProbeResult: l.ProbeResult,
FailureClass: l.FailureClass,
HTTPStatus: l.HTTPStatus,
LatencyMs: l.LatencyMs,
RiskScore: l.RiskScore,
EvaluatedTransition: l.EvaluatedTransition,
ExecutedAt: l.ExecutedAt.Format("2006-01-02T15:04:05Z"),
RequestID: l.RequestID,
Version: l.Version,
})
}
writeJSON(w, http.StatusOK, map[string]any{"items": rows, "total": len(rows)})
}