huskies: merge 565_story_gateway_web_ui_shell_with_project_switcher
This commit is contained in:
+238
-1
@@ -9,7 +9,7 @@
|
|||||||
use poem::EndpointExt;
|
use poem::EndpointExt;
|
||||||
use poem::handler;
|
use poem::handler;
|
||||||
use poem::http::StatusCode;
|
use poem::http::StatusCode;
|
||||||
use poem::web::Data;
|
use poem::web::{Data, Json};
|
||||||
use poem::{Body, Request, Response};
|
use poem::{Body, Request, Response};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -545,6 +545,240 @@ pub async fn gateway_health_handler(state: Data<&Arc<GatewayState>>) -> Response
|
|||||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gateway Web UI ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Self-contained HTML page for the gateway web UI. Fetches project list from
|
||||||
|
/// `/api/gateway` and switches projects via `POST /api/gateway/switch`, which
|
||||||
|
/// internally calls the `switch_project` MCP tool logic.
|
||||||
|
const GATEWAY_UI_HTML: &str = r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Huskies Gateway</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.logo { font-size: 1.75rem; }
|
||||||
|
h1 { font-size: 1.25rem; font-weight: 600; color: #f8fafc; }
|
||||||
|
.subtitle { font-size: 0.8rem; color: #64748b; margin-top: 0.125rem; }
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.875rem center;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.25); }
|
||||||
|
.active-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
background: rgba(99,102,241,0.15);
|
||||||
|
border: 1px solid rgba(99,102,241,0.4);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #a5b4fc;
|
||||||
|
margin-top: 0.875rem;
|
||||||
|
}
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; }
|
||||||
|
.status { margin-top: 1rem; font-size: 0.8rem; color: #64748b; min-height: 1.25rem; }
|
||||||
|
.status.ok { color: #4ade80; }
|
||||||
|
.status.err { color: #f87171; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="header">
|
||||||
|
<span class="logo">🐺</span>
|
||||||
|
<div>
|
||||||
|
<h1>Huskies Gateway</h1>
|
||||||
|
<div class="subtitle">Multi-project orchestration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="project-select">Active Project</label>
|
||||||
|
<select id="project-select" onchange="switchProject(this.value)">
|
||||||
|
<option disabled>Loading…</option>
|
||||||
|
</select>
|
||||||
|
<div id="active-label" class="active-badge" style="display:none">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span id="active-name"></span>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function loadState() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/gateway');
|
||||||
|
const data = await r.json();
|
||||||
|
const sel = document.getElementById('project-select');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
for (const p of data.projects) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.name;
|
||||||
|
opt.textContent = p.name + ' — ' + p.url;
|
||||||
|
if (p.name === data.active) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
document.getElementById('active-name').textContent = data.active;
|
||||||
|
document.getElementById('active-label').style.display = 'inline-flex';
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('status').textContent = 'Failed to load state: ' + e;
|
||||||
|
document.getElementById('status').className = 'status err';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function switchProject(name) {
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
statusEl.className = 'status';
|
||||||
|
statusEl.textContent = 'Switching…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/gateway/switch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({project: name})
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.ok) {
|
||||||
|
document.getElementById('active-name').textContent = name;
|
||||||
|
statusEl.className = 'status ok';
|
||||||
|
statusEl.textContent = 'Switched to ' + name;
|
||||||
|
} else {
|
||||||
|
statusEl.className = 'status err';
|
||||||
|
statusEl.textContent = data.error || 'Switch failed';
|
||||||
|
loadState();
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
statusEl.className = 'status err';
|
||||||
|
statusEl.textContent = 'Error: ' + e;
|
||||||
|
loadState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadState();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Serve the gateway web UI HTML page at `GET /`.
|
||||||
|
#[handler]
|
||||||
|
pub async fn gateway_index_handler() -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
.body(Body::from(GATEWAY_UI_HTML))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/gateway` — returns the list of registered projects and the active project.
|
||||||
|
#[handler]
|
||||||
|
pub async fn gateway_api_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
||||||
|
let active = state.active_project.read().await.clone();
|
||||||
|
let projects: Vec<Value> = state
|
||||||
|
.config
|
||||||
|
.projects
|
||||||
|
.iter()
|
||||||
|
.map(|(name, entry)| {
|
||||||
|
json!({
|
||||||
|
"name": name,
|
||||||
|
"url": entry.url,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let body = json!({ "active": active, "projects": projects });
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for `POST /api/gateway/switch`.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SwitchRequest {
|
||||||
|
project: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/gateway/switch` — switch the active project by calling the
|
||||||
|
/// `switch_project` MCP tool logic, then return `{"ok": true}` or `{"ok": false, "error": "..."}`.
|
||||||
|
#[handler]
|
||||||
|
pub async fn gateway_switch_handler(
|
||||||
|
state: Data<&Arc<GatewayState>>,
|
||||||
|
body: Json<SwitchRequest>,
|
||||||
|
) -> Response {
|
||||||
|
let params = json!({ "arguments": { "project": body.project } });
|
||||||
|
let resp = handle_switch_project(¶ms, &state).await;
|
||||||
|
|
||||||
|
let (ok, error) = if resp.result.is_some() {
|
||||||
|
(true, None)
|
||||||
|
} else {
|
||||||
|
let msg = resp
|
||||||
|
.error
|
||||||
|
.as_ref()
|
||||||
|
.map(|e| e.message.clone())
|
||||||
|
.unwrap_or_else(|| "unknown error".to_string());
|
||||||
|
(false, Some(msg))
|
||||||
|
};
|
||||||
|
|
||||||
|
let body_val = if ok {
|
||||||
|
json!({ "ok": true })
|
||||||
|
} else {
|
||||||
|
json!({ "ok": false, "error": error })
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = if ok {
|
||||||
|
StatusCode::OK
|
||||||
|
} else {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
};
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_vec(&body_val).unwrap_or_default(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Gateway server startup ───────────────────────────────────────────
|
// ── Gateway server startup ───────────────────────────────────────────
|
||||||
|
|
||||||
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
|
/// Start the gateway HTTP server. This is the entry point when `--gateway` is used.
|
||||||
@@ -588,6 +822,9 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let route = poem::Route::new()
|
let route = poem::Route::new()
|
||||||
|
.at("/", poem::get(gateway_index_handler))
|
||||||
|
.at("/api/gateway", poem::get(gateway_api_handler))
|
||||||
|
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
|
||||||
.at(
|
.at(
|
||||||
"/mcp",
|
"/mcp",
|
||||||
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
|
poem::post(gateway_mcp_post_handler).get(gateway_mcp_get_handler),
|
||||||
|
|||||||
Reference in New Issue
Block a user