Files
oikos/internal/httpapi/metrics.go
dtoro fb39a48bef refactor: split phase3.go + extract MCP tool registry (R4)
internal/httpapi/phase3.go (2627 lines, 12+ resource domains) split into
15 per-resource files:
- actuator.go: SSH execution machinery (initSSH, sshExec, resolveRunTarget,
  executeApprovedAction, jsonErr, gatewayPreflightPassed, resolveTemplate)
- checks.go, classifications.go, executions.go, approvals.go, patterns.go,
  skills.go, approval_rules.go, autonomy.go, risk_classes.go,
  relationships.go, entity_types.go, metrics.go, agent_activity.go,
  helpers.go — one file per resource domain, each with its own imports.

internal/mcp/server.go: newServer (708 lines, 33 inline tool registrations)
refactored to a registry pattern:
- internal/mcp/tools.go (new): toolReg struct + allTools() returning all 33
  tool definitions. Handler logic moved verbatim — no changes to tool names,
  descriptions, schemas, or behavior.
- server.go: newServer is now 9 lines (iterate registry, AddTool each).
  -699 lines.

No function logic, names, or signatures changed. go vet, build, and all
tests pass (httpapi, mcp, db, policy).
2026-07-17 22:41:40 +02:00

181 lines
4.4 KiB
Go

package httpapi
import (
"context"
"fmt"
"time"
"github.com/dtoro/oikos/internal/domain"
"github.com/dtoro/oikos/internal/httpapi/gen"
"github.com/jackc/pgx/v5/pgtype"
)
// ─── Metrics ───────────────────────────────────────────────────────────
func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestObject) (gen.QueryMetricsResponseObject, error) {
if req.Params.EntityId == nil || *req.Params.EntityId == "" {
return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput)
}
entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId)
if err != nil {
return nil, err
}
from := time.Now().Add(-24 * time.Hour)
if req.Params.From != nil {
from = *req.Params.From
}
to := time.Now()
if req.Params.To != nil {
to = *req.Params.To
}
var metricNames []string
if req.Params.Metric != nil && len(*req.Params.Metric) > 0 {
metricNames = *req.Params.Metric
} else {
// metric omitted: report every metric recorded for this entity in range.
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT metric FROM metric_samples
WHERE entity_id = $1 AND ts >= $2 AND ts <= $3
ORDER BY metric`, entityID, from, to)
if err != nil {
return nil, err
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return nil, err
}
metricNames = append(metricNames, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
}
items := []gen.MetricSeries{}
for _, metricName := range metricNames {
series := gen.MetricSeries{
EntityId: entityID.String(),
Metric: metricName,
Rollup: gen.MetricSeriesRollupRaw,
}
rows, err := s.pool.Query(ctx, `
SELECT ts, value
FROM metric_samples
WHERE entity_id = $1 AND metric = $2
AND ts >= $3 AND ts <= $4
ORDER BY ts ASC`,
entityID, metricName, from, to)
if err != nil {
return nil, err
}
samples := []struct {
Avg *float32 `json:"avg"`
Count *int `json:"count"`
Max *float32 `json:"max"`
Min *float32 `json:"min"`
Ts time.Time `json:"ts"`
Value *float32 `json:"value"`
}{}
for rows.Next() {
var ts time.Time
var val float64
if err := rows.Scan(&ts, &val); err != nil {
rows.Close()
return nil, err
}
f := float32(val)
samples = append(samples, struct {
Avg *float32 `json:"avg"`
Count *int `json:"count"`
Max *float32 `json:"max"`
Min *float32 `json:"min"`
Ts time.Time `json:"ts"`
Value *float32 `json:"value"`
}{Value: &f, Ts: ts})
}
rows.Close()
if rows.Err() != nil {
return nil, rows.Err()
}
series.Samples = samples
items = append(items, series)
}
if items == nil {
items = []gen.MetricSeries{}
}
return gen.QueryMetrics200JSONResponse{Items: items}, nil
}
func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject) (gen.GetTrendsResponseObject, error) {
entityID, err := s.resolveEntityID(ctx, req.EntityId)
if err != nil {
return nil, err
}
from := time.Now().Add(-7 * 24 * time.Hour)
if req.Params.From != nil {
from = *req.Params.From
}
rows, err := s.pool.Query(ctx, `
SELECT metric,
ROUND(avg(value)::numeric, 2) AS avg_val,
ROUND(stddev(value)::numeric, 2) AS std_val,
count(*) AS sample_count,
ROUND(regr_slope(value, EXTRACT(EPOCH FROM ts)::numeric)::numeric, 4) AS slope
FROM metric_samples
WHERE entity_id = $1 AND ts >= $2
GROUP BY metric
ORDER BY metric`, entityID, from)
if err != nil {
return nil, err
}
defer rows.Close()
items := []gen.Trend{}
for rows.Next() {
var t gen.Trend
var avgVal, stdVal, slopeNum pgtype.Numeric
var sampleCount int
if err := rows.Scan(&t.Metric, &avgVal, &stdVal, &sampleCount, &slopeNum); err != nil {
return nil, err
}
// Determine direction.
if slopeNum.Valid {
f, _ := slopeNum.Float64Value()
t.Slope = float32Ptr(float32(f.Float64))
if f.Float64 > 0.01 {
t.Direction = gen.Improving
} else if f.Float64 < -0.01 {
t.Direction = gen.Degrading
} else {
t.Direction = gen.Stable
}
} else {
t.Direction = gen.Unknown
}
items = append(items, t)
}
if rows.Err() != nil {
return nil, rows.Err()
}
if items == nil {
items = []gen.Trend{}
}
return gen.GetTrends200JSONResponse{Items: items}, nil
}
func float32Ptr(f float32) *float32 {
return &f
}