use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, Query, State,
},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::json;
use tower_http::services::{ServeDir, ServeFile};
use crate::api::webview::Snapshot;
use crate::store::{Store, TriggerError};
#[derive(Clone)]
pub struct AppState {
pub store: Arc<Store>,
pub csrf_token: String,
}
pub fn router(state: AppState, web_dir: &str) -> Router {
let index = format!("{web_dir}/index.html");
let static_service = ServeDir::new(web_dir).fallback(ServeFile::new(index));
Router::new()
.route("/api/websocket_token", get(websocket_token))
.route("/ws/view", get(view_websocket))
.route("/api/view", get(view_json))
.route("/api/snapshot/:id", get(snapshot_json))
.route("/api/trigger", post(handle_trigger))
.route("/api/override/trigger_mode", post(handle_override_trigger_mode))
.route("/api/set_tiltfile_args", post(handle_set_tiltfile_args))
.route("/api/analytics", post(handle_analytics))
.route("/api/analytics_opt", post(handle_analytics_opt))
.route("/proxy/apis/tilt.dev/v1alpha1/uibuttons", get(list_buttons))
.route("/proxy/apis/tilt.dev/v1alpha1/uibuttons/:name", get(get_button))
.route(
"/proxy/apis/tilt.dev/v1alpha1/uibuttons/:name/status",
axum::routing::put(put_button_status),
)
.fallback_service(static_service)
.with_state(state)
}
async fn list_buttons(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({
"apiVersion": "tilt.dev/v1alpha1",
"kind": "UIButtonList",
"metadata": {},
"items": state.store.list_buttons(),
}))
}
async fn get_button(State(state): State<AppState>, Path(name): Path<String>) -> Response {
match state.store.get_button(&name) {
Some(b) => Json(b).into_response(),
None => (StatusCode::NOT_FOUND, "no such uibutton\n").into_response(),
}
}
async fn put_button_status(
State(state): State<AppState>,
Path(name): Path<String>,
Json(body): Json<crate::api::v1alpha1::UIButton>,
) -> Response {
let inputs = body
.status
.as_ref()
.map(|s| s.inputs.clone())
.unwrap_or_default();
let Some(button) = state.store.record_button_click(&name, inputs) else {
return (StatusCode::NOT_FOUND, "no such uibutton\n").into_response();
};
let btype = button
.metadata
.as_ref()
.and_then(|m| m.annotations.as_ref())
.and_then(|a| a.get("tilt.dev/uibutton-type"))
.cloned()
.unwrap_or_default();
let target = button
.spec
.as_ref()
.map(|s| s.location.component_id.clone())
.unwrap_or_default();
if btype == "DisableToggle" && !target.is_empty() {
let now_disabled = !state.store.is_resource_disabled(&target);
state.store.set_resource_disabled(&target, now_disabled);
state.store.append_log(
Some(&target),
"INFO",
&format!(
"{} via web UI\n",
if now_disabled { "Disabled" } else { "Enabled" }
),
);
}
Json(button).into_response()
}
async fn websocket_token(State(state): State<AppState>) -> impl IntoResponse {
state.csrf_token.clone()
}
async fn view_json(State(state): State<AppState>) -> impl IntoResponse {
Json(state.store.full_view())
}
async fn snapshot_json(
State(state): State<AppState>,
Path(_id): Path<String>,
) -> impl IntoResponse {
let view = state.store.full_view();
Json(Snapshot {
created_at: view.tilt_start_time.clone(),
view: Some(view),
..Default::default()
})
}
async fn view_websocket(
ws: WebSocketUpgrade,
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
if params.get("csrf").map(String::as_str) != Some(state.csrf_token.as_str()) {
return (StatusCode::FORBIDDEN, "bad csrf token").into_response();
}
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: AppState) {
let full = state.store.full_view();
if send_view(&mut socket, &full).await.is_err() {
return;
}
let mut checkpoint = state.store.log_len();
let mut rx = state.store.subscribe();
loop {
tokio::select! {
inbound = socket.recv() => {
match inbound {
Some(Ok(Message::Close(_))) | None => break,
Some(Err(_)) => break,
_ => continue,
}
}
recv = rx.recv() => {
match recv {
Ok(()) => {}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
}
tokio::time::sleep(Duration::from_millis(200)).await;
while rx.try_recv().is_ok() {}
let (delta, next) = state.store.delta_view(checkpoint);
checkpoint = next;
if send_view(&mut socket, &delta).await.is_err() {
break;
}
}
}
}
}
async fn send_view(
socket: &mut WebSocket,
view: &crate::api::webview::View,
) -> Result<(), axum::Error> {
let text = serde_json::to_string(view).expect("View serializes");
socket.send(Message::Text(text)).await
}
#[derive(Deserialize)]
struct TriggerRequest {
manifest_names: Vec<String>,
#[serde(default)]
#[allow(dead_code)]
build_reason: i32,
}
async fn handle_trigger(
State(state): State<AppState>,
Json(req): Json<TriggerRequest>,
) -> Response {
if req.manifest_names.len() != 1 {
return (
StatusCode::BAD_REQUEST,
"/api/trigger requires exactly one manifest name\n",
)
.into_response();
}
match state.store.trigger(&req.manifest_names[0]) {
Ok(()) => StatusCode::OK.into_response(),
Err(TriggerError::NotFound) => (
StatusCode::NOT_FOUND,
format!("no resource named {:?}\n", req.manifest_names[0]),
)
.into_response(),
Err(TriggerError::EngineGone) => (
StatusCode::INTERNAL_SERVER_ERROR,
"build engine not running\n",
)
.into_response(),
Err(TriggerError::BadMode) => StatusCode::BAD_REQUEST.into_response(),
}
}
#[derive(Deserialize)]
struct OverrideTriggerModeRequest {
manifest_names: Vec<String>,
trigger_mode: i32,
}
async fn handle_override_trigger_mode(
State(state): State<AppState>,
Json(req): Json<OverrideTriggerModeRequest>,
) -> Response {
match state
.store
.set_trigger_mode(&req.manifest_names, req.trigger_mode)
{
Ok(()) => StatusCode::OK.into_response(),
Err(TriggerError::BadMode) => {
(StatusCode::BAD_REQUEST, "invalid trigger mode\n").into_response()
}
Err(TriggerError::NotFound) => {
(StatusCode::BAD_REQUEST, "unknown manifest\n").into_response()
}
Err(TriggerError::EngineGone) => {
(StatusCode::INTERNAL_SERVER_ERROR, "build engine not running\n").into_response()
}
}
}
async fn handle_set_tiltfile_args(Json(args): Json<Vec<String>>) -> Response {
tracing::info!(?args, "set_tiltfile_args (accepted; live arg-injection into the Starlingfile not implemented yet)");
StatusCode::OK.into_response()
}
async fn handle_analytics(Json(_payload): Json<serde_json::Value>) -> Response {
StatusCode::OK.into_response()
}
async fn handle_analytics_opt(Json(_payload): Json<serde_json::Value>) -> Response {
StatusCode::OK.into_response()
}
#[allow(dead_code)]
fn json_err(status: StatusCode, msg: &str) -> Response {
(status, Json(json!({ "error": msg }))).into_response()
}