Files
rogee b6d0af1a56
management-images / build-and-publish (push) Successful in 9m15s
docs: add one-click management deployment and image workflow
2026-09-16 17:57:05 +08:00

574 lines
21 KiB
Go

package management
import (
"encoding/base64"
"encoding/json"
"errors"
"sort"
"strconv"
"strings"
"time"
"database/sql"
"github.com/gofiber/fiber/v3"
)
type statsQuery struct {
From *time.Time
To *time.Time
Mode string
ProviderID string
TrunkID string
CellID string
EgressPoolID string
}
type metricAccumulator struct {
Attempts, StartedConfirmed, StartedUncertain, NotStarted, Rejected, Answered, TerminalNotAnswered, PendingNotAnswered, ActiveAnswered, AIFailed int
TalkSeconds []float64
WaitSeconds []float64
FailureReasons map[string]int
Starts []time.Time
Answers []time.Time
}
func parseStatsQuery(c fiber.Ctx, serverMode string) (statsQuery, *AppError) {
mode := strings.ToLower(strings.TrimSpace(c.Query("mode")))
if mode == "" {
mode = serverMode
}
if mode == "all" {
mode = "mixed"
}
if mode != "mock" && mode != "real" && mode != "mixed" {
return statsQuery{}, newAppError(422, "INVALID_MODE", "mode must be mock, mixed, or real", nil)
}
to := time.Now().UTC()
from := to.Add(-24 * time.Hour)
var err error
if raw := c.Query("from"); raw != "" {
from, err = time.Parse(time.RFC3339, raw)
if err != nil {
return statsQuery{}, newAppError(422, "INVALID_FROM", "from must be RFC3339 UTC", nil)
}
}
if raw := c.Query("to"); raw != "" {
to, err = time.Parse(time.RFC3339, raw)
if err != nil {
return statsQuery{}, newAppError(422, "INVALID_TO", "to must be RFC3339 UTC", nil)
}
}
from = from.UTC()
to = to.UTC()
if !to.After(from) {
return statsQuery{}, newAppError(422, "INVALID_RANGE", "to must be after from", nil)
}
if to.Sub(from) > 31*24*time.Hour {
return statsQuery{}, newAppError(422, "RANGE_TOO_LARGE", "statistics range cannot exceed 31 days", nil)
}
return statsQuery{From: &from, To: &to, Mode: mode, ProviderID: c.Query("provider_id"), TrunkID: c.Query("trunk_id"), CellID: c.Query("cell_id"), EgressPoolID: c.Query("egress_pool_id")}, nil
}
func (s *Server) outboundSummary(c fiber.Ctx) error {
p, authErr := s.authorize(c, "admin", "sip.statistics.read", "", "")
if authErr != nil {
return s.fail(c, authErr)
}
if err := s.checkFilterResources(c, p); err != nil {
return s.fail(c, err)
}
q, appErr := parseStatsQuery(c, s.cfg.PublicMode())
if appErr != nil {
return s.fail(c, appErr)
}
ctx, cancel := s.context(c)
defer cancel()
attempts, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode)})
if err != nil {
return s.fail(c, asAppError(err))
}
timeline, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode), Timeline: true})
if err != nil {
return s.fail(c, asAppError(err))
}
now := time.Now().UTC()
current, err := s.store.ListAttempts(ctx, AttemptFilter{ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode), Timeline: true})
if err != nil {
return s.fail(c, asAppError(err))
}
cpsFrom := now.Truncate(time.Second).Add(-time.Second)
cpsTo := cpsFrom.Add(time.Second)
recent, err := s.store.ListAttempts(ctx, AttemptFilter{From: &cpsFrom, To: &cpsTo, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode)})
if err != nil {
return s.fail(c, asAppError(err))
}
attempts = filterAttemptResources(p, attempts)
timeline = filterAttemptResources(p, timeline)
current = filterAttemptResources(p, current)
recent = filterAttemptResources(p, recent)
current = activeAttempts(current, now)
result := summaryResult(q, attempts, timeline, current, recent, now, cpsFrom, cpsTo)
return c.JSON(result)
}
func filterAttemptResources(p principal, attempts []Attempt) []Attempt {
if len(p.Resources) == 0 {
return attempts
}
out := attempts[:0]
for _, attempt := range attempts {
if resourceAllowed(p.TokenSpec, "provider", attempt.ProviderID) && resourceAllowed(p.TokenSpec, "trunk", attempt.TrunkID) && resourceAllowed(p.TokenSpec, "cell", attempt.CellID) && resourceAllowed(p.TokenSpec, "egress_pool", attempt.EgressPoolID) {
out = append(out, attempt)
}
}
return out
}
func activeAttempts(attempts []Attempt, asOf time.Time) []Attempt {
active := make([]Attempt, 0, len(attempts))
for _, attempt := range attempts {
if attempt.AnsweredAt != nil && (attempt.EndedAt == nil || attempt.EndedAt.After(asOf)) {
active = append(active, attempt)
}
}
return active
}
func modeFilter(mode string) string {
if mode == "mixed" {
return ""
}
return mode
}
func summaryResult(q statsQuery, attempts, timeline, current, recent []Attempt, sampledAt, cpsFrom, cpsTo time.Time) map[string]any {
m := accumulate(attempts, *q.To)
complete := m.StartedUncertain == 0 && validFacts(attempts)
missing := []string{}
if len(attempts) == 0 {
missing = append(missing, "attempt_facts")
}
if m.StartedUncertain > 0 {
missing = append(missing, "origin_confirmation")
}
if !validFacts(attempts) {
missing = append(missing, "fact_order")
}
dataAsOf := any(nil)
for _, a := range attempts {
if !a.UpdatedAt.IsZero() && (dataAsOf == nil || a.UpdatedAt.Format(time.RFC3339Nano) > dataAsOf.(string)) {
dataAsOf = utcString(a.UpdatedAt)
}
}
var answerRate any
if m.StartedConfirmed > 0 {
answerRate = float64(m.Answered) / float64(m.StartedConfirmed)
}
realtime := accumulate(current, sampledAt)
talkStats := durationStats(m.TalkSeconds)
metrics := map[string]any{"attempts_total": m.Attempts, "started_confirmed": m.StartedConfirmed, "started_uncertain": m.StartedUncertain, "not_started": m.NotStarted, "rejected": m.Rejected, "answered": m.Answered, "terminal_not_answered": m.TerminalNotAnswered, "pending_not_answered": m.PendingNotAnswered, "active_answered": realtime.ActiveAnswered, "ai_failed_after_answer": m.AIFailed, "answer_rate": answerRate, "talk_duration_seconds": talkStats, "acd_seconds": talkStats["avg"], "total_talk_seconds": talkStats["total"], "wait_duration_seconds": durationStats(m.WaitSeconds), "peak_concurrency": peakConcurrency(timeline, *q.From, *q.To), "peak_cps": peakCPS(m.Starts, *q.From, *q.To)}
return map[string]any{"mode": q.Mode, "from": utcString(*q.From), "to": utcString(*q.To), "timezone": "UTC", "definition_version": "sip-statistics.v1", "filters": map[string]string{"provider_id": q.ProviderID, "trunk_id": q.TrunkID, "cell_id": q.CellID, "egress_pool_id": q.EgressPoolID}, "complete": complete, "missing_sources": missing, "unresolved_count": m.StartedUncertain + m.PendingNotAnswered, "data_as_of": dataAsOf, "generated_at": utcString(time.Now()), "metrics": metrics, "realtime": map[string]any{"current_answered": realtime.ActiveAnswered, "current_cps": peakCPS(accumulate(recent, cpsTo).Starts, cpsFrom, cpsTo), "cps_bucket_start": utcString(cpsFrom), "cps_bucket_end": utcString(cpsTo), "sampled_at": utcString(sampledAt), "complete": validFacts(current) && validFacts(recent), "source": "attempt_facts"}, "failure_reasons": failureReasonList(m.FailureReasons), "semantics": map[string]string{"started_confirmed": "only attempts with a persisted confirmed origin fact", "answered": "answered_at is present and not after ended_at", "terminal_not_answered": "ended_at is present while answered_at is absent", "complete": "false when origin or time-order facts are uncertain"}}
}
func accumulate(attempts []Attempt, to time.Time) metricAccumulator {
m := metricAccumulator{FailureReasons: map[string]int{}}
for _, a := range attempts {
if a.OriginStatus == "rejected" {
m.Rejected++
continue
}
m.Attempts++
switch a.OriginStatus {
case "confirmed":
m.StartedConfirmed++
case "uncertain":
m.StartedUncertain++
case "not_started":
m.NotStarted++
}
if a.AttemptStartedAt != nil {
m.Starts = append(m.Starts, *a.AttemptStartedAt)
}
if a.AnsweredAt != nil {
m.Answers = append(m.Answers, *a.AnsweredAt)
m.Answered++
if a.EndedAt == nil || a.EndedAt.After(to) {
m.ActiveAnswered++
}
} else if a.EndedAt != nil {
m.TerminalNotAnswered++
reason := strings.ToLower(strings.TrimSpace(a.TerminationReason))
if reason == "" {
reason = "unknown"
}
m.FailureReasons[reason]++
} else if a.OriginStatus == "confirmed" || a.OriginStatus == "uncertain" {
m.PendingNotAnswered++
}
if a.AIStatus == "failed" && a.AnsweredAt != nil {
m.AIFailed++
}
if a.AnsweredAt != nil && a.EndedAt != nil && !a.EndedAt.Before(*a.AnsweredAt) {
m.TalkSeconds = append(m.TalkSeconds, a.EndedAt.Sub(*a.AnsweredAt).Seconds())
if a.AttemptStartedAt != nil && !a.AnsweredAt.Before(*a.AttemptStartedAt) {
m.WaitSeconds = append(m.WaitSeconds, a.AnsweredAt.Sub(*a.AttemptStartedAt).Seconds())
}
}
}
return m
}
func validFacts(attempts []Attempt) bool {
for _, a := range attempts {
if a.AnsweredAt != nil && a.AttemptStartedAt != nil && a.AnsweredAt.Before(*a.AttemptStartedAt) {
return false
}
if a.EndedAt != nil && a.AnsweredAt != nil && a.EndedAt.Before(*a.AnsweredAt) {
return false
}
}
return true
}
func durationStats(values []float64) map[string]any {
if len(values) == 0 {
return map[string]any{"count": 0, "total": 0, "p50": nil, "p95": nil, "avg": nil}
}
sort.Float64s(values)
sum := 0.0
for _, v := range values {
sum += v
}
return map[string]any{"count": len(values), "total": sum, "p50": percentile(values, .50), "p95": percentile(values, .95), "avg": sum / float64(len(values))}
}
func percentile(v []float64, p float64) float64 {
if len(v) == 0 {
return 0
}
i := int(float64(len(v)-1)*p + 0.5)
if i < 0 {
i = 0
}
if i >= len(v) {
i = len(v) - 1
}
return v[i]
}
func failureReasonList(values map[string]int) []map[string]any {
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]map[string]any, 0, len(keys))
for _, k := range keys {
out = append(out, map[string]any{"reason": k, "count": values[k]})
}
return out
}
func peakConcurrency(attempts []Attempt, from, to time.Time) int {
type event struct {
t time.Time
delta int
}
events := []event{}
for _, a := range attempts {
if a.AnsweredAt == nil {
continue
}
start := *a.AnsweredAt
if start.Before(from) {
start = from
}
if !start.Before(to) {
continue
}
end := to
if a.EndedAt != nil && a.EndedAt.Before(end) {
end = *a.EndedAt
}
if !end.After(start) {
continue
}
events = append(events, event{start, 1}, event{end, -1})
}
sort.Slice(events, func(i, j int) bool {
if events[i].t.Equal(events[j].t) {
return events[i].delta < events[j].delta
}
return events[i].t.Before(events[j].t)
})
current, max := 0, 0
for _, e := range events {
current += e.delta
if current > max {
max = current
}
}
return max
}
func peakCPS(starts []time.Time, from, to time.Time) int {
counts := map[string]int{}
for _, t := range starts {
if t.Before(from) || !t.Before(to) {
continue
}
key := t.UTC().Truncate(time.Second).Format(time.RFC3339)
counts[key]++
}
max := 0
for _, v := range counts {
if v > max {
max = v
}
}
return max
}
func (s *Server) outboundTimeseries(c fiber.Ctx) error {
p, authErr := s.authorize(c, "admin", "sip.statistics.read", "", "")
if authErr != nil {
return s.fail(c, authErr)
}
if err := s.checkFilterResources(c, p); err != nil {
return s.fail(c, err)
}
q, appErr := parseStatsQuery(c, s.cfg.PublicMode())
if appErr != nil {
return s.fail(c, appErr)
}
gran := strings.ToLower(c.Query("granularity"))
if gran == "" {
gran = "hour"
}
step := time.Hour
switch gran {
case "minute":
step = time.Minute
if q.To.Sub(*q.From) > 24*time.Hour {
return s.fail(c, newAppError(422, "GRANULARITY_TOO_FINE", "minute series cannot exceed 24 hours", nil))
}
case "hour":
step = time.Hour
case "day":
step = 24 * time.Hour
default:
return s.fail(c, newAppError(422, "INVALID_GRANULARITY", "granularity must be minute, hour, or day", nil))
}
ctx, cancel := s.context(c)
defer cancel()
attempts, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode)})
if err != nil {
return s.fail(c, asAppError(err))
}
timeline, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode), Timeline: true})
if err != nil {
return s.fail(c, asAppError(err))
}
attempts = filterAttemptResources(p, attempts)
timeline = filterAttemptResources(p, timeline)
buckets := make([]map[string]any, 0)
start := q.From.Truncate(step)
for bucket := start; bucket.Before(*q.To); bucket = bucket.Add(step) {
end := bucket.Add(step)
if end.After(*q.To) {
end = *q.To
}
windowFrom := bucket
if windowFrom.Before(*q.From) {
windowFrom = *q.From
}
var subset []Attempt
for _, a := range attempts {
if a.AttemptStartedAt != nil && !a.AttemptStartedAt.Before(windowFrom) && a.AttemptStartedAt.Before(end) {
subset = append(subset, a)
}
}
m := accumulate(subset, end)
buckets = append(buckets, map[string]any{"bucket_start": utcString(bucket), "bucket_end": utcString(end), "attempts_total": m.Attempts, "started_confirmed": m.StartedConfirmed, "started_uncertain": m.StartedUncertain, "answered": m.Answered, "terminal_not_answered": m.TerminalNotAnswered, "pending_not_answered": m.PendingNotAnswered, "peak_concurrency": peakConcurrency(timelineForBucket(timeline, windowFrom, end), windowFrom, end), "peak_cps": peakCPS(m.Starts, windowFrom, end)})
}
return c.JSON(map[string]any{"mode": q.Mode, "from": utcString(*q.From), "to": utcString(*q.To), "timezone": "UTC", "definition_version": "sip-statistics.v1", "filters": map[string]string{"provider_id": q.ProviderID, "trunk_id": q.TrunkID, "cell_id": q.CellID, "egress_pool_id": q.EgressPoolID}, "granularity": gran, "complete": validFacts(attempts) && countUncertain(attempts) == 0, "series": buckets})
}
func timelineForBucket(attempts []Attempt, from, to time.Time) []Attempt {
out := make([]Attempt, 0, len(attempts))
for _, attempt := range attempts {
if attempt.AnsweredAt != nil && attempt.AnsweredAt.Before(to) && (attempt.EndedAt == nil || attempt.EndedAt.After(from)) {
out = append(out, attempt)
}
}
return out
}
func countUncertain(v []Attempt) int {
n := 0
for _, a := range v {
if a.OriginStatus == "uncertain" {
n++
}
}
return n
}
func (s *Server) callAttempts(c fiber.Ctx) error {
p, authErr := s.authorize(c, "admin", "sip.calls.read", "", "")
if authErr != nil {
return s.fail(c, authErr)
}
if err := s.checkFilterResources(c, p); err != nil {
return s.fail(c, err)
}
q, appErr := parseStatsQuery(c, s.cfg.PublicMode())
if appErr != nil {
return s.fail(c, appErr)
}
limit := 50
if raw := c.Query("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 200 {
return s.fail(c, newAppError(422, "INVALID_LIMIT", "limit must be between 1 and 200", nil))
}
limit = n
}
ctx, cancel := s.context(c)
defer cancel()
attempts, err := s.store.ListAttempts(ctx, AttemptFilter{From: q.From, To: q.To, ProviderID: q.ProviderID, TrunkID: q.TrunkID, CellID: q.CellID, EgressPoolID: q.EgressPoolID, Mode: modeFilter(q.Mode), Limit: limit + 1})
if err != nil {
return s.fail(c, asAppError(err))
}
attempts = filterAttemptResources(p, attempts)
fingerprint := statsFingerprint(q)
cursor := c.Query("cursor")
if cursor != "" {
last, err := decodeCursor(cursor)
if err != nil || last.Filter != fingerprint {
return s.fail(c, newAppError(400, "INVALID_CURSOR", "cursor is invalid for the current filters", nil))
}
filtered := attempts[:0]
for _, a := range attempts {
current := cursorTime(a)
if current.After(last.Time) || (current.Equal(last.Time) && a.AttemptID > last.ID) {
filtered = append(filtered, a)
}
}
attempts = filtered
}
next := ""
if len(attempts) > limit {
last := attempts[limit-1]
next = encodeCursor(last, fingerprint)
attempts = attempts[:limit]
}
out := make([]map[string]any, 0, len(attempts))
for _, a := range attempts {
out = append(out, attemptView(a, false))
}
return c.JSON(map[string]any{"mode": q.Mode, "from": utcString(*q.From), "to": utcString(*q.To), "complete": validFacts(attempts) && countUncertain(attempts) == 0, "attempts": out, "next_cursor": next})
}
type cursorValue struct {
Time time.Time
ID, Filter string
}
func cursorTime(a Attempt) time.Time {
if a.AttemptStartedAt != nil {
return *a.AttemptStartedAt
}
return a.UpdatedAt
}
func statsFingerprint(q statsQuery) string {
b, _ := json.Marshal(map[string]any{"from": q.From, "to": q.To, "mode": q.Mode, "provider_id": q.ProviderID, "trunk_id": q.TrunkID, "cell_id": q.CellID, "egress_pool_id": q.EgressPoolID})
return hashBytes(b)
}
func encodeCursor(a Attempt, filter string) string {
b, _ := json.Marshal(map[string]string{"time": utcString(cursorTime(a)), "id": a.AttemptID, "filter": filter})
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeCursor(raw string) (cursorValue, error) {
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return cursorValue{}, err
}
var v struct {
Time string `json:"time"`
ID string `json:"id"`
Filter string `json:"filter"`
}
if err := json.Unmarshal(b, &v); err != nil {
return cursorValue{}, err
}
t, err := time.Parse(time.RFC3339Nano, v.Time)
return cursorValue{Time: t, ID: v.ID, Filter: v.Filter}, err
}
func attemptView(a Attempt, detail bool) map[string]any {
out := map[string]any{"attempt_id": a.AttemptID, "execution_id": a.ExecutionID, "call_id": a.CallID, "tenant_key": a.TenantKey, "provider_id": a.ProviderID, "trunk_id": a.TrunkID, "cell_id": a.CellID, "egress_pool_id": a.EgressPoolID, "config_revision": a.ConfigRevision, "mode": a.Mode, "dialed_number": maskNumber(a.DialedNumber), "origin_status": a.OriginStatus, "termination_reason": a.TerminationReason, "sip_code": a.SIPCode, "q850": a.Q850, "ai_status": a.AIStatus, "source": a.Source, "fact_version": a.FactVersion, "updated_at": utcString(a.UpdatedAt)}
if a.AttemptStartedAt != nil {
out["attempt_started_at"] = utcString(*a.AttemptStartedAt)
} else {
out["attempt_started_at"] = nil
}
if a.AnsweredAt != nil {
out["answered_at"] = utcString(*a.AnsweredAt)
} else {
out["answered_at"] = nil
}
if a.EndedAt != nil {
out["ended_at"] = utcString(*a.EndedAt)
} else {
out["ended_at"] = nil
}
if !detail {
delete(out, "execution_id")
delete(out, "call_id")
}
return out
}
func (s *Server) callAttempt(c fiber.Ctx) error {
id := c.Params("attempt_id")
p, authErr := s.authorize(c, "admin", "sip.calls.read", "", "")
if authErr != nil {
return s.fail(c, authErr)
}
_ = p
ctx, cancel := s.context(c)
defer cancel()
a, err := s.store.GetAttempt(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
return s.fail(c, newAppError(404, "ATTEMPT_NOT_FOUND", "call attempt does not exist", nil))
}
if err != nil {
return s.fail(c, asAppError(err))
}
return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "attempt": attemptView(a, true)})
}
func (s *Server) audit(c fiber.Ctx) error {
p, authErr := s.authorize(c, "admin", "sip.audit.read", "", "")
if authErr != nil {
return s.fail(c, authErr)
}
if err := s.checkFilterResources(c, p); err != nil {
return s.fail(c, err)
}
limit := 50
if raw := c.Query("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 200 {
return s.fail(c, newAppError(422, "INVALID_LIMIT", "limit must be between 1 and 200", nil))
}
limit = n
}
ctx, cancel := s.context(c)
defer cancel()
rows, err := s.store.ListAudit(ctx, c.Query("resource_id"), c.Query("request_id"), c.Query("actor"), limit)
if err != nil {
return s.fail(c, asAppError(err))
}
visible := rows[:0]
for _, row := range rows {
if resourceAllowed(p.TokenSpec, row.ResourceType, row.ResourceID) {
visible = append(visible, row)
}
}
return c.JSON(map[string]any{"mode": s.cfg.PublicMode(), "audit": visible})
}