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).
181 lines
4.4 KiB
Go
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
|
|
}
|