feat: add embedded svelte frontend

This commit is contained in:
Jack O'Neill
2026-04-05 09:40:38 +02:00
parent f0e242293f
commit 6c6f4022a0
22 changed files with 2757 additions and 145 deletions

View File

@@ -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)

View File

@@ -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) + ` &nbsp;•&nbsp;
<strong>Commit:</strong> <code>` + html.EscapeString(data.Commit) + `</code> &nbsp;•&nbsp;
<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)
}

View File

@@ -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 {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

17
internal/app/ui/dist/index.html vendored Normal file
View File

@@ -0,0 +1,17 @@
<!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."
/>
<script type="module" crossorigin src="/assets/index-CIozj4p7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BDhMN0_9.css">
</head>
<body class="bg-slate-950">
<div id="app"></div>
</body>
</html>

22
internal/app/ui_assets.go Normal file
View 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")
}