feat(ui): implement OAuth login flow and dashboard components
Some checks failed
CI / build-and-test (push) Failing after -32m0s
Some checks failed
CI / build-and-test (push) Failing after -32m0s
* Add OAuth login handling in app and UI components * Create new components for login and dashboard pages * Refactor sidebar and navigation structure * Introduce types for access entries and status responses
This commit is contained in:
268
internal/app/admin.go
Normal file
268
internal/app/admin.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type adminHandlers struct {
|
||||||
|
db *store.DB
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAdminHandlers(db *store.DB, logger *slog.Logger) *adminHandlers {
|
||||||
|
return &adminHandlers{db: db, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) register(mux *http.ServeMux, middleware func(http.Handler) http.Handler) {
|
||||||
|
handle := func(pattern string, fn http.HandlerFunc) {
|
||||||
|
mux.Handle(pattern, middleware(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
handle("GET /api/admin/projects", h.listProjects)
|
||||||
|
handle("POST /api/admin/projects", h.createProject)
|
||||||
|
handle("GET /api/admin/thoughts", h.listThoughts)
|
||||||
|
handle("GET /api/admin/thoughts/{id}", h.getThought)
|
||||||
|
handle("DELETE /api/admin/thoughts/{id}", h.deleteThought)
|
||||||
|
handle("POST /api/admin/thoughts/{id}/archive", h.archiveThought)
|
||||||
|
handle("GET /api/admin/skills", h.listSkills)
|
||||||
|
handle("DELETE /api/admin/skills/{id}", h.deleteSkill)
|
||||||
|
handle("GET /api/admin/guardrails", h.listGuardrails)
|
||||||
|
handle("DELETE /api/admin/guardrails/{id}", h.deleteGuardrail)
|
||||||
|
handle("GET /api/admin/files", h.listFiles)
|
||||||
|
handle("GET /api/admin/stats", h.stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Projects ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
|
projects, err := h.db.ListProjects(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list projects", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) createProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(body.Name) == "" {
|
||||||
|
http.Error(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := h.db.CreateProject(r.Context(), body.Name, body.Description)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "create project", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Thoughts ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listThoughts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
limit := 50
|
||||||
|
if l := q.Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||||
|
limit = min(n, 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(q.Get("q"))
|
||||||
|
includeArchived := q.Get("include_archived") == "true"
|
||||||
|
|
||||||
|
var projectID *uuid.UUID
|
||||||
|
if pid := q.Get("project_id"); pid != "" {
|
||||||
|
if id, err := uuid.Parse(pid); err == nil {
|
||||||
|
projectID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if query != "" {
|
||||||
|
results, err := h.db.SearchThoughtsText(r.Context(), query, limit, projectID, nil)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "search thoughts", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, results)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thoughts, err := h.db.ListThoughts(r.Context(), ext.ListFilter{
|
||||||
|
Limit: limit,
|
||||||
|
ProjectID: projectID,
|
||||||
|
IncludeArchived: includeArchived,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list thoughts", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, thoughts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) getThought(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thought, err := h.db.GetThought(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "get thought", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, thought)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) deleteThought(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.DeleteThought(r.Context(), id); err != nil {
|
||||||
|
h.internalError(w, "delete thought", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) archiveThought(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.ArchiveThought(r.Context(), id); err != nil {
|
||||||
|
h.internalError(w, "archive thought", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skills ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listSkills(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tag := r.URL.Query().Get("tag")
|
||||||
|
skills, err := h.db.ListSkills(r.Context(), tag)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list skills", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, skills)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) deleteSkill(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.RemoveSkill(r.Context(), id); err != nil {
|
||||||
|
h.internalError(w, "delete skill", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Guardrails ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listGuardrails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
guardrails, err := h.db.ListGuardrails(r.Context(), q.Get("tag"), q.Get("severity"))
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list guardrails", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, guardrails)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *adminHandlers) deleteGuardrail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(w, r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.RemoveGuardrail(r.Context(), id); err != nil {
|
||||||
|
h.internalError(w, "delete guardrail", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Files ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) listFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
limit := 100
|
||||||
|
if l := q.Get("limit"); l != "" {
|
||||||
|
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||||
|
limit = min(n, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := ext.StoredFileFilter{Limit: limit}
|
||||||
|
if pid := q.Get("project_id"); pid != "" {
|
||||||
|
if id, err := uuid.Parse(pid); err == nil {
|
||||||
|
filter.ProjectID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tid := q.Get("thought_id"); tid != "" {
|
||||||
|
if id, err := uuid.Parse(tid); err == nil {
|
||||||
|
filter.ThoughtID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filter.Kind = q.Get("kind")
|
||||||
|
|
||||||
|
files, err := h.db.ListStoredFiles(r.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "list files", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stats ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) stats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
stats, err := h.db.Stats(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.internalError(w, "stats", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func (h *adminHandlers) internalError(w http.ResponseWriter, op string, err error) {
|
||||||
|
h.logger.Error("admin handler error", slog.String("op", op), slog.String("error", err.Error()))
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUUID(w http.ResponseWriter, s string) (uuid.UUID, bool) {
|
||||||
|
id, err := uuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return uuid.UUID{}, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
@@ -92,12 +92,12 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tokenStore = auth.NewTokenStore(0)
|
||||||
if len(cfg.Auth.OAuth.Clients) > 0 {
|
if len(cfg.Auth.OAuth.Clients) > 0 {
|
||||||
oauthRegistry, err = auth.NewOAuthRegistry(cfg.Auth.OAuth.Clients)
|
oauthRegistry, err = auth.NewOAuthRegistry(cfg.Auth.OAuth.Clients)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tokenStore = auth.NewTokenStore(0)
|
|
||||||
}
|
}
|
||||||
authCodes := auth.NewAuthCodeStore()
|
authCodes := auth.NewAuthCodeStore()
|
||||||
dynClients := auth.NewDynamicClientStore()
|
dynClients := auth.NewDynamicClientStore()
|
||||||
@@ -186,7 +186,7 @@ func Run(ctx context.Context, configPath string) error {
|
|||||||
func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, bgEmbeddings *ai.EmbeddingRunner, bgMetadata *ai.MetadataRunner, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) {
|
func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, bgEmbeddings *ai.EmbeddingRunner, bgMetadata *ai.MetadataRunner, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
accessTracker := auth.NewAccessTracker()
|
accessTracker := auth.NewAccessTracker()
|
||||||
oauthEnabled := oauthRegistry != nil && tokenStore != nil
|
oauthEnabled := oauthRegistry != nil
|
||||||
authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, accessTracker, logger)
|
authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, accessTracker, logger)
|
||||||
filesTool := tools.NewFilesTool(db, activeProjects)
|
filesTool := tools.NewFilesTool(db, activeProjects)
|
||||||
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
||||||
@@ -227,16 +227,13 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
mux.Handle(cfg.MCP.SSEPath, authMiddleware(mcpHandlers.SSE))
|
mux.Handle(cfg.MCP.SSEPath, authMiddleware(mcpHandlers.SSE))
|
||||||
logger.Info("SSE transport enabled", slog.String("sse_path", cfg.MCP.SSEPath))
|
logger.Info("SSE transport enabled", slog.String("sse_path", cfg.MCP.SSEPath))
|
||||||
}
|
}
|
||||||
|
newAdminHandlers(db, logger).register(mux, authMiddleware)
|
||||||
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
||||||
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
||||||
if oauthEnabled {
|
|
||||||
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
||||||
mux.HandleFunc("/oauth-authorization-server", oauthMetadataHandler())
|
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
|
||||||
mux.HandleFunc("/oauth/register", oauthRegisterHandler(dynClients, logger))
|
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
||||||
mux.HandleFunc("/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
||||||
mux.HandleFunc("/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
|
||||||
mux.HandleFunc("/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
|
||||||
}
|
|
||||||
mux.HandleFunc("/favicon.ico", serveFavicon)
|
mux.HandleFunc("/favicon.ico", serveFavicon)
|
||||||
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
||||||
mux.HandleFunc("/images/icon.png", serveIcon)
|
mux.HandleFunc("/images/icon.png", serveIcon)
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ func oauthMetadataHandler() http.HandlerFunc {
|
|||||||
base := serverBaseURL(r)
|
base := serverBaseURL(r)
|
||||||
meta := oauthServerMetadata{
|
meta := oauthServerMetadata{
|
||||||
Issuer: base,
|
Issuer: base,
|
||||||
AuthorizationEndpoint: base + "/authorize",
|
AuthorizationEndpoint: base + "/api/oauth/authorize",
|
||||||
TokenEndpoint: base + "/oauth/token",
|
TokenEndpoint: base + "/api/oauth/token",
|
||||||
RegistrationEndpoint: base + "/oauth/register",
|
RegistrationEndpoint: base + "/api/oauth/register",
|
||||||
ScopesSupported: []string{"mcp"},
|
ScopesSupported: []string{"mcp"},
|
||||||
ResponseTypesSupported: []string{"code"},
|
ResponseTypesSupported: []string{"code"},
|
||||||
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
|
GrantTypesSupported: []string{"authorization_code", "client_credentials"},
|
||||||
@@ -244,6 +244,10 @@ func oauthTokenHandler(oauthRegistry *auth.OAuthRegistry, tokenStore *auth.Token
|
|||||||
|
|
||||||
switch r.FormValue("grant_type") {
|
switch r.FormValue("grant_type") {
|
||||||
case "client_credentials":
|
case "client_credentials":
|
||||||
|
if oauthRegistry == nil {
|
||||||
|
writeTokenError(w, "unsupported_grant_type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
handleClientCredentials(w, r, oauthRegistry, tokenStore, log)
|
handleClientCredentials(w, r, oauthRegistry, tokenStore, log)
|
||||||
case "authorization_code":
|
case "authorization_code":
|
||||||
handleAuthorizationCode(w, r, authCodes, tokenStore, log)
|
handleAuthorizationCode(w, r, authCodes, tokenStore, log)
|
||||||
@@ -334,7 +338,7 @@ button{padding:.5rem 1.2rem;margin-right:.5rem;cursor:pointer;font-size:1rem}
|
|||||||
<body>
|
<body>
|
||||||
<h2>Authorize Access</h2>
|
<h2>Authorize Access</h2>
|
||||||
<p><strong>%s</strong> is requesting access to this AMCS server.</p>
|
<p><strong>%s</strong> is requesting access to this AMCS server.</p>
|
||||||
<form method=POST action=/oauth/authorize>
|
<form method=POST action=/api/oauth/authorize>
|
||||||
<input type=hidden name=client_id value="%s">
|
<input type=hidden name=client_id value="%s">
|
||||||
<input type=hidden name=redirect_uri value="%s">
|
<input type=hidden name=redirect_uri value="%s">
|
||||||
<input type=hidden name=state value="%s">
|
<input type=hidden name=state value="%s">
|
||||||
|
|||||||
@@ -90,8 +90,6 @@ func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFu
|
|||||||
if serveUIAsset(w, r, requestPath) {
|
if serveUIAsset(w, r, requestPath) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serveUIIndex(w, r)
|
serveUIIndex(w, r)
|
||||||
|
|||||||
@@ -1,67 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getApiURL } from '@warkypublic/svelix';
|
import LoginPage from './components/auth/LoginPage.svelte';
|
||||||
|
import AdminShell from './components/shell/AdminShell.svelte';
|
||||||
|
import type { ShellPage, StatusResponse } from './types';
|
||||||
|
import { fromStore } from 'svelte/store';
|
||||||
import {
|
import {
|
||||||
buildOAuthAuthorizationURL,
|
buildOAuthAuthorizationURL,
|
||||||
ensureApiURL,
|
ensureApiURL,
|
||||||
exchangeOAuthCode,
|
exchangeOAuthCode,
|
||||||
GlobalStateStore,
|
GlobalStateStore,
|
||||||
|
isLoggedInStore,
|
||||||
setCurrentPath
|
setCurrentPath
|
||||||
} from './shellState';
|
} from './shellState';
|
||||||
|
|
||||||
type AccessEntry = {
|
|
||||||
key_id: string;
|
|
||||||
last_accessed_at: string;
|
|
||||||
last_path: string;
|
|
||||||
user_agent: 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[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type NavItem = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
|
||||||
{
|
|
||||||
id: 'dashboard',
|
|
||||||
label: 'Dashboard',
|
|
||||||
description: 'System overview and status snapshots.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'projects',
|
|
||||||
label: 'Projects',
|
|
||||||
description: 'First management module for AMCS projects.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'thoughts',
|
|
||||||
label: 'Thoughts',
|
|
||||||
description: 'Thought management arrives after projects.',
|
|
||||||
disabled: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'files',
|
|
||||||
label: 'Files',
|
|
||||||
description: 'File inventory and attachment views.',
|
|
||||||
disabled: true
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
let authMessage = $state('');
|
let authMessage = $state('');
|
||||||
let authError = $state('');
|
let authError = $state('');
|
||||||
let authBusy = $state(false);
|
let authBusy = $state(false);
|
||||||
@@ -69,14 +20,13 @@
|
|||||||
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 currentPage = $state<'dashboard' | 'projects'>('dashboard');
|
let currentPage = $state<ShellPage>('dashboard');
|
||||||
|
|
||||||
ensureApiURL(import.meta.env.VITE_API_URL);
|
ensureApiURL(import.meta.env.VITE_API_URL);
|
||||||
|
|
||||||
const isLoggedIn = $derived(GlobalStateStore.isLoggedIn());
|
const isLoggedIn = fromStore(isLoggedInStore);
|
||||||
const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/');
|
const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/');
|
||||||
const isOAuthCallback = $derived(currentPath === '/oauth/callback');
|
const isOAuthCallback = $derived(currentPath === '/oauth/callback');
|
||||||
const oauthAuthorizeURL = $derived(`${getApiURL()}/oauth/authorize`);
|
|
||||||
|
|
||||||
async function startOAuthLogin(): Promise<void> {
|
async function startOAuthLogin(): Promise<void> {
|
||||||
authBusy = true;
|
authBusy = true;
|
||||||
@@ -112,9 +62,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = await exchangeOAuthCode(code, returnedState);
|
const token = await exchangeOAuthCode(code, returnedState);
|
||||||
await GlobalStateStore.getState().login(token, {
|
await GlobalStateStore.getState().login(token, { username: 'OAuth operator' });
|
||||||
username: 'OAuth operator'
|
|
||||||
});
|
|
||||||
|
|
||||||
authMessage = 'OAuth login complete. Welcome back.';
|
authMessage = 'OAuth login complete. Welcome back.';
|
||||||
window.history.replaceState({}, '', '/');
|
window.history.replaceState({}, '', '/');
|
||||||
@@ -149,10 +97,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string): string {
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
setCurrentPath(window.location.pathname);
|
setCurrentPath(window.location.pathname);
|
||||||
@@ -163,7 +107,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn.current) {
|
||||||
await loadStatus();
|
await loadStatus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -174,248 +118,24 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
{#if !isLoggedIn}
|
{#if !isLoggedIn.current}
|
||||||
<main class="mx-auto flex min-h-screen max-w-6xl items-center px-4 py-10 sm:px-6 lg:px-8">
|
<LoginPage
|
||||||
<section class="grid w-full gap-8 lg:grid-cols-[1.15fr_0.85fr]">
|
{isOAuthCallback}
|
||||||
<div class="rounded-3xl border border-cyan-400/20 bg-slate-900/80 p-8 shadow-2xl shadow-slate-950/40">
|
{callbackBusy}
|
||||||
<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">
|
{authBusy}
|
||||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
{authError}
|
||||||
AMCS Control Interface
|
{authMessage}
|
||||||
</div>
|
onstartLogin={startOAuthLogin}
|
||||||
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white">
|
/>
|
||||||
{#if isOAuthCallback}
|
|
||||||
Completing login
|
|
||||||
{:else}
|
{:else}
|
||||||
Login
|
<AdminShell
|
||||||
{/if}
|
{currentPage}
|
||||||
</h1>
|
{data}
|
||||||
<p class="mt-3 max-w-2xl text-base leading-7 text-slate-300">
|
{loading}
|
||||||
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth is the front door now,
|
{error}
|
||||||
not the old login shortcut.
|
onlogout={logout}
|
||||||
</p>
|
onnavigate={(page) => { currentPage = page; }}
|
||||||
|
onrefresh={loadStatus}
|
||||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
/>
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Primary module</p>
|
|
||||||
<p class="mt-2 text-2xl font-semibold text-white">Projects</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-400">Projects are the first real admin screen in this rollout.</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">OAuth path</p>
|
|
||||||
<p class="mt-2 text-2xl font-semibold text-white">ResolveSpec</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-400">Client registration, authorize, callback, token exchange.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-3xl border border-white/10 bg-slate-900 p-6 shadow-xl shadow-slate-950/30 sm:p-8">
|
|
||||||
{#if isOAuthCallback}
|
|
||||||
<h2 class="text-xl font-semibold text-white">Authorizing operator session</h2>
|
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-400">
|
|
||||||
Finishing the ResolveSpec handshake and exchanging the returned code for an AMCS token.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-6 rounded-2xl border border-cyan-400/20 bg-cyan-400/5 px-4 py-6 text-sm text-cyan-100">
|
|
||||||
{#if callbackBusy}
|
|
||||||
Working the callback doohickey…
|
|
||||||
{:else if authError}
|
|
||||||
Callback failed. Fix the route or try the login run again.
|
|
||||||
{:else}
|
|
||||||
Callback processed.
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<h2 class="text-xl font-semibold text-white">Operator login</h2>
|
|
||||||
<p class="mt-1 text-sm text-slate-400">Authenticate through AMCS ResolveSpec OAuth endpoints.</p>
|
|
||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
onclick={startOAuthLogin}
|
|
||||||
disabled={authBusy}
|
|
||||||
>
|
|
||||||
{#if authBusy}Starting OAuth login…{:else}Login with ResolveSpec OAuth{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-slate-300">
|
|
||||||
<p class="font-semibold text-white">Routes in play</p>
|
|
||||||
<ul class="mt-3 space-y-2 text-slate-400">
|
|
||||||
<li>• discovery: <code class="text-cyan-100">/api/.well-known/oauth-authorization-server</code></li>
|
|
||||||
<li>• registration: <code class="text-cyan-100">/api/oauth/register</code></li>
|
|
||||||
<li>• authorize: <code class="text-cyan-100">{oauthAuthorizeURL}</code></li>
|
|
||||||
<li>• callback: <code class="text-cyan-100">/oauth/callback</code></li>
|
|
||||||
<li>• token: <code class="text-cyan-100">/api/oauth/token</code></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if authError}
|
|
||||||
<p class="text-sm text-rose-300">{authError}</p>
|
|
||||||
{/if}
|
|
||||||
{#if authMessage}
|
|
||||||
<p class="text-sm text-emerald-300">{authMessage}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
{:else}
|
|
||||||
<div class="grid min-h-screen lg:grid-cols-[17rem_1fr]">
|
|
||||||
<aside class="border-r border-white/10 bg-slate-900/90 p-6">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300">AMCS</p>
|
|
||||||
<h1 class="mt-2 text-2xl font-semibold text-white">Admin</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-400">Origin-style shell, starting with Projects.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="mt-8 space-y-2">
|
|
||||||
{#each navItems as item}
|
|
||||||
<button
|
|
||||||
class={`w-full rounded-2xl border px-4 py-3 text-left transition ${item.disabled ? 'cursor-not-allowed border-white/5 bg-white/[0.02] text-slate-600' : currentPage === item.id ? 'border-cyan-300/30 bg-cyan-400/10 text-cyan-100' : 'border-white/10 bg-white/5 text-slate-200 hover:bg-white/10'}`}
|
|
||||||
disabled={item.disabled}
|
|
||||||
onclick={() => {
|
|
||||||
if (!item.disabled && (item.id === 'dashboard' || item.id === 'projects')) {
|
|
||||||
currentPage = item.id;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="text-sm font-semibold">{item.label}</div>
|
|
||||||
<div class="mt-1 text-xs text-slate-400">{item.description}</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="mt-8 inline-flex w-full items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm font-medium text-slate-200 transition hover:bg-white/10"
|
|
||||||
onclick={logout}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main class="px-4 py-6 sm:px-6 lg:px-8">
|
|
||||||
{#if currentPage === 'dashboard'}
|
|
||||||
<section class="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">System overview</h2>
|
|
||||||
<p class="mt-1 text-sm text-slate-400">Current AMCS status behind the admin shell.</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"
|
|
||||||
onclick={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}
|
|
||||||
<div class="mt-6 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>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{:else}
|
|
||||||
<section class="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">Projects</h2>
|
|
||||||
<p class="mt-1 text-sm text-slate-400">First module scaffold. Grid/Form wiring comes next.</p>
|
|
||||||
</div>
|
|
||||||
<span class="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-medium text-amber-200">
|
|
||||||
Structure phase
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 grid gap-4 lg:grid-cols-[1.35fr_0.65fr]">
|
|
||||||
<div class="rounded-2xl border border-dashed border-cyan-400/20 bg-cyan-400/5 p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-white">Project grid placeholder</h3>
|
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-300">
|
|
||||||
This is the landing zone for the Origin-style projects grid using Svelix and GridlerFull.
|
|
||||||
Next pass: wire ResolveSpec-backed project list, row actions, and editor flow.
|
|
||||||
</p>
|
|
||||||
<ul class="mt-4 space-y-2 text-sm text-slate-400">
|
|
||||||
<li>• Project list and search</li>
|
|
||||||
<li>• Project detail/edit drawer or modal</li>
|
|
||||||
<li>• Create/archive actions</li>
|
|
||||||
<li>• Link-outs to related thoughts and skills</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-white">Build notes</h3>
|
|
||||||
<dl class="mt-4 space-y-3 text-sm text-slate-300">
|
|
||||||
<div>
|
|
||||||
<dt class="text-slate-500">Auth path</dt>
|
|
||||||
<dd class="mt-1">ResolveSpec OAuth packages</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-slate-500">Page pattern</dt>
|
|
||||||
<dd class="mt-1">Mapped toward Origin login and shell</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-slate-500">First module</dt>
|
|
||||||
<dd class="mt-1">Projects</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data && currentPage === 'dashboard' && data.entries.length > 0}
|
|
||||||
<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">
|
|
||||||
<h3 class="text-xl font-semibold text-white">Recent access</h3>
|
|
||||||
<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">Agent</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="max-w-[16rem] truncate px-4 py-3 align-top text-xs text-slate-400">{entry.user_agent ?? '—'}</td>
|
|
||||||
<td class="px-4 py-3 align-top font-semibold text-white">{entry.request_count}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
81
ui/src/api.ts
Normal file
81
ui/src/api.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { GlobalStateStore } from './shellState';
|
||||||
|
|
||||||
|
function authHeaders(): HeadersInit {
|
||||||
|
const token = GlobalStateStore.getState().session.authToken;
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(path, { headers: authHeaders() });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(path: string): Promise<void> {
|
||||||
|
const res = await fetch(path, { method: 'DELETE', headers: authHeaders() });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
projects: {
|
||||||
|
list: () => get<import('./types').ProjectSummary[]>('/api/admin/projects'),
|
||||||
|
create: (name: string, description: string) =>
|
||||||
|
post<import('./types').Project>('/api/admin/projects', { name, description })
|
||||||
|
},
|
||||||
|
thoughts: {
|
||||||
|
list: (params: { q?: string; project_id?: string; limit?: number; include_archived?: boolean }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.q) qs.set('q', params.q);
|
||||||
|
if (params.project_id) qs.set('project_id', params.project_id);
|
||||||
|
if (params.limit) qs.set('limit', String(params.limit));
|
||||||
|
if (params.include_archived) qs.set('include_archived', 'true');
|
||||||
|
return get<(import('./types').Thought | import('./types').SearchResult)[]>(
|
||||||
|
`/api/admin/thoughts${qs.size ? '?' + qs : ''}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
get: (id: string) => get<import('./types').Thought>(`/api/admin/thoughts/${id}`),
|
||||||
|
delete: (id: string) => del(`/api/admin/thoughts/${id}`),
|
||||||
|
archive: (id: string) => post<void>(`/api/admin/thoughts/${id}/archive`, {})
|
||||||
|
},
|
||||||
|
skills: {
|
||||||
|
list: (tag?: string) => {
|
||||||
|
const qs = tag ? `?tag=${encodeURIComponent(tag)}` : '';
|
||||||
|
return get<import('./types').AgentSkill[]>(`/api/admin/skills${qs}`);
|
||||||
|
},
|
||||||
|
delete: (id: string) => del(`/api/admin/skills/${id}`)
|
||||||
|
},
|
||||||
|
guardrails: {
|
||||||
|
list: (params?: { tag?: string; severity?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.tag) qs.set('tag', params.tag);
|
||||||
|
if (params?.severity) qs.set('severity', params.severity);
|
||||||
|
return get<import('./types').AgentGuardrail[]>(
|
||||||
|
`/api/admin/guardrails${qs.size ? '?' + qs : ''}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
delete: (id: string) => del(`/api/admin/guardrails/${id}`)
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.project_id) qs.set('project_id', params.project_id);
|
||||||
|
if (params?.thought_id) qs.set('thought_id', params.thought_id);
|
||||||
|
if (params?.kind) qs.set('kind', params.kind);
|
||||||
|
return get<import('./types').StoredFile[]>(
|
||||||
|
`/api/admin/files${qs.size ? '?' + qs : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stats: () => get<import('./types').ThoughtStats>('/api/admin/stats')
|
||||||
|
};
|
||||||
34
ui/src/components/auth/LoginInfoPanel.svelte
Normal file
34
ui/src/components/auth/LoginInfoPanel.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const { isOAuthCallback }: { isOAuthCallback: boolean } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-3xl border border-cyan-400/20 bg-slate-900/80 p-8 shadow-2xl shadow-slate-950/40">
|
||||||
|
<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>
|
||||||
|
AMCS Control Interface
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white">
|
||||||
|
{#if isOAuthCallback}
|
||||||
|
Completing login
|
||||||
|
{:else}
|
||||||
|
Login
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 max-w-2xl text-base leading-7 text-slate-300">
|
||||||
|
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth is the front door now,
|
||||||
|
not the old login shortcut.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Primary module</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">Projects</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-400">Projects are the first real admin screen in this rollout.</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">OAuth path</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">ResolveSpec</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-400">Client registration, authorize, callback, token exchange.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
34
ui/src/components/auth/LoginPage.svelte
Normal file
34
ui/src/components/auth/LoginPage.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LoginInfoPanel from './LoginInfoPanel.svelte';
|
||||||
|
import LoginPanel from './LoginPanel.svelte';
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOAuthCallback,
|
||||||
|
callbackBusy,
|
||||||
|
authBusy,
|
||||||
|
authError,
|
||||||
|
authMessage,
|
||||||
|
onstartLogin
|
||||||
|
}: {
|
||||||
|
isOAuthCallback: boolean;
|
||||||
|
callbackBusy: boolean;
|
||||||
|
authBusy: boolean;
|
||||||
|
authError: string;
|
||||||
|
authMessage: string;
|
||||||
|
onstartLogin: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="mx-auto flex min-h-screen max-w-6xl items-center px-4 py-10 sm:px-6 lg:px-8">
|
||||||
|
<section class="grid w-full gap-8 lg:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<LoginInfoPanel {isOAuthCallback} />
|
||||||
|
<LoginPanel
|
||||||
|
{isOAuthCallback}
|
||||||
|
{callbackBusy}
|
||||||
|
{authBusy}
|
||||||
|
{authError}
|
||||||
|
{authMessage}
|
||||||
|
{onstartLogin}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
72
ui/src/components/auth/LoginPanel.svelte
Normal file
72
ui/src/components/auth/LoginPanel.svelte
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getApiURL } from '@warkypublic/svelix';
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOAuthCallback,
|
||||||
|
callbackBusy,
|
||||||
|
authBusy,
|
||||||
|
authError,
|
||||||
|
authMessage,
|
||||||
|
onstartLogin
|
||||||
|
}: {
|
||||||
|
isOAuthCallback: boolean;
|
||||||
|
callbackBusy: boolean;
|
||||||
|
authBusy: boolean;
|
||||||
|
authError: string;
|
||||||
|
authMessage: string;
|
||||||
|
onstartLogin: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const oauthAuthorizeURL = `${getApiURL()}/oauth/authorize`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-3xl border border-white/10 bg-slate-900 p-6 shadow-xl shadow-slate-950/30 sm:p-8">
|
||||||
|
{#if isOAuthCallback}
|
||||||
|
<h2 class="text-xl font-semibold text-white">Authorizing operator session</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-400">
|
||||||
|
Finishing the ResolveSpec handshake and exchanging the returned code for an AMCS token.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-2xl border border-cyan-400/20 bg-cyan-400/5 px-4 py-6 text-sm text-cyan-100">
|
||||||
|
{#if callbackBusy}
|
||||||
|
Working the callback doohickey…
|
||||||
|
{:else if authError}
|
||||||
|
Callback failed. Fix the route or try the login run again.
|
||||||
|
{:else}
|
||||||
|
Callback processed.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<h2 class="text-xl font-semibold text-white">Operator login</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">Authenticate through AMCS ResolveSpec OAuth endpoints.</p>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
onclick={onstartLogin}
|
||||||
|
disabled={authBusy}
|
||||||
|
>
|
||||||
|
{#if authBusy}Starting OAuth login…{:else}Login with ResolveSpec OAuth{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-slate-300">
|
||||||
|
<p class="font-semibold text-white">Routes in play</p>
|
||||||
|
<ul class="mt-3 space-y-2 text-slate-400">
|
||||||
|
<li>• discovery: <code class="text-cyan-100">/.well-known/oauth-authorization-server</code></li>
|
||||||
|
<li>• registration: <code class="text-cyan-100">/api/oauth/register</code></li>
|
||||||
|
<li>• authorize: <code class="text-cyan-100">{oauthAuthorizeURL}</code></li>
|
||||||
|
<li>• callback: <code class="text-cyan-100">/oauth/callback</code></li>
|
||||||
|
<li>• token: <code class="text-cyan-100">/api/oauth/token</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if authError}
|
||||||
|
<p class="text-sm text-rose-300">{authError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if authMessage}
|
||||||
|
<p class="text-sm text-emerald-300">{authMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
39
ui/src/components/dashboard/AccessTable.svelte
Normal file
39
ui/src/components/dashboard/AccessTable.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AccessEntry } from '../../types';
|
||||||
|
|
||||||
|
const { entries }: { entries: AccessEntry[] } = $props();
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<h3 class="text-xl font-semibold text-white">Recent access</h3>
|
||||||
|
<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">Agent</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 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="max-w-[16rem] truncate px-4 py-3 align-top text-xs text-slate-400">{entry.user_agent ?? '—'}</td>
|
||||||
|
<td class="px-4 py-3 align-top font-semibold text-white">{entry.request_count}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
49
ui/src/components/dashboard/DashboardPage.svelte
Normal file
49
ui/src/components/dashboard/DashboardPage.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { StatusResponse } from '../../types';
|
||||||
|
import AccessTable from './AccessTable.svelte';
|
||||||
|
import StatusCards from './StatusCards.svelte';
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onrefresh
|
||||||
|
}: {
|
||||||
|
data: StatusResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
onrefresh: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="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">System overview</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">Current AMCS status behind the admin shell.</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"
|
||||||
|
onclick={onrefresh}
|
||||||
|
>
|
||||||
|
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}
|
||||||
|
<StatusCards {data} />
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if data && data.entries.length > 0}
|
||||||
|
<AccessTable entries={data.entries} />
|
||||||
|
{/if}
|
||||||
20
ui/src/components/dashboard/StatusCards.svelte
Normal file
20
ui/src/components/dashboard/StatusCards.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { StatusResponse } from '../../types';
|
||||||
|
|
||||||
|
const { data }: { data: StatusResponse } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-6 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>
|
||||||
92
ui/src/components/files/FilesPage.svelte
Normal file
92
ui/src/components/files/FilesPage.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import type { StoredFile } from '../../types';
|
||||||
|
|
||||||
|
let files = $state<StoredFile[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
function formatBytes(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
files = await api.files.list();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load files';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold text-white">Files</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">{files.length} file{files.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||||
|
onclick={load}
|
||||||
|
>Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||||
|
{:else if files.length === 0}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No files stored.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||||
|
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||||
|
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Name</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Type</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Kind</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Size</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Uploaded</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Download</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||||
|
{#each files as f}
|
||||||
|
<tr class="hover:bg-white/[0.03]">
|
||||||
|
<td class="max-w-xs truncate px-4 py-3 font-medium text-white">{f.name}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-400 text-xs">{f.media_type}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">{f.kind || '—'}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{formatBytes(f.size_bytes)}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-slate-400">{formatDate(f.created_at)}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<a
|
||||||
|
href={`/files/${f.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="text-xs text-cyan-400 hover:text-cyan-300"
|
||||||
|
>↓</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
103
ui/src/components/guardrails/GuardrailsPage.svelte
Normal file
103
ui/src/components/guardrails/GuardrailsPage.svelte
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import type { AgentGuardrail } from '../../types';
|
||||||
|
|
||||||
|
const severityColour: Record<string, string> = {
|
||||||
|
low: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200',
|
||||||
|
medium: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
|
||||||
|
high: 'border-orange-400/20 bg-orange-400/10 text-orange-200',
|
||||||
|
critical: 'border-rose-400/20 bg-rose-400/10 text-rose-200'
|
||||||
|
};
|
||||||
|
|
||||||
|
let guardrails = $state<AgentGuardrail[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let busy = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
guardrails = await api.guardrails.list();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load guardrails';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string, name: string) {
|
||||||
|
if (!confirm(`Delete guardrail "${name}"?`)) return;
|
||||||
|
busy = id;
|
||||||
|
try {
|
||||||
|
await api.guardrails.delete(id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Delete failed';
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold text-white">Guardrails</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">{guardrails.length} guardrail{guardrails.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||||
|
onclick={load}
|
||||||
|
>Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||||
|
{:else if guardrails.length === 0}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No guardrails registered.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each guardrails as g}
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="font-semibold text-white">{g.name}</p>
|
||||||
|
<span class={`rounded-full border px-2 py-0.5 text-xs font-medium ${severityColour[g.severity] ?? severityColour.medium}`}>
|
||||||
|
{g.severity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if g.description}
|
||||||
|
<p class="mt-1 text-sm text-slate-400">{g.description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if g.tags?.length}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
{#each g.tags as tag}
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
|
||||||
|
onclick={() => remove(g.id, g.name)}
|
||||||
|
disabled={busy === g.id}
|
||||||
|
>Delete</button>
|
||||||
|
</div>
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
|
||||||
|
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{g.content}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
137
ui/src/components/projects/ProjectsPage.svelte
Normal file
137
ui/src/components/projects/ProjectsPage.svelte
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import type { ProjectSummary } from '../../types';
|
||||||
|
|
||||||
|
let projects = $state<ProjectSummary[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let creating = $state(false);
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let newName = $state('');
|
||||||
|
let newDesc = $state('');
|
||||||
|
let createError = $state('');
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
projects = await api.projects.list();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load projects';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
creating = true;
|
||||||
|
createError = '';
|
||||||
|
try {
|
||||||
|
await api.projects.create(newName.trim(), newDesc.trim());
|
||||||
|
newName = '';
|
||||||
|
newDesc = '';
|
||||||
|
showCreate = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
createError = e instanceof Error ? e.message : 'Failed to create project';
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold text-white">Projects</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">{projects.length} project{projects.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-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"
|
||||||
|
onclick={load}
|
||||||
|
>Refresh</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
|
||||||
|
onclick={() => { showCreate = !showCreate; }}
|
||||||
|
>New project</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreate}
|
||||||
|
<div class="rounded-2xl border border-cyan-400/20 bg-slate-900 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-white">Create project</h3>
|
||||||
|
<div class="mt-3 space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
|
||||||
|
bind:value={newName}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
|
||||||
|
bind:value={newDesc}
|
||||||
|
/>
|
||||||
|
{#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20 disabled:opacity-50"
|
||||||
|
onclick={create}
|
||||||
|
disabled={creating || !newName.trim()}
|
||||||
|
>{creating ? 'Creating…' : 'Create'}</button>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300 transition hover:bg-white/10"
|
||||||
|
onclick={() => { showCreate = false; createError = ''; }}
|
||||||
|
>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||||
|
{:else if projects.length === 0}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">
|
||||||
|
No projects yet.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||||
|
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||||
|
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Name</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Description</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Thoughts</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Last active</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||||
|
{#each projects as p}
|
||||||
|
<tr class="hover:bg-white/[0.03]">
|
||||||
|
<td class="px-4 py-3 font-medium text-white">{p.name}</td>
|
||||||
|
<td class="max-w-xs truncate px-4 py-3 text-slate-400">{p.description || '—'}</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{p.thought_count}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-slate-400">{formatDate(p.last_active_at)}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-slate-400">{formatDate(p.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
48
ui/src/components/shell/AdminShell.svelte
Normal file
48
ui/src/components/shell/AdminShell.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ShellPage, StatusResponse } from '../../types';
|
||||||
|
import FilesPage from '../files/FilesPage.svelte';
|
||||||
|
import GuardrailsPage from '../guardrails/GuardrailsPage.svelte';
|
||||||
|
import DashboardPage from '../dashboard/DashboardPage.svelte';
|
||||||
|
import ProjectsPage from '../projects/ProjectsPage.svelte';
|
||||||
|
import SkillsPage from '../skills/SkillsPage.svelte';
|
||||||
|
import ThoughtsPage from '../thoughts/ThoughtsPage.svelte';
|
||||||
|
import AppSidebar from './AppSidebar.svelte';
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentPage,
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onlogout,
|
||||||
|
onnavigate,
|
||||||
|
onrefresh
|
||||||
|
}: {
|
||||||
|
currentPage: ShellPage;
|
||||||
|
data: StatusResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
onlogout: () => void;
|
||||||
|
onnavigate: (page: ShellPage) => void;
|
||||||
|
onrefresh: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid min-h-screen lg:grid-cols-[17rem_1fr]">
|
||||||
|
<AppSidebar {currentPage} {onnavigate} {onlogout} />
|
||||||
|
|
||||||
|
<main class="px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
{#if currentPage === 'dashboard'}
|
||||||
|
<DashboardPage {data} {loading} {error} {onrefresh} />
|
||||||
|
{:else if currentPage === 'projects'}
|
||||||
|
<ProjectsPage />
|
||||||
|
{:else if currentPage === 'thoughts'}
|
||||||
|
<ThoughtsPage />
|
||||||
|
{:else if currentPage === 'skills'}
|
||||||
|
<SkillsPage />
|
||||||
|
{:else if currentPage === 'guardrails'}
|
||||||
|
<GuardrailsPage />
|
||||||
|
{:else if currentPage === 'files'}
|
||||||
|
<FilesPage />
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
49
ui/src/components/shell/AppSidebar.svelte
Normal file
49
ui/src/components/shell/AppSidebar.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { NavItem, ShellPage } from '../../types';
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentPage,
|
||||||
|
onnavigate,
|
||||||
|
onlogout
|
||||||
|
}: {
|
||||||
|
currentPage: ShellPage;
|
||||||
|
onnavigate: (page: ShellPage) => void;
|
||||||
|
onlogout: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
|
||||||
|
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
|
||||||
|
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
|
||||||
|
{ id: 'skills', label: 'Skills', description: 'Agent skill registry.' },
|
||||||
|
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail registry.' },
|
||||||
|
{ id: 'files', label: 'Files', description: 'Stored file inventory.' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="border-r border-white/10 bg-slate-900/90 p-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300">AMCS</p>
|
||||||
|
<h1 class="mt-2 text-2xl font-semibold text-white">Admin</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-400">Memory server control panel.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="mt-8 space-y-1">
|
||||||
|
{#each navItems as item}
|
||||||
|
<button
|
||||||
|
class={`w-full rounded-2xl border px-4 py-3 text-left transition ${currentPage === item.id ? 'border-cyan-300/30 bg-cyan-400/10 text-cyan-100' : 'border-transparent text-slate-200 hover:border-white/10 hover:bg-white/5'}`}
|
||||||
|
onclick={() => onnavigate(item.id as ShellPage)}
|
||||||
|
>
|
||||||
|
<div class="text-sm font-semibold">{item.label}</div>
|
||||||
|
<div class="mt-0.5 text-xs text-slate-500">{item.description}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="mt-8 inline-flex w-full items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm font-medium text-slate-200 transition hover:bg-white/10"
|
||||||
|
onclick={onlogout}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
91
ui/src/components/skills/SkillsPage.svelte
Normal file
91
ui/src/components/skills/SkillsPage.svelte
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import type { AgentSkill } from '../../types';
|
||||||
|
|
||||||
|
let skills = $state<AgentSkill[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let busy = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
skills = await api.skills.list();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load skills';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string, name: string) {
|
||||||
|
if (!confirm(`Delete skill "${name}"?`)) return;
|
||||||
|
busy = id;
|
||||||
|
try {
|
||||||
|
await api.skills.delete(id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Delete failed';
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-semibold text-white">Skills</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">{skills.length} skill{skills.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||||
|
onclick={load}
|
||||||
|
>Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||||
|
{:else if skills.length === 0}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No skills registered.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each skills as skill}
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-semibold text-white">{skill.name}</p>
|
||||||
|
{#if skill.description}
|
||||||
|
<p class="mt-1 text-sm text-slate-400">{skill.description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if skill.tags?.length}
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
{#each skill.tags as tag}
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
|
||||||
|
onclick={() => remove(skill.id, skill.name)}
|
||||||
|
disabled={busy === skill.id}
|
||||||
|
>Delete</button>
|
||||||
|
</div>
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
|
||||||
|
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{skill.content}</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
165
ui/src/components/thoughts/ThoughtsPage.svelte
Normal file
165
ui/src/components/thoughts/ThoughtsPage.svelte
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import type { Thought, SearchResult } from '../../types';
|
||||||
|
|
||||||
|
type Row = Thought | SearchResult;
|
||||||
|
|
||||||
|
let rows = $state<Row[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let query = $state('');
|
||||||
|
let includeArchived = $state(false);
|
||||||
|
let actionBusy = $state<string | null>(null);
|
||||||
|
let actionError = $state('');
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
rows = await api.thoughts.list({ q: query || undefined, include_archived: includeArchived || undefined, limit: 100 }) as Row[];
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load thoughts';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueryInput() {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(load, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archive(id: string) {
|
||||||
|
actionBusy = id;
|
||||||
|
actionError = '';
|
||||||
|
try {
|
||||||
|
await api.thoughts.archive(id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
actionError = e instanceof Error ? e.message : 'Archive failed';
|
||||||
|
} finally {
|
||||||
|
actionBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!confirm('Permanently delete this thought?')) return;
|
||||||
|
actionBusy = id;
|
||||||
|
actionError = '';
|
||||||
|
try {
|
||||||
|
await api.thoughts.delete(id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
actionError = e instanceof Error ? e.message : 'Delete failed';
|
||||||
|
} finally {
|
||||||
|
actionBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArchived(row: Row): boolean {
|
||||||
|
return 'archived_at' in row && !!row.archived_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
function content(row: Row): string {
|
||||||
|
return row.content.length > 120 ? row.content.slice(0, 120) + '…' : row.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<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">Thoughts</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">{rows.length} result{rows.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-400">
|
||||||
|
<input type="checkbox" class="accent-cyan-400" bind:checked={includeArchived} onchange={load} />
|
||||||
|
Archived
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
|
||||||
|
onclick={load}
|
||||||
|
>Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search thoughts…"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
|
||||||
|
bind:value={query}
|
||||||
|
oninput={onQueryInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if actionError}
|
||||||
|
<p class="text-sm text-rose-300">{actionError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No thoughts found.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-white/10">
|
||||||
|
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
|
||||||
|
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Content</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Type</th>
|
||||||
|
<th class="px-4 py-3 text-left font-medium">Status</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Created</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||||
|
{#each rows as row}
|
||||||
|
<tr class={`hover:bg-white/[0.03] ${isArchived(row) ? 'opacity-50' : ''}`}>
|
||||||
|
<td class="max-w-sm px-4 py-3 align-top">
|
||||||
|
<p class="text-white">{content(row)}</p>
|
||||||
|
{#if row.metadata.topics?.length}
|
||||||
|
<p class="mt-1 text-xs text-slate-500">{row.metadata.topics.slice(0,3).join(', ')}</p>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 align-top">
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">
|
||||||
|
{row.metadata.type || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 align-top text-xs text-slate-400">
|
||||||
|
{isArchived(row) ? 'archived' : (row.metadata.metadata_status || 'active')}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 align-top text-right text-slate-400">{formatDate(row.created_at)}</td>
|
||||||
|
<td class="px-4 py-3 align-top text-right">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
{#if !isArchived(row)}
|
||||||
|
<button
|
||||||
|
class="text-xs text-slate-400 underline-offset-2 hover:text-slate-200"
|
||||||
|
onclick={() => archive(row.id)}
|
||||||
|
disabled={actionBusy === row.id}
|
||||||
|
>Archive</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="text-xs text-rose-400 underline-offset-2 hover:text-rose-300"
|
||||||
|
onclick={() => remove(row.id)}
|
||||||
|
disabled={actionBusy === row.id}
|
||||||
|
>Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GlobalStateStore } from '@warkypublic/svelix';
|
import { GlobalStateStore, isLoggedInStore } from '@warkypublic/svelix';
|
||||||
|
|
||||||
const normalizeApiURL = (url: string): string => url.replace(/\/+$/, '');
|
const normalizeApiURL = (url: string): string => url.replace(/\/+$/, '');
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ const resolveApiURL = (envURL?: string): string => {
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export { GlobalStateStore };
|
export { GlobalStateStore, isLoggedInStore };
|
||||||
|
|
||||||
export type OAuthClientRegistration = {
|
export type OAuthClientRegistration = {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
@@ -174,8 +174,8 @@ async function sha256(input: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOAuthMetadata(): Promise<OAuthServerMetadata> {
|
export async function fetchOAuthMetadata(): Promise<OAuthServerMetadata> {
|
||||||
const apiURL = ensureApiURL();
|
const base = getPublicBaseURL();
|
||||||
const response = await fetch(`${apiURL}/.well-known/oauth-authorization-server`);
|
const response = await fetch(`${base}/.well-known/oauth-authorization-server`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load OAuth metadata (${response.status})`);
|
throw new Error(`Failed to load OAuth metadata (${response.status})`);
|
||||||
}
|
}
|
||||||
@@ -185,10 +185,6 @@ export async function fetchOAuthMetadata(): Promise<OAuthServerMetadata> {
|
|||||||
|
|
||||||
export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadata): Promise<OAuthClientRegistration> {
|
export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadata): Promise<OAuthClientRegistration> {
|
||||||
const redirectURI = getOAuthRedirectURI();
|
const redirectURI = getOAuthRedirectURI();
|
||||||
const existing = readOAuthClient();
|
|
||||||
if (existing?.client_id && existing.redirect_uris?.includes(redirectURI)) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(metadata.registration_endpoint, {
|
const response = await fetch(metadata.registration_endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -214,6 +210,7 @@ export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadat
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function buildOAuthAuthorizationURL(): Promise<string> {
|
export async function buildOAuthAuthorizationURL(): Promise<string> {
|
||||||
|
removeStorage(OAUTH_CLIENT_KEY);
|
||||||
const metadata = await fetchOAuthMetadata();
|
const metadata = await fetchOAuthMetadata();
|
||||||
const client = await ensureOAuthClientRegistration(metadata);
|
const client = await ensureOAuthClientRegistration(metadata);
|
||||||
const codeVerifier = createRandomString(96);
|
const codeVerifier = createRandomString(96);
|
||||||
|
|||||||
111
ui/src/types.ts
Normal file
111
ui/src/types.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
export type AccessEntry = {
|
||||||
|
key_id: string;
|
||||||
|
last_accessed_at: string;
|
||||||
|
last_path: string;
|
||||||
|
user_agent: string;
|
||||||
|
request_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'skills' | 'guardrails' | 'files';
|
||||||
|
|
||||||
|
export type Project = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
last_active_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectSummary = Project & {
|
||||||
|
thought_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThoughtMetadata = {
|
||||||
|
people: string[];
|
||||||
|
action_items: string[];
|
||||||
|
dates_mentioned: string[];
|
||||||
|
topics: string[];
|
||||||
|
type: string;
|
||||||
|
source: string;
|
||||||
|
metadata_status: string;
|
||||||
|
metadata_error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Thought = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
metadata: ThoughtMetadata;
|
||||||
|
project_id?: string;
|
||||||
|
archived_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchResult = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
metadata: ThoughtMetadata;
|
||||||
|
similarity: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentSkill = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentGuardrail = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
tags: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StoredFile = {
|
||||||
|
id: string;
|
||||||
|
thought_id?: string;
|
||||||
|
project_id?: string;
|
||||||
|
name: string;
|
||||||
|
media_type: string;
|
||||||
|
kind: string;
|
||||||
|
size_bytes: number;
|
||||||
|
sha256: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThoughtStats = {
|
||||||
|
total_count: number;
|
||||||
|
type_counts: Record<string, number>;
|
||||||
|
top_topics: { key: string; count: number }[];
|
||||||
|
top_people: { key: string; count: number }[];
|
||||||
|
};
|
||||||
@@ -18,9 +18,6 @@ export default defineConfig({
|
|||||||
'/favicon.ico': backendTarget,
|
'/favicon.ico': backendTarget,
|
||||||
'/mcp': backendTarget,
|
'/mcp': backendTarget,
|
||||||
'/files': backendTarget,
|
'/files': backendTarget,
|
||||||
'/oauth-authorization-server': backendTarget,
|
|
||||||
'/authorize': backendTarget,
|
|
||||||
'/oauth': backendTarget,
|
|
||||||
'/.well-known': backendTarget
|
'/.well-known': backendTarget
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user