From 76705ce51b3538acb165a5562f5a74d1b3984c81 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Mon, 8 Jun 2026 14:39:31 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Add read-only admin web viewer","authority":"XY-19"} --- apps/elf-api/src/routes.rs | 48 +- apps/elf-api/static/viewer.html | 1241 +++++++++++++++++++++ docs/guide/getting_started.md | 8 + docs/spec/system_elf_memory_service_v2.md | 27 + 4 files changed, 1318 insertions(+), 6 deletions(-) create mode 100644 apps/elf-api/static/viewer.html diff --git a/apps/elf-api/src/routes.rs b/apps/elf-api/src/routes.rs index 0afc91b9..421f0488 100644 --- a/apps/elf-api/src/routes.rs +++ b/apps/elf-api/src/routes.rs @@ -9,7 +9,7 @@ use axum::{ }, http::{ HeaderMap, HeaderValue, Request, StatusCode, - header::{CONTENT_LENGTH, CONTENT_TYPE}, + header::{CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE}, }, middleware::{self, Next}, response::{IntoResponse, Response}, @@ -55,6 +55,8 @@ use elf_service::{ pub const OPENAPI_JSON_PATH: &str = "/openapi.json"; /// Scalar API reference route. pub const SCALAR_DOCS_PATH: &str = "/docs"; +/// Local read-only admin viewer route. +pub const ADMIN_VIEWER_PATH: &str = "/viewer"; const HEADER_TENANT_ID: &str = "X-ELF-Tenant-Id"; const HEADER_PROJECT_ID: &str = "X-ELF-Project-Id"; @@ -75,6 +77,7 @@ const MAX_NOTE_IDS_PER_DETAILS: usize = 256; const MAX_TOP_K: u32 = 100; const MAX_CANDIDATE_K: u32 = 1_000; const MAX_ERROR_LOG_CHARS: usize = 1_024; +const VIEWER_HTML: &str = include_str!("../static/viewer.html"); /// Generated OpenAPI document for the ELF HTTP API. #[derive(OpenApi)] @@ -556,8 +559,13 @@ pub fn router(state: AppState) -> Router { /// Builds the authenticated admin API router. pub fn admin_router(state: AppState) -> Router { let auth_state = state.clone(); - - Router::new() + let protected_router = Router::new() + .route("/v2/admin/searches", routing::post(searches_create)) + .route("/v2/admin/searches/{search_id}", routing::get(searches_get)) + .route("/v2/admin/searches/{search_id}/timeline", routing::get(searches_timeline)) + .route("/v2/admin/searches/{search_id}/notes", routing::post(searches_notes)) + .route("/v2/admin/notes", routing::get(notes_list)) + .route("/v2/admin/notes/{note_id}", routing::get(notes_get)) .route( "/v2/admin/events/ingestion-profiles/default", routing::get(admin_ingestion_profile_default_get) @@ -594,7 +602,12 @@ pub fn admin_router(state: AppState) -> Router { .route("/v2/admin/notes/{note_id}/provenance", routing::get(admin_note_provenance_get)) .with_state(state) .layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)) - .layer(middleware::from_fn_with_state(auth_state, admin_auth_middleware)) + .layer(middleware::from_fn_with_state(auth_state, admin_auth_middleware)); + + Router::new() + .route(ADMIN_VIEWER_PATH, routing::get(admin_viewer)) + .route("/", routing::get(admin_viewer)) + .merge(protected_router) } /// Builds the API contract router. @@ -915,6 +928,17 @@ async fn openapi_json() -> Response { response } +async fn admin_viewer() -> Response { + let mut response = VIEWER_HTML.into_response(); + + response + .headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); + response.headers_mut().insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + + response +} + async fn with_request_id(response: Response, request_id: Uuid) -> Response { let (mut parts, body) = response.into_parts(); @@ -2892,8 +2916,8 @@ mod tests { use uuid::Uuid; use crate::routes::{ - self, HEADER_AGENT_ID, HEADER_AUTHORIZATION, HEADER_PROJECT_ID, HEADER_READ_PROFILE, - HEADER_REQUEST_ID, HEADER_TENANT_ID, HEADER_TRUSTED_TOKEN_ID, + self, ADMIN_VIEWER_PATH, HEADER_AGENT_ID, HEADER_AUTHORIZATION, HEADER_PROJECT_ID, + HEADER_READ_PROFILE, HEADER_REQUEST_ID, HEADER_TENANT_ID, HEADER_TRUSTED_TOKEN_ID, }; use elf_config::{SecurityAuthKey, SecurityAuthRole}; @@ -2929,6 +2953,18 @@ mod tests { .expect("Expected auth_mode != static_keys."); } + #[test] + fn admin_viewer_is_admin_prefixed_and_read_only() { + let html = routes::VIEWER_HTML; + + assert_eq!(ADMIN_VIEWER_PATH, "/viewer"); + assert!(html.contains("/v2/admin/searches")); + assert!(html.contains("/v2/admin/traces/recent")); + assert!(html.contains("/v2/admin/notes/")); + assert!(!html.contains("method: \"PATCH\"")); + assert!(!html.contains("method: \"DELETE\"")); + } + #[test] fn resolve_auth_key_requires_bearer_header() { let headers = HeaderMap::new(); diff --git a/apps/elf-api/static/viewer.html b/apps/elf-api/static/viewer.html new file mode 100644 index 00000000..0bf852d2 --- /dev/null +++ b/apps/elf-api/static/viewer.html @@ -0,0 +1,1241 @@ + + + + + + ELF Viewer + + + +
+ + +
+
+
Ready.
+
+ +
+
+ +
+
+
+
+

