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
|
||||
style: form
|
||||
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:
|
||||
'200':
|
||||
description: Graph view
|
||||
@@ -1696,6 +1707,22 @@ paths:
|
||||
$ref: '#/components/schemas/HealthSummary'
|
||||
default:
|
||||
$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:
|
||||
get:
|
||||
tags:
|
||||
@@ -1887,6 +1914,21 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
health:
|
||||
type: string
|
||||
description: last observed health, when the entity is monitored
|
||||
nullable: true
|
||||
enum:
|
||||
- healthy
|
||||
- degraded
|
||||
- down
|
||||
- unknown
|
||||
- stale
|
||||
last_check_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: when health was last observed
|
||||
EntityCreate:
|
||||
type: object
|
||||
required:
|
||||
@@ -1983,6 +2025,17 @@ components:
|
||||
truncated:
|
||||
type: boolean
|
||||
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:
|
||||
type: object
|
||||
required:
|
||||
@@ -2207,6 +2260,7 @@ components:
|
||||
- disk
|
||||
- cert-expiry
|
||||
- drift
|
||||
- ping
|
||||
- ssh-script
|
||||
target:
|
||||
type: string
|
||||
@@ -2246,6 +2300,7 @@ components:
|
||||
- disk
|
||||
- cert-expiry
|
||||
- drift
|
||||
- ping
|
||||
- ssh-script
|
||||
target:
|
||||
type: string
|
||||
@@ -2992,6 +3047,9 @@ components:
|
||||
type: integer
|
||||
unknown:
|
||||
type: integer
|
||||
stale:
|
||||
type: integer
|
||||
description: last observation older than the check's expected cadence
|
||||
entities:
|
||||
type: array
|
||||
items:
|
||||
@@ -3012,6 +3070,7 @@ components:
|
||||
- degraded
|
||||
- down
|
||||
- unknown
|
||||
- stale
|
||||
trend:
|
||||
type: string
|
||||
enum:
|
||||
@@ -3024,6 +3083,72 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
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:
|
||||
type: object
|
||||
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
|
||||
// multi-turn. The current user turn is saved by the HTTP handler before
|
||||
// this runs, so it is already included in the history for real sessions.
|
||||
// Intermediate tool_use/tool_result pairs are not replayed (their ids
|
||||
// must match exactly or the API rejects them); prior final answers carry
|
||||
// the salient context. Ephemeral sessions (no store) fall back to the
|
||||
// single incoming message.
|
||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(a.system)}
|
||||
// Prior tool_use/tool_result pairs are replayed as a tool-calling
|
||||
// assistant message followed by matching tool-role results, so the agent
|
||||
// starts each turn already knowing what it already checked instead of
|
||||
// re-querying the same tools from scratch. Ephemeral sessions (no store)
|
||||
// fall back to the single incoming message.
|
||||
system := a.system
|
||||
if snapshot := a.fleetSnapshot(); snapshot != "" {
|
||||
system += "\n\n" + snapshot
|
||||
}
|
||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
|
||||
history, _ := a.store.getMessages(ctx, sessionID)
|
||||
for _, m := range history {
|
||||
text := extractText(m.Content)
|
||||
@@ -120,6 +125,12 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
case "user":
|
||||
messages = append(messages, openai.UserMessage(text))
|
||||
case "assistant":
|
||||
if calls := extractToolCalls(m.Content); len(calls) > 0 {
|
||||
messages = append(messages, assistantToolCallMessage(calls))
|
||||
for _, c := range calls {
|
||||
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
|
||||
}
|
||||
}
|
||||
if text != "" {
|
||||
messages = append(messages, openai.AssistantMessage(text))
|
||||
}
|
||||
@@ -243,6 +254,140 @@ func extractText(content json.RawMessage) string {
|
||||
return m.Text
|
||||
}
|
||||
|
||||
// persistedCall is one merged tool_use+tool_result pair from a persisted
|
||||
// assistant message's tool_calls array. The store keeps them as two entries
|
||||
// sharing the same id (mirroring the SSE event pair); replay needs one
|
||||
// entry per id to build a valid tool-calling assistant message.
|
||||
type persistedCall struct {
|
||||
id string
|
||||
name string
|
||||
args json.RawMessage
|
||||
result json.RawMessage
|
||||
errMsg string
|
||||
}
|
||||
|
||||
func (c persistedCall) resultText() string {
|
||||
if c.errMsg != "" {
|
||||
return c.errMsg
|
||||
}
|
||||
if len(c.result) > 0 {
|
||||
return string(c.result)
|
||||
}
|
||||
return "null"
|
||||
}
|
||||
|
||||
// extractToolCalls parses and merges a persisted message's tool_calls array,
|
||||
// preserving first-seen order across ids.
|
||||
func extractToolCalls(content json.RawMessage) []persistedCall {
|
||||
var m struct {
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"args"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &m); err != nil || len(m.ToolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
byID := make(map[string]*persistedCall, len(m.ToolCalls))
|
||||
var order []string
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.ID == "" {
|
||||
continue
|
||||
}
|
||||
pc, ok := byID[tc.ID]
|
||||
if !ok {
|
||||
pc = &persistedCall{id: tc.ID}
|
||||
byID[tc.ID] = pc
|
||||
order = append(order, tc.ID)
|
||||
}
|
||||
if tc.Name != "" {
|
||||
pc.name = tc.Name
|
||||
}
|
||||
if len(tc.Args) > 0 && string(tc.Args) != "null" {
|
||||
pc.args = tc.Args
|
||||
}
|
||||
if tc.Type == "tool_result" {
|
||||
pc.errMsg = tc.Error
|
||||
pc.result = tc.Result
|
||||
}
|
||||
}
|
||||
|
||||
calls := make([]persistedCall, 0, len(order))
|
||||
for _, id := range order {
|
||||
calls = append(calls, *byID[id])
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
// assistantToolCallMessage builds the tool-calling assistant message that
|
||||
// must precede the tool-role results being replayed.
|
||||
func assistantToolCallMessage(calls []persistedCall) openai.ChatCompletionMessageParamUnion {
|
||||
toolCalls := make([]openai.ChatCompletionMessageToolCallParam, 0, len(calls))
|
||||
for _, c := range calls {
|
||||
args := string(c.args)
|
||||
if args == "" {
|
||||
args = "{}"
|
||||
}
|
||||
toolCalls = append(toolCalls, openai.ChatCompletionMessageToolCallParam{
|
||||
ID: c.id,
|
||||
Function: openai.ChatCompletionMessageToolCallFunctionParam{
|
||||
Name: c.name,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
}
|
||||
return openai.ChatCompletionMessageParamUnion{
|
||||
OfAssistant: &openai.ChatCompletionAssistantMessageParam{ToolCalls: toolCalls},
|
||||
}
|
||||
}
|
||||
|
||||
// fleetSnapshot returns a compact, current-as-of-now fleet health line for
|
||||
// the system prompt so the agent starts each turn already oriented instead
|
||||
// of spending its first iteration rediscovering topology it already has
|
||||
// tools to query. Best-effort: an empty string on any failure just means no
|
||||
// snapshot, not an error for the turn.
|
||||
func (a *agent) fleetSnapshot() string {
|
||||
result, err := a.client.callTool("get_health_summary", map[string]any{})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
rows, ok := result.([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
var attention []string
|
||||
for _, r := range rows {
|
||||
row, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
health, _ := row["health"].(string)
|
||||
counts[health]++
|
||||
if health != "healthy" && health != "" {
|
||||
if slug, ok := row["slug"].(string); ok && len(attention) < 10 {
|
||||
attention = append(attention, fmt.Sprintf("%s(%s)", slug, health))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(counts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("Current fleet snapshot (as of now): healthy=%d degraded=%d down=%d stale=%d unknown=%d.",
|
||||
counts["healthy"], counts["degraded"], counts["down"], counts["stale"], counts["unknown"])
|
||||
if len(attention) > 0 {
|
||||
summary += " Needs attention: " + fmt.Sprintf("%v", attention) + "."
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
||||
defs, err := a.client.listToolsFull()
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -351,9 +352,51 @@ type mcpJSONRPCResponse struct {
|
||||
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) {
|
||||
c.mu.Lock()
|
||||
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++
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
@@ -378,13 +421,21 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
|
||||
}
|
||||
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.sessionID = resp.Header.Get("Mcp-Session-Id")
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
gotData := false
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
gotData = true
|
||||
data := line[6:]
|
||||
if err := json.Unmarshal([]byte(data), result); err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
c.sessionID = result.sessionID
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/config"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
@@ -24,30 +27,38 @@ import (
|
||||
|
||||
// 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/*;
|
||||
// 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 {
|
||||
dist, err := web.DistFS()
|
||||
if err != nil {
|
||||
slog.Warn("ui: embedded assets unavailable", "error", err)
|
||||
return http.NotFoundHandler()
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(dist))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/ui"), "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
serve := func(w http.ResponseWriter, r *http.Request, name string) bool {
|
||||
f, err := dist.Open(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if f, err := dist.Open(path); err == nil {
|
||||
f.Close()
|
||||
r.URL.Path = "/" + path
|
||||
fileServer.ServeHTTP(w, r)
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
// SPA fallback: serve index.html for unknown client-side routes.
|
||||
if f, err := dist.Open("index.html"); err == nil {
|
||||
f.Close()
|
||||
r.URL.Path = "/index.html"
|
||||
fileServer.ServeHTTP(w, r)
|
||||
if serve(w, r, "index.html") {
|
||||
return
|
||||
}
|
||||
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
|
||||
|
||||
# --- Runtime: distroless static ---
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
# --- Runtime: alpine with SSH + ping for scheduler checks ---
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates openssh-client-default
|
||||
|
||||
COPY --from=builder /oikos /oikos
|
||||
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_DEBUG: "true"
|
||||
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"]
|
||||
stop_signal: SIGTERM
|
||||
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.
|
||||
|
||||
| ADR | Title |
|
||||
|---|---|
|
||||
|---|---|---|
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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;
|
||||
|
||||
-- name: InsertMetricSample :exec
|
||||
INSERT INTO metric_samples (entity_id, metric, value, tags)
|
||||
VALUES ($1, $2, $3, $4);
|
||||
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
||||
VALUES ($1, $2, $3, $4, now());
|
||||
|
||||
-- name: QueryMetrics :many
|
||||
SELECT time_bucket(sqlc.arg('bucket_interval')::interval, ts) AS bucket,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dtoro/oikos/internal/checkdefaults"
|
||||
"github.com/google/uuid"
|
||||
"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)
|
||||
}
|
||||
|
||||
checkdefaults.Ensure(ctx, tx, entityID, slug, typeName, attrsBytes)
|
||||
|
||||
r.Entities++
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,22 @@ type AgentActivity struct {
|
||||
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 {
|
||||
EntityID uuid.UUID
|
||||
SubjectEntityID *uuid.UUID
|
||||
|
||||
@@ -560,8 +560,8 @@ func (q *Queries) InsertFeedback(ctx context.Context, arg InsertFeedbackParams)
|
||||
}
|
||||
|
||||
const insertMetricSample = `-- name: InsertMetricSample :exec
|
||||
INSERT INTO metric_samples (entity_id, metric, value, tags)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO metric_samples (entity_id, metric, value, tags, ts)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
`
|
||||
|
||||
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"
|
||||
CheckKindDrift CheckKind = "drift"
|
||||
CheckKindHttp CheckKind = "http"
|
||||
CheckKindPing CheckKind = "ping"
|
||||
CheckKindSshScript CheckKind = "ssh-script"
|
||||
CheckKindTcp CheckKind = "tcp"
|
||||
)
|
||||
@@ -91,6 +92,7 @@ const (
|
||||
CheckCreateKindDisk CheckCreateKind = "disk"
|
||||
CheckCreateKindDrift CheckCreateKind = "drift"
|
||||
CheckCreateKindHttp CheckCreateKind = "http"
|
||||
CheckCreateKindPing CheckCreateKind = "ping"
|
||||
CheckCreateKindSshScript CheckCreateKind = "ssh-script"
|
||||
CheckCreateKindTcp CheckCreateKind = "tcp"
|
||||
)
|
||||
@@ -102,6 +104,15 @@ const (
|
||||
ClassificationRouteHold ClassificationRoute = "hold"
|
||||
)
|
||||
|
||||
// Defines values for EntityHealth.
|
||||
const (
|
||||
EntityHealthDegraded EntityHealth = "degraded"
|
||||
EntityHealthDown EntityHealth = "down"
|
||||
EntityHealthHealthy EntityHealth = "healthy"
|
||||
EntityHealthStale EntityHealth = "stale"
|
||||
EntityHealthUnknown EntityHealth = "unknown"
|
||||
)
|
||||
|
||||
// Defines values for EntityTypeLayer.
|
||||
const (
|
||||
EntityTypeLayerCognition EntityTypeLayer = "cognition"
|
||||
@@ -153,11 +164,21 @@ const (
|
||||
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.
|
||||
const (
|
||||
HealthSummaryEntitiesHealthDegraded HealthSummaryEntitiesHealth = "degraded"
|
||||
HealthSummaryEntitiesHealthDown HealthSummaryEntitiesHealth = "down"
|
||||
HealthSummaryEntitiesHealthHealthy HealthSummaryEntitiesHealth = "healthy"
|
||||
HealthSummaryEntitiesHealthStale HealthSummaryEntitiesHealth = "stale"
|
||||
HealthSummaryEntitiesHealthUnknown HealthSummaryEntitiesHealth = "unknown"
|
||||
)
|
||||
|
||||
@@ -295,6 +316,11 @@ const (
|
||||
Out GetEntityRelationsParamsDirection = "out"
|
||||
)
|
||||
|
||||
// Defines values for GetGraphParamsInclude.
|
||||
const (
|
||||
Status GetGraphParamsInclude = "status"
|
||||
)
|
||||
|
||||
// Defines values for QueryMetricsParamsRollup.
|
||||
const (
|
||||
QueryMetricsParamsRollupAuto QueryMetricsParamsRollup = "auto"
|
||||
@@ -520,6 +546,38 @@ type ClientSecrets struct {
|
||||
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.
|
||||
type EnrollRequest struct {
|
||||
// Hostname Actual hostname of the enrolling machine
|
||||
@@ -552,18 +610,27 @@ type EnrollResponse struct {
|
||||
|
||||
// Entity defines model for Entity.
|
||||
type Entity struct {
|
||||
Attributes *map[string]interface{} `json:"attributes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
MaintenanceUntil *time.Time `json:"maintenance_until"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
State *string `json:"state"`
|
||||
Type string `json:"type"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Attributes *map[string]interface{} `json:"attributes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Health last observed health, when the entity is monitored
|
||||
Health *EntityHealth `json:"health"`
|
||||
Id openapi_types.UUID `json:"id"`
|
||||
|
||||
// LastCheckAt when health was last observed
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
MaintenanceUntil *time.Time `json:"maintenance_until"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
State *string `json:"state"`
|
||||
Type string `json:"type"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// EntityHealth last observed health, when the entity is monitored
|
||||
type EntityHealth string
|
||||
|
||||
// EntityCreate defines model for EntityCreate.
|
||||
type EntityCreate struct {
|
||||
// Attributes Validated against the type's attribute_schema
|
||||
@@ -696,12 +763,18 @@ type ExecutionRequest struct {
|
||||
// GraphView defines model for GraphView.
|
||||
type GraphView struct {
|
||||
Edges []Relationship `json:"edges"`
|
||||
Nodes []Entity `json:"nodes"`
|
||||
|
||||
// Health entity id -> health, present when include=status was requested
|
||||
Health *map[string]GraphViewHealth `json:"health,omitempty"`
|
||||
Nodes []Entity `json:"nodes"`
|
||||
|
||||
// Truncated True if node cap was hit
|
||||
Truncated *bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// GraphViewHealth defines model for GraphView.Health.
|
||||
type GraphViewHealth string
|
||||
|
||||
// HealthSummary defines model for HealthSummary.
|
||||
type HealthSummary struct {
|
||||
Entities []struct {
|
||||
@@ -715,7 +788,10 @@ type HealthSummary struct {
|
||||
Degraded int `json:"degraded"`
|
||||
Down int `json:"down"`
|
||||
Healthy int `json:"healthy"`
|
||||
Unknown int `json:"unknown"`
|
||||
|
||||
// Stale last observation older than the check's expected cadence
|
||||
Stale *int `json:"stale,omitempty"`
|
||||
Unknown int `json:"unknown"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
@@ -1234,8 +1310,14 @@ type GetGraphParams struct {
|
||||
Root *string `form:"root,omitempty" json:"root,omitempty"`
|
||||
Depth *int `form:"depth,omitempty" json:"depth,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.
|
||||
type SearchKnowledgeParams struct {
|
||||
Q string `form:"q" json:"q"`
|
||||
@@ -1469,6 +1551,9 @@ type ServerInterface interface {
|
||||
// List secrets accessible to this client
|
||||
// (GET /clients/{slug}/secrets)
|
||||
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
|
||||
// (GET /entities)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// (GET /entities)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
// ------------- 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) {
|
||||
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.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.Get(options.BaseURL+"/entities", wrapper.ListEntities)
|
||||
})
|
||||
@@ -5020,6 +5142,34 @@ func (response GetClientSecretsdefaultApplicationProblemPlusJSONResponse) VisitG
|
||||
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 {
|
||||
Params ListEntitiesParams
|
||||
}
|
||||
@@ -6401,6 +6551,9 @@ type StrictServerInterface interface {
|
||||
// List secrets accessible to this client
|
||||
// (GET /clients/{slug}/secrets)
|
||||
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
|
||||
// (GET /entities)
|
||||
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
|
||||
func (sh *strictHandler) ListEntities(w http.ResponseWriter, r *http.Request, params ListEntitiesParams) {
|
||||
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
|
||||
var swaggerSpec = []string{
|
||||
|
||||
"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",
|
||||
"7gAA",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification file
|
||||
|
||||
@@ -75,22 +75,31 @@ func (s *Server) resolveEntityID(ctx context.Context, idOrSlug string) (uuid.UUI
|
||||
return id, err
|
||||
}
|
||||
|
||||
// entityCols requires the entities table to be aliased as `e`.
|
||||
// entityCols requires the entities table to be aliased as `e`, with
|
||||
// entity_status left-joined and aliased as `st` (see withEntityStatus).
|
||||
const entityCols = `e.id, e.slug, e.type, e.name, e.state, e.attributes,
|
||||
e.maintenance_until, e.version, e.created_at, e.updated_at`
|
||||
e.maintenance_until, e.version, e.created_at, e.updated_at,
|
||||
st.health, st.last_check_at`
|
||||
|
||||
func scanEntity(row pgx.Row) (gen.Entity, error) {
|
||||
var e gen.Entity
|
||||
var state *string
|
||||
var attrsJSON []byte
|
||||
var maint *time.Time
|
||||
var health *string
|
||||
var lastCheckAt *time.Time
|
||||
err := row.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
||||
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt)
|
||||
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
e.State = state
|
||||
e.MaintenanceUntil = maint
|
||||
if health != nil {
|
||||
h := gen.EntityHealth(*health)
|
||||
e.Health = &h
|
||||
}
|
||||
e.LastCheckAt = lastCheckAt
|
||||
var attrs map[string]any
|
||||
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
||||
e.Attributes = &attrs
|
||||
@@ -113,6 +122,7 @@ func (s *Server) ListEntities(ctx context.Context, req gen.ListEntitiesRequestOb
|
||||
)
|
||||
SELECT ` + entityCols + ` FROM entities e
|
||||
JOIN entity_types et ON et.name = e.type
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
WHERE e.type IN (SELECT name FROM tt)
|
||||
AND ($2::text IS NULL OR e.state = $2)
|
||||
AND ($3::text IS NULL OR et.domain = $3)
|
||||
@@ -159,7 +169,7 @@ func (s *Server) GetEntity(ctx context.Context, req gen.GetEntityRequestObject)
|
||||
return nil, err
|
||||
}
|
||||
e, err := scanEntity(s.pool.QueryRow(ctx,
|
||||
"SELECT "+entityCols+" FROM entities e WHERE e.id = $1", id))
|
||||
"SELECT "+entityCols+" FROM entities e LEFT JOIN entity_status st ON st.entity_id = e.id WHERE e.id = $1", id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -231,6 +241,7 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
|
||||
SELECT `+entityCols+`, b.depth
|
||||
FROM blast_radius($1, $2) b
|
||||
JOIN entities e ON e.id = b.entity_id
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY b.depth, e.slug`, id, depth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -246,13 +257,20 @@ func (s *Server) GetBlastRadius(ctx context.Context, req gen.GetBlastRadiusReque
|
||||
var state *string
|
||||
var attrsJSON []byte
|
||||
var maint *time.Time
|
||||
var health *string
|
||||
var lastCheckAt *time.Time
|
||||
var d int
|
||||
if err := rows.Scan(&e.Id, &e.Slug, &e.Type, &e.Name, &state, &attrsJSON,
|
||||
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &d); err != nil {
|
||||
&maint, &e.Version, &e.CreatedAt, &e.UpdatedAt, &health, &lastCheckAt, &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.State = state
|
||||
e.MaintenanceUntil = maint
|
||||
if health != nil {
|
||||
h := gen.EntityHealth(*health)
|
||||
e.Health = &h
|
||||
}
|
||||
e.LastCheckAt = lastCheckAt
|
||||
var attrs map[string]any
|
||||
if len(attrsJSON) > 0 && json.Unmarshal(attrsJSON, &attrs) == nil && len(attrs) > 0 {
|
||||
e.Attributes = &attrs
|
||||
@@ -283,10 +301,13 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
||||
nodes, err = s.queryEntities(ctx, `
|
||||
SELECT `+entityCols+`
|
||||
FROM blast_radius($1, $2, $3) b JOIN entities e ON e.id = b.entity_id
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY e.slug`, rootID, depth, req.Params.RelType)
|
||||
} else {
|
||||
nodes, err = s.queryEntities(ctx, `
|
||||
SELECT `+entityCols+` FROM entities e ORDER BY e.slug LIMIT $1`,
|
||||
SELECT `+entityCols+` FROM entities e
|
||||
LEFT JOIN entity_status st ON st.entity_id = e.id
|
||||
ORDER BY e.slug LIMIT $1`,
|
||||
graphNodeCap+1)
|
||||
if err == nil && len(nodes) > graphNodeCap {
|
||||
nodes = nodes[:graphNodeCap]
|
||||
@@ -322,9 +343,44 @@ func (s *Server) GetGraph(ctx context.Context, req gen.GetGraphRequestObject) (g
|
||||
if truncated {
|
||||
resp.Truncated = &truncated
|
||||
}
|
||||
|
||||
if req.Params.Include != nil {
|
||||
for _, inc := range *req.Params.Include {
|
||||
if inc == gen.Status {
|
||||
health, herr := s.entityHealthByID(ctx, ids)
|
||||
if herr != nil {
|
||||
return nil, herr
|
||||
}
|
||||
resp.Health = &health
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// entityHealthByID returns entity_status.health keyed by entity id, for the
|
||||
// given id set (used by GetGraph's include=status).
|
||||
func (s *Server) entityHealthByID(ctx context.Context, ids []uuid.UUID) (map[string]gen.GraphViewHealth, error) {
|
||||
health := make(map[string]gen.GraphViewHealth, len(ids))
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT entity_id, health FROM entity_status WHERE entity_id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var h string
|
||||
if err := rows.Scan(&id, &h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
health[id.String()] = gen.GraphViewHealth(h)
|
||||
}
|
||||
return health, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) queryEntities(ctx context.Context, query string, args ...any) ([]gen.Entity, error) {
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
@@ -488,14 +544,18 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
||||
Type string `json:"type"`
|
||||
}{}
|
||||
|
||||
// Exclude 'check' entities (internal probes) — only entities actually
|
||||
// being monitored should count toward fleet health.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.type <> 'check'
|
||||
ORDER BY e.slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
stale := 0
|
||||
for rows.Next() {
|
||||
var slug, typ, health string
|
||||
var lastCheck *time.Time
|
||||
@@ -509,6 +569,8 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
||||
resp.Summary.Degraded++
|
||||
case "down":
|
||||
resp.Summary.Down++
|
||||
case "stale":
|
||||
stale++
|
||||
default:
|
||||
resp.Summary.Unknown++
|
||||
}
|
||||
@@ -525,6 +587,9 @@ func (s *Server) GetFleetHealth(ctx context.Context, req gen.GetFleetHealthReque
|
||||
Type: typ,
|
||||
})
|
||||
}
|
||||
if stale > 0 {
|
||||
resp.Summary.Stale = &stale
|
||||
}
|
||||
return resp, rows.Err()
|
||||
}
|
||||
|
||||
@@ -920,6 +985,8 @@ func (s *Server) CreateEntity(ctx context.Context, req gen.CreateEntityRequestOb
|
||||
return nil, eventErr
|
||||
}
|
||||
|
||||
ensureDefaultChecks(ctx, tx, inserted.ID, slug, req.Body.Type, attrsJSON)
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1184,6 +1251,8 @@ func (s *Server) EnrollClient(ctx context.Context, req gen.EnrollClientRequestOb
|
||||
"info", "oikos-api", "",
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ func (s *Server) CreateCheck(ctx context.Context, req gen.CreateCheckRequestObje
|
||||
entity, err := q.InsertEntity(ctx, sqlcgen.InsertEntityParams{
|
||||
ID: id,
|
||||
Slug: slug,
|
||||
Type: "check_def",
|
||||
Type: "check",
|
||||
Name: slug,
|
||||
Attributes: []byte("{}"),
|
||||
})
|
||||
@@ -1835,10 +1835,6 @@ func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestOb
|
||||
if req.Params.EntityId == nil || *req.Params.EntityId == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1853,8 +1849,33 @@ func (s *Server) QueryMetrics(ctx context.Context, req gen.QueryMetricsRequestOb
|
||||
to = *req.Params.To
|
||||
}
|
||||
|
||||
var metricNames []string
|
||||
if req.Params.Metric != nil && len(*req.Params.Metric) > 0 {
|
||||
metricNames = *req.Params.Metric
|
||||
} else {
|
||||
// metric omitted: report every metric recorded for this entity in range.
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT metric FROM metric_samples
|
||||
WHERE entity_id = $1 AND ts >= $2 AND ts <= $3
|
||||
ORDER BY metric`, entityID, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
metricNames = append(metricNames, name)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
items := []gen.MetricSeries{}
|
||||
for _, metricName := range *req.Params.Metric {
|
||||
for _, metricName := range metricNames {
|
||||
series := gen.MetricSeries{
|
||||
EntityId: entityID.String(),
|
||||
Metric: metricName,
|
||||
|
||||
@@ -125,6 +125,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
||||
return queryRows(ctx, pool, `
|
||||
SELECT e.slug, e.type, st.health, st.last_check_at
|
||||
FROM entity_status st JOIN entities e ON e.id = st.entity_id
|
||||
WHERE e.type <> 'check'
|
||||
ORDER BY e.slug`), nil
|
||||
})
|
||||
|
||||
|
||||
@@ -7,10 +7,15 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dtoro/oikos/internal/config"
|
||||
@@ -22,6 +27,11 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var (
|
||||
sshKeyPath string
|
||||
sshUser string
|
||||
)
|
||||
|
||||
// Run starts the scheduler loop. Blocks until ctx is cancelled.
|
||||
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
||||
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
|
||||
}
|
||||
|
||||
sshKeyPath = cfg.SSHKeyPath
|
||||
sshUser = cfg.SSHUser
|
||||
if sshUser == "" {
|
||||
sshUser = "root"
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -78,85 +94,94 @@ func runCheckPass(ctx context.Context, pool *db.Pool) {
|
||||
}
|
||||
|
||||
// runCheck executes a single check and processes the result.
|
||||
//
|
||||
// check_defs.entity_id identifies the *check* (probe) entity itself;
|
||||
// check_defs.target_id identifies the entity actually being observed (the
|
||||
// host/service/etc). Health, metrics, and events must attach to the target
|
||||
// so the observed entity's own record reflects reality — not the internal
|
||||
// probe. Signals stay keyed by the check entity (cd.EntityID), matching how
|
||||
// they are created below and resolved elsewhere.
|
||||
func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) {
|
||||
q := sqlcgen.New(pool)
|
||||
start := time.Now()
|
||||
|
||||
health, signalKind, evidence, checkErr := executeCheck(ctx, cd)
|
||||
result := executeCheck(ctx, cd)
|
||||
|
||||
latency := time.Since(start).Milliseconds()
|
||||
|
||||
// Write metric
|
||||
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
|
||||
EntityID: cd.EntityID,
|
||||
Metric: "probe_latency_ms",
|
||||
Value: float64(latency),
|
||||
Tags: []byte(`{}`),
|
||||
})
|
||||
if result.metrics == nil {
|
||||
result.metrics = make(map[string]float64)
|
||||
}
|
||||
result.metrics["probe_latency_ms"] = float64(latency)
|
||||
|
||||
if checkErr != nil {
|
||||
slog.Warn("scheduler: check failed",
|
||||
"entity", cd.EntitySlug, "kind", cd.Kind, "error", checkErr)
|
||||
targetID := cd.EntityID
|
||||
if cd.TargetID != nil {
|
||||
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" {
|
||||
// Recovery: resolve any open signal for this check
|
||||
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
|
||||
// Update entity_status to healthy
|
||||
if result.err != nil {
|
||||
slog.Warn("scheduler: check failed",
|
||||
"entity", cd.EntitySlug, "kind", cd.Kind, "error", result.err)
|
||||
}
|
||||
|
||||
prevHealth := currentHealth(ctx, pool, targetID)
|
||||
|
||||
if result.signalKind == "" || result.health == "healthy" {
|
||||
resolveSignal(ctx, pool, cd.EntityID, targetID, cd.EntitySlug)
|
||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||
EntityID: cd.EntityID,
|
||||
EntityID: targetID,
|
||||
Health: "healthy",
|
||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||
Details: []byte(`{}`),
|
||||
})
|
||||
if prevHealth != "" && prevHealth != "healthy" {
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, "info",
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", targetID, "info",
|
||||
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": "healthy"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Failure: upsert signal (dedup via partial unique index)
|
||||
slog.Warn("scheduler: raising signal",
|
||||
"entity", cd.EntitySlug, "kind", signalKind, "evidence", evidence)
|
||||
"entity", cd.EntitySlug, "kind", result.signalKind, "evidence", result.evidence)
|
||||
|
||||
severity := "warning"
|
||||
if signalKind == "down" {
|
||||
severity = "critical"
|
||||
}
|
||||
severity := evaluateSeverity(cd.Kind, result.signalKind, cd.Config, result.metrics)
|
||||
|
||||
sig, err := q.UpsertSignal(ctx, sqlcgen.UpsertSignalParams{
|
||||
EntityID: cd.EntityID,
|
||||
Kind: signalKind,
|
||||
Kind: result.signalKind,
|
||||
Severity: severity,
|
||||
TargetEntityID: cd.TargetID,
|
||||
Evidence: &evidence,
|
||||
Evidence: &result.evidence,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("scheduler: upsert signal", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update entity_status
|
||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||
EntityID: cd.EntityID,
|
||||
Health: health,
|
||||
EntityID: targetID,
|
||||
Health: result.health,
|
||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||
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" {
|
||||
emitSchedulerEvent(ctx, pool, "signal.raised", cd.EntityID, severity,
|
||||
map[string]any{"slug": cd.EntitySlug, "kind": signalKind, "evidence": evidence})
|
||||
emitSchedulerEvent(ctx, pool, "signal.raised", targetID, severity,
|
||||
map[string]any{"slug": cd.EntitySlug, "kind": result.signalKind, "evidence": result.evidence})
|
||||
}
|
||||
if prevHealth != health {
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, severity,
|
||||
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": health})
|
||||
if prevHealth != result.health {
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", targetID, severity,
|
||||
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)
|
||||
}
|
||||
|
||||
// resolveSignal resolves any open signal for the given check entity.
|
||||
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
|
||||
// resolveSignal resolves any open signal raised by the given check entity.
|
||||
// checkID matches how signals are keyed (UpsertSignal uses the check's own
|
||||
// entity id); targetID is the observed entity whose status this affects.
|
||||
func resolveSignal(ctx context.Context, pool *db.Pool, checkID, targetID uuid.UUID, slug string) {
|
||||
q := sqlcgen.New(pool)
|
||||
// Check if there's an open signal on this entity
|
||||
tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
||||
WHERE entity_id = $1 AND state = 'raised'`, entityID)
|
||||
WHERE entity_id = $1 AND state = 'raised'`, checkID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
emitSchedulerEvent(ctx, pool, "signal.resolved", entityID, "info",
|
||||
emitSchedulerEvent(ctx, pool, "signal.resolved", targetID, "info",
|
||||
map[string]any{"slug": slug})
|
||||
}
|
||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||
EntityID: entityID,
|
||||
EntityID: targetID,
|
||||
Health: "healthy",
|
||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||
Details: []byte(`{}`),
|
||||
@@ -197,8 +224,17 @@ func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, 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.
|
||||
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 {
|
||||
case "http":
|
||||
return checkHTTP(ctx, cd)
|
||||
@@ -208,8 +244,12 @@ func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (heal
|
||||
return checkDisk(ctx, cd)
|
||||
case "cert-expiry":
|
||||
return checkCertExpiry(ctx, cd)
|
||||
case "ping":
|
||||
return checkPing(ctx, cd)
|
||||
case "ssh-script":
|
||||
return checkSSHScript(ctx, cd)
|
||||
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)
|
||||
}
|
||||
|
||||
staleSweep(ctx, pool)
|
||||
|
||||
// Log housekeeping completion
|
||||
slog.Debug("scheduler: housekeeping done", "pruned_idempotency_before", cutoff.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// staleMultiplier and staleFloor bound how long an entity can go unobserved
|
||||
// before its last-known health is no longer trusted. An entity is stale once
|
||||
// it has gone longer than staleMultiplier times its fastest enabled check's
|
||||
// interval (or staleFloor, whichever is larger) without a fresh observation —
|
||||
// covering both a stalled scheduler and a disabled/broken check_def.
|
||||
const (
|
||||
staleMultiplier = 3
|
||||
staleFloor = 5 * time.Minute
|
||||
)
|
||||
|
||||
// staleSweep marks entities whose last observation has aged past their
|
||||
// check's expected cadence as 'stale', so the system never reports an old
|
||||
// health value as if it were current. Runs once per housekeeping pass.
|
||||
func staleSweep(ctx context.Context, pool *db.Pool) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT e.id, e.slug, st.health
|
||||
FROM entity_status st
|
||||
JOIN entities e ON e.id = st.entity_id
|
||||
JOIN (
|
||||
SELECT target_id, MIN(interval_s) AS min_interval
|
||||
FROM check_defs
|
||||
WHERE enabled AND target_id IS NOT NULL
|
||||
GROUP BY target_id
|
||||
) iv ON iv.target_id = st.entity_id
|
||||
WHERE st.health <> 'stale'
|
||||
AND (st.last_check_at IS NULL
|
||||
OR st.last_check_at < now() - make_interval(secs => GREATEST(iv.min_interval * $1, $2)))`,
|
||||
staleMultiplier, int(staleFloor.Seconds()))
|
||||
if err != nil {
|
||||
slog.Error("scheduler: stale sweep query", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type staleEntity struct {
|
||||
id uuid.UUID
|
||||
slug string
|
||||
health string
|
||||
}
|
||||
var stale []staleEntity
|
||||
for rows.Next() {
|
||||
var se staleEntity
|
||||
if err := rows.Scan(&se.id, &se.slug, &se.health); err != nil {
|
||||
continue
|
||||
}
|
||||
stale = append(stale, se)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, se := range stale {
|
||||
_, err := pool.Exec(ctx,
|
||||
`UPDATE entity_status SET health = 'stale', updated_at = now() WHERE entity_id = $1`, se.id)
|
||||
if err != nil {
|
||||
slog.Error("scheduler: mark stale", "entity", se.slug, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Warn("scheduler: entity stale", "entity", se.slug, "prev_health", se.health)
|
||||
emitSchedulerEvent(ctx, pool, "health.stale", se.id, "warning",
|
||||
map[string]any{"slug": se.slug, "from": se.health, "to": "stale"})
|
||||
}
|
||||
}
|
||||
|
||||
// checkHTTP performs an HTTP health check.
|
||||
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
||||
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||
cfg := struct {
|
||||
URL string `json:"url"`
|
||||
ExpectedStatus int `json:"expected_status"`
|
||||
@@ -241,7 +345,7 @@ func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
|
||||
_ = json.Unmarshal(cd.Config, &cfg)
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return "healthy", "", "", nil
|
||||
return checkResult{health: "healthy"}
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
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()
|
||||
|
||||
if resp.StatusCode != cfg.ExpectedStatus {
|
||||
return "degraded", "http",
|
||||
fmt.Sprintf("GET %s returned %d (expected %d)", cfg.URL, resp.StatusCode, cfg.ExpectedStatus), nil
|
||||
return checkResult{
|
||||
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.
|
||||
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
||||
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||
cfg := struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -285,7 +399,7 @@ func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
|
||||
_ = json.Unmarshal(cd.Config, &cfg)
|
||||
}
|
||||
if cfg.Host == "" || cfg.Port == 0 {
|
||||
return "healthy", "", "", nil
|
||||
return checkResult{health: "healthy"}
|
||||
}
|
||||
|
||||
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))
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
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()
|
||||
return "healthy", "", "", nil
|
||||
return checkResult{health: "healthy"}
|
||||
}
|
||||
|
||||
// checkDisk performs a disk usage check via local or SSH.
|
||||
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
||||
// checkDisk performs a disk usage check.
|
||||
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||
cfg := struct {
|
||||
Path string `json:"path"`
|
||||
ThresholdPct int `json:"threshold_pct"`
|
||||
@@ -315,29 +433,45 @@ func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string,
|
||||
_ = json.Unmarshal(cd.Config, &cfg)
|
||||
}
|
||||
|
||||
// Use unix.Statfs for disk usage.
|
||||
var stat unix.Statfs_t
|
||||
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)
|
||||
free := stat.Bfree * uint64(stat.Bsize)
|
||||
if total == 0 {
|
||||
return "healthy", "", "", nil
|
||||
return checkResult{health: "healthy"}
|
||||
}
|
||||
|
||||
usedPct := float64(total-free) / float64(total) * 100
|
||||
if usedPct > float64(cfg.ThresholdPct) {
|
||||
return "degraded", "disk",
|
||||
fmt.Sprintf("%s %.1f%% full (threshold %d%%)", cfg.Path, usedPct, cfg.ThresholdPct), nil
|
||||
inodePct := 0.0
|
||||
if stat.Files > 0 {
|
||||
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.
|
||||
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (string, string, string, error) {
|
||||
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
||||
cfg := struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
@@ -352,7 +486,7 @@ func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) (s
|
||||
_ = json.Unmarshal(cd.Config, &cfg)
|
||||
}
|
||||
if cfg.Host == "" {
|
||||
return "healthy", "", "", nil
|
||||
return checkResult{health: "healthy"}
|
||||
}
|
||||
|
||||
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}}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
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()
|
||||
|
||||
tlsConn := conn.(*tls.Conn)
|
||||
// Use crypto/tls ConnectionState to get verified chains
|
||||
cs := tlsConn.ConnectionState()
|
||||
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]
|
||||
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
|
||||
|
||||
if daysLeft <= cfg.CritDays {
|
||||
return "down", "cert-expiry",
|
||||
fmt.Sprintf("%s expires in %d days (crit=%d)", cfg.Host, daysLeft, cfg.CritDays), nil
|
||||
}
|
||||
if daysLeft <= cfg.WarnDays {
|
||||
return "degraded", "cert-expiry",
|
||||
fmt.Sprintf("%s expires in %d days (warn=%d)", cfg.Host, daysLeft, cfg.WarnDays), nil
|
||||
metrics := map[string]float64{
|
||||
"cert_days_left": float64(daysLeft),
|
||||
}
|
||||
|
||||
return "healthy", "", "", nil
|
||||
if daysLeft <= cfg.CritDays {
|
||||
return checkResult{
|
||||
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 {
|
||||
return checkResult{
|
||||
health: "degraded", signalKind: "cert-expiry",
|
||||
evidence: fmt.Sprintf("%s expires in %d days (warn=%d)", cfg.Host, daysLeft, cfg.WarnDays),
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
@@ -31,8 +45,18 @@ Dependencies kept minimal:
|
||||
- `d3-force` — graph physics only; render SVG/canvas by hand
|
||||
- `uPlot` — ~45 KB canvas time-series, ideal for `/metrics` rollups
|
||||
- `openapi-typescript` — dev-only, generates `api-types.d.ts`
|
||||
- No SvelteKit (no SSR wanted — the Go binary is the server), no component
|
||||
framework; hash router; hand-rolled dark-theme CSS.
|
||||
- No SvelteKit (no SSR wanted — the Go binary is the server); hash router.
|
||||
|
||||
*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
|
||||
`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-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 | [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-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned |
|
||||
|
||||
## 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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@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-sonner": "^1.1.1",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^5.5.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">
|
||||
import Chat from '$lib/../pages/Chat.svelte'
|
||||
import Sessions from '$lib/../pages/Sessions.svelte'
|
||||
import Chat from './pages/Chat.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 { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
||||
import { connectionState } from '$lib/stores/events'
|
||||
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 routeParam = $state('')
|
||||
let drawerOpen = $state(false)
|
||||
|
||||
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
||||
const openSignals = $derived(openSignalCount($summary))
|
||||
|
||||
onMount(() => {
|
||||
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()
|
||||
window.addEventListener('hashchange', sync)
|
||||
return () => window.removeEventListener('hashchange', sync)
|
||||
const unsubscribeCtx = subscribeContext()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', sync)
|
||||
unsubscribeCtx()
|
||||
}
|
||||
})
|
||||
|
||||
function navigate(p: string) {
|
||||
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>
|
||||
|
||||
<div class="app">
|
||||
<nav class="sidebar">
|
||||
<div class="logo">Oikos</div>
|
||||
<button class="nav-btn" onclick={() => { newChat(); navigate('chat') }}>
|
||||
<span class="nav-icon">✚</span>
|
||||
<span>New</span>
|
||||
</button>
|
||||
<button class="nav-btn" class:active={page === 'chat'} onclick={() => navigate('chat')}>
|
||||
<span class="nav-icon">💬</span>
|
||||
<span>Chat</span>
|
||||
</button>
|
||||
<button class="nav-btn" class:active={page === 'sessions'} onclick={() => navigate('sessions')}>
|
||||
<span class="nav-icon">📋</span>
|
||||
<span>Sessions</span>
|
||||
</button>
|
||||
<Toaster />
|
||||
|
||||
<div class="spacer"></div>
|
||||
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
|
||||
<Sidebar.Root collapsible="icon" variant="inset">
|
||||
<Sidebar.Header>
|
||||
<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>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
||||
onclick={() => { newChat(); navigate('chat') }}
|
||||
tooltipContent="New chat"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<button {...props}>
|
||||
<PlusIcon />
|
||||
<span>New chat</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Header>
|
||||
|
||||
<button class="drawer-toggle" onclick={() => drawerOpen = !drawerOpen}>
|
||||
Chat {drawerOpen ? '▼' : '▲'}
|
||||
</button>
|
||||
</nav>
|
||||
<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>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</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>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
{#if item.badge?.()}
|
||||
<Sidebar.MenuBadge>{item.badge()}</Sidebar.MenuBadge>
|
||||
{/if}
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
</Sidebar.Content>
|
||||
|
||||
<main class="main">
|
||||
{#if page === 'chat'}
|
||||
<Chat />
|
||||
{:else if page === 'sessions'}
|
||||
<Sessions />
|
||||
{:else}
|
||||
<Chat />
|
||||
{/if}
|
||||
</main>
|
||||
<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>
|
||||
|
||||
{#if drawerOpen}
|
||||
<aside class="drawer" transition:slide={{ axis: 'x' }}>
|
||||
<Chat />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
<Sidebar.Inset class="min-h-0 overflow-hidden">
|
||||
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
|
||||
<Sidebar.Trigger class="-ms-1" />
|
||||
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
|
||||
<div class="ms-auto flex items-center gap-2.5">
|
||||
{#if $summary}
|
||||
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
|
||||
<span class="flex items-center gap-1" title="healthy"><span class="size-2 rounded-full bg-success"></span>{$summary.health.healthy}</span>
|
||||
<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>
|
||||
{/if}
|
||||
{#if openSignals}
|
||||
<button type="button" onclick={() => navigate('signals')}>
|
||||
<Badge variant="secondary" class="cursor-pointer">{openSignals} signal{openSignals === 1 ? '' : 's'}</Badge>
|
||||
</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'}
|
||||
<Sessions />
|
||||
{:else if page === 'agent'}
|
||||
<Agent />
|
||||
{:else if page === 'knowledge'}
|
||||
<Knowledge />
|
||||
{:else if page === 'audit'}
|
||||
<Audit />
|
||||
{:else}
|
||||
<Chat />
|
||||
{/if}
|
||||
</main>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<style>
|
||||
.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>
|
||||
<Sheet.Root bind:open={drawerOpen}>
|
||||
<Sheet.Content side="right" class="w-[400px] p-0 sm:max-w-[400px]">
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>Nomos chat</Sheet.Title>
|
||||
<Sheet.Description>Persistent chat drawer</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
<div class="flex h-full flex-col">
|
||||
<Chat showRail={false} />
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
137
web/src/app.css
137
web/src/app.css
@@ -1,33 +1,122 @@
|
||||
@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 {
|
||||
--bg: #0d1117;
|
||||
--bg-surface: #161b22;
|
||||
--bg-deeper: #0a0e13;
|
||||
--bg-hover: #21262d;
|
||||
--bg-active: #292e36;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--accent-blue: #58a6ff;
|
||||
--accent-green: #3fb950;
|
||||
--accent-red: #f85149;
|
||||
--accent-orange: #d29922;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
/* legacy aliases still referenced by Chat/Sessions/App */
|
||||
--bg: var(--background);
|
||||
--bg-surface: var(--card);
|
||||
--bg-deeper: 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 {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--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 {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
/* Tailwind's preflight resets <button> to cursor: default; every button
|
||||
and native interactive element in this app is clickable, so restore
|
||||
the pointer cursor app-wide instead of annotating each one. */
|
||||
button:not(:disabled),
|
||||
[role='button']:not([aria-disabled='true']),
|
||||
a[href],
|
||||
summary,
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const BASE = '/agent'
|
||||
const API = '/api/v1'
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
@@ -90,3 +91,420 @@ export function streamChat(
|
||||
|
||||
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