Compare commits
12 Commits
335fa67d55
...
desktop-0.
| Author | SHA1 | Date | |
|---|---|---|---|
| a429436903 | |||
| 07d67c8446 | |||
| 8b50753746 | |||
| 680575e2cf | |||
| 6b52c1ae57 | |||
| 5d6d9e9040 | |||
| 04006553a3 | |||
| 7b0a0f01b5 | |||
| f6a699469d | |||
| 4c4afc4783 | |||
| 604b608fa8 | |||
| 62a8ec1d8d |
98
.gitea/workflows/desktop.yml
Normal file
98
.gitea/workflows/desktop.yml
Normal 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
5
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|
||||||
|
|||||||
23
Makefile
23
Makefile
@@ -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:
|
||||||
|
|||||||
26
cmd/desktop/entitlements.plist
Normal file
26
cmd/desktop/entitlements.plist
Normal 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
382
cmd/desktop/main.go
Normal 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
9
cmd/desktop/wails.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "oikos",
|
||||||
|
"outputfilename": "oikos-desktop",
|
||||||
|
"frontend:dir": "frontend",
|
||||||
|
"author": {
|
||||||
|
"name": "Hubris",
|
||||||
|
"email": "d.toro.v@pm.me"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: timescale/timescaledb:2.17.2-pg16
|
image: timescale/timescaledb:2.17.2-pg16
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: oikos
|
POSTGRES_DB: oikos
|
||||||
POSTGRES_USER: oikos
|
POSTGRES_USER: oikos
|
||||||
@@ -57,6 +58,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["dev", "full"]
|
profiles: ["dev", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
seed:
|
seed:
|
||||||
@@ -70,6 +72,8 @@ services:
|
|||||||
# every request needs this token. nomos uses the same value to call
|
# every request needs this token. nomos uses the same value to call
|
||||||
# back into api's /mcp and /api/v1/approvals/*/decision.
|
# back into api's /mcp and /api/v1/approvals/*/decision.
|
||||||
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token}
|
||||||
|
OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/}
|
||||||
|
OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod}
|
||||||
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos}
|
||||||
NOMOS_PROXY_URL: http://nomos:8092
|
NOMOS_PROXY_URL: http://nomos:8092
|
||||||
volumes:
|
volumes:
|
||||||
@@ -85,6 +89,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["dev", "full"]
|
profiles: ["dev", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
seed:
|
seed:
|
||||||
@@ -108,6 +113,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/oikos/Dockerfile
|
dockerfile: compose/oikos/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["dev", "full"]
|
profiles: ["dev", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
seed:
|
seed:
|
||||||
@@ -129,6 +135,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/nomos/Dockerfile
|
dockerfile: compose/nomos/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["full"]
|
profiles: ["full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
@@ -155,6 +162,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: compose/web/Dockerfile
|
dockerfile: compose/web/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["dev", "full"]
|
profiles: ["dev", "full"]
|
||||||
ports:
|
ports:
|
||||||
- "8091:80"
|
- "8091:80"
|
||||||
@@ -163,6 +171,7 @@ services:
|
|||||||
# Redis (required by Infisical — Phase 5)
|
# Redis (required by Infisical — Phase 5)
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["infisical", "full"]
|
profiles: ["infisical", "full"]
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
@@ -175,6 +184,7 @@ services:
|
|||||||
# Infisical self-hosted (Phase 5 secrets management)
|
# Infisical self-hosted (Phase 5 secrets management)
|
||||||
infisical:
|
infisical:
|
||||||
image: infisical/infisical:latest
|
image: infisical/infisical:latest
|
||||||
|
restart: unless-stopped
|
||||||
profiles: ["infisical", "full"]
|
profiles: ["infisical", "full"]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
10
go.mod
10
go.mod
@@ -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
33
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ type Config struct {
|
|||||||
APIEnv string // dev, prod
|
APIEnv string // dev, prod
|
||||||
|
|
||||||
// Auth (Phase 2: static bearer tokens + OIDC JWT)
|
// Auth (Phase 2: static bearer tokens + OIDC JWT)
|
||||||
APIToken string // operator/CI bearer token for the REST API
|
APIToken string // operator/CI bearer token for the REST API
|
||||||
MCPBearerToken string // shared secret for Nomos→API MCP calls
|
MCPBearerToken string // shared secret for Nomos→API MCP calls
|
||||||
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
|
OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/)
|
||||||
OIDCClientID string // OIDC client ID (aud claim expected in JWT)
|
OIDCClientID string // OIDC client ID (aud claim expected in JWT)
|
||||||
|
OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients)
|
||||||
|
|
||||||
// CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md
|
// CORS (client/server split — see plans/2026-07-12-wails-desktop-app.md
|
||||||
// 0.3). Needed for the Wails webview and local dev (Vite on a different
|
// 0.3). Needed for the Wails webview and local dev (Vite on a different
|
||||||
@@ -108,6 +109,9 @@ func FromEnv() Config {
|
|||||||
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
|
if v := os.Getenv("OIKOS_OIDC_CLIENT_ID"); v != "" {
|
||||||
c.OIDCClientID = v
|
c.OIDCClientID = v
|
||||||
}
|
}
|
||||||
|
if v := os.Getenv("OIKOS_OIDC_CLIENT_SECRET"); v != "" {
|
||||||
|
c.OIDCClientSecret = v
|
||||||
|
}
|
||||||
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
|
if v := os.Getenv("OIKOS_API_TOKEN"); v != "" {
|
||||||
c.APIToken = v
|
c.APIToken = v
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -127,6 +128,16 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// OIDC endpoints — unauthenticated. The SPA needs the issuer + client_id
|
||||||
|
// to build the authorization URL, and uses the token proxy to exchange
|
||||||
|
// authorization codes and refresh tokens without CORS issues.
|
||||||
|
r.Get("/api/v1/auth/oidc-config", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
s.serveOIDCConfig(w, req, cfg)
|
||||||
|
})
|
||||||
|
r.Post("/api/v1/auth/oidc-token", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
s.serveOIDCToken(w, req, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
|
strict := gen.NewStrictHandlerWithOptions(s, nil, gen.StrictHTTPServerOptions{
|
||||||
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) {
|
||||||
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
writeProblem(w, req, http.StatusBadRequest, "bad request", err.Error())
|
||||||
@@ -530,6 +541,122 @@ func requestLogger(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveOIDCEndpointURL derives an endpoint URL from the issuer by walking
|
||||||
|
// up one path segment. Authentik's issuer is per-provider
|
||||||
|
// (e.g. .../application/o/oikos/) but shared endpoints live at the parent
|
||||||
|
// path (.../application/o/<suffix>).
|
||||||
|
func resolveOIDCEndpointURL(issuer, suffix string) string {
|
||||||
|
u, err := url.Parse(issuer)
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimRight(issuer, "/") + suffix
|
||||||
|
}
|
||||||
|
u.Path = strings.TrimRight(u.Path, "/")
|
||||||
|
if idx := strings.LastIndex(u.Path, "/"); idx >= 0 {
|
||||||
|
u.Path = u.Path[:idx]
|
||||||
|
}
|
||||||
|
u.Path += suffix
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOIDCTokenURL derives the token endpoint URL from the issuer.
|
||||||
|
func resolveOIDCTokenURL(issuer string) string {
|
||||||
|
return resolveOIDCEndpointURL(issuer, "/token/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveOIDCConfig returns the OIDC issuer and client_id so the SPA can build
|
||||||
|
// authorization URLs without hardcoding them.
|
||||||
|
func (s *Server) serveOIDCConfig(w http.ResponseWriter, _ *http.Request, cfg config.Config) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": cfg.OIDCIssuer,
|
||||||
|
"client_id": cfg.OIDCClientID,
|
||||||
|
"authorization_endpoint": resolveOIDCEndpointURL(cfg.OIDCIssuer, "/authorize/"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenExchangeBody mirrors the JSON the SPA sends to the token proxy.
|
||||||
|
type tokenExchangeBody struct {
|
||||||
|
GrantType string `json:"grant_type"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
CodeVerifier string `json:"code_verifier,omitempty"`
|
||||||
|
RedirectURI string `json:"redirect_uri,omitempty"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveOIDCToken proxies authorization_code and refresh_token grants to the
|
||||||
|
// OIDC provider's token endpoint. The SPA can't POST directly to Authentik
|
||||||
|
// because of CORS; this proxy avoids the cross-origin problem entirely.
|
||||||
|
func (s *Server) serveOIDCToken(w http.ResponseWriter, req *http.Request, cfg config.Config) {
|
||||||
|
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
|
||||||
|
writeProblem(w, req, http.StatusServiceUnavailable, "oidc not configured", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tb tokenExchangeBody
|
||||||
|
if err := json.Unmarshal(body, &tb); err != nil {
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "invalid token request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the form-encoded body for Authentik's token endpoint
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("client_id", cfg.OIDCClientID)
|
||||||
|
if cfg.OIDCClientSecret != "" {
|
||||||
|
form.Set("client_secret", cfg.OIDCClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tb.GrantType {
|
||||||
|
case "authorization_code":
|
||||||
|
form.Set("grant_type", "authorization_code")
|
||||||
|
form.Set("code", tb.Code)
|
||||||
|
form.Set("code_verifier", tb.CodeVerifier)
|
||||||
|
form.Set("redirect_uri", tb.RedirectURI)
|
||||||
|
case "refresh_token":
|
||||||
|
form.Set("grant_type", "refresh_token")
|
||||||
|
form.Set("refresh_token", tb.RefreshToken)
|
||||||
|
default:
|
||||||
|
writeProblem(w, req, http.StatusBadRequest, "unsupported grant_type", tb.GrantType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenURL := resolveOIDCTokenURL(cfg.OIDCIssuer)
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
||||||
|
}}
|
||||||
|
resp, err := client.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("oidc token proxy failed", "error", err)
|
||||||
|
writeProblem(w, req, http.StatusBadGateway, "token endpoint unreachable", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, req, http.StatusInternalServerError, "read token response failed", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
slog.Warn("oidc token endpoint returned error", "status", resp.StatusCode, "body", string(respBody))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
w.Write(respBody)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
w.Write(respBody)
|
||||||
|
}
|
||||||
|
|
||||||
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||||
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
||||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -149,12 +149,12 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
ORDER BY ts DESC LIMIT 50`, nStr(args["entity_id"])), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking)",
|
register(&mcp.Tool{Name: "search_knowledge", Description: "Full-text search across documentation (PostgreSQL FTS with ts_rank ranking). Returns a short snippet per hit, not the full note — call get_knowledge_content with the returned slug to read the whole thing.",
|
||||||
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
InputSchema: objSchema(prop{"query", "string", "Search terms"}),
|
||||||
}, 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,15 +165,15 @@ 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",
|
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.",
|
||||||
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
InputSchema: objSchema(prop{"entity_slug", "string", "Entity slug (e.g. lxc:jellyfin, service:caddy)"}),
|
||||||
}, 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,10 +193,22 @@ 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: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge read it back.",
|
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.",
|
||||||
|
InputSchema: objSchema(prop{"slug", "string", "The knowledge entity's own slug (e.g. document:containers/101-jellyfin, runbook:client-enrollment) — not the slug of an entity it's about."}),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["slug"].(string)
|
||||||
|
return queryRows(ctx, pool, `
|
||||||
|
SELECT ke.title, e.slug, e.type AS kind, ke.content, ke.source, ke.tags, ke.updated_at::text
|
||||||
|
FROM knowledge_entities ke
|
||||||
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
|
WHERE e.slug = $1`, slug), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
register(&mcp.Tool{Name: "upsert_knowledge", Description: "Write back what you learned so future sessions (and future you) benefit — this is how the system gets smarter over time. Use it AFTER solving a non-obvious problem, deploying a service, or discovering a gotcha: record the finding, the fix, and any caveats. Re-calling with the same title updates the existing note instead of duplicating. This is the ONLY way to persist knowledge; a chat message alone is forgotten. search_knowledge/get_entity_knowledge find it, get_knowledge_content reads the full body back.",
|
||||||
InputSchema: objSchema(
|
InputSchema: objSchema(
|
||||||
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
prop{"title", "string", "Short, specific, searchable title (e.g. 'Dragonfly memlock rlimit in unprivileged LXCs', not 'notes')."},
|
||||||
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
prop{"content", "string", "The knowledge itself, in markdown. Be concrete: symptom, root cause, the exact fix/commands, and any caveats. Written for someone hitting this fresh."},
|
||||||
@@ -277,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,
|
||||||
@@ -286,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 ──────────────────────────────────────────
|
||||||
@@ -428,6 +440,23 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
}
|
}
|
||||||
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
|
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
|
||||||
id, targetID, action+":"+params, correlationID, agentID)
|
id, targetID, action+":"+params, correlationID, agentID)
|
||||||
|
pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
||||||
|
id, targetID)
|
||||||
|
if sessionID != "" {
|
||||||
|
pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
||||||
|
FROM entities t WHERE t.slug = $2
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
||||||
|
id, "task:"+sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
// Execute reversible actions immediately. restart/pct_exec/systemctl
|
// Execute reversible actions immediately. restart/pct_exec/systemctl
|
||||||
// (outside enable/disable) never reach here — they're routed through
|
// (outside enable/disable) never reach here — they're routed through
|
||||||
@@ -620,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 ──────────────────────────────
|
||||||
@@ -635,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",
|
||||||
@@ -782,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,
|
||||||
@@ -792,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",
|
||||||
@@ -803,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,
|
||||||
@@ -811,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",
|
||||||
@@ -849,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
|
||||||
@@ -857,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
|
||||||
@@ -873,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",
|
||||||
@@ -1099,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 (
|
||||||
@@ -1397,6 +1446,23 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
|||||||
}
|
}
|
||||||
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, $4, 'running', $5, $6) ON CONFLICT DO NOTHING`,
|
||||||
id, targetID, actionCol, riskClass, correlationID, agentID)
|
id, targetID, actionCol, riskClass, correlationID, agentID)
|
||||||
|
pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, 'targets', '{"by":"nomos"}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = 'targets' AND valid_to IS NULL)`,
|
||||||
|
id, targetID)
|
||||||
|
if sessionID != "" {
|
||||||
|
pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT t.id, $1, 'involves', '{"by":"nomos"}'::jsonb, now()
|
||||||
|
FROM entities t WHERE t.slug = $2
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = t.id AND target_id = $1 AND type = 'involves' AND valid_to IS NULL)`,
|
||||||
|
id, "task:"+sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
if riskClass == policy.RiskReadOnly {
|
if riskClass == policy.RiskReadOnly {
|
||||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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.0–0.6 deployed to production (mac-mini, commit
|
||||||
local browser test, and **deployed to production** (mac-mini, commit
|
`0c0f35a`, 2026-07-12). Phases 1.0–1.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 →
|
||||||
66
plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md
Normal file
66
plans/done/2026-07-13-mcp-tool-apps-custom-chat-renderers.md
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { connectionState } from '$lib/stores/events'
|
import { connectionState } from '$lib/stores/events'
|
||||||
import { isConfigured } from '$lib/config'
|
import { isConfigured } from '$lib/config'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
import { processPendingCallback, initOIDC } from '$lib/oidc'
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar'
|
import * as Sidebar from '$lib/components/ui/sidebar'
|
||||||
import * as Sheet from '$lib/components/ui/sheet'
|
import * as Sheet from '$lib/components/ui/sheet'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
@@ -37,7 +38,13 @@
|
|||||||
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
|
||||||
const openSignals = $derived(openSignalCount($summary))
|
const openSignals = $derived(openSignalCount($summary))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
|
if (await processPendingCallback()) {
|
||||||
|
configured = true
|
||||||
|
} else if (!configured) {
|
||||||
|
if (await initOIDC()) configured = true
|
||||||
|
}
|
||||||
|
|
||||||
function sync() {
|
function sync() {
|
||||||
const path = location.hash.slice(2) || 'overview'
|
const path = location.hash.slice(2) || 'overview'
|
||||||
const [head, ...rest] = path.split('/')
|
const [head, ...rest] = path.split('/')
|
||||||
|
|||||||
@@ -120,7 +120,37 @@
|
|||||||
return typeCategory.get(type) === category
|
return typeCategory.get(type) === category
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
// Brand-new nodes (no `prev`) get x/y left undefined, and d3-force's
|
||||||
|
// default init spreads those via a spiral centered on the ORIGIN — not
|
||||||
|
// (width/2, height/2) — while the x/y centering forces below are
|
||||||
|
// deliberately weak (0.04, so they don't fight the link/collide layout).
|
||||||
|
// Together that meant the cluster could settle noticeably off-origin
|
||||||
|
// instead of centered. Fixed by explicitly fitting the viewport to the
|
||||||
|
// node bounding box once the simulation settles, rather than relying on
|
||||||
|
// the force balance to land on center by itself.
|
||||||
|
function fitToView() {
|
||||||
|
const placed = nodes.filter((n) => n.x != null && n.y != null)
|
||||||
|
if (!placed.length) return
|
||||||
|
const xs = placed.map((n) => n.x as number)
|
||||||
|
const ys = placed.map((n) => n.y as number)
|
||||||
|
const minX = Math.min(...xs)
|
||||||
|
const maxX = Math.max(...xs)
|
||||||
|
const minY = Math.min(...ys)
|
||||||
|
const maxY = Math.max(...ys)
|
||||||
|
const pad = 70
|
||||||
|
const bw = Math.max(maxX - minX, 1)
|
||||||
|
const bh = Math.max(maxY - minY, 1)
|
||||||
|
const k = Math.min((width - pad * 2) / bw, (height - pad * 2) / bh, 2.5)
|
||||||
|
const cx = (minX + maxX) / 2
|
||||||
|
const cy = (minY + maxY) / 2
|
||||||
|
view = { k, x: width / 2 - cx * k, y: height / 2 - cy * k }
|
||||||
|
}
|
||||||
|
|
||||||
|
// fit=false for passive background reloads (live entity/relationship
|
||||||
|
// events) — those shouldn't yank the view out from under someone
|
||||||
|
// actively panning/zooming. Fresh loads (mount, root/depth change,
|
||||||
|
// reset, re-root) default to fit=true.
|
||||||
|
async function load(fit = true) {
|
||||||
loading = true
|
loading = true
|
||||||
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
graph = await fetchGraph({ root: root || undefined, depth, includeStatus: true })
|
||||||
loading = false
|
loading = false
|
||||||
@@ -163,6 +193,9 @@
|
|||||||
.on('tick', () => {
|
.on('tick', () => {
|
||||||
nodes = [...nodes]
|
nodes = [...nodes]
|
||||||
})
|
})
|
||||||
|
.on('end', () => {
|
||||||
|
if (fit) fitToView()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -191,7 +224,7 @@
|
|||||||
const ev = $liveEvents[0]
|
const ev = $liveEvents[0]
|
||||||
if (!ev) return
|
if (!ev) return
|
||||||
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
if (ev.type.startsWith('entity.') || ev.type.startsWith('relationship.') || ev.type === 'health.changed') {
|
||||||
load()
|
load(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
// or cross-origin (Wails webview, remote access). See
|
// or cross-origin (Wails webview, remote access). See
|
||||||
// plans/2026-07-12-wails-desktop-app.md 0.2.
|
// plans/2026-07-12-wails-desktop-app.md 0.2.
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -51,7 +54,7 @@ export function clearConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isConfigured(): boolean {
|
export function isConfigured(): boolean {
|
||||||
return !!getConfig().token
|
return !!getConfig().token || isOIDCConfigured()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relative paths are used in dev (Vite proxy) and when the SPA shares an
|
// Relative paths are used in dev (Vite proxy) and when the SPA shares an
|
||||||
@@ -63,6 +66,15 @@ export function apiBase(path: string): string {
|
|||||||
return `${c.apiUrl}${path}`
|
return `${c.apiUrl}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves the auth token for a request: OIDC takes precedence, then static.
|
||||||
|
function resolveAuthHeader(): string | null {
|
||||||
|
const oidcToken = getToken()
|
||||||
|
if (oidcToken) return `Bearer ${oidcToken}`
|
||||||
|
const c = getConfig()
|
||||||
|
if (c.token) return `Bearer ${c.token}`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Auth fetch wrapper ----
|
// ---- Auth fetch wrapper ----
|
||||||
// Prepends the API base URL (absolute when configured, relative when unset
|
// Prepends the API base URL (absolute when configured, relative when unset
|
||||||
// for the Vite dev proxy / same-origin prod) and adds the Authorization
|
// for the Vite dev proxy / same-origin prod) and adds the Authorization
|
||||||
@@ -72,9 +84,9 @@ export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<R
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(opts?.headers as Record<string, string> ?? {})
|
...(opts?.headers as Record<string, string> ?? {})
|
||||||
}
|
}
|
||||||
const c = getConfig()
|
const authH = resolveAuthHeader()
|
||||||
if (c.token) {
|
if (authH) {
|
||||||
headers['Authorization'] = `Bearer ${c.token}`
|
headers['Authorization'] = authH
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(apiBase(path), { ...opts, headers })
|
return fetch(apiBase(path), { ...opts, headers })
|
||||||
@@ -84,9 +96,11 @@ export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<R
|
|||||||
// a query parameter (the SSE handler's combinedAuth checks it alongside the
|
// a query parameter (the SSE handler's combinedAuth checks it alongside the
|
||||||
// Authorization header, only for this route).
|
// Authorization header, only for this route).
|
||||||
export function sseUrl(path: string): string {
|
export function sseUrl(path: string): string {
|
||||||
const c = getConfig()
|
|
||||||
const url = apiBase(path)
|
const url = apiBase(path)
|
||||||
if (!c.token) return url
|
const c = getConfig()
|
||||||
|
const oidcToken = getToken()
|
||||||
|
const token = oidcToken ?? c.token
|
||||||
|
if (!token) return url
|
||||||
const sep = url.includes('?') ? '&' : '?'
|
const sep = url.includes('?') ? '&' : '?'
|
||||||
return `${url}${sep}token=${encodeURIComponent(c.token)}`
|
return `${url}${sep}token=${encodeURIComponent(token)}`
|
||||||
}
|
}
|
||||||
|
|||||||
272
web/src/lib/oidc.ts
Normal file
272
web/src/lib/oidc.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { apiBase } from './config'
|
||||||
|
|
||||||
|
interface OIDCConfig {
|
||||||
|
issuer: string
|
||||||
|
client_id: string
|
||||||
|
authorization_endpoint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
expires_in?: number
|
||||||
|
refresh_token?: string
|
||||||
|
id_token?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OIDCState {
|
||||||
|
config: OIDCConfig | null
|
||||||
|
accessToken: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
user: string | null
|
||||||
|
refreshing: Promise<string | null> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_KEY = 'oidc_access_token'
|
||||||
|
const REFRESH_KEY = 'oidc_refresh_token'
|
||||||
|
const USER_KEY = 'oidc_user'
|
||||||
|
const PKCE_KEY = 'oidc_pkce_verifier'
|
||||||
|
const STATE_KEY = 'oidc_state'
|
||||||
|
|
||||||
|
let state: OIDCState = {
|
||||||
|
config: null,
|
||||||
|
accessToken: sessionStorage.getItem(SESSION_KEY),
|
||||||
|
refreshToken: localStorage.getItem(REFRESH_KEY),
|
||||||
|
user: localStorage.getItem(USER_KEY),
|
||||||
|
refreshing: null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConfig(): Promise<OIDCConfig | null> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(apiBase('/api/v1/auth/oidc-config'))
|
||||||
|
if (!resp.ok) return null
|
||||||
|
const cfg: OIDCConfig = await resp.json()
|
||||||
|
state.config = cfg
|
||||||
|
return cfg
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64URLEncode(buf: ArrayBuffer): string {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandom(len: number): string {
|
||||||
|
const arr = new Uint8Array(len)
|
||||||
|
crypto.getRandomValues(arr)
|
||||||
|
return base64URLEncode(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(plain: string): Promise<ArrayBuffer> {
|
||||||
|
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(plain))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startLogin(): Promise<void> {
|
||||||
|
const cfg = state.config ?? await fetchConfig()
|
||||||
|
if (!cfg) throw new Error('OIDC not configured on server')
|
||||||
|
|
||||||
|
const codeVerifier = generateRandom(64)
|
||||||
|
const challengeBuf = await sha256(codeVerifier)
|
||||||
|
const codeChallenge = base64URLEncode(challengeBuf)
|
||||||
|
const oidcState = generateRandom(32)
|
||||||
|
|
||||||
|
sessionStorage.setItem(PKCE_KEY, codeVerifier)
|
||||||
|
sessionStorage.setItem(STATE_KEY, oidcState)
|
||||||
|
|
||||||
|
const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: cfg.client_id,
|
||||||
|
redirect_uri: redirectURI,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state: oidcState,
|
||||||
|
scope: 'openid profile email'
|
||||||
|
})
|
||||||
|
|
||||||
|
location.href = `${cfg.authorization_endpoint.replace(/\/$/, '')}/?${params}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCallback(code: string, returnedState: string): Promise<boolean> {
|
||||||
|
const verifier = sessionStorage.getItem(PKCE_KEY)
|
||||||
|
const savedState = sessionStorage.getItem(STATE_KEY)
|
||||||
|
sessionStorage.removeItem(PKCE_KEY)
|
||||||
|
sessionStorage.removeItem(STATE_KEY)
|
||||||
|
|
||||||
|
if (!verifier || savedState !== returnedState) return false
|
||||||
|
|
||||||
|
const cfg = state.config ?? await fetchConfig()
|
||||||
|
if (!cfg) return false
|
||||||
|
|
||||||
|
const redirectURI = (location.origin + location.pathname).replace(/\/$/, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code,
|
||||||
|
code_verifier: verifier,
|
||||||
|
redirect_uri: redirectURI
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok) return false
|
||||||
|
|
||||||
|
const tokens: TokenResponse = await resp.json()
|
||||||
|
if (!tokens.access_token) return false
|
||||||
|
|
||||||
|
storeTokens(tokens)
|
||||||
|
|
||||||
|
if (tokens.id_token) {
|
||||||
|
const user = parseIDTokenUser(tokens.id_token)
|
||||||
|
if (user) {
|
||||||
|
state.user = user
|
||||||
|
localStorage.setItem(USER_KEY, user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIDTokenUser(idToken: string): string | null {
|
||||||
|
try {
|
||||||
|
const payload = idToken.split('.')[1]
|
||||||
|
const claims = JSON.parse(atob(payload))
|
||||||
|
return claims.preferred_username || claims.email || claims.sub || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeTokens(tokens: TokenResponse) {
|
||||||
|
state.accessToken = tokens.access_token
|
||||||
|
sessionStorage.setItem(SESSION_KEY, tokens.access_token)
|
||||||
|
|
||||||
|
if (tokens.refresh_token) {
|
||||||
|
state.refreshToken = tokens.refresh_token
|
||||||
|
localStorage.setItem(REFRESH_KEY, tokens.refresh_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
return state.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser(): string | null {
|
||||||
|
return state.user
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOIDCAvailable(): boolean {
|
||||||
|
return !!(state.accessToken || state.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureToken(): Promise<string | null> {
|
||||||
|
if (state.accessToken) return state.accessToken
|
||||||
|
|
||||||
|
if (state.refreshToken) {
|
||||||
|
return refreshAccessToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
if (state.refreshing) return state.refreshing
|
||||||
|
|
||||||
|
const cfg = state.config ?? await fetchConfig()
|
||||||
|
if (!cfg || !state.refreshToken) return null
|
||||||
|
|
||||||
|
state.refreshing = (async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(apiBase('/api/v1/auth/oidc-token'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: state.refreshToken
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
clearTokens()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: TokenResponse = await resp.json()
|
||||||
|
if (!tokens.access_token) {
|
||||||
|
clearTokens()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
storeTokens(tokens)
|
||||||
|
return tokens.access_token
|
||||||
|
} catch {
|
||||||
|
clearTokens()
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
state.refreshing = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return state.refreshing
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTokens() {
|
||||||
|
state.accessToken = null
|
||||||
|
state.refreshToken = null
|
||||||
|
state.user = null
|
||||||
|
sessionStorage.removeItem(SESSION_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): void {
|
||||||
|
clearTokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOIDCConfigured(): boolean {
|
||||||
|
return !!(state.accessToken || state.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initOIDC(): Promise<boolean> {
|
||||||
|
if (state.accessToken) return true
|
||||||
|
|
||||||
|
if (state.refreshToken) {
|
||||||
|
const token = await refreshAccessToken()
|
||||||
|
return token !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPendingCallback(): boolean {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
return params.has('code') && params.has('state')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processPendingCallback(): Promise<boolean> {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const code = params.get('code')
|
||||||
|
const oidcState = params.get('state')
|
||||||
|
|
||||||
|
if (!code || !oidcState) return false
|
||||||
|
|
||||||
|
const ok = await handleCallback(code, oidcState)
|
||||||
|
|
||||||
|
const url = new URL(location.href)
|
||||||
|
url.searchParams.delete('code')
|
||||||
|
url.searchParams.delete('state')
|
||||||
|
history.replaceState(null, '', url.toString())
|
||||||
|
|
||||||
|
return ok
|
||||||
|
}
|
||||||
70
web/src/lib/renderers/BlastRadius.svelte
Normal file
70
web/src/lib/renderers/BlastRadius.svelte
Normal 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}
|
||||||
92
web/src/lib/renderers/ChangeLog.svelte
Normal file
92
web/src/lib/renderers/ChangeLog.svelte
Normal 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}
|
||||||
118
web/src/lib/renderers/EntityCard.svelte
Normal file
118
web/src/lib/renderers/EntityCard.svelte
Normal 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}
|
||||||
60
web/src/lib/renderers/EntityTable.svelte
Normal file
60
web/src/lib/renderers/EntityTable.svelte
Normal 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}
|
||||||
93
web/src/lib/renderers/FleetSnapshot.svelte
Normal file
93
web/src/lib/renderers/FleetSnapshot.svelte
Normal 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}
|
||||||
82
web/src/lib/renderers/HealthSummary.svelte
Normal file
82
web/src/lib/renderers/HealthSummary.svelte
Normal 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}
|
||||||
70
web/src/lib/renderers/KnowledgeResults.svelte
Normal file
70
web/src/lib/renderers/KnowledgeResults.svelte
Normal 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}
|
||||||
85
web/src/lib/renderers/LXCList.svelte
Normal file
85
web/src/lib/renderers/LXCList.svelte
Normal 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}
|
||||||
68
web/src/lib/renderers/MetricChart.svelte
Normal file
68
web/src/lib/renderers/MetricChart.svelte
Normal 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}
|
||||||
9
web/src/lib/renderers/blast-radius.ts
Normal file
9
web/src/lib/renderers/blast-radius.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
12
web/src/lib/renderers/change-log.ts
Normal file
12
web/src/lib/renderers/change-log.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
11
web/src/lib/renderers/entity-card.ts
Normal file
11
web/src/lib/renderers/entity-card.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/entity-table.ts
Normal file
9
web/src/lib/renderers/entity-table.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/fleet-snapshot.ts
Normal file
9
web/src/lib/renderers/fleet-snapshot.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/health-summary.ts
Normal file
9
web/src/lib/renderers/health-summary.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
19
web/src/lib/renderers/index.ts
Normal file
19
web/src/lib/renderers/index.ts
Normal 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()
|
||||||
12
web/src/lib/renderers/knowledge-results.ts
Normal file
12
web/src/lib/renderers/knowledge-results.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/lxc-list.ts
Normal file
9
web/src/lib/renderers/lxc-list.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
9
web/src/lib/renderers/metric-chart.ts
Normal file
9
web/src/lib/renderers/metric-chart.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
17
web/src/lib/tool-renderers.ts
Normal file
17
web/src/lib/tool-renderers.ts
Normal 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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Label } from '$lib/components/ui/label'
|
import { Label } from '$lib/components/ui/label'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
|
||||||
|
import { startLogin, logout as oidcLogout, getUser, isOIDCConfigured } from '$lib/oidc'
|
||||||
|
|
||||||
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
|
||||||
|
|
||||||
@@ -13,12 +14,18 @@
|
|||||||
let token = $state(existing.token ?? '')
|
let token = $state(existing.token ?? '')
|
||||||
let connecting = $state(false)
|
let connecting = $state(false)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
let oidcLoggingIn = $state(false)
|
||||||
|
let oidcUser = $state(getUser())
|
||||||
|
let oidcConfigured = $state(isOIDCConfigured())
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
clearConfig()
|
clearConfig()
|
||||||
|
oidcLogout()
|
||||||
apiUrl = ''
|
apiUrl = ''
|
||||||
token = ''
|
token = ''
|
||||||
error = ''
|
error = ''
|
||||||
|
oidcUser = null
|
||||||
|
oidcConfigured = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
@@ -36,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'
|
||||||
@@ -43,6 +51,33 @@
|
|||||||
connecting = false
|
connecting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
error = ''
|
||||||
|
oidcLoggingIn = true
|
||||||
|
try {
|
||||||
|
await startLogin()
|
||||||
|
} catch (e: any) {
|
||||||
|
error = e.message || 'OIDC login failed'
|
||||||
|
oidcLoggingIn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoutOIDC() {
|
||||||
|
oidcLogout()
|
||||||
|
oidcUser = null
|
||||||
|
oidcConfigured = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-svh items-center justify-center p-6">
|
<div class="flex h-svh items-center justify-center p-6">
|
||||||
@@ -52,10 +87,10 @@
|
|||||||
<Card.Description>Enter the server URL and your access token.</Card.Description>
|
<Card.Description>Enter the server URL and your access token.</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<Tabs.Root value="token">
|
<Tabs.Root value={oidcConfigured ? 'oidc' : 'token'}>
|
||||||
<Tabs.List class="mb-4 grid w-full grid-cols-2">
|
<Tabs.List class="mb-4 grid w-full grid-cols-2">
|
||||||
<Tabs.Trigger value="token">Token</Tabs.Trigger>
|
<Tabs.Trigger value="token">Token</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="oidc" disabled>Login with Authentik (coming soon)</Tabs.Trigger>
|
<Tabs.Trigger value="oidc">Login with Authentik</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content value="token">
|
<Tabs.Content value="token">
|
||||||
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
|
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
|
||||||
@@ -90,6 +125,36 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
<Tabs.Content value="oidc">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#if oidcConfigured && oidcUser}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Logged in as <span class="font-medium text-foreground">{oidcUser}</span>
|
||||||
|
</p>
|
||||||
|
<Button type="button" variant="default" onclick={() => onConnected()}>
|
||||||
|
Continue to Dashboard
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onclick={logoutOIDC}>
|
||||||
|
Log out
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Sign in with your Authentik account to access the control room.
|
||||||
|
</p>
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-destructive">{error}</p>
|
||||||
|
{/if}
|
||||||
|
<Button type="button" disabled={oidcLoggingIn} onclick={loginWithAuthentik}>
|
||||||
|
{oidcLoggingIn ? 'Redirecting to Authentik…' : 'Login with Authentik'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if existing.token}
|
||||||
|
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
|
||||||
|
Forget saved connection
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
Reference in New Issue
Block a user