package httpapi import ( "context" "encoding/json" "strconv" "time" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/google/uuid" ) func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestObject) (gen.ListEntitiesResponseObject, error) { limit := clampLimit(req.Params.Limit) // Type filter includes descendants via the parent hierarchy (R3-1). query := ` WITH RECURSIVE tt AS ( SELECT name FROM entity_types WHERE $1::text IS NULL OR name = $1 UNION SELECT et.name FROM entity_types et JOIN tt ON et.parent_type = tt.name WHERE $1::text IS NOT NULL ) SELECT ` + entityCols + ` FROM entities e JOIN entity_types et ON et.name = e.type LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.type IN (SELECT name FROM tt) AND ($2::text IS NULL OR e.state = $2) AND ($3::text IS NULL OR et.domain = $3) AND ($4::text IS NULL OR et.layer = $4) AND ($5::text IS NULL OR e.slug ILIKE '%'||$5||'%' OR e.name ILIKE '%'||$5||'%') AND ($6::text IS NULL OR e.slug > $6) ORDER BY e.slug LIMIT $7` rows, err := s.pool.Query(ctx, query, req.Params.Type, req.Params.State, req.Params.Domain, req.Params.Layer, req.Params.Q, req.Params.Cursor, limit+1) if err != nil { return nil, err } defer rows.Close() var items []gen.Entity for rows.Next() { e, err := scanEntity(rows) if err != nil { return nil, err } items = append(items, e) } if rows.Err() != nil { return nil, rows.Err() } var next *string if len(items) > limit { items = items[:limit] next = &items[len(items)-1].Slug } if items == nil { items = []gen.Entity{} } return gen.ListEntities200JSONResponse{Items: items, NextCursor: next}, nil } func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject) (gen.GetEntityResponseObject, error) { id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } e, err := scanEntity(s.pool.QueryRow(ctx, "SELECT "+entityCols+" FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1", id)) if err != nil { return nil, err } return gen.GetEntity200JSONResponse{ Body: e, Headers: gen.GetEntity200ResponseHeaders{ETag: `"` + strconv.Itoa(e.Version) + `"`}, }, nil } func (s *Server) GetEntityRelations(ctx context.Context, req gen.GetEntityRelationsRequestObject) (gen.GetEntityRelationsResponseObject, error) { id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } dir := "both" if req.Params.Direction != nil { dir = string(*req.Params.Direction) } relType := req.Params.RelType rows, err := sqlcgen.New(s.pool).ListEntityRelations(ctx, sqlcgen.ListEntityRelationsParams{ Direction: dir, ID: id, RelType: relType, }) if err != nil { return nil, err } items := []gen.Relationship{} for _, r := range rows { var attrs *map[string]any if len(r.Attributes) > 0 { var m map[string]any if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 { attrs = &m } } validTo := r.ValidTo items = append(items, gen.Relationship{ Source: r.SourceSlug, Target: r.TargetSlug, Type: r.Type, Attributes: attrs, ValidFrom: r.ValidFrom, ValidTo: validTo, }) } return gen.GetEntityRelations200JSONResponse{Items: items}, nil } func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusRequestObject) (gen.GetBlastRadiusResponseObject, error) { id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } depth := 3 if req.Params.Depth != nil { depth = *req.Params.Depth } rows, err := s.pool.Query(ctx, ` SELECT `+entityCols+`, b.depth FROM blast_radius($1, $2) b JOIN entities e ON e.id = b.entity_id LEFT JOIN entity_status st ON st.entity_id = e.id ORDER BY b.depth, e.slug`, id, depth) if err != nil { return nil, err } defer rows.Close() resp := gen.GetBlastRadius200JSONResponse{Items: []struct { Depth int `json:"depth"` Entity gen.Entity `json:"entity"` }{}} for rows.Next() { var e gen.Entity var state *string var attrsJSON []byte var maint *time.Time var health *string var lastCheckAt *time.Time var d int if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON, &maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt, &d); err != nil { return nil, err } e.State = state e.MaintenanceUntil = maint if health != nil { h := gen.EntityHealth(*health) e.Health = &h } e.LastCheckAt = lastCheckAt var attrs map[string]any if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 { e.Attributes = &attrs } resp.Items = append(resp.Items, struct { Depth int `json:"depth"` Entity gen.Entity `json:"entity"` }{Depth: d, Entity: e}) } return resp, rows.Err() } func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (gen.GetGraphResponseObject, error) { depth := 2 if req.Params.Depth != nil { depth = *req.Params.Depth } var nodes []gen.Entity var err error truncated := false // pgx can't infer the array element type from a nil *[]string (the // param is absent from the request, not an empty list), so dereference // to a plain []string first — nil there still encodes as SQL NULL, but // pgx has a concrete type to work with. var relTypes []string if req.Params.RelType != nil { relTypes = *req.Params.RelType } if req.Params.Root != nil && *req.Params.Root != "" { rootID, rerr := s.resolveEntityID(ctx, *req.Params.Root) if rerr != nil { return nil, rerr } nodes, err = s.queryEntities(ctx, ` SELECT `+entityCols+` FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id LEFT JOIN entity_status st ON st.entity_id = e.id ORDER BY e.slug`, rootID, depth, relTypes) } else { // Whole-graph view: pick the most-connected entities first so the // graph shows actual topology, not just whatever sorts first // alphabetically. Without this the cap fills with exec:* rows and // drops every host/lxc/service/vm — and every edge those entities // connect — because edges require both endpoints in the node set. // Exclude the cognition transactional types (execution/task): they // are audit records rather than topology, and at ~380 rows they // consumed most of the old 500-node cap. nodes, err = s.queryEntities(ctx, ` SELECT `+entityCols+` FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.type NOT IN ('execution','task') AND e.id IN ( SELECT e2.id FROM entities e2 LEFT JOIN relationships r ON r.valid_to IS NULL AND (r.source_id = e2.id OR r.target_id = e2.id) WHERE e2.type NOT IN ('execution','task') GROUP BY e2.id ORDER BY count(r.type) DESC, e2.slug LIMIT $1 ) ORDER BY e.slug`, graphNodeCap+1) if err == nil && len(nodes) > graphNodeCap { nodes = nodes[:graphNodeCap] truncated = true } } if err != nil { return nil, err } ids := make([]uuid.UUID, len(nodes)) for i, n := range nodes { ids[i] = uuid.UUID(n.Id) } edgeRows, err := sqlcgen.New(s.pool).ListGraphEdges(ctx, sqlcgen.ListGraphEdgesParams{ Ids: ids, RelTypes: relTypes, }) if err != nil { return nil, err } edges := []gen.Relationship{} for _, r := range edgeRows { var attrs *map[string]any if len(r.Attributes) > 0 { var m map[string]any if json.Unmarshal(r.Attributes, &m) == nil && len(m) > 0 { attrs = &m } } validTo := r.ValidTo edges = append(edges, gen.Relationship{ Source: r.SourceSlug, Target: r.TargetSlug, Type: r.Type, Attributes: attrs, ValidFrom: r.ValidFrom, ValidTo: validTo, }) } resp := gen.GetGraph200JSONResponse{Nodes: nodes, Edges: edges} if truncated { resp.Truncated = &truncated } if req.Params.Include != nil { for _, inc := range *req.Params.Include { if inc == gen.Status { health, herr := s.entityHealthByID(ctx, ids) if herr != nil { return nil, herr } resp.Health = &health break } } } return resp, nil } // entityHealthByID returns entity_status.health keyed by entity id, for the // given id set (used by GetGraph's include=status). func (s *Server) entityHealthByID(ctx context.Context, ids []uuid.UUID) (map[string]gen.GraphViewHealth, error) { health := make(map[string]gen.GraphViewHealth, len(ids)) rows, err := s.pool.Query(ctx, `SELECT entity_id, health FROM entity_status WHERE entity_id = ANY($1)`, ids) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var id uuid.UUID var h string if err := rows.Scan(&id, &h); err != nil { return nil, err } health[id.String()] = gen.GraphViewHealth(h) } return health, rows.Err() } func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) { rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() items := []gen.Entity{} for rows.Next() { e, err := scanEntity(rows) if err != nil { return nil, err } items = append(items, e) } return items, rows.Err() } // sqlcEntityToGen converts a sqlcgen.Entity to a gen.Entity. func sqlcEntityToGen(e sqlcgen.Entity) gen.Entity { out := gen.Entity{ Id: e.ID, Slug: e.Slug, Type: e.Type, Name: e.Name, State: e.State, Version: int(e.Version), CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, } if e.MaintenanceUntil != nil { out.MaintenanceUntil = e.MaintenanceUntil } if len(e.Attributes) > 0 { var attrs map[string]any if json.Unmarshal(e.Attributes, &attrs) == nil && len(attrs) > 0 { out.Attributes = &attrs } } return out }