feat(ui): implement public status endpoint and update UI components
Some checks failed
CI / build-and-test (push) Failing after -30m49s
Some checks failed
CI / build-and-test (push) Failing after -30m49s
* add public status handler and response types * modify status API to restrict access and update client tracking * adjust UI components to display public status information * update routing to include public status endpoint
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,5 +33,6 @@ bin/
|
|||||||
OB1/
|
OB1/
|
||||||
ui/node_modules/
|
ui/node_modules/
|
||||||
ui/.svelte-kit/
|
ui/.svelte-kit/
|
||||||
internal/app/ui/dist/
|
internal/app/ui/dist/*
|
||||||
|
!internal/app/ui/dist/placeholder.txt
|
||||||
.codex
|
.codex
|
||||||
|
|||||||
@@ -246,8 +246,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
mux.HandleFunc("/llms.txt", serveLLMSTXT)
|
mux.HandleFunc("/llms.txt", serveLLMSTXT)
|
||||||
mux.HandleFunc("/.well-known/llms.txt", serveLLMSTXT)
|
mux.HandleFunc("/.well-known/llms.txt", serveLLMSTXT)
|
||||||
mux.HandleFunc("/robots.txt", serveRobotsTXT)
|
mux.HandleFunc("/robots.txt", serveRobotsTXT)
|
||||||
mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled))
|
mux.Handle("/api/status", authMiddleware(statusAPIHandler(info, accessTracker, oauthEnabled)))
|
||||||
mux.HandleFunc("/status", statusAPIHandler(info, accessTracker, oauthEnabled))
|
mux.HandleFunc("/status", publicStatusHandler(accessTracker))
|
||||||
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -29,8 +29,24 @@ type statusAPIResponse struct {
|
|||||||
OAuthEnabled bool `json:"oauth_enabled"`
|
OAuthEnabled bool `json:"oauth_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type publicClientStatus struct {
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
RequestCount int `json:"request_count"`
|
||||||
|
LastAccessedAt time.Time `json:"last_accessed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type publicStatusResponse struct {
|
||||||
|
ConnectedCount int `json:"connected_count"`
|
||||||
|
ConnectedWindow string `json:"connected_window"`
|
||||||
|
Entries []publicClientStatus `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
|
func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
|
||||||
entries := tracker.Snapshot()
|
entries := tracker.Snapshot()
|
||||||
|
metrics := tracker.Metrics(20)
|
||||||
|
metrics.TopIPs = nil
|
||||||
|
metrics.TopAgents = nil
|
||||||
|
metrics.TopTools = nil
|
||||||
return statusAPIResponse{
|
return statusAPIResponse{
|
||||||
Title: "Avelon Memory Crystal Server (AMCS)",
|
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.",
|
Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.",
|
||||||
@@ -40,8 +56,8 @@ func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabl
|
|||||||
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
|
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
|
||||||
TotalKnown: len(entries),
|
TotalKnown: len(entries),
|
||||||
ConnectedWindow: "last 10 minutes",
|
ConnectedWindow: "last 10 minutes",
|
||||||
Entries: entries,
|
Entries: nil,
|
||||||
Metrics: tracker.Metrics(20),
|
Metrics: metrics,
|
||||||
OAuthEnabled: oauthEnabled,
|
OAuthEnabled: oauthEnabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,7 +71,7 @@ func fallback(value, defaultValue string) string {
|
|||||||
|
|
||||||
func statusAPIHandler(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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/api/status" && r.URL.Path != "/status" {
|
if r.URL.Path != "/api/status" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -75,6 +91,47 @@ func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEna
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publicStatusHandler(tracker *auth.AccessTracker) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/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
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.UTC().Add(-connectedWindow)
|
||||||
|
snapshot := tracker.Snapshot()
|
||||||
|
entries := make([]publicClientStatus, 0, len(snapshot))
|
||||||
|
for _, item := range snapshot {
|
||||||
|
if item.LastAccessedAt.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, publicClientStatus{
|
||||||
|
KeyID: item.KeyID,
|
||||||
|
RequestCount: item.RequestCount,
|
||||||
|
LastAccessedAt: item.LastAccessedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(publicStatusResponse{
|
||||||
|
ConnectedCount: len(entries),
|
||||||
|
ConnectedWindow: "last 10 minutes",
|
||||||
|
Entries: entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
|
func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
|||||||
@@ -43,11 +43,8 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
|||||||
if snapshot.ConnectedCount != 1 {
|
if snapshot.ConnectedCount != 1 {
|
||||||
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
|
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
|
||||||
}
|
}
|
||||||
if len(snapshot.Entries) != 1 {
|
if len(snapshot.Entries) != 0 {
|
||||||
t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries))
|
t.Fatalf("len(Entries) = %d, want 0 for counts-only status", 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])
|
|
||||||
}
|
}
|
||||||
if snapshot.Metrics.TotalRequests != 1 {
|
if snapshot.Metrics.TotalRequests != 1 {
|
||||||
t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests)
|
t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests)
|
||||||
@@ -61,6 +58,9 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
|||||||
if snapshot.Metrics.UniqueTools != 1 {
|
if snapshot.Metrics.UniqueTools != 1 {
|
||||||
t.Fatalf("Metrics.UniqueTools = %d, want 1", snapshot.Metrics.UniqueTools)
|
t.Fatalf("Metrics.UniqueTools = %d, want 1", snapshot.Metrics.UniqueTools)
|
||||||
}
|
}
|
||||||
|
if len(snapshot.Metrics.TopIPs) != 0 || len(snapshot.Metrics.TopAgents) != 0 || len(snapshot.Metrics.TopTools) != 0 {
|
||||||
|
t.Fatalf("Top breakdowns should be hidden in counts-only status: %+v", snapshot.Metrics)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
||||||
@@ -86,23 +86,49 @@ func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusAPIHandlerSupportsStatusPath(t *testing.T) {
|
func TestStatusAPIHandlerRejectsStatusPath(t *testing.T) {
|
||||||
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
|
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/status", nil)
|
req := httptest.NewRequest(http.MethodGet, "/status", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicStatusHandlerReturnsConnectedClientsOnly(t *testing.T) {
|
||||||
|
tracker := auth.NewAccessTracker()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tracker.Record("recent-client", "/mcp", "127.0.0.1:1234", "tester", "list_projects", now.Add(-2*time.Minute))
|
||||||
|
tracker.Record("stale-client", "/mcp", "127.0.0.1:9999", "tester", "list_projects", now.Add(-30*time.Minute))
|
||||||
|
|
||||||
|
handler := publicStatusHandler(tracker)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/status", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload statusAPIResponse
|
var payload publicStatusResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
if payload.Version != "v1" {
|
if payload.ConnectedCount != 1 {
|
||||||
t.Fatalf("version = %q, want %q", payload.Version, "v1")
|
t.Fatalf("ConnectedCount = %d, want 1", payload.ConnectedCount)
|
||||||
|
}
|
||||||
|
if len(payload.Entries) != 1 {
|
||||||
|
t.Fatalf("len(Entries) = %d, want 1", len(payload.Entries))
|
||||||
|
}
|
||||||
|
if payload.Entries[0].KeyID != "recent-client" {
|
||||||
|
t.Fatalf("Entries[0].KeyID = %q, want %q", payload.Entries[0].KeyID, "recent-client")
|
||||||
|
}
|
||||||
|
if payload.Entries[0].LastAccessedAt.Before(now.Add(-11 * time.Minute)) {
|
||||||
|
t.Fatalf("Entries[0].LastAccessedAt = %v, expected recent timestamp", payload.Entries[0].LastAccessedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
internal/app/ui/dist/placeholder.txt
vendored
Normal file
1
internal/app/ui/dist/placeholder.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
placeholder file to keep ui/dist present for go:embed in test environments
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import LoginPage from './components/auth/LoginPage.svelte';
|
import LoginPage from './components/auth/LoginPage.svelte';
|
||||||
import AdminShell from './components/shell/AdminShell.svelte';
|
import AdminShell from './components/shell/AdminShell.svelte';
|
||||||
import type { ShellPage, StatusResponse } from './types';
|
import type { PublicStatusResponse, ShellPage, StatusResponse } from './types';
|
||||||
import { fromStore } from 'svelte/store';
|
import { fromStore } from 'svelte/store';
|
||||||
import {
|
import {
|
||||||
ensureApiURL,
|
ensureApiURL,
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
let data = $state<StatusResponse | null>(null);
|
let data = $state<StatusResponse | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
let publicStatus = $state<PublicStatusResponse | null>(null);
|
||||||
|
let publicStatusLoading = $state(false);
|
||||||
|
let publicStatusError = $state('');
|
||||||
let currentPage = $state<ShellPage>('dashboard');
|
let currentPage = $state<ShellPage>('dashboard');
|
||||||
|
|
||||||
ensureApiURL(import.meta.env.VITE_API_URL);
|
ensureApiURL(import.meta.env.VITE_API_URL);
|
||||||
@@ -57,7 +60,7 @@
|
|||||||
const token = await loginWithCredentials(username, password);
|
const token = await loginWithCredentials(username, password);
|
||||||
await GlobalStateStore.getState().login(token, { username });
|
await GlobalStateStore.getState().login(token, { username });
|
||||||
authMessage = 'Login successful.';
|
authMessage = 'Login successful.';
|
||||||
await loadStatus();
|
await loadDashboardStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authError = err instanceof Error ? err.message : 'Login failed.';
|
authError = err instanceof Error ? err.message : 'Login failed.';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -88,7 +91,7 @@
|
|||||||
|
|
||||||
authMessage = 'OAuth login complete. Welcome back.';
|
authMessage = 'OAuth login complete. Welcome back.';
|
||||||
window.history.replaceState({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
await loadStatus();
|
await loadDashboardStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
authError = err instanceof Error ? err.message : 'OAuth callback failed.';
|
authError = err instanceof Error ? err.message : 'OAuth callback failed.';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -100,14 +103,23 @@
|
|||||||
await GlobalStateStore.getState().logout();
|
await GlobalStateStore.getState().logout();
|
||||||
authMessage = 'Logged out.';
|
authMessage = 'Logged out.';
|
||||||
authError = '';
|
authError = '';
|
||||||
|
await loadPublicStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStatus(): Promise<void> {
|
async function loadDashboardStatus(): Promise<void> {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/status');
|
const token = GlobalStateStore.getState().session?.authToken;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Missing auth token for dashboard status.');
|
||||||
|
}
|
||||||
|
const response = await fetch('/api/status', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Status request failed with ${response.status}`);
|
throw new Error(`Status request failed with ${response.status}`);
|
||||||
}
|
}
|
||||||
@@ -141,6 +153,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPublicStatus(): Promise<void> {
|
||||||
|
publicStatusLoading = true;
|
||||||
|
publicStatusError = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/status');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Public status request failed with ${response.status}`);
|
||||||
|
}
|
||||||
|
const raw = (await response.json()) as Partial<PublicStatusResponse> | null;
|
||||||
|
publicStatus = {
|
||||||
|
connected_count: raw?.connected_count ?? 0,
|
||||||
|
connected_window: raw?.connected_window ?? 'last 10 minutes',
|
||||||
|
entries: Array.isArray(raw?.entries) ? raw.entries : []
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
publicStatusError = err instanceof Error ? err.message : 'Failed to load public status';
|
||||||
|
} finally {
|
||||||
|
publicStatusLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
setCurrentPath(window.location.pathname);
|
setCurrentPath(window.location.pathname);
|
||||||
@@ -153,7 +187,11 @@
|
|||||||
|
|
||||||
await GlobalStateStore.getState().fetchData();
|
await GlobalStateStore.getState().fetchData();
|
||||||
|
|
||||||
await loadStatus();
|
if (GlobalStateStore.getState().isLoggedIn()) {
|
||||||
|
await loadDashboardStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadPublicStatus();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -169,9 +207,9 @@
|
|||||||
{authBusy}
|
{authBusy}
|
||||||
{authError}
|
{authError}
|
||||||
{authMessage}
|
{authMessage}
|
||||||
statusData={data}
|
statusData={publicStatus}
|
||||||
statusLoading={loading}
|
statusLoading={publicStatusLoading}
|
||||||
statusError={error}
|
statusError={publicStatusError}
|
||||||
onlogin={handleCredentialLogin}
|
onlogin={handleCredentialLogin}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -182,7 +220,7 @@
|
|||||||
{error}
|
{error}
|
||||||
onlogout={logout}
|
onlogout={logout}
|
||||||
onnavigate={(page) => { currentPage = page; }}
|
onnavigate={(page) => { currentPage = page; }}
|
||||||
onrefresh={loadStatus}
|
onrefresh={loadDashboardStatus}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { StatusResponse } from "../../types";
|
import type { PublicStatusResponse } from "../../types";
|
||||||
type IntelligenceCard = {
|
type IntelligenceCard = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
isOAuthCallback: boolean;
|
isOAuthCallback: boolean;
|
||||||
data: StatusResponse | null;
|
data: PublicStatusResponse | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -143,10 +143,24 @@
|
|||||||
<p class="mt-2 text-sm font-medium text-rose-300">Unavailable</p>
|
<p class="mt-2 text-sm font-medium text-rose-300">Unavailable</p>
|
||||||
<p class="mt-2 text-xs text-rose-200/80">{error}</p>
|
<p class="mt-2 text-xs text-rose-200/80">{error}</p>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
<p class="mt-2 text-2xl font-semibold text-white">{data.version}</p>
|
<p class="mt-2 text-2xl font-semibold text-white">
|
||||||
<p class="mt-2 text-sm text-slate-400">
|
{data.connected_count}
|
||||||
{data.connected_count} connected in {data.connected_window}
|
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-400">
|
||||||
|
connected in {data.connected_window}
|
||||||
|
</p>
|
||||||
|
{#if data.entries.length > 0}
|
||||||
|
<p class="mt-2 text-xs text-slate-400">
|
||||||
|
Clients:
|
||||||
|
{#each data.entries.slice(0, 3) as client, idx}
|
||||||
|
<code class="text-slate-200">{client.key_id}</code>{idx <
|
||||||
|
Math.min(data.entries.length, 3) - 1
|
||||||
|
? ", "
|
||||||
|
: ""}
|
||||||
|
{/each}
|
||||||
|
{data.entries.length > 3 ? " …" : ""}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
<a
|
<a
|
||||||
href="/status"
|
href="/status"
|
||||||
class="mt-3 inline-flex items-center rounded-lg border border-emerald-300/30 bg-emerald-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-emerald-100 transition hover:border-emerald-300/50 hover:bg-emerald-400/20"
|
class="mt-3 inline-flex items-center rounded-lg border border-emerald-300/30 bg-emerald-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-emerald-100 transition hover:border-emerald-300/50 hover:bg-emerald-400/20"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LoginInfoPanel from './LoginInfoPanel.svelte';
|
import LoginInfoPanel from './LoginInfoPanel.svelte';
|
||||||
import LoginPanel from './LoginPanel.svelte';
|
import LoginPanel from './LoginPanel.svelte';
|
||||||
import type { StatusResponse } from '../../types';
|
import type { PublicStatusResponse } from '../../types';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOAuthCallback,
|
isOAuthCallback,
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
authBusy: boolean;
|
authBusy: boolean;
|
||||||
authError: string;
|
authError: string;
|
||||||
authMessage: string;
|
authMessage: string;
|
||||||
statusData: StatusResponse | null;
|
statusData: PublicStatusResponse | null;
|
||||||
statusLoading: boolean;
|
statusLoading: boolean;
|
||||||
statusError: string;
|
statusError: string;
|
||||||
onlogin: (username: string, password: string) => void;
|
onlogin: (username: string, password: string) => void;
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ export type StatusResponse = {
|
|||||||
metrics: AccessMetrics;
|
metrics: AccessMetrics;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PublicStatusClient = {
|
||||||
|
key_id: string;
|
||||||
|
request_count: number;
|
||||||
|
last_accessed_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicStatusResponse = {
|
||||||
|
connected_count: number;
|
||||||
|
connected_window: string;
|
||||||
|
entries: PublicStatusClient[];
|
||||||
|
};
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': backendTarget,
|
'/api': backendTarget,
|
||||||
|
'/status': backendTarget,
|
||||||
'/healthz': backendTarget,
|
'/healthz': backendTarget,
|
||||||
'/readyz': backendTarget,
|
'/readyz': backendTarget,
|
||||||
'/llm': backendTarget,
|
'/llm': backendTarget,
|
||||||
|
|||||||
Reference in New Issue
Block a user