feat: add embedded svelte frontend
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user