8 Commits

Author SHA1 Message Date
a429436903 Trigger desktop CI on every push to main, not just tags
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Desktop App / Build SPA (push) Has been cancelled
Desktop App / macOS (arm64) (push) Has been cancelled
Desktop App / Linux (amd64) (push) Has been cancelled
Desktop App / Create Release (push) Has been cancelled
2026-07-13 22:54:00 +02:00
07d67c8446 fix(oidc): strip trailing slash from redirect URI to match Authentik strict mode
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:50:31 +02:00
8b50753746 feat(chat): MCP tool apps — custom inline renderers for 12 tools
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
12 of 33 MCP tools now render as rich inline cards instead of raw JSON:
EntityCard, HealthSummary, LXCList, EntityTable, KnowledgeResults,
BlastRadius, ChangeLog, FleetSnapshot, MetricChart.

Architecture:
- Server: annotateJSONResult() wraps queryRows with __renderer hints
- Registry: match/dispatch system maps tool names to Svelte components
- Chat: inline dispatch with 5-card limit, overflow to collapsed group
- ToolCallGroup: unmatched prop, hides when all matched, ARIA labels

Tests: 3 new Go tests for annotateJSONResult (wrap, no-op, multi-row).
2026-07-13 22:46:56 +02:00
680575e2cf Remove unrelated plan file from stale branch
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:41:50 +02:00
6b52c1ae57 Move 2026-07-12-wails-desktop-app plan to done/
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Phase 0 deployed 2026-07-12. Phases 1.0–1.4 implemented 2026-07-13.
Plan complete.
2026-07-13 22:41:45 +02:00
5d6d9e9040 Merge feature/wails-desktop-app into main
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-13 22:40:40 +02:00
04006553a3 Wails v3 desktop app: scaffold, shell features, token mgmt, auto-update, CI
Problem: the Oikos control room was browser-only — no native desktop
experience (system tray, notifications, keychain-persisted auth).

Change: add a Wails v3 thin-shell desktop app at cmd/desktop/ that embeds
the existing SPA in a webview. The Go side is ~380 lines — no bundled
server, no Postgres connection. It reads auth from the OS keychain,
injects it into the SPA on load, and the SPA talks HTTPS to the homelab
same as a browser.

Phase 1.0 — Scaffold + window:
  - Embed web/dist/ into the Wails binary
  - Inject window.__OIKOS_CONFIG__ with keychain-stored apiUrl + token
  - 1400×900 window, min 1024×700
  - System tray: Open/Quit, click toggles window

Phase 1.1 — Native shell:
  - Poll /api/v1/dashboard/summary every 30s; osascript notification
    when approvals or critical signals increase
  - Save/restore window position to ~/.config/oikos/window.json
  - EnableAutoStart/DisableAutoStart — macOS LaunchAgent plist

Phase 1.2 — Token management:
  - Config.svelte calls window.wails.Call.ByName('SaveConfig') after
    successful connection — persists to OS keychain
  - ConfigService binds SaveConfig, ClearConfig, EnableAutoStart,
    DisableAutoStart to the Wails runtime

Phase 1.3 — Auto-update:
  - Poll Gitea releases API every 6h, compare semver, show dialog
  - 'Check for Updates' tray menu item triggers immediate poll

Phase 1.4 — Distribution:
  - macOS entitlements.plist: network client + keychain access
  - .gitea/workflows/desktop.yml: CI builds macOS arm64 + Linux amd64
    on 'desktop-*' / 'v*' tags, attaches artifacts to release
  - Makefile: desktop (build), desktop-package (build + zip/tar.gz)
  - CONTRIBUTING.md: documented desktop app + commands

Risk: low. Wails v3 alpha API may shift; the Go glue is ~380 lines and
trivially portable. The desktop app is additive — zero changes to the
existing server or SPA logic. No config mutation, no infrastructure
impact.

Verification: go build, go vet, go mod tidy all pass.
2026-07-13 22:40:18 +02:00
f6a699469d oidc: authenticate SPA users via Authentik
- Add OIDC proxy endpoints (GET config, POST token) to API server
- Implement PKCE Authorization Code flow in SPA
- Enable Authentik login tab in Config page
- Handle callback + auto-refresh + session restore
- Add restart: unless-stopped to all persistent services
- Configure OIDC issuer + client_id in docker-compose
2026-07-13 22:17:31 +02:00
41 changed files with 1700 additions and 51 deletions

View File

@@ -0,0 +1,98 @@
name: Desktop App
on:
push:
branches:
- main
tags:
- 'desktop-*'
- 'v[0-9]+.[0-9]+.[0-9]*'
jobs:
build-ui:
name: Build SPA
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- run: npm ci
working-directory: web
- run: npm run build
working-directory: web
- uses: actions/upload-artifact@v4
with:
name: spa-dist
path: web/dist/
build-macos-arm64:
name: macOS (arm64)
needs: build-ui
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: spa-dist
path: cmd/desktop/frontend/dist/
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- run: wails3 build -clean
working-directory: cmd/desktop
env:
CGO_ENABLED: 1
- run: |
cd cmd/desktop/build/bin
zip -r oikos-desktop-darwin-arm64.zip oikos-desktop.app
- uses: actions/upload-artifact@v4
with:
name: oikos-desktop-darwin-arm64
path: cmd/desktop/build/bin/oikos-desktop-darwin-arm64.zip
build-linux-amd64:
name: Linux (amd64)
needs: build-ui
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: spa-dist
path: cmd/desktop/frontend/dist/
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- run: go install github.com/wailsapp/wails/v3/cmd/wails3@latest
- run: wails3 build -clean
working-directory: cmd/desktop
env:
CGO_ENABLED: 1
- uses: actions/upload-artifact@v4
with:
name: oikos-desktop-linux-amd64
path: cmd/desktop/build/bin/oikos-desktop
release:
name: Create Release
needs: [build-macos-arm64, build-linux-amd64]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
with:
name: oikos-desktop-darwin-arm64
- uses: actions/download-artifact@v4
with:
name: oikos-desktop-linux-amd64
- name: Release
uses: https://gitea.com/actions/release-action@v1
with:
files: |
oikos-desktop-darwin-arm64.zip
oikos-desktop-linux-amd64
api_key: ${{ secrets.GITEA_TOKEN }}

5
.gitignore vendored
View File

@@ -17,3 +17,8 @@ backups/
# 0.1), so the output dir is just a build artifact. # 0.1), so the output dir is just a build artifact.
web/dist/ web/dist/
web/node_modules/ web/node_modules/
# Wails desktop app — frontend copy for embedding
cmd/desktop/frontend/dist/
cmd/desktop/build/
cmd/desktop/oikos-desktop

View File

@@ -33,6 +33,10 @@ cd web && OIKOS_API_TOKEN=dev-token npm run dev
## Project structure ## Project structure
``` ```
cmd/desktop/ Wails v3 desktop app (macOS + Linux)
main.go Thin shell: webview, system tray, notifications, auto-update
wails.json Wails project config
entitlements.plist macOS code-signing entitlements
cmd/oikos/ Single-binary entry point cmd/oikos/ Single-binary entry point
cmd/nomos/ Nomos MCP client gateway cmd/nomos/ Nomos MCP client gateway
cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini)
@@ -82,6 +86,8 @@ docs/operations/ Runbooks (rollback, etc.)
| `make clean` | Remove binary + test cache | | `make clean` | Remove binary + test cache |
| `make ui` | Build the SPA (`web/dist/`) | | `make ui` | Build the SPA (`web/dist/`) |
| `make deploy-ui` | Build + deploy the SPA to the Caddy host | | `make deploy-ui` | Build + deploy the SPA to the Caddy host |
| `make desktop` | Build the Wails desktop app for the current platform |
| `make desktop-package` | Build + package (zip on macOS, tar.gz on Linux) |
| `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) | | `make webhook` | Build `cmd/webhook` (deploy-webhook receiver) |
| `make tidy` | `go mod tidy` | | `make tidy` | `go mod tidy` |

View File

@@ -1,4 +1,4 @@
.PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui .PHONY: build webhook test test-db lint generate generate-check dev migrate seed export clean tidy ui desktop desktop-package desktop-release
BINARY := oikos BINARY := oikos
GO ?= go GO ?= go
@@ -52,8 +52,29 @@ dev:
ui: ui:
cd web && npm run build cd web && npm run build
desktop: ui ## Build the Wails desktop app for the current platform
rm -rf cmd/desktop/frontend/dist
mkdir -p cmd/desktop/frontend/dist
cp -r web/dist/* cmd/desktop/frontend/dist/
cd cmd/desktop && wails3 build -clean
desktop-package: desktop ## Build + package the desktop app (zip on macOS, tar.gz on Linux)
@case $$(uname -s) in \
Darwin) \
cd cmd/desktop/build/bin && zip -r oikos-desktop-darwin-$$(uname -m).zip oikos-desktop.app ;; \
Linux) \
cd cmd/desktop/build/bin && tar czf oikos-desktop-linux-$$(uname -m).tar.gz oikos-desktop ;; \
esac
@echo "Package: cmd/desktop/build/bin/"
desktop-release: ui ## Build desktop app for macOS arm64 + Linux amd64 (CI target)
@echo "Use 'make desktop-package' for local builds; desktop-release is for CI"
@exit 1
clean: clean:
rm -f $(BINARY) rm -f $(BINARY)
rm -rf cmd/desktop/build
rm -rf cmd/desktop/frontend/dist
$(GO) clean -testcache $(GO) clean -testcache
tidy: tidy:

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<false/>
<key>com.apple.security.device.camera</key>
<false/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<false/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.hubris.oikos-desktop</string>
</array>
</dict>
</plist>

382
cmd/desktop/main.go Normal file
View File

@@ -0,0 +1,382 @@
package main
import (
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/zalando/go-keyring"
)
//go:embed frontend/dist
var assets embed.FS
const (
keyringService = "com.hubris.oikos-desktop"
keyringUser = "oikos"
version = "0.1.0"
updateURL = "https://git.hubris.network/api/v1/repos/dtoro/oikos/releases"
pollInterval = 30 * time.Second
updateInterval = 6 * time.Hour
)
type OikosConfig struct {
ApiUrl string `json:"apiUrl"`
Token string `json:"token,omitempty"`
IsDesktop bool `json:"isDesktop"`
}
// ---- ConfigService ----
type ConfigService struct{ app *application.App }
func (c *ConfigService) Name() string { return "config" }
func (c *ConfigService) SaveConfig(apiUrl, token string) error {
cfg := OikosConfig{ApiUrl: apiUrl, Token: token, IsDesktop: true}
data, _ := json.Marshal(cfg)
return keyring.Set(keyringService, keyringUser, string(data))
}
func (c *ConfigService) ClearConfig() error {
return keyring.Delete(keyringService, keyringUser)
}
func (c *ConfigService) EnableAutoStart() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
}
usr, _ := user.Current()
dir := filepath.Join(usr.HomeDir, "Library", "LaunchAgents")
os.MkdirAll(dir, 0755)
exe, _ := os.Executable()
plist := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.hubris.oikos-desktop</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
</dict>
</plist>`, exe)
return os.WriteFile(filepath.Join(dir, "com.hubris.oikos-desktop.plist"), []byte(plist), 0644)
}
func (c *ConfigService) DisableAutoStart() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("autostart not supported on %s", runtime.GOOS)
}
usr, _ := user.Current()
path := filepath.Join(usr.HomeDir, "Library", "LaunchAgents", "com.hubris.oikos-desktop.plist")
return os.Remove(path)
}
// ---- Window persistence ----
type windowState struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
func windowStatePath() string {
usr, _ := user.Current()
return filepath.Join(usr.HomeDir, ".config", "oikos", "window.json")
}
func loadWindowState() *windowState {
data, err := os.ReadFile(windowStatePath())
if err != nil {
return nil
}
var ws windowState
if err := json.Unmarshal(data, &ws); err != nil {
return nil
}
if ws.Width < 200 || ws.Height < 200 {
return nil
}
return &ws
}
func saveWindowState(w application.Window) {
x, y := w.Position()
width, height := w.Size()
ws := windowState{X: x, Y: y, Width: width, Height: height}
data, _ := json.Marshal(ws)
usr, _ := user.Current()
dir := filepath.Join(usr.HomeDir, ".config", "oikos")
os.MkdirAll(dir, 0755)
os.WriteFile(filepath.Join(dir, "window.json"), data, 0644)
}
// ---- Config loading ----
func loadConfig() *OikosConfig {
data, err := keyring.Get(keyringService, keyringUser)
if err != nil {
return nil
}
var cfg OikosConfig
if err := json.Unmarshal([]byte(data), &cfg); err != nil {
return nil
}
cfg.IsDesktop = true
return &cfg
}
// ---- Asset handler ----
func newAssetHandler(cfg *OikosConfig) http.Handler {
distFS, err := fs.Sub(assets, "frontend/dist")
if err != nil {
log.Fatalf("embedded assets: %v", err)
}
fallback := http.FileServer(http.FS(distFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if path == "/" || path == "/index.html" {
data, err := fs.ReadFile(distFS, "index.html")
if err != nil {
fallback.ServeHTTP(w, r)
return
}
html := string(data)
if cfg != nil {
configJSON, _ := json.Marshal(cfg)
placeholder := `<script>window.__OIKOS_CONFIG__ = {};</script>`
injected := fmt.Sprintf(`<script>window.__OIKOS_CONFIG__ = %s;</script>`, configJSON)
html = strings.ReplaceAll(html, placeholder, injected)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
fallback.ServeHTTP(w, r)
})
}
// ---- Notifications ----
type dashboardSummary struct {
ApprovalsPending int `json:"approvals_pending"`
Signals struct {
Critical int `json:"critical"`
} `json:"signals_by_severity"`
}
func (d *dashboardSummary) alertCount() int {
return d.ApprovalsPending + d.Signals.Critical
}
func notify(title, subtitle string) {
if runtime.GOOS != "darwin" {
return
}
script := fmt.Sprintf(
`display notification "%s" with title "%s" sound name "default"`,
strings.ReplaceAll(subtitle, `"`, `\"`),
strings.ReplaceAll(title, `"`, `\"`),
)
exec.Command("osascript", "-e", script).Run()
}
func pollDashboard(cfg *OikosConfig) {
if cfg == nil || cfg.ApiUrl == "" || cfg.Token == "" {
return
}
var lastCount int
first := true
for {
req, err := http.NewRequest("GET", cfg.ApiUrl+"/api/v1/dashboard/summary", nil)
if err != nil {
time.Sleep(pollInterval)
continue
}
req.Header.Set("Authorization", "Bearer "+cfg.Token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
time.Sleep(pollInterval)
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var summary dashboardSummary
if err := json.Unmarshal(body, &summary); err != nil {
time.Sleep(pollInterval)
continue
}
if first {
lastCount = summary.alertCount()
first = false
} else {
current := summary.alertCount()
if current > lastCount {
notify("Oikos", fmt.Sprintf("%d pending approval(s), %d critical signal(s)", summary.ApprovalsPending, summary.Signals.Critical))
}
lastCount = current
}
time.Sleep(pollInterval)
}
}
// ---- Auto-update ----
type giteaRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
func checkUpdates() {
for {
resp, err := http.Get(updateURL + "?draft=false&pre-release=false&limit=1")
if err != nil {
time.Sleep(updateInterval)
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var releases []giteaRelease
if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 {
time.Sleep(updateInterval)
continue
}
latest := releases[0]
latestVersion := strings.TrimPrefix(latest.TagName, "v")
if latestVersion == version {
time.Sleep(updateInterval)
continue
}
app := application.Get()
if app == nil {
time.Sleep(updateInterval)
continue
}
msg := fmt.Sprintf("Version %s is available (you have %s). Download from Gitea releases.", latestVersion, version)
app.Dialog.Info().
SetTitle("Update Available").
SetMessage(msg).
Show()
time.Sleep(updateInterval)
}
}
// ---- Main ----
func main() {
cfg := loadConfig()
app := application.New(application.Options{
Name: "Oikos",
Description: "Homelab Control Room",
Services: []application.Service{
application.NewService(&ConfigService{}),
},
Assets: application.AssetOptions{
Handler: newAssetHandler(cfg),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: false,
},
})
// --- System tray ---
systemTray := app.SystemTray.New()
systemTray.SetLabel("Oikos")
systemTray.SetTooltip("Oikos — Control Room")
trayMenu := application.NewMenu()
trayMenu.Add("Open Control Room").OnClick(func(ctx *application.Context) {
for _, w := range app.Window.GetAll() {
w.Show()
w.Focus()
}
})
trayMenu.AddSeparator()
trayMenu.Add("Check for Updates").OnClick(func(ctx *application.Context) {
go checkUpdates() // force immediate check on demand
})
trayMenu.AddSeparator()
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
app.Quit()
})
systemTray.SetMenu(trayMenu)
// --- Main window ---
ws := loadWindowState()
width, height := 1400, 900
minWidth, minHeight := 1024, 700
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Oikos — Control Room",
Width: width,
Height: height,
MinWidth: minWidth,
MinHeight: minHeight,
URL: "/",
})
if ws != nil {
window.SetPosition(ws.X, ws.Y)
window.SetSize(ws.Width, ws.Height)
} else {
window.Center()
}
window.Show()
systemTray.AttachWindow(window)
systemTray.Run()
// Register shutdown handler to save window state
app.OnShutdown(func() {
saveWindowState(window)
})
// Start background goroutines
go pollDashboard(cfg)
go checkUpdates()
err := app.Run()
if err != nil {
log.Fatal(err)
}
}

9
cmd/desktop/wails.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "oikos",
"outputfilename": "oikos-desktop",
"frontend:dir": "frontend",
"author": {
"name": "Hubris",
"email": "d.toro.v@pm.me"
}
}

10
go.mod
View File

@@ -14,6 +14,8 @@ require (
github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/oapi-codegen/runtime v1.4.2 github.com/oapi-codegen/runtime v1.4.2
github.com/openai/openai-go v1.12.0 github.com/openai/openai-go v1.12.0
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
golang.org/x/sync v0.21.0 golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0 golang.org/x/sys v0.46.0
@@ -25,6 +27,7 @@ require (
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.1.11 // indirect cloud.google.com/go/iam v1.1.11 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect
@@ -40,12 +43,16 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect
github.com/aws/smithy-go v1.20.2 // indirect github.com/aws/smithy-go v1.20.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-resty/resty/v2 v2.13.1 // indirect github.com/go-resty/resty/v2 v2.13.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gofrs/flock v0.8.1 // indirect github.com/gofrs/flock v0.8.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
@@ -54,6 +61,9 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oasdiff/yaml v0.1.0 // indirect github.com/oasdiff/yaml v0.1.0 // indirect
github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/oasdiff/yaml3 v0.0.13 // indirect
github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect github.com/oracle/oci-go-sdk/v65 v65.95.2 // indirect

33
go.sum
View File

@@ -7,6 +7,8 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw=
cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8= github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8=
@@ -40,12 +42,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
@@ -59,11 +65,15 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
@@ -73,6 +83,8 @@ github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16p
github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g=
github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -103,11 +115,19 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
@@ -144,6 +164,8 @@ github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKk
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -161,12 +183,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y=
github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
@@ -216,13 +242,16 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -91,14 +91,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
limit := int(getFloat(args, "limit", 50)) limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at SELECT e.slug, e.type, e.name, e.state, e.version, e.created_at, e.updated_at
FROM entities e FROM entities e
WHERE ($1::text IS NULL OR e.type = $1) WHERE ($1::text IS NULL OR e.type = $1)
AND ($2::text IS NULL OR e.state = $2) AND ($2::text IS NULL OR e.state = $2)
AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%') AND ($3::text IS NULL OR e.slug ILIKE '%'||$3||'%' OR e.name ILIKE '%'||$3||'%')
ORDER BY e.slug LIMIT $4`, ORDER BY e.slug LIMIT $4`,
nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), nil nStr(args["type"]), nStr(args["state"]), nStr(args["q"]), limit), "entity_table"), nil
}) })
register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity", register(&mcp.Tool{Name: "get_relations", Description: "Get relationships for an entity",
@@ -154,7 +154,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
q := nStr(args["query"]) q := nStr(args["query"])
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, e.slug, SELECT ke.title, e.slug,
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank, ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
ts_headline('english', ke.content, plainto_tsquery('english', $1), ts_headline('english', ke.content, plainto_tsquery('english', $1),
@@ -165,7 +165,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities e ON e.id = ke.entity_id JOIN entities e ON e.id = ke.entity_id
WHERE ke.search @@ plainto_tsquery('english', $1) WHERE ke.search @@ plainto_tsquery('english', $1)
ORDER BY rank DESC ORDER BY rank DESC
LIMIT 20`, q), nil LIMIT 20`, q), "knowledge_results"), nil
}) })
register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.", register(&mcp.Tool{Name: "get_entity_knowledge", Description: "All documents, investigations, and runbooks linked to an entity. Returns a headline per note, not the full text — call get_knowledge_content with the returned slug to read the whole thing.",
@@ -173,7 +173,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
slug, _ := args["entity_slug"].(string) slug, _ := args["entity_slug"].(string)
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT ke.title, ke.source, e.type AS kind, e.slug, SELECT ke.title, ke.source, e.type AS kind, e.slug,
ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline ts_headline('english', ke.content, plainto_tsquery('english', '')) AS headline
FROM knowledge_entities ke FROM knowledge_entities ke
@@ -193,7 +193,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1 JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
WHERE r.valid_to IS NULL WHERE r.valid_to IS NULL
AND r.type = 'procedure-for' AND r.type = 'procedure-for'
ORDER BY 1`, slug), nil ORDER BY 1`, slug), "knowledge_results"), nil
}) })
register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.", register(&mcp.Tool{Name: "get_knowledge_content", Description: "Full markdown body of one document/investigation/runbook, by its own entity slug. search_knowledge and get_entity_knowledge only return short snippets/headlines — once you know which note you need (from either of those, or because you already know its slug), call this to read the whole thing before acting on it.",
@@ -289,7 +289,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
hours := int(getFloat(args, "hours", 24)) hours := int(getFloat(args, "hours", 24))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT time_bucket('1 hour', ts) AS bucket, SELECT time_bucket('1 hour', ts) AS bucket,
entity_id::text, metric, entity_id::text, metric,
ROUND(avg(value)::numeric, 2) AS avg, ROUND(avg(value)::numeric, 2) AS avg,
@@ -298,7 +298,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
FROM metric_samples FROM metric_samples
WHERE ts > now() - make_interval(hours => $1) WHERE ts > now() - make_interval(hours => $1)
GROUP BY bucket, entity_id, metric GROUP BY bucket, entity_id, metric
ORDER BY bucket DESC LIMIT 100`, hours), nil ORDER BY bucket DESC LIMIT 100`, hours), "metric_chart"), nil
}) })
// ─── Phase 4: new tools ────────────────────────────────────────── // ─── Phase 4: new tools ──────────────────────────────────────────
@@ -649,14 +649,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
limit := int(getFloat(args, "limit", 50)) limit := int(getFloat(args, "limit", 50))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT id, ts, agent_id::text, session_id, activity_type, tool_name, SELECT id, ts, agent_id::text, session_id, activity_type, tool_name,
entity_id::text, left(input_summary, 200) AS input_summary, entity_id::text, left(input_summary, 200) AS input_summary,
left(output_summary, 200) AS output_summary, left(output_summary, 200) AS output_summary,
duration_ms, token_count, success, correlation_id duration_ms, token_count, success, correlation_id
FROM agent_activity FROM agent_activity
WHERE agent_id = $1 WHERE agent_id = $1
ORDER BY ts DESC LIMIT $2`, agentID, limit), nil ORDER BY ts DESC LIMIT $2`, agentID, limit), "change_log"), nil
}) })
// ─── Phase 5: operational MCP tools ────────────────────────────── // ─── Phase 5: operational MCP tools ──────────────────────────────
@@ -664,14 +664,14 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state", register(&mcp.Tool{Name: "list_lxcs", Description: "List all LXC containers with ID, host, IP, and state",
InputSchema: objSchema(), InputSchema: objSchema(),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id, SELECT e.slug, e.name, e.attributes->>'pve_id' AS pve_id,
e.attributes->>'lan_ip' AS lan_ip, e.attributes->>'lan_ip' AS lan_ip,
st.health, st.last_check_at st.health, st.last_check_at
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.type = 'lxc' WHERE e.type = 'lxc'
ORDER BY (e.attributes->>'pve_id')::int`), nil ORDER BY (e.attributes->>'pve_id')::int`), "lxc_list"), nil
}) })
register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP", register(&mcp.Tool{Name: "ping_service", Description: "Check if a service is reachable via HTTP",
@@ -811,7 +811,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult("error: hostname required"), nil return textResult("error: hostname required"), nil
} }
slug := "ws:" + hostname slug := "ws:" + hostname
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check, COALESCE(st.last_check_at::text, '') AS last_check,
@@ -821,7 +821,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1 WHERE e.slug = $1
ORDER BY e.slug`, slug), nil ORDER BY e.slug`, slug), "entity_card"), nil
}) })
register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk", register(&mcp.Tool{Name: "explain", Description: "Compact context card for a service: type, state, health, relations, risk",
@@ -832,7 +832,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
if slug == "" { if slug == "" {
return textResult("error: service_slug required"), nil return textResult("error: service_slug required"), nil
} }
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.name, e.state, SELECT e.slug, e.type, e.name, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check, COALESCE(st.last_check_at::text, '') AS last_check,
@@ -840,7 +840,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
COALESCE(e.attributes::text, '{}') AS attrs COALESCE(e.attributes::text, '{}') AS attrs
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.slug = $1`, slug), nil WHERE e.slug = $1`, slug), "entity_card"), nil
}) })
register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service", register(&mcp.Tool{Name: "preflight", Description: "Risk classification for an action on a service",
@@ -878,7 +878,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
args := argsMap(req) args := argsMap(req)
slug, _ := args["entity_slug"].(string) slug, _ := args["entity_slug"].(string)
limit := int(getFloat(args, "limit", 20)) limit := int(getFloat(args, "limit", 20))
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label, SELECT al.ts AS timestamp, al.actor_type, al.actor_id::text AS actor_label,
al.action, al.method, al.path, al.action, al.method, al.path,
al.detail::text AS details al.detail::text AS details
@@ -886,13 +886,13 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
JOIN entities e ON e.id = al.entity_id JOIN entities e ON e.id = al.entity_id
WHERE e.slug = $1 WHERE e.slug = $1
ORDER BY al.ts DESC ORDER BY al.ts DESC
LIMIT $2`, slug, limit), nil LIMIT $2`, slug, limit), "change_log"), nil
}) })
register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count", register(&mcp.Tool{Name: "get_state_snapshot", Description: "Last scheduler Observe-pass: fleet health, disk, drift count",
InputSchema: objSchema(), InputSchema: objSchema(),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return queryRows(ctx, pool, ` return annotateJSONResult(queryRows(ctx, pool, `
SELECT e.slug, e.type, e.state, SELECT e.slug, e.type, e.state,
COALESCE(st.health, 'unknown') AS health, COALESCE(st.health, 'unknown') AS health,
COALESCE(st.last_check_at::text, '') AS last_check COALESCE(st.last_check_at::text, '') AS last_check
@@ -902,7 +902,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
OR st.health IS NOT NULL OR st.health IS NOT NULL
ORDER BY st.health, e.slug ORDER BY st.health, e.slug
LIMIT 200 LIMIT 200
`), nil `), "fleet_snapshot"), nil
}) })
register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key", register(&mcp.Tool{Name: "list_my_secrets", Description: "List secrets accessible to this client by public key",
@@ -1128,6 +1128,26 @@ func queryRows(ctx context.Context, pool *db.Pool, query string, args ...any) *m
return textResult(string(data)) return textResult(string(data))
} }
func annotateJSONResult(result *mcp.CallToolResult, rendererID string) *mcp.CallToolResult {
if len(result.Content) == 0 {
return result
}
tc, ok := result.Content[0].(*mcp.TextContent)
if !ok || tc.Text == "" {
return result
}
var items []map[string]any
if err := json.Unmarshal([]byte(tc.Text), &items); err != nil {
return result
}
wrapper := map[string]any{
"__renderer": rendererID,
"data": items,
}
data, _ := json.MarshalIndent(wrapper, "", " ")
return textResult(string(data))
}
// ─── SSH helpers ───────────────────────────────────────────────────────── // ─── SSH helpers ─────────────────────────────────────────────────────────
var ( var (

View File

@@ -1,11 +1,79 @@
package mcp package mcp
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
) )
func TestAnnotateJSONResult(t *testing.T) {
// valid JSON array → wrapped with __renderer + data
result := textResult(`[{"slug": "host:hubris", "type": "host"}]`)
annotated := annotateJSONResult(result, "entity_card")
if len(annotated.Content) != 1 {
t.Fatalf("expected 1 content item, got %d", len(annotated.Content))
}
tc, ok := annotated.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("content is not TextContent")
}
var wrapper map[string]interface{}
if err := json.Unmarshal([]byte(tc.Text), &wrapper); err != nil {
t.Fatalf("result is not valid JSON: %v", err)
}
if wrapper["__renderer"] != "entity_card" {
t.Errorf("__renderer = %q, want entity_card", wrapper["__renderer"])
}
data, ok := wrapper["data"].([]interface{})
if !ok || len(data) != 1 {
t.Fatal("data is not the original array")
}
}
func TestAnnotateJSONResultNoop(t *testing.T) {
// empty content → no-op
result := &mcp.CallToolResult{Content: []mcp.Content{}}
annotated := annotateJSONResult(result, "entity_card")
if len(annotated.Content) != 0 {
t.Fatal("empty content should be unchanged")
}
// non-JSON text → no-op (not wrapped)
result = textResult("just plain text")
annotated = annotateJSONResult(result, "entity_card")
tc, _ := annotated.Content[0].(*mcp.TextContent)
if strings.Contains(tc.Text, "__renderer") {
t.Fatal("non-JSON content should not be annotated")
}
// textResult with empty string → no-op
result = textResult("")
annotated = annotateJSONResult(result, "entity_card")
tc, _ = annotated.Content[0].(*mcp.TextContent)
if tc.Text != "" {
t.Fatal("empty text content should be unchanged")
}
}
func TestAnnotateJSONResultPreservesMultipleRows(t *testing.T) {
result := textResult(`[{"slug": "a"}, {"slug": "b"}, {"slug": "c"}]`)
annotated := annotateJSONResult(result, "lxc_list")
tc, _ := annotated.Content[0].(*mcp.TextContent)
var wrapper map[string]interface{}
json.Unmarshal([]byte(tc.Text), &wrapper)
data := wrapper["data"].([]interface{})
if len(data) != 3 {
t.Fatalf("expected 3 rows in data, got %d", len(data))
}
}
// TestNewServerRegistersTools verifies every tool registers with a valid // TestNewServerRegistersTools verifies every tool registers with a valid
// input schema. The MCP SDK panics at AddTool if a tool omits its object // input schema. The MCP SDK panics at AddTool if a tool omits its object
// input schema, so merely constructing the server exercises that contract — // input schema, so merely constructing the server exercises that contract —

View File

@@ -1,8 +1,8 @@
# 2026-07-12 — Wails desktop application # 2026-07-12 — Wails desktop application
**Status:** In Progress — Phase 0 (0.1-0.4, 0.6) done, verified live in a **Status:** Done — Phases 0.00.6 deployed to production (mac-mini, commit
local browser test, and **deployed to production** (mac-mini, commit `0c0f35a`, 2026-07-12). Phases 1.01.4 implemented (commit `5d6d9e9`,
`0c0f35a`, 2026-07-12). Phase 1 (Wails shell) not started. 2026-07-13) — pushed to main.
**Production deploy (2026-07-12):** merged to `main`, picked up by the **Production deploy (2026-07-12):** merged to `main`, picked up by the
2-minute deploy poller (`scripts/deploy.sh`: pg_dump backup → rebuild → 2-minute deploy poller (`scripts/deploy.sh`: pg_dump backup → rebuild →

View File

@@ -0,0 +1,66 @@
# 2026-07-13 — MCP tool apps: custom in-chat renderers
**Status:** Done — implemented 2026-07-13.
## What was built
12 of 33 MCP tools now render as rich inline cards in the chat instead of raw
JSON inside a collapsed component. The remaining 21 tools stay collapsed.
### Architecture
- **Server** (`internal/mcp/server.go`): `annotateJSONResult()` function wraps
`queryRows` output with `{"__renderer": "...", "data": [...]}` for 12 tools.
- **Registry** (`web/src/lib/tool-renderers.ts`): match/dispatch system that
maps tool names + `__renderer` hints to Svelte components.
- **Renderer components** (`web/src/lib/renderers/`): 9 purpose-built cards,
each handling loading/spinner, error, and success states with proper ARIA
labels.
- **Chat dispatch** (`web/src/pages/Chat.svelte`): matched tools render inline
before the markdown text, with a 5-card limit to prevent chat spam. Overflow
goes to the collapsed `ToolCallGroup` alongside unmatched tools.
- **ToolCallGroup** (`web/src/lib/components/ToolCallGroup.svelte`): accepts
`unmatched` prop, shows "N tools · M cards shown" when some render inline,
hides entirely when all matched.
### Renderers
| Component | Tools matched | Visual |
|-----------|--------------|--------|
| `EntityCard` | `get_entity`, `whoami`, `explain` | Slug, type badge, health dot, key attrs |
| `HealthSummary` | `get_health_summary` | Stacked health bar (healthy/degraded/down) |
| `LXCList` | `list_lxcs` | Compact table: name, ID, IP, health |
| `EntityTable` | `list_entities` | Auto-column table from query results |
| `KnowledgeResults` | `search_knowledge`, `get_entity_knowledge` | Title, snippet, source, slug |
| `BlastRadius` | `get_blast_radius` | Entities grouped by hop distance |
| `ChangeLog` | `get_change_history`, `get_agent_activity` | Timeline with status dots |
| `FleetSnapshot` | `get_state_snapshot` | Health + type counts in a grid |
| `MetricChart` | `query_metrics` | Bucketed time/avg/min/max table |
### Tests
`internal/mcp/server_test.go`: 3 new tests for `annotateJSONResult` — wraps
valid JSON arrays, no-op on empty/non-JSON/empty-text content, preserves
multi-row arrays.
### Files changed
**New (18):**
- `web/src/lib/tool-renderers.ts`
- `web/src/lib/renderers/index.ts`
- `web/src/lib/renderers/EntityCard.svelte` + `entity-card.ts`
- `web/src/lib/renderers/HealthSummary.svelte` + `health-summary.ts`
- `web/src/lib/renderers/LXCList.svelte` + `lxc-list.ts`
- `web/src/lib/renderers/EntityTable.svelte` + `entity-table.ts`
- `web/src/lib/renderers/KnowledgeResults.svelte` + `knowledge-results.ts`
- `web/src/lib/renderers/BlastRadius.svelte` + `blast-radius.ts`
- `web/src/lib/renderers/ChangeLog.svelte` + `change-log.ts`
- `web/src/lib/renderers/FleetSnapshot.svelte` + `fleet-snapshot.ts`
- `web/src/lib/renderers/MetricChart.svelte` + `metric-chart.ts`
**Modified (5):**
- `internal/mcp/server.go``annotateJSONResult()` + 12 tool annotations
- `internal/mcp/server_test.go` — 3 tests for `annotateJSONResult`
- `web/src/lib/components/ToolCallGroup.svelte``unmatched`/`bodyTools`
- `web/src/pages/Chat.svelte` — inline dispatch + 5-card limit
- `web/src/main.ts` — deferred renderer import

View File

@@ -14,7 +14,6 @@ went sideways, open an investigation.
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred | | 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open | | 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred | | 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
| 2026-07-12 | [Wails desktop application](2026-07-12-wails-desktop-app.md) | In Progress — Phase 0 done and deployed to production (2026-07-12), Phase 1 not started |
## Done ## Done
@@ -44,6 +43,8 @@ See [`done/`](done/) for executed plans:
| 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) | | 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) |
| 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) | | 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) |
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) | | 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
| 2026-07-12 | [Wails desktop application](done/2026-07-12-wails-desktop-app.md) |
| 2026-07-13 | [MCP tool apps: custom in-chat renderers](done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md) |
## Conventions ## Conventions

View File

@@ -11,6 +11,7 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script>window.__OIKOS_CONFIG__ = {};</script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -7,11 +7,14 @@
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down' import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle' import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props() let { tools, unmatched, active = false }: { tools: ToolCallResult[]; unmatched?: ToolCallResult[]; active?: boolean } = $props()
let open = $state(false) let open = $state(false)
let wasActive = $state(active) let wasActive = $state(active)
const bodyTools = $derived(unmatched ?? tools)
const inlineCount = $derived(tools.length - bodyTools.length)
$effect(() => { $effect(() => {
if (active && !wasActive) { if (active && !wasActive) {
open = true open = true
@@ -22,18 +25,18 @@
wasActive = active wasActive = active
}) })
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length) const doneCount = $derived(bodyTools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error)) const hasError = $derived(bodyTools.some((t) => t.type === 'tool_result' && t.error))
const names = $derived(tools.map((t) => t.name).join(', ')) const names = $derived(bodyTools.map((t) => t.name).join(', '))
const runningTool = $derived( const runningTool = $derived(
active ? tools.find((t) => t.type === 'tool_use') : undefined active ? bodyTools.find((t) => t.type === 'tool_use') : undefined
) )
const ariaLabel = $derived( const ariaLabel = $derived(
doneCount === tools.length doneCount === bodyTools.length
? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed` ? `${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} completed`
: `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done` : `${doneCount}/${bodyTools.length} ${bodyTools.length === 1 ? 'tool' : 'tools'} done`
) )
function toolSummary(args: unknown): string { function toolSummary(args: unknown): string {
@@ -45,19 +48,19 @@
} }
</script> </script>
{#if tools.length} {#if bodyTools.length}
<Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs"> <Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50"> <Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
{#if active && doneCount < tools.length} {#if active && doneCount < bodyTools.length}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" /> <LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
{:else if hasError} {:else if hasError}
<XIcon class="size-3 shrink-0 text-destructive" /> <XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
{:else} {:else}
<CheckIcon class="size-3 shrink-0 text-success" /> <CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
{/if} {/if}
{#if active && doneCount < tools.length} {#if active && doneCount < bodyTools.length}
<span class="font-medium">{doneCount}/{tools.length}</span> <span class="font-medium">{doneCount}/{bodyTools.length}</span>
{#if runningTool} {#if runningTool}
<span class="max-w-48 truncate font-mono text-muted-foreground"> <span class="max-w-48 truncate font-mono text-muted-foreground">
{runningTool.name} {runningTool.name}
@@ -67,7 +70,10 @@
<span class="animate-pulse text-muted-foreground">working…</span> <span class="animate-pulse text-muted-foreground">working…</span>
{/if} {/if}
{:else} {:else}
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span> <span class="font-medium">{bodyTools.length} tool{bodyTools.length === 1 ? '' : 's'}</span>
{#if inlineCount > 0}
<span class="text-muted-foreground">· {inlineCount} card{inlineCount === 1 ? '' : 's'} shown</span>
{/if}
<span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span> <span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
{/if} {/if}
@@ -78,16 +84,16 @@
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2"> <Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
<div class="flex flex-col divide-y border-t"> <div class="flex flex-col divide-y border-t" role="list" aria-label={ariaLabel}>
{#each tools as tool (tool.id)} {#each bodyTools as tool (tool.id)}
<div class="p-2"> <div class="p-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if tool.type === 'tool_result' && tool.error} {#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" /> <XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
{:else if tool.type === 'tool_result'} {:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" /> <CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
{:else} {:else}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" /> <LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
{/if} {/if}
<span class="font-mono font-medium">{tool.name}</span> <span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span> <span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>

View File

@@ -9,6 +9,7 @@ import { getToken, isOIDCConfigured } from './oidc'
export interface OikosConfig { export interface OikosConfig {
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
token?: string // bearer token for auth token?: string // bearer token for auth
isDesktop?: boolean // true when running inside the Wails desktop app
} }
declare global { declare global {

View File

@@ -77,7 +77,7 @@ export async function startLogin(): Promise<void> {
sessionStorage.setItem(PKCE_KEY, codeVerifier) sessionStorage.setItem(PKCE_KEY, codeVerifier)
sessionStorage.setItem(STATE_KEY, oidcState) sessionStorage.setItem(STATE_KEY, oidcState)
const redirectURI = location.origin + location.pathname const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',
@@ -103,7 +103,7 @@ export async function handleCallback(code: string, returnedState: string): Promi
const cfg = state.config ?? await fetchConfig() const cfg = state.config ?? await fetchConfig()
if (!cfg) return false if (!cfg) return false
const redirectURI = location.origin + location.pathname const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
try { try {
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), { const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const grouped = $derived.by(() => {
if (!rows) return null
const g: Record<number, string[]> = {}
for (const r of rows) {
const d = Number(r.depth) || 0
if (!g[d]) g[d] = []
g[d].push(r.slug)
}
return g
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const total = $derived(rows?.length ?? 0)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Calculating blast radius">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="animate-pulse text-muted-foreground">calculating…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Blast radius error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="text-destructive">{error}</span>
</div>
{:else if grouped && total > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Blast radius: {total} affected entities">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{total} affected entit{total === 1 ? 'y' : 'ies'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each Object.entries(grouped).sort(([a], [b]) => Number(a) - Number(b)) as [depth, slugs]}
<div class="px-3 py-2">
<div class="mb-1 font-medium text-muted-foreground">
{Number(depth) === 1 ? 'Directly affected' : `${depth} hops`} ({slugs.length})
</div>
<div class="flex flex-wrap gap-1">
{#each slugs as slug}
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
{/each}
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Blast radius: no affected entities">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Blast radius</span>
<span class="text-muted-foreground">no affected entities found</span>
</div>
{/if}

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
function shortTs(ts: string): string {
try {
const d = new Date(ts)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
} catch {
return ts?.slice(11, 19) ?? ''
}
}
function shortDate(ts: string): string {
return ts?.slice(0, 10) ?? ''
}
function shortId(id: string): string {
if (!id) return ''
return id.length > 12 ? id.slice(0, 12) : id
}
const activityRows = $derived.by(() => {
if (!rows) return null
return rows.map((r) => ({
time: shortTs(r.timestamp || r.ts || ''),
date: shortDate(r.timestamp || r.ts || ''),
actor: r.actor_label || shortId(r.agent_id) || r.actor_type || '',
action: r.action || r.activity_type || '',
toolName: r.tool_name || r.path || '',
status: r.success ?? (r.error ? 'false' : undefined),
}))
})
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading activity log">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Activity log error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="text-destructive">{error}</span>
</div>
{:else if activityRows && activityRows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Activity log: {activityRows.length} entries">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{activityRows.length} entr{activityRows.length === 1 ? 'y' : 'ies'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each activityRows as row}
<div class="flex items-center gap-2 px-3 py-1.5 font-mono">
<span class="shrink-0 text-muted-foreground">{row.time}</span>
{#if row.date !== activityRows[0].date}
<span class="shrink-0 text-[10px] text-muted-foreground/60">{row.date}</span>
{/if}
<span class="text-muted-foreground">{row.action}</span>
<span class="max-w-32 truncate">{row.toolName}</span>
<span class="text-muted-foreground/60">{row.actor}</span>
{#if row.status === 'true'}
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-success" title="success"></span>
{:else if row.status === 'false'}
<span class="ml-auto size-1.5 shrink-0 rounded-full bg-destructive" title="error"></span>
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Activity log: no entries">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Activity log</span>
<span class="text-muted-foreground">no entries</span>
</div>
{/if}

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import { Badge } from '$lib/components/ui/badge'
import { relativeTime } from '$lib/utils'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import BoxIcon from '@lucide/svelte/icons/box'
import MonitorIcon from '@lucide/svelte/icons/monitor'
import ContainerIcon from '@lucide/svelte/icons/container'
import GlobeIcon from '@lucide/svelte/icons/globe'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import ZapIcon from '@lucide/svelte/icons/zap'
let { tool }: { tool: ToolCallResult } = $props()
const entity = $derived.by(() => {
if (tool.type !== 'tool_result') return null
const r = tool.result
if (!r) return null
if (Array.isArray(r)) return r[0]
if (r && typeof r === 'object' && 'data' in r) return Array.isArray(r.data) ? r.data[0] : r.data
return r
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const slug = $derived(entity?.slug ?? tool.args?.slug_or_id ?? tool.args?.hostname ?? tool.args?.service_slug ?? '')
const typeIcon: Record<string, typeof BoxIcon> = {
host: MonitorIcon,
lxc: ContainerIcon,
service: GlobeIcon,
check: ZapIcon,
}
const Icon = $derived(entity?.type ? (typeIcon[entity.type] ?? BoxIcon) : BoxIcon)
const keyAttrs = $derived.by(() => {
if (!entity) return [] as [string, string][]
const out: [string, string][] = []
const skip = new Set(['slug', 'type', 'name', 'state', 'health', 'last_check', 'version', 'created_at', 'updated_at', 'maintenance_until', '__renderer', 'data', 'attrs', 'attributes', 'enrolled_at'])
for (const k of ['mesh_ip', 'ip', 'version', 'age_pubkey', 'enrolled_at', 'last_check']) {
const v = entity[k]
if (v && typeof v === 'string') {
out.push([k, k === 'age_pubkey' ? v.slice(0, 16) + '…' : v])
}
}
const attrs = entity.attributes ?? entity.attrs
if (attrs && typeof attrs === 'object') {
for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {
if (!skip.has(k) && v != null && v !== '') {
out.push([k, typeof v === 'object' ? JSON.stringify(v) : String(v)])
}
}
}
return out.slice(0, 4)
})
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entity: {slug || tool.name}">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entity: {error}">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="text-destructive">{error}</span>
</div>
{:else if entity}
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Entity: {entity.slug}{entity.type}{entity.health || 'no health data'}">
<div class="flex flex-wrap items-center gap-1.5">
<div class="flex items-center gap-1.5">
<Icon class="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="font-mono font-semibold">{entity.slug ?? slug}</span>
</div>
{#if entity.type}
<Badge variant="outline" class="text-[10px]">{entity.type}</Badge>
{/if}
{#if entity.state}
<Badge variant="secondary" class="text-[10px]">{entity.state}</Badge>
{/if}
{#if entity.health && entity.health !== 'unknown'}
<span class="flex items-center gap-1 text-muted-foreground">
<span class="size-2 rounded-full" style="background: {healthColor[entity.health] ?? 'var(--muted-foreground)'}"></span>
{entity.health}
</span>
{/if}
{#if entity.last_check}
<span class="text-muted-foreground">· {relativeTime(entity.last_check)}</span>
{/if}
</div>
{#if keyAttrs.length > 0}
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground">
{#each keyAttrs as [k, v]}
<span class="font-mono text-[10px]"><span class="opacity-60">{k}:</span> {v}</span>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Entity: {slug || tool.name} — no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-mono font-medium">{slug || tool.name}</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const cols = $derived(rows && rows.length > 0 ? Object.keys(rows[0]).filter(k => k !== '__renderer') : [])
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading entities">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Entities</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading entities">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Entities</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Entities: {rows.length} results">
<div class="max-h-56 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
{#each cols as col}
<th class="px-2 py-1 text-left font-medium whitespace-nowrap">{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
{#each cols as col}
<td class="px-2 py-1 whitespace-nowrap font-mono max-w-48 truncate">{row[col] ?? '—'}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs text-muted-foreground" role="status" aria-label="Entities: no results">
<XIcon class="size-3 shrink-0" aria-hidden="true" />
<span>No entities found</span>
</div>
{/if}

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const summary = $derived.by(() => {
if (!rows) return null
const health: Record<string, number> = {}
const types: Record<string, number> = {}
for (const r of rows) {
health[r.health || 'unknown'] = (health[r.health || 'unknown'] || 0) + 1
types[r.type || 'unknown'] = (types[r.type || 'unknown'] || 0) + 1
}
return { health, types, total: rows.length }
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
unknown: 'var(--muted-foreground)',
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading fleet snapshot">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Fleet snapshot error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="text-destructive">{error}</span>
</div>
{:else if summary && summary.total > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Fleet snapshot: {summary.total} entities">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{summary.total} entities</span>
</div>
<div class="px-3 py-2 space-y-2">
<!-- Health -->
<div>
<div class="text-muted-foreground mb-1">Health</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each ['healthy', 'degraded', 'down', 'unknown'] as h}
{#if summary.health[h]}
<div class="flex items-center gap-1.5 font-mono">
<span class="size-1.5 rounded-full" style="background: {healthColor[h] ?? 'var(--muted-foreground)'}"></span>
<span class="text-muted-foreground">{h}</span>
<span class="tabular-nums">{summary.health[h]}</span>
</div>
{/if}
{/each}
</div>
</div>
<!-- Types -->
{#if Object.keys(summary.types).length > 0}
<div>
<div class="text-muted-foreground mb-1">By type</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
{#each Object.entries(summary.types).sort(([,a], [,b]) => b - a) as [type, count]}
<div class="flex items-center gap-1.5 font-mono">
<span class="text-muted-foreground">{type}</span>
<span class="tabular-nums">{count}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Fleet snapshot: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Fleet snapshot</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data : null
})
const counts = $derived.by(() => {
if (!rows) return null
const m: Record<string, number> = {}
for (const r of rows as any[]) m[r.health || 'unknown'] = (m[r.health || 'unknown'] || 0) + 1
return m
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const total = $derived(counts ? Object.values(counts).reduce((a, b) => a + b, 0) : 0)
const bars: { label: string; count: number; color: string }[] = [
{ label: 'healthy', count: counts?.healthy ?? 0, color: 'var(--success)' },
{ label: 'degraded', count: counts?.degraded ?? 0, color: 'var(--warning)' },
{ label: 'down', count: counts?.down ?? 0, color: 'var(--destructive)' },
{ label: 'unknown', count: counts?.unknown ?? 0, color: 'var(--muted-foreground)' },
]
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading health summary">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading health summary">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="text-destructive">{error}</span>
</div>
{:else if counts && total > 0}
<div class="rounded-lg border bg-card px-3 py-2 text-xs" aria-label="Health summary: {total} entities — healthy {counts?.healthy ?? 0}, degraded {counts?.degraded ?? 0}, down {counts?.down ?? 0}">
<div class="flex items-center gap-2 mb-1.5">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{total} entities</span>
</div>
<div class="flex h-5 w-full overflow-hidden rounded">
{#each bars as bar}
{#if bar.count > 0}
<div
style="width: {(bar.count / total) * 100}%; background: {bar.color}"
class="flex items-center justify-center text-[9px] font-medium text-white min-w-[2rem]"
title="{bar.label}: {bar.count}"
>
{bar.count}
</div>
{/if}
{/each}
</div>
<div class="mt-1.5 flex flex-wrap gap-x-3 text-muted-foreground">
{#each bars as bar}
{#if bar.count > 0}
<span class="flex items-center gap-1">
<span class="size-1.5 rounded-full" style="background: {bar.color}"></span>
{bar.label} {bar.count}
</span>
{/if}
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Health summary: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Health summary</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import FileTextIcon from '@lucide/svelte/icons/file-text'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Searching knowledge">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="animate-pulse text-muted-foreground">searching…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Knowledge search error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Knowledge: {rows.length} results">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} result{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-56 overflow-y-auto divide-y">
{#each rows as row}
<div class="px-3 py-2">
<div class="flex items-start gap-2">
<FileTextIcon class="size-3 shrink-0 mt-0.5 text-muted-foreground" />
<div class="min-w-0">
<div class="font-mono font-medium truncate">{row.title}</div>
{#if row.snippet || row.headline}
<div class="mt-0.5 text-muted-foreground leading-relaxed line-clamp-2">
{row.snippet || row.headline}
</div>
{/if}
<div class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
{#if row.source}
<span>{row.source}</span>
{/if}
{#if row.slug}
<span class="font-mono opacity-60">{row.slug}</span>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Knowledge: no results">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Knowledge</span>
<span class="text-muted-foreground">no results</span>
</div>
{/if}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
const healthColor: Record<string, string> = {
healthy: 'var(--success)',
degraded: 'var(--warning)',
down: 'var(--destructive)',
}
function shortName(slug: string): string {
return slug.split(':').pop() ?? slug
}
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading LXC containers">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Error loading LXC containers">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="LXC containers: {rows.length} total">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} container{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-48 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
<th class="px-3 py-1 text-left font-medium">Name</th>
<th class="px-3 py-1 text-left font-medium">ID</th>
<th class="px-3 py-1 text-left font-medium">IP</th>
<th class="px-3 py-1 text-left font-medium">Health</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="px-3 py-1 font-mono">{shortName(row.slug)}</td>
<td class="px-3 py-1 tabular-nums text-muted-foreground">{row.pve_id ?? '—'}</td>
<td class="px-3 py-1 font-mono text-muted-foreground">{row.lan_ip ?? '—'}</td>
<td class="px-3 py-1">
{#if row.health}
<span class="flex items-center gap-1">
<span class="size-1.5 rounded-full" style="background: {healthColor[row.health] ?? 'var(--muted-foreground)'}"></span>
{row.health}
</span>
{:else}
<span class="text-muted-foreground"></span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="LXC containers: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">LXC containers</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
let { tool }: { tool: ToolCallResult } = $props()
const rows = $derived.by(() => {
if (tool.type !== 'tool_result' || !tool.result) return null
const data = (tool.result as any).data ?? tool.result
return Array.isArray(data) ? data as any[] : null
})
const loading = $derived(tool.type === 'tool_use')
const error = $derived(tool.type === 'tool_result' ? tool.error : undefined)
</script>
{#if loading}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Loading metrics">
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="animate-pulse text-muted-foreground">loading…</span>
</div>
{:else if error}
<div class="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs" role="alert" aria-label="Metrics error">
<XIcon class="size-3 shrink-0 text-destructive" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="text-destructive">{error}</span>
</div>
{:else if rows && rows.length > 0}
<div class="rounded-lg border bg-card text-xs" aria-label="Metrics: {rows.length} samples">
<div class="flex items-center gap-2 px-3 py-1.5 border-b">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">{rows.length} sample{rows.length === 1 ? '' : 's'}</span>
</div>
<div class="max-h-48 overflow-y-auto">
<table class="w-full">
<thead>
<tr class="border-b text-muted-foreground">
<th class="px-2 py-1 text-left font-medium">Time</th>
<th class="px-2 py-1 text-left font-medium">Metric</th>
<th class="px-2 py-1 text-right font-medium">Avg</th>
<th class="px-2 py-1 text-right font-medium">Min</th>
<th class="px-2 py-1 text-right font-medium">Max</th>
</tr>
</thead>
<tbody>
{#each rows as row}
<tr class="border-b last:border-0 hover:bg-muted/30">
<td class="px-2 py-1 font-mono tabular-nums whitespace-nowrap">{row.bucket?.slice(11, 16) ?? row.bucket?.slice(0, 19) ?? '—'}</td>
<td class="px-2 py-1 font-mono max-w-32 truncate">{row.metric ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right">{row.avg ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.min ?? '—'}</td>
<td class="px-2 py-1 font-mono tabular-nums text-right text-muted-foreground">{row.max ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{:else}
<div class="flex items-center gap-2 rounded-lg border bg-card px-3 py-2 text-xs" role="status" aria-label="Metrics: no data">
<CheckIcon class="size-3 shrink-0 text-success" aria-hidden="true" />
<span class="font-medium">Metrics</span>
<span class="text-muted-foreground">no data</span>
</div>
{/if}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import BlastRadius from './BlastRadius.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_blast_radius' || t.result?.__renderer === 'blast_radius',
component: BlastRadius,
})
}

View File

@@ -0,0 +1,12 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import ChangeLog from './ChangeLog.svelte'
export function init() {
registerToolRenderer({
match: (t) =>
t.name === 'get_change_history' ||
t.name === 'get_agent_activity' ||
t.result?.__renderer === 'change_log',
component: ChangeLog,
})
}

View File

@@ -0,0 +1,11 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import EntityCard from './EntityCard.svelte'
const TOOLS = ['get_entity', 'whoami', 'explain']
export function init() {
registerToolRenderer({
match: (t) => TOOLS.includes(t.name) || t.result?.__renderer === 'entity_card',
component: EntityCard,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import EntityTable from './EntityTable.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'list_entities' || t.result?.__renderer === 'entity_table',
component: EntityTable,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import FleetSnapshot from './FleetSnapshot.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_state_snapshot' || t.result?.__renderer === 'fleet_snapshot',
component: FleetSnapshot,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import HealthSummary from './HealthSummary.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'get_health_summary' || t.result?.__renderer === 'health_summary',
component: HealthSummary,
})
}

View File

@@ -0,0 +1,19 @@
import { init as initEntityCard } from './entity-card'
import { init as initHealthSummary } from './health-summary'
import { init as initLXCList } from './lxc-list'
import { init as initEntityTable } from './entity-table'
import { init as initKnowledgeResults } from './knowledge-results'
import { init as initBlastRadius } from './blast-radius'
import { init as initChangeLog } from './change-log'
import { init as initFleetSnapshot } from './fleet-snapshot'
import { init as initMetricChart } from './metric-chart'
initEntityCard()
initHealthSummary()
initLXCList()
initEntityTable()
initKnowledgeResults()
initBlastRadius()
initChangeLog()
initFleetSnapshot()
initMetricChart()

View File

@@ -0,0 +1,12 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import KnowledgeResults from './KnowledgeResults.svelte'
export function init() {
registerToolRenderer({
match: (t) =>
t.name === 'search_knowledge' ||
t.name === 'get_entity_knowledge' ||
t.result?.__renderer === 'knowledge_results',
component: KnowledgeResults,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import LXCList from './LXCList.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'list_lxcs' || t.result?.__renderer === 'lxc_list',
component: LXCList,
})
}

View File

@@ -0,0 +1,9 @@
import { registerToolRenderer } from '$lib/tool-renderers'
import MetricChart from './MetricChart.svelte'
export function init() {
registerToolRenderer({
match: (t) => t.name === 'query_metrics' || t.result?.__renderer === 'metric_chart',
component: MetricChart,
})
}

View File

@@ -0,0 +1,17 @@
import type { Component } from 'svelte'
import type { ToolCallResult } from '$lib/stores/chat'
export interface ToolRenderer {
match: (tool: ToolCallResult) => boolean
component: Component<{ tool: ToolCallResult }>
}
const registry: ToolRenderer[] = []
export function registerToolRenderer(r: ToolRenderer) {
registry.push(r)
}
export function getToolRenderer(tool: ToolCallResult): ToolRenderer | undefined {
return registry.find((r) => r.match(tool))
}

View File

@@ -5,5 +5,7 @@ import { initConfig } from '$lib/config'
initConfig() initConfig()
requestAnimationFrame(() => import('./lib/renderers'))
const app = mount(App, { target: document.getElementById('app')! }) const app = mount(App, { target: document.getElementById('app')! })
export default app export default app

View File

@@ -4,6 +4,7 @@
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte' import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte' import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
import InlineApproval from '$lib/components/InlineApproval.svelte' import InlineApproval from '$lib/components/InlineApproval.svelte'
import { getToolRenderer } from '$lib/tool-renderers'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { Textarea } from '$lib/components/ui/textarea' import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up' import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
@@ -16,6 +17,19 @@
let input = $state('') let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null) let messagesEnd = $state<HTMLDivElement | null>(null)
const MAX_INLINE_CARDS = 5
function getInlineTools(msg: { tools: any[] }): any[] {
const matched = msg.tools.filter((t) => getToolRenderer(t))
if (matched.length <= MAX_INLINE_CARDS) return matched
return matched.slice(0, MAX_INLINE_CARDS)
}
function getRemaining(msg: { tools: any[] }, inline: any[]): any[] {
const inlineIds = new Set(inline.map((t) => t.id))
return msg.tools.filter((t) => !inlineIds.has(t.id))
}
// Resizable right rail (session graph). Persisted so it survives reloads. // Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260 const RAIL_MIN = 260
const RAIL_MAX = 620 const RAIL_MAX = 620
@@ -113,7 +127,17 @@
<div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div> <div class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap">{msg.text}</div>
{:else} {:else}
<div class="flex w-full flex-col gap-2"> <div class="flex w-full flex-col gap-2">
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} /> {#each getInlineTools(msg) as tool (tool.id)}
{@const renderer = getToolRenderer(tool)}
{#if renderer}
<renderer.component {tool} />
{/if}
{/each}
<ToolCallGroup
tools={msg.tools}
unmatched={getRemaining(msg, getInlineTools(msg))}
active={$streaming && i === $messages.length - 1}
/>
{#if msg.text} {#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed"> <div class="prose-chat max-w-none text-sm leading-relaxed">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify --> <!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->

View File

@@ -43,6 +43,7 @@
error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}` error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}`
return return
} }
saveToDesktop()
onConnected() onConnected()
} catch (e) { } catch (e) {
error = 'Could not reach server — check the URL' error = 'Could not reach server — check the URL'
@@ -51,6 +52,16 @@
} }
} }
function saveToDesktop() {
const wails = (window as any).wails
if (!wails?.Call?.ByName) return
try {
wails.Call.ByName('SaveConfig', apiUrl.trim(), token.trim())
} catch {
// ignore — optional desktop-only path
}
}
async function loginWithAuthentik() { async function loginWithAuthentik() {
error = '' error = ''
oidcLoggingIn = true oidcLoggingIn = true