Compare commits
5 Commits
feat/dbml-
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73eb852361 | ||
|
|
a42274a770 | ||
| f0d9c4dc09 | |||
|
|
4bf1c1fe60 | ||
|
|
6c6f4022a0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,3 +31,6 @@ cmd/amcs-server/__debug_*
|
||||
bin/
|
||||
.cache/
|
||||
OB1/
|
||||
ui/node_modules/
|
||||
ui/.svelte-kit/
|
||||
internal/app/ui/dist/
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,3 +1,14 @@
|
||||
FROM node:22-bookworm AS ui-builder
|
||||
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /src/ui
|
||||
|
||||
COPY ui/package.json ui/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY ui/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
FROM golang:1.26.1-bookworm AS builder
|
||||
|
||||
WORKDIR /src
|
||||
@@ -6,6 +17,7 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=ui-builder /src/internal/app/ui/dist ./internal/app/ui/dist
|
||||
|
||||
RUN set -eu; \
|
||||
VERSION_TAG="$(git describe --tags --exact-match 2>/dev/null || echo dev)"; \
|
||||
|
||||
24
Makefile
24
Makefile
@@ -3,6 +3,7 @@ GO_CACHE_DIR := $(CURDIR)/.cache/go-build
|
||||
SERVER_BIN := $(BIN_DIR)/amcs-server
|
||||
CMD_SERVER := ./cmd/amcs-server
|
||||
BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo
|
||||
UI_DIR := $(CURDIR)/ui
|
||||
PATCH_INCREMENT ?= 1
|
||||
VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
|
||||
COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
@@ -11,21 +12,34 @@ RELSPEC ?= $(shell command -v relspec 2>/dev/null || echo $(HOME)/go/bin/relspec
|
||||
SCHEMA_FILES := $(sort $(wildcard schema/*.dbml))
|
||||
MERGE_TARGET_TMP := $(CURDIR)/.cache/schema.merge-target.dbml
|
||||
GENERATED_SCHEMA_MIGRATION := migrations/020_generated_schema.sql
|
||||
PNPM ?= pnpm
|
||||
LDFLAGS := -s -w \
|
||||
-X $(BUILDINFO_PKG).Version=$(VERSION_TAG) \
|
||||
-X $(BUILDINFO_PKG).TagName=$(VERSION_TAG) \
|
||||
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
|
||||
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
|
||||
|
||||
.PHONY: all build clean migrate release-version test generate-migrations check-schema-drift
|
||||
.PHONY: all build clean migrate release-version test generate-migrations check-schema-drift build-cli ui-install ui-build ui-dev ui-check
|
||||
|
||||
all: build
|
||||
|
||||
build:
|
||||
build: ui-build
|
||||
@mkdir -p $(BIN_DIR)
|
||||
go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER)
|
||||
|
||||
test:
|
||||
ui-install:
|
||||
cd $(UI_DIR) && $(PNPM) install --frozen-lockfile
|
||||
|
||||
ui-build: ui-install
|
||||
cd $(UI_DIR) && $(PNPM) run build
|
||||
|
||||
ui-dev: ui-install
|
||||
cd $(UI_DIR) && $(PNPM) run dev
|
||||
|
||||
ui-check: ui-install
|
||||
cd $(UI_DIR) && $(PNPM) run check
|
||||
|
||||
test: ui-check
|
||||
@mkdir -p $(GO_CACHE_DIR)
|
||||
GOCACHE=$(GO_CACHE_DIR) go test ./...
|
||||
|
||||
@@ -78,3 +92,7 @@ check-schema-drift:
|
||||
exit 1; \
|
||||
fi; \
|
||||
rm -f $$tmpfile
|
||||
|
||||
build-cli:
|
||||
@mkdir -p $(BIN_DIR)
|
||||
go build -o $(BIN_DIR)/amcs-cli ./cmd/amcs-cli
|
||||
|
||||
32
README.md
32
README.md
@@ -531,7 +531,37 @@ Run the SQL migrations against a local database with:
|
||||
|
||||
`DATABASE_URL=postgres://... make migrate`
|
||||
|
||||
LLM integration instructions are served at `/llm`.
|
||||
### Backend + embedded UI build
|
||||
|
||||
The web UI now lives in the top-level `ui/` module and is embedded into the Go binary at build time with `go:embed`.
|
||||
|
||||
**Use `pnpm` for all UI work in this repo.**
|
||||
|
||||
- `make build` — runs the real UI build first, then compiles the Go server
|
||||
- `make test` — runs `svelte-check` for the frontend and `go test ./...` for the backend
|
||||
- `make ui-install` — installs frontend dependencies with `pnpm install --frozen-lockfile`
|
||||
- `make ui-build` — builds only the frontend bundle
|
||||
- `make ui-dev` — starts the Vite dev server with hot reload on `http://localhost:5173`
|
||||
- `make ui-check` — runs the frontend type and Svelte checks
|
||||
|
||||
### Local UI workflow
|
||||
|
||||
For the normal production-style local flow:
|
||||
|
||||
1. Start the backend: `./scripts/run-local.sh configs/dev.yaml`
|
||||
2. Open `http://localhost:8080`
|
||||
|
||||
For frontend iteration with hot reload and no Go rebuilds:
|
||||
|
||||
1. Start the backend once: `go run ./cmd/amcs-server --config configs/dev.yaml`
|
||||
2. In another shell start the UI dev server: `make ui-dev`
|
||||
3. Open `http://localhost:5173`
|
||||
|
||||
The Vite dev server proxies backend routes such as `/api/status`, `/llm`, `/healthz`, `/readyz`, `/files`, `/mcp`, and the OAuth endpoints back to the Go server on `http://127.0.0.1:8080` by default. Override that target with `AMCS_UI_BACKEND` if needed.
|
||||
|
||||
The root page (`/`) is now the Svelte frontend. It preserves the existing landing-page content and status information by fetching data from `GET /api/status`.
|
||||
|
||||
LLM integration instructions are still served at `/llm`.
|
||||
|
||||
## Containers
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
||||
mux.HandleFunc("/images/icon.png", serveIcon)
|
||||
mux.HandleFunc("/llm", serveLLMInstructions)
|
||||
mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled))
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,131 +15,33 @@ import (
|
||||
|
||||
const connectedWindow = 10 * time.Minute
|
||||
|
||||
type statusPageData struct {
|
||||
Version string
|
||||
BuildDate string
|
||||
Commit string
|
||||
ConnectedCount int
|
||||
TotalKnown int
|
||||
Entries []auth.AccessSnapshot
|
||||
OAuthEnabled bool
|
||||
type statusAPIResponse struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
BuildDate string `json:"build_date"`
|
||||
Commit string `json:"commit"`
|
||||
ConnectedCount int `json:"connected_count"`
|
||||
TotalKnown int `json:"total_known"`
|
||||
ConnectedWindow string `json:"connected_window"`
|
||||
Entries []auth.AccessSnapshot `json:"entries"`
|
||||
OAuthEnabled bool `json:"oauth_enabled"`
|
||||
}
|
||||
|
||||
func renderHomePage(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) string {
|
||||
func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
|
||||
entries := tracker.Snapshot()
|
||||
data := statusPageData{
|
||||
Version: fallback(info.Version, "dev"),
|
||||
BuildDate: fallback(info.BuildDate, "unknown"),
|
||||
Commit: fallback(info.Commit, "unknown"),
|
||||
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
|
||||
TotalKnown: len(entries),
|
||||
Entries: entries,
|
||||
OAuthEnabled: oauthEnabled,
|
||||
return statusAPIResponse{
|
||||
Title: "Avelon Memory Crystal Server (AMCS)",
|
||||
Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.",
|
||||
Version: fallback(info.Version, "dev"),
|
||||
BuildDate: fallback(info.BuildDate, "unknown"),
|
||||
Commit: fallback(info.Commit, "unknown"),
|
||||
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
|
||||
TotalKnown: len(entries),
|
||||
ConnectedWindow: "last 10 minutes",
|
||||
Entries: entries,
|
||||
OAuthEnabled: oauthEnabled,
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AMCS</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fb; color: #172033; }
|
||||
main { max-width: 980px; margin: 48px auto; background: #fff; border-radius: 12px; box-shadow: 0 10px 28px rgba(23, 32, 51, 0.12); overflow: hidden; }
|
||||
.content { padding: 28px; }
|
||||
h1, h2 { margin: 0 0 12px 0; }
|
||||
p { margin: 0; line-height: 1.5; color: #334155; }
|
||||
.actions { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.link { display: inline-block; padding: 10px 14px; border-radius: 8px; background: #172033; color: #fff; text-decoration: none; font-weight: 600; }
|
||||
.link:hover { background: #0f172a; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-top: 24px; }
|
||||
.card { background: #eef2ff; border-radius: 10px; padding: 16px; }
|
||||
.label { display: block; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; color: #475569; }
|
||||
.value { display: block; margin-top: 6px; font-size: 1.4rem; font-weight: 700; color: #0f172a; }
|
||||
.meta { margin-top: 28px; color: #475569; font-size: 0.95rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
||||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #e2e8f0; vertical-align: top; }
|
||||
th { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; color: #475569; }
|
||||
.empty { margin-top: 16px; color: #64748b; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
img { display: block; width: 100%; height: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img src="/images/project.jpg" alt="Avelon Memory Crystal project image">
|
||||
<div class="content">
|
||||
<h1>Avelon Memory Crystal Server (AMCS)</h1>
|
||||
<p>AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.</p>
|
||||
<div class="actions">
|
||||
<a class="link" href="/llm">LLM Instructions</a>
|
||||
<a class="link" href="/healthz">Health Check</a>
|
||||
<a class="link" href="/readyz">Readiness Check</a>`)
|
||||
if data.OAuthEnabled {
|
||||
b.WriteString(`
|
||||
<a class="link" href="/oauth-authorization-server">OAuth Authorization Server</a>`)
|
||||
}
|
||||
b.WriteString(`
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="card">
|
||||
<span class="label">Connected users</span>
|
||||
<span class="value">` + fmt.Sprintf("%d", data.ConnectedCount) + `</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="label">Known principals</span>
|
||||
<span class="value">` + fmt.Sprintf("%d", data.TotalKnown) + `</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="label">Version</span>
|
||||
<span class="value">` + html.EscapeString(data.Version) + `</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<strong>Build date:</strong> ` + html.EscapeString(data.BuildDate) + ` •
|
||||
<strong>Commit:</strong> <code>` + html.EscapeString(data.Commit) + `</code> •
|
||||
<strong>Connected window:</strong> last 10 minutes
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top: 28px;">Recent access</h2>`)
|
||||
if len(data.Entries) == 0 {
|
||||
b.WriteString(`
|
||||
<p class="empty">No authenticated access recorded yet.</p>`)
|
||||
} else {
|
||||
b.WriteString(`
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Principal</th>
|
||||
<th>Last accessed</th>
|
||||
<th>Last path</th>
|
||||
<th>Requests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`)
|
||||
for _, entry := range data.Entries {
|
||||
b.WriteString(`
|
||||
<tr>
|
||||
<td><code>` + html.EscapeString(entry.KeyID) + `</code></td>
|
||||
<td>` + html.EscapeString(entry.LastAccessedAt.UTC().Format(time.RFC3339)) + `</td>
|
||||
<td>` + html.EscapeString(entry.LastPath) + `</td>
|
||||
<td>` + fmt.Sprintf("%d", entry.RequestCount) + `</td>
|
||||
</tr>`)
|
||||
}
|
||||
b.WriteString(`
|
||||
</tbody>
|
||||
</table>`)
|
||||
}
|
||||
b.WriteString(`
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func fallback(value, defaultValue string) string {
|
||||
@@ -147,25 +51,90 @@ func fallback(value, defaultValue string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func homeHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
|
||||
func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
if r.URL.Path != "/api/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(renderHomePage(info, tracker, oauthEnabled, time.Now())))
|
||||
_ = json.NewEncoder(w).Encode(statusSnapshot(info, tracker, oauthEnabled, time.Now()))
|
||||
}
|
||||
}
|
||||
|
||||
func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
requestPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if requestPath == "." {
|
||||
requestPath = ""
|
||||
}
|
||||
|
||||
if requestPath != "" {
|
||||
if serveUIAsset(w, r, requestPath) {
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
serveUIIndex(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func serveUIAsset(w http.ResponseWriter, r *http.Request, name string) bool {
|
||||
if uiDistFS == nil {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return false
|
||||
}
|
||||
file, err := uiDistFS.Open(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
|
||||
data, err := fs.ReadFile(uiDistFS, name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, info.Name(), info.ModTime(), bytes.NewReader(data))
|
||||
return true
|
||||
}
|
||||
|
||||
func serveUIIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if indexHTML == nil {
|
||||
http.Error(w, "ui assets not built", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(indexHTML)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -14,29 +15,62 @@ import (
|
||||
"git.warky.dev/wdevs/amcs/internal/config"
|
||||
)
|
||||
|
||||
func TestRenderHomePageHidesOAuthLinkWhenDisabled(t *testing.T) {
|
||||
func TestStatusSnapshotHidesOAuthLinkWhenDisabled(t *testing.T) {
|
||||
tracker := auth.NewAccessTracker()
|
||||
page := renderHomePage(buildinfo.Info{Version: "v1.2.3", BuildDate: "2026-04-04", Commit: "abc123"}, tracker, false, time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC))
|
||||
snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3", BuildDate: "2026-04-04", Commit: "abc123"}, tracker, false, time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC))
|
||||
|
||||
if strings.Contains(page, "/oauth-authorization-server") {
|
||||
t.Fatal("page unexpectedly contains OAuth link")
|
||||
if snapshot.OAuthEnabled {
|
||||
t.Fatal("OAuthEnabled = true, want false")
|
||||
}
|
||||
if !strings.Contains(page, "Connected users") {
|
||||
t.Fatal("page missing Connected users stat")
|
||||
if snapshot.ConnectedCount != 0 {
|
||||
t.Fatalf("ConnectedCount = %d, want 0", snapshot.ConnectedCount)
|
||||
}
|
||||
if snapshot.Title == "" {
|
||||
t.Fatal("Title = empty, want non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHomePageShowsTrackedAccess(t *testing.T) {
|
||||
func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
||||
tracker := auth.NewAccessTracker()
|
||||
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
|
||||
tracker.Record("client-a", "/files", "127.0.0.1:1234", "tester", now)
|
||||
|
||||
page := renderHomePage(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now)
|
||||
snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now)
|
||||
|
||||
for _, needle := range []string{"client-a", "/files", "1</span>", "/oauth-authorization-server"} {
|
||||
if !strings.Contains(page, needle) {
|
||||
t.Fatalf("page missing %q", needle)
|
||||
}
|
||||
if !snapshot.OAuthEnabled {
|
||||
t.Fatal("OAuthEnabled = false, want true")
|
||||
}
|
||||
if snapshot.ConnectedCount != 1 {
|
||||
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
|
||||
}
|
||||
if len(snapshot.Entries) != 1 {
|
||||
t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries))
|
||||
}
|
||||
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" {
|
||||
t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
||||
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
|
||||
t.Fatalf("content-type = %q, want application/json", got)
|
||||
}
|
||||
|
||||
var payload statusAPIResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.Version != "v1" {
|
||||
t.Fatalf("version = %q, want %q", payload.Version, "v1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +89,21 @@ func TestHomeHandlerAllowsHead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeHandlerServesIndex(t *testing.T) {
|
||||
handler := homeHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), false)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<div id=\"app\"></div>") {
|
||||
t.Fatalf("body = %q, want embedded UI index", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareRecordsAuthenticatedAccess(t *testing.T) {
|
||||
keyring, err := auth.NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
|
||||
if err != nil {
|
||||
|
||||
22
internal/app/ui_assets.go
Normal file
22
internal/app/ui_assets.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed ui/dist
|
||||
uiFiles embed.FS
|
||||
uiDistFS fs.FS
|
||||
indexHTML []byte
|
||||
)
|
||||
|
||||
func init() {
|
||||
dist, err := fs.Sub(uiFiles, "ui/dist")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
uiDistFS = dist
|
||||
indexHTML, _ = fs.ReadFile(uiDistFS, "index.html")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
# Schema workflow
|
||||
|
||||
The `schema/*.dbml` files are the database schema source of truth.
|
||||
|
||||
## Generate SQL migrations
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
make generate-migrations
|
||||
```
|
||||
|
||||
This uses `relspec` to convert the DBML files into PostgreSQL SQL and writes the generated schema migration to:
|
||||
|
||||
- `migrations/020_generated_schema.sql`
|
||||
|
||||
## Check schema drift
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
make check-schema-drift
|
||||
```
|
||||
|
||||
This regenerates the SQL from `schema/*.dbml` and compares it with `migrations/020_generated_schema.sql`.
|
||||
If the generated output differs, the command fails so CI can catch schema drift.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Update the DBML files in `schema/`
|
||||
2. Run `make generate-migrations`
|
||||
3. Review the generated SQL
|
||||
4. Commit both the DBML changes and the generated migration
|
||||
|
||||
Existing handwritten migrations stay in place. Going forward, update the DBML first and regenerate the SQL from there.
|
||||
@@ -1,44 +0,0 @@
|
||||
Table family_members {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
relationship text
|
||||
birth_date date
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
Table activities {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
family_member_id uuid [ref: > family_members.id]
|
||||
title text [not null]
|
||||
activity_type text
|
||||
day_of_week text
|
||||
start_time time
|
||||
end_time time
|
||||
start_date date
|
||||
end_date date
|
||||
location text
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
day_of_week
|
||||
family_member_id
|
||||
(start_date, end_date)
|
||||
}
|
||||
}
|
||||
|
||||
Table important_dates {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
family_member_id uuid [ref: > family_members.id]
|
||||
title text [not null]
|
||||
date_value date [not null]
|
||||
recurring_yearly boolean [not null, default: false]
|
||||
reminder_days_before int [not null, default: 7]
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
date_value
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
Table thoughts {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
content text [not null]
|
||||
metadata jsonb [default: `'{}'::jsonb`]
|
||||
created_at timestamptz [default: `now()`]
|
||||
updated_at timestamptz [default: `now()`]
|
||||
project_id uuid [ref: > projects.guid]
|
||||
archived_at timestamptz
|
||||
}
|
||||
|
||||
Table projects {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
name text [unique, not null]
|
||||
description text
|
||||
created_at timestamptz [default: `now()`]
|
||||
last_active_at timestamptz [default: `now()`]
|
||||
}
|
||||
|
||||
Table thought_links {
|
||||
from_id bigint [not null, ref: > thoughts.id]
|
||||
to_id bigint [not null, ref: > thoughts.id]
|
||||
relation text [not null]
|
||||
created_at timestamptz [default: `now()`]
|
||||
|
||||
indexes {
|
||||
(from_id, to_id, relation) [pk]
|
||||
from_id
|
||||
to_id
|
||||
}
|
||||
}
|
||||
|
||||
Table embeddings {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
thought_id uuid [not null, ref: > thoughts.guid]
|
||||
model text [not null]
|
||||
dim int [not null]
|
||||
embedding vector [not null]
|
||||
created_at timestamptz [default: `now()`]
|
||||
updated_at timestamptz [default: `now()`]
|
||||
|
||||
indexes {
|
||||
(thought_id, model) [unique]
|
||||
thought_id
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
Table professional_contacts {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
company text
|
||||
title text
|
||||
email text
|
||||
phone text
|
||||
linkedin_url text
|
||||
how_we_met text
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
notes text
|
||||
last_contacted timestamptz
|
||||
follow_up_date date
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
last_contacted
|
||||
follow_up_date
|
||||
}
|
||||
}
|
||||
|
||||
Table contact_interactions {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
contact_id uuid [not null, ref: > professional_contacts.id]
|
||||
interaction_type text [not null]
|
||||
occurred_at timestamptz [not null, default: `now()`]
|
||||
summary text [not null]
|
||||
follow_up_needed boolean [not null, default: false]
|
||||
follow_up_notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(contact_id, occurred_at)
|
||||
}
|
||||
}
|
||||
|
||||
Table opportunities {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
contact_id uuid [ref: > professional_contacts.id]
|
||||
title text [not null]
|
||||
description text
|
||||
stage text [not null, default: 'identified']
|
||||
value "decimal(12,2)"
|
||||
expected_close_date date
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
stage
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
Table stored_files {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
thought_id uuid [ref: > thoughts.guid]
|
||||
project_id uuid [ref: > projects.guid]
|
||||
name text [not null]
|
||||
media_type text [not null]
|
||||
kind text [not null, default: 'file']
|
||||
encoding text [not null, default: 'base64']
|
||||
size_bytes bigint [not null]
|
||||
sha256 text [not null]
|
||||
content bytea [not null]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
thought_id
|
||||
project_id
|
||||
sha256
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-file refs (for relspecgo merge)
|
||||
Ref: stored_files.thought_id > thoughts.guid [delete: set null]
|
||||
Ref: stored_files.project_id > projects.guid [delete: set null]
|
||||
@@ -1,31 +0,0 @@
|
||||
Table household_items {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
category text
|
||||
location text
|
||||
details jsonb [not null, default: `'{}'`]
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
category
|
||||
}
|
||||
}
|
||||
|
||||
Table household_vendors {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
service_type text
|
||||
phone text
|
||||
email text
|
||||
website text
|
||||
notes text
|
||||
rating int
|
||||
last_used date
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
service_type
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
Table maintenance_tasks {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
category text
|
||||
frequency_days int
|
||||
last_completed timestamptz
|
||||
next_due timestamptz
|
||||
priority text [not null, default: 'medium']
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
next_due
|
||||
}
|
||||
}
|
||||
|
||||
Table maintenance_logs {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
task_id uuid [not null, ref: > maintenance_tasks.id]
|
||||
completed_at timestamptz [not null, default: `now()`]
|
||||
performed_by text
|
||||
cost "decimal(10,2)"
|
||||
notes text
|
||||
next_action text
|
||||
|
||||
indexes {
|
||||
(task_id, completed_at)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
Table recipes {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
cuisine text
|
||||
prep_time_minutes int
|
||||
cook_time_minutes int
|
||||
servings int
|
||||
ingredients jsonb [not null, default: `'[]'`]
|
||||
instructions jsonb [not null, default: `'[]'`]
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
rating int
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
cuisine
|
||||
tags
|
||||
}
|
||||
}
|
||||
|
||||
Table meal_plans {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
week_start date [not null]
|
||||
day_of_week text [not null]
|
||||
meal_type text [not null]
|
||||
recipe_id uuid [ref: > recipes.id]
|
||||
custom_meal text
|
||||
servings int
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
week_start
|
||||
}
|
||||
}
|
||||
|
||||
Table shopping_lists {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
week_start date [unique, not null]
|
||||
items jsonb [not null, default: `'[]'`]
|
||||
notes text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
week_start
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
Table chat_histories {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
session_id text [not null]
|
||||
title text
|
||||
channel text
|
||||
agent_id text
|
||||
project_id uuid [ref: > projects.guid]
|
||||
messages jsonb [not null, default: `'[]'`]
|
||||
summary text
|
||||
metadata jsonb [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
session_id
|
||||
project_id
|
||||
channel
|
||||
agent_id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
|
||||
Table tool_annotations {
|
||||
id bigserial [pk]
|
||||
tool_name text [unique, not null]
|
||||
notes text [not null, default: '']
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
// Cross-file refs (for relspecgo merge)
|
||||
Ref: chat_histories.project_id > projects.guid [delete: set null]
|
||||
@@ -1,46 +0,0 @@
|
||||
Table agent_skills {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [unique, not null]
|
||||
description text [not null, default: '']
|
||||
content text [not null]
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
Table agent_guardrails {
|
||||
id uuid [pk, default: `gen_random_uuid()`]
|
||||
name text [unique, not null]
|
||||
description text [not null, default: '']
|
||||
content text [not null]
|
||||
severity text [not null, default: 'medium']
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
Table project_skills {
|
||||
project_id uuid [not null, ref: > projects.guid]
|
||||
skill_id uuid [not null, ref: > agent_skills.id]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(project_id, skill_id) [pk]
|
||||
project_id
|
||||
}
|
||||
}
|
||||
|
||||
Table project_guardrails {
|
||||
project_id uuid [not null, ref: > projects.guid]
|
||||
guardrail_id uuid [not null, ref: > agent_guardrails.id]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(project_id, guardrail_id) [pk]
|
||||
project_id
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-file refs (for relspecgo merge)
|
||||
Ref: project_skills.project_id > projects.guid [delete: cascade]
|
||||
Ref: project_guardrails.project_id > projects.guid [delete: cascade]
|
||||
@@ -2,4 +2,11 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
go run ./cmd/amcs-server --config "${1:-configs/dev.yaml}"
|
||||
CONFIG_PATH="${1:-configs/dev.yaml}"
|
||||
|
||||
if [[ ! -f internal/app/ui/dist/index.html ]]; then
|
||||
echo "UI build not found; building frontend first..."
|
||||
make ui-build
|
||||
fi
|
||||
|
||||
go run ./cmd/amcs-server --config "$CONFIG_PATH"
|
||||
|
||||
16
ui/index.html
Normal file
16
ui/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AMCS</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-slate-950">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
ui/package.json
Normal file
23
ui/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "amcs-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/node": "^24.5.2",
|
||||
"svelte": "^5.28.2",
|
||||
"svelte-check": "^4.1.6",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.2"
|
||||
}
|
||||
}
|
||||
1291
ui/pnpm-lock.yaml
generated
Normal file
1291
ui/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
257
ui/src/App.svelte
Normal file
257
ui/src/App.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
type AccessEntry = {
|
||||
key_id: string;
|
||||
last_accessed_at: string;
|
||||
last_path: string;
|
||||
request_count: number;
|
||||
};
|
||||
|
||||
type StatusResponse = {
|
||||
title: string;
|
||||
description: string;
|
||||
version: string;
|
||||
build_date: string;
|
||||
commit: string;
|
||||
connected_count: number;
|
||||
total_known: number;
|
||||
connected_window: string;
|
||||
oauth_enabled: boolean;
|
||||
entries: AccessEntry[];
|
||||
};
|
||||
|
||||
let data: StatusResponse | null = null;
|
||||
let loading = true;
|
||||
let error = "";
|
||||
|
||||
const quickLinks = [
|
||||
{ href: "/llm", label: "LLM Instructions" },
|
||||
{ href: "/healthz", label: "Health Check" },
|
||||
{ href: "/readyz", label: "Readiness Check" },
|
||||
];
|
||||
|
||||
async function loadStatus() {
|
||||
loading = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/status");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status request failed with ${response.status}`);
|
||||
}
|
||||
data = (await response.json()) as StatusResponse;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load status";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
onMount(loadStatus);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AMCS</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<main
|
||||
class="mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8"
|
||||
>
|
||||
<section
|
||||
class="overflow-hidden rounded-3xl border border-white/10 bg-slate-900 shadow-2xl shadow-slate-950/40"
|
||||
>
|
||||
<img
|
||||
src="/images/project.jpg"
|
||||
alt="Avelon Memory Crystal"
|
||||
class="h-64 w-full object-cover object-center sm:h-80"
|
||||
/>
|
||||
|
||||
<div class="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1.6fr_1fr] lg:p-10">
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-sm font-medium text-cyan-200"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||
Avalon Memory Crystal Server
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-semibold tracking-tight text-white sm:text-4xl"
|
||||
>
|
||||
Avelon Memory Crystal Server (AMCS)
|
||||
</h1>
|
||||
<p
|
||||
class="mt-3 max-w-3xl text-base leading-7 text-slate-300 sm:text-lg"
|
||||
>
|
||||
{data?.description ??
|
||||
"AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each quickLinks as link}
|
||||
<a
|
||||
class="inline-flex items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20"
|
||||
href={link.href}>{link.label}</a
|
||||
>
|
||||
{/each}
|
||||
{#if data?.oauth_enabled}
|
||||
<a
|
||||
class="inline-flex items-center justify-center rounded-xl border border-violet-300/20 bg-violet-400/10 px-4 py-2 text-sm font-semibold text-violet-100 transition hover:border-violet-300/40 hover:bg-violet-400/20"
|
||||
href="/oauth-authorization-server">OAuth Authorization Server</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||
Connected users
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">
|
||||
{data?.connected_count ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||
Known principals
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">
|
||||
{data?.total_known ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||
Version
|
||||
</p>
|
||||
<p class="mt-2 break-all text-2xl font-semibold text-white">
|
||||
{data?.version ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
class="space-y-4 rounded-2xl border border-white/10 bg-slate-950/50 p-5"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Build details</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">The same status info.</p>
|
||||
</div>
|
||||
<dl class="space-y-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<dt class="text-slate-500">Build date</dt>
|
||||
<dd class="mt-1 font-medium text-white">
|
||||
{data?.build_date ?? "unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Commit</dt>
|
||||
<dd
|
||||
class="mt-1 break-all rounded-lg bg-white/5 px-3 py-2 font-mono text-xs text-cyan-100"
|
||||
>
|
||||
{data?.commit ?? "unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Connected window</dt>
|
||||
<dd class="mt-1 font-medium text-white">
|
||||
{data?.connected_window ?? "last 10 minutes"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mt-6 rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Recent access</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
Authenticated principals AMCS has seen recently.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition hover:bg-white/10"
|
||||
on:click={loadStatus}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div
|
||||
class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400"
|
||||
>
|
||||
Loading status…
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="mt-6 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-6 text-sm text-rose-100"
|
||||
>
|
||||
<p class="font-semibold">Couldn’t load the status snapshot.</p>
|
||||
<p class="mt-1 text-rose-100/80">{error}</p>
|
||||
</div>
|
||||
{:else if data && data.entries.length === 0}
|
||||
<div
|
||||
class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400"
|
||||
>
|
||||
No authenticated access recorded yet.
|
||||
</div>
|
||||
{:else if data}
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300"
|
||||
>
|
||||
<thead
|
||||
class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Principal</th>
|
||||
<th class="px-4 py-3 font-medium">Last accessed</th>
|
||||
<th class="px-4 py-3 font-medium">Last path</th>
|
||||
<th class="px-4 py-3 font-medium">Requests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each data.entries as entry}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="px-4 py-3 align-top"
|
||||
><code
|
||||
class="rounded bg-white/5 px-2 py-1 font-mono text-xs text-cyan-100"
|
||||
>{entry.key_id}</code
|
||||
></td
|
||||
>
|
||||
<td class="px-4 py-3 align-top text-slate-200"
|
||||
>{formatDate(entry.last_accessed_at)}</td
|
||||
>
|
||||
<td class="px-4 py-3 align-top"
|
||||
><code class="text-slate-100">{entry.last_path}</code></td
|
||||
>
|
||||
<td class="px-4 py-3 align-top font-semibold text-white"
|
||||
>{entry.request_count}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
16
ui/src/app.css
Normal file
16
ui/src/app.css
Normal file
@@ -0,0 +1,16 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
9
ui/src/main.ts
Normal file
9
ui/src/main.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
import { mount } from 'svelte';
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!
|
||||
});
|
||||
|
||||
export default app;
|
||||
5
ui/svelte.config.js
Normal file
5
ui/svelte.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
compilerOptions: {
|
||||
dev: process.env.NODE_ENV !== 'production'
|
||||
}
|
||||
};
|
||||
15
ui/tsconfig.json
Normal file
15
ui/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ES2022",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"verbatimModuleSyntax": true,
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"types": ["svelte", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "vite.config.ts"]
|
||||
}
|
||||
8
ui/tsconfig.node.json
Normal file
8
ui/tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
}
|
||||
}
|
||||
31
ui/vite.config.ts
Normal file
31
ui/vite.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
const backendTarget = process.env.AMCS_UI_BACKEND ?? 'http://127.0.0.1:8080';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), tailwindcss()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': backendTarget,
|
||||
'/healthz': backendTarget,
|
||||
'/readyz': backendTarget,
|
||||
'/llm': backendTarget,
|
||||
'/images': backendTarget,
|
||||
'/favicon.ico': backendTarget,
|
||||
'/mcp': backendTarget,
|
||||
'/files': backendTarget,
|
||||
'/oauth-authorization-server': backendTarget,
|
||||
'/authorize': backendTarget,
|
||||
'/oauth': backendTarget,
|
||||
'/.well-known': backendTarget
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: '../internal/app/ui/dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user