Search Session

+ +
+
+ +
+ + + + +
+
+ +
+
+ +
+
+
+ +
+
+
+
+

Index

+
+
+
+
No session loaded.
+
+
+
+
+

Timeline

+ +
+
+
No timeline loaded.
+
+
+
+
+
+

Note Detail

+
Select a note.
+
+
+

Trace Explain

+
Run or load a session.
+
+
+
+
+ +
+
+
+

Notes

+ +
+
+
+ + + +
+
+
+
+
+

Note List

+
No notes loaded.
+
+
+

Note Metadata

+
Select a note.
+
+
+
+ +
+
+
+

Recent Traces

+ +
+
+
+ + + +
+
+
+
+
+

Trace List

+
No traces loaded.
+
+
+

Trace Bundle

+
Select a trace.
+
+
+
+
+
+
+ + + + diff --git a/docs/guide/getting_started.md b/docs/guide/getting_started.md index 320fe95e..470d75c0 100644 --- a/docs/guide/getting_started.md +++ b/docs/guide/getting_started.md @@ -75,6 +75,7 @@ After `elf-api` starts, the API process serves: - `GET /openapi.json` for the generated OpenAPI contract. - `GET /docs` for the Scalar API reference UI. +- `GET /viewer` on the admin bind for the local read-only search, note, and trace viewer. Use the host and port from `service.http_bind` in your config. For example: @@ -84,6 +85,13 @@ curl -fsS http://127.0.0.1:51892/openapi.json open http://127.0.0.1:51892/docs ``` +Use the host and port from `service.admin_bind` for the viewer. +For the checked-in local config: + +```sh +open http://127.0.0.1:51891/viewer +``` + ## 5. Smoke the local stack ```sh diff --git a/docs/spec/system_elf_memory_service_v2.md b/docs/spec/system_elf_memory_service_v2.md index ea4527de..89d8e9fb 100644 --- a/docs/spec/system_elf_memory_service_v2.md +++ b/docs/spec/system_elf_memory_service_v2.md @@ -953,6 +953,33 @@ Request correlation: - If omitted, elf-api generates a new UUID. - Response includes `X-ELF-Request-Id` header and `request_id` in JSON responses. +GET /viewer + +Behavior: +- Serves the local read-only web viewer from the admin bind only. +- Must not be mounted on the public HTTP bind by default. +- The viewer uses admin-bind same-origin requests and only calls read-only endpoints. +- In `static_keys` mode, the viewer page may load without credentials, but data requests still require an admin bearer token. + +Admin read-only session mirror: +- POST /v2/admin/searches +- GET /v2/admin/searches/{search_id} +- GET /v2/admin/searches/{search_id}/timeline +- POST /v2/admin/searches/{search_id}/notes + +Behavior: +- These endpoints mirror the public progressive search session endpoints for local admin viewer use. +- They are read-only with respect to notes; detail hydration must default to `record_hits = false` when the viewer calls it. +- They require the same context headers as the public session endpoints, plus admin authentication when `security.auth_mode = "static_keys"`. + +Admin read-only note mirror: +- GET /v2/admin/notes +- GET /v2/admin/notes/{note_id} + +Behavior: +- These endpoints mirror the public note list/detail reads for local admin viewer use. +- Note metadata that includes `created_at`, `hit_count`, and `last_hit_at` is available through `GET /v2/admin/notes/{note_id}/provenance`. + POST /v2/admin/qdrant/rebuild Behavior: