Compare commits
7 Commits
a39e67b6e9
...
claude/cha
| Author | SHA1 | Date | |
|---|---|---|---|
| 614c38ea7c | |||
| 22412d2fa3 | |||
| 5686b9de40 | |||
| aa6017e0ca | |||
| cbfd09c5df | |||
| 851b5dce67 | |||
| 279549c8c9 |
@@ -1914,6 +1914,21 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
health:
|
||||
type: string
|
||||
description: last observed health, when the entity is monitored
|
||||
nullable: true
|
||||
enum:
|
||||
- healthy
|
||||
- degraded
|
||||
- down
|
||||
- unknown
|
||||
- stale
|
||||
last_check_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: when health was last observed
|
||||
EntityCreate:
|
||||
type: object
|
||||
required:
|
||||
@@ -2020,6 +2035,7 @@ components:
|
||||
- degraded
|
||||
- down
|
||||
- unknown
|
||||
- stale
|
||||
EntityType:
|
||||
type: object
|
||||
required:
|
||||
@@ -3031,6 +3047,9 @@ components:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
stale:
|
||||
type: integer
|
||||
description: last observation older than the check's expected cadence
|
||||
entities:
|
||||
type: array
|
||||
items:
|
||||
@@ -3051,6 +3070,7 @@ components:
|
||||
- degraded
|
||||
- down
|
||||
- unknown
|
||||
- stale
|
||||
trend:
|
||||
type: string
|
||||
enum:
|
||||
@@ -3100,6 +3120,9 @@ components:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
stale:
|
||||
type: integer
|
||||
description: last observation older than the check's expected cadence
|
||||
signals_by_severity:
|
||||
type: object
|
||||
description: open (non-resolved) signal counts keyed by severity
|
||||
|
||||
@@ -108,11 +108,16 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
// Rebuild conversation context from persisted history so sessions are
|
||||
// multi-turn. The current user turn is saved by the HTTP handler before
|
||||
// this runs, so it is already included in the history for real sessions.
|
||||
// Intermediate tool_use/tool_result pairs are not replayed (their ids
|
||||
// must match exactly or the API rejects them); prior final answers carry
|
||||
// the salient context. Ephemeral sessions (no store) fall back to the
|
||||
// single incoming message.
|
||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(a.system)}
|
||||
// Prior tool_use/tool_result pairs are replayed as a tool-calling
|
||||
// assistant message followed by matching tool-role results, so the agent
|
||||
// starts each turn already knowing what it already checked instead of
|
||||
// re-querying the same tools from scratch. Ephemeral sessions (no store)
|
||||
// fall back to the single incoming message.
|
||||
system := a.system
|
||||
if snapshot := a.fleetSnapshot(); snapshot != "" {
|
||||
system += "\n\n" + snapshot
|
||||
}
|
||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
|
||||
history, _ := a.store.getMessages(ctx, sessionID)
|
||||
for _, m := range history {
|
||||
text := extractText(m.Content)
|
||||
@@ -120,6 +125,12 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
case "user":
|
||||
messages = append(messages, openai.UserMessage(text))
|
||||
case "assistant":
|
||||
if calls := extractToolCalls(m.Content); len(calls) > 0 {
|
||||
messages = append(messages, assistantToolCallMessage(calls))
|
||||
for _, c := range calls {
|
||||
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
|
||||
}
|
||||
}
|
||||
if text != "" {
|
||||
messages = append(messages, openai.AssistantMessage(text))
|
||||
}
|
||||
@@ -243,6 +254,140 @@ func extractText(content json.RawMessage) string {
|
||||
return m.Text
|
||||
}
|
||||
|
||||
// persistedCall is one merged tool_use+tool_result pair from a persisted
|
||||
// assistant message's tool_calls array. The store keeps them as two entries
|
||||
// sharing the same id (mirroring the SSE event pair); replay needs one
|
||||
// entry per id to build a valid tool-calling assistant message.
|
||||
type persistedCall struct {
|
||||
id string
|
||||
name string
|
||||
args json.RawMessage
|
||||
result json.RawMessage
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (c persistedCall) resultText() string {
|
||||
if c.errMsg != "" {
|
||||
return c.errMsg
|
||||
}
|
||||
if len(c.result) > 0 {
|
||||
return string(c.result)
|
||||
}
|
||||
return "null"
|
||||
}
|
||||
|
||||
// extractToolCalls parses and merges a persisted message's tool_calls array,
|
||||
// preserving first-seen order across ids.
|
||||
func extractToolCalls(content json.RawMessage) []persistedCall {
|
||||
var m struct {
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &m); err != nil || len(m.ToolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
byID := make(map[string]*persistedCall, len(m.ToolCalls))
|
||||
var order []string
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.ID == "" {
|
||||
continue
|
||||
}
|
||||
pc, ok := byID[tc.ID]
|
||||
if !ok {
|
||||
pc = &persistedCall{id: tc.ID}
|
||||
byID[tc.ID] = pc
|
||||
order = append(order, tc.ID)
|
||||
}
|
||||
if tc.Name != "" {
|
||||
pc.name = tc.Name
|
||||
}
|
||||
if len(tc.Args) > 0 && string(tc.Args) != "null" {
|
||||
pc.args = tc.Args
|
||||
}
|
||||
if tc.Type == "tool_result" {
|
||||
pc.errMsg = tc.Error
|
||||
pc.result = tc.Result
|
||||
}
|
||||
}
|
||||
|
||||
calls := make([]persistedCall, 0, len(order))
|
||||
for _, id := range order {
|
||||
calls = append(calls, *byID[id])
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
// assistantToolCallMessage builds the tool-calling assistant message that
|
||||
// must precede the tool-role results being replayed.
|
||||
func assistantToolCallMessage(calls []persistedCall) openai.ChatCompletionMessageParamUnion {
|
||||
toolCalls := make([]openai.ChatCompletionMessageToolCallParam, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
args := string(c.args)
|
||||
if args == "" {
|
||||
args = "{}"
|
||||
}
|
||||
toolCalls = append(toolCalls, openai.ChatCompletionMessageToolCallParam{
|
||||
ID: c.id,
|
||||
Function: openai.ChatCompletionMessageToolCallFunctionParam{
|
||||
Name: c.name,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
}
|
||||
return openai.ChatCompletionMessageParamUnion{
|
||||
OfAssistant: &openai.ChatCompletionAssistantMessageParam{ToolCalls: toolCalls},
|
||||
}
|
||||
}
|
||||
|
||||
// fleetSnapshot returns a compact, current-as-of-now fleet health line for
|
||||
// the system prompt so the agent starts each turn already oriented instead
|
||||
// of spending its first iteration rediscovering topology it already has
|
||||
// tools to query. Best-effort: an empty string on any failure just means no
|
||||
// snapshot, not an error for the turn.
|
||||
func (a *agent) fleetSnapshot() string {
|
||||
result, err := a.client.callTool("get_health_summary", map[string]any{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rows, ok := result.([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
var attention []string
|
||||
for _, r := range rows {
|
||||
row, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
health, _ := row["health"].(string)
|
||||
counts[health]++
|
||||
if health != "healthy" && health != "" {
|
||||
if slug, ok := row["slug"].(string); ok && len(attention) < 10 {
|
||||
attention = append(attention, fmt.Sprintf("%s(%s)", slug, health))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(counts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Current fleet snapshot (as of now): healthy=%d degraded=%d down=%d stale=%d unknown=%d.",
|
||||
counts["healthy"], counts["degraded"], counts["down"], counts["stale"], counts["unknown"])
|
||||
if len(attention) > 0 {
|
||||
summary += " Needs attention: " + fmt.Sprintf("%v", attention) + "."
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
||||
defs, err := a.client.listToolsFull()
|
||||
if err != nil {
|
||||
|
||||
@@ -54,10 +54,17 @@ func (s *Server) GetDashboardSummary(ctx context.Context, req gen.GetDashboardSu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = s.pool.Query(ctx, `SELECT health, count(*) FROM entity_status GROUP BY health`)
|
||||
// Exclude 'check' entities (internal probes) — only entities actually
|
||||
// being monitored should count toward the fleet health rollup.
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT st.health, count(*)
|
||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.type <> 'check'
|
||||
GROUP BY st.health`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stale := 0
|
||||
for rows.Next() {
|
||||
var health string
|
||||
var n int
|
||||
@@ -72,6 +79,8 @@ func (s *Server) GetDashboardSummary(ctx context.Context, req gen.GetDashboardSu
|
||||
resp.Health.Degraded = n
|
||||
case "down":
|
||||
resp.Health.Down = n
|
||||
case "stale":
|
||||
stale = n
|
||||
default:
|
||||
resp.Health.Unknown = n
|
||||
}
|
||||
@@ -80,6 +89,9 @@ func (s *Server) GetDashboardSummary(ctx context.Context, req gen.GetDashboardSu
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stale > 0 {
|
||||
resp.Health.Stale = &stale
|
||||
}
|
||||
|
||||
rows, err = s.pool.Query(ctx, `
|
||||
SELECT severity, count(*) FROM signals
|
||||
|
||||
@@ -104,6 +104,15 @@ const (
|
||||
ClassificationRouteHold ClassificationRoute = "hold"
|
||||
)
|
||||
|
||||
// Defines values for EntityHealth.
|
||||
const (
|
||||
EntityHealthDegraded EntityHealth = "degraded"
|
||||
EntityHealthDown EntityHealth = "down"
|
||||
EntityHealthHealthy EntityHealth = "healthy"
|
||||
EntityHealthStale EntityHealth = "stale"
|
||||
EntityHealthUnknown EntityHealth = "unknown"
|
||||
)
|
||||
|
||||
// Defines values for EntityTypeLayer.
|
||||
const (
|
||||
EntityTypeLayerCognition EntityTypeLayer = "cognition"
|
||||
@@ -160,6 +169,7 @@ const (
|
||||
GraphViewHealthDegraded GraphViewHealth = "degraded"
|
||||
GraphViewHealthDown GraphViewHealth = "down"
|
||||
GraphViewHealthHealthy GraphViewHealth = "healthy"
|
||||
GraphViewHealthStale GraphViewHealth = "stale"
|
||||
GraphViewHealthUnknown GraphViewHealth = "unknown"
|
||||
)
|
||||
|
||||
@@ -168,6 +178,7 @@ const (
|
||||
HealthSummaryEntitiesHealthDegraded HealthSummaryEntitiesHealth = "degraded"
|
||||
HealthSummaryEntitiesHealthDown HealthSummaryEntitiesHealth = "down"
|
||||
HealthSummaryEntitiesHealthHealthy HealthSummaryEntitiesHealth = "healthy"
|
||||
HealthSummaryEntitiesHealthStale HealthSummaryEntitiesHealth = "stale"
|
||||
HealthSummaryEntitiesHealthUnknown HealthSummaryEntitiesHealth = "unknown"
|
||||
)
|
||||
|
||||
@@ -262,10 +273,10 @@ const (
|
||||
|
||||
// Defines values for TrendDirection.
|
||||
const (
|
||||
TrendDirectionDegrading TrendDirection = "degrading"
|
||||
TrendDirectionImproving TrendDirection = "improving"
|
||||
TrendDirectionStable TrendDirection = "stable"
|
||||
TrendDirectionUnknown TrendDirection = "unknown"
|
||||
Degrading TrendDirection = "degrading"
|
||||
Improving TrendDirection = "improving"
|
||||
Stable TrendDirection = "stable"
|
||||
Unknown TrendDirection = "unknown"
|
||||
)
|
||||
|
||||
// Defines values for ListApprovalsParamsStatus.
|
||||
@@ -557,7 +568,10 @@ type DashboardSummary struct {
|
||||
Degraded int `json:"degraded"`
|
||||
Down int `json:"down"`
|
||||
Healthy int `json:"healthy"`
|
||||
Unknown int `json:"unknown"`
|
||||
|
||||
// Stale last observation older than the check's expected cadence
|
||||
Stale *int `json:"stale,omitempty"`
|
||||
Unknown int `json:"unknown"`
|
||||
} `json:"health"`
|
||||
|
||||
// SignalsBySeverity open (non-resolved) signal counts keyed by severity
|
||||
@@ -596,18 +610,27 @@ type EnrollResponse struct {
|
||||
|
||||
// Entity defines model for Entity.
|
||||
type Entity struct {
|
||||
Attributes *map[string]interface{} `json:"attributes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
MaintenanceUntil *time.Time `json:"maintenance_until"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
State *string `json:"state"`
|
||||
Type string `json:"type"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Attributes *map[string]interface{} `json:"attributes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Health last observed health, when the entity is monitored
|
||||
Health *EntityHealth `json:"health"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
|
||||
// LastCheckAt when health was last observed
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
MaintenanceUntil *time.Time `json:"maintenance_until"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
State *string `json:"state"`
|
||||
Type string `json:"type"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// EntityHealth last observed health, when the entity is monitored
|
||||
type EntityHealth string
|
||||
|
||||
// EntityCreate defines model for EntityCreate.
|
||||
type EntityCreate struct {
|
||||
// Attributes Validated against the type's attribute_schema
|
||||
@@ -765,7 +788,10 @@ type HealthSummary struct {
|
||||
Degraded int `json:"degraded"`
|
||||
Down int `json:"down"`
|
||||
Healthy int `json:"healthy"`
|
||||
Unknown int `json:"unknown"`
|
||||
|
||||
// Stale last observation older than the check's expected cadence
|
||||
Stale *int `json:"stale,omitempty"`
|
||||
Unknown int `json:"unknown"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
@@ -8182,173 +8208,175 @@ func (sh *strictHandler) GetTrends(w http.ResponseWriter, r *http.Request, entit
|
||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
"7gAA",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -75,22 +75,31 @@ func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUI
|
||||
return id, err
|
||||
}
|
||||
|
||||
// entityCols requires the entities table to be aliased as `e`.
|
||||
// entityCols requires the entities table to be aliased as `e`, with
|
||||
// entity_status left-joined and aliased as `st` (see withEntityStatus).
|
||||
const entityCols = `e.id, e.slug, e.type, e.name, e.state, e.attributes,
|
||||
e.maintenance_until, e.version, e.created_at, e.updated_at`
|
||||
e.maintenance_until, e.version, e.created_at, e.updated_at,
|
||||
st.health, st.last_check_at`
|
||||
|
||||
func scanEntity(row pgx.Row) (gen.Entity, error) {
|
||||
var e gen.Entity
|
||||
var state *string
|
||||
var attrsJSON []byte
|
||||
var maint *time.Time
|
||||
var health *string
|
||||
var lastCheckAt *time.Time
|
||||
err := row.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
||||
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt)
|
||||
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt)
|
||||
if err != nil {
|
||||
return e, 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
|
||||
@@ -113,6 +122,7 @@ func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestOb
|
||||
)
|
||||
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)
|
||||
@@ -159,7 +169,7 @@ func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject)
|
||||
return nil, err
|
||||
}
|
||||
e, err := scanEntity(s.pool.QueryRow(ctx,
|
||||
"SELECT "+entityCols+" FROM entities e WHERE e.id = $1", id))
|
||||
"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
|
||||
}
|
||||
@@ -231,6 +241,7 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
|
||||
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
|
||||
@@ -246,13 +257,20 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
|
||||
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, &d); err != nil {
|
||||
&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
|
||||
@@ -283,10 +301,13 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
||||
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, req.Params.RelType)
|
||||
} else {
|
||||
nodes, err = s.queryEntities(ctx, `
|
||||
SELECT `+entityCols+` FROM entities e ORDER BY e.slug LIMIT $1`,
|
||||
SELECT `+entityCols+` FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY e.slug LIMIT $1`,
|
||||
graphNodeCap+1)
|
||||
if err == nil && len(nodes) > graphNodeCap {
|
||||
nodes = nodes[:graphNodeCap]
|
||||
@@ -523,14 +544,18 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
||||
Type string `json:"type"`
|
||||
}{}
|
||||
|
||||
// Exclude 'check' entities (internal probes) — only entities actually
|
||||
// being monitored should count toward fleet health.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.type <> 'check'
|
||||
ORDER BY e.slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
stale := 0
|
||||
for rows.Next() {
|
||||
var slug, typ, health string
|
||||
var lastCheck *time.Time
|
||||
@@ -544,6 +569,8 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
||||
resp.Summary.Degraded++
|
||||
case "down":
|
||||
resp.Summary.Down++
|
||||
case "stale":
|
||||
stale++
|
||||
default:
|
||||
resp.Summary.Unknown++
|
||||
}
|
||||
@@ -560,6 +587,9 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
||||
Type: typ,
|
||||
})
|
||||
}
|
||||
if stale > 0 {
|
||||
resp.Summary.Stale = &stale
|
||||
}
|
||||
return resp, rows.Err()
|
||||
}
|
||||
|
||||
|
||||
@@ -1974,14 +1974,14 @@ func (s *Server) GetTrends(ctx context.Context, req gen.GetTrendsRequestObject)
|
||||
f, _ := slopeNum.Float64Value()
|
||||
t.Slope = float32Ptr(float32(f.Float64))
|
||||
if f.Float64 > 0.01 {
|
||||
t.Direction = gen.TrendDirectionImproving
|
||||
t.Direction = gen.Improving
|
||||
} else if f.Float64 < -0.01 {
|
||||
t.Direction = gen.TrendDirectionDegrading
|
||||
t.Direction = gen.Degrading
|
||||
} else {
|
||||
t.Direction = gen.TrendDirectionStable
|
||||
t.Direction = gen.Stable
|
||||
}
|
||||
} else {
|
||||
t.Direction = gen.TrendDirectionUnknown
|
||||
t.Direction = gen.Unknown
|
||||
}
|
||||
items = append(items, t)
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.type <> 'check'
|
||||
ORDER BY e.slug`), nil
|
||||
})
|
||||
|
||||
|
||||
@@ -94,6 +94,13 @@ func runCheckPass(ctx context.Context, pool *db.Pool) {
|
||||
}
|
||||
|
||||
// runCheck executes a single check and processes the result.
|
||||
//
|
||||
// check_defs.entity_id identifies the *check* (probe) entity itself;
|
||||
// check_defs.target_id identifies the entity actually being observed (the
|
||||
// host/service/etc). Health, metrics, and events must attach to the target
|
||||
// so the observed entity's own record reflects reality — not the internal
|
||||
// probe. Signals stay keyed by the check entity (cd.EntityID), matching how
|
||||
// they are created below and resolved elsewhere.
|
||||
func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) {
|
||||
q := sqlcgen.New(pool)
|
||||
start := time.Now()
|
||||
@@ -107,9 +114,14 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
||||
}
|
||||
result.metrics["probe_latency_ms"] = float64(latency)
|
||||
|
||||
targetID := cd.EntityID
|
||||
if cd.TargetID != nil {
|
||||
targetID = *cd.TargetID
|
||||
}
|
||||
|
||||
for metric, value := range result.metrics {
|
||||
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
|
||||
EntityID: cd.EntityID,
|
||||
EntityID: targetID,
|
||||
Metric: metric,
|
||||
Value: value,
|
||||
Tags: []byte(`{}`),
|
||||
@@ -121,18 +133,18 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
||||
"entity", cd.EntitySlug, "kind", cd.Kind, "error", result.err)
|
||||
}
|
||||
|
||||
prevHealth := currentHealth(ctx, pool, cd.EntityID)
|
||||
prevHealth := currentHealth(ctx, pool, targetID)
|
||||
|
||||
if result.signalKind == "" || result.health == "healthy" {
|
||||
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
|
||||
resolveSignal(ctx, pool, cd.EntityID, targetID, cd.EntitySlug)
|
||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||
EntityID: cd.EntityID,
|
||||
EntityID: targetID,
|
||||
Health: "healthy",
|
||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||
Details: []byte(`{}`),
|
||||
})
|
||||
if prevHealth != "" && prevHealth != "healthy" {
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, "info",
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", targetID, "info",
|
||||
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": "healthy"})
|
||||
}
|
||||
return
|
||||
@@ -156,7 +168,7 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
||||
}
|
||||
|
||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||
EntityID: cd.EntityID,
|
||||
EntityID: targetID,
|
||||
Health: result.health,
|
||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||
Details: []byte(`{}`),
|
||||
@@ -164,11 +176,11 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
||||
_ = sig
|
||||
|
||||
if prevHealth == "" || prevHealth == "healthy" {
|
||||
emitSchedulerEvent(ctx, pool, "signal.raised", cd.EntityID, severity,
|
||||
emitSchedulerEvent(ctx, pool, "signal.raised", targetID, severity,
|
||||
map[string]any{"slug": cd.EntitySlug, "kind": result.signalKind, "evidence": result.evidence})
|
||||
}
|
||||
if prevHealth != result.health {
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, severity,
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", targetID, severity,
|
||||
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": result.health})
|
||||
}
|
||||
}
|
||||
@@ -188,21 +200,23 @@ func emitSchedulerEvent(ctx context.Context, pool *db.Pool, eventType string, en
|
||||
_ = observability.Event(ctx, sqlcgen.New(pool), eventType, &entityID, severity, "scheduler", "", data)
|
||||
}
|
||||
|
||||
// resolveSignal resolves any open signal for the given check entity.
|
||||
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
|
||||
// resolveSignal resolves any open signal raised by the given check entity.
|
||||
// checkID matches how signals are keyed (UpsertSignal uses the check's own
|
||||
// entity id); targetID is the observed entity whose status this affects.
|
||||
func resolveSignal(ctx context.Context, pool *db.Pool, checkID, targetID uuid.UUID, slug string) {
|
||||
q := sqlcgen.New(pool)
|
||||
// Check if there's an open signal on this entity
|
||||
tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
||||
WHERE entity_id = $1 AND state = 'raised'`, entityID)
|
||||
WHERE entity_id = $1 AND state = 'raised'`, checkID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
emitSchedulerEvent(ctx, pool, "signal.resolved", entityID, "info",
|
||||
emitSchedulerEvent(ctx, pool, "signal.resolved", targetID, "info",
|
||||
map[string]any{"slug": slug})
|
||||
}
|
||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||
EntityID: entityID,
|
||||
EntityID: targetID,
|
||||
Health: "healthy",
|
||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||
Details: []byte(`{}`),
|
||||
@@ -250,10 +264,74 @@ func housekeeping(ctx context.Context, pool *db.Pool) {
|
||||
slog.Error("scheduler: prune idempotency keys", "error", err)
|
||||
}
|
||||
|
||||
staleSweep(ctx, pool)
|
||||
|
||||
// Log housekeeping completion
|
||||
slog.Debug("scheduler: housekeeping done", "pruned_idempotency_before", cutoff.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// staleMultiplier and staleFloor bound how long an entity can go unobserved
|
||||
// before its last-known health is no longer trusted. An entity is stale once
|
||||
// it has gone longer than staleMultiplier times its fastest enabled check's
|
||||
// interval (or staleFloor, whichever is larger) without a fresh observation —
|
||||
// covering both a stalled scheduler and a disabled/broken check_def.
|
||||
const (
|
||||
staleMultiplier = 3
|
||||
staleFloor = 5 * time.Minute
|
||||
)
|
||||
|
||||
// staleSweep marks entities whose last observation has aged past their
|
||||
// check's expected cadence as 'stale', so the system never reports an old
|
||||
// health value as if it were current. Runs once per housekeeping pass.
|
||||
func staleSweep(ctx context.Context, pool *db.Pool) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT e.id, e.slug, st.health
|
||||
FROM entity_status st
|
||||
JOIN entities e ON e.id = st.entity_id
|
||||
JOIN (
|
||||
SELECT target_id, MIN(interval_s) AS min_interval
|
||||
FROM check_defs
|
||||
WHERE enabled AND target_id IS NOT NULL
|
||||
GROUP BY target_id
|
||||
) iv ON iv.target_id = st.entity_id
|
||||
WHERE st.health <> 'stale'
|
||||
AND (st.last_check_at IS NULL
|
||||
OR st.last_check_at < now() - make_interval(secs => GREATEST(iv.min_interval * $1, $2)))`,
|
||||
staleMultiplier, int(staleFloor.Seconds()))
|
||||
if err != nil {
|
||||
slog.Error("scheduler: stale sweep query", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type staleEntity struct {
|
||||
id uuid.UUID
|
||||
slug string
|
||||
health string
|
||||
}
|
||||
var stale []staleEntity
|
||||
for rows.Next() {
|
||||
var se staleEntity
|
||||
if err := rows.Scan(&se.id, &se.slug, &se.health); err != nil {
|
||||
continue
|
||||
}
|
||||
stale = append(stale, se)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, se := range stale {
|
||||
_, err := pool.Exec(ctx,
|
||||
`UPDATE entity_status SET health = 'stale', updated_at = now() WHERE entity_id = $1`, se.id)
|
||||
if err != nil {
|
||||
slog.Error("scheduler: mark stale", "entity", se.slug, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Warn("scheduler: entity stale", "entity", se.slug, "prev_health", se.health)
|
||||
emitSchedulerEvent(ctx, pool, "health.stale", se.id, "warning",
|
||||
map[string]any{"slug": se.slug, "from": se.health, "to": "stale"})
|
||||
}
|
||||
}
|
||||
|
||||
// checkHTTP performs an HTTP health check.
|
||||
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||
cfg := struct {
|
||||
|
||||
16
migrations/016_fix_check_status_misattribution.up.sql
Normal file
16
migrations/016_fix_check_status_misattribution.up.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 016_fix_check_status_misattribution.up.sql
|
||||
-- The scheduler historically wrote entity_status and metric_samples keyed by
|
||||
-- the *check* entity (check_defs.entity_id) instead of the entity being
|
||||
-- monitored (check_defs.target_id). Every real host/service/lxc/etc. entity
|
||||
-- was therefore permanently stuck at health='unknown' while all observed
|
||||
-- health and metrics accumulated on the internal probe entities instead.
|
||||
-- The Go fix (scheduler.go) starts writing to target_id going forward; this
|
||||
-- migration removes the now-orphaned check-entity rows so dashboard/fleet
|
||||
-- health rollups stop double-counting probes as if they were monitored
|
||||
-- entities. Historical metric_samples on check entities are left in place
|
||||
-- (time-series data, not safe to reattribute retroactively) but are no
|
||||
-- longer queried through any entity-scoped view once checks stop being
|
||||
-- written to.
|
||||
|
||||
DELETE FROM entity_status
|
||||
WHERE entity_id IN (SELECT id FROM entities WHERE type = 'check');
|
||||
253
plans/2026-07-08-liveness-drift-and-ux-cohesion.md
Normal file
253
plans/2026-07-08-liveness-drift-and-ux-cohesion.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 2026-07-08 — Liveness, drift, and UX cohesion
|
||||
|
||||
**Status:** Code complete for Phases 1–4 core scope; not yet deployed to the
|
||||
live containers (pending explicit go-ahead — see below). Phase 5 partially
|
||||
covered by pre-existing endpoints; full CRUD UI deferred.
|
||||
|
||||
- **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution
|
||||
fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health
|
||||
`check`-entity exclusion, Entities table health column. Migration 016
|
||||
applied to the live dev DB (safe cleanup, additive-only).
|
||||
- **Phase 2 (detail sidebar + legibility):** done. `EntityDetailContent`
|
||||
extracted and shared between the full `#/entity/:slug` page and a new
|
||||
`EntitySheet` opened from the Entities table (master-detail, no
|
||||
navigation). Adds a **Monitoring** card (per-entity `check_defs`: kind,
|
||||
interval, enabled/disabled with click-to-toggle) and renders attributes as
|
||||
a key/value list instead of raw JSON.
|
||||
- **Phase 3 (sessions rejoin chat):** done. Fixed the click-does-nothing bug,
|
||||
added a session rail inside Chat, fixed the local dev proxy to match
|
||||
production's `/agent` prefix-stripping. **Also found and fixed a real
|
||||
latent bug**: persisted `tool_calls` store the `tool_use`/`tool_result` as
|
||||
two entries sharing one `id`; Chat.svelte's keyed `{#each tool (tool.id)}`
|
||||
threw on the duplicate key and silently blanked the entire message list.
|
||||
This had presumably never been noticed because sessions were never
|
||||
clickable before this fix. Fixed in `chat.ts` by merging tool_calls by id
|
||||
before rendering.
|
||||
- **Phase 4 (agent efficiency):** core piece done — prior turns' tool
|
||||
calls/results are now replayed into the conversation (previously dropped
|
||||
entirely), and a compact live fleet-health snapshot is injected into the
|
||||
system prompt each turn so the agent starts oriented. Prompt caching and
|
||||
reconsidering the default model are **not done** (lower priority, no
|
||||
measured regression without them).
|
||||
- **Phase 5 (CRUD):** `PatchEntity` and a full `/checks` CRUD API
|
||||
(list/create/patch, including enable/disable) already existed server-side;
|
||||
the new Monitoring card's toggle uses `PatchCheck`. **Not done**: a
|
||||
"run check now" endpoint (no scheduler on-demand entrypoint exists yet),
|
||||
relationship editing, and an entity attribute editor UI.
|
||||
|
||||
**Not yet deployed** — the live `oikos-api`/`oikos-scheduler`/nomos
|
||||
containers still run the pre-fix binaries; rebuilding and restarting them
|
||||
needs an explicit go-ahead since it touches the running homelab control
|
||||
plane.
|
||||
|
||||
Addresses five felt problems with the current system: (1) the agent reports
|
||||
stale machine state as if it were fresh, (2) sessions can't be opened and feel
|
||||
disconnected from chat, (3) the Nomos agent re-derives state every turn and
|
||||
wastes iterations, (4) the UI feels dead — tables with no context, no sense of
|
||||
what is monitored, (5) no way to inspect or customize entities and their checks.
|
||||
|
||||
The unifying UX principle for this plan: **master-detail with a detail
|
||||
sidebar**, not full-page navigation. Selecting an entity, session, or signal
|
||||
opens a right-hand detail panel over the current list, so the operator keeps
|
||||
context and drills in without losing their place. Full pages remain
|
||||
addressable (deep links) but are no longer the primary way to inspect a row.
|
||||
|
||||
Sequencing is driven by pain: **drift/staleness is Phase 1.**
|
||||
|
||||
---
|
||||
|
||||
## Root causes (verified in code)
|
||||
|
||||
### Drift / staleness — root cause was worse than a missing TTL
|
||||
Live-DB inspection (`oikos-postgres-1`) found the real cause: `check_defs` has
|
||||
two entity references — `entity_id` (the internal probe/"check" entity) and
|
||||
`target_id` (the host/service actually being observed). The scheduler wrote
|
||||
`entity_status`, `metric_samples`, and scheduler-sourced `events` keyed by
|
||||
`cd.EntityID` (the probe) instead of `cd.TargetID` (the target) —
|
||||
[scheduler.go:110-171](../internal/scheduler/scheduler.go) (pre-fix). Verified
|
||||
against the live database:
|
||||
|
||||
```
|
||||
entity_status by type: only type='check' rows ever had real health (24
|
||||
healthy, 1 down); every host/service/lxc/vm/proxmox-host was frozen at
|
||||
'unknown' since creation.
|
||||
metric_samples: 17,559 rows, 100% attached to type='check' entities — zero
|
||||
attached to any real host or service.
|
||||
events: 45 of 46 scheduler-sourced rows attached to type='check' entities.
|
||||
```
|
||||
|
||||
So this wasn't staleness in the TTL sense — the entities you actually care
|
||||
about (`host:hubris`, `service:authentik`, etc.) **never received an
|
||||
observation at all**. Every health check, metric, and event the scheduler
|
||||
produced was filed under an internal bookkeeping entity the UI doesn't even
|
||||
surface distinctly. This is the literal mechanism behind "the agent tells me
|
||||
stale/wrong state."
|
||||
|
||||
**Fixed** (this session): `runCheck`/`resolveSignal` now resolve
|
||||
`targetID := cd.TargetID` and write status/metrics/events there, falling back
|
||||
to the check's own id only if `target_id` is unset. Signals remain keyed by
|
||||
the check entity (unchanged, matches their existing resolution logic). A new
|
||||
migration ([016_fix_check_status_misattribution](../migrations/016_fix_check_status_misattribution.up.sql))
|
||||
deletes the orphaned check-entity `entity_status` rows so rollups stop
|
||||
double-counting probes as monitored entities; historical `metric_samples` on
|
||||
check entities are left as-is (time-series data, not safe to reattribute).
|
||||
|
||||
On top of the misattribution fix, a genuine staleness gap also existed and is
|
||||
now closed: `entity_status.health` was written only when a check ran, with no
|
||||
TTL — a stalled scheduler or disabled check_def would leave the last health
|
||||
value looking current forever.
|
||||
- `last_check_at` is recorded but was never surfaced. The Entities table
|
||||
showed `entity.updated_at` (row mutation time), not observation time
|
||||
([Entities.svelte:108](../web/src/pages/Entities.svelte), pre-fix).
|
||||
- The `/entities` list endpoint returned neither `health` nor `last_check_at`
|
||||
— only `/graph?include=status` and `/fleet/health` did
|
||||
([impl.go:344](../internal/httpapi/impl.go), pre-fix).
|
||||
|
||||
### Sessions
|
||||
- Clicking a session calls `loadSessionMessages()` but never navigates to the
|
||||
chat page ([Sessions.svelte:17](../web/src/pages/Sessions.svelte)); it mutates
|
||||
the chat store while the user stays on the session list, so nothing appears to
|
||||
happen. There is also no session switcher inside Chat.
|
||||
|
||||
### Agent efficiency
|
||||
- Multi-turn history replay **drops all `tool_use`/`tool_result` pairs**; only
|
||||
prior final text is replayed ([agent.go:108-127](../cmd/nomos/agent.go)). Each
|
||||
new turn re-discovers the fleet from scratch, re-calling tools already run.
|
||||
- Cold start: the system prompt injects no fleet snapshot
|
||||
([agent.go:81](../cmd/nomos/agent.go)); default model is
|
||||
`deepseek/deepseek-v4-flash` ([agent.go:34](../cmd/nomos/agent.go)); tool
|
||||
schema + system prompt are rebuilt each call with no prompt caching.
|
||||
|
||||
### Dead UI / no inspection
|
||||
- Entities table = slug/type/name/state/updated; no health, no last-seen, no
|
||||
signal count.
|
||||
- EntityDetail dumps `JSON.stringify(attributes)` raw
|
||||
([EntityDetail.svelte:123](../web/src/pages/EntityDetail.svelte)) and never
|
||||
shows the entity's `check_defs` — the operator cannot see *what is monitored*,
|
||||
when it last ran, or what it returned.
|
||||
- No CRUD anywhere: no entity editor, no check management (enable/disable/edit/
|
||||
run-now), no relationship editing. `check_defs` do not appear in the web app.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Kill the drift (highest priority)
|
||||
|
||||
Goal: the system never presents stale observations as fresh, and freshness is
|
||||
visible everywhere health is.
|
||||
|
||||
**Backend**
|
||||
- Add a staleness sweep to `housekeeping()`
|
||||
([scheduler.go:243](../internal/scheduler/scheduler.go)): for each
|
||||
`entity_status` where `now() - last_check_at > staleAfter` (default
|
||||
`max(3 × check interval, 5m)`), transition health to a new `stale` value and
|
||||
emit a `health.stale` event once (not every pass).
|
||||
- Treat `stale` as a first-class health in dashboard rollups
|
||||
([dashboard.go:57](../internal/httpapi/dashboard.go)) and fleet health
|
||||
([impl.go:516](../internal/httpapi/impl.go)) — do not fold it into `unknown`.
|
||||
- Extend the `/entities` list response with `health` and `last_check_at`
|
||||
(join `entity_status`), so the table can show freshness without N graph calls.
|
||||
- Nomos: when answering about state, tool results should carry `last_check_at`
|
||||
and a stale flag so the agent can hedge ("healthy as of 4m ago") instead of
|
||||
asserting stale data. (Verify the MCP topology/health tools include it.)
|
||||
|
||||
**Frontend**
|
||||
- Entities table: replace the `Updated` column with **health dot + relative
|
||||
"checked 2m ago"**, and add an **open-signal count** badge per row. Stale rows
|
||||
get a distinct muted/amber treatment, not a green dot.
|
||||
- Global header: add an "as of {time}" and make the SSE connection dot a real
|
||||
liveness indicator (last event received, reconnect state).
|
||||
|
||||
**Acceptance:** disable a check or stop the scheduler → within one stale window
|
||||
the affected entity shows `stale` in the table and dashboard, an event fires,
|
||||
and asking Nomos "is X healthy?" yields a freshness-qualified answer.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Detail sidebar + entity legibility (less navigation)
|
||||
|
||||
Goal: inspect any row in place; make an entity's monitoring self-evident.
|
||||
|
||||
- Introduce a reusable **DetailSheet** (right-side panel) used across Entities,
|
||||
Signals, Sessions, Executions. Row click opens the sheet; URL hash updates for
|
||||
deep-linking; Esc / click-away closes. Full `#/entity/:slug` page remains for
|
||||
direct links but reuses the same detail component.
|
||||
- Entity detail content (in the sheet):
|
||||
- Header: slug, type, **health + freshness** ("checked 2m ago" / "stale
|
||||
18m").
|
||||
- **Monitoring card**: the entity's `check_defs` — kind, schedule, enabled,
|
||||
last result + evidence, next run. This is the missing "what is watched."
|
||||
- Attributes rendered as a key/value panel, not raw JSON.
|
||||
- Relations, open signals, recent executions, metrics sparklines (reuse
|
||||
existing EntityDetail sections).
|
||||
- Backend: endpoint to list `check_defs` for an entity with last-result join
|
||||
(currently checks are only visible to the scheduler).
|
||||
|
||||
**Acceptance:** from the Entities list, one click reveals what an entity is,
|
||||
what's monitoring it, when it was last seen, and its open signals — without a
|
||||
full page load or losing the list.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Sessions rejoin chat
|
||||
|
||||
Goal: sessions are openable and live next to the conversation.
|
||||
|
||||
- Fix: clicking a session navigates to `#/chat` and loads it
|
||||
([Sessions.svelte:17](../web/src/pages/Sessions.svelte)).
|
||||
- Add a **session rail inside Chat** (collapsible left list: title, last-active,
|
||||
active highlight) so switching sessions never leaves the chat surface. The
|
||||
standalone Sessions page becomes a thin wrapper / can be retired from nav.
|
||||
- Show session metadata (message count, last actor) and allow rename/delete.
|
||||
|
||||
**Acceptance:** clicking any past session opens its transcript in the chat view;
|
||||
starting a new chat and switching back and forth works without navigation.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Agent efficiency
|
||||
|
||||
Goal: stop re-deriving state; start each turn already oriented.
|
||||
|
||||
- Persist and replay tool evidence across turns
|
||||
([agent.go:108-127](../cmd/nomos/agent.go)): either replay `tool_use`/
|
||||
`tool_result` pairs with consistent ids, or persist a compacted per-turn
|
||||
"evidence summary" and replay that. Removes redundant re-querying.
|
||||
- Inject a compact fleet snapshot (counts by health, open signals, stale set)
|
||||
into the system prompt ([agent.go:81](../cmd/nomos/agent.go)) so the agent
|
||||
starts oriented instead of spending iterations on discovery.
|
||||
- Add prompt caching for the system prompt + tool schema (rebuilt every call
|
||||
today); revisit the default model
|
||||
([agent.go:34](../cmd/nomos/agent.go)) — evaluate a stronger default for
|
||||
fewer, better tool calls.
|
||||
- Surface per-turn iteration/token/cost in the chat UI (data already logged to
|
||||
`agent_activity`) so inefficiency is visible and measurable.
|
||||
|
||||
**Acceptance:** a 3-turn conversation about the same entity does not re-call the
|
||||
same read tools each turn; median iterations-per-answer drops.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Customize & inspect (CRUD)
|
||||
|
||||
Goal: manage the system from the UI, not just observe it.
|
||||
|
||||
- Entity editor (attributes, state) via existing mutation endpoints.
|
||||
- Check management from the entity detail sheet: enable/disable, edit config/
|
||||
thresholds, and **run-now** (trigger a single check pass on demand — new
|
||||
scheduler entrypoint).
|
||||
- Relationship add/remove.
|
||||
- Raw DB-row view toggle in the detail sheet for power inspection.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. Phase 1 backend (staleness sweep + `/entities` health/freshness) →
|
||||
Phase 1 frontend (table freshness + liveness header).
|
||||
2. Phase 2 DetailSheet + entity monitoring card.
|
||||
3. Phase 3 sessions fix (small; can slot in earlier if desired).
|
||||
4. Phase 4 agent efficiency.
|
||||
5. Phase 5 CRUD.
|
||||
|
||||
Phases 1–3 are the ones that most directly turn "the system feels dead and I
|
||||
don't trust it" into "it's alive and I can see and act on it."
|
||||
160
plans/2026-07-09-chat-sessions-improvements.md
Normal file
160
plans/2026-07-09-chat-sessions-improvements.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Fix concrete problems found by inspecting the *actual* production Nomos chat
|
||||
data (`agent_sessions`/`agent_messages` on the `oikos` Postgres, 24 sessions /
|
||||
52 messages as of 2026-07-09), not a code-review of the UI in the abstract.
|
||||
Findings below are backed by real rows, not hypotheticals.
|
||||
|
||||
## How this was investigated
|
||||
|
||||
Queried `oikos-postgres-1` directly (`docker exec oikos-postgres-1 psql -U oikos
|
||||
-d oikos`) since this runs on mac-mini, the same host as the production
|
||||
containers. Pulled session list, per-message content sizes, tool-call
|
||||
breakdowns, and cross-checked against `cmd/nomos/agent.go` to explain what was
|
||||
observed.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. ~17% of turns come back completely empty, silently
|
||||
|
||||
4 of 24 sessions ("hi" ×3, "what services are healthy?") have an assistant
|
||||
message with `text=""` and zero tool calls — the model returned a blank
|
||||
completion. `cmd/nomos/agent.go:175-183` treats `len(msg.ToolCalls) == 0` as a
|
||||
normal final answer and emits `text: ""` + `done`. No `error` event fires, so
|
||||
[chat.ts](../web/src/lib/stores/chat.ts) never sets `$error`, and
|
||||
[Chat.svelte](../web/src/pages/Chat.svelte) renders a permanently-blank
|
||||
assistant bubble (the "…" typing dots only show while `$streaming` is true;
|
||||
once `done` fires they vanish, leaving nothing). The user has no idea the turn
|
||||
failed and no obvious way to retry — they have to notice the silence and
|
||||
retype.
|
||||
|
||||
**Fix:** in `agent.chat()`, if `msg.Content == "" && len(msg.ToolCalls) == 0`,
|
||||
treat it as a retryable failure: log it, retry once against the provider
|
||||
before giving up, and if still empty, emit a real `error` event instead of an
|
||||
empty `text`/`done` pair. On the frontend, surface a "Nomos didn't respond —
|
||||
retry?" affordance on empty assistant messages rather than a silent blank
|
||||
bubble.
|
||||
|
||||
### 2. A tool-heavy turn came back as a canned non-English refusal
|
||||
|
||||
The session "what are the termals of hubris?" ran 22 tool calls and then
|
||||
returned, verbatim: `关于这个问题,我没有相关信息,您可以尝试问我其它问题,我会尽力为您解答~`
|
||||
("I don't have relevant information on this, try asking me something else").
|
||||
This is after successfully gathering data via tools — the model discarded its
|
||||
own tool results and emitted a boilerplate deflection in the wrong language.
|
||||
`NOMOS_MODEL` is currently a DeepSeek flash-tier model on OpenRouter, which is
|
||||
consistent with this kind of degraded-tier fallback text leaking through.
|
||||
|
||||
**Fix:** add a response-quality guard in `agent.chat()` — if the final text
|
||||
doesn't match the conversation's language/looks like a canned refusal (simple
|
||||
heuristic: non-ASCII-majority reply to an ASCII-majority conversation, or
|
||||
matches a small denylist of known refusal boilerplate), treat it like the
|
||||
empty-response case (retry, then surface an error rather than showing it to
|
||||
the operator as a real answer). Separately, reconsider whether the flash-tier
|
||||
model is worth the latency/cost tradeoff given it's producing failures like
|
||||
this in a small sample — worth an eval pass against a couple of alternative
|
||||
OpenRouter models on the same 24 real prompts before deciding.
|
||||
|
||||
### 3. Simple fleet questions fan out into dozens of individual tool calls
|
||||
|
||||
"Are any of the proxmox hosts saturated?" (2 hosts) triggered **70 tool
|
||||
calls** in one turn, 21 of them individual `get_lxc_state` calls — one per LXC
|
||||
container — instead of using the already-available `list_lxcs()` bulk tool.
|
||||
Similar pattern in "What should be updated with high priority?" (68 calls) and
|
||||
"What needs updating?" (54 calls). Each `get_lxc_state` is a live `pct status`
|
||||
SSH round-trip to the Proxmox host, so this is 21 sequential SSH round trips
|
||||
to answer a question `list_lxcs()` already answers in one call. This is the
|
||||
direct cause of both slow responses and the huge persisted payloads in
|
||||
finding 4.
|
||||
|
||||
**Fix:** two angles, not mutually exclusive:
|
||||
- **Prompt-level**: tighten the Nomos system prompt (`nomos/SOUL.md`) to
|
||||
explicitly prefer bulk tools (`list_lxcs`, `get_state_snapshot`,
|
||||
`query_metrics`) over per-entity tools when the question is fleet-wide, and
|
||||
only fall back to `get_lxc_state`/`tail_log` for a specific named entity.
|
||||
- **Tool-level**: `get_lxc_state` already exists per-slug; consider whether
|
||||
`list_lxcs()`'s summary is actually sufficient for "saturated" (CPU/mem %
|
||||
per container) — if it's missing that field, that's *why* the model loops
|
||||
per-container, and the real fix is enriching `list_lxcs()` rather than
|
||||
prompting around the gap.
|
||||
|
||||
### 4. Tool results are persisted raw and unbounded, inflating messages to 100KB+
|
||||
|
||||
Message content sizes in `agent_messages.content` (JSONB) range up to
|
||||
**106KB** for a single assistant turn. Even a plain "hi" greeting produced a
|
||||
44KB message, because `get_state_snapshot()`'s full result — every entity in
|
||||
the DB, including ~15 `document:containers/*` rows that are all
|
||||
`state: <nil>, health: unknown` and contribute nothing — gets embedded
|
||||
verbatim in the `tool_calls[].result` field and stored as-is
|
||||
(`cmd/nomos/store.go:68-76` just JSON-inserts whatever the tool returned).
|
||||
This bloats the DB, and every time a session is opened via
|
||||
[loadSessionMessages](../web/src/lib/stores/chat.ts#L56) or the
|
||||
[SessionRail](../web/src/lib/components/SessionRail.svelte)/
|
||||
[Sessions](../web/src/pages/Sessions.svelte) page loads history, the browser
|
||||
downloads and parses all of it just to render a collapsed tool-call summary.
|
||||
|
||||
**Fix:**
|
||||
- Filter `get_state_snapshot()`'s result server-side (in the MCP tool, not
|
||||
the agent) to drop entities with no meaningful state/health signal, or add
|
||||
a `type` filter param the agent can pass.
|
||||
- In `store.saveMessage`, cap persisted tool-result size (e.g. truncate to a
|
||||
few KB with a `"...truncated, N bytes"` marker) — the full result already
|
||||
served its purpose informing that turn's answer; historical replay
|
||||
(`agent.chat()`'s history-replay loop at `agent.go:122-137`) doesn't need
|
||||
the full blob, just enough for the model to know what it already checked.
|
||||
|
||||
### 5. No session hygiene: duplicate/typo'd titles, no delete/archive
|
||||
|
||||
Session titles are the raw, unprocessed first user message
|
||||
(`store.createSession`), with no dedup, normalization, or cleanup. Real
|
||||
production titles include **6 sessions titled "hi"**, **2 titled "say ok"**,
|
||||
and typos preserved verbatim ("what are the **termals** of hubris?", "whats
|
||||
the **termans** of strong", "Are any of the **proxomox** hosts saturated?").
|
||||
Neither [Sessions.svelte](../web/src/pages/Sessions.svelte) nor
|
||||
[SessionRail.svelte](../web/src/lib/components/SessionRail.svelte) nor the
|
||||
`store`/API layer (`cmd/nomos/store.go`, `web/src/lib/api.ts`) has any delete
|
||||
or archive path — grepped the whole stack, confirmed absent. Throwaway test
|
||||
sessions accumulate forever with no way to clean them from the UI.
|
||||
|
||||
**Fix:**
|
||||
- Add `DELETE /sessions/{id}` to the nomos gateway + a matching store method
|
||||
and wire a delete affordance into `SessionRail`/`Sessions` (hover trash
|
||||
icon, confirm on click).
|
||||
- Generate titles from the assistant's actual answer once the turn completes
|
||||
(or a cheap follow-up summarization call) instead of the raw first message,
|
||||
so distinct "hi" sessions become distinguishable by what was actually
|
||||
discussed.
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Empty-response + refusal-leak guard** (`cmd/nomos/agent.go`) — highest
|
||||
user-visible impact, smallest change, no schema/API changes.
|
||||
2. **Bulk-tool prompting fix** (`nomos/SOUL.md`) — cheap, directly cuts
|
||||
latency and tool-call volume; re-run the same 24 real prompts against the
|
||||
updated prompt to confirm `get_lxc_state` fan-out drops.
|
||||
3. **Tool-result truncation on persist** (`cmd/nomos/store.go`) — bounds
|
||||
future DB growth; pair with a one-off cleanup pass on the 52 existing rows
|
||||
if the table needs to be shrunk immediately.
|
||||
4. **`get_state_snapshot` filtering** — coordinate with whichever MCP tool
|
||||
file defines it; verify with `jsonb_pretty` on a fresh "hi" session that
|
||||
payload drops well below the current ~44KB.
|
||||
5. **Session delete + title generation** — UI + gateway change, lowest risk,
|
||||
can ship independently of 1-4.
|
||||
|
||||
## Verification
|
||||
|
||||
- Re-run the same 24 real user prompts (recorded in this plan's investigation)
|
||||
against the patched agent; confirm zero empty/refusal-leak responses and
|
||||
`get_lxc_state`-style fan-out drops to O(hosts) not O(containers).
|
||||
- `docker exec oikos-postgres-1 psql -U oikos -d oikos -c "SELECT max(length(content::text)) FROM agent_messages;"`
|
||||
before/after — expect the ceiling to move from ~106KB to low single-digit KB.
|
||||
- Manually delete a test session via the new UI affordance, confirm it's gone
|
||||
from both `SessionRail` and the `agent_sessions` table.
|
||||
@@ -13,6 +13,7 @@ went sideways, open an investigation.
|
||||
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
|
||||
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
|
||||
| 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress |
|
||||
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned |
|
||||
|
||||
## Done
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
4
web/public/favicon.svg
Normal file
4
web/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="91" height="100" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#ffffff" d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
@@ -19,10 +19,10 @@
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { Toaster } from '$lib/components/ui/sonner'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
||||
import ListIcon from '@lucide/svelte/icons/list'
|
||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity'
|
||||
@@ -71,22 +71,40 @@
|
||||
{ id: 'events', label: 'Events', icon: ActivityIcon },
|
||||
{ id: 'agent', label: 'Agent', icon: BotIcon },
|
||||
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon },
|
||||
{ id: 'sessions', label: 'Sessions', icon: ListIcon }
|
||||
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
|
||||
]
|
||||
</script>
|
||||
|
||||
<Toaster />
|
||||
|
||||
<Sidebar.Provider>
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
|
||||
<Sidebar.Root collapsible="icon" variant="inset">
|
||||
<Sidebar.Header>
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-sm font-bold text-primary">Oikos</span>
|
||||
</div>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton onclick={() => { newChat(); navigate('chat') }} tooltipContent="New chat">
|
||||
<Sidebar.MenuButton
|
||||
class="data-[slot=sidebar-menu-button]:!p-1.5"
|
||||
onclick={() => navigate('overview')}
|
||||
tooltipContent="Oikos"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<svg viewBox="0 0 91 100" class="!size-5 shrink-0 fill-white" aria-hidden="true" role="img">
|
||||
<title>Oikos</title>
|
||||
<path d="m45.601 1q20.993 0 33.71 15.946 10.799 13.625 10.799 31.287 0 12.414-5.9548 25.131-5.9548 12.717-16.451 19.176-10.395 6.4592-23.213 6.4592-20.892 0-33.205-16.653-10.395-14.029-10.395-31.489 0-12.717 6.2577-25.232 6.3584-12.616 16.653-18.57 10.295-6.0556 21.801-6.0556zm-3.128 6.5605q-5.3492 0-10.799 3.2296-5.3492 3.1287-8.68 11.102-3.3305 7.9735-3.3305 20.488 0 20.185 7.973 34.82 8.0743 14.634 21.195 14.634 9.7896 0 16.149-8.0743 6.3584-8.0743 6.3584-27.755 0-24.627-10.597-38.756-7.1657-9.6888-18.268-9.6888z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
||||
onclick={() => { newChat(); navigate('chat') }}
|
||||
tooltipContent="New chat"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<PlusIcon />
|
||||
@@ -142,11 +160,12 @@
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
|
||||
<Sidebar.Inset class="h-svh min-h-0">
|
||||
<header class="flex h-11 shrink-0 items-center gap-3 border-b px-3">
|
||||
<Sidebar.Trigger />
|
||||
<span class="text-sm font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
|
||||
<div class="flex-1"></div>
|
||||
<Sidebar.Inset class="min-h-0 overflow-hidden">
|
||||
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
|
||||
<Sidebar.Trigger class="-ms-1" />
|
||||
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
|
||||
<div class="ms-auto flex items-center gap-2.5">
|
||||
{#if $summary}
|
||||
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
|
||||
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
|
||||
@@ -168,6 +187,7 @@
|
||||
class="size-2 rounded-full {$connectionState === 'open' ? 'bg-success' : $connectionState === 'connecting' ? 'animate-pulse bg-warning' : 'bg-destructive'}"
|
||||
title="event stream: {$connectionState}"
|
||||
></span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="min-h-0 flex-1 overflow-hidden">
|
||||
{#if page === 'overview'}
|
||||
|
||||
@@ -2,59 +2,61 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Neutral gray theme matching shadcn/ui's canonical dark palette (0-chroma
|
||||
OKLCH grays) — the app is dark-only, so :root carries the dark values
|
||||
directly rather than gating behind a .dark class. --success/--warning are
|
||||
Oikos-specific semantic status colors (real health state), kept
|
||||
distinguishable rather than desaturated to match the neutral chrome. */
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--background: #0d1117;
|
||||
--foreground: #e6edf3;
|
||||
--card: #161b22;
|
||||
--card-foreground: #e6edf3;
|
||||
--popover: #161b22;
|
||||
--popover-foreground: #e6edf3;
|
||||
--primary: #58a6ff;
|
||||
--primary-foreground: #0d1117;
|
||||
--secondary: #21262d;
|
||||
--secondary-foreground: #e6edf3;
|
||||
--muted: #21262d;
|
||||
--muted-foreground: #8b949e;
|
||||
--accent: #292e36;
|
||||
--accent-foreground: #e6edf3;
|
||||
--destructive: #f85149;
|
||||
--destructive-foreground: #ffffff;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--border: #30363d;
|
||||
--input: #30363d;
|
||||
--ring: #58a6ff;
|
||||
--sidebar: #161b22;
|
||||
--sidebar-foreground: #e6edf3;
|
||||
--sidebar-primary: #58a6ff;
|
||||
--sidebar-primary-foreground: #0d1117;
|
||||
--sidebar-accent: #21262d;
|
||||
--sidebar-accent-foreground: #e6edf3;
|
||||
--sidebar-border: #30363d;
|
||||
--sidebar-ring: #58a6ff;
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
/* legacy aliases still referenced by Chat/Sessions/App */
|
||||
--bg: var(--background);
|
||||
--bg-surface: var(--card);
|
||||
--bg-deeper: #0a0e13;
|
||||
--bg-deeper: oklch(0.11 0 0);
|
||||
--bg-hover: var(--secondary);
|
||||
--bg-active: var(--accent);
|
||||
--text: var(--foreground);
|
||||
--text-muted: var(--muted-foreground);
|
||||
--accent-blue: var(--primary);
|
||||
/* accent-blue stays a real blue (matches --sidebar-primary) for the few
|
||||
spots that want an interactive "pop" — everything else (buttons,
|
||||
links, focus rings) rides the neutral --primary now. */
|
||||
--accent-blue: var(--sidebar-primary);
|
||||
--accent-green: var(--success);
|
||||
--accent-red: var(--destructive);
|
||||
--accent-orange: var(--warning);
|
||||
}
|
||||
|
||||
/* the app is dark-only; treat root as the dark theme unconditionally */
|
||||
.dark {
|
||||
--background: #0d1117;
|
||||
--foreground: #e6edf3;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
@@ -105,6 +107,16 @@
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* Tailwind's preflight resets <button> to cursor: default; every button
|
||||
and native interactive element in this app is clickable, so restore
|
||||
the pointer cursor app-wide instead of annotating each one. */
|
||||
button:not(:disabled),
|
||||
[role='button']:not([aria-disabled='true']),
|
||||
a[href],
|
||||
summary,
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
@@ -108,6 +108,8 @@ export async function fetchDashboardSummary(): Promise<DashboardSummary | null>
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export type EntityHealth = 'healthy' | 'degraded' | 'down' | 'unknown' | 'stale'
|
||||
|
||||
export interface Entity {
|
||||
id: string
|
||||
slug: string
|
||||
@@ -118,6 +120,8 @@ export interface Entity {
|
||||
version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
health?: EntityHealth | null
|
||||
last_check_at?: string | null
|
||||
}
|
||||
|
||||
export interface EntityFilters {
|
||||
@@ -396,6 +400,38 @@ export async function fetchEntityExecutions(entityId: string): Promise<Execution
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export interface Check {
|
||||
id: string
|
||||
slug: string
|
||||
kind: 'http' | 'tcp' | 'disk' | 'cert-expiry' | 'drift' | 'ping' | 'ssh-script'
|
||||
target?: string | null
|
||||
target_type?: string | null
|
||||
config?: Record<string, unknown>
|
||||
interval_s: number
|
||||
timeout_s: number
|
||||
zone?: string | null
|
||||
enabled: boolean
|
||||
version: number
|
||||
}
|
||||
|
||||
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {
|
||||
const params = new URLSearchParams({ target: targetSlug, limit: '50' })
|
||||
const res = await fetch(`${API}/checks?${params}`)
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return data.items ?? []
|
||||
}
|
||||
|
||||
export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> {
|
||||
const res = await fetch(`${API}/checks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'If-Match': `"${version}"` },
|
||||
body: JSON.stringify(patch)
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
id: number
|
||||
ts: string
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { summary, pendingApprovals, subscribeContext, refreshContext } from '$lib/stores/context'
|
||||
import { liveEvents } from '$lib/stores/events'
|
||||
import { decideApproval } from '$lib/api'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
let deciding = $state<string | null>(null)
|
||||
|
||||
onMount(() => subscribeContext())
|
||||
|
||||
async function decide(id: string, decision: 'approve' | 'deny') {
|
||||
deciding = id
|
||||
const result = await decideApproval(id, decision)
|
||||
deciding = null
|
||||
if (result) {
|
||||
toast.success(`Approval ${decision === 'approve' ? 'approved' : 'denied'}`)
|
||||
refreshContext()
|
||||
} else {
|
||||
toast.error('Decision failed')
|
||||
}
|
||||
}
|
||||
|
||||
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (sev === 'critical') return 'destructive'
|
||||
if (sev === 'warning') return 'secondary'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const recentEvents = $derived($liveEvents.slice(0, 10))
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full w-72 shrink-0 flex-col gap-3 overflow-y-auto border-l bg-card/50 p-3">
|
||||
{#if $summary}
|
||||
<div>
|
||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Fleet health</p>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
|
||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
|
||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
|
||||
<span class="flex items-center gap-1"><span class="size-2 rounded-full bg-muted-foreground"></span>{$summary.health.unknown}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Pending approvals</p>
|
||||
{#if $pendingApprovals.length}
|
||||
<Badge variant="destructive" class="h-4 px-1.5 text-[10px]">{$pendingApprovals.length}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each $pendingApprovals.slice(0, 5) as approval (approval.id)}
|
||||
<div class="rounded-md border bg-background p-2">
|
||||
<p class="truncate font-mono text-[11px]">{approval.subject ?? approval.slug}</p>
|
||||
<p class="mb-1.5 text-xs">{approval.action} <Badge variant="outline" class="ml-1 h-4 px-1 text-[10px]">{approval.risk_class}</Badge></p>
|
||||
<div class="flex gap-1.5">
|
||||
<Button size="sm" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'approve')}>Approve</Button>
|
||||
<Button size="sm" variant="destructive" class="h-6 flex-1 text-xs" disabled={deciding === approval.id} onclick={() => decide(approval.id, 'deny')}>Deny</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">Nothing waiting on you.</p>
|
||||
{/each}
|
||||
{#if $pendingApprovals.length > 5}
|
||||
<button type="button" class="text-left text-xs text-primary hover:underline" onclick={() => (location.hash = '#/ops')}>
|
||||
+{$pendingApprovals.length - 5} more in Operations
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if $summary && Object.keys($summary.signals_by_severity).length}
|
||||
<Separator />
|
||||
<div>
|
||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Open signals</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each Object.entries($summary.signals_by_severity) as [severity, count]}
|
||||
<button type="button" onclick={() => (location.hash = '#/signals')}>
|
||||
<Badge variant={severityVariant(severity)} class="cursor-pointer">{severity}: {count}</Badge>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Separator />
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Live events</p>
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div class="flex flex-col gap-1.5 pr-2">
|
||||
{#each recentEvents as ev (ev.id)}
|
||||
<div class="text-[11px] leading-tight">
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
|
||||
<span class="ml-1 {ev.severity === 'critical' ? 'text-destructive' : ev.severity === 'warning' ? 'text-warning' : ''}">{ev.type}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">Quiet for now.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</aside>
|
||||
297
web/src/lib/components/EntityDetailContent.svelte
Normal file
297
web/src/lib/components/EntityDetailContent.svelte
Normal file
@@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte'
|
||||
import uPlot from 'uplot'
|
||||
import 'uplot/dist/uPlot.min.css'
|
||||
import {
|
||||
fetchEntity,
|
||||
fetchGraph,
|
||||
fetchMetrics,
|
||||
fetchEntityEvents,
|
||||
fetchEntitySignals,
|
||||
fetchEntityExecutions,
|
||||
fetchEntityKnowledge,
|
||||
fetchChecksForTarget,
|
||||
patchCheck,
|
||||
type Entity,
|
||||
type Relationship,
|
||||
type MetricSeries,
|
||||
type Signal,
|
||||
type Execution,
|
||||
type KnowledgeHit,
|
||||
type Check
|
||||
} from '$lib/api'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import type { OikosEvent } from '$lib/stores/events'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import { toast } from 'svelte-sonner'
|
||||
|
||||
let { slug }: { slug: string } = $props()
|
||||
|
||||
let entity = $state<Entity | null>(null)
|
||||
let relations = $state<Relationship[]>([])
|
||||
let metrics = $state<MetricSeries[]>([])
|
||||
let events = $state<OikosEvent[]>([])
|
||||
let signals = $state<Signal[]>([])
|
||||
let executions = $state<Execution[]>([])
|
||||
let knowledge = $state<KnowledgeHit[]>([])
|
||||
let checks = $state<Check[]>([])
|
||||
let loading = $state(true)
|
||||
let chartContainers: Record<string, HTMLDivElement> = {}
|
||||
|
||||
async function load(s: string) {
|
||||
loading = true
|
||||
entity = await fetchEntity(s)
|
||||
if (!entity) {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
const [graphView, m, ev, sig, exec, kh, ch] = await Promise.all([
|
||||
fetchGraph({ root: entity.id, depth: 1 }),
|
||||
fetchMetrics(entity.id),
|
||||
fetchEntityEvents(entity.id),
|
||||
fetchEntitySignals(entity.id),
|
||||
fetchEntityExecutions(entity.id),
|
||||
fetchEntityKnowledge(entity.id),
|
||||
fetchChecksForTarget(entity.slug)
|
||||
])
|
||||
relations = graphView?.edges ?? []
|
||||
metrics = m
|
||||
events = ev
|
||||
signals = sig
|
||||
executions = exec
|
||||
knowledge = kh
|
||||
checks = ch
|
||||
loading = false
|
||||
|
||||
await tick()
|
||||
renderCharts()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load(slug)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load(slug)
|
||||
})
|
||||
|
||||
function renderCharts() {
|
||||
for (const series of metrics) {
|
||||
const el = chartContainers[series.metric]
|
||||
if (!el) continue
|
||||
el.innerHTML = ''
|
||||
const xs = series.samples.map((s) => new Date(s.ts).getTime() / 1000)
|
||||
const ys = series.samples.map((s) => s.value ?? s.avg ?? null)
|
||||
new uPlot(
|
||||
{
|
||||
width: el.clientWidth || 400,
|
||||
height: 160,
|
||||
series: [{}, { label: series.metric, stroke: '#58a6ff', width: 2 }],
|
||||
axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
|
||||
scales: { x: { time: true } },
|
||||
legend: { show: false }
|
||||
},
|
||||
[xs, ys],
|
||||
el
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (sev === 'critical') return 'destructive'
|
||||
if (sev === 'warning') return 'secondary'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const healthDot: Record<string, string> = {
|
||||
healthy: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
stale: 'bg-warning/50',
|
||||
unknown: 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
async function toggleCheck(check: Check) {
|
||||
const updated = await patchCheck(check.id, check.version, { enabled: !check.enabled })
|
||||
if (updated) {
|
||||
checks = checks.map((c) => (c.id === check.id ? updated : c))
|
||||
toast.success(`${check.slug} ${updated.enabled ? 'enabled' : 'disabled'}`)
|
||||
} else {
|
||||
toast.error('Failed to update check')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="@container flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
|
||||
{#if loading}
|
||||
<Skeleton class="h-8 w-48" />
|
||||
<div class="grid grid-cols-1 gap-4 @lg:grid-cols-2">
|
||||
<Skeleton class="h-40 w-full" />
|
||||
<Skeleton class="h-40 w-full" />
|
||||
</div>
|
||||
{:else if !entity}
|
||||
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
|
||||
<Badge variant="outline">{entity.type}</Badge>
|
||||
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5 text-xs text-muted-foreground" title="{entity.health} — checked {relativeTime(entity.last_check_at)}">
|
||||
<span class="size-2 rounded-full {healthDot[entity.health] ?? healthDot.unknown}"></span>
|
||||
{entity.health} · checked {relativeTime(entity.last_check_at)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Monitoring ({checks.length})</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1.5">
|
||||
{#each checks as check (check.id)}
|
||||
<div class="flex items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="font-mono">{check.kind}</Badge>
|
||||
<span class="text-muted-foreground">every {check.interval_s}s</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer"
|
||||
onclick={() => toggleCheck(check)}
|
||||
title={check.enabled ? 'Click to disable' : 'Click to enable'}
|
||||
>
|
||||
<Badge variant={check.enabled ? 'default' : 'secondary'}>{check.enabled ? 'enabled' : 'disabled'}</Badge>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No checks configured for this entity.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Attributes</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if entity.attributes && Object.keys(entity.attributes).length}
|
||||
<dl class="flex flex-col gap-1.5 text-xs">
|
||||
{#each Object.entries(entity.attributes) as [key, value]}
|
||||
<div class="flex items-start justify-between gap-3 border-b pb-1.5 last:border-0">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
|
||||
<dd class="min-w-0 flex-1 truncate text-right">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No attributes.</p>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Relations ({relations.length})</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each relations as rel}
|
||||
<div class="flex items-center gap-1 font-mono text-xs">
|
||||
<span>{rel.source}</span>
|
||||
<span class="text-muted-foreground">—{rel.type}→</span>
|
||||
<span>{rel.target}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
{#if metrics.length}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Metrics</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
{#each metrics as series (series.metric)}
|
||||
<div>
|
||||
<p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
|
||||
<div bind:this={chartContainers[series.metric]}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Open signals</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each signals as signal (signal.id)}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span>{signal.kind}</span>
|
||||
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Executions</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each executions as execution (execution.id)}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span>{execution.action}</span>
|
||||
<Badge variant="outline">{execution.status}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Knowledge</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each knowledge as hit (hit.id)}
|
||||
<div class="text-xs">
|
||||
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None linked.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Recent events</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each events as ev (ev.id)}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
||||
<span>{ev.type}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No events yet.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
18
web/src/lib/components/EntitySheet.svelte
Normal file
18
web/src/lib/components/EntitySheet.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
||||
import * as Sheet from '$lib/components/ui/sheet'
|
||||
|
||||
let { slug, open = $bindable(false) }: { slug: string | null; open?: boolean } = $props()
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open>
|
||||
<Sheet.Content side="right" class="w-full p-0 sm:max-w-2xl">
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>{slug ?? 'Entity detail'}</Sheet.Title>
|
||||
<Sheet.Description>Entity detail panel</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
{#if slug}
|
||||
<EntityDetailContent {slug} />
|
||||
{/if}
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
441
web/src/lib/components/SessionGraph.svelte
Normal file
441
web/src/lib/components/SessionGraph.svelte
Normal file
@@ -0,0 +1,441 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from 'svelte'
|
||||
import {
|
||||
forceSimulation,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceCenter,
|
||||
forceCollide,
|
||||
forceX,
|
||||
forceY,
|
||||
type Simulation
|
||||
} from 'd3-force'
|
||||
import { fetchGraph, type Entity } from '$lib/api'
|
||||
import { messages } from '$lib/stores/chat'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
||||
|
||||
interface Node extends Entity {
|
||||
x?: number
|
||||
y?: number
|
||||
vx?: number
|
||||
vy?: number
|
||||
fx?: number | null
|
||||
fy?: number | null
|
||||
degree: number
|
||||
}
|
||||
interface Edge {
|
||||
source: string | Node
|
||||
target: string | Node
|
||||
type: string
|
||||
}
|
||||
|
||||
// Probe/bookkeeping entity types are excluded — a health conversation
|
||||
// mentions dozens of check:… slugs that would swamp the fleet topology.
|
||||
const EXCLUDED = new Set(['check', 'execution'])
|
||||
|
||||
// Slug shape: lowercase type prefix, then one or more colon-separated
|
||||
// segments (host:hubris, check:ping:8cf, lxc:caddy, investigation:foo/bar).
|
||||
const SLUG_RE = /\b[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*/g
|
||||
|
||||
let nodes = $state<Node[]>([])
|
||||
let links = $state<Edge[]>([])
|
||||
let selected = $state<Node | null>(null)
|
||||
let sheetSlug = $state<string | null>(null)
|
||||
let sheetOpen = $state(false)
|
||||
|
||||
let sim: Simulation<Node, Edge> | null = null
|
||||
|
||||
// Non-reactive caches (persist across message deltas). resolvedVersion is a
|
||||
// reactive counter bumped when async resolution finishes, so the reconcile
|
||||
// effect re-runs once entities come back.
|
||||
const resolvedCache = new Map<string, Node | null>()
|
||||
const edgeCache: { source: string; target: string; type: string }[] = []
|
||||
const edgeKeys = new Set<string>()
|
||||
const resolving = new Set<string>()
|
||||
let resolvedVersion = $state(0)
|
||||
|
||||
// container size drives the simulation coordinate space (1:1 with pixels so
|
||||
// node dragging maps cleanly regardless of the resizable panel width).
|
||||
let container = $state<HTMLDivElement | null>(null)
|
||||
let cw = $state(300)
|
||||
let ch = $state(300)
|
||||
|
||||
function collectSlugs(value: unknown, out: Set<string>) {
|
||||
if (typeof value === 'string') {
|
||||
const m = value.match(SLUG_RE)
|
||||
if (m) for (const s of m) out.add(s.replace(/[.,;)\]]+$/, ''))
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) collectSlugs(v, out)
|
||||
} else if (value && typeof value === 'object') {
|
||||
for (const v of Object.values(value)) collectSlugs(v, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Only pull from what the conversation is *about*: message text and the
|
||||
// arguments the agent passed to tools — never bulk result rows (a single
|
||||
// get_health_summary would otherwise dump all 168 entities into the graph).
|
||||
const candidateSlugs = $derived.by(() => {
|
||||
const out = new Set<string>()
|
||||
for (const m of $messages) {
|
||||
collectSlugs(m.text, out)
|
||||
for (const t of m.tools) collectSlugs(t.args, out)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
async function resolveSlugs(slugs: string[]) {
|
||||
const todo = slugs.filter((s) => !resolvedCache.has(s) && !resolving.has(s))
|
||||
if (!todo.length) return
|
||||
for (const s of todo) resolving.add(s)
|
||||
await Promise.all(
|
||||
todo.map(async (s) => {
|
||||
try {
|
||||
const g = await fetchGraph({ root: s, depth: 1 })
|
||||
const root = g?.nodes.find((n) => n.slug === s) ?? null
|
||||
resolvedCache.set(s, root ? { ...root, degree: 0 } : null)
|
||||
if (g && root) {
|
||||
for (const e of g.edges) {
|
||||
const k = `${e.source}|${e.target}|${e.type}`
|
||||
if (!edgeKeys.has(k)) {
|
||||
edgeKeys.add(k)
|
||||
edgeCache.push({ source: e.source, target: e.target, type: e.type })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
resolvedCache.set(s, null)
|
||||
} finally {
|
||||
resolving.delete(s)
|
||||
}
|
||||
})
|
||||
)
|
||||
resolvedVersion++
|
||||
}
|
||||
|
||||
function reconcile(cands: Set<string>) {
|
||||
const desired: Node[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const s of cands) {
|
||||
const e = resolvedCache.get(s)
|
||||
if (e && !EXCLUDED.has(e.type) && !seen.has(e.slug)) {
|
||||
seen.add(e.slug)
|
||||
desired.push(e)
|
||||
}
|
||||
}
|
||||
const desiredSlugs = new Set(desired.map((e) => e.slug))
|
||||
const current = nodes
|
||||
const curSlugs = new Set(current.map((n) => n.slug))
|
||||
|
||||
let changed = desiredSlugs.size !== curSlugs.size
|
||||
if (!changed) for (const s of desiredSlugs) if (!curSlugs.has(s)) { changed = true; break }
|
||||
if (!changed) return
|
||||
|
||||
const bySlug = new Map(current.map((n) => [n.slug, n]))
|
||||
const ls = edgeCache
|
||||
.filter((e) => desiredSlugs.has(e.source) && desiredSlugs.has(e.target))
|
||||
.map((e) => ({ ...e }))
|
||||
|
||||
const deg = new Map<string, number>()
|
||||
for (const l of ls) {
|
||||
deg.set(l.source as string, (deg.get(l.source as string) ?? 0) + 1)
|
||||
deg.set(l.target as string, (deg.get(l.target as string) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const next = desired.map((e) => {
|
||||
const p = bySlug.get(e.slug)
|
||||
return { ...e, x: p?.x, y: p?.y, vx: p?.vx, vy: p?.vy, degree: deg.get(e.slug) ?? 0 }
|
||||
})
|
||||
|
||||
nodes = next
|
||||
links = ls
|
||||
if (selected && !desiredSlugs.has(selected.slug)) selected = null
|
||||
buildSim()
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const cands = candidateSlugs
|
||||
void resolvedVersion
|
||||
const missing = [...cands].filter((s) => !resolvedCache.has(s))
|
||||
if (missing.length) resolveSlugs(missing)
|
||||
untrack(() => reconcile(cands))
|
||||
})
|
||||
|
||||
function buildSim() {
|
||||
sim?.stop()
|
||||
if (!nodes.length) {
|
||||
sim = null
|
||||
return
|
||||
}
|
||||
sim = forceSimulation(nodes)
|
||||
.force('link', forceLink<Node, Edge>(links).id((n) => n.slug).distance(48).strength(0.5))
|
||||
.force('charge', forceManyBody().strength(-150).distanceMax(240))
|
||||
.force('center', forceCenter(cw / 2, ch / 2))
|
||||
.force('collide', forceCollide<Node>((n) => nodeRadius(n) + 6))
|
||||
.force('x', forceX(cw / 2).strength(0.06))
|
||||
.force('y', forceY(ch / 2).strength(0.06))
|
||||
.velocityDecay(0.34)
|
||||
.alphaDecay(0.045)
|
||||
.on('tick', () => {
|
||||
nodes = [...nodes]
|
||||
})
|
||||
}
|
||||
|
||||
// keep the layout centred as the panel resizes
|
||||
$effect(() => {
|
||||
const w = cw
|
||||
const h = ch
|
||||
if (sim) {
|
||||
sim.force('center', forceCenter(w / 2, h / 2))
|
||||
sim.force('x', forceX(w / 2).strength(0.06))
|
||||
sim.force('y', forceY(h / 2).strength(0.06))
|
||||
sim.alpha(0.3).restart()
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!container) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const r = entries[0].contentRect
|
||||
cw = Math.max(r.width, 1)
|
||||
ch = Math.max(r.height, 1)
|
||||
})
|
||||
ro.observe(container)
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
|
||||
onDestroy(() => sim?.stop())
|
||||
|
||||
const healthColor: Record<string, string> = {
|
||||
healthy: 'var(--success)',
|
||||
degraded: 'var(--warning)',
|
||||
down: 'var(--destructive)',
|
||||
stale: 'var(--warning)',
|
||||
unknown: 'var(--muted-foreground)'
|
||||
}
|
||||
function nodeColor(n: Node): string {
|
||||
return n.health ? healthColor[n.health] ?? 'var(--muted-foreground)' : 'var(--muted-foreground)'
|
||||
}
|
||||
function nodeRadius(n: Node): number {
|
||||
return 6 + Math.min(Math.sqrt(n.degree) * 1.5, 6)
|
||||
}
|
||||
function shortName(slug: string): string {
|
||||
return slug.split(':').pop() ?? slug
|
||||
}
|
||||
|
||||
function endpoint(end: string | Node): Node | undefined {
|
||||
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
|
||||
}
|
||||
function endpointSlug(end: string | Node): string {
|
||||
return typeof end === 'object' ? end.slug : end
|
||||
}
|
||||
|
||||
// ─── drag / select ───────────────────────────────────────────────────
|
||||
let dragState: { node: Node; moved: boolean } | null = null
|
||||
|
||||
function toLocal(clientX: number, clientY: number) {
|
||||
const rect = container!.getBoundingClientRect()
|
||||
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||
}
|
||||
|
||||
function onNodeDown(e: PointerEvent, node: Node) {
|
||||
e.stopPropagation()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
dragState = { node, moved: false }
|
||||
sim?.alphaTarget(0.2).restart()
|
||||
}
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!dragState) return
|
||||
const p = toLocal(e.clientX, e.clientY)
|
||||
dragState.node.fx = p.x
|
||||
dragState.node.fy = p.y
|
||||
dragState.moved = true
|
||||
nodes = [...nodes]
|
||||
}
|
||||
function onUp() {
|
||||
if (!dragState) return
|
||||
const { node, moved } = dragState
|
||||
node.fx = null
|
||||
node.fy = null
|
||||
sim?.alphaTarget(0)
|
||||
dragState = null
|
||||
if (!moved) selected = selected?.slug === node.slug ? null : node
|
||||
}
|
||||
|
||||
const selectedRelations = $derived(
|
||||
selected
|
||||
? links
|
||||
.filter((l) => endpointSlug(l.source) === selected!.slug || endpointSlug(l.target) === selected!.slug)
|
||||
.map((l) => {
|
||||
const outgoing = endpointSlug(l.source) === selected!.slug
|
||||
return { dir: outgoing ? '→' : '←', type: l.type, other: outgoing ? endpointSlug(l.target) : endpointSlug(l.source) }
|
||||
})
|
||||
: []
|
||||
)
|
||||
|
||||
function openFull() {
|
||||
if (!selected) return
|
||||
sheetSlug = selected.slug
|
||||
sheetOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
||||
<div class="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Session graph</p>
|
||||
{#if nodes.length}
|
||||
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
||||
{#if nodes.length === 0}
|
||||
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
|
||||
<svg viewBox="0 0 120 120" class="size-24 text-muted-foreground/40" fill="none">
|
||||
<circle cx="60" cy="60" r="6" fill="currentColor">
|
||||
<animate attributeName="opacity" values="0.4;1;0.4" dur="2.4s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<g stroke="currentColor" stroke-width="1" opacity="0.5">
|
||||
<line x1="60" y1="60" x2="26" y2="34"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="96" y2="40"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.4s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="34" y2="92"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="2.8s" repeatCount="indefinite" /></line>
|
||||
<line x1="60" y1="60" x2="92" y2="90"><animate attributeName="opacity" values="0.1;0.5;0.1" dur="3.1s" repeatCount="indefinite" /></line>
|
||||
</g>
|
||||
<g fill="currentColor">
|
||||
<circle cx="26" cy="34" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="96" cy="40" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.4s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="34" cy="92" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="2.8s" repeatCount="indefinite" /></circle>
|
||||
<circle cx="92" cy="90" r="3.5"><animate attributeName="opacity" values="0.2;0.7;0.2" dur="3.1s" repeatCount="indefinite" /></circle>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="max-w-[16rem] text-xs leading-relaxed text-muted-foreground">
|
||||
Entities Nomos explores in this conversation appear here, wired up by their relationships.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<svg
|
||||
width={cw}
|
||||
height={ch}
|
||||
viewBox="0 0 {cw} {ch}"
|
||||
class="h-full w-full touch-none select-none"
|
||||
role="application"
|
||||
aria-label="Session entity graph"
|
||||
onpointermove={onMove}
|
||||
onpointerup={onUp}
|
||||
onpointercancel={onUp}
|
||||
>
|
||||
<g>
|
||||
{#each links as link}
|
||||
{@const s = endpoint(link.source)}
|
||||
{@const t = endpoint(link.target)}
|
||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
|
||||
{@const focus = selected && (s.slug === selected.slug || t.slug === selected.slug)}
|
||||
<line
|
||||
x1={s.x}
|
||||
y1={s.y}
|
||||
x2={t.x}
|
||||
y2={t.y}
|
||||
stroke="var(--muted-foreground)"
|
||||
stroke-width={focus ? 1.6 : 1}
|
||||
opacity={selected ? (focus ? 0.7 : 0.12) : 0.35}
|
||||
>
|
||||
<title>{link.type}</title>
|
||||
</line>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
<g>
|
||||
{#each nodes as node (node.slug)}
|
||||
{#if node.x != null && node.y != null}
|
||||
{@const r = nodeRadius(node)}
|
||||
{@const isSel = selected?.slug === node.slug}
|
||||
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
||||
<g
|
||||
transform="translate({node.x},{node.y})"
|
||||
class="cursor-pointer"
|
||||
opacity={dim ? 0.35 : 1}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => onNodeDown(e, node)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (selected = node)}
|
||||
>
|
||||
{#if isSel}
|
||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||
{/if}
|
||||
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
|
||||
<text
|
||||
y={r + 10}
|
||||
text-anchor="middle"
|
||||
font-size="9"
|
||||
fill={isSel ? 'var(--foreground)' : 'var(--muted-foreground)'}
|
||||
paint-order="stroke"
|
||||
stroke="var(--background)"
|
||||
stroke-width="2.5"
|
||||
class="pointer-events-none"
|
||||
>
|
||||
{shortName(node.slug)}
|
||||
</text>
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selected}
|
||||
<div class="max-h-[55%] shrink-0 space-y-3 overflow-y-auto border-t p-3 text-xs">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="font-mono text-sm font-semibold">{selected.slug}</span>
|
||||
<Badge variant="outline">{selected.type}</Badge>
|
||||
{#if selected.state}<Badge variant="secondary">{selected.state}</Badge>{/if}
|
||||
</div>
|
||||
{#if selected.health}
|
||||
<div class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span class="size-2 rounded-full" style="background: {nodeColor(selected)}"></span>
|
||||
{selected.health} · checked {relativeTime(selected.last_check_at)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selected.attributes && Object.keys(selected.attributes).length}
|
||||
<div>
|
||||
<p class="mb-1 font-medium text-muted-foreground">Attributes</p>
|
||||
<dl class="flex flex-col gap-1">
|
||||
{#each Object.entries(selected.attributes).slice(0, 6) as [key, value]}
|
||||
<div class="flex items-start justify-between gap-3 border-b pb-1 last:border-0">
|
||||
<dt class="shrink-0 font-mono text-muted-foreground">{key}</dt>
|
||||
<dd class="min-w-0 flex-1 truncate text-right">{typeof value === 'object' ? JSON.stringify(value) : String(value)}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedRelations.length}
|
||||
<div>
|
||||
<p class="mb-1 font-medium text-muted-foreground">Relations ({selectedRelations.length})</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each selectedRelations as rel}
|
||||
<div class="flex items-center gap-1 font-mono">
|
||||
<span class="text-muted-foreground">{rel.dir} {rel.type} →</span>
|
||||
<button type="button" class="truncate hover:underline" onclick={() => { const n = nodes.find((x) => x.slug === rel.other); if (n) selected = n }}>
|
||||
{rel.other}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button variant="outline" size="sm" class="w-full" onclick={openFull}>
|
||||
<ExternalLinkIcon class="mr-1 size-3.5" />
|
||||
Full detail
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />
|
||||
41
web/src/lib/components/SessionRail.svelte
Normal file
41
web/src/lib/components/SessionRail.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat } from '$lib/stores/chat'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||
|
||||
onMount(() => {
|
||||
loadSessions()
|
||||
})
|
||||
|
||||
// Pick up sessions created/renamed elsewhere (e.g. after a turn completes).
|
||||
$effect(() => {
|
||||
void $currentSession
|
||||
loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => newChat()}>
|
||||
<PlusIcon class="size-3.5" />
|
||||
New chat
|
||||
</Button>
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div class="flex flex-col gap-1 pr-2">
|
||||
{#each $sessions as session (session.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
||||
onclick={() => loadSessionMessages(session.id)}
|
||||
>
|
||||
<span class="w-full truncate font-medium">{session.title || 'Untitled'}</span>
|
||||
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="px-2 py-4 text-center text-xs text-muted-foreground">No sessions yet.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
79
web/src/lib/components/ToolCallGroup.svelte
Normal file
79
web/src/lib/components/ToolCallGroup.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import type { ToolCallResult } from '$lib/stores/chat'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||
|
||||
// active = this message is the one currently streaming a round of tool
|
||||
// calls. The group starts open while active (so progress is visible live)
|
||||
// and auto-collapses the moment that round finishes; a loaded/historical
|
||||
// message is never active, so it starts collapsed. Once the effect below
|
||||
// fires the one-time auto-collapse, manual toggles are left alone.
|
||||
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
|
||||
|
||||
let open = $state(active)
|
||||
let wasActive = active
|
||||
|
||||
$effect(() => {
|
||||
if (wasActive && !active) {
|
||||
open = false
|
||||
}
|
||||
wasActive = active
|
||||
})
|
||||
|
||||
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
|
||||
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
|
||||
const inProgress = $derived(active && doneCount < tools.length)
|
||||
const names = $derived(tools.map((t) => t.name).join(', '))
|
||||
|
||||
function toolSummary(args: unknown): string {
|
||||
if (!args || typeof args !== 'object') return ''
|
||||
return Object.entries(args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join(' ')
|
||||
.slice(0, 80)
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if tools.length}
|
||||
<details bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
||||
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
|
||||
{#if inProgress}
|
||||
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
||||
{:else if hasError}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
{:else}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
{/if}
|
||||
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
|
||||
<span class="max-w-64 truncate font-mono text-muted-foreground">{names}</span>
|
||||
<ChevronDownIcon class="size-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div class="flex flex-col divide-y border-t">
|
||||
{#each tools as tool (tool.id)}
|
||||
<div class="p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tool.type === 'tool_result' && tool.error}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
{:else if tool.type === 'tool_result'}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
{:else}
|
||||
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
||||
{/if}
|
||||
<span class="font-mono font-medium">{tool.name}</span>
|
||||
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
||||
</div>
|
||||
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
|
||||
{#if tool.args}
|
||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
|
||||
{/if}
|
||||
{#if tool.type === 'tool_result'}
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
@@ -12,6 +12,6 @@
|
||||
<SheetPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="sheet-overlay"
|
||||
class={cn("bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||
class={cn("bg-black/20 fixed inset-0 z-50", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -61,7 +61,10 @@
|
||||
"data-slot": "sidebar-menu-button",
|
||||
"data-sidebar": "menu-button",
|
||||
"data-size": size,
|
||||
"data-active": isActive,
|
||||
// Tailwind's bare `data-active:` variant matches attribute *presence*,
|
||||
// not its value — omit the attribute entirely when false instead of
|
||||
// rendering data-active="false" (which the variant still matches).
|
||||
"data-active": isActive || undefined,
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
"data-slot": "sidebar-menu-sub-button",
|
||||
"data-sidebar": "menu-sub-button",
|
||||
"data-size": size,
|
||||
"data-active": isActive,
|
||||
// Tailwind's bare `data-active:` variant matches attribute *presence*,
|
||||
// not its value — omit the attribute entirely when false instead of
|
||||
// rendering data-active="false" (which the variant still matches).
|
||||
"data-active": isActive || undefined,
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -36,6 +36,23 @@ export async function loadSessions() {
|
||||
sessions.set(list)
|
||||
}
|
||||
|
||||
// mergeToolCalls collapses a persisted tool_calls array into one entry per
|
||||
// call id. Nomos persists the tool_use and tool_result as two separate
|
||||
// entries sharing the same id (matching the SSE event pair); Chat.svelte
|
||||
// renders tools in a keyed {#each ... (tool.id)}, which throws on duplicate
|
||||
// keys and silently aborts the whole message list. Live-streamed messages
|
||||
// never hit this because sendMessage() merges tool_result into the existing
|
||||
// tool_use entry in place rather than appending a second one.
|
||||
function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] {
|
||||
const byId = new Map<string, ToolCallResult>()
|
||||
for (const tc of raw ?? []) {
|
||||
const key = tc.id ?? crypto.randomUUID()
|
||||
const existing = byId.get(key)
|
||||
byId.set(key, existing ? { ...existing, ...tc, id: key } : { ...tc, id: key })
|
||||
}
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
|
||||
export async function loadSessionMessages(sessionId: string) {
|
||||
currentSession.set(sessionId)
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
@@ -44,7 +61,7 @@ export async function loadSessionMessages(sessionId: string) {
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
||||
tools: m.content?.tool_calls ?? []
|
||||
tools: mergeToolCalls(m.content?.tool_calls)
|
||||
}))
|
||||
messages.set(chatMsgs)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,22 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// relativeTime renders a compact "Xs/Xm/Xh/Xd ago" label for freshness
|
||||
// indicators (health checks, live event timestamps, etc).
|
||||
export function relativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "never";
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (ms < 0) return "just now";
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold">Agent activity</h1>
|
||||
<span class="text-xs text-muted-foreground">{activities.length} entries</span>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold">Audit trail</h1>
|
||||
<span class="text-xs text-muted-foreground">{entries.length} entries</span>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||
import ContextRail from '$lib/components/ContextRail.svelte'
|
||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||
import SessionGraph from '$lib/components/SessionGraph.svelte'
|
||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||
import { Button } from '$lib/components/ui/button'
|
||||
import { Textarea } from '$lib/components/ui/textarea'
|
||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
|
||||
import SquareIcon from '@lucide/svelte/icons/square'
|
||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
||||
import CheckIcon from '@lucide/svelte/icons/check'
|
||||
import XIcon from '@lucide/svelte/icons/x'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
@@ -16,6 +15,35 @@
|
||||
let input = $state('')
|
||||
let messagesEnd = $state<HTMLDivElement | null>(null)
|
||||
|
||||
// Resizable right rail (session graph). Persisted so it survives reloads.
|
||||
const RAIL_MIN = 260
|
||||
const RAIL_MAX = 620
|
||||
function loadRailWidth(): number {
|
||||
if (typeof localStorage === 'undefined') return 320
|
||||
const v = Number(localStorage.getItem('oikos-rail-width'))
|
||||
return v >= RAIL_MIN && v <= RAIL_MAX ? v : 320
|
||||
}
|
||||
let railWidth = $state(loadRailWidth())
|
||||
let resizing = $state(false)
|
||||
|
||||
function startResize(e: PointerEvent) {
|
||||
e.preventDefault()
|
||||
resizing = true
|
||||
const startX = e.clientX
|
||||
const startW = railWidth
|
||||
function move(ev: PointerEvent) {
|
||||
railWidth = Math.min(RAIL_MAX, Math.max(RAIL_MIN, startW + (startX - ev.clientX)))
|
||||
}
|
||||
function up() {
|
||||
resizing = false
|
||||
localStorage.setItem('oikos-rail-width', String(railWidth))
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', up)
|
||||
}
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', up)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void $messages
|
||||
void $streaming
|
||||
@@ -51,17 +79,14 @@
|
||||
if ($streaming) return
|
||||
sendMessage(q)
|
||||
}
|
||||
|
||||
function toolSummary(args: unknown): string {
|
||||
if (!args || typeof args !== 'object') return ''
|
||||
return Object.entries(args as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join(' ')
|
||||
.slice(0, 80)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0">
|
||||
{#if showRail}
|
||||
<div class="hidden md:block">
|
||||
<SessionRail />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-5 p-4">
|
||||
@@ -81,35 +106,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each $messages as msg (msg.id)}
|
||||
{#each $messages as msg, i (msg.id)}
|
||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
||||
{#if msg.role === 'user'}
|
||||
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
{#each msg.tools as tool (tool.id)}
|
||||
<details class="w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
|
||||
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">
|
||||
{#if tool.type === 'tool_result' && tool.error}
|
||||
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||
{:else if tool.type === 'tool_result'}
|
||||
<CheckIcon class="size-3 shrink-0 text-success" />
|
||||
{:else}
|
||||
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" />
|
||||
{/if}
|
||||
<span class="font-mono font-medium">{tool.name}</span>
|
||||
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
|
||||
</summary>
|
||||
<div class="max-h-48 overflow-y-auto border-t bg-background/60 p-2">
|
||||
{#if tool.args}
|
||||
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
|
||||
{/if}
|
||||
{#if tool.type === 'tool_result'}
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
{/each}
|
||||
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
|
||||
{#if msg.text}
|
||||
<div class="prose-chat max-w-none text-sm leading-relaxed">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||
@@ -168,8 +171,22 @@
|
||||
</div>
|
||||
|
||||
{#if showRail}
|
||||
<div class="hidden xl:block">
|
||||
<ContextRail />
|
||||
<div class="hidden shrink-0 xl:flex" style="width: {railWidth}px">
|
||||
<button
|
||||
type="button"
|
||||
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
|
||||
onpointerdown={startResize}
|
||||
aria-label="Resize session graph"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
|
||||
? 'bg-primary/60'
|
||||
: 'bg-border group-hover/rz:bg-primary/50'}"
|
||||
></span>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<SessionGraph />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fetchEntities, type Entity } from '$lib/api'
|
||||
import { fetchEntities, type Entity, type EntityHealth } from '$lib/api'
|
||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||
import { relativeTime } from '$lib/utils'
|
||||
import * as Table from '$lib/components/ui/table'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Input } from '$lib/components/ui/input'
|
||||
import * as Select from '$lib/components/ui/select'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||
|
||||
let sheetOpen = $state(false)
|
||||
let selectedSlug = $state<string | null>(null)
|
||||
|
||||
function openEntity(slug: string) {
|
||||
selectedSlug = slug
|
||||
sheetOpen = true
|
||||
}
|
||||
|
||||
let entities = $state<Entity[]>([])
|
||||
let loading = $state(true)
|
||||
@@ -47,9 +57,23 @@
|
||||
if (state === 'active' || state === 'healthy') return 'default'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
const healthDot: Record<EntityHealth, string> = {
|
||||
healthy: 'bg-success',
|
||||
degraded: 'bg-warning',
|
||||
down: 'bg-destructive',
|
||||
stale: 'bg-warning/50',
|
||||
unknown: 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
function healthTitle(entity: Entity): string {
|
||||
if (!entity.health) return 'not monitored'
|
||||
if (entity.health === 'stale') return `stale — last checked ${relativeTime(entity.last_check_at)}`
|
||||
return `${entity.health} — checked ${relativeTime(entity.last_check_at)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold">Entities</h1>
|
||||
<span class="text-xs text-muted-foreground">{filtered.length} of {entities.length}</span>
|
||||
@@ -85,14 +109,14 @@
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>State</Table.Head>
|
||||
<Table.Head>Updated</Table.Head>
|
||||
<Table.Head>Health</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filtered as entity (entity.id)}
|
||||
<Table.Row
|
||||
class="cursor-pointer"
|
||||
onclick={() => (location.hash = '#/entity/' + encodeURIComponent(entity.slug))}
|
||||
onclick={() => openEntity(entity.slug)}
|
||||
>
|
||||
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
|
||||
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||
@@ -104,9 +128,16 @@
|
||||
<span class="text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs text-muted-foreground"
|
||||
>{new Date(entity.updated_at).toLocaleString()}</Table.Cell
|
||||
>
|
||||
<Table.Cell>
|
||||
{#if entity.health}
|
||||
<span class="flex items-center gap-1.5 text-xs" title={healthTitle(entity)}>
|
||||
<span class="size-2 shrink-0 rounded-full {healthDot[entity.health]}"></span>
|
||||
<span class="text-muted-foreground">{relativeTime(entity.last_check_at)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
@@ -120,3 +151,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />
|
||||
|
||||
@@ -1,226 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte'
|
||||
import uPlot from 'uplot'
|
||||
import 'uplot/dist/uPlot.min.css'
|
||||
import {
|
||||
fetchEntity,
|
||||
fetchGraph,
|
||||
fetchMetrics,
|
||||
fetchEntityEvents,
|
||||
fetchEntitySignals,
|
||||
fetchEntityExecutions,
|
||||
fetchEntityKnowledge,
|
||||
type Entity,
|
||||
type Relationship,
|
||||
type MetricSeries,
|
||||
type Signal,
|
||||
type Execution,
|
||||
type KnowledgeHit
|
||||
} from '$lib/api'
|
||||
import type { OikosEvent } from '$lib/stores/events'
|
||||
import * as Card from '$lib/components/ui/card'
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import EntityDetailContent from '$lib/components/EntityDetailContent.svelte'
|
||||
|
||||
let { slug }: { slug: string } = $props()
|
||||
|
||||
let entity = $state<Entity | null>(null)
|
||||
let relations = $state<Relationship[]>([])
|
||||
let metrics = $state<MetricSeries[]>([])
|
||||
let events = $state<OikosEvent[]>([])
|
||||
let signals = $state<Signal[]>([])
|
||||
let executions = $state<Execution[]>([])
|
||||
let knowledge = $state<KnowledgeHit[]>([])
|
||||
let loading = $state(true)
|
||||
let chartContainers: Record<string, HTMLDivElement> = {}
|
||||
|
||||
async function load(s: string) {
|
||||
loading = true
|
||||
entity = await fetchEntity(s)
|
||||
if (!entity) {
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
const [graphView, m, ev, sig, exec, kh] = await Promise.all([
|
||||
fetchGraph({ root: entity.id, depth: 1 }),
|
||||
fetchMetrics(entity.id),
|
||||
fetchEntityEvents(entity.id),
|
||||
fetchEntitySignals(entity.id),
|
||||
fetchEntityExecutions(entity.id),
|
||||
fetchEntityKnowledge(entity.id)
|
||||
])
|
||||
relations = graphView?.edges ?? []
|
||||
metrics = m
|
||||
events = ev
|
||||
signals = sig
|
||||
executions = exec
|
||||
knowledge = kh
|
||||
loading = false
|
||||
|
||||
await tick()
|
||||
renderCharts()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load(slug)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (slug) load(slug)
|
||||
})
|
||||
|
||||
function renderCharts() {
|
||||
for (const series of metrics) {
|
||||
const el = chartContainers[series.metric]
|
||||
if (!el) continue
|
||||
el.innerHTML = ''
|
||||
const xs = series.samples.map((s) => new Date(s.ts).getTime() / 1000)
|
||||
const ys = series.samples.map((s) => s.value ?? s.avg ?? null)
|
||||
new uPlot(
|
||||
{
|
||||
width: el.clientWidth || 400,
|
||||
height: 160,
|
||||
series: [{}, { label: series.metric, stroke: '#58a6ff', width: 2 }],
|
||||
axes: [{ stroke: '#8b949e' }, { stroke: '#8b949e' }],
|
||||
scales: { x: { time: true } },
|
||||
legend: { show: false }
|
||||
},
|
||||
[xs, ys],
|
||||
el
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
||||
if (sev === 'critical') return 'destructive'
|
||||
if (sev === 'warning') return 'secondary'
|
||||
return 'default'
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
||||
{#if loading}
|
||||
<Skeleton class="h-8 w-48" />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Skeleton class="h-40 w-full" />
|
||||
<Skeleton class="h-40 w-full" />
|
||||
</div>
|
||||
{:else if !entity}
|
||||
<p class="text-sm text-muted-foreground">Entity "{slug}" not found.</p>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="font-mono text-lg font-semibold">{entity.slug}</h1>
|
||||
<Badge variant="outline">{entity.type}</Badge>
|
||||
{#if entity.state}<Badge>{entity.state}</Badge>{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Attributes</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<pre class="overflow-x-auto rounded bg-muted p-2 text-xs">{JSON.stringify(entity.attributes, null, 2)}</pre>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Relations ({relations.length})</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each relations as rel}
|
||||
<div class="flex items-center gap-1 font-mono text-xs">
|
||||
<span>{rel.source}</span>
|
||||
<span class="text-muted-foreground">—{rel.type}→</span>
|
||||
<span>{rel.target}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No direct relations.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
{#if metrics.length}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Metrics</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{#each metrics as series (series.metric)}
|
||||
<div>
|
||||
<p class="mb-1 text-xs text-muted-foreground">{series.metric} ({series.rollup})</p>
|
||||
<div bind:this={chartContainers[series.metric]}></div>
|
||||
</div>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Open signals</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each signals as signal (signal.id)}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span>{signal.kind}</span>
|
||||
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Executions</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each executions as execution (execution.id)}
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span>{execution.action}</span>
|
||||
<Badge variant="outline">{execution.status}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Knowledge</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each knowledge as hit (hit.id)}
|
||||
<div class="text-xs">
|
||||
<Badge variant="outline" class="mr-1">{hit.type}</Badge>{hit.title}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">None linked.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-sm">Recent events</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1">
|
||||
{#each events as ev (ev.id)}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
||||
<span>{ev.type}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No events yet.</p>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
<EntityDetailContent {slug} />
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold">Live event feed</h1>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<h1 class="text-lg font-semibold">Knowledge search</h1>
|
||||
|
||||
<form
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
const decidedApprovals = $derived(approvals.filter((a) => a.status !== 'pending'))
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<h1 class="text-lg font-semibold">Operations ledger</h1>
|
||||
|
||||
<Tabs.Root value="approvals" class="flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import { Badge } from '$lib/components/ui/badge'
|
||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||
import TriangleAlertIcon from '@lucide/svelte/icons/triangle-alert'
|
||||
import OctagonXIcon from '@lucide/svelte/icons/octagon-x'
|
||||
|
||||
let summary = $state<DashboardSummary | null>(null)
|
||||
let loading = $state(true)
|
||||
@@ -47,9 +50,44 @@
|
||||
function formatEventLabel(ev: OikosEvent) {
|
||||
return ev.type
|
||||
}
|
||||
|
||||
const totalEntities = $derived(
|
||||
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
|
||||
)
|
||||
const topTypes = $derived(
|
||||
summary
|
||||
? Object.entries(summary.entities_by_type)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
: []
|
||||
)
|
||||
const entityTypeCount = $derived(summary ? Object.keys(summary.entities_by_type).length : 0)
|
||||
|
||||
const totalMonitored = $derived(
|
||||
summary
|
||||
? summary.health.healthy + summary.health.degraded + summary.health.down + summary.health.unknown
|
||||
: 0
|
||||
)
|
||||
const healthTone = $derived(
|
||||
!summary ? 'ok' : summary.health.down > 0 ? 'down' : summary.health.degraded > 0 ? 'degraded' : 'ok'
|
||||
)
|
||||
|
||||
const totalSignals = $derived(
|
||||
summary ? Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0) : 0
|
||||
)
|
||||
const worstSeverity = $derived(
|
||||
summary?.signals_by_severity.critical
|
||||
? 'critical'
|
||||
: summary?.signals_by_severity.warning
|
||||
? 'warning'
|
||||
: 'none'
|
||||
)
|
||||
|
||||
const executionsRunning = $derived(summary?.executions_by_state.running ?? 0)
|
||||
const executionsFailed = $derived(summary?.executions_by_state.failed ?? 0)
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col gap-4 overflow-y-auto p-6">
|
||||
<div class="@container/main flex h-full flex-col gap-4 overflow-y-auto p-4 md:p-6">
|
||||
<h1 class="text-lg font-semibold">Overview</h1>
|
||||
|
||||
{#if loading}
|
||||
@@ -59,58 +97,99 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else if summary}
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card.Root>
|
||||
<div
|
||||
class="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
|
||||
>
|
||||
<Card.Root class="@container/card">
|
||||
<Card.Header>
|
||||
<Card.Description>Entities</Card.Description>
|
||||
<Card.Title class="text-2xl">
|
||||
{Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0)}
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{totalEntities}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
<Badge variant="outline">{entityTypeCount} types</Badge>
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-wrap gap-1 text-xs text-muted-foreground">
|
||||
{#each Object.entries(summary.entities_by_type) as [type, count]}
|
||||
<Badge variant="outline">{type}: {count}</Badge>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
|
||||
{#each topTypes as [type, count]}
|
||||
<span class="text-muted-foreground">{type}: <span class="text-foreground">{count}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text-muted-foreground">Across the fleet</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Root class="@container/card">
|
||||
<Card.Header>
|
||||
<Card.Description>Health</Card.Description>
|
||||
<Card.Title class="text-2xl">{summary.health.healthy} healthy</Card.Title>
|
||||
<Card.Description>Fleet health</Card.Description>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{summary.health.healthy} / {totalMonitored}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if healthTone === 'ok'}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />healthy</Badge>
|
||||
{:else if healthTone === 'degraded'}
|
||||
<Badge variant="outline"><TriangleAlertIcon class="text-warning" />degraded</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><OctagonXIcon class="text-destructive" />down</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-wrap gap-1 text-xs">
|
||||
<Badge>healthy: {summary.health.healthy}</Badge>
|
||||
<Badge variant="secondary">degraded: {summary.health.degraded}</Badge>
|
||||
<Badge variant="destructive">down: {summary.health.down}</Badge>
|
||||
<Badge variant="outline">unknown: {summary.health.unknown}</Badge>
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
{summary.health.degraded} degraded · {summary.health.down} down · {summary.health.unknown} unmonitored
|
||||
</div>
|
||||
<div class="text-muted-foreground">Healthy entities as observed by the scheduler</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Root class="@container/card">
|
||||
<Card.Header>
|
||||
<Card.Description>Open signals</Card.Description>
|
||||
<Card.Title class="text-2xl">
|
||||
{Object.values(summary.signals_by_severity).reduce((a, b) => a + b, 0)}
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{totalSignals}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if worstSeverity === 'critical'}
|
||||
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
|
||||
{:else if worstSeverity === 'warning'}
|
||||
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-wrap gap-1 text-xs">
|
||||
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
|
||||
<Badge variant={severityVariant(severity)}>{severity}: {count}</Badge>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
|
||||
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
|
||||
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text-muted-foreground">Unresolved right now</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Root class="@container/card">
|
||||
<Card.Header>
|
||||
<Card.Description>Pending approvals</Card.Description>
|
||||
<Card.Title class="text-2xl">{summary.approvals_pending}</Card.Title>
|
||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||
{summary.approvals_pending}
|
||||
</Card.Title>
|
||||
<Card.Action>
|
||||
{#if summary.approvals_pending > 0}
|
||||
<Badge variant="destructive">needs review</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||
{/if}
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-wrap gap-1 text-xs">
|
||||
{#each Object.entries(summary.executions_by_state) as [state, count]}
|
||||
<Badge variant="outline">{state}: {count}</Badge>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||
{executionsRunning} running · {executionsFailed} failed
|
||||
</div>
|
||||
<div class="text-muted-foreground">Executions in the last 24h</div>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
<button
|
||||
class="session-card"
|
||||
class:active={$currentSession === session.id}
|
||||
onclick={() => loadSessionMessages(session.id)}
|
||||
onclick={() => {
|
||||
loadSessionMessages(session.id)
|
||||
location.hash = '#/chat'
|
||||
}}
|
||||
>
|
||||
<div class="session-title">{session.title || 'Untitled'}</div>
|
||||
<div class="session-meta">
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex h-full flex-col gap-4 p-6">
|
||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-lg font-semibold">Signals</h1>
|
||||
<Select.Root type="single" bind:value={severityFilter}>
|
||||
|
||||
@@ -15,7 +15,13 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8090',
|
||||
'/agent': 'http://localhost:8092'
|
||||
// Production Caddy strips /agent before forwarding to nomos
|
||||
// (compose/caddy/Caddyfile.oikos handle_path /agent/*); match that
|
||||
// here so dev and prod agree on nomos's actual route paths.
|
||||
'/agent': {
|
||||
target: 'http://localhost:8092',
|
||||
rewrite: (path) => path.replace(/^\/agent/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user