huskies: merge 782
This commit is contained in:
@@ -0,0 +1,493 @@
|
||||
//! REST HTTP handlers for the gateway: agents, projects, bot configuration, and pipeline.
|
||||
|
||||
use crate::service::gateway::{self, GatewayState};
|
||||
use poem::handler;
|
||||
use poem::http::StatusCode;
|
||||
use poem::web::Path as PoemPath;
|
||||
use poem::web::{Data, Json};
|
||||
use poem::{Body, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
// ── Agent REST handlers ───────────────────────────────────────────────────────
|
||||
|
||||
/// `GET /gateway/mode` — returns `{"mode":"gateway"}`.
|
||||
#[handler]
|
||||
pub async fn gateway_mode_handler() -> Response {
|
||||
let body = json!({ "mode": "gateway" });
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// `POST /gateway/tokens` — generate a one-time join token.
|
||||
#[handler]
|
||||
pub async fn gateway_generate_token_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
||||
let token = gateway::generate_join_token(&state).await;
|
||||
let body = json!({ "token": token });
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// `GET /gateway/agents` — list all alive build agents registered in the CRDT.
|
||||
#[handler]
|
||||
pub async fn gateway_list_agents_handler(_state: Data<&Arc<GatewayState>>) -> Response {
|
||||
let agents = gateway::list_agents();
|
||||
let body = serde_json::to_vec(&agents).unwrap_or_default();
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body))
|
||||
}
|
||||
|
||||
/// Request body for assigning an agent to a project.
|
||||
#[derive(Deserialize)]
|
||||
struct AssignAgentRequest {
|
||||
project: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /gateway/agents/:id/assign` — assign (or unassign) an agent to a project.
|
||||
#[handler]
|
||||
pub async fn gateway_assign_agent_handler(
|
||||
PoemPath(id): PoemPath<String>,
|
||||
body: Json<AssignAgentRequest>,
|
||||
state: Data<&Arc<GatewayState>>,
|
||||
) -> Response {
|
||||
match gateway::assign_agent(&state, &id, body.0.project).await {
|
||||
Ok(agent) => {
|
||||
let body = serde_json::to_vec(&agent).unwrap_or_default();
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(body))
|
||||
}
|
||||
Err(gateway::Error::ProjectNotFound(msg)) => Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from(msg)),
|
||||
Err(_) => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from("agent not found")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway Web UI ────────────────────────────────────────────────────────────
|
||||
|
||||
/// `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
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.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.
|
||||
#[handler]
|
||||
pub async fn gateway_switch_handler(
|
||||
state: Data<&Arc<GatewayState>>,
|
||||
body: Json<SwitchRequest>,
|
||||
) -> Response {
|
||||
match gateway::switch_project(&state, &body.project).await {
|
||||
Ok(_) => {
|
||||
let body_val = json!({ "ok": true });
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body_val).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
Err(e) => {
|
||||
let body_val = json!({ "ok": false, "error": e.to_string() });
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body_val).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Project management API ────────────────────────────────────────────────────
|
||||
|
||||
/// Request body for adding a new project.
|
||||
#[derive(Deserialize)]
|
||||
struct AddProjectRequest {
|
||||
name: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// `POST /api/gateway/projects` — add a new project.
|
||||
#[handler]
|
||||
pub async fn gateway_add_project_handler(
|
||||
state: Data<&Arc<GatewayState>>,
|
||||
body: Json<AddProjectRequest>,
|
||||
) -> Response {
|
||||
match gateway::add_project(&state, &body.name, &body.url).await {
|
||||
Ok(()) => {
|
||||
let name = body.0.name.trim().to_string();
|
||||
let url = body.0.url.trim().to_string();
|
||||
let body_val = json!({ "name": name, "url": url });
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&body_val).unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
Err(gateway::Error::DuplicateToken(_)) => Response::builder()
|
||||
.status(StatusCode::CONFLICT)
|
||||
.body(Body::from(format!(
|
||||
"project '{}' already exists",
|
||||
body.0.name.trim()
|
||||
))),
|
||||
Err(e) => Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/gateway/projects/:name` — remove a project.
|
||||
#[handler]
|
||||
pub async fn gateway_remove_project_handler(
|
||||
PoemPath(name): PoemPath<String>,
|
||||
state: Data<&Arc<GatewayState>>,
|
||||
) -> Response {
|
||||
match gateway::remove_project(&state, &name).await {
|
||||
Ok(()) => Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(Body::empty()),
|
||||
Err(gateway::Error::ProjectNotFound(msg)) => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from(msg)),
|
||||
Err(e) => Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(Body::from(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bot configuration API ─────────────────────────────────────────────────────
|
||||
|
||||
/// Request/response body for the bot configuration API.
|
||||
#[derive(Deserialize, Serialize, Default)]
|
||||
pub(crate) struct BotConfigPayload {
|
||||
transport: String,
|
||||
homeserver: Option<String>,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
slack_bot_token: Option<String>,
|
||||
slack_signing_secret: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON.
|
||||
#[handler]
|
||||
pub async fn gateway_bot_config_get_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
||||
let fields = gateway::io::read_bot_config_raw(&state.config_dir);
|
||||
let payload = BotConfigPayload {
|
||||
transport: fields.transport,
|
||||
homeserver: fields.homeserver,
|
||||
username: fields.username,
|
||||
password: fields.password,
|
||||
slack_bot_token: fields.slack_bot_token,
|
||||
slack_signing_secret: fields.slack_signing_secret,
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&payload).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// `POST /api/gateway/bot-config` — write new bot.toml and restart the bot.
|
||||
#[handler]
|
||||
pub async fn gateway_bot_config_save_handler(
|
||||
state: Data<&Arc<GatewayState>>,
|
||||
body: Json<BotConfigPayload>,
|
||||
) -> Response {
|
||||
let content = gateway::config::serialize_bot_config(
|
||||
&body.transport,
|
||||
body.homeserver.as_deref(),
|
||||
body.username.as_deref(),
|
||||
body.password.as_deref(),
|
||||
body.slack_bot_token.as_deref(),
|
||||
body.slack_signing_secret.as_deref(),
|
||||
);
|
||||
|
||||
match gateway::save_bot_config_and_restart(&state, &content).await {
|
||||
Ok(()) => {
|
||||
let ok = json!({ "ok": true });
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&ok).unwrap_or_default()))
|
||||
}
|
||||
Err(e) => {
|
||||
let err = json!({ "ok": false, "error": e.to_string() });
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&err).unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/gateway/pipeline` — fetch pipeline status from all registered projects.
|
||||
#[handler]
|
||||
pub async fn gateway_all_pipeline_handler(state: Data<&Arc<GatewayState>>) -> Response {
|
||||
let project_urls: BTreeMap<String, String> = state
|
||||
.projects
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(n, e)| (n.clone(), e.url.clone()))
|
||||
.collect();
|
||||
|
||||
let results =
|
||||
gateway::io::fetch_all_project_pipeline_statuses(&project_urls, &state.client).await;
|
||||
|
||||
let active = state.active_project.read().await.clone();
|
||||
let body = json!({ "active": active, "projects": results });
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||
}
|
||||
|
||||
// ── Bot config page ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Self-contained HTML page for bot configuration.
|
||||
const GATEWAY_BOT_CONFIG_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>Bot Configuration — 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: 520px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back {
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
.back:hover { color: #94a3b8; }
|
||||
.logo { font-size: 1.5rem; }
|
||||
h1 { font-size: 1.2rem; font-weight: 600; color: #f8fafc; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 2px rgba(99,102,241,0.25); }
|
||||
select {
|
||||
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;
|
||||
}
|
||||
.section { margin-top: 1rem; }
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid #334155;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #6366f1;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
button:hover { background: #4f46e5; }
|
||||
button:disabled { background: #334155; color: #64748b; cursor: not-allowed; }
|
||||
.status { margin-top: 0.875rem; 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">
|
||||
<a href="/" class="back">← Gateway</a>
|
||||
<span class="logo">🤖</span>
|
||||
<h1>Bot Configuration</h1>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="transport">Transport</label>
|
||||
<select id="transport" onchange="onTransportChange(this.value)">
|
||||
<option value="matrix">Matrix</option>
|
||||
<option value="slack">Slack</option>
|
||||
</select>
|
||||
</div>
|
||||
<hr class="divider">
|
||||
<div id="matrix-fields" class="section">
|
||||
<div class="field">
|
||||
<label for="homeserver">Homeserver URL</label>
|
||||
<input type="text" id="homeserver" placeholder="https://matrix.example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="username">Bot Username</label>
|
||||
<input type="text" id="username" placeholder="@bot:example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" placeholder="••••••••">
|
||||
</div>
|
||||
</div>
|
||||
<div id="slack-fields" class="section" style="display:none">
|
||||
<div class="field">
|
||||
<label for="slack-bot-token">Bot Token</label>
|
||||
<input type="password" id="slack-bot-token" placeholder="xoxb-…">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="slack-signing-secret">App / Signing Secret</label>
|
||||
<input type="password" id="slack-signing-secret" placeholder="Your signing secret">
|
||||
</div>
|
||||
</div>
|
||||
<button id="save-btn" onclick="save()">Save & Restart Bot</button>
|
||||
<div id="status" class="status"></div>
|
||||
</div>
|
||||
<script>
|
||||
function onTransportChange(v) {
|
||||
document.getElementById('matrix-fields').style.display = v === 'matrix' ? '' : 'none';
|
||||
document.getElementById('slack-fields').style.display = v === 'slack' ? '' : 'none';
|
||||
}
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/gateway/bot-config');
|
||||
const d = await r.json();
|
||||
document.getElementById('transport').value = d.transport || 'matrix';
|
||||
onTransportChange(d.transport || 'matrix');
|
||||
document.getElementById('homeserver').value = d.homeserver || '';
|
||||
document.getElementById('username').value = d.username || '';
|
||||
document.getElementById('password').value = d.password || '';
|
||||
document.getElementById('slack-bot-token').value = d.slack_bot_token || '';
|
||||
document.getElementById('slack-signing-secret').value = d.slack_signing_secret || '';
|
||||
} catch(e) {
|
||||
document.getElementById('status').textContent = 'Failed to load config: ' + e;
|
||||
document.getElementById('status').className = 'status err';
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
const btn = document.getElementById('save-btn');
|
||||
const statusEl = document.getElementById('status');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving\u2026';
|
||||
statusEl.className = 'status';
|
||||
statusEl.textContent = '';
|
||||
const transport = document.getElementById('transport').value;
|
||||
const payload = { transport };
|
||||
if (transport === 'matrix') {
|
||||
payload.homeserver = document.getElementById('homeserver').value;
|
||||
payload.username = document.getElementById('username').value;
|
||||
payload.password = document.getElementById('password').value;
|
||||
} else {
|
||||
payload.slack_bot_token = document.getElementById('slack-bot-token').value;
|
||||
payload.slack_signing_secret = document.getElementById('slack-signing-secret').value;
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/gateway/bot-config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
statusEl.className = 'status ok';
|
||||
statusEl.textContent = 'Saved \u2014 bot restarted with new credentials.';
|
||||
} else {
|
||||
statusEl.className = 'status err';
|
||||
statusEl.textContent = d.error || 'Save failed';
|
||||
}
|
||||
} catch(e) {
|
||||
statusEl.className = 'status err';
|
||||
statusEl.textContent = 'Error: ' + e;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save & Restart Bot';
|
||||
}
|
||||
loadConfig();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
/// Serve the bot configuration HTML page at `GET /bot-config`.
|
||||
#[handler]
|
||||
pub async fn gateway_bot_config_page_handler() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "text/html; charset=utf-8")
|
||||
.body(Body::from(GATEWAY_BOT_CONFIG_HTML))
|
||||
}
|
||||
Reference in New Issue
Block a user