278 lines
8.6 KiB
Go
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)})
|
||
|
|
}
|
||
|
|
|