Compare commits
24 Commits
e8e230b4a5
...
claude/cha
| Author | SHA1 | Date | |
|---|---|---|---|
| 614c38ea7c | |||
| 22412d2fa3 | |||
| 5686b9de40 | |||
| aa6017e0ca | |||
| cbfd09c5df | |||
| 851b5dce67 | |||
| 279549c8c9 | |||
| a39e67b6e9 | |||
| 551497e0b3 | |||
| 4a5e68bafe | |||
| ef00e5b8e4 | |||
| a512d40669 | |||
| b8bb29464b | |||
| 81acadec1d | |||
| 291b45565b | |||
| d45f2326b6 | |||
| 4bf811a383 | |||
| 35feada286 | |||
| cca2ae4621 | |||
| d13f6991b1 | |||
| 2de3602ebc | |||
| 2908b0a377 | |||
| cff05c0768 | |||
| 5d02126e16 |
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "web",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["--prefix", "web", "run", "dev"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
125
api/openapi.yaml
125
api/openapi.yaml
@@ -312,6 +312,17 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
style: form
|
style: form
|
||||||
explode: true
|
explode: true
|
||||||
|
- name: include
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- status
|
||||||
|
style: form
|
||||||
|
explode: true
|
||||||
|
description: include=status joins entity_status and populates GraphView.health
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Graph view
|
description: Graph view
|
||||||
@@ -1696,6 +1707,22 @@ paths:
|
|||||||
$ref: '#/components/schemas/HealthSummary'
|
$ref: '#/components/schemas/HealthSummary'
|
||||||
default:
|
default:
|
||||||
$ref: '#/components/responses/Problem'
|
$ref: '#/components/responses/Problem'
|
||||||
|
/dashboard/summary:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- observability
|
||||||
|
operationId: getDashboardSummary
|
||||||
|
summary: One-round-trip overview for the control room home page
|
||||||
|
x-required-scope: viewer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Dashboard summary
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DashboardSummary'
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
/export:
|
/export:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -1887,6 +1914,21 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
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:
|
EntityCreate:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -1983,6 +2025,17 @@ components:
|
|||||||
truncated:
|
truncated:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: True if node cap was hit
|
description: True if node cap was hit
|
||||||
|
health:
|
||||||
|
type: object
|
||||||
|
description: entity id -> health, present when include=status was requested
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- healthy
|
||||||
|
- degraded
|
||||||
|
- down
|
||||||
|
- unknown
|
||||||
|
- stale
|
||||||
EntityType:
|
EntityType:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -2207,6 +2260,7 @@ components:
|
|||||||
- disk
|
- disk
|
||||||
- cert-expiry
|
- cert-expiry
|
||||||
- drift
|
- drift
|
||||||
|
- ping
|
||||||
- ssh-script
|
- ssh-script
|
||||||
target:
|
target:
|
||||||
type: string
|
type: string
|
||||||
@@ -2246,6 +2300,7 @@ components:
|
|||||||
- disk
|
- disk
|
||||||
- cert-expiry
|
- cert-expiry
|
||||||
- drift
|
- drift
|
||||||
|
- ping
|
||||||
- ssh-script
|
- ssh-script
|
||||||
target:
|
target:
|
||||||
type: string
|
type: string
|
||||||
@@ -2992,6 +3047,9 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
unknown:
|
unknown:
|
||||||
type: integer
|
type: integer
|
||||||
|
stale:
|
||||||
|
type: integer
|
||||||
|
description: last observation older than the check's expected cadence
|
||||||
entities:
|
entities:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -3012,6 +3070,7 @@ components:
|
|||||||
- degraded
|
- degraded
|
||||||
- down
|
- down
|
||||||
- unknown
|
- unknown
|
||||||
|
- stale
|
||||||
trend:
|
trend:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -3024,6 +3083,72 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
nullable: true
|
nullable: true
|
||||||
|
DashboardSummary:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- entities_by_type
|
||||||
|
- entities_by_state
|
||||||
|
- health
|
||||||
|
- signals_by_severity
|
||||||
|
- approvals_pending
|
||||||
|
- executions_by_state
|
||||||
|
- event_rate
|
||||||
|
properties:
|
||||||
|
entities_by_type:
|
||||||
|
type: object
|
||||||
|
description: entity counts keyed by type
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
entities_by_state:
|
||||||
|
type: object
|
||||||
|
description: entity counts keyed by state
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
health:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- healthy
|
||||||
|
- degraded
|
||||||
|
- down
|
||||||
|
- unknown
|
||||||
|
properties:
|
||||||
|
healthy:
|
||||||
|
type: integer
|
||||||
|
degraded:
|
||||||
|
type: integer
|
||||||
|
down:
|
||||||
|
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
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
approvals_pending:
|
||||||
|
type: integer
|
||||||
|
executions_by_state:
|
||||||
|
type: object
|
||||||
|
description: execution counts keyed by state, last 24h
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
event_rate:
|
||||||
|
type: array
|
||||||
|
description: event counts bucketed by 5-minute interval, most recent last
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- bucket
|
||||||
|
- count
|
||||||
|
properties:
|
||||||
|
bucket:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
EnrollRequest:
|
EnrollRequest:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
23
checks/backup_freshness.sh
Normal file
23
checks/backup_freshness.sh
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# backup_freshness.sh — check that backups exist and are recent.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIRS="${OIKOS_BACKUP_DIRS:-/var/backups /opt/backups /mnt/backups}"
|
||||||
|
GRACE_HOURS="${OIKOS_BACKUP_GRACE:-48}"
|
||||||
|
STALE=""
|
||||||
|
|
||||||
|
for dir in $BACKUP_DIRS; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
NEWEST=$(find "$dir" -type f -mmin -$((GRACE_HOURS * 60)) 2>/dev/null | head -1 || true)
|
||||||
|
if [ -z "$NEWEST" ]; then
|
||||||
|
LATEST_TS=$(find "$dir" -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | awk '{print $1}' || echo "0")
|
||||||
|
LATEST_HOURS=$(awk "BEGIN {printf \"%.0f\", ($(date +%s) - ${LATEST_TS:-0})/3600}")
|
||||||
|
STALE="$STALE $dir(${LATEST_HOURS}h)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$STALE" ]; then
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"backup-stale\",\"evidence\":\"stale backups:$(echo "$STALE" | sed 's/ /, /g')\"}"
|
||||||
|
else
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
fi
|
||||||
46
checks/caddy_error_rate.sh
Normal file
46
checks/caddy_error_rate.sh
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# caddy_error_rate.sh — 5xx error rate from Caddy JSON access logs.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOG_DIR=""
|
||||||
|
if [ -d /var/log/caddy ]; then
|
||||||
|
LOG_DIR="/var/log/caddy"
|
||||||
|
elif [ -d /var/lib/caddy/logs ]; then
|
||||||
|
LOG_DIR="/var/lib/caddy/logs"
|
||||||
|
elif [ -d /opt/caddy/logs ]; then
|
||||||
|
LOG_DIR="/opt/caddy/logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$LOG_DIR" ]; then
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
CUTOFF=$(date -u -d "5 minutes ago" +%Y-%m-%dT%H:%M 2>/dev/null || date -u -v-5M +%Y-%m-%dT%H:%M 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
TOTAL=0
|
||||||
|
ERR_5XX=0
|
||||||
|
|
||||||
|
for LOG in "$LOG_DIR"/access*.log "$LOG_DIR"/access*.json "$LOG_DIR"/*.log 2>/dev/null; do
|
||||||
|
[ -f "$LOG" ] || continue
|
||||||
|
[ -r "$LOG" ] || continue
|
||||||
|
|
||||||
|
if [ -n "$CUTOFF" ]; then
|
||||||
|
NEW=$(awk -v cutoff="$CUTOFF" '$0 >= cutoff {print}' "$LOG" 2>/dev/null | wc -l | tr -d ' ' || echo 0)
|
||||||
|
if [ "$NEW" -gt 0 ]; then
|
||||||
|
TOTAL=$((TOTAL + NEW))
|
||||||
|
ERR_5XX=$((ERR_5XX + $(awk -v cutoff="$CUTOFF" '$0 >= cutoff && /"status":5[0-9][0-9]/ {print}' "$LOG" 2>/dev/null | wc -l | tr -d ' ' || echo 0)))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$TOTAL" -gt 100 ]; then
|
||||||
|
RATE=$(awk "BEGIN {printf \"%.1f\", $ERR_5XX*100/$TOTAL}")
|
||||||
|
if [ "$(echo "$RATE > 5" | bc 2>/dev/null || echo 0)" = "1" ]; then
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"caddy-errors\",\"evidence\":\"${RATE}% 5xx rate ($ERR_5XX/$TOTAL)\",\"metrics\":{\"caddy_5xx_rate\":$RATE,\"caddy_requests\":$TOTAL,\"caddy_5xx\":$ERR_5XX}}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"caddy_5xx_rate\":$RATE,\"caddy_requests\":$TOTAL,\"caddy_5xx\":$ERR_5XX}}"
|
||||||
|
else
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
fi
|
||||||
20
checks/cpu_check.sh
Normal file
20
checks/cpu_check.sh
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# cpu_check.sh — CPU usage % and thermal temperature.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
USAGE=$(top -bn1 2>/dev/null | awk '/^%Cpu/ {print 100 - $8}' || true)
|
||||||
|
if [ -z "$USAGE" ]; then
|
||||||
|
CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
|
||||||
|
USAGE=$(awk -v cores="$CORES" '{print ($1+$2+$3)*100/cores}' /proc/loadavg 2>/dev/null || echo "0")
|
||||||
|
fi
|
||||||
|
|
||||||
|
TEMP=""
|
||||||
|
if [ -f /sys/class/thermal/thermal_zone0/temp ]; then
|
||||||
|
TEMP=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$TEMP" ]; then
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"cpu_pct\":$USAGE,\"cpu_temp\":$TEMP}}"
|
||||||
|
else
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"cpu_pct\":$USAGE}}"
|
||||||
|
fi
|
||||||
29
checks/disk_smart_check.sh
Normal file
29
checks/disk_smart_check.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# disk_smart_check.sh — SMART pre-failure indicators for physical disks.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v smartctl >/dev/null 2>&1; then
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
DISKS=$(lsblk -ndo NAME,TYPE 2>/dev/null | awk '$2=="disk"{print "/dev/"$1}' || true)
|
||||||
|
if [ -z "$DISKS" ]; then
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
FAILED=""
|
||||||
|
for dev in $DISKS; do
|
||||||
|
INFO=$(smartctl -H "$dev" 2>/dev/null || true)
|
||||||
|
if ! echo "$INFO" | grep -q "PASSED\|OK"; then
|
||||||
|
MODEL=$(smartctl -i "$dev" 2>/dev/null | awk -F': ' '/Device Model|Product/{print $2; exit}' || echo "$dev")
|
||||||
|
FAILED="$FAILED $MODEL"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$FAILED" ]; then
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"disk-smart-fail\",\"evidence\":\"SMART check failed for:$(echo "$FAILED" | sed 's/ /, /g')\"}"
|
||||||
|
else
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
fi
|
||||||
26
checks/disk_usage_check.sh
Normal file
26
checks/disk_usage_check.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# disk_usage_check.sh — disk usage and inode usage per mountpoint.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MOUNTS=$(df -k 2>/dev/null | awk 'NR>1 && $1 ~ /^\// && $NF !~ /^\/(snap|dev|proc|sys|run|private)/ {print $NF}' || true)
|
||||||
|
FIRST=1
|
||||||
|
|
||||||
|
echo -n '{"health":"healthy","metrics":{'
|
||||||
|
for m in $MOUNTS; do
|
||||||
|
LINE=$(df -k "$m" 2>/dev/null | awk 'NR==2 {print $3, $4, $5, $7}' | tr -d '%' || true)
|
||||||
|
if [ -z "$LINE" ]; then continue; fi
|
||||||
|
USED=$(echo "$LINE" | awk '{print $1}')
|
||||||
|
FREE=$(echo "$LINE" | awk '{print $2}')
|
||||||
|
PCT=$(echo "$LINE" | awk '{print $3}')
|
||||||
|
|
||||||
|
INODE_LINE=$(df -i "$m" 2>/dev/null | awk 'NR==2 {print $5}' | tr -d '%' || echo "0")
|
||||||
|
INODE_PCT=$(echo "${INODE_LINE:-0}" | sed 's/-/0/')
|
||||||
|
|
||||||
|
KEY=$(echo "$m" | sed 's|/|_|g' | sed 's|^_||')
|
||||||
|
[ -z "$KEY" ] && KEY="root"
|
||||||
|
|
||||||
|
if [ $FIRST -eq 0 ]; then echo -n ','; fi
|
||||||
|
FIRST=0
|
||||||
|
echo -n "\"disk_${KEY}_pct\":$PCT,\"inode_${KEY}_pct\":$INODE_PCT"
|
||||||
|
done
|
||||||
|
echo '}}'
|
||||||
19
checks/docker_health_check.sh
Normal file
19
checks/docker_health_check.sh
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# docker_health_check.sh — detect unhealthy Docker containers.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
UNHEALTHY=$(docker ps --filter "health=unhealthy" --format "{{.Names}}" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$UNHEALTHY" ]; then
|
||||||
|
COUNT=$(echo "$UNHEALTHY" | wc -l | tr -d ' ')
|
||||||
|
NAMES=$(echo "$UNHEALTHY" | tr '\n' ',' | sed 's/,$//')
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"docker-unhealthy\",\"evidence\":\"$COUNT unhealthy container(s): $NAMES\",\"metrics\":{\"docker_unhealthy\":$COUNT}}"
|
||||||
|
else
|
||||||
|
TOTAL=$(docker ps -q 2>/dev/null | wc -l | tr -d ' ' || echo 0)
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"docker_unhealthy\":0,\"docker_total\":$TOTAL}}"
|
||||||
|
fi
|
||||||
13
checks/fd_check.sh
Normal file
13
checks/fd_check.sh
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# fd_check.sh — open file descriptor usage ratio.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FD_PCT=0
|
||||||
|
if [ -r /proc/sys/fs/file-nr ]; then
|
||||||
|
read -r ALLOC _ LIMIT < /proc/sys/fs/file-nr
|
||||||
|
if [ "$LIMIT" -gt 0 ]; then
|
||||||
|
FD_PCT=$(awk "BEGIN {printf \"%.1f\", $ALLOC*100/$LIMIT}")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"fd_pct\":$FD_PCT}}"
|
||||||
29
checks/install.sh
Normal file
29
checks/install.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install all check scripts into the canonical directory.
|
||||||
|
# Auto-setup hook called by tools/post-pull.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||||
|
CHECK_SRC="$CLONE_DIR/checks"
|
||||||
|
CHECK_DST="${OIKOS_CHECK_DIR:-/opt/oikos/checks}"
|
||||||
|
|
||||||
|
if [ ! -d "$CHECK_SRC" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$CHECK_DST"
|
||||||
|
|
||||||
|
for script in "$CHECK_SRC"/*.sh; do
|
||||||
|
name=$(basename "$script")
|
||||||
|
if [ "$name" = "install.sh" ]; then continue; fi
|
||||||
|
if [ -f "$CHECK_DST/$name" ]; then
|
||||||
|
if cmp -s "$script" "$CHECK_DST/$name"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cp "$script" "$CHECK_DST/$name"
|
||||||
|
chmod 755 "$CHECK_DST/$name"
|
||||||
|
echo "[setup-checks] installed $name"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[setup-checks] done"
|
||||||
15
checks/journal_check.sh
Normal file
15
checks/journal_check.sh
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# journal_check.sh — count journal errors in the last check interval.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ERRORS=0
|
||||||
|
|
||||||
|
if command -v journalctl >/dev/null 2>&1; then
|
||||||
|
ERRORS=$(journalctl -p err --since "-5min" --no-pager 2>/dev/null | wc -l | tr -d ' ' || echo 0)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ERRORS" -gt 0 ]; then
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"journal-errors\",\"evidence\":\"$ERRORS error entries in last 5min\",\"metrics\":{\"journal_errors\":$ERRORS}}"
|
||||||
|
else
|
||||||
|
echo '{"health":"healthy","metrics":{"journal_errors":0}}'
|
||||||
|
fi
|
||||||
8
checks/load_check.sh
Normal file
8
checks/load_check.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# load_check.sh — system load average scaled by CPU count.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOAD=$(awk '{print $1}' /proc/loadavg 2>/dev/null || sysctl -n vm.loadavg 2>/dev/null | awk '{print $2}' || echo "0")
|
||||||
|
CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
|
||||||
|
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"load1\":$LOAD,\"cores\":$CORES}}"
|
||||||
27
checks/memory_check.sh
Normal file
27
checks/memory_check.sh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# memory_check.sh — RAM usage percentage.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOTAL=1
|
||||||
|
AVAIL=1
|
||||||
|
|
||||||
|
if [ -r /proc/meminfo ]; then
|
||||||
|
TOTAL=$(awk '/^MemTotal:/ {printf "%d", $2}' /proc/meminfo)
|
||||||
|
AVAIL=$(awk '/^MemAvailable:/ {printf "%d", $2}' /proc/meminfo)
|
||||||
|
elif [ "$(uname)" = "Darwin" ]; then
|
||||||
|
MEM=$(vm_stat 2>/dev/null | awk '
|
||||||
|
/page size/ {ps=$8}
|
||||||
|
/Pages free/ {free+=$NF}
|
||||||
|
/Pages active/ {active+=$NF}
|
||||||
|
/Pages wired/ {wired+=$NF}
|
||||||
|
END {printf "%.2f %.2f", ps*(free+active+wired)/1048576, ps*wired/1048576}')
|
||||||
|
TOTAL=$(echo "$MEM" | awk '{printf "%.0f", $1}')
|
||||||
|
AVAIL=$(echo "$MEM" | awk '{printf "%.0f", $1 - $2}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$TOTAL" -eq 0 ]; then TOTAL=1; fi
|
||||||
|
if [ "$AVAIL" -lt 0 ]; then AVAIL=0; fi
|
||||||
|
|
||||||
|
USED_PCT=$(awk "BEGIN {printf \"%.1f\", (1 - $AVAIL/$TOTAL)*100}")
|
||||||
|
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"mem_pct\":$USED_PCT}}"
|
||||||
17
checks/oom_check.sh
Normal file
17
checks/oom_check.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# oom_check.sh — detect OOM kills since last boot.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OOM_COUNT=0
|
||||||
|
|
||||||
|
if command -v dmesg >/dev/null 2>&1; then
|
||||||
|
OOM_COUNT=$(dmesg 2>/dev/null | grep -ci 'out of memory\|oom-killer\|Killed process' || echo 0)
|
||||||
|
elif command -v journalctl >/dev/null 2>&1; then
|
||||||
|
OOM_COUNT=$(journalctl -k --no-pager 2>/dev/null | grep -ci 'out of memory\|oom-killer\|Killed process' || echo 0)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$OOM_COUNT" -gt 0 ]; then
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"oom-kills\",\"evidence\":\"$OOM_COUNT OOM events detected since boot\",\"metrics\":{\"oom_count\":$OOM_COUNT}}"
|
||||||
|
else
|
||||||
|
echo '{"health":"healthy","metrics":{"oom_count":0}}'
|
||||||
|
fi
|
||||||
22
checks/process_check.sh
Normal file
22
checks/process_check.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# process_check.sh — systemd service liveness.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE="${1:-}"
|
||||||
|
if [ -z "$SERVICE" ]; then
|
||||||
|
echo '{"health":"unknown","signalKind":"process-check","evidence":"no service name provided"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v systemctl >/dev/null 2>&1; then
|
||||||
|
echo '{"health":"unknown","signalKind":"process-check","evidence":"systemctl not found"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
STATE=$(systemctl is-active "$SERVICE" 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
if [ "$STATE" = "active" ]; then
|
||||||
|
echo "{\"health\":\"healthy\"}"
|
||||||
|
else
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"$SERVICE\",\"evidence\":\"$SERVICE is $STATE\"}"
|
||||||
|
fi
|
||||||
22
checks/swap_check.sh
Normal file
22
checks/swap_check.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# swap_check.sh — swap usage percentage.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -r /proc/meminfo ]; then
|
||||||
|
SWAP_TOTAL=$(awk '/^SwapTotal:/ {printf "%d", $2}' /proc/meminfo)
|
||||||
|
SWAP_FREE=$(awk '/^SwapFree:/ {printf "%d", $2}' /proc/meminfo)
|
||||||
|
elif [ "$(uname)" = "Darwin" ]; then
|
||||||
|
SP=$(sysctl vm.swapusage 2>/dev/null | awk '{print $4, $9}' | tr -d 'M' || echo "0 0")
|
||||||
|
SWAP_TOTAL=$(echo "$SP" | awk '{printf "%.0f", $1*1024}')
|
||||||
|
SWAP_FREE=$(echo "$SP" | awk '{printf "%.0f", ($1-$2)*1024}')
|
||||||
|
else
|
||||||
|
SWAP_TOTAL=0
|
||||||
|
SWAP_FREE=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$SWAP_TOTAL" -eq 0 ]; then
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"swap_pct\":0}}"
|
||||||
|
else
|
||||||
|
USED_PCT=$(awk "BEGIN {printf \"%.1f\", (1 - $SWAP_FREE/$SWAP_TOTAL)*100}")
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"swap_pct\":$USED_PCT}}"
|
||||||
|
fi
|
||||||
26
checks/time_check.sh
Normal file
26
checks/time_check.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# time_check.sh — NTP synchronization status and clock drift.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DRIFT_S=0
|
||||||
|
SYNCED=true
|
||||||
|
|
||||||
|
if command -v chronyc >/dev/null 2>&1; then
|
||||||
|
TRACKING=$(chronyc tracking 2>/dev/null || true)
|
||||||
|
DRIFT_NS=$(echo "$TRACKING" | awk '/System time/ {print $4}' | sed 's/-//' || echo "0")
|
||||||
|
DRIFT_S=$(awk "BEGIN {printf \"%.6f\", $DRIFT_NS/1e9}")
|
||||||
|
# chronyc returns a very small value when synced (nanoseconds)
|
||||||
|
elif command -v timedatectl >/dev/null 2>&1; then
|
||||||
|
STATUS=$(timedatectl show 2>/dev/null || true)
|
||||||
|
if echo "$STATUS" | grep -q "NTPSynchronized=no"; then
|
||||||
|
SYNCED=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! $SYNCED; then
|
||||||
|
echo '{"health":"degraded","signalKind":"time-drift","evidence":"NTP not synchronized"}'
|
||||||
|
elif [ "$(echo "$DRIFT_S > 1" | bc 2>/dev/null || echo 0)" = "1" ]; then
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"time-drift\",\"evidence\":\"clock drift ${DRIFT_S}s exceeds 1s threshold\",\"metrics\":{\"clock_drift_s\":$DRIFT_S}}"
|
||||||
|
else
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"clock_drift_s\":$DRIFT_S}}"
|
||||||
|
fi
|
||||||
17
checks/updates_check.sh
Normal file
17
checks/updates_check.sh
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# updates_check.sh — pending apt security updates and reboot-required flag.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SECURITY=0
|
||||||
|
REBOOT=0
|
||||||
|
|
||||||
|
if command -v apt >/dev/null 2>&1; then
|
||||||
|
apt update -qq >/dev/null 2>&1 || true
|
||||||
|
SECURITY=$(apt list --upgradable 2>/dev/null | grep -c '\-security' || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f /var/run/reboot-required ]; then
|
||||||
|
REBOOT=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"security_updates\":$SECURITY,\"reboot_required\":$REBOOT}}"
|
||||||
7
checks/uptime_check.sh
Normal file
7
checks/uptime_check.sh
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# uptime_check.sh — detect unexpected reboots by monitoring uptime.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
UPTIME=$(awk '{printf "%.0f", $1}' /proc/uptime 2>/dev/null || sysctl -n kern.boottime 2>/dev/null | awk '{print $4}' | tr -d ',' || echo "0")
|
||||||
|
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"uptime_seconds\":$UPTIME}}"
|
||||||
29
checks/zfs_check.sh
Normal file
29
checks/zfs_check.sh
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# zfs_check.sh — ZFS pool health.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v zpool >/dev/null 2>&1; then
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
STATUS=$(zpool status -x 2>&1 || true)
|
||||||
|
SCRUB_OVERDUE=""
|
||||||
|
|
||||||
|
if echo "$STATUS" | grep -q "all pools are healthy"; then
|
||||||
|
for pool in $(zpool list -Ho name 2>/dev/null || true); do
|
||||||
|
LAST=$(zpool status "$pool" 2>/dev/null | awk '/scan:/{print $0}' || true)
|
||||||
|
if [ -z "$LAST" ] || echo "$LAST" | grep -q "scrub repaired"; then
|
||||||
|
SCRUB_OVERDUE="$pool"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$SCRUB_OVERDUE" ]; then
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"zfs-scrub-overdue\",\"evidence\":\"pool $SCRUB_OVERDUE scrub has errors or is overdue\"}"
|
||||||
|
else
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
HEALTH=$(echo "$STATUS" | grep "state:" | awk '{print $2}' | head -1)
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"zfs-degraded\",\"evidence\":\"pool state: $HEALTH\"}"
|
||||||
|
fi
|
||||||
@@ -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
|
// Rebuild conversation context from persisted history so sessions are
|
||||||
// multi-turn. The current user turn is saved by the HTTP handler before
|
// 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.
|
// this runs, so it is already included in the history for real sessions.
|
||||||
// Intermediate tool_use/tool_result pairs are not replayed (their ids
|
// Prior tool_use/tool_result pairs are replayed as a tool-calling
|
||||||
// must match exactly or the API rejects them); prior final answers carry
|
// assistant message followed by matching tool-role results, so the agent
|
||||||
// the salient context. Ephemeral sessions (no store) fall back to the
|
// starts each turn already knowing what it already checked instead of
|
||||||
// single incoming message.
|
// re-querying the same tools from scratch. Ephemeral sessions (no store)
|
||||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(a.system)}
|
// 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)
|
history, _ := a.store.getMessages(ctx, sessionID)
|
||||||
for _, m := range history {
|
for _, m := range history {
|
||||||
text := extractText(m.Content)
|
text := extractText(m.Content)
|
||||||
@@ -120,6 +125,12 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
|||||||
case "user":
|
case "user":
|
||||||
messages = append(messages, openai.UserMessage(text))
|
messages = append(messages, openai.UserMessage(text))
|
||||||
case "assistant":
|
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 != "" {
|
if text != "" {
|
||||||
messages = append(messages, openai.AssistantMessage(text))
|
messages = append(messages, openai.AssistantMessage(text))
|
||||||
}
|
}
|
||||||
@@ -243,6 +254,140 @@ func extractText(content json.RawMessage) string {
|
|||||||
return m.Text
|
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) {
|
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
||||||
defs, err := a.client.listToolsFull()
|
defs, err := a.client.listToolsFull()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -351,9 +352,51 @@ type mcpJSONRPCResponse struct {
|
|||||||
Error json.RawMessage `json:"error"`
|
Error json.RawMessage `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errStaleSession signals that the MCP server rejected our session id (e.g.
|
||||||
|
// after an api/MCP restart), so the client should re-initialize and retry.
|
||||||
|
var errStaleSession = fmt.Errorf("mcp session stale")
|
||||||
|
|
||||||
|
// doRequest serializes MCP calls and transparently re-initializes the session
|
||||||
|
// if the server has forgotten it (common after an api redeploy), retrying the
|
||||||
|
// original call once. Without this, an api restart permanently breaks nomos
|
||||||
|
// until it is itself restarted.
|
||||||
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
resp, err := c.send(method, params)
|
||||||
|
if err != nil && method != "initialize" && errors.Is(err, errStaleSession) {
|
||||||
|
slog.Warn("nomos: mcp session stale, reconnecting")
|
||||||
|
if rerr := c.reconnectLocked(); rerr != nil {
|
||||||
|
return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err)
|
||||||
|
}
|
||||||
|
return c.send(method, params)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
|
||||||
|
func (c *mcpClient) reconnectLocked() error {
|
||||||
|
c.sessionID = ""
|
||||||
|
resp, err := c.send("initialize", map[string]any{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": map[string]any{},
|
||||||
|
"clientInfo": map[string]any{"name": "nomos", "version": "2.0"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.sessionID == "" {
|
||||||
|
return fmt.Errorf("no session ID on re-initialize")
|
||||||
|
}
|
||||||
|
c.sessionID = resp.sessionID
|
||||||
|
_, _ = c.send("notifications/initialized", map[string]any{})
|
||||||
|
slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// send performs one MCP round-trip. It does not lock; callers hold c.mu.
|
||||||
|
func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) {
|
||||||
c.nextID++
|
c.nextID++
|
||||||
body, _ := json.Marshal(map[string]any{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -378,13 +421,21 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// A rejected/unknown session comes back as 4xx (commonly 400/404).
|
||||||
|
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
|
||||||
|
return nil, errStaleSession
|
||||||
|
}
|
||||||
|
|
||||||
result := &mcpJSONRPCResponse{}
|
result := &mcpJSONRPCResponse{}
|
||||||
result.sessionID = resp.Header.Get("Mcp-Session-Id")
|
result.sessionID = resp.Header.Get("Mcp-Session-Id")
|
||||||
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
|
gotData := false
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
if strings.HasPrefix(line, "data: ") {
|
if strings.HasPrefix(line, "data: ") {
|
||||||
|
gotData = true
|
||||||
data := line[6:]
|
data := line[6:]
|
||||||
if err := json.Unmarshal([]byte(data), result); err != nil {
|
if err := json.Unmarshal([]byte(data), result); err != nil {
|
||||||
return nil, fmt.Errorf("parse response: %w", err)
|
return nil, fmt.Errorf("parse response: %w", err)
|
||||||
@@ -396,6 +447,12 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
|
|||||||
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
|
return nil, fmt.Errorf("rpc error: %s", string(result.Error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty body with no result and a session set: the server likely dropped
|
||||||
|
// our session. Notifications legitimately return no data, so exempt them.
|
||||||
|
if !gotData && result.Result == nil && method != "notifications/initialized" {
|
||||||
|
return nil, errStaleSession
|
||||||
|
}
|
||||||
|
|
||||||
if result.sessionID != "" {
|
if result.sessionID != "" {
|
||||||
c.sessionID = result.sessionID
|
c.sessionID = result.sessionID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
@@ -24,30 +27,38 @@ import (
|
|||||||
|
|
||||||
// uiHandler serves the control-room SPA from assets embedded at build time
|
// uiHandler serves the control-room SPA from assets embedded at build time
|
||||||
// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*;
|
// (web/embed.go), with SPA fallback to index.html. Requests arrive as /ui/*;
|
||||||
// the /ui prefix is stripped to index into the embedded dist/ tree.
|
// the /ui prefix is stripped to index into the embedded dist/ tree. Files are
|
||||||
|
// written via http.ServeContent (not http.FileServer) to avoid its
|
||||||
|
// index.html -> "./" canonical redirect, which loops for /ui/.
|
||||||
func uiHandler() http.Handler {
|
func uiHandler() http.Handler {
|
||||||
dist, err := web.DistFS()
|
dist, err := web.DistFS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("ui: embedded assets unavailable", "error", err)
|
slog.Warn("ui: embedded assets unavailable", "error", err)
|
||||||
return http.NotFoundHandler()
|
return http.NotFoundHandler()
|
||||||
}
|
}
|
||||||
fileServer := http.FileServer(http.FS(dist))
|
serve := func(w http.ResponseWriter, r *http.Request, name string) bool {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f, err := dist.Open(name)
|
||||||
path := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
|
if err != nil {
|
||||||
if path == "" {
|
return false
|
||||||
path = "index.html"
|
|
||||||
}
|
}
|
||||||
if f, err := dist.Open(path); err == nil {
|
defer f.Close()
|
||||||
f.Close()
|
data, err := io.ReadAll(f)
|
||||||
r.URL.Path = "/" + path
|
if err != nil {
|
||||||
fileServer.ServeHTTP(w, r)
|
return false
|
||||||
|
}
|
||||||
|
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
|
||||||
|
if name == "" {
|
||||||
|
name = "index.html"
|
||||||
|
}
|
||||||
|
if serve(w, r, name) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// SPA fallback: serve index.html for unknown client-side routes.
|
// SPA fallback: serve index.html for unknown client-side routes.
|
||||||
if f, err := dist.Open("index.html"); err == nil {
|
if serve(w, r, "index.html") {
|
||||||
f.Close()
|
|
||||||
r.URL.Path = "/index.html"
|
|
||||||
fileServer.ServeHTTP(w, r)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ COPY --from=ui-builder /web/dist ./web/dist
|
|||||||
|
|
||||||
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
|
RUN CGO_ENABLED=0 go build -o /oikos -tags timetzdata -ldflags="-s -w" ./cmd/oikos
|
||||||
|
|
||||||
# --- Runtime: distroless static ---
|
# --- Runtime: alpine with SSH + ping for scheduler checks ---
|
||||||
FROM gcr.io/distroless/static:nonroot
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates openssh-client-default
|
||||||
|
|
||||||
COPY --from=builder /oikos /oikos
|
COPY --from=builder /oikos /oikos
|
||||||
COPY --from=builder /build/seeds /seeds
|
COPY --from=builder /build/seeds /seeds
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ services:
|
|||||||
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
OIKOS_DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
|
||||||
OIKOS_DEBUG: "true"
|
OIKOS_DEBUG: "true"
|
||||||
OIKOS_SCHEDULER_INTERVAL: "30s"
|
OIKOS_SCHEDULER_INTERVAL: "30s"
|
||||||
|
OIKOS_SSH_KEY_PATH: /etc/oikos/ssh_key
|
||||||
|
OIKOS_SSH_USER: root
|
||||||
|
volumes:
|
||||||
|
- ${OIKOS_SSH_KEY_PATH:-~/.ssh/id_ed25519}:/etc/oikos/ssh_key:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_RAW
|
||||||
command: ["scheduler"]
|
command: ["scheduler"]
|
||||||
stop_signal: SIGTERM
|
stop_signal: SIGTERM
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
|
|||||||
197
docs/adr/0013-signal-triggers.md
Normal file
197
docs/adr/0013-signal-triggers.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Signal Trigger Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When Nomos is asked "what are the thermals of hubris?", here is exactly what happens:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant N as Nomos (Agent)
|
||||||
|
participant A as Oikos API
|
||||||
|
participant S as Scheduler (Docker)
|
||||||
|
participant H as Hubris (Proxmox)
|
||||||
|
participant T as TimescaleDB
|
||||||
|
|
||||||
|
Note over S,H: Every 60s (autonomous loop)
|
||||||
|
S->>H: SSH exec /opt/oikos/checks/cpu_check.sh
|
||||||
|
H-->>S: {"health":"ok","metrics":{"cpu_pct":2.5,"cpu_temp":48}}
|
||||||
|
S->>T: INSERT metric_samples (cpu_pct, cpu_temp)
|
||||||
|
S->>T: UPSERT entity_status (health)
|
||||||
|
alt unhealthy
|
||||||
|
S->>T: UPSERT signal (dedup by target+kind)
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over N,T: User asks "what are the thermals of hubris?"
|
||||||
|
N->>A: MCP query_metrics(metric=["cpu_pct","cpu_temp"])
|
||||||
|
A->>T: SELECT time_bucket(…) FROM metric_samples
|
||||||
|
T-->>A: cpu_pct=15%, cpu_temp=48°C
|
||||||
|
A-->>N: {avg, min, max} per bucket
|
||||||
|
```
|
||||||
|
|
||||||
|
## Two Paths
|
||||||
|
|
||||||
|
### Path A — Autonomous Collection (Scheduler)
|
||||||
|
|
||||||
|
1. Operator creates a check via REST API: `POST /api/v1/checks`
|
||||||
|
2. Scheduler loads enabled checks every 30s from `check_defs` table
|
||||||
|
3. For `ssh-script` checks, scheduler SSHs to target host and runs `/opt/oikos/checks/<script>.sh`
|
||||||
|
4. Script returns JSON with `health`, `signalKind`, `evidence`, and `metrics`
|
||||||
|
5. Metrics written to TimescaleDB `metric_samples` table **every cycle** (healthy or not)
|
||||||
|
6. If unhealthy: a signal is raised (deduplicated by target_entity_id + kind)
|
||||||
|
7. If healthy again: the signal is auto-resolved, entity_status health updated
|
||||||
|
8. All state changes emit SSE events for real-time UI updates
|
||||||
|
|
||||||
|
### Path B — Query (Nomos via MCP)
|
||||||
|
|
||||||
|
1. Nomos calls `query_metrics(metric=["cpu_pct","cpu_temp"])` MCP tool
|
||||||
|
2. API runs time-bucketed aggregation over `metric_samples`
|
||||||
|
3. Returns latest readings with avg/min/max per bucket
|
||||||
|
4. Nomos formats them and presents to the user
|
||||||
|
|
||||||
|
## Check Kinds
|
||||||
|
|
||||||
|
| Kind | Where it runs | Protocol | Example |
|
||||||
|
|------|--------------|----------|---------|
|
||||||
|
| `ping` | Scheduler container | ICMP (`ping` binary) | Reachability + latency |
|
||||||
|
| `http` | Scheduler container | HTTP GET | Service endpoint health |
|
||||||
|
| `tcp` | Scheduler container | TCP dial | Port open check |
|
||||||
|
| `disk` | Scheduler container | `unix.Statfs` | Local disk usage + inodes |
|
||||||
|
| `cert-expiry` | Scheduler container | TLS dial | Certificate days remaining |
|
||||||
|
| `ssh-script` | Remote target via SSH | SSH exec + JSON | Any script in `/opt/oikos/checks/` |
|
||||||
|
|
||||||
|
## Available Check Scripts
|
||||||
|
|
||||||
|
All scripts live in `/opt/oikos/checks/` on target hosts. They output JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"health":"healthy","metrics":{"cpu_pct":2.5,"cpu_temp":48.0}}
|
||||||
|
```
|
||||||
|
or on failure:
|
||||||
|
```json
|
||||||
|
{"health":"degraded","signalKind":"disk-smart-fail","evidence":"SMART failed for Samsung 990"}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Script | Metrics | Signal (on failure) |
|
||||||
|
|--------|---------|---------------------|
|
||||||
|
| `cpu_check.sh` | `cpu_pct`, `cpu_temp` | threshold-based |
|
||||||
|
| `memory_check.sh` | `mem_pct` | threshold-based |
|
||||||
|
| `load_check.sh` | `load1`, `cores` | threshold-based |
|
||||||
|
| `swap_check.sh` | `swap_pct` | threshold-based |
|
||||||
|
| `disk_usage_check.sh` | `disk_*_pct`, `inode_*_pct` | threshold-based |
|
||||||
|
| `disk_smart_check.sh` | — | `disk-smart-fail` |
|
||||||
|
| `updates_check.sh` | `security_updates`, `reboot_required` | threshold-based |
|
||||||
|
| `zfs_check.sh` | — | `zfs-degraded`, `zfs-scrub-overdue` |
|
||||||
|
| `process_check.sh` | — | `<service-name>` |
|
||||||
|
| `uptime_check.sh` | `uptime_seconds` | threshold-based |
|
||||||
|
| `oom_check.sh` | `oom_count` | `oom-kills` |
|
||||||
|
| `journal_check.sh` | `journal_errors` | `journal-errors` |
|
||||||
|
| `time_check.sh` | `clock_drift_s` | `time-drift` |
|
||||||
|
| `fd_check.sh` | `fd_pct` | threshold-based |
|
||||||
|
| `docker_health_check.sh` | `docker_unhealthy`, `docker_total` | `docker-unhealthy` |
|
||||||
|
| `caddy_error_rate.sh` | `caddy_5xx_rate`, `caddy_requests`, `caddy_5xx` | `caddy-errors` |
|
||||||
|
| `backup_freshness.sh` | — | `backup-stale` |
|
||||||
|
|
||||||
|
## Script Deployment
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as Git Repo
|
||||||
|
participant T as Sync Timer (5min)
|
||||||
|
participant H as Target Host
|
||||||
|
|
||||||
|
Note over R,T: Operator pushes scripts
|
||||||
|
R->>T: git pull (homelab-context)
|
||||||
|
T->>T: tools/post-pull.sh
|
||||||
|
T->>T: → tools/setup-checks.sh
|
||||||
|
T->>T: → checks/install.sh
|
||||||
|
T->>H: cp *.sh → /opt/oikos/checks/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Defining a Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://oikos:8090/api/v1/checks \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"kind": "ssh-script",
|
||||||
|
"target": "host:hubris",
|
||||||
|
"config": {
|
||||||
|
"host": "192.168.8.77",
|
||||||
|
"script": "cpu_check.sh",
|
||||||
|
"thresholds": {
|
||||||
|
"cpu_pct": {"warn": 90, "crit": 95},
|
||||||
|
"cpu_temp": {"crit": 85}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interval_s": 60
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Signal Lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> raised
|
||||||
|
raised --> acknowledged
|
||||||
|
raised --> muted: mute_until set
|
||||||
|
raised --> resolved: condition cleared
|
||||||
|
|
||||||
|
acknowledged --> acting: classification exists
|
||||||
|
acknowledged --> muted
|
||||||
|
acknowledged --> resolved
|
||||||
|
|
||||||
|
acting --> resolved: verification passed
|
||||||
|
acting --> raised: retry budget remaining
|
||||||
|
acting --> failed
|
||||||
|
|
||||||
|
failed --> acknowledged: operator retry
|
||||||
|
|
||||||
|
muted --> raised: mute_until expired
|
||||||
|
|
||||||
|
resolved --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
Signals deduplicate: **one open signal per (target_entity_id, kind)**.
|
||||||
|
Repeated failures increment `occurrence_count` instead of creating duplicates.
|
||||||
|
|
||||||
|
## Threshold Evaluation
|
||||||
|
|
||||||
|
Each check config can define per-metric thresholds in the `config` JSONB:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thresholds": {
|
||||||
|
"cpu_temp": {"crit": 85},
|
||||||
|
"cpu_pct": {"warn": 90, "crit": 95}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Severity mapping:
|
||||||
|
- metric >= `crit` → severity = `critical`
|
||||||
|
- metric >= `warn` → severity = `warning`
|
||||||
|
- `health == "down"` with no thresholds → severity = `critical`
|
||||||
|
- Otherwise → severity = `warning`
|
||||||
|
|
||||||
|
## Data Flow (DB Tables)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
CD[check_defs] -->|scheduler reads| EC[executeCheck]
|
||||||
|
EC -->|healthy?| RS[resolve signal + upsert entity_status]
|
||||||
|
EC -->|unhealthy?| US[UpsertSignal dedup by target+kind]
|
||||||
|
EC -->|every cycle| IM[INSERT metric_samples]
|
||||||
|
US --> S[signals]
|
||||||
|
RS --> ES[entity_status]
|
||||||
|
IM --> MS[(metric_samples)]
|
||||||
|
MS --> R1H[metric_rollups_1h continuous aggregate]
|
||||||
|
MS --> R1D[metric_rollups_1d continuous aggregate]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites for SSH Checks
|
||||||
|
|
||||||
|
1. **Key**: SSH private key mounted at `/etc/oikos/ssh_key` in the scheduler container
|
||||||
|
2. **User**: `OIKOS_SSH_USER=root` (or set `"user"` in check config)
|
||||||
|
3. **Scripts**: Deployed on target host at `/opt/oikos/checks/`
|
||||||
|
4. **Network**: Scheduler container must reach target host (bridge → LAN works)
|
||||||
|
5. **Container**: Scheduler needs `openssh-client` (alpine base) + `CAP_NET_RAW` (for ping)
|
||||||
613
docs/adr/0014-entity-model.md
Normal file
613
docs/adr/0014-entity-model.md
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
# Oikos Entity Model — Types, Relationships & Interactions
|
||||||
|
|
||||||
|
**Status:** Adopted
|
||||||
|
**Date:** 2026-07-08
|
||||||
|
**Scope:** Full inventory of every entity type, relationship, state machine, and
|
||||||
|
cognition pipeline — with clear markers for what is **code-real** vs **schema-only**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Entity Type Hierarchy (56 types)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph meta["layer: meta"]
|
||||||
|
entity["★ entity"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph infrastructure["layer: infrastructure"]
|
||||||
|
subgraph physical["domain: physical"]
|
||||||
|
site
|
||||||
|
ups
|
||||||
|
sensor
|
||||||
|
peripheral
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph compute["domain: compute"]
|
||||||
|
ce["★ compute-entity"]
|
||||||
|
machine["★ machine"]
|
||||||
|
proxmox-host
|
||||||
|
standalone-server
|
||||||
|
workstation
|
||||||
|
appliance
|
||||||
|
vm
|
||||||
|
container["★ container"]
|
||||||
|
lxc
|
||||||
|
docker-container
|
||||||
|
hypervisor
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph network["domain: network"]
|
||||||
|
net["★ network"]
|
||||||
|
lan
|
||||||
|
mesh
|
||||||
|
vlan
|
||||||
|
network-interface
|
||||||
|
dns-zone
|
||||||
|
dns-record
|
||||||
|
ingress-route
|
||||||
|
certificate
|
||||||
|
firewall-rule
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph storage["domain: storage"]
|
||||||
|
storage-pool
|
||||||
|
volume
|
||||||
|
backup-target
|
||||||
|
dataset
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph software["domain: software"]
|
||||||
|
service
|
||||||
|
application
|
||||||
|
config-repo
|
||||||
|
deploy-pipeline
|
||||||
|
package-set
|
||||||
|
cluster
|
||||||
|
compose-stack
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph external["domain: external"]
|
||||||
|
domain-registration
|
||||||
|
cloud-service
|
||||||
|
isp-link
|
||||||
|
vendor-dependency
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph governance["layer: governance"]
|
||||||
|
subgraph identity["domain: identity"]
|
||||||
|
person
|
||||||
|
agent
|
||||||
|
identity-provider
|
||||||
|
account
|
||||||
|
secret
|
||||||
|
key
|
||||||
|
access-grant
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph cognition["layer: cognition"]
|
||||||
|
check
|
||||||
|
signal
|
||||||
|
classification
|
||||||
|
execution
|
||||||
|
feedback
|
||||||
|
pattern
|
||||||
|
skill
|
||||||
|
approval
|
||||||
|
document
|
||||||
|
runbook
|
||||||
|
investigation
|
||||||
|
end
|
||||||
|
|
||||||
|
entity --> ce
|
||||||
|
entity --> machine
|
||||||
|
entity --> net
|
||||||
|
entity --> container
|
||||||
|
entity --> site
|
||||||
|
entity --> ups
|
||||||
|
entity --> sensor
|
||||||
|
entity --> peripheral
|
||||||
|
entity --> vm
|
||||||
|
entity --> hypervisor
|
||||||
|
entity --> network-interface
|
||||||
|
entity --> dns-zone
|
||||||
|
entity --> dns-record
|
||||||
|
entity --> ingress-route
|
||||||
|
entity --> certificate
|
||||||
|
entity --> firewall-rule
|
||||||
|
entity --> storage-pool
|
||||||
|
entity --> volume
|
||||||
|
entity --> backup-target
|
||||||
|
entity --> dataset
|
||||||
|
entity --> service
|
||||||
|
entity --> application
|
||||||
|
entity --> config-repo
|
||||||
|
entity --> deploy-pipeline
|
||||||
|
entity --> package-set
|
||||||
|
entity --> cluster
|
||||||
|
entity --> compose-stack
|
||||||
|
entity --> domain-registration
|
||||||
|
entity --> cloud-service
|
||||||
|
entity --> isp-link
|
||||||
|
entity --> vendor-dependency
|
||||||
|
entity --> person
|
||||||
|
entity --> agent
|
||||||
|
entity --> identity-provider
|
||||||
|
entity --> account
|
||||||
|
entity --> secret
|
||||||
|
entity --> key
|
||||||
|
entity --> access-grant
|
||||||
|
entity --> check
|
||||||
|
entity --> signal
|
||||||
|
entity --> classification
|
||||||
|
entity --> execution
|
||||||
|
entity --> feedback
|
||||||
|
entity --> pattern
|
||||||
|
entity --> skill
|
||||||
|
entity --> approval
|
||||||
|
entity --> document
|
||||||
|
entity --> runbook
|
||||||
|
entity --> investigation
|
||||||
|
|
||||||
|
ce --> machine
|
||||||
|
ce --> container
|
||||||
|
machine --> proxmox-host
|
||||||
|
machine --> standalone-server
|
||||||
|
machine --> workstation
|
||||||
|
machine --> appliance
|
||||||
|
container --> lxc
|
||||||
|
container --> docker-container
|
||||||
|
net --> lan
|
||||||
|
net --> mesh
|
||||||
|
net --> vlan
|
||||||
|
```
|
||||||
|
|
||||||
|
★ = abstract (cannot be instantiated; polymorphic target for relationships)
|
||||||
|
|
||||||
|
### Concrete instances (88 active entities)
|
||||||
|
|
||||||
|
| Type | Count | Examples |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `lxc` | 19 | jellyfin, caddy, dns, gitea, nextcloud, matrix, arriman… |
|
||||||
|
| `service` | 25 | caddy, authentik, dns, jellyfin, paperless, matrix… |
|
||||||
|
| `ingress-route` | 21 | *.hubris.network |
|
||||||
|
| `config-repo` | 6 | caddy-conf, gitea-customizations, mule-image… |
|
||||||
|
| `proxmox-host` | 2 | hubris, strong |
|
||||||
|
| `workstation` | 2 | mac-mini, republic-laptop |
|
||||||
|
| `standalone-server` | 1 | netbird-vps |
|
||||||
|
| `vm` | 2 | zimaos, haos |
|
||||||
|
| `storage-pool` | 3 | local-lvm-hubris, library-hubris, ludo-lvm |
|
||||||
|
| `volume` | 2 | library, media-local |
|
||||||
|
| + sites, networks, agents, documents, destroyed… | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Sequence: Machine Onboarding
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant O as Operator
|
||||||
|
participant A as Oikos API
|
||||||
|
participant D as DB
|
||||||
|
participant S as Scheduler
|
||||||
|
participant T as Target Machine
|
||||||
|
|
||||||
|
O->>A: POST /api/v1/entities {type:proxmox-host, slug:host:new, …}
|
||||||
|
A->>D: INSERT INTO entities
|
||||||
|
A->>A: ensureDefaultChecks().resolveHost() → lan_ip
|
||||||
|
A->>D: INSERT check_defs × 6 (target_id set)
|
||||||
|
A-->>O: 201 Created
|
||||||
|
|
||||||
|
Note over S,T: 30s scheduler tick
|
||||||
|
S->>D: ListEnabledCheckDefs
|
||||||
|
S->>T: SSH exec /opt/oikos/checks/cpu_check.sh
|
||||||
|
T-->>S: {"health":"ok","metrics":{"cpu_pct":2.5,"cpu_temp":48}}
|
||||||
|
S->>D: INSERT metric_samples
|
||||||
|
S->>D: UPSERT entity_status (health)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What's code-real here:**
|
||||||
|
- `CreateEntity()` at `internal/httpapi/impl.go:811` — handles POST, validates type, calls `ensureDefaultChecks()`
|
||||||
|
- `ensureDefaultChecks()` → `internal/checkdefaults/defaults.go:144` — resolves host IP, SSH user, creates 6 check_defs rows with target_id
|
||||||
|
- Scheduler at `internal/scheduler/scheduler.go:26` — loads `ListEnabledCheckDefs`, dispatches by kind, writes metrics + signals
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Core Sequence: The OODA Loop (observe → orient → decide → act → learn)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph OBSERVE["🔍 OBSERVE (Scheduler every 30s)"]
|
||||||
|
direction TB
|
||||||
|
S1["ListEnabledCheckDefs"]
|
||||||
|
S2["ping → exec.Command(ping, host)"]
|
||||||
|
S3["http → http.Get(url)"]
|
||||||
|
S4["tcp → net.DialTimeout(tcp, addr)"]
|
||||||
|
S5["disk → unix.Statfs(path)"]
|
||||||
|
S6["cert-expiry → tls.Dial + cert.NotAfter"]
|
||||||
|
S7["ssh-script → exec.Command(ssh, host, script)"]
|
||||||
|
S8["checkResult{health, signalKind, evidence, metrics}"]
|
||||||
|
S1 --> S2 & S3 & S4 & S5 & S6 & S7
|
||||||
|
S2 & S3 & S4 & S5 & S6 & S7 --> S8
|
||||||
|
S8 -->|INSERT| MS[("metric_samples")]
|
||||||
|
S8 -->|UPSERT| SG["signals (dedup by target+kind)"]
|
||||||
|
S8 -->|UPSERT| ES["entity_status (health)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ORIENT["🧭 ORIENT (Classification)"]
|
||||||
|
direction TB
|
||||||
|
C1["policy.ClassifySignal(signal, entity, blast_radius)"]
|
||||||
|
C2["read_only → route: auto_act"]
|
||||||
|
C3["reversible_low → route: auto_act"]
|
||||||
|
C4["config_mutation → route: escalate"]
|
||||||
|
C5["destructive → route: hold"]
|
||||||
|
C1 --> C2 & C3 & C4 & C5
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DECIDE["⚖️ DECIDE (Approval Gate)"]
|
||||||
|
direction TB
|
||||||
|
D1["auto_act → execute immediately"]
|
||||||
|
D2["escalate → INSERT approval (pending)"]
|
||||||
|
D3["notifier → Matrix alert + HMAC token"]
|
||||||
|
D4["operator replies ✅ or ❌"]
|
||||||
|
D5["hold → queued, never auto-executed"]
|
||||||
|
D2 --> D3 --> D4
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ACT["⚡ ACT (Execution)"]
|
||||||
|
direction TB
|
||||||
|
A1["Nomos → MCP request_execution"]
|
||||||
|
A2["actuator.Execute() → SSH exec"]
|
||||||
|
A3["systemctl restart / apt upgrade / pct exec"]
|
||||||
|
A1 --> A2 --> A3
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph LEARN["🧠 LEARN (Patterns + Skills)"]
|
||||||
|
direction TB
|
||||||
|
L1["execution → produces → feedback"]
|
||||||
|
L2["feedback → contributes-to → pattern"]
|
||||||
|
L3["pattern → informs → skill"]
|
||||||
|
L1 --> L2 --> L3
|
||||||
|
end
|
||||||
|
|
||||||
|
SG --> ORIENT
|
||||||
|
ORIENT --> DECIDE
|
||||||
|
DECIDE --> ACT
|
||||||
|
ACT --> LEARN
|
||||||
|
|
||||||
|
style OBSERVE fill:#e3f2fd
|
||||||
|
style ORIENT fill:#fff3e0
|
||||||
|
style DECIDE fill:#fce4ec
|
||||||
|
style ACT fill:#e8f5e9
|
||||||
|
style LEARN fill:#f3e5f5
|
||||||
|
```
|
||||||
|
|
||||||
|
### What's code-real in the OODA loop
|
||||||
|
|
||||||
|
| Phase | Table | Code | Status |
|
||||||
|
|-------|-------|------|--------|
|
||||||
|
| Observe | `check_defs`, `metric_samples` | `scheduler.go:26-215` | ✅ fully wired, 6 probe kinds |
|
||||||
|
| Observe → Orient | `signals` | `scheduler.go:130-141` (UpsertSignal) | ✅ dedup, severity, events |
|
||||||
|
| Orient | `classifications` | `policy/classify.go` (function exists) | ⚠ function defined but scheduler never calls it |
|
||||||
|
| Decide | `approvals` | `server.go:311-367` (request_execution) | ✅ escalation gate works |
|
||||||
|
| Act | `executions` | `actuator/exec.go` (SSH exec) | ✅ systemctl, apt, pct |
|
||||||
|
| Learn | `feedback`, `patterns`, `skills` | tables + list endpoints only | ⚠ schema only, no write path |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Relationship Types — The Edge Catalog (34 edges)
|
||||||
|
|
||||||
|
### Infrastructure Topology
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
HH["host:hubris"] -->|hosts| LX1["lxc:jellyfin"]
|
||||||
|
HH -->|hosts| LX2["lxc:caddy"]
|
||||||
|
HH -->|hosts| LX3["lxc:dns"]
|
||||||
|
HH -->|hosts| LX4["lxc:gitea"]
|
||||||
|
HH -->|hosts| LX5["lxc:…"]
|
||||||
|
HS["host:strong"] -->|hosts| LX6["lxc:jellyfin"]
|
||||||
|
HS -->|hosts| LX7["lxc:arriman"]
|
||||||
|
HH -->|member-of| CL["cluster:homelab"]
|
||||||
|
HS -->|member-of| CL
|
||||||
|
LX2 -->|provides| SV1["service:caddy"]
|
||||||
|
LX4 -->|provides| SV2["service:gitea"]
|
||||||
|
LX3 -->|provides| SV3["service:dns"]
|
||||||
|
HH -->|mounts| VL["volume:library"]
|
||||||
|
HH -->|stores-on| PL["pool:library-hubris"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
IG["ingress:paperless.hubris.network"] -->|routes-to| SP["service:paperless"]
|
||||||
|
IG -->|secured-by| IDP["idp:authentik"]
|
||||||
|
IG -->|uses-certificate| CRT["cert:*.hubris.network"]
|
||||||
|
SJ["service:jellyfin"] -->|authenticates-via| IDP
|
||||||
|
DNS["dns:paperless"] -->|in-zone| ZN["zone:hubris.network"]
|
||||||
|
DNS -->|resolves-to| LX["lxc:caddy"]
|
||||||
|
HH["host:hubris"] -->|connects-via| LL["lan:lab"]
|
||||||
|
HS["host:strong"] -->|connects-via| LH["lan:household"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Dependencies (feeds blast_radius CTE)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
JF["service:jellyfin"] -->|depends-on| AK["service:authentik"]
|
||||||
|
PP["service:paperless"] -->|depends-on| AK
|
||||||
|
AR["service:arr-stack"] -->|depends-on| JF
|
||||||
|
AK -->|depends-on| CD["service:caddy"]
|
||||||
|
AK -->|depends-on| DNS["service:dns"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cognition (OODA edges)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
CK["check:ssh-script:d419257d"] -->|checks| HH["host:hubris"]
|
||||||
|
CK -->|raises| SG["signal:cpu-pressure"]
|
||||||
|
SG -->|about| HH
|
||||||
|
CL["classification:xyz"] -->|classifies| SG
|
||||||
|
CL -->|precedes| EX["execution:restart-xyz"]
|
||||||
|
EX -->|targets| HH
|
||||||
|
EX -->|performs| AG["agent:nomos"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
DT["person:dtoro"] -->|owns| NO["agent:nomos"]
|
||||||
|
DT -->|decides| AP["approval:xyz"]
|
||||||
|
AK["idp:authentik"] -->|authenticates| DT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Lifecycle State Machines
|
||||||
|
|
||||||
|
### Infrastructure (15 concrete types use this)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> planned
|
||||||
|
planned --> provisioning
|
||||||
|
planned --> destroyed: cancelled
|
||||||
|
|
||||||
|
provisioning --> active
|
||||||
|
provisioning --> failed
|
||||||
|
|
||||||
|
active --> migrating
|
||||||
|
active --> failed
|
||||||
|
active --> deprecated
|
||||||
|
|
||||||
|
migrating --> active: post-verify
|
||||||
|
migrating --> failed
|
||||||
|
|
||||||
|
failed --> active: recovery verified
|
||||||
|
failed --> deprecated: write-off
|
||||||
|
|
||||||
|
deprecated --> active: un-deprecate
|
||||||
|
deprecated --> destroyed
|
||||||
|
|
||||||
|
destroyed --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Real precondition checks** (code in `impl.go:1494-1579`):
|
||||||
|
| Transition | Precondition | How it's checked |
|
||||||
|
|------------|-------------|-----------------|
|
||||||
|
| provisioning→active | `health-check-answering` | `SELECT health FROM entity_status WHERE entity_id=$1` — must be healthy |
|
||||||
|
| provisioning→active | `age-key-enrolled-if-needed` | Checks `attributes->>'age_pubkey'` (workstation only) |
|
||||||
|
| provisioning→active | `mesh-joined-if-needed` | Checks `attributes->>'mesh_ip'` (workstation only) |
|
||||||
|
| provisioning→active | `doc-page-complete` | `SELECT count(*) FROM relationships WHERE target_id=$1 AND type='documents'` |
|
||||||
|
| deprecated→destroyed | `no-inbound-edges` | `SELECT count(*) FROM relationships WHERE target_id=$1 AND valid_to IS NULL` |
|
||||||
|
| any → terminated | `backups-verified` | Checks flag in entity attributes |
|
||||||
|
| any → terminated | `secrets-revoked` | Checks flag in entity attributes |
|
||||||
|
|
||||||
|
**Soft preconditions** (always pass — operator-confirmed): `inventory-entry`, `ip-reserved`, `preflight-passed`, `backup-verified`, `replacement-live`, `caddy-backends-checked`, `un-deprecate-note`, etc.
|
||||||
|
|
||||||
|
### Signal
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> raised
|
||||||
|
raised --> acknowledged
|
||||||
|
raised --> muted: mute_until set
|
||||||
|
raised --> resolved: condition cleared
|
||||||
|
|
||||||
|
acknowledged --> acting: classification exists
|
||||||
|
acknowledged --> muted
|
||||||
|
acknowledged --> resolved
|
||||||
|
|
||||||
|
acting --> resolved: verification passed
|
||||||
|
acting --> raised: retry budget
|
||||||
|
acting --> failed
|
||||||
|
|
||||||
|
failed --> acknowledged: operator retry
|
||||||
|
|
||||||
|
muted --> raised: mute_until expired
|
||||||
|
|
||||||
|
resolved --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implemented preconditions:**
|
||||||
|
- `raised → muted`: requires `mute_until` set (MuteSignal handler, `impl.go:615-689`)
|
||||||
|
- `acting → resolved`: requires `verification-passed` (soft — operator confirms)
|
||||||
|
|
||||||
|
**Dedup mechanism:** `UNIQUE INDEX uq_signals_open ON signals(target_entity_id, kind) WHERE state NOT IN ('resolved','failed')` — at most one open signal per (entity, kind). Repeated failures call `UpsertSignal` which increments `occurrence_count` on the existing row.
|
||||||
|
|
||||||
|
### Execution
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> proposed
|
||||||
|
proposed --> approved
|
||||||
|
proposed --> auto_approved
|
||||||
|
proposed --> denied
|
||||||
|
proposed --> expired
|
||||||
|
|
||||||
|
approved --> executing
|
||||||
|
auto_approved --> executing
|
||||||
|
expired --> [*]
|
||||||
|
denied --> [*]
|
||||||
|
|
||||||
|
executing --> verified
|
||||||
|
executing --> failed
|
||||||
|
executing --> timed_out
|
||||||
|
|
||||||
|
failed --> rolled_back
|
||||||
|
rolled_back --> verified
|
||||||
|
rolled_back --> rollback_failed
|
||||||
|
|
||||||
|
verified --> [*]
|
||||||
|
rollback_failed --> [*]
|
||||||
|
timed_out --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Approval
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> pending
|
||||||
|
pending --> approved
|
||||||
|
pending --> denied
|
||||||
|
approved --> revoked
|
||||||
|
approved --> expired
|
||||||
|
|
||||||
|
denied --> [*]
|
||||||
|
revoked --> [*]
|
||||||
|
expired --> [*]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. What's Code-Real vs Schema-Only
|
||||||
|
|
||||||
|
### ✅ Fully Implemented (code exists, running in production)
|
||||||
|
|
||||||
|
| Component | File(s) | What it does |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| Entity CRUD | `impl.go:811-966` | Create, read, patch, list entities |
|
||||||
|
| Lifecycle transitions | `impl.go:1494-1579` | Precondition checks + state transitions |
|
||||||
|
| Relationship management | `seed.go` (ingest) | Create edges with `valid_from/valid_to` |
|
||||||
|
| Client enrollment | `impl.go:1134-1236` | `POST /clients/enroll` — age keypair, Infisical, state: provisioning |
|
||||||
|
| Check definitions | `phase3.go:265-376` | CreateCheck, ListChecks, PatchCheck |
|
||||||
|
| Scheduler observe | `scheduler.go:26-215` | 6 probe kinds, metric_samples, signals, entity_status |
|
||||||
|
| Signals | `scheduler.go:81-161` | UpsertSignal (dedup), ResolveSignal, severity evaluation |
|
||||||
|
| Executions | `server.go:286-411` | request_execution MCP tool — reversible_low/config_mutation/destructive |
|
||||||
|
| Approvals | `server.go:970-1052` | createApproval, DecideApproval → executeApprovedAction |
|
||||||
|
| Notifier | `notifier/notifier.go` | Matrix alerts for pending approvals |
|
||||||
|
| Patterns | `phase3.go:1100+` | ListPatterns, PatchPattern (status/quarantine) |
|
||||||
|
| Skills | `phase3.go:1300+` | ListSkills, PatchSkill, ListSkillVersions |
|
||||||
|
| Default checks | `checkdefaults/defaults.go` | Auto-create checks on entity creation/enrollment/seed |
|
||||||
|
| TimescaleDB metrics | `metric_samples` table | Hypertable with 1h/1d continuous aggregates, 90-day retention |
|
||||||
|
| Events + SSE | `events` table + pg_notify | Real-time UI updates via SSE endpoint |
|
||||||
|
| Audit log | `audit_log` hypertable | Every mutation with actor + action |
|
||||||
|
| Knowledge entities | `knowledge_entities` | Documents, runbooks, investigations with FTS |
|
||||||
|
| MCP tools | `server.go` | 24 tools for observe/orient/decide/act |
|
||||||
|
| Blast radius | `blast_radius()` fn | Recursive CTE — depends-on + hosts + routes-to edges |
|
||||||
|
|
||||||
|
### ⚠ Schema Defined, Not Yet Wired (table exists, no active code path creates rows)
|
||||||
|
|
||||||
|
| Component | What's Missing |
|
||||||
|
|-----------|---------------|
|
||||||
|
| `classifications` auto-creation | `policy.ClassifySignal()` exists but scheduler never calls it. Signals are raised but never automatically classified. |
|
||||||
|
| `feedback` records | No code writes to the `feedback` table. Execution results are not analyzed for patterns. |
|
||||||
|
| Pattern auto-learning | No code transitions patterns from `hypothesized → validated`. Requires `evidence-count≥5 + confidence≥0.7` but no aggregation runs. |
|
||||||
|
| Skill execution | Skill entities carry a JSON `procedure` field but no execution engine reads or runs it. |
|
||||||
|
| `drift` check kind | Defined in OpenAPI and `check_defs.kind` enum, but no scheduler implementation exists. |
|
||||||
|
|
||||||
|
### 📋 Defined in Seeds Only (ontology.yaml references, no DB schema)
|
||||||
|
|
||||||
|
| Item | Notes |
|
||||||
|
|------|-------|
|
||||||
|
| Relationship type `cluster` | Mentioned in inventory but not in ontology relationship_types |
|
||||||
|
| `certificate` entity type | Referenced in `uses-certificate` edges but no concrete certificates in inventory |
|
||||||
|
| Relationship type `powers` / `monitors` | Not defined in relationship_types |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Database Physical Schema
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
lifecycle_defs ||--o{ entity_types : "lifecycle_id FK"
|
||||||
|
entity_types ||--o{ entities : "type FK"
|
||||||
|
entity_types ||--o| entity_types : "parent_type FK (self-ref)"
|
||||||
|
|
||||||
|
entities ||--o| entity_status : "dual entity (shared PK)"
|
||||||
|
entities ||--o| check_defs : "dual entity (shared PK)"
|
||||||
|
entities ||--o| signals : "dual entity (shared PK)"
|
||||||
|
entities ||--o| classifications : "dual entity (shared PK)"
|
||||||
|
entities ||--o| executions : "dual entity (shared PK)"
|
||||||
|
entities ||--o| feedback : "dual entity (shared PK)"
|
||||||
|
entities ||--o| patterns : "dual entity (shared PK)"
|
||||||
|
entities ||--o| skills : "dual entity (shared PK)"
|
||||||
|
entities ||--o| approvals : "dual entity (shared PK)"
|
||||||
|
entities ||--o| knowledge_entities : "dual entity (shared PK)"
|
||||||
|
|
||||||
|
entities ||--o{ relationships : "source_id FK"
|
||||||
|
entities ||--o{ relationships : "target_id FK"
|
||||||
|
|
||||||
|
check_defs }o--|| entities : "target_id FK"
|
||||||
|
signals }o--|| entities : "target_entity_id FK"
|
||||||
|
executions }o--|| entities : "target_entity_id FK"
|
||||||
|
approvals }o--|| entities : "subject_entity_id FK"
|
||||||
|
|
||||||
|
entity_types ||--o{ relationship_types : "source_type FK"
|
||||||
|
entity_types ||--o{ relationship_types : "target_type FK"
|
||||||
|
relationship_types ||--o{ relationships : "type FK"
|
||||||
|
|
||||||
|
entity_types ||--o{ approval_rules : "entity_type FK"
|
||||||
|
|
||||||
|
signals ||--o| check_defs : "check_id FK"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key architectural patterns:**
|
||||||
|
- **Dual entities:** `check_defs`, `signals`, `classifications`, `executions`, `feedback`, `patterns`, `skills`, `approvals`, `knowledge_entities` — all have `entity_id UUID PK REFERENCES entities(id)`. Every row is also an entity.
|
||||||
|
- **Partial unique indexes:** `relationships` (current edges), `signals` (open signals), `patterns` (per-type action) — all use `WHERE` clauses for snapshot semantics.
|
||||||
|
- **TimescaleDB hypertables:** `metric_samples`, `events`, `audit_log`, `agent_activity` — with continuous aggregates and retention policies.
|
||||||
|
- **SSE fan-out:** `pg_notify('oikos_events', ...)` trigger on `events` INSERT → Go listener fan-out → SSE connections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. How Nomos Queries Thermals — End-to-End Trace
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant N as Nomos (Agent)
|
||||||
|
participant A as Oikos API
|
||||||
|
participant T as TimescaleDB
|
||||||
|
participant S as Scheduler
|
||||||
|
participant H as Hubris
|
||||||
|
|
||||||
|
Note over S,H: Autonomous collection (every 60s)
|
||||||
|
S->>H: SSH exec cpu_check.sh
|
||||||
|
H-->>S: {"metrics":{"cpu_pct":15,"cpu_temp":48}}
|
||||||
|
S->>T: INSERT metric_samples (cpu_pct, cpu_temp)
|
||||||
|
|
||||||
|
Note over U,N: User asks question
|
||||||
|
U->>N: "what are the thermals of hubris?"
|
||||||
|
N->>A: MCP query_metrics(metric=["cpu_pct","cpu_temp"])
|
||||||
|
A->>T: SELECT time_bucket('1h', ts) … FROM metric_samples
|
||||||
|
T-->>A: {avg:15, min:2, max:40} (cpu_pct)
|
||||||
|
T-->>A: {avg:48, min:42, max:85} (cpu_temp)
|
||||||
|
A-->>N: time-bucketed metrics
|
||||||
|
N-->>U: "CPU at 15%, temp 48°C — normal range"
|
||||||
|
```
|
||||||
|
|
||||||
|
**What made this possible (chronologically):**
|
||||||
|
1. `scheduler.go` refactored to return metrics map → `checkResult{metrics}`
|
||||||
|
2. `ssh-script` check kind implemented → SSH exec to remote host
|
||||||
|
3. `cpu_check.sh` deployed to hubris → returns `{"metrics":{"cpu_pct":2.5,"cpu_temp":48}}`
|
||||||
|
4. Check created: `POST /checks {"kind":"ssh-script","target":"host:hubris","config":{"host":"192.168.8.77","script":"cpu_check.sh"}}`
|
||||||
|
5. Fixed: `InsertMetricSample` missing `ts` column → `now()` literal
|
||||||
|
6. Fixed: SSH port/user parsing bugs
|
||||||
|
7. Fixed: SSH warnings polluting JSON output
|
||||||
|
8. Scheduler loop → metrics written to TimescaleDB every 60s
|
||||||
|
9. MCP `query_metrics` reads from TimescaleDB → Nomos gets live data
|
||||||
@@ -5,7 +5,7 @@ after acceptance — superseding decisions get a new ADR that links back.
|
|||||||
Statuses: proposed | accepted | superseded-by-NNNN.
|
Statuses: proposed | accepted | superseded-by-NNNN.
|
||||||
|
|
||||||
| ADR | Title |
|
| ADR | Title |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| [0001](0001-go-single-binary.md) | Go with single-binary role packaging |
|
| [0001](0001-go-single-binary.md) | Go with single-binary role packaging |
|
||||||
| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore |
|
| [0002](0002-postgres-timescale-only-datastore.md) | PostgreSQL + TimescaleDB as the only datastore |
|
||||||
| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests |
|
| [0003](0003-db-native-ontology-yaml-seeds.md) | DB-native ontology with YAML seed manifests |
|
||||||
@@ -16,3 +16,7 @@ Statuses: proposed | accepted | superseded-by-NNNN.
|
|||||||
| [0008](0008-forward-only-migrations.md) | Forward-only migrations |
|
| [0008](0008-forward-only-migrations.md) | Forward-only migrations |
|
||||||
| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream |
|
| [0009](0009-sse-over-websocket.md) | SSE over WebSocket for the event stream |
|
||||||
| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback |
|
| [0010](0010-infisical-with-sops-fallback.md) | Infisical secrets with SOPS DR fallback |
|
||||||
|
| [0011](0011-client-lifecycle-flows.md) | Client lifecycle flows — enrollment, bootstrap, sync |
|
||||||
|
| [0012](0012-hermes-oikos-interactions.md) | Hermes–Oikos interactions — agent/OS contract |
|
||||||
|
| [0013](0013-signal-triggers.md) | Signal triggers — host health checks via scheduler |
|
||||||
|
| [0014](0014-entity-model.md) | Entity model — types, relationships, state machines, OODA loop |
|
||||||
|
|||||||
204
internal/checkdefaults/defaults.go
Normal file
204
internal/checkdefaults/defaults.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package checkdefaults
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CheckDef struct {
|
||||||
|
Kind string
|
||||||
|
Script string
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Port int
|
||||||
|
Thresholds map[string]any
|
||||||
|
Extra map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveHost(attrs map[string]any) string {
|
||||||
|
if ip, ok := attrs["lan_ip"].(string); ok && ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
if mesh, ok := attrs["mesh"].(map[string]any); ok {
|
||||||
|
if nb, ok := mesh["netbird"].(map[string]any); ok {
|
||||||
|
if ip, ok := nb["ip"].(string); ok && ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ip, ok := attrs["mesh_ip"].(string); ok && ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSSHUser(attrs map[string]any) string {
|
||||||
|
if ssh, ok := attrs["ssh"].(map[string]any); ok {
|
||||||
|
if u, ok := ssh["user"].(string); ok && u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSSHPort(attrs map[string]any) int {
|
||||||
|
if ssh, ok := attrs["ssh"].(map[string]any); ok {
|
||||||
|
switch p := ssh["port"].(type) {
|
||||||
|
case float64:
|
||||||
|
return int(p)
|
||||||
|
case int:
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 22
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForEntityType(entityType string, attrs map[string]any) []CheckDef {
|
||||||
|
host := ResolveHost(attrs)
|
||||||
|
user := resolveSSHUser(attrs)
|
||||||
|
port := resolveSSHPort(attrs)
|
||||||
|
|
||||||
|
ssh := func(script string) CheckDef {
|
||||||
|
return CheckDef{Kind: "ssh-script", Script: script, Host: host, User: user, Port: port}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch entityType {
|
||||||
|
case "proxmox-host", "standalone-server":
|
||||||
|
if host == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []CheckDef{
|
||||||
|
{Kind: "ping", Host: host},
|
||||||
|
ssh("cpu_check.sh"),
|
||||||
|
ssh("memory_check.sh"),
|
||||||
|
ssh("load_check.sh"),
|
||||||
|
ssh("disk_usage_check.sh"),
|
||||||
|
ssh("updates_check.sh"),
|
||||||
|
}
|
||||||
|
case "workstation":
|
||||||
|
if host == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []CheckDef{
|
||||||
|
{Kind: "ping", Host: host},
|
||||||
|
ssh("cpu_check.sh"),
|
||||||
|
ssh("memory_check.sh"),
|
||||||
|
ssh("load_check.sh"),
|
||||||
|
}
|
||||||
|
case "lxc":
|
||||||
|
if host == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []CheckDef{
|
||||||
|
ssh("cpu_check.sh"),
|
||||||
|
ssh("memory_check.sh"),
|
||||||
|
ssh("load_check.sh"),
|
||||||
|
ssh("disk_usage_check.sh"),
|
||||||
|
}
|
||||||
|
case "vm":
|
||||||
|
if host == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []CheckDef{
|
||||||
|
{Kind: "ping", Host: host},
|
||||||
|
}
|
||||||
|
case "service":
|
||||||
|
if host == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, _ := attrs["name"].(string)
|
||||||
|
if n == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []CheckDef{
|
||||||
|
{Kind: "ssh-script", Script: "process_check.sh", Host: host, User: user, Port: port,
|
||||||
|
Extra: map[string]any{"args": n}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ShortSlug(slug string) string {
|
||||||
|
const n = 8
|
||||||
|
if len(slug) > n {
|
||||||
|
return slug[len(slug)-n:]
|
||||||
|
}
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultInterval(kind string) int32 {
|
||||||
|
switch kind {
|
||||||
|
case "ping":
|
||||||
|
return 30
|
||||||
|
case "ssh-script":
|
||||||
|
return 60
|
||||||
|
default:
|
||||||
|
return 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Ensure(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType string, attrsJSON []byte) {
|
||||||
|
_, _ = tx.Exec(ctx,
|
||||||
|
`INSERT INTO entity_status (entity_id, health, updated_at)
|
||||||
|
VALUES ($1, 'unknown', now())
|
||||||
|
ON CONFLICT (entity_id) DO NOTHING`,
|
||||||
|
entityID)
|
||||||
|
|
||||||
|
var attrs map[string]any
|
||||||
|
if len(attrsJSON) > 0 {
|
||||||
|
json.Unmarshal(attrsJSON, &attrs)
|
||||||
|
}
|
||||||
|
if attrs == nil {
|
||||||
|
attrs = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
defs := ForEntityType(entityType, attrs)
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, def := range defs {
|
||||||
|
checkID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
checkID = uuid.New()
|
||||||
|
}
|
||||||
|
checkSlug := fmt.Sprintf("check:%s:%s:%d", def.Kind, ShortSlug(slug), i)
|
||||||
|
|
||||||
|
_, _ = tx.Exec(ctx,
|
||||||
|
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, 'check', $2, 'active', '{}', 1, now(), now())
|
||||||
|
ON CONFLICT (slug) DO NOTHING`,
|
||||||
|
checkID, checkSlug)
|
||||||
|
|
||||||
|
configMap := map[string]any{}
|
||||||
|
if def.Script != "" {
|
||||||
|
configMap["script"] = def.Script
|
||||||
|
}
|
||||||
|
if def.Host != "" {
|
||||||
|
configMap["host"] = def.Host
|
||||||
|
}
|
||||||
|
if def.User != "" && def.User != "root" {
|
||||||
|
configMap["user"] = def.User
|
||||||
|
}
|
||||||
|
if def.Port != 0 && def.Port != 22 {
|
||||||
|
configMap["port"] = def.Port
|
||||||
|
}
|
||||||
|
if def.Thresholds != nil {
|
||||||
|
configMap["thresholds"] = def.Thresholds
|
||||||
|
}
|
||||||
|
for k, v := range def.Extra {
|
||||||
|
configMap[k] = v
|
||||||
|
}
|
||||||
|
configJSON, _ := json.Marshal(configMap)
|
||||||
|
|
||||||
|
_, _ = tx.Exec(ctx,
|
||||||
|
`INSERT INTO check_defs (entity_id, target_id, kind, config, interval_s, timeout_s, enabled)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 30, true)
|
||||||
|
ON CONFLICT (entity_id) DO NOTHING`,
|
||||||
|
checkID, entityID, def.Kind, configJSON, DefaultInterval(def.Kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -240,8 +240,8 @@ SELECT * FROM risk_classes ORDER BY name;
|
|||||||
SELECT * FROM approval_rules ORDER BY entity_type, action;
|
SELECT * FROM approval_rules ORDER BY entity_type, action;
|
||||||
|
|
||||||
-- name: InsertMetricSample :exec
|
-- name: InsertMetricSample :exec
|
||||||
INSERT INTO metric_samples (entity_id, metric, value, tags)
|
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
||||||
VALUES ($1, $2, $3, $4);
|
VALUES ($1, $2, $3, $4, now());
|
||||||
|
|
||||||
-- name: QueryMetrics :many
|
-- name: QueryMetrics :many
|
||||||
SELECT time_bucket(sqlc.arg('bucket_interval')::interval, ts) AS bucket,
|
SELECT time_bucket(sqlc.arg('bucket_interval')::interval, ts) AS bucket,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -143,6 +144,8 @@ func IngestInventorySeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*
|
|||||||
return nil, fmt.Errorf("entity_status %s: %w", slug, err)
|
return nil, fmt.Errorf("entity_status %s: %w", slug, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkdefaults.Ensure(ctx, tx, entityID, slug, typeName, attrsBytes)
|
||||||
|
|
||||||
r.Entities++
|
r.Entities++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ type AgentActivity struct {
|
|||||||
CorrelationID *string
|
CorrelationID *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentMessage struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
SessionID uuid.UUID
|
||||||
|
Role string
|
||||||
|
Content []byte
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentSession struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Title string
|
||||||
|
Actor string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastActiveAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Approval struct {
|
type Approval struct {
|
||||||
EntityID uuid.UUID
|
EntityID uuid.UUID
|
||||||
SubjectEntityID *uuid.UUID
|
SubjectEntityID *uuid.UUID
|
||||||
|
|||||||
@@ -560,8 +560,8 @@ func (q *Queries) InsertFeedback(ctx context.Context, arg InsertFeedbackParams)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertMetricSample = `-- name: InsertMetricSample :exec
|
const insertMetricSample = `-- name: InsertMetricSample :exec
|
||||||
INSERT INTO metric_samples (entity_id, metric, value, tags)
|
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, now())
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertMetricSampleParams struct {
|
type InsertMetricSampleParams struct {
|
||||||
|
|||||||
172
internal/httpapi/dashboard.go
Normal file
172
internal/httpapi/dashboard.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDashboardSummary returns one round-trip overview for the control room
|
||||||
|
// home page: entity counts, health rollup, open signals, pending approvals,
|
||||||
|
// executions in the last 24h, and an event-rate sparkline.
|
||||||
|
func (s *Server) GetDashboardSummary(ctx context.Context, req gen.GetDashboardSummaryRequestObject) (gen.GetDashboardSummaryResponseObject, error) {
|
||||||
|
resp := gen.DashboardSummary{
|
||||||
|
EntitiesByType: map[string]int{},
|
||||||
|
EntitiesByState: map[string]int{},
|
||||||
|
SignalsBySeverity: map[string]int{},
|
||||||
|
ExecutionsByState: map[string]int{},
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT type, count(*) FROM entities GROUP BY type`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var typ string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&typ, &n); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.EntitiesByType[typ] = n
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = s.pool.Query(ctx, `SELECT coalesce(state, 'unknown'), count(*) FROM entities GROUP BY state`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var state string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&state, &n); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.EntitiesByState[state] = n
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if err := rows.Scan(&health, &n); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
switch health {
|
||||||
|
case "healthy":
|
||||||
|
resp.Health.Healthy = n
|
||||||
|
case "degraded":
|
||||||
|
resp.Health.Degraded = n
|
||||||
|
case "down":
|
||||||
|
resp.Health.Down = n
|
||||||
|
case "stale":
|
||||||
|
stale = n
|
||||||
|
default:
|
||||||
|
resp.Health.Unknown = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
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
|
||||||
|
WHERE state NOT IN ('resolved', 'failed')
|
||||||
|
GROUP BY severity`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var severity string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&severity, &n); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.SignalsBySeverity[severity] = n
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM approvals WHERE status = 'pending'`,
|
||||||
|
).Scan(&resp.ApprovalsPending); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = s.pool.Query(ctx, `
|
||||||
|
SELECT status, count(*) FROM executions
|
||||||
|
WHERE created_at > now() - interval '24 hours'
|
||||||
|
GROUP BY status`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var status string
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&status, &n); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.ExecutionsByState[status] = n
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = s.pool.Query(ctx, `
|
||||||
|
SELECT date_trunc('hour', ts) + (extract(minute FROM ts)::int / 5) * interval '5 minutes' AS bucket,
|
||||||
|
count(*)
|
||||||
|
FROM events
|
||||||
|
WHERE ts > now() - interval '6 hours'
|
||||||
|
GROUP BY bucket
|
||||||
|
ORDER BY bucket`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var bucket time.Time
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&bucket, &n); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.EventRate = append(resp.EventRate, struct {
|
||||||
|
Bucket time.Time `json:"bucket"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}{Bucket: bucket, Count: n})
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return gen.GetDashboardSummary200JSONResponse(resp), nil
|
||||||
|
}
|
||||||
13
internal/httpapi/default_checks.go
Normal file
13
internal/httpapi/default_checks.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureDefaultChecks(ctx context.Context, tx pgx.Tx, entityID uuid.UUID, slug, entityType string, attrsJSON []byte) {
|
||||||
|
checkdefaults.Ensure(ctx, tx, entityID, slug, entityType, attrsJSON)
|
||||||
|
}
|
||||||
@@ -81,6 +81,7 @@ const (
|
|||||||
CheckKindDisk CheckKind = "disk"
|
CheckKindDisk CheckKind = "disk"
|
||||||
CheckKindDrift CheckKind = "drift"
|
CheckKindDrift CheckKind = "drift"
|
||||||
CheckKindHttp CheckKind = "http"
|
CheckKindHttp CheckKind = "http"
|
||||||
|
CheckKindPing CheckKind = "ping"
|
||||||
CheckKindSshScript CheckKind = "ssh-script"
|
CheckKindSshScript CheckKind = "ssh-script"
|
||||||
CheckKindTcp CheckKind = "tcp"
|
CheckKindTcp CheckKind = "tcp"
|
||||||
)
|
)
|
||||||
@@ -91,6 +92,7 @@ const (
|
|||||||
CheckCreateKindDisk CheckCreateKind = "disk"
|
CheckCreateKindDisk CheckCreateKind = "disk"
|
||||||
CheckCreateKindDrift CheckCreateKind = "drift"
|
CheckCreateKindDrift CheckCreateKind = "drift"
|
||||||
CheckCreateKindHttp CheckCreateKind = "http"
|
CheckCreateKindHttp CheckCreateKind = "http"
|
||||||
|
CheckCreateKindPing CheckCreateKind = "ping"
|
||||||
CheckCreateKindSshScript CheckCreateKind = "ssh-script"
|
CheckCreateKindSshScript CheckCreateKind = "ssh-script"
|
||||||
CheckCreateKindTcp CheckCreateKind = "tcp"
|
CheckCreateKindTcp CheckCreateKind = "tcp"
|
||||||
)
|
)
|
||||||
@@ -102,6 +104,15 @@ const (
|
|||||||
ClassificationRouteHold ClassificationRoute = "hold"
|
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.
|
// Defines values for EntityTypeLayer.
|
||||||
const (
|
const (
|
||||||
EntityTypeLayerCognition EntityTypeLayer = "cognition"
|
EntityTypeLayerCognition EntityTypeLayer = "cognition"
|
||||||
@@ -153,11 +164,21 @@ const (
|
|||||||
ExecutionStatusVerifying ExecutionStatus = "verifying"
|
ExecutionStatusVerifying ExecutionStatus = "verifying"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Defines values for GraphViewHealth.
|
||||||
|
const (
|
||||||
|
GraphViewHealthDegraded GraphViewHealth = "degraded"
|
||||||
|
GraphViewHealthDown GraphViewHealth = "down"
|
||||||
|
GraphViewHealthHealthy GraphViewHealth = "healthy"
|
||||||
|
GraphViewHealthStale GraphViewHealth = "stale"
|
||||||
|
GraphViewHealthUnknown GraphViewHealth = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
// Defines values for HealthSummaryEntitiesHealth.
|
// Defines values for HealthSummaryEntitiesHealth.
|
||||||
const (
|
const (
|
||||||
HealthSummaryEntitiesHealthDegraded HealthSummaryEntitiesHealth = "degraded"
|
HealthSummaryEntitiesHealthDegraded HealthSummaryEntitiesHealth = "degraded"
|
||||||
HealthSummaryEntitiesHealthDown HealthSummaryEntitiesHealth = "down"
|
HealthSummaryEntitiesHealthDown HealthSummaryEntitiesHealth = "down"
|
||||||
HealthSummaryEntitiesHealthHealthy HealthSummaryEntitiesHealth = "healthy"
|
HealthSummaryEntitiesHealthHealthy HealthSummaryEntitiesHealth = "healthy"
|
||||||
|
HealthSummaryEntitiesHealthStale HealthSummaryEntitiesHealth = "stale"
|
||||||
HealthSummaryEntitiesHealthUnknown HealthSummaryEntitiesHealth = "unknown"
|
HealthSummaryEntitiesHealthUnknown HealthSummaryEntitiesHealth = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -295,6 +316,11 @@ const (
|
|||||||
Out GetEntityRelationsParamsDirection = "out"
|
Out GetEntityRelationsParamsDirection = "out"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Defines values for GetGraphParamsInclude.
|
||||||
|
const (
|
||||||
|
Status GetGraphParamsInclude = "status"
|
||||||
|
)
|
||||||
|
|
||||||
// Defines values for QueryMetricsParamsRollup.
|
// Defines values for QueryMetricsParamsRollup.
|
||||||
const (
|
const (
|
||||||
QueryMetricsParamsRollupAuto QueryMetricsParamsRollup = "auto"
|
QueryMetricsParamsRollupAuto QueryMetricsParamsRollup = "auto"
|
||||||
@@ -520,6 +546,38 @@ type ClientSecrets struct {
|
|||||||
Keys []string `json:"keys"`
|
Keys []string `json:"keys"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DashboardSummary defines model for DashboardSummary.
|
||||||
|
type DashboardSummary struct {
|
||||||
|
ApprovalsPending int `json:"approvals_pending"`
|
||||||
|
|
||||||
|
// EntitiesByState entity counts keyed by state
|
||||||
|
EntitiesByState map[string]int `json:"entities_by_state"`
|
||||||
|
|
||||||
|
// EntitiesByType entity counts keyed by type
|
||||||
|
EntitiesByType map[string]int `json:"entities_by_type"`
|
||||||
|
|
||||||
|
// EventRate event counts bucketed by 5-minute interval, most recent last
|
||||||
|
EventRate []struct {
|
||||||
|
Bucket time.Time `json:"bucket"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"event_rate"`
|
||||||
|
|
||||||
|
// ExecutionsByState execution counts keyed by state, last 24h
|
||||||
|
ExecutionsByState map[string]int `json:"executions_by_state"`
|
||||||
|
Health struct {
|
||||||
|
Degraded int `json:"degraded"`
|
||||||
|
Down int `json:"down"`
|
||||||
|
Healthy int `json:"healthy"`
|
||||||
|
|
||||||
|
// 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
|
||||||
|
SignalsBySeverity map[string]int `json:"signals_by_severity"`
|
||||||
|
}
|
||||||
|
|
||||||
// EnrollRequest defines model for EnrollRequest.
|
// EnrollRequest defines model for EnrollRequest.
|
||||||
type EnrollRequest struct {
|
type EnrollRequest struct {
|
||||||
// Hostname Actual hostname of the enrolling machine
|
// Hostname Actual hostname of the enrolling machine
|
||||||
@@ -554,7 +612,13 @@ type EnrollResponse struct {
|
|||||||
type Entity struct {
|
type Entity struct {
|
||||||
Attributes *map[string]interface{} `json:"attributes,omitempty"`
|
Attributes *map[string]interface{} `json:"attributes,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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"`
|
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"`
|
MaintenanceUntil *time.Time `json:"maintenance_until"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
@@ -564,6 +628,9 @@ type Entity struct {
|
|||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EntityHealth last observed health, when the entity is monitored
|
||||||
|
type EntityHealth string
|
||||||
|
|
||||||
// EntityCreate defines model for EntityCreate.
|
// EntityCreate defines model for EntityCreate.
|
||||||
type EntityCreate struct {
|
type EntityCreate struct {
|
||||||
// Attributes Validated against the type's attribute_schema
|
// Attributes Validated against the type's attribute_schema
|
||||||
@@ -696,12 +763,18 @@ type ExecutionRequest struct {
|
|||||||
// GraphView defines model for GraphView.
|
// GraphView defines model for GraphView.
|
||||||
type GraphView struct {
|
type GraphView struct {
|
||||||
Edges []Relationship `json:"edges"`
|
Edges []Relationship `json:"edges"`
|
||||||
|
|
||||||
|
// Health entity id -> health, present when include=status was requested
|
||||||
|
Health *map[string]GraphViewHealth `json:"health,omitempty"`
|
||||||
Nodes []Entity `json:"nodes"`
|
Nodes []Entity `json:"nodes"`
|
||||||
|
|
||||||
// Truncated True if node cap was hit
|
// Truncated True if node cap was hit
|
||||||
Truncated *bool `json:"truncated,omitempty"`
|
Truncated *bool `json:"truncated,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GraphViewHealth defines model for GraphView.Health.
|
||||||
|
type GraphViewHealth string
|
||||||
|
|
||||||
// HealthSummary defines model for HealthSummary.
|
// HealthSummary defines model for HealthSummary.
|
||||||
type HealthSummary struct {
|
type HealthSummary struct {
|
||||||
Entities []struct {
|
Entities []struct {
|
||||||
@@ -715,6 +788,9 @@ type HealthSummary struct {
|
|||||||
Degraded int `json:"degraded"`
|
Degraded int `json:"degraded"`
|
||||||
Down int `json:"down"`
|
Down int `json:"down"`
|
||||||
Healthy int `json:"healthy"`
|
Healthy int `json:"healthy"`
|
||||||
|
|
||||||
|
// Stale last observation older than the check's expected cadence
|
||||||
|
Stale *int `json:"stale,omitempty"`
|
||||||
Unknown int `json:"unknown"`
|
Unknown int `json:"unknown"`
|
||||||
} `json:"summary"`
|
} `json:"summary"`
|
||||||
}
|
}
|
||||||
@@ -1234,8 +1310,14 @@ type GetGraphParams struct {
|
|||||||
Root *string `form:"root,omitempty" json:"root,omitempty"`
|
Root *string `form:"root,omitempty" json:"root,omitempty"`
|
||||||
Depth *int `form:"depth,omitempty" json:"depth,omitempty"`
|
Depth *int `form:"depth,omitempty" json:"depth,omitempty"`
|
||||||
RelType *[]string `form:"rel_type,omitempty" json:"rel_type,omitempty"`
|
RelType *[]string `form:"rel_type,omitempty" json:"rel_type,omitempty"`
|
||||||
|
|
||||||
|
// Include include=status joins entity_status and populates GraphView.health
|
||||||
|
Include *[]GetGraphParamsInclude `form:"include,omitempty" json:"include,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGraphParamsInclude defines parameters for GetGraph.
|
||||||
|
type GetGraphParamsInclude string
|
||||||
|
|
||||||
// SearchKnowledgeParams defines parameters for SearchKnowledge.
|
// SearchKnowledgeParams defines parameters for SearchKnowledge.
|
||||||
type SearchKnowledgeParams struct {
|
type SearchKnowledgeParams struct {
|
||||||
Q string `form:"q" json:"q"`
|
Q string `form:"q" json:"q"`
|
||||||
@@ -1469,6 +1551,9 @@ type ServerInterface interface {
|
|||||||
// List secrets accessible to this client
|
// List secrets accessible to this client
|
||||||
// (GET /clients/{slug}/secrets)
|
// (GET /clients/{slug}/secrets)
|
||||||
GetClientSecrets(w http.ResponseWriter, r *http.Request, slug EntitySlug)
|
GetClientSecrets(w http.ResponseWriter, r *http.Request, slug EntitySlug)
|
||||||
|
// One-round-trip overview for the control room home page
|
||||||
|
// (GET /dashboard/summary)
|
||||||
|
GetDashboardSummary(w http.ResponseWriter, r *http.Request)
|
||||||
// List entities
|
// List entities
|
||||||
// (GET /entities)
|
// (GET /entities)
|
||||||
ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams)
|
ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams)
|
||||||
@@ -1664,6 +1749,12 @@ func (_ Unimplemented) GetClientSecrets(w http.ResponseWriter, r *http.Request,
|
|||||||
w.WriteHeader(http.StatusNotImplemented)
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-round-trip overview for the control room home page
|
||||||
|
// (GET /dashboard/summary)
|
||||||
|
func (_ Unimplemented) GetDashboardSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
// List entities
|
// List entities
|
||||||
// (GET /entities)
|
// (GET /entities)
|
||||||
func (_ Unimplemented) ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams) {
|
func (_ Unimplemented) ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams) {
|
||||||
@@ -2537,6 +2628,26 @@ func (siw *ServerInterfaceWrapper) GetClientSecrets(w http.ResponseWriter, r *ht
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDashboardSummary operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) GetDashboardSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.GetDashboardSummary(w, r)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// ListEntities operation middleware
|
// ListEntities operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) ListEntities(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) ListEntities(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -3305,6 +3416,14 @@ func (siw *ServerInterfaceWrapper) GetGraph(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------- Optional query parameter "include" -------------
|
||||||
|
|
||||||
|
err = runtime.BindQueryParameter("form", true, false, "include", r.URL.Query(), ¶ms.Include)
|
||||||
|
if err != nil {
|
||||||
|
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include", Err: err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
siw.Handler.GetGraph(w, r, params)
|
siw.Handler.GetGraph(w, r, params)
|
||||||
}))
|
}))
|
||||||
@@ -4547,6 +4666,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get(options.BaseURL+"/clients/{slug}/secrets", wrapper.GetClientSecrets)
|
r.Get(options.BaseURL+"/clients/{slug}/secrets", wrapper.GetClientSecrets)
|
||||||
})
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Get(options.BaseURL+"/dashboard/summary", wrapper.GetDashboardSummary)
|
||||||
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get(options.BaseURL+"/entities", wrapper.ListEntities)
|
r.Get(options.BaseURL+"/entities", wrapper.ListEntities)
|
||||||
})
|
})
|
||||||
@@ -5020,6 +5142,34 @@ func (response GetClientSecretsdefaultApplicationProblemPlusJSONResponse) VisitG
|
|||||||
return json.NewEncoder(w).Encode(response.Body)
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetDashboardSummaryRequestObject struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetDashboardSummaryResponseObject interface {
|
||||||
|
VisitGetDashboardSummaryResponse(w http.ResponseWriter) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetDashboardSummary200JSONResponse DashboardSummary
|
||||||
|
|
||||||
|
func (response GetDashboardSummary200JSONResponse) VisitGetDashboardSummaryResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetDashboardSummarydefaultApplicationProblemPlusJSONResponse struct {
|
||||||
|
Body Problem
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response GetDashboardSummarydefaultApplicationProblemPlusJSONResponse) VisitGetDashboardSummaryResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/problem+json")
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
|
}
|
||||||
|
|
||||||
type ListEntitiesRequestObject struct {
|
type ListEntitiesRequestObject struct {
|
||||||
Params ListEntitiesParams
|
Params ListEntitiesParams
|
||||||
}
|
}
|
||||||
@@ -6401,6 +6551,9 @@ type StrictServerInterface interface {
|
|||||||
// List secrets accessible to this client
|
// List secrets accessible to this client
|
||||||
// (GET /clients/{slug}/secrets)
|
// (GET /clients/{slug}/secrets)
|
||||||
GetClientSecrets(ctx context.Context, request GetClientSecretsRequestObject) (GetClientSecretsResponseObject, error)
|
GetClientSecrets(ctx context.Context, request GetClientSecretsRequestObject) (GetClientSecretsResponseObject, error)
|
||||||
|
// One-round-trip overview for the control room home page
|
||||||
|
// (GET /dashboard/summary)
|
||||||
|
GetDashboardSummary(ctx context.Context, request GetDashboardSummaryRequestObject) (GetDashboardSummaryResponseObject, error)
|
||||||
// List entities
|
// List entities
|
||||||
// (GET /entities)
|
// (GET /entities)
|
||||||
ListEntities(ctx context.Context, request ListEntitiesRequestObject) (ListEntitiesResponseObject, error)
|
ListEntities(ctx context.Context, request ListEntitiesRequestObject) (ListEntitiesResponseObject, error)
|
||||||
@@ -6870,6 +7023,30 @@ func (sh *strictHandler) GetClientSecrets(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDashboardSummary operation middleware
|
||||||
|
func (sh *strictHandler) GetDashboardSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request GetDashboardSummaryRequestObject
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||||
|
return sh.ssi.GetDashboardSummary(ctx, request.(GetDashboardSummaryRequestObject))
|
||||||
|
}
|
||||||
|
for _, middleware := range sh.middlewares {
|
||||||
|
handler = middleware(handler, "GetDashboardSummary")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := handler(r.Context(), w, r, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
} else if validResponse, ok := response.(GetDashboardSummaryResponseObject); ok {
|
||||||
|
if err := validResponse.VisitGetDashboardSummaryResponse(w); err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
}
|
||||||
|
} else if response != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListEntities operation middleware
|
// ListEntities operation middleware
|
||||||
func (sh *strictHandler) ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams) {
|
func (sh *strictHandler) ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams) {
|
||||||
var request ListEntitiesRequestObject
|
var request ListEntitiesRequestObject
|
||||||
@@ -8031,166 +8208,175 @@ func (sh *strictHandler) GetTrends(w http.ResponseWriter, r *http.Request, entit
|
|||||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||||
var swaggerSpec = []string{
|
var swaggerSpec = []string{
|
||||||
|
|
||||||
"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",
|
||||||
|
"7gAA",
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSwagger returns the content of the embedded swagger specification file
|
// 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
|
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,
|
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) {
|
func scanEntity(row pgx.Row) (gen.Entity, error) {
|
||||||
var e gen.Entity
|
var e gen.Entity
|
||||||
var state *string
|
var state *string
|
||||||
var attrsJSON []byte
|
var attrsJSON []byte
|
||||||
var maint *time.Time
|
var maint *time.Time
|
||||||
|
var health *string
|
||||||
|
var lastCheckAt *time.Time
|
||||||
err := row.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
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 {
|
if err != nil {
|
||||||
return e, err
|
return e, err
|
||||||
}
|
}
|
||||||
e.State = state
|
e.State = state
|
||||||
e.MaintenanceUntil = maint
|
e.MaintenanceUntil = maint
|
||||||
|
if health != nil {
|
||||||
|
h := gen.EntityHealth(*health)
|
||||||
|
e.Health = &h
|
||||||
|
}
|
||||||
|
e.LastCheckAt = lastCheckAt
|
||||||
var attrs map[string]any
|
var attrs map[string]any
|
||||||
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
||||||
e.Attributes = &attrs
|
e.Attributes = &attrs
|
||||||
@@ -113,6 +122,7 @@ func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestOb
|
|||||||
)
|
)
|
||||||
SELECT ` + entityCols + ` FROM entities e
|
SELECT ` + entityCols + ` FROM entities e
|
||||||
JOIN entity_types et ON et.name = e.type
|
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)
|
WHERE e.type IN (SELECT name FROM tt)
|
||||||
AND ($2::text IS NULL OR e.state = $2)
|
AND ($2::text IS NULL OR e.state = $2)
|
||||||
AND ($3::text IS NULL OR et.domain = $3)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
e, err := scanEntity(s.pool.QueryRow(ctx,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -231,6 +241,7 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
|
|||||||
SELECT `+entityCols+`, b.depth
|
SELECT `+entityCols+`, b.depth
|
||||||
FROM blast_radius($1, $2) b
|
FROM blast_radius($1, $2) b
|
||||||
JOIN entities e ON e.id = b.entity_id
|
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)
|
ORDER BY b.depth, e.slug`, id, depth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -246,13 +257,20 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
|
|||||||
var state *string
|
var state *string
|
||||||
var attrsJSON []byte
|
var attrsJSON []byte
|
||||||
var maint *time.Time
|
var maint *time.Time
|
||||||
|
var health *string
|
||||||
|
var lastCheckAt *time.Time
|
||||||
var d int
|
var d int
|
||||||
if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
e.State = state
|
e.State = state
|
||||||
e.MaintenanceUntil = maint
|
e.MaintenanceUntil = maint
|
||||||
|
if health != nil {
|
||||||
|
h := gen.EntityHealth(*health)
|
||||||
|
e.Health = &h
|
||||||
|
}
|
||||||
|
e.LastCheckAt = lastCheckAt
|
||||||
var attrs map[string]any
|
var attrs map[string]any
|
||||||
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
||||||
e.Attributes = &attrs
|
e.Attributes = &attrs
|
||||||
@@ -283,10 +301,13 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
|||||||
nodes, err = s.queryEntities(ctx, `
|
nodes, err = s.queryEntities(ctx, `
|
||||||
SELECT `+entityCols+`
|
SELECT `+entityCols+`
|
||||||
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
|
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)
|
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
|
||||||
} else {
|
} else {
|
||||||
nodes, err = s.queryEntities(ctx, `
|
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)
|
graphNodeCap+1)
|
||||||
if err == nil && len(nodes) > graphNodeCap {
|
if err == nil && len(nodes) > graphNodeCap {
|
||||||
nodes = nodes[:graphNodeCap]
|
nodes = nodes[:graphNodeCap]
|
||||||
@@ -322,9 +343,44 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
|||||||
if truncated {
|
if truncated {
|
||||||
resp.Truncated = &truncated
|
resp.Truncated = &truncated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Params.Include != nil {
|
||||||
|
for _, inc := range *req.Params.Include {
|
||||||
|
if inc == gen.Status {
|
||||||
|
health, herr := s.entityHealthByID(ctx, ids)
|
||||||
|
if herr != nil {
|
||||||
|
return nil, herr
|
||||||
|
}
|
||||||
|
resp.Health = &health
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entityHealthByID returns entity_status.health keyed by entity id, for the
|
||||||
|
// given id set (used by GetGraph's include=status).
|
||||||
|
func (s *Server) entityHealthByID(ctx context.Context, ids []uuid.UUID) (map[string]gen.GraphViewHealth, error) {
|
||||||
|
health := make(map[string]gen.GraphViewHealth, len(ids))
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT entity_id, health FROM entity_status WHERE entity_id = ANY($1)`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
|
var h string
|
||||||
|
if err := rows.Scan(&id, &h); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
health[id.String()] = gen.GraphViewHealth(h)
|
||||||
|
}
|
||||||
|
return health, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) {
|
func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) {
|
||||||
rows, err := s.pool.Query(ctx, query, args...)
|
rows, err := s.pool.Query(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -488,14 +544,18 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
|||||||
Type string `json:"type"`
|
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, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||||
|
WHERE e.type <> 'check'
|
||||||
ORDER BY e.slug`)
|
ORDER BY e.slug`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
stale := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var slug, typ, health string
|
var slug, typ, health string
|
||||||
var lastCheck *time.Time
|
var lastCheck *time.Time
|
||||||
@@ -509,6 +569,8 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
|||||||
resp.Summary.Degraded++
|
resp.Summary.Degraded++
|
||||||
case "down":
|
case "down":
|
||||||
resp.Summary.Down++
|
resp.Summary.Down++
|
||||||
|
case "stale":
|
||||||
|
stale++
|
||||||
default:
|
default:
|
||||||
resp.Summary.Unknown++
|
resp.Summary.Unknown++
|
||||||
}
|
}
|
||||||
@@ -525,6 +587,9 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
|||||||
Type: typ,
|
Type: typ,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if stale > 0 {
|
||||||
|
resp.Summary.Stale = &stale
|
||||||
|
}
|
||||||
return resp, rows.Err()
|
return resp, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,6 +985,8 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
|||||||
return nil, eventErr
|
return nil, eventErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureDefaultChecks(ctx, tx, inserted.ID, slug, req.Body.Type, attrsJSON)
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1184,6 +1251,8 @@ func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestOb
|
|||||||
"info", "oikos-api", "",
|
"info", "oikos-api", "",
|
||||||
map[string]any{"slug": req.Body.Slug, "type": current.Type})
|
map[string]any{"slug": req.Body.Slug, "type": current.Type})
|
||||||
|
|
||||||
|
ensureDefaultChecks(ctx, tx, id, req.Body.Slug, current.Type, attrsJSON)
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObje
|
|||||||
entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
||||||
ID: id,
|
ID: id,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Type: "check_def",
|
Type: "check",
|
||||||
Name: slug,
|
Name: slug,
|
||||||
Attributes: []byte("{}"),
|
Attributes: []byte("{}"),
|
||||||
})
|
})
|
||||||
@@ -1835,10 +1835,6 @@ func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestOb
|
|||||||
if req.Params.EntityId == nil || *req.Params.EntityId == "" {
|
if req.Params.EntityId == nil || *req.Params.EntityId == "" {
|
||||||
return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput)
|
return nil, fmt.Errorf("%w: entity_id is required", domain.ErrInvalidInput)
|
||||||
}
|
}
|
||||||
if req.Params.Metric == nil || len(*req.Params.Metric) == 0 {
|
|
||||||
return nil, fmt.Errorf("%w: metric is required", domain.ErrInvalidInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId)
|
entityID, err := s.resolveEntityID(ctx, *req.Params.EntityId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1853,8 +1849,33 @@ func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestOb
|
|||||||
to = *req.Params.To
|
to = *req.Params.To
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var metricNames []string
|
||||||
|
if req.Params.Metric != nil && len(*req.Params.Metric) > 0 {
|
||||||
|
metricNames = *req.Params.Metric
|
||||||
|
} else {
|
||||||
|
// metric omitted: report every metric recorded for this entity in range.
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT DISTINCT metric FROM metric_samples
|
||||||
|
WHERE entity_id = $1 AND ts >= $2 AND ts <= $3
|
||||||
|
ORDER BY metric`, entityID, from, to)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
metricNames = append(metricNames, name)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items := []gen.MetricSeries{}
|
items := []gen.MetricSeries{}
|
||||||
for _, metricName := range *req.Params.Metric {
|
for _, metricName := range metricNames {
|
||||||
series := gen.MetricSeries{
|
series := gen.MetricSeries{
|
||||||
EntityId: entityID.String(),
|
EntityId: entityID.String(),
|
||||||
Metric: metricName,
|
Metric: metricName,
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
return queryRows(ctx, pool, `
|
return queryRows(ctx, pool, `
|
||||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||||
|
WHERE e.type <> 'check'
|
||||||
ORDER BY e.slug`), nil
|
ORDER BY e.slug`), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/config"
|
"github.com/dtoro/oikos/internal/config"
|
||||||
@@ -22,6 +27,11 @@ import (
|
|||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sshKeyPath string
|
||||||
|
sshUser string
|
||||||
|
)
|
||||||
|
|
||||||
// Run starts the scheduler loop. Blocks until ctx is cancelled.
|
// Run starts the scheduler loop. Blocks until ctx is cancelled.
|
||||||
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||||
slog.Info("scheduler: starting", "interval", cfg.SchedulerInterval)
|
slog.Info("scheduler: starting", "interval", cfg.SchedulerInterval)
|
||||||
@@ -30,6 +40,12 @@ func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
|||||||
interval = 30 * time.Second
|
interval = 30 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sshKeyPath = cfg.SSHKeyPath
|
||||||
|
sshUser = cfg.SSHUser
|
||||||
|
if sshUser == "" {
|
||||||
|
sshUser = "root"
|
||||||
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -78,85 +94,94 @@ func runCheckPass(ctx context.Context, pool *db.Pool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runCheck executes a single check and processes the result.
|
// 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) {
|
func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) {
|
||||||
q := sqlcgen.New(pool)
|
q := sqlcgen.New(pool)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
health, signalKind, evidence, checkErr := executeCheck(ctx, cd)
|
result := executeCheck(ctx, cd)
|
||||||
|
|
||||||
latency := time.Since(start).Milliseconds()
|
latency := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
// Write metric
|
if result.metrics == nil {
|
||||||
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
|
result.metrics = make(map[string]float64)
|
||||||
EntityID: cd.EntityID,
|
}
|
||||||
Metric: "probe_latency_ms",
|
result.metrics["probe_latency_ms"] = float64(latency)
|
||||||
Value: float64(latency),
|
|
||||||
Tags: []byte(`{}`),
|
|
||||||
})
|
|
||||||
|
|
||||||
if checkErr != nil {
|
targetID := cd.EntityID
|
||||||
slog.Warn("scheduler: check failed",
|
if cd.TargetID != nil {
|
||||||
"entity", cd.EntitySlug, "kind", cd.Kind, "error", checkErr)
|
targetID = *cd.TargetID
|
||||||
}
|
}
|
||||||
|
|
||||||
prevHealth := currentHealth(ctx, pool, cd.EntityID)
|
for metric, value := range result.metrics {
|
||||||
|
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
|
||||||
|
EntityID: targetID,
|
||||||
|
Metric: metric,
|
||||||
|
Value: value,
|
||||||
|
Tags: []byte(`{}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if signalKind == "" || health == "healthy" {
|
if result.err != nil {
|
||||||
// Recovery: resolve any open signal for this check
|
slog.Warn("scheduler: check failed",
|
||||||
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
|
"entity", cd.EntitySlug, "kind", cd.Kind, "error", result.err)
|
||||||
// Update entity_status to healthy
|
}
|
||||||
|
|
||||||
|
prevHealth := currentHealth(ctx, pool, targetID)
|
||||||
|
|
||||||
|
if result.signalKind == "" || result.health == "healthy" {
|
||||||
|
resolveSignal(ctx, pool, cd.EntityID, targetID, cd.EntitySlug)
|
||||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||||
EntityID: cd.EntityID,
|
EntityID: targetID,
|
||||||
Health: "healthy",
|
Health: "healthy",
|
||||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||||
Details: []byte(`{}`),
|
Details: []byte(`{}`),
|
||||||
})
|
})
|
||||||
if prevHealth != "" && prevHealth != "healthy" {
|
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"})
|
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": "healthy"})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Failure: upsert signal (dedup via partial unique index)
|
|
||||||
slog.Warn("scheduler: raising signal",
|
slog.Warn("scheduler: raising signal",
|
||||||
"entity", cd.EntitySlug, "kind", signalKind, "evidence", evidence)
|
"entity", cd.EntitySlug, "kind", result.signalKind, "evidence", result.evidence)
|
||||||
|
|
||||||
severity := "warning"
|
severity := evaluateSeverity(cd.Kind, result.signalKind, cd.Config, result.metrics)
|
||||||
if signalKind == "down" {
|
|
||||||
severity = "critical"
|
|
||||||
}
|
|
||||||
|
|
||||||
sig, err := q.UpsertSignal(ctx, sqlcgen.UpsertSignalParams{
|
sig, err := q.UpsertSignal(ctx, sqlcgen.UpsertSignalParams{
|
||||||
EntityID: cd.EntityID,
|
EntityID: cd.EntityID,
|
||||||
Kind: signalKind,
|
Kind: result.signalKind,
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
TargetEntityID: cd.TargetID,
|
TargetEntityID: cd.TargetID,
|
||||||
Evidence: &evidence,
|
Evidence: &result.evidence,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("scheduler: upsert signal", "error", err)
|
slog.Error("scheduler: upsert signal", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update entity_status
|
|
||||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||||
EntityID: cd.EntityID,
|
EntityID: targetID,
|
||||||
Health: health,
|
Health: result.health,
|
||||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||||
Details: []byte(`{}`),
|
Details: []byte(`{}`),
|
||||||
})
|
})
|
||||||
_ = sig // used for flap detection below
|
_ = sig
|
||||||
|
|
||||||
// Emit only on transition into failure so a persistently-down entity
|
|
||||||
// doesn't flood the stream every tick.
|
|
||||||
if prevHealth == "" || prevHealth == "healthy" {
|
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": signalKind, "evidence": evidence})
|
map[string]any{"slug": cd.EntitySlug, "kind": result.signalKind, "evidence": result.evidence})
|
||||||
}
|
}
|
||||||
if prevHealth != health {
|
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": health})
|
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": result.health})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,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)
|
_ = observability.Event(ctx, sqlcgen.New(pool), eventType, &entityID, severity, "scheduler", "", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveSignal resolves any open signal for the given check entity.
|
// resolveSignal resolves any open signal raised by the given check entity.
|
||||||
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
|
// 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)
|
q := sqlcgen.New(pool)
|
||||||
// Check if there's an open signal on this entity
|
// Check if there's an open signal on this entity
|
||||||
tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() > 0 {
|
if tag.RowsAffected() > 0 {
|
||||||
emitSchedulerEvent(ctx, pool, "signal.resolved", entityID, "info",
|
emitSchedulerEvent(ctx, pool, "signal.resolved", targetID, "info",
|
||||||
map[string]any{"slug": slug})
|
map[string]any{"slug": slug})
|
||||||
}
|
}
|
||||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||||
EntityID: entityID,
|
EntityID: targetID,
|
||||||
Health: "healthy",
|
Health: "healthy",
|
||||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||||
Details: []byte(`{}`),
|
Details: []byte(`{}`),
|
||||||
@@ -197,8 +224,17 @@ func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug
|
|||||||
slog.Info("scheduler: signal resolved", "entity", slug)
|
slog.Info("scheduler: signal resolved", "entity", slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkResult bundles the outcome of a single check execution.
|
||||||
|
type checkResult struct {
|
||||||
|
health string
|
||||||
|
signalKind string
|
||||||
|
evidence string
|
||||||
|
metrics map[string]float64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
// executeCheck dispatches to the appropriate checker by kind.
|
// executeCheck dispatches to the appropriate checker by kind.
|
||||||
func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (health string, signalKind string, evidence string, err error) {
|
func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
switch cd.Kind {
|
switch cd.Kind {
|
||||||
case "http":
|
case "http":
|
||||||
return checkHTTP(ctx, cd)
|
return checkHTTP(ctx, cd)
|
||||||
@@ -208,8 +244,12 @@ func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (heal
|
|||||||
return checkDisk(ctx, cd)
|
return checkDisk(ctx, cd)
|
||||||
case "cert-expiry":
|
case "cert-expiry":
|
||||||
return checkCertExpiry(ctx, cd)
|
return checkCertExpiry(ctx, cd)
|
||||||
|
case "ping":
|
||||||
|
return checkPing(ctx, cd)
|
||||||
|
case "ssh-script":
|
||||||
|
return checkSSHScript(ctx, cd)
|
||||||
default:
|
default:
|
||||||
return "unknown", "", "", nil
|
return checkResult{health: "unknown"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,12 +264,76 @@ func housekeeping(ctx context.Context, pool *db.Pool) {
|
|||||||
slog.Error("scheduler: prune idempotency keys", "error", err)
|
slog.Error("scheduler: prune idempotency keys", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
staleSweep(ctx, pool)
|
||||||
|
|
||||||
// Log housekeeping completion
|
// Log housekeeping completion
|
||||||
slog.Debug("scheduler: housekeeping done", "pruned_idempotency_before", cutoff.Format(time.RFC3339))
|
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.
|
// checkHTTP performs an HTTP health check.
|
||||||
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
cfg := struct {
|
cfg := struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ExpectedStatus int `json:"expected_status"`
|
ExpectedStatus int `json:"expected_status"`
|
||||||
@@ -241,7 +345,7 @@ func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
|
|||||||
_ = json.Unmarshal(cd.Config, &cfg)
|
_ = json.Unmarshal(cd.Config, &cfg)
|
||||||
}
|
}
|
||||||
if cfg.URL == "" {
|
if cfg.URL == "" {
|
||||||
return "healthy", "", "", nil
|
return checkResult{health: "healthy"}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout := time.Duration(cd.TimeoutS) * time.Second
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
||||||
@@ -258,25 +362,35 @@ func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "down", "http", fmt.Sprintf("invalid URL %q: %v", cfg.URL, err), err
|
return checkResult{
|
||||||
|
health: "down", signalKind: "http",
|
||||||
|
evidence: fmt.Sprintf("invalid URL %q: %v", cfg.URL, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "down", "http", fmt.Sprintf("GET %s: %v", cfg.URL, err), err
|
return checkResult{
|
||||||
|
health: "down", signalKind: "http",
|
||||||
|
evidence: fmt.Sprintf("GET %s: %v", cfg.URL, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != cfg.ExpectedStatus {
|
if resp.StatusCode != cfg.ExpectedStatus {
|
||||||
return "degraded", "http",
|
return checkResult{
|
||||||
fmt.Sprintf("GET %s returned %d (expected %d)", cfg.URL, resp.StatusCode, cfg.ExpectedStatus), nil
|
health: "degraded", signalKind: "http",
|
||||||
|
evidence: fmt.Sprintf("GET %s returned %d (expected %d)", cfg.URL, resp.StatusCode, cfg.ExpectedStatus),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "healthy", "", "", nil
|
return checkResult{health: "healthy"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkTCP performs a TCP dial check.
|
// checkTCP performs a TCP dial check.
|
||||||
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
cfg := struct {
|
cfg := struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
@@ -285,7 +399,7 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
|
|||||||
_ = json.Unmarshal(cd.Config, &cfg)
|
_ = json.Unmarshal(cd.Config, &cfg)
|
||||||
}
|
}
|
||||||
if cfg.Host == "" || cfg.Port == 0 {
|
if cfg.Host == "" || cfg.Port == 0 {
|
||||||
return "healthy", "", "", nil
|
return checkResult{health: "healthy"}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout := time.Duration(cd.TimeoutS) * time.Second
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
||||||
@@ -296,14 +410,18 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
|
|||||||
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
||||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "down", "tcp", fmt.Sprintf("dial %s: %v", addr, err), err
|
return checkResult{
|
||||||
|
health: "down", signalKind: "tcp",
|
||||||
|
evidence: fmt.Sprintf("dial %s: %v", addr, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return "healthy", "", "", nil
|
return checkResult{health: "healthy"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDisk performs a disk usage check via local or SSH.
|
// checkDisk performs a disk usage check.
|
||||||
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
cfg := struct {
|
cfg := struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
ThresholdPct int `json:"threshold_pct"`
|
ThresholdPct int `json:"threshold_pct"`
|
||||||
@@ -315,29 +433,45 @@ func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
|
|||||||
_ = json.Unmarshal(cd.Config, &cfg)
|
_ = json.Unmarshal(cd.Config, &cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use unix.Statfs for disk usage.
|
|
||||||
var stat unix.Statfs_t
|
var stat unix.Statfs_t
|
||||||
if err := unix.Statfs(cfg.Path, &stat); err != nil {
|
if err := unix.Statfs(cfg.Path, &stat); err != nil {
|
||||||
return "down", "disk", fmt.Sprintf("statfs %s: %v", cfg.Path, err), err
|
return checkResult{
|
||||||
|
health: "down", signalKind: "disk",
|
||||||
|
evidence: fmt.Sprintf("statfs %s: %v", cfg.Path, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
total := stat.Blocks * uint64(stat.Bsize)
|
total := stat.Blocks * uint64(stat.Bsize)
|
||||||
free := stat.Bfree * uint64(stat.Bsize)
|
free := stat.Bfree * uint64(stat.Bsize)
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return "healthy", "", "", nil
|
return checkResult{health: "healthy"}
|
||||||
}
|
}
|
||||||
|
|
||||||
usedPct := float64(total-free) / float64(total) * 100
|
usedPct := float64(total-free) / float64(total) * 100
|
||||||
if usedPct > float64(cfg.ThresholdPct) {
|
inodePct := 0.0
|
||||||
return "degraded", "disk",
|
if stat.Files > 0 {
|
||||||
fmt.Sprintf("%s %.1f%% full (threshold %d%%)", cfg.Path, usedPct, cfg.ThresholdPct), nil
|
inodePct = float64(stat.Files-stat.Ffree) / float64(stat.Files) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
return "healthy", "", "", nil
|
metrics := map[string]float64{
|
||||||
|
"disk_used_pct": usedPct,
|
||||||
|
"disk_inode_pct": inodePct,
|
||||||
|
}
|
||||||
|
|
||||||
|
if usedPct > float64(cfg.ThresholdPct) {
|
||||||
|
return checkResult{
|
||||||
|
health: "degraded", signalKind: "disk",
|
||||||
|
evidence: fmt.Sprintf("%s %.1f%% full (threshold %d%%)", cfg.Path, usedPct, cfg.ThresholdPct),
|
||||||
|
metrics: metrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkResult{health: "healthy", metrics: metrics}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCertExpiry checks TLS certificate expiry.
|
// checkCertExpiry checks TLS certificate expiry.
|
||||||
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
cfg := struct {
|
cfg := struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
@@ -352,7 +486,7 @@ func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (s
|
|||||||
_ = json.Unmarshal(cd.Config, &cfg)
|
_ = json.Unmarshal(cd.Config, &cfg)
|
||||||
}
|
}
|
||||||
if cfg.Host == "" {
|
if cfg.Host == "" {
|
||||||
return "healthy", "", "", nil
|
return checkResult{health: "healthy"}
|
||||||
}
|
}
|
||||||
|
|
||||||
timeout := time.Duration(cd.TimeoutS) * time.Second
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
||||||
@@ -365,31 +499,264 @@ func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (s
|
|||||||
d := tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true}}
|
d := tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true}}
|
||||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "down", "cert-expiry", fmt.Sprintf("TLS dial %s: %v", addr, err), err
|
return checkResult{
|
||||||
|
health: "down", signalKind: "cert-expiry",
|
||||||
|
evidence: fmt.Sprintf("TLS dial %s: %v", addr, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
tlsConn := conn.(*tls.Conn)
|
tlsConn := conn.(*tls.Conn)
|
||||||
// Use crypto/tls ConnectionState to get verified chains
|
|
||||||
cs := tlsConn.ConnectionState()
|
cs := tlsConn.ConnectionState()
|
||||||
if len(cs.PeerCertificates) == 0 {
|
if len(cs.PeerCertificates) == 0 {
|
||||||
return "down", "cert-expiry", "no peer certificates", nil
|
return checkResult{
|
||||||
|
health: "down", signalKind: "cert-expiry",
|
||||||
|
evidence: "no peer certificates",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cert := cs.PeerCertificates[0]
|
cert := cs.PeerCertificates[0]
|
||||||
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
|
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
|
||||||
|
|
||||||
|
metrics := map[string]float64{
|
||||||
|
"cert_days_left": float64(daysLeft),
|
||||||
|
}
|
||||||
|
|
||||||
if daysLeft <= cfg.CritDays {
|
if daysLeft <= cfg.CritDays {
|
||||||
return "down", "cert-expiry",
|
return checkResult{
|
||||||
fmt.Sprintf("%s expires in %d days (crit=%d)", cfg.Host, daysLeft, cfg.CritDays), nil
|
health: "down", signalKind: "cert-expiry",
|
||||||
|
evidence: fmt.Sprintf("%s expires in %d days (crit=%d)", cfg.Host, daysLeft, cfg.CritDays),
|
||||||
|
metrics: metrics,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if daysLeft <= cfg.WarnDays {
|
if daysLeft <= cfg.WarnDays {
|
||||||
return "degraded", "cert-expiry",
|
return checkResult{
|
||||||
fmt.Sprintf("%s expires in %d days (warn=%d)", cfg.Host, daysLeft, cfg.WarnDays), nil
|
health: "degraded", signalKind: "cert-expiry",
|
||||||
|
evidence: fmt.Sprintf("%s expires in %d days (warn=%d)", cfg.Host, daysLeft, cfg.WarnDays),
|
||||||
|
metrics: metrics,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "healthy", "", "", nil
|
return checkResult{health: "healthy", metrics: metrics}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkPing performs an ICMP ping check using the system ping command.
|
||||||
|
func checkPing(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
|
cfg := struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}{}
|
||||||
|
if len(cd.Config) > 0 {
|
||||||
|
_ = json.Unmarshal(cd.Config, &cfg)
|
||||||
|
}
|
||||||
|
if cfg.Host == "" {
|
||||||
|
return checkResult{health: "healthy"}
|
||||||
|
}
|
||||||
|
if cfg.Count <= 0 {
|
||||||
|
cfg.Count = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Duration(cfg.Count+1) * timeout
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, deadline)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
countStr := strconv.Itoa(cfg.Count)
|
||||||
|
timeoutSec := strconv.Itoa(int(timeout.Seconds()))
|
||||||
|
if timeoutSec == "0" {
|
||||||
|
timeoutSec = "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "ping", "-c", countStr, "-W", timeoutSec, cfg.Host)
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
cmd = exec.CommandContext(ctx, "ping", "-c", countStr, "-t", timeoutSec, cfg.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return checkResult{
|
||||||
|
health: "down", signalKind: "ping",
|
||||||
|
evidence: fmt.Sprintf("ping %s: %v", cfg.Host, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
latency := parsePingLatency(output)
|
||||||
|
metrics := map[string]float64{}
|
||||||
|
if latency > 0 {
|
||||||
|
metrics["ping_latency_ms"] = latency
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkResult{health: "healthy", metrics: metrics}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pingRttRe = regexp.MustCompile(`(?:rtt\s+min\/avg\/max\/mdev|round-trip\s+min\/avg\/max\/stddev)\s*=\s*[\d.]+\/([\d.]+)\/`)
|
||||||
|
|
||||||
|
func parsePingLatency(output []byte) float64 {
|
||||||
|
matches := pingRttRe.FindSubmatch(output)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val, err := strconv.ParseFloat(string(matches[1]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSSHScript executes an allowlisted script on a remote host via SSH.
|
||||||
|
func checkSSHScript(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||||
|
cfg := struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Script string `json:"script"`
|
||||||
|
}{}
|
||||||
|
if len(cd.Config) > 0 {
|
||||||
|
_ = json.Unmarshal(cd.Config, &cfg)
|
||||||
|
}
|
||||||
|
if cfg.Host == "" || cfg.Script == "" {
|
||||||
|
return checkResult{health: "healthy"}
|
||||||
|
}
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 22
|
||||||
|
}
|
||||||
|
if cfg.User == "" {
|
||||||
|
cfg.User = sshUser
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowlistedScript(cfg.Script) {
|
||||||
|
return checkResult{
|
||||||
|
health: "unknown", signalKind: "ssh-script",
|
||||||
|
evidence: fmt.Sprintf("script %q not allowlisted", cfg.Script),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
scriptPath := "/opt/oikos/checks/" + cfg.Script
|
||||||
|
port := strconv.Itoa(cfg.Port)
|
||||||
|
|
||||||
|
output, err := sshExec(ctx, cfg.Host, port, cfg.User, scriptPath, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return checkResult{
|
||||||
|
health: "down", signalKind: "ssh-script",
|
||||||
|
evidence: fmt.Sprintf("ssh %s:%s %s: %v", cfg.Host, strconv.Itoa(cfg.Port), cfg.Script, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type scriptOutput struct {
|
||||||
|
Health string `json:"health"`
|
||||||
|
SignalKind string `json:"signalKind"`
|
||||||
|
Evidence string `json:"evidence"`
|
||||||
|
Metrics map[string]float64 `json:"metrics"`
|
||||||
|
}
|
||||||
|
var so scriptOutput
|
||||||
|
if err := json.Unmarshal(output, &so); err != nil {
|
||||||
|
return checkResult{
|
||||||
|
health: "down", signalKind: "ssh-script",
|
||||||
|
evidence: fmt.Sprintf("invalid script output from %s: %v", cfg.Script, err),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
health := so.Health
|
||||||
|
if health == "" {
|
||||||
|
health = "healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := so.Metrics
|
||||||
|
if metrics == nil {
|
||||||
|
metrics = make(map[string]float64)
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkResult{
|
||||||
|
health: health,
|
||||||
|
signalKind: so.SignalKind,
|
||||||
|
evidence: so.Evidence,
|
||||||
|
metrics: metrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var scriptNameRe = regexp.MustCompile(`^[a-z][a-z0-9_-]+\.sh$`)
|
||||||
|
|
||||||
|
func allowlistedScript(name string) bool {
|
||||||
|
return scriptNameRe.MatchString(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Duration) ([]byte, error) {
|
||||||
|
args := []string{
|
||||||
|
"-o", "ConnectTimeout=" + strconv.Itoa(int(timeout.Seconds())),
|
||||||
|
"-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "UserKnownHostsFile=/dev/null",
|
||||||
|
"-o", "LogLevel=ERROR",
|
||||||
|
}
|
||||||
|
if sshKeyPath != "" {
|
||||||
|
args = append(args, "-i", sshKeyPath)
|
||||||
|
}
|
||||||
|
if port != "" && port != "22" {
|
||||||
|
args = append(args, "-p", port)
|
||||||
|
}
|
||||||
|
args = append(args, "-l", user, host, cmd)
|
||||||
|
c := exec.CommandContext(ctx, "ssh", args...)
|
||||||
|
out, err := c.Output()
|
||||||
|
if err != nil {
|
||||||
|
var ee *exec.ExitError
|
||||||
|
if errors.As(err, &ee) {
|
||||||
|
return nil, fmt.Errorf("ssh %s: %v (stderr: %s)", host, err, string(ee.Stderr))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ssh %s: %v", host, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// metricThreshold defines warn/crit thresholds for a single metric.
|
||||||
|
type metricThreshold struct {
|
||||||
|
Warn float64 `json:"warn"`
|
||||||
|
Crit float64 `json:"crit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// thresholdsConfig is parsed from check_defs.config.thresholds JSONB.
|
||||||
|
type thresholdsConfig map[string]metricThreshold
|
||||||
|
|
||||||
|
// evaluateSeverity determines signal severity from check result and thresholds.
|
||||||
|
func evaluateSeverity(kind string, signalKind string, config []byte, metrics map[string]float64) string {
|
||||||
|
var thresholds thresholdsConfig
|
||||||
|
if len(config) > 0 {
|
||||||
|
_ = json.Unmarshal(config, &thresholds)
|
||||||
|
}
|
||||||
|
|
||||||
|
for metric, value := range metrics {
|
||||||
|
t, ok := thresholds[metric]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.Crit > 0 && value >= t.Crit {
|
||||||
|
return "critical"
|
||||||
|
}
|
||||||
|
if t.Warn > 0 && value >= t.Warn {
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if signalKind == "down" {
|
||||||
|
return "critical"
|
||||||
|
}
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
|
||||||
var _ = uuid.UUID{} // ensure uuid import stays
|
var _ = uuid.UUID{} // ensure uuid import stays
|
||||||
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');
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
# 2026-07-08 — Control room web UI
|
# 2026-07-08 — Control room web UI
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** In Progress — N0-N3 (Nomos amendment: chat home + sessions), M1
|
||||||
|
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
|
||||||
|
component system), M2 (Operations ledger with approve/deny + cancel, Signals
|
||||||
|
page with ack/resolve/mute, live nav badges), and M3 (graph explorer with
|
||||||
|
`include=status` health coloring, per-relationship-type edge coloring +
|
||||||
|
legend/filter, node-type filter, node search/highlight, entity detail page
|
||||||
|
with uPlot metric charts, `#/entity/:slug` route) complete 2026-07-08. Event
|
||||||
|
gap-fill (M2's
|
||||||
|
other half) landed earlier in commit e8e230b, and approval creation's FK bug
|
||||||
|
(gaps-plan A1) was already fixed, unblocking M2. While building M3, also
|
||||||
|
fixed `GET /metrics` to make the `metric` query param genuinely optional
|
||||||
|
(server now reports every metric recorded for the entity in range) — the
|
||||||
|
implementation previously 400'd when it was omitted, contradicting its own
|
||||||
|
documented-optional spec. M4 (agent activity, knowledge search page, audit,
|
||||||
|
correlation grouping, polish) remains.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -31,8 +45,18 @@ Dependencies kept minimal:
|
|||||||
- `d3-force` — graph physics only; render SVG/canvas by hand
|
- `d3-force` — graph physics only; render SVG/canvas by hand
|
||||||
- `uPlot` — ~45 KB canvas time-series, ideal for `/metrics` rollups
|
- `uPlot` — ~45 KB canvas time-series, ideal for `/metrics` rollups
|
||||||
- `openapi-typescript` — dev-only, generates `api-types.d.ts`
|
- `openapi-typescript` — dev-only, generates `api-types.d.ts`
|
||||||
- No SvelteKit (no SSR wanted — the Go binary is the server), no component
|
- No SvelteKit (no SSR wanted — the Go binary is the server); hash router.
|
||||||
framework; hash router; hand-rolled dark-theme CSS.
|
|
||||||
|
*Amendment 2026-07-08 (M1):* component library is
|
||||||
|
[shadcn-svelte](https://www.shadcn-svelte.com/) over Tailwind CSS v4
|
||||||
|
(`@tailwindcss/vite`), not hand-rolled CSS — Table, Card, Badge, Sidebar,
|
||||||
|
Sheet, Select, Input, Button, Tabs, ScrollArea, Tooltip, Dialog,
|
||||||
|
Dropdown-menu, Sonner installed via `npx shadcn-svelte add`. The existing
|
||||||
|
dark GitHub-style palette (`app.css`) was ported into shadcn's CSS-variable
|
||||||
|
theme contract (`--background`, `--card`, `--primary`, etc. under
|
||||||
|
`@theme inline`) so old and new components share one palette. `d3-force` /
|
||||||
|
`uPlot` remain the plan for the graph/charts milestones (M3), unaffected by
|
||||||
|
this change.
|
||||||
|
|
||||||
Build integration: commit a placeholder `web/dist/index.html` so backend-only
|
Build integration: commit a placeholder `web/dist/index.html` so backend-only
|
||||||
`go build` never breaks; `make ui` runs the Vite build; add a node stage to
|
`go build` never breaks; `make ui` runs the Vite build; add a node stage to
|
||||||
|
|||||||
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."
|
||||||
387
plans/2026-07-08-signal-triggers.md
Normal file
387
plans/2026-07-08-signal-triggers.md
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
# 2026-07-08 — Signal triggers: host health checks
|
||||||
|
|
||||||
|
**Status:** Implemented (Phases 1-5 complete)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a comprehensive set of signal triggers for host-level monitoring —
|
||||||
|
network reachability, CPU/memory/disk pressure, thermal state, pending
|
||||||
|
updates, disk health, ZFS pool status, and process liveness. Every check
|
||||||
|
raises properly deduped signals through the existing `UpsertSignal` path
|
||||||
|
and feeds the OODA pipeline (observe → classify → decide → act).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. New check kinds
|
||||||
|
|
||||||
|
| Kind | What it measures | Signal kind | Severity mapping |
|
||||||
|
|------|-----------------|-------------|------------------|
|
||||||
|
| `ping` | ICMP reachability + RTT | `ping-unreachable` | down → critical |
|
||||||
|
| `cpu` | Usage % + thermal (Linux only) | `cpu-pressure`, `cpu-thermal` | >90% → warning, >95% → critical; temp >85°C → critical |
|
||||||
|
| `memory` | RAM usage % | `memory-pressure` | >90% → warning, >95% → critical |
|
||||||
|
| `load` | Load avg / CPU count | `load-pressure` | >CPU×2 → warning, >CPU×4 → critical |
|
||||||
|
| `swap` | Swap usage % | `swap-pressure` | >50% → warning, >80% → critical |
|
||||||
|
| `disk-usage` | Already exists as `disk` — enhance with inode %, per-mountpoint | `disk-full` | >85% → warning, >95% → critical |
|
||||||
|
| `disk-smart` | SMART pre-failure attributes | `disk-smart-fail` | any fail → critical |
|
||||||
|
| `updates` | Pending apt updates (security, critical) | `updates-pending` | security >0 → warning, critical-reboot >0 → critical |
|
||||||
|
| `zfs` | Pool health + scrub status | `zfs-degraded`, `zfs-scrub-overdue` | degraded → critical, scrub >30d → warning |
|
||||||
|
| `process` | Process/service running | `process-down` | not running → critical |
|
||||||
|
| `uptime` | Detect unexpected reboots | `uptime-bounce` | < previous → warning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Execution model
|
||||||
|
|
||||||
|
Two tiers based on where the check runs:
|
||||||
|
|
||||||
|
### Tier A: Local (scheduler host)
|
||||||
|
|
||||||
|
`ping`, `http`, `tcp`, `cert-expiry` — run directly from the scheduler process. Already implemented for `http`/`tcp`/`disk`/`cert-expiry`. Add `ping` here.
|
||||||
|
|
||||||
|
### Tier B: Remote via SSH (`ssh-script`)
|
||||||
|
|
||||||
|
`cpu`, `memory`, `load`, `swap`, `disk-smart`, `updates`, `zfs`, `process`, `uptime` — run on the target host via SSH. The existing `ssh-script` kind (defined in OpenAPI, not implemented in scheduler) is the one generic mechanism.
|
||||||
|
|
||||||
|
### Why one `ssh-script` kind instead of separate kinds per metric
|
||||||
|
|
||||||
|
The check runs a small allowlisted shell snippet on the target host via SSH.
|
||||||
|
The script outputs JSON with `health`, `signalKind`, `evidence`, and optional
|
||||||
|
`metrics` (name→value map). This keeps the scheduler simple — one code path for
|
||||||
|
all remote checks — while the check definition's `config.script` field encodes
|
||||||
|
what to run.
|
||||||
|
|
||||||
|
### SSH config shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "192.168.30.10",
|
||||||
|
"port": 22,
|
||||||
|
"user": "root",
|
||||||
|
"script": "cpu_check.sh",
|
||||||
|
"timeout_s": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Scripts live in `/opt/oikos/checks/` on each host, deployed by the Homelab sync
|
||||||
|
timer alongside the AGENTS.md context. They are allowlisted — the scheduler only
|
||||||
|
executes scripts whose names match `^[a-z][a-z0-9_-]+\.sh$` and that exist in the
|
||||||
|
check directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Check scripts (per host, `/opt/oikos/checks/`)
|
||||||
|
|
||||||
|
### `cpu_check.sh`
|
||||||
|
```sh
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
USAGE=$(top -bn1 | awk '/^%Cpu/ {print 100 - $8}')
|
||||||
|
CORES=$(nproc)
|
||||||
|
TEMP=""
|
||||||
|
if [ -f /sys/class/thermal/thermal_zone0/temp ]; then
|
||||||
|
TEMP=$(echo "scale=1; $(cat /sys/class/thermal/thermal_zone0/temp) / 1000" | bc)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"cpu_pct\":$USAGE,\"cpu_temp\":$TEMP}}"
|
||||||
|
```
|
||||||
|
Signal raised by scheduler logic when `cpu_pct > threshold_pct` or `cpu_temp > threshold_temp`.
|
||||||
|
|
||||||
|
### `memory_check.sh`
|
||||||
|
```sh
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
MEMINFO=$(awk '/MemTotal|MemAvailable|SwapTotal|SwapFree/ {printf "\"%s\":%d,", tolower($1), $2}' /proc/meminfo | sed 's/,$//')
|
||||||
|
USED_PCT=$(python3 -c "print(round((1 - ${memavailable:-0}/${memtotal:-1})*100, 1))")
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"mem_pct\":$USED_PCT}}"
|
||||||
|
```
|
||||||
|
Actual implementation would precompute from `/proc/meminfo` in bash directly (avoid python dependency).
|
||||||
|
|
||||||
|
### `load_check.sh`
|
||||||
|
```sh
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LOAD=$(awk '{print $1}' /proc/loadavg)
|
||||||
|
CORES=$(nproc)
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"load1\":$LOAD,\"cores\":$CORES}}"
|
||||||
|
```
|
||||||
|
Scheduler computes `load_pct = load1 / cores` and thresholds on that.
|
||||||
|
|
||||||
|
### `swap_check.sh`
|
||||||
|
Reports swap used / swap total from `/proc/meminfo`.
|
||||||
|
|
||||||
|
### `disk_smart_check.sh`
|
||||||
|
```sh
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
FAIL=0
|
||||||
|
for dev in $(lsblk -ndo NAME,TYPE | awk '$2=="disk"{print "/dev/"$1}'); do
|
||||||
|
smartctl -H "$dev" | grep -q "PASSED" || { FAIL=1; break; }
|
||||||
|
done
|
||||||
|
[ $FAIL -eq 0 ] && echo '{"health":"healthy"}' || echo '{"health":"degraded","signalKind":"disk-smart-fail","evidence":"SMART health check failed"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### `updates_check.sh`
|
||||||
|
```sh
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
apt update -qq >/dev/null 2>&1
|
||||||
|
SECURITY=$(apt list --upgradable 2>/dev/null | grep -c '\-security' || true)
|
||||||
|
REBOOT=$(test -f /var/run/reboot-required && echo 1 || echo 0)
|
||||||
|
echo "{\"health\":\"healthy\",\"metrics\":{\"security_updates\":$SECURITY,\"reboot_required\":$REBOOT}}"
|
||||||
|
```
|
||||||
|
Scheduler thresholds: `security_updates > 0` → warning signal, `reboot_required > 0` → critical.
|
||||||
|
|
||||||
|
### `zfs_check.sh`
|
||||||
|
```sh
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
STATUS=$(zpool status -x 2>&1)
|
||||||
|
if echo "$STATUS" | grep -q "all pools are healthy"; then
|
||||||
|
echo '{"health":"healthy"}'
|
||||||
|
else
|
||||||
|
echo "{\"health\":\"degraded\",\"signalKind\":\"zfs-degraded\",\"evidence\":\"$(echo $STATUS | head -1)\"}"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### `process_check.sh`
|
||||||
|
Runs `systemctl is-active <service>` for a service name passed in config.
|
||||||
|
Signal on `inactive`/`failed`.
|
||||||
|
|
||||||
|
### `uptime_check.sh`
|
||||||
|
Reads current uptime, compares to last stored value (in a temp file or via metric).
|
||||||
|
Signal if uptime went backward (reboot detected).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Scheduler changes
|
||||||
|
|
||||||
|
### `internal/scheduler/scheduler.go`
|
||||||
|
|
||||||
|
Add two new cases to `executeCheck()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
case "ping":
|
||||||
|
return checkPing(ctx, cd)
|
||||||
|
case "ssh-script":
|
||||||
|
return checkSSHScript(ctx, cd)
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional changes:
|
||||||
|
- **Metric recording**: stop hardcoding `probe_latency_ms`. Each check returns
|
||||||
|
a `map[string]float64` of metrics, and the scheduler writes all of them.
|
||||||
|
Change `executeCheck` signature from `(health, signalKind, evidence, err)`
|
||||||
|
to also return `metrics map[string]float64`.
|
||||||
|
- **Threshold evaluation for metric-based checks** (`cpu`, `memory`, `load`,
|
||||||
|
`swap`, `updates`): the scheduler reads `config.thresholds` from check config
|
||||||
|
JSONB and evaluates metrics against them.
|
||||||
|
|
||||||
|
### `checkPing()`
|
||||||
|
|
||||||
|
Uses `golang.org/x/net/icmp` + `golang.org/x/net/ipv4` for non-privileged
|
||||||
|
ICMP echo (or falls back to `net.DialTimeout("ip4:icmp", ...)`).
|
||||||
|
On macOS, `ping -c 1 -t <timeout>` via exec as fallback (ICMP raw sockets
|
||||||
|
require root on macOS). Returns `probe_latency_ms` metric.
|
||||||
|
|
||||||
|
Config:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "192.168.30.10",
|
||||||
|
"count": 1,
|
||||||
|
"timeout_s": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `checkSSHScript()`
|
||||||
|
|
||||||
|
Connects via `golang.org/x/crypto/ssh` using agent forwarding or key file.
|
||||||
|
Executes the allowlisted script path, validates JSON output, returns metrics
|
||||||
|
and health.
|
||||||
|
|
||||||
|
Config:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "192.168.30.10",
|
||||||
|
"port": 22,
|
||||||
|
"user": "root",
|
||||||
|
"script": "cpu_check.sh",
|
||||||
|
"thresholds": {
|
||||||
|
"cpu_pct": {"warn": 90, "crit": 95},
|
||||||
|
"cpu_temp": {"crit": 85}
|
||||||
|
},
|
||||||
|
"timeout_s": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Threshold config schema
|
||||||
|
|
||||||
|
Each check kind with metric-based thresholds adds a `thresholds` key:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thresholds": {
|
||||||
|
"<metric_name>": {"warn": <float>, "crit": <float>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing thresholds → no signal raised; metrics still recorded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DB and migration
|
||||||
|
|
||||||
|
No schema migration needed. `check_defs.config` is JSONB — new check kinds
|
||||||
|
use it with their own config shapes. `signals` table handles any `kind` string.
|
||||||
|
|
||||||
|
One small addition: add the new signal kinds to the OpenAPI `CheckKind` enum
|
||||||
|
and the generated code (`api/openapi.yaml` line 2247, `internal/httpapi/gen/api.gen.go` line 84).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Seed data: default checks per host
|
||||||
|
|
||||||
|
Add to `seeds/inventory.yaml` or a new `seeds/checks.yaml` — a set of default
|
||||||
|
checks per entity type:
|
||||||
|
|
||||||
|
- Every `machine`, `workstation`, `proxmox-host`, `lxc` gets:
|
||||||
|
- `cpu` (via ssh-script)
|
||||||
|
- `memory` (via ssh-script)
|
||||||
|
- `load` (via ssh-script)
|
||||||
|
- `swap` (via ssh-script)
|
||||||
|
- `disk-usage` (local or ssh-script for remote)
|
||||||
|
- `updates` (via ssh-script)
|
||||||
|
- `uptime` (via ssh-script)
|
||||||
|
- Every `machine`, `proxmox-host`, `standalone-server` additionally gets:
|
||||||
|
- `disk-smart` (via ssh-script)
|
||||||
|
- `zfs` (via ssh-script) if `storage-pool` edges exist
|
||||||
|
- Every `service` gets:
|
||||||
|
- `process` (via ssh-script, `systemctl is-active`)
|
||||||
|
|
||||||
|
Default intervals:
|
||||||
|
- `cpu`, `memory`, `load`: 60s
|
||||||
|
- `disk-usage`, `swap`: 300s
|
||||||
|
- `ping`: 30s
|
||||||
|
- `updates`: 3600s (hourly)
|
||||||
|
- `disk-smart`, `zfs`: 86400s (daily)
|
||||||
|
- `process`: 30s
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Policy integration
|
||||||
|
|
||||||
|
New approval rule for `ssh-script` checks:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ssh-script execution is read_only on the target — it only reads metrics
|
||||||
|
- entity_type: machine
|
||||||
|
action: health-check
|
||||||
|
risk_class: read_only
|
||||||
|
autonomy: auto
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ping` kind is `read_only` (no mutation). All new checks are `read_only`
|
||||||
|
— they observe state, no action taken automatically. The *response* to signals
|
||||||
|
(restart service, clear cache, apt upgrade) goes through the existing
|
||||||
|
classification → approval → execution pipeline separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Other common important signals (per user request)
|
||||||
|
|
||||||
|
Beyond the core checks above, these are worth including:
|
||||||
|
|
||||||
|
| # | Trigger | Why important |
|
||||||
|
|---|---------|---------------|
|
||||||
|
| 11 | **Inode exhaustion** | Filesystem can be "full" with free space but zero inodes (Docker overlay, mail queues). Distinct from disk-usage. |
|
||||||
|
| 12 | **OOM kills** | `dmesg | grep -i 'out of memory'` count since last boot. Indicates memory pressure beyond usage %. |
|
||||||
|
| 13 | **Journal errors** | `journalctl -p err -S -1h --no-pager | wc -l`. Catches kernel panics, segfaults, service failures. |
|
||||||
|
| 14 | **Docker/container health** | `docker ps --filter health=unhealthy`. Catches containers in unhealthy state. |
|
||||||
|
| 15 | **Caddy/nginx error rate** | Parse access logs for 5xx rate over last 5min. Expensive, do at 300s interval. |
|
||||||
|
| 16 | **Time drift** | `chronyc tracking | grep 'System time'`. NTP offset > 1s → warning (affects TLS, auth, DB). |
|
||||||
|
| 17 | **Open file descriptors** | `/proc/sys/fs/file-nr` ratio used/total. >80% → warning (service exhaustion). |
|
||||||
|
| 18 | **Backup freshness** | Check timestamp of last backup file. >schedule+grace → critical. |
|
||||||
|
|
||||||
|
All of these (11–18) are implemented as `ssh-script` checks with
|
||||||
|
corresponding scripts in `/opt/oikos/checks/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Implementation order
|
||||||
|
|
||||||
|
### Phase 1: Foundation (2-3 days)
|
||||||
|
|
||||||
|
1. **Refactor `executeCheck`** to return metrics map. Update all existing check
|
||||||
|
functions (`http`, `tcp`, `disk`, `cert-expiry`).
|
||||||
|
2. **Add `ping` kind** — ICMP reachability on the scheduler host.
|
||||||
|
3. **Add `ssh-script` kind** — SSH execution engine, script allowlisting,
|
||||||
|
JSON output parsing.
|
||||||
|
4. **Add threshold evaluation** — scheduler reads `config.thresholds` and
|
||||||
|
compares against returned metrics to decide health/signal.
|
||||||
|
|
||||||
|
### Phase 2: Host check scripts (1-2 days)
|
||||||
|
|
||||||
|
5. Write and test each script in `/opt/oikos/checks/`:
|
||||||
|
`cpu_check.sh`, `memory_check.sh`, `load_check.sh`, `swap_check.sh`,
|
||||||
|
`disk_smart_check.sh`, `updates_check.sh`, `zfs_check.sh`,
|
||||||
|
`process_check.sh`, `uptime_check.sh`, `oom_check.sh`, `journal_check.sh`,
|
||||||
|
`time_check.sh`, `fd_check.sh`.
|
||||||
|
6. Add `tools/setup-checks.sh` to auto-deploy scripts via the sync timer
|
||||||
|
(same pattern as `tools/setup-caveman.sh`).
|
||||||
|
|
||||||
|
### Phase 3: Seed data + API (1 day)
|
||||||
|
|
||||||
|
7. Add `seeds/checks.yaml` with default check definitions per entity type.
|
||||||
|
8. Add new check kinds to OpenAPI spec and regenerate Go types.
|
||||||
|
9. Add a `/api/v1/checks/defaults/{entity_type}` endpoint that returns
|
||||||
|
recommended checks for a given entity type (convenience for operators).
|
||||||
|
|
||||||
|
### Phase 4: Policy + observability (1 day)
|
||||||
|
|
||||||
|
10. Add `read_only` policy rules for `ping` and `ssh-script` actions.
|
||||||
|
11. Add per-check-kind metric recording (not just `probe_latency_ms`).
|
||||||
|
12. Wire `cpu_temp`, `mem_pct`, `load_pct`, etc. into `query_metrics` and
|
||||||
|
the MCP `get_trend` tool.
|
||||||
|
|
||||||
|
### Phase 5: Docker + service signals (1 day)
|
||||||
|
|
||||||
|
13. `docker_health_check.sh` — `docker ps --filter health=unhealthy`.
|
||||||
|
14. `caddy_error_rate.sh` — parse Caddy JSON logs for 5xx.
|
||||||
|
15. `backup_freshness.sh` — check last backup timestamp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Dependencies
|
||||||
|
|
||||||
|
| Dependency | For | Risk |
|
||||||
|
|------------|-----|------|
|
||||||
|
| `golang.org/x/crypto/ssh` | SSH client in scheduler | Already in `go.mod` (used by deployer) |
|
||||||
|
| `golang.org/x/net/icmp` + `ipv4` | ICMP ping | New dep; macOS needs root for raw sockets → exec fallback |
|
||||||
|
| `smartmontools` on hosts | `disk_smart_check.sh` | Already installed on Proxmox; add to LXCs |
|
||||||
|
| `zfsutils-linux` on hosts | `zfs_check.sh` | Already on Proxmox; add to LXCs with ZFS |
|
||||||
|
| SSH key on scheduler | `ssh-script` to all hosts | Already deployed (Homelab sync SSH keys) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Risks and mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| `ssh-script` is a remote exec vector | Scripts are allowlisted by name (`^[a-z][a-z0-9_-]+\.sh$`), deployed via git (auditable), and read-only (no mutation). SSH key restricted to a dedicated `oikos-check` user with sudo only for `systemctl is-active`. |
|
||||||
|
| ICMP requires root on macOS | Fallback to `ping` CLI via `os/exec`. Production runs on Linux (strong) where raw sockets work. |
|
||||||
|
| Metrics cardinality explosion | Metrics are per-check-definition, not per-script-output. The script returns a fixed set of known metric names. TimescaleDB handles the volume. |
|
||||||
|
| Check script drift between hosts | Scripts deploy via `tools/setup-checks.sh` in the sync timer — same mechanism that keeps AGENTS.md in sync. Checksum validation before execution. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Verification
|
||||||
|
|
||||||
|
- **Unit tests**: each check function (`checkPing`, `checkSSHScript`) tested
|
||||||
|
with mock SSH server and mock ICMP responses.
|
||||||
|
- **Integration test**: deploy to `strong` (macOS scheduler host), define
|
||||||
|
checks for `hubris` (Proxmox), `dns` (LXC), `caddy` (LXC), run scheduler
|
||||||
|
with `--check-interval 10s`, verify signals appear in `get_signal_history`.
|
||||||
|
- **MCP smoke test**: `search_knowledge("signal triggers")`, `get_signal_history`,
|
||||||
|
`query_metrics(metric=["cpu_pct", "mem_pct"])`, `get_trend`.
|
||||||
|
- **Policy smoke test**: `preflight(service:caddy, restart)` still returns
|
||||||
|
correct risk class despite new check kinds in the DB.
|
||||||
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.
|
||||||
@@ -11,8 +11,9 @@ went sideways, open an investigation.
|
|||||||
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned |
|
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned |
|
||||||
| 2026-07-08 | [Plan vs implementation cross-reference](2026-07-08-plan-implementation-audit.md) | Planned |
|
| 2026-07-08 | [Plan vs implementation cross-reference](2026-07-08-plan-implementation-audit.md) | Planned |
|
||||||
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
|
| 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) | 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-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
|
## Done
|
||||||
|
|
||||||
|
|||||||
13
tools/setup-checks.sh
Normal file
13
tools/setup-checks.sh
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# setup-checks.sh — deploy check scripts to /opt/oikos/checks on each host.
|
||||||
|
# Auto-setup hook: tools/*.setup.sh runs after every git pull.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CLONE_DIR="${HOMELAB_CONTEXT_DIR:-/opt/homelab}"
|
||||||
|
CHECK_SETUP="$CLONE_DIR/checks/install.sh"
|
||||||
|
|
||||||
|
if [ -f "$CHECK_SETUP" ]; then
|
||||||
|
bash "$CHECK_SETUP" || echo "[setup-checks] WARNING: install.sh exited with code $?"
|
||||||
|
else
|
||||||
|
echo "[setup-checks] no checks/install.sh found, skipping"
|
||||||
|
fi
|
||||||
17
web/components.json
Normal file
17
web/components.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"style": "vega",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/app.css",
|
||||||
|
"baseColor": "zinc"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Oikos — Control Room</title>
|
<title>Oikos — Control Room</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="android-chrome-192.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="512x512" href="android-chrome-512.png" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
997
web/package-lock.json
generated
997
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,27 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@lucide/svelte": "^1.23.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
|
"bits-ui": "^2.18.1",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-sonner": "^1.1.1",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
|
"marked": "^18.0.5",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"uplot": "^1.6.32"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
web/public/android-chrome-192.png
Normal file
BIN
web/public/android-chrome-192.png
Normal file
Binary file not shown.
BIN
web/public/android-chrome-512.png
Normal file
BIN
web/public/android-chrome-512.png
Normal file
Binary file not shown.
BIN
web/public/apple-touch-icon.png
Normal file
BIN
web/public/apple-touch-icon.png
Normal file
Binary file not shown.
BIN
web/public/favicon.png
Normal file
BIN
web/public/favicon.png
Normal file
Binary file not shown.
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 |
@@ -1,155 +1,232 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Chat from '$lib/../pages/Chat.svelte'
|
import Chat from './pages/Chat.svelte'
|
||||||
import Sessions from '$lib/../pages/Sessions.svelte'
|
import Sessions from './pages/Sessions.svelte'
|
||||||
|
import Overview from './pages/Overview.svelte'
|
||||||
|
import Entities from './pages/Entities.svelte'
|
||||||
|
import Events from './pages/Events.svelte'
|
||||||
|
import Ops from './pages/Ops.svelte'
|
||||||
|
import Signals from './pages/Signals.svelte'
|
||||||
|
import Graph from './pages/Graph.svelte'
|
||||||
|
import EntityDetail from './pages/EntityDetail.svelte'
|
||||||
|
import Agent from './pages/Agent.svelte'
|
||||||
|
import Knowledge from './pages/Knowledge.svelte'
|
||||||
|
import Audit from './pages/Audit.svelte'
|
||||||
import { newChat } from '$lib/stores/chat'
|
import { newChat } from '$lib/stores/chat'
|
||||||
|
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
||||||
|
import { connectionState } from '$lib/stores/events'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { slide } from 'svelte/transition'
|
import * as Sidebar from '$lib/components/ui/sidebar'
|
||||||
|
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 LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
||||||
|
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||||
|
import ActivityIcon from '@lucide/svelte/icons/activity'
|
||||||
|
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
||||||
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||||
|
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||||
|
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
||||||
|
import BotIcon from '@lucide/svelte/icons/bot'
|
||||||
|
import SearchIcon from '@lucide/svelte/icons/search'
|
||||||
|
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
|
||||||
|
|
||||||
let page = $state('chat')
|
let page = $state('chat')
|
||||||
|
let routeParam = $state('')
|
||||||
let drawerOpen = $state(false)
|
let drawerOpen = $state(false)
|
||||||
|
|
||||||
|
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
||||||
|
const openSignals = $derived(openSignalCount($summary))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
function sync() {
|
function sync() {
|
||||||
page = location.hash.slice(2) || 'chat'
|
const path = location.hash.slice(2) || 'chat'
|
||||||
|
const [head, ...rest] = path.split('/')
|
||||||
|
page = head || 'chat'
|
||||||
|
routeParam = rest.join('/')
|
||||||
}
|
}
|
||||||
sync()
|
sync()
|
||||||
window.addEventListener('hashchange', sync)
|
window.addEventListener('hashchange', sync)
|
||||||
return () => window.removeEventListener('hashchange', sync)
|
const unsubscribeCtx = subscribeContext()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hashchange', sync)
|
||||||
|
unsubscribeCtx()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function navigate(p: string) {
|
function navigate(p: string) {
|
||||||
location.hash = '#/' + p
|
location.hash = '#/' + p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'overview', label: 'Overview', icon: LayoutDashboardIcon },
|
||||||
|
{ id: 'entities', label: 'Entities', icon: DatabaseIcon },
|
||||||
|
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
|
||||||
|
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
||||||
|
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
||||||
|
{ id: 'events', label: 'Events', icon: ActivityIcon },
|
||||||
|
{ id: 'agent', label: 'Agent', icon: BotIcon },
|
||||||
|
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||||
|
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app">
|
<Toaster />
|
||||||
<nav class="sidebar">
|
|
||||||
<div class="logo">Oikos</div>
|
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
|
||||||
<button class="nav-btn" onclick={() => { newChat(); navigate('chat') }}>
|
<Sidebar.Root collapsible="icon" variant="inset">
|
||||||
<span class="nav-icon">✚</span>
|
<Sidebar.Header>
|
||||||
<span>New</span>
|
<Sidebar.Menu>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<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>
|
</button>
|
||||||
<button class="nav-btn" class:active={page === 'chat'} onclick={() => navigate('chat')}>
|
{/snippet}
|
||||||
<span class="nav-icon">💬</span>
|
</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 />
|
||||||
|
<span>New chat</span>
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
</Sidebar.Menu>
|
||||||
|
</Sidebar.Header>
|
||||||
|
|
||||||
|
<Sidebar.Content>
|
||||||
|
<Sidebar.Group>
|
||||||
|
<Sidebar.Menu>
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton isActive={page === 'chat'} onclick={() => navigate('chat')} tooltipContent="Chat">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<button {...props}>
|
||||||
|
<MessageSquareIcon />
|
||||||
<span>Chat</span>
|
<span>Chat</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" class:active={page === 'sessions'} onclick={() => navigate('sessions')}>
|
{/snippet}
|
||||||
<span class="nav-icon">📋</span>
|
</Sidebar.MenuButton>
|
||||||
<span>Sessions</span>
|
</Sidebar.MenuItem>
|
||||||
|
{#each navItems as item}
|
||||||
|
<Sidebar.MenuItem>
|
||||||
|
<Sidebar.MenuButton
|
||||||
|
isActive={page === item.id}
|
||||||
|
onclick={() => navigate(item.id)}
|
||||||
|
tooltipContent={item.label}
|
||||||
|
>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<button {...props}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Sidebar.MenuButton>
|
||||||
|
{#if item.badge?.()}
|
||||||
|
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
|
||||||
|
{/if}
|
||||||
|
</Sidebar.MenuItem>
|
||||||
|
{/each}
|
||||||
|
</Sidebar.Menu>
|
||||||
|
</Sidebar.Group>
|
||||||
|
</Sidebar.Content>
|
||||||
|
|
||||||
<div class="spacer"></div>
|
<Sidebar.Footer>
|
||||||
|
<Button variant="ghost" size="sm" class="justify-start gap-2" onclick={() => (drawerOpen = true)}>
|
||||||
|
<PanelRightIcon />
|
||||||
|
<span>Chat drawer</span>
|
||||||
|
</Button>
|
||||||
|
</Sidebar.Footer>
|
||||||
|
</Sidebar.Root>
|
||||||
|
|
||||||
<button class="drawer-toggle" onclick={() => drawerOpen = !drawerOpen}>
|
<Sidebar.Inset class="min-h-0 overflow-hidden">
|
||||||
Chat {drawerOpen ? '▼' : '▲'}
|
<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>
|
||||||
|
<span class="flex items-center gap-1" title="degraded"><span class="size-2 rounded-full bg-warning"></span>{$summary.health.degraded}</span>
|
||||||
|
<span class="flex items-center gap-1" title="down"><span class="size-2 rounded-full bg-destructive"></span>{$summary.health.down}</span>
|
||||||
|
</div>
|
||||||
|
{#if approvalsPending}
|
||||||
|
<button type="button" onclick={() => navigate('ops')}>
|
||||||
|
<Badge variant="destructive" class="cursor-pointer">{approvalsPending} approval{approvalsPending === 1 ? '' : 's'}</Badge>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
{/if}
|
||||||
|
{#if openSignals}
|
||||||
<main class="main">
|
<button type="button" onclick={() => navigate('signals')}>
|
||||||
{#if page === 'chat'}
|
<Badge variant="secondary" class="cursor-pointer">{openSignals} signal{openSignals === 1 ? '' : 's'}</Badge>
|
||||||
<Chat />
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
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'}
|
||||||
|
<Overview />
|
||||||
|
{:else if page === 'entities'}
|
||||||
|
<Entities />
|
||||||
|
{:else if page === 'graph'}
|
||||||
|
<Graph />
|
||||||
|
{:else if page === 'entity' && routeParam}
|
||||||
|
<EntityDetail slug={routeParam} />
|
||||||
|
{:else if page === 'ops'}
|
||||||
|
<Ops />
|
||||||
|
{:else if page === 'signals'}
|
||||||
|
<Signals />
|
||||||
|
{:else if page === 'events'}
|
||||||
|
<Events />
|
||||||
{:else if page === 'sessions'}
|
{:else if page === 'sessions'}
|
||||||
<Sessions />
|
<Sessions />
|
||||||
|
{:else if page === 'agent'}
|
||||||
|
<Agent />
|
||||||
|
{:else if page === 'knowledge'}
|
||||||
|
<Knowledge />
|
||||||
|
{:else if page === 'audit'}
|
||||||
|
<Audit />
|
||||||
{:else}
|
{:else}
|
||||||
<Chat />
|
<Chat />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
</Sidebar.Inset>
|
||||||
|
</Sidebar.Provider>
|
||||||
|
|
||||||
{#if drawerOpen}
|
<Sheet.Root bind:open={drawerOpen}>
|
||||||
<aside class="drawer" transition:slide={{ axis: 'x' }}>
|
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
|
||||||
<Chat />
|
<Sheet.Header class="sr-only">
|
||||||
</aside>
|
<Sheet.Title>Nomos chat</Sheet.Title>
|
||||||
{/if}
|
<Sheet.Description>Persistent chat drawer</Sheet.Description>
|
||||||
|
</Sheet.Header>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<Chat showRail={false} />
|
||||||
</div>
|
</div>
|
||||||
|
</Sheet.Content>
|
||||||
<style>
|
</Sheet.Root>
|
||||||
.app {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 56px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-blue);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
width: 44px;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn.active {
|
|
||||||
background: var(--bg-active);
|
|
||||||
color: var(--accent-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-toggle {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-toggle:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer {
|
|
||||||
width: 380px;
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
127
web/src/app.css
127
web/src/app.css
@@ -1,34 +1,123 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@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 {
|
:root {
|
||||||
--bg: #0d1117;
|
--radius: 0.625rem;
|
||||||
--bg-surface: #161b22;
|
--background: oklch(0.145 0 0);
|
||||||
--bg-deeper: #0a0e13;
|
--foreground: oklch(0.985 0 0);
|
||||||
--bg-hover: #21262d;
|
--card: oklch(0.205 0 0);
|
||||||
--bg-active: #292e36;
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--border: #30363d;
|
--popover: oklch(0.205 0 0);
|
||||||
--text: #e6edf3;
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--text-muted: #8b949e;
|
--primary: oklch(0.922 0 0);
|
||||||
--accent-blue: #58a6ff;
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--accent-green: #3fb950;
|
--secondary: oklch(0.269 0 0);
|
||||||
--accent-red: #f85149;
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--accent-orange: #d29922;
|
--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: 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;
|
--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: oklch(0.11 0 0);
|
||||||
|
--bg-hover: var(--secondary);
|
||||||
|
--bg-active: var(--accent);
|
||||||
|
--text: var(--foreground);
|
||||||
|
--text-muted: var(--muted-foreground);
|
||||||
|
/* 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
@theme inline {
|
||||||
box-sizing: border-box;
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
margin: 0;
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
padding: 0;
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--font-mono: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
-webkit-font-smoothing: antialiased;
|
-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 {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const BASE = '/agent'
|
const BASE = '/agent'
|
||||||
|
const API = '/api/v1'
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string
|
id: string
|
||||||
@@ -90,3 +91,420 @@ export function streamChat(
|
|||||||
|
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
entities_by_type: Record<string, number>
|
||||||
|
entities_by_state: Record<string, number>
|
||||||
|
health: { healthy: number; degraded: number; down: number; unknown: number }
|
||||||
|
signals_by_severity: Record<string, number>
|
||||||
|
approvals_pending: number
|
||||||
|
executions_by_state: Record<string, number>
|
||||||
|
event_rate: { bucket: string; count: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDashboardSummary(): Promise<DashboardSummary | null> {
|
||||||
|
const res = await fetch(`${API}/dashboard/summary`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntityHealth = 'healthy' | 'degraded' | 'down' | 'unknown' | 'stale'
|
||||||
|
|
||||||
|
export interface Entity {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
state?: string | null
|
||||||
|
attributes: Record<string, unknown>
|
||||||
|
version: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
health?: EntityHealth | null
|
||||||
|
last_check_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityFilters {
|
||||||
|
type?: string
|
||||||
|
state?: string
|
||||||
|
q?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.type) params.set('type', filters.type)
|
||||||
|
if (filters.state) params.set('state', filters.state)
|
||||||
|
if (filters.q) params.set('q', filters.q)
|
||||||
|
params.set('limit', '200')
|
||||||
|
const res = await fetch(`${API}/entities?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventFilters {
|
||||||
|
type?: string
|
||||||
|
severity?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEvents(filters: EventFilters = {}): Promise<import('./stores/events').OikosEvent[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.type) params.set('type', filters.type)
|
||||||
|
if (filters.severity) params.set('severity', filters.severity)
|
||||||
|
params.set('limit', '100')
|
||||||
|
const res = await fetch(`${API}/events?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Approval {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
subject?: string | null
|
||||||
|
action: string
|
||||||
|
risk_class: string
|
||||||
|
kind: 'execution' | 'policy-change' | 'pattern-activation'
|
||||||
|
payload?: Record<string, unknown> | null
|
||||||
|
status: 'pending' | 'approved' | 'denied' | 'expired' | 'revoked'
|
||||||
|
expires_at: string
|
||||||
|
decided_at?: string | null
|
||||||
|
decided_by?: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchApprovals(status?: string): Promise<Approval[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (status) params.set('status', status)
|
||||||
|
params.set('limit', '200')
|
||||||
|
const res = await fetch(`${API}/approvals?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decideApproval(
|
||||||
|
id: string,
|
||||||
|
decision: 'approve' | 'deny' | 'revoke',
|
||||||
|
note?: string
|
||||||
|
): Promise<Approval | null> {
|
||||||
|
const res = await fetch(`${API}/approvals/${id}/decision`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ decision, note })
|
||||||
|
})
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Execution {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
target?: string | null
|
||||||
|
action: string
|
||||||
|
risk_class: string
|
||||||
|
status: string
|
||||||
|
approval_id?: string | null
|
||||||
|
agent_id?: string | null
|
||||||
|
result?: Record<string, unknown> | null
|
||||||
|
duration_ms?: number | null
|
||||||
|
verified: boolean
|
||||||
|
correlation_id: string
|
||||||
|
started_at?: string | null
|
||||||
|
completed_at?: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExecutions(status?: string): Promise<Execution[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (status) params.set('status', status)
|
||||||
|
params.set('limit', '200')
|
||||||
|
const res = await fetch(`${API}/executions?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelExecution(id: string): Promise<Execution | null> {
|
||||||
|
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' })
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Signal {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
kind: string
|
||||||
|
severity: 'info' | 'warning' | 'critical'
|
||||||
|
state: 'raised' | 'acknowledged' | 'acting' | 'muted' | 'resolved' | 'failed'
|
||||||
|
target?: string | null
|
||||||
|
evidence?: string | null
|
||||||
|
likely_cause?: string | null
|
||||||
|
occurrence_count: number
|
||||||
|
flap_count: number
|
||||||
|
hold_down_until?: string | null
|
||||||
|
mute_until?: string | null
|
||||||
|
first_seen_at: string
|
||||||
|
last_seen_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSignals(filters: { state?: string; severity?: string } = {}): Promise<Signal[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.state) params.set('state', filters.state)
|
||||||
|
if (filters.severity) params.set('severity', filters.severity)
|
||||||
|
params.set('limit', '200')
|
||||||
|
const res = await fetch(`${API}/signals?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ackSignal(id: string): Promise<Signal | null> {
|
||||||
|
const res = await fetch(`${API}/signals/${id}/ack`, { method: 'POST' })
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSignal(id: string, note?: string): Promise<Signal | null> {
|
||||||
|
const res = await fetch(`${API}/signals/${id}/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ note })
|
||||||
|
})
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
|
||||||
|
const res = await fetch(`${API}/signals/${id}/mute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mute_until: muteUntil, note })
|
||||||
|
})
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Relationship {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
type: string
|
||||||
|
attributes?: Record<string, unknown> | null
|
||||||
|
valid_from: string
|
||||||
|
valid_to?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Health = 'healthy' | 'degraded' | 'down' | 'unknown'
|
||||||
|
|
||||||
|
export interface GraphView {
|
||||||
|
nodes: Entity[]
|
||||||
|
edges: Relationship[]
|
||||||
|
truncated?: boolean
|
||||||
|
health?: Record<string, Health>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphFilters {
|
||||||
|
root?: string
|
||||||
|
depth?: number
|
||||||
|
relType?: string[]
|
||||||
|
includeStatus?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGraph(filters: GraphFilters = {}): Promise<GraphView | null> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.root) params.set('root', filters.root)
|
||||||
|
if (filters.depth) params.set('depth', String(filters.depth))
|
||||||
|
for (const rt of filters.relType ?? []) params.append('rel_type', rt)
|
||||||
|
if (filters.includeStatus) params.append('include', 'status')
|
||||||
|
const res = await fetch(`${API}/graph?${params}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlastRadiusItem {
|
||||||
|
entity: Entity
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
|
||||||
|
const res = await fetch(`${API}/entities/${id}/blast-radius`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntity(id: string): Promise<Entity | null> {
|
||||||
|
const res = await fetch(`${API}/entities/${id}`)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricSample {
|
||||||
|
ts: string
|
||||||
|
value?: number | null
|
||||||
|
avg?: number | null
|
||||||
|
min?: number | null
|
||||||
|
max?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricSeries {
|
||||||
|
entity_id: string
|
||||||
|
metric: string
|
||||||
|
rollup: 'raw' | '1h' | '1d'
|
||||||
|
samples: MetricSample[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMetrics(entityId: string): Promise<MetricSeries[]> {
|
||||||
|
const params = new URLSearchParams({ entity_id: entityId, rollup: 'auto' })
|
||||||
|
const res = await fetch(`${API}/metrics?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeHit {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
type: 'document' | 'runbook' | 'investigation'
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeHit[]> {
|
||||||
|
const res = await fetch(`${API}/knowledge/${entityId}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
|
||||||
|
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||||
|
const res = await fetch(`${API}/events?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
|
||||||
|
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
|
||||||
|
const res = await fetch(`${API}/signals?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> {
|
||||||
|
const params = new URLSearchParams({ target: entityId, limit: '50' })
|
||||||
|
const res = await fetch(`${API}/executions?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
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
|
||||||
|
agent_id: string
|
||||||
|
session_id?: string | null
|
||||||
|
activity_type: 'tool_call' | 'reasoning' | 'decision' | 'mcp_query' | 'escalation'
|
||||||
|
tool_name?: string | null
|
||||||
|
entity_id?: string | null
|
||||||
|
input_summary?: string | null
|
||||||
|
output_summary?: string | null
|
||||||
|
duration_ms?: number | null
|
||||||
|
token_count?: number | null
|
||||||
|
success?: boolean | null
|
||||||
|
correlation_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAgentActivity(filters: {
|
||||||
|
agent_id?: string
|
||||||
|
activity_type?: string
|
||||||
|
entity_id?: string
|
||||||
|
limit?: number
|
||||||
|
} = {}): Promise<AgentActivity[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.agent_id) params.set('agent_id', filters.agent_id)
|
||||||
|
if (filters.activity_type) params.set('activity_type', filters.activity_type)
|
||||||
|
if (filters.entity_id) params.set('entity_id', filters.entity_id)
|
||||||
|
params.set('limit', String(filters.limit ?? 200))
|
||||||
|
const res = await fetch(`${API}/agent-activity?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchKnowledge(q: string, limit = 50): Promise<KnowledgeHit[]> {
|
||||||
|
const params = new URLSearchParams({ q, limit: String(limit) })
|
||||||
|
const res = await fetch(`${API}/knowledge/search?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number
|
||||||
|
ts: string
|
||||||
|
actor_type: 'agent' | 'operator' | 'system' | 'scheduler'
|
||||||
|
actor_id?: string | null
|
||||||
|
action: string
|
||||||
|
entity_id?: string | null
|
||||||
|
method?: string | null
|
||||||
|
path?: string | null
|
||||||
|
status_code?: number | null
|
||||||
|
detail?: Record<string, unknown>
|
||||||
|
source_ip?: string | null
|
||||||
|
correlation_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAudit(filters: {
|
||||||
|
actor_type?: string
|
||||||
|
actor_id?: string
|
||||||
|
entity_id?: string
|
||||||
|
action?: string
|
||||||
|
correlation_id?: string
|
||||||
|
limit?: number
|
||||||
|
} = {}): Promise<AuditEntry[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.actor_type) params.set('actor_type', filters.actor_type)
|
||||||
|
if (filters.actor_id) params.set('actor_id', filters.actor_id)
|
||||||
|
if (filters.entity_id) params.set('entity_id', filters.entity_id)
|
||||||
|
if (filters.action) params.set('action', filters.action)
|
||||||
|
if (filters.correlation_id) params.set('correlation_id', filters.correlation_id)
|
||||||
|
params.set('limit', String(filters.limit ?? 200))
|
||||||
|
const res = await fetch(`${API}/audit?${params}`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.items ?? []
|
||||||
|
}
|
||||||
|
|||||||
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}
|
||||||
49
web/src/lib/components/ui/badge/badge.svelte
Normal file
49
web/src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const badgeVariants = tv({
|
||||||
|
base: "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||||
|
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
href,
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:element
|
||||||
|
this={href ? "a" : "span"}
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="badge"
|
||||||
|
{href}
|
||||||
|
class={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</svelte:element>
|
||||||
2
web/src/lib/components/ui/badge/index.ts
Normal file
2
web/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as Badge } from "./badge.svelte";
|
||||||
|
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||||
82
web/src/lib/components/ui/button/button.svelte
Normal file
82
web/src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
import { type VariantProps, tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||||
|
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||||
|
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||||
|
|
||||||
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
ref = $bindable(null),
|
||||||
|
href = undefined,
|
||||||
|
type = "button",
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
href={disabled ? undefined : href}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
role={disabled ? "link" : undefined}
|
||||||
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
17
web/src/lib/components/ui/button/index.ts
Normal file
17
web/src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Root, {
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
buttonVariants,
|
||||||
|
} from "./button.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
type ButtonProps as Props,
|
||||||
|
//
|
||||||
|
Root as Button,
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
};
|
||||||
23
web/src/lib/components/ui/card/card-action.svelte
Normal file
23
web/src/lib/components/ui/card/card-action.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-action"
|
||||||
|
class={cn(
|
||||||
|
"cn-card-action col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
web/src/lib/components/ui/card/card-content.svelte
Normal file
20
web/src/lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-content"
|
||||||
|
class={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
web/src/lib/components/ui/card/card-description.svelte
Normal file
20
web/src/lib/components/ui/card/card-description.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
20
web/src/lib/components/ui/card/card-footer.svelte
Normal file
20
web/src/lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-footer"
|
||||||
|
class={cn("rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
23
web/src/lib/components/ui/card/card-header.svelte
Normal file
23
web/src/lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-header"
|
||||||
|
class={cn(
|
||||||
|
"gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
20
web/src/lib/components/ui/card/card-title.svelte
Normal file
20
web/src/lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-title"
|
||||||
|
class={cn("text-base leading-normal font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
22
web/src/lib/components/ui/card/card.svelte
Normal file
22
web/src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
size = "default",
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
class={cn("ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-xl py-6 text-sm shadow-xs ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
25
web/src/lib/components/ui/card/index.ts
Normal file
25
web/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./card.svelte";
|
||||||
|
import Content from "./card-content.svelte";
|
||||||
|
import Description from "./card-description.svelte";
|
||||||
|
import Footer from "./card-footer.svelte";
|
||||||
|
import Header from "./card-header.svelte";
|
||||||
|
import Title from "./card-title.svelte";
|
||||||
|
import Action from "./card-action.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Title,
|
||||||
|
Action,
|
||||||
|
//
|
||||||
|
Root as Card,
|
||||||
|
Content as CardContent,
|
||||||
|
Description as CardDescription,
|
||||||
|
Footer as CardFooter,
|
||||||
|
Header as CardHeader,
|
||||||
|
Title as CardTitle,
|
||||||
|
Action as CardAction,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
||||||
11
web/src/lib/components/ui/collapsible/collapsible.svelte
Normal file
11
web/src/lib/components/ui/collapsible/collapsible.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
open = $bindable(false),
|
||||||
|
...restProps
|
||||||
|
}: CollapsiblePrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||||
13
web/src/lib/components/ui/collapsible/index.ts
Normal file
13
web/src/lib/components/ui/collapsible/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import Root from "./collapsible.svelte";
|
||||||
|
import Trigger from "./collapsible-trigger.svelte";
|
||||||
|
import Content from "./collapsible-content.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
//
|
||||||
|
Root as Collapsible,
|
||||||
|
Content as CollapsibleContent,
|
||||||
|
Trigger as CollapsibleTrigger,
|
||||||
|
};
|
||||||
11
web/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
11
web/src/lib/components/ui/dialog/dialog-close.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
type = "button",
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {type} {...restProps} />
|
||||||
48
web/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
48
web/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import DialogPortal from "./dialog-portal.svelte";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Dialog from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
|
||||||
|
children: Snippet;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPortal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close data-slot="dialog-close">
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button variant="ghost" class="absolute top-4 right-4" size="icon-sm" {...props}>
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
17
web/src/lib/components/ui/dialog/dialog-description.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-description"
|
||||||
|
class={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
32
web/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
32
web/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { Button } from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
showCloseButton = false,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
class={cn("gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button variant="outline" {...props}>Close</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
20
web/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
web/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-header"
|
||||||
|
class={cn("gap-2 flex flex-col", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
17
web/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
class={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
web/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Portal {...restProps} />
|
||||||
17
web/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
web/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-title"
|
||||||
|
class={cn("leading-none font-medium", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
11
web/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
11
web/src/lib/components/ui/dialog/dialog-trigger.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
type = "button",
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {type} {...restProps} />
|
||||||
7
web/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
web/src/lib/components/ui/dialog/dialog.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Root bind:open {...restProps} />
|
||||||
34
web/src/lib/components/ui/dialog/index.ts
Normal file
34
web/src/lib/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Root from "./dialog.svelte";
|
||||||
|
import Portal from "./dialog-portal.svelte";
|
||||||
|
import Title from "./dialog-title.svelte";
|
||||||
|
import Footer from "./dialog-footer.svelte";
|
||||||
|
import Header from "./dialog-header.svelte";
|
||||||
|
import Overlay from "./dialog-overlay.svelte";
|
||||||
|
import Content from "./dialog-content.svelte";
|
||||||
|
import Description from "./dialog-description.svelte";
|
||||||
|
import Trigger from "./dialog-trigger.svelte";
|
||||||
|
import Close from "./dialog-close.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Close as DialogClose,
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable([]),
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.CheckboxGroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.CheckboxGroup
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
data-slot="dropdown-menu-checkbox-group"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
checked = $bindable(false),
|
||||||
|
indeterminate = $bindable(false),
|
||||||
|
class: className,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
bind:ref
|
||||||
|
bind:checked
|
||||||
|
bind:indeterminate
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
class={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ checked, indeterminate })}
|
||||||
|
<span
|
||||||
|
class="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
{#if indeterminate}
|
||||||
|
<MinusIcon />
|
||||||
|
{:else if checked}
|
||||||
|
<CheckIcon />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{@render childrenProp?.()}
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import DropdownMenuPortal from "./dropdown-menu-portal.svelte";
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "start",
|
||||||
|
portalProps,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.ContentProps & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DropdownMenuPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPortal {...portalProps}>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
{sideOffset}
|
||||||
|
{align}
|
||||||
|
class={cn(
|
||||||
|
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-md p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-(--bits-dropdown-menu-anchor-width) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPortal>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
|
||||||
|
inset?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.GroupHeading
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-group-heading"
|
||||||
|
data-inset={inset}
|
||||||
|
class={cn("px-2 py-1.5 text-sm font-semibold data-[inset]:ps-8", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.ItemProps & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
class={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||||
|
inset?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
class={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8 data-[inset]:pl-8", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ...restProps }: DropdownMenuPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.Portal {...restProps} />
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
...restProps
|
||||||
|
}: DropdownMenuPrimitive.RadioGroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
bind:ref
|
||||||
|
bind:value
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user