/api/logs now accepts sort_by (an already-declared param name) and sort_missing_fallback: lines carrying that param are ordered by its value, while lines missing it are either appended in ottyl_seq order (fallback, the default) or dropped from the response. Default ottyl_ts-tracking order is unchanged when sort_by is omitted. The log viewer (both the inline panel and its popout) exposes this as a "Sort by" control next to the column picker, persisted to localStorage under one shared key (ottyl.logPreview.sort) — the same global-viewer- setting convention already used for visible param columns and the log viewer's height, since there's no per-dashboard scope for this to divide by. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
1579 lines
62 KiB
Rust
1579 lines
62 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use crate::dto::{
|
|
BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest,
|
|
CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto,
|
|
ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest,
|
|
SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest,
|
|
};
|
|
use crate::engine::{
|
|
DeclareMetricRequest, DeclareParamRequest, EngineHandle, EngineMessage, EngineQuery,
|
|
EngineResponse,
|
|
};
|
|
use crate::alerts::{deliver_ntfy, AlertError};
|
|
use crate::profile::{self, ParamDeclarationRecord, Profile, ProfileError, ProfileStore};
|
|
use crate::settings::{CssError, SettingsHandle};
|
|
use axum::extract::{Path as AxumPath, Query, State};
|
|
use axum::http::{header, StatusCode};
|
|
use axum::response::{IntoResponse, Json, Response};
|
|
use axum::routing::{delete, get, post, put};
|
|
use axum::Router;
|
|
use fs_embed::Dir as EmbedDir;
|
|
use tokio::sync::oneshot;
|
|
|
|
static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist");
|
|
|
|
#[derive(Clone)]
|
|
struct ApiState {
|
|
engine: EngineHandle,
|
|
settings: SettingsHandle,
|
|
/// Imported CSS files are stored alongside this path's parent directory (see `ApiState::css_dir`).
|
|
settings_path: Arc<PathBuf>,
|
|
}
|
|
|
|
impl ApiState {
|
|
fn css_dir(&self) -> PathBuf {
|
|
self.settings_path
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
}
|
|
|
|
/// Best-effort: a no-op if no profile is active, and I/O failures are only logged, since the engine declaration is already the source of truth.
|
|
fn persist_param_declaration(&self, request: &DeclareParamRequest) {
|
|
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
|
return;
|
|
};
|
|
let mut store = ProfileStore::load(&store_path);
|
|
if let Err(err) = store.record_param_declaration(&store_path, &profile_name, request) {
|
|
eprintln!(
|
|
"warning: failed to persist param declaration '{}' into profile '{profile_name}': {err}",
|
|
request.name
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Same as `persist_param_declaration`, for metric declarations. Also promotes any of the
|
|
/// metric's referenced params that only exist as auto-derived engine state (see
|
|
/// `promoted_param_records`) into persisted profile params, so a metric built against an
|
|
/// auto-detected logfmt key survives a restart instead of being rejected as "undeclared" once
|
|
/// declarations replay before any log line re-derives it.
|
|
async fn persist_metric_declaration(&self, request: &DeclareMetricRequest) {
|
|
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
|
return;
|
|
};
|
|
let mut store = ProfileStore::load(&store_path);
|
|
let existing_params = store.get(&profile_name).map(|p| p.params.clone()).unwrap_or_default();
|
|
let promoted = promoted_param_records(&self.engine, &existing_params, request).await;
|
|
if let Err(err) =
|
|
store.record_metric_declaration(&store_path, &profile_name, request, &promoted)
|
|
{
|
|
eprintln!(
|
|
"warning: failed to persist metric declaration '{}' into profile '{profile_name}': {err}",
|
|
request.name
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Same as `persist_metric_declaration`, for an in-place edit (`PUT /api/metrics/{name}`) — also drops the persisted record under `old_name` when the edit renames the metric, so a rename doesn't leave a stale duplicate declaration in the profile file.
|
|
async fn persist_metric_declaration_update(&self, old_name: &str, request: &DeclareMetricRequest) {
|
|
let Some((store_path, profile_name)) = self.active_profile_location() else {
|
|
return;
|
|
};
|
|
let mut store = ProfileStore::load(&store_path);
|
|
let existing_params = store.get(&profile_name).map(|p| p.params.clone()).unwrap_or_default();
|
|
let promoted = promoted_param_records(&self.engine, &existing_params, request).await;
|
|
if let Err(err) = store.record_metric_declaration_update(
|
|
&store_path,
|
|
&profile_name,
|
|
old_name,
|
|
request,
|
|
&promoted,
|
|
) {
|
|
eprintln!(
|
|
"warning: failed to persist metric declaration update '{}' into profile '{profile_name}': {err}",
|
|
request.name
|
|
);
|
|
}
|
|
}
|
|
|
|
fn active_profile_location(&self) -> Option<(PathBuf, String)> {
|
|
let settings = self.settings.lock().unwrap();
|
|
let profile_name = settings.profiles.active_profile.clone()?;
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &self.settings_path);
|
|
Some((profile::profiles_store_path(&profiles_dir), profile_name))
|
|
}
|
|
}
|
|
|
|
pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: PathBuf) -> Router {
|
|
let state = ApiState {
|
|
engine,
|
|
settings,
|
|
settings_path: Arc::new(settings_path),
|
|
};
|
|
|
|
Router::new()
|
|
.route("/api/logs", get(get_logs))
|
|
.route("/api/timeline", get(get_timeline))
|
|
.route("/api/stats", get(get_stats))
|
|
.route("/api/params", get(list_params).post(declare_param))
|
|
.route("/api/metrics", get(list_metrics).post(declare_metric))
|
|
.route(
|
|
"/api/metrics/{name}",
|
|
get(get_metric_bucket_values).put(update_metric),
|
|
)
|
|
.route("/api/query", get(run_promql_query))
|
|
.route("/api/settings", get(get_settings).patch(update_settings))
|
|
.route("/api/settings/css", get(list_css).post(import_css))
|
|
.route("/api/settings/css/link", post(link_css))
|
|
.route("/api/settings/css/{id}", delete(remove_css))
|
|
.route("/api/settings/css/{id}/content", get(get_css_content))
|
|
.route("/api/settings/alerts", get(list_alert_rules).post(create_alert_rule))
|
|
.route(
|
|
"/api/settings/alerts/{id}",
|
|
put(update_alert_rule).delete(delete_alert_rule),
|
|
)
|
|
.route("/api/settings/alerts/test", post(test_alert_notification))
|
|
.route("/api/settings/profiles", get(list_profiles).post(create_profile))
|
|
.route("/api/settings/profiles/{name}", delete(delete_profile))
|
|
.route("/api/settings/profiles/{name}/activate", post(activate_profile))
|
|
.route(
|
|
"/api/settings/profiles/{name}/dashboards",
|
|
get(list_dashboards).post(create_dashboard),
|
|
)
|
|
.route(
|
|
"/api/settings/profiles/{name}/dashboards/{dashboard_id}",
|
|
get(get_dashboard).put(save_dashboard).delete(delete_dashboard),
|
|
)
|
|
.with_state(state)
|
|
.fallback(serve_webapp)
|
|
}
|
|
|
|
/// Falls back to `index.html` for unmatched paths so client-side routing works.
|
|
async fn serve_webapp(uri: axum::http::Uri) -> Response {
|
|
let path = uri.path().trim_start_matches('/');
|
|
let file = WEBAPP_DIST
|
|
.get_file(path)
|
|
.or_else(|| WEBAPP_DIST.get_file("index.html"));
|
|
match file {
|
|
Some(file) => {
|
|
let mime = mime_for_extension(file.extension().unwrap_or(""));
|
|
let bytes = file.read_bytes().unwrap_or_default();
|
|
([(header::CONTENT_TYPE, mime)], bytes).into_response()
|
|
}
|
|
None => StatusCode::NOT_FOUND.into_response(),
|
|
}
|
|
}
|
|
|
|
fn mime_for_extension(extension: &str) -> &'static str {
|
|
match extension {
|
|
"html" => "text/html; charset=utf-8",
|
|
"js" => "text/javascript; charset=utf-8",
|
|
"css" => "text/css; charset=utf-8",
|
|
"svg" => "image/svg+xml",
|
|
"json" => "application/json",
|
|
"png" => "image/png",
|
|
"ico" => "image/x-icon",
|
|
"woff" => "font/woff",
|
|
"woff2" => "font/woff2",
|
|
_ => "application/octet-stream",
|
|
}
|
|
}
|
|
|
|
async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse, StatusCode> {
|
|
let (respond_to, response) = oneshot::channel();
|
|
engine
|
|
.send(EngineMessage::Query { query, respond_to })
|
|
.await
|
|
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
|
|
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
|
|
}
|
|
|
|
/// Any of `request`'s referenced params (`label_params`/`value_param`) not already present in
|
|
/// `existing_params` are looked up live in the engine and returned as records ready to persist
|
|
/// alongside the metric (see `ProfileStore::record_metric_declaration`'s `promoted_params`). A
|
|
/// metric can validly reference a param that's only auto-derived engine state (see
|
|
/// `crate::engine::auto_declare_params`) — that param was never written into any profile, so without
|
|
/// this promotion the metric would be rejected as "undeclared" the next time declarations replay on
|
|
/// startup, before any log line has re-derived it.
|
|
async fn promoted_param_records(
|
|
engine: &EngineHandle,
|
|
existing_params: &[ParamDeclarationRecord],
|
|
request: &DeclareMetricRequest,
|
|
) -> Vec<ParamDeclarationRecord> {
|
|
let missing: Vec<&str> = request
|
|
.label_params
|
|
.iter()
|
|
.map(String::as_str)
|
|
.chain(request.value_param.as_deref())
|
|
.filter(|name| !existing_params.iter().any(|p| p.name == **name))
|
|
.collect();
|
|
if missing.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let Ok(EngineResponse::ParamDeclarations(declarations)) =
|
|
ask(engine, EngineQuery::ParamDeclarations).await
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
declarations
|
|
.into_iter()
|
|
.filter(|param| missing.contains(¶m.name.as_str()))
|
|
.map(|param| ParamDeclarationRecord {
|
|
name: param.name,
|
|
message_match_regex: param.message_match_regex,
|
|
capture_group: param.capture_group,
|
|
json_field_path: param.json_field_path,
|
|
kind: param.kind.into(),
|
|
elapsed_since_last: param.elapsed_since_last,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
async fn get_logs(
|
|
State(state): State<ApiState>,
|
|
Query(params): Query<LogsParams>,
|
|
) -> impl IntoResponse {
|
|
match ask(&state.engine, EngineQuery::Logs(params)).await {
|
|
Ok(EngineResponse::Logs(entries)) => Json(entries).into_response(),
|
|
Ok(EngineResponse::Error(err)) => {
|
|
(StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response()
|
|
}
|
|
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
Err(status) => status.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_timeline(
|
|
State(state): State<ApiState>,
|
|
Query(params): Query<TimelineParams>,
|
|
) -> impl IntoResponse {
|
|
match ask(&state.engine, EngineQuery::Timeline(params)).await {
|
|
Ok(EngineResponse::Timeline(buckets)) => Json(buckets).into_response(),
|
|
Ok(EngineResponse::Error(err)) => {
|
|
(StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response()
|
|
}
|
|
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
Err(status) => status.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
|
|
match ask(&state.engine, EngineQuery::Stats).await {
|
|
Ok(EngineResponse::Stats(stats)) => Json(stats).into_response(),
|
|
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
Err(status) => status.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_params(State(state): State<ApiState>) -> impl IntoResponse {
|
|
match ask(&state.engine, EngineQuery::ParamDeclarations).await {
|
|
Ok(EngineResponse::ParamDeclarations(declarations)) => Json(declarations).into_response(),
|
|
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
Err(status) => status.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn declare_param(
|
|
State(state): State<ApiState>,
|
|
Json(body): Json<DeclareParamRequest>,
|
|
) -> impl IntoResponse {
|
|
let (respond_to, response) = oneshot::channel();
|
|
let request = body.clone();
|
|
if state
|
|
.engine
|
|
.send(EngineMessage::DeclareParam { request: body, respond_to })
|
|
.await
|
|
.is_err()
|
|
{
|
|
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
|
}
|
|
match response.await {
|
|
Ok(Ok(())) => {
|
|
state.persist_param_declaration(&request);
|
|
let dto = ParamDeclarationDto {
|
|
name: request.name,
|
|
message_match_regex: request.message_match_regex,
|
|
capture_group: request.capture_group,
|
|
json_field_path: request.json_field_path,
|
|
kind: request.kind,
|
|
elapsed_since_last: request.elapsed_since_last,
|
|
// A request that reached here was accepted by `declare_param`, which always stores
|
|
// it as non-auto (see `engine::declare_param`).
|
|
auto_derived: false,
|
|
};
|
|
(StatusCode::CREATED, Json(dto)).into_response()
|
|
}
|
|
Ok(Err(err)) => (StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response(),
|
|
Err(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_metrics(State(state): State<ApiState>) -> impl IntoResponse {
|
|
match ask(&state.engine, EngineQuery::MetricDeclarations).await {
|
|
Ok(EngineResponse::MetricDeclarations(declarations)) => Json(declarations).into_response(),
|
|
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
Err(status) => status.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn declare_metric(
|
|
State(state): State<ApiState>,
|
|
Json(body): Json<DeclareMetricRequest>,
|
|
) -> impl IntoResponse {
|
|
let (respond_to, response) = oneshot::channel();
|
|
let request = body.clone();
|
|
if state
|
|
.engine
|
|
.send(EngineMessage::DeclareMetric { request: body, respond_to })
|
|
.await
|
|
.is_err()
|
|
{
|
|
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
|
}
|
|
match response.await {
|
|
Ok(Ok(())) => {
|
|
state.persist_metric_declaration(&request).await;
|
|
let dto = MetricDeclarationDto {
|
|
name: request.name,
|
|
message_match_regex: request.message_match_regex,
|
|
label_params: request.label_params,
|
|
value_param: request.value_param,
|
|
matched_labels: request.matched_labels,
|
|
unmatched_labels: request.unmatched_labels,
|
|
};
|
|
(StatusCode::CREATED, Json(dto)).into_response()
|
|
}
|
|
Ok(Err(err)) => (StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response(),
|
|
Err(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Updates the metric declaration named by the URL path in place, reusing `declare_metric`'s validation (via `engine::update_metric`) rather than creating a second declaration or erroring on a duplicate-name conflict. `body.name` may differ from the path segment to rename the metric, so long as no other declaration already uses the new name.
|
|
async fn update_metric(
|
|
State(state): State<ApiState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
Json(body): Json<DeclareMetricRequest>,
|
|
) -> impl IntoResponse {
|
|
let (respond_to, response) = oneshot::channel();
|
|
let request = body.clone();
|
|
if state
|
|
.engine
|
|
.send(EngineMessage::UpdateMetric { name: name.clone(), request: body, respond_to })
|
|
.await
|
|
.is_err()
|
|
{
|
|
return StatusCode::SERVICE_UNAVAILABLE.into_response();
|
|
}
|
|
match response.await {
|
|
Ok(Ok(())) => {
|
|
state.persist_metric_declaration_update(&name, &request).await;
|
|
let dto = MetricDeclarationDto {
|
|
name: request.name,
|
|
message_match_regex: request.message_match_regex,
|
|
label_params: request.label_params,
|
|
value_param: request.value_param,
|
|
matched_labels: request.matched_labels,
|
|
unmatched_labels: request.unmatched_labels,
|
|
};
|
|
(StatusCode::OK, Json(dto)).into_response()
|
|
}
|
|
Ok(Err(err)) => (StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response(),
|
|
Err(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Malformed pairs (no `=`) are skipped rather than rejected.
|
|
fn parse_labels_filter(raw: &str) -> BTreeMap<String, String> {
|
|
raw.split(',')
|
|
.filter_map(|pair| pair.split_once('='))
|
|
.map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
|
|
.collect()
|
|
}
|
|
|
|
async fn get_metric_bucket_values(
|
|
State(state): State<ApiState>,
|
|
AxumPath(metric_name): AxumPath<String>,
|
|
Query(params): Query<BucketValuesParams>,
|
|
) -> impl IntoResponse {
|
|
let labels_filter = params.labels.as_deref().map(parse_labels_filter);
|
|
let query = EngineQuery::BucketValues {
|
|
metric_name,
|
|
labels_filter,
|
|
start_ms: params.start_ms,
|
|
end_ms: params.end_ms,
|
|
since_sequence: params.since_sequence,
|
|
limit: params.limit,
|
|
};
|
|
match ask(&state.engine, query).await {
|
|
Ok(EngineResponse::BucketValues(values)) => Json(values).into_response(),
|
|
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
Err(status) => status.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn run_promql_query(
|
|
State(state): State<ApiState>,
|
|
Query(params): Query<PromqlParams>,
|
|
) -> impl IntoResponse {
|
|
let query = EngineQuery::Promql {
|
|
query: params.promql,
|
|
start_ms: params.start_ms,
|
|
end_ms: params.end_ms,
|
|
};
|
|
match ask(&state.engine, query).await {
|
|
Ok(EngineResponse::Promql(Ok(series))) => Json(series).into_response(),
|
|
Ok(EngineResponse::Promql(Err(err))) => {
|
|
(StatusCode::BAD_REQUEST, Json(ErrorDto { error: err })).into_response()
|
|
}
|
|
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
|
Err(status) => status.into_response(),
|
|
}
|
|
}
|
|
|
|
fn css_error_response(err: CssError) -> axum::response::Response {
|
|
match err {
|
|
CssError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
|
CssError::NotImported => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
|
CssError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_css(State(state): State<ApiState>) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
|
|
Json(entries).into_response()
|
|
}
|
|
|
|
async fn import_css(
|
|
State(state): State<ApiState>,
|
|
Json(body): Json<ImportCssRequest>,
|
|
) -> impl IntoResponse {
|
|
let css_dir = state.css_dir();
|
|
let mut settings = state.settings.lock().unwrap();
|
|
match settings.import_css(&state.settings_path, &css_dir, body.filename, &body.content) {
|
|
Ok(id) => {
|
|
let entry = settings
|
|
.css_entries
|
|
.iter()
|
|
.find(|entry| crate::settings::css_entry_id(entry) == id)
|
|
.expect("entry was just inserted by import_css");
|
|
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
|
|
}
|
|
Err(err) => css_error_response(err),
|
|
}
|
|
}
|
|
|
|
/// Content is never copied, and the entry can never be deleted via `DELETE /api/settings/css/:id`.
|
|
async fn link_css(
|
|
State(state): State<ApiState>,
|
|
Json(body): Json<LinkCssRequest>,
|
|
) -> impl IntoResponse {
|
|
let mut settings = state.settings.lock().unwrap();
|
|
match settings.link_css(&state.settings_path, body.path_or_url.clone()) {
|
|
Ok(()) => {
|
|
let entry = settings
|
|
.css_entries
|
|
.last()
|
|
.expect("entry was just pushed by link_css");
|
|
(StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response()
|
|
}
|
|
Err(err) => css_error_response(err),
|
|
}
|
|
}
|
|
|
|
async fn remove_css(
|
|
State(state): State<ApiState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
) -> impl IntoResponse {
|
|
let css_dir = state.css_dir();
|
|
let mut settings = state.settings.lock().unwrap();
|
|
match settings.remove_css(&state.settings_path, &css_dir, &id) {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => css_error_response(err),
|
|
}
|
|
}
|
|
|
|
/// Rejects a linked entry with 400 — there's no local content; the frontend should use `path_or_url` directly.
|
|
async fn get_css_content(
|
|
State(state): State<ApiState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
) -> impl IntoResponse {
|
|
let css_dir = state.css_dir();
|
|
let settings = state.settings.lock().unwrap();
|
|
match settings.css_content(&css_dir, &id) {
|
|
Ok(content) => (
|
|
StatusCode::OK,
|
|
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
|
|
content,
|
|
)
|
|
.into_response(),
|
|
Err(err) => css_error_response(err),
|
|
}
|
|
}
|
|
|
|
fn alert_error_response(err: AlertError) -> axum::response::Response {
|
|
match err {
|
|
AlertError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
|
AlertError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_alert_rules(State(state): State<ApiState>) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
Json(settings.alert_rules.clone()).into_response()
|
|
}
|
|
|
|
async fn create_alert_rule(
|
|
State(state): State<ApiState>,
|
|
Json(body): Json<CreateAlertRuleRequest>,
|
|
) -> impl IntoResponse {
|
|
let mut settings = state.settings.lock().unwrap();
|
|
match settings.create_alert_rule(&state.settings_path, body.name, body.enabled, body.condition) {
|
|
Ok(rule) => (StatusCode::CREATED, Json(rule)).into_response(),
|
|
Err(err) => alert_error_response(err),
|
|
}
|
|
}
|
|
|
|
async fn update_alert_rule(
|
|
State(state): State<ApiState>,
|
|
AxumPath(id): AxumPath<String>,
|
|
Json(body): Json<UpdateAlertRuleRequest>,
|
|
) -> impl IntoResponse {
|
|
let mut settings = state.settings.lock().unwrap();
|
|
match settings.update_alert_rule(&state.settings_path, &id, body.name, body.enabled, body.condition) {
|
|
Ok(rule) => Json(rule).into_response(),
|
|
Err(err) => alert_error_response(err),
|
|
}
|
|
}
|
|
|
|
async fn delete_alert_rule(State(state): State<ApiState>, AxumPath(id): AxumPath<String>) -> impl IntoResponse {
|
|
let mut settings = state.settings.lock().unwrap();
|
|
match settings.remove_alert_rule(&state.settings_path, &id) {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => alert_error_response(err),
|
|
}
|
|
}
|
|
|
|
/// Delivers a test notification without a saved rule so the sink can be verified before saving.
|
|
async fn test_alert_notification(Json(body): Json<TestAlertNotificationRequest>) -> impl IntoResponse {
|
|
let server = body.server.unwrap_or_else(|| "https://ntfy.sh".to_string());
|
|
match deliver_ntfy(&server, &body.topic, "Test notification from ottyl").await {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => (StatusCode::BAD_GATEWAY, err).into_response(),
|
|
}
|
|
}
|
|
|
|
/// CSS entries have their own endpoint (`GET /api/settings/css`).
|
|
async fn get_settings(State(state): State<ApiState>) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
Json(SettingsDto::from(&*settings)).into_response()
|
|
}
|
|
|
|
/// `max_logs: null` clears the cap; a changed value is pushed to the engine so it applies without a restart.
|
|
async fn update_settings(
|
|
State(state): State<ApiState>,
|
|
Json(body): Json<UpdateSettingsRequest>,
|
|
) -> impl IntoResponse {
|
|
let max_logs_changed = body.max_logs.is_some();
|
|
let new_max_logs = {
|
|
let mut settings = state.settings.lock().unwrap();
|
|
if let Some(color_scheme) = body.color_scheme {
|
|
settings.color_scheme = color_scheme;
|
|
}
|
|
if let Some(max_logs) = body.max_logs {
|
|
settings.max_logs = max_logs;
|
|
}
|
|
if let Some(custom_theme_id) = body.custom_theme_id {
|
|
settings.custom_theme_id = custom_theme_id;
|
|
}
|
|
if let Some(ntfy_topic) = body.ntfy_topic {
|
|
settings.ntfy_topic = ntfy_topic;
|
|
}
|
|
if let Some(ntfy_server) = body.ntfy_server {
|
|
settings.ntfy_server = ntfy_server;
|
|
}
|
|
match settings.save(&state.settings_path) {
|
|
Ok(()) => (),
|
|
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
}
|
|
(SettingsDto::from(&*settings), settings.max_logs)
|
|
};
|
|
if max_logs_changed {
|
|
let _ = state
|
|
.engine
|
|
.send(EngineMessage::SetMaxLogs(new_max_logs.1))
|
|
.await;
|
|
}
|
|
Json(new_max_logs.0).into_response()
|
|
}
|
|
|
|
fn profile_error_response(err: ProfileError) -> axum::response::Response {
|
|
match err {
|
|
ProfileError::NotFound => (StatusCode::NOT_FOUND, err.to_string()).into_response(),
|
|
ProfileError::DuplicateName => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
|
ProfileError::CannotDeleteDefault => (StatusCode::BAD_REQUEST, err.to_string()).into_response(),
|
|
ProfileError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_profiles(State(state): State<ApiState>) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
|
let summaries: Vec<ProfileSummaryDto> = store.list().iter().map(ProfileSummaryDto::from).collect();
|
|
Json(summaries).into_response()
|
|
}
|
|
|
|
async fn create_profile(
|
|
State(state): State<ApiState>,
|
|
Json(body): Json<CreateProfileRequest>,
|
|
) -> impl IntoResponse {
|
|
let mut settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let mut store = ProfileStore::load(&store_path);
|
|
match store.create(&store_path, &mut settings, &state.settings_path, body.name.clone()) {
|
|
Ok(()) => {
|
|
let profile = store.get(&body.name).expect("profile was just created by store.create");
|
|
(StatusCode::CREATED, Json(ProfileSummaryDto::from(profile))).into_response()
|
|
}
|
|
Err(err) => profile_error_response(err),
|
|
}
|
|
}
|
|
|
|
/// If it was the active profile, `active_profile` is cleared, but the engine's already-declared params/metrics keep running until the next restart.
|
|
async fn delete_profile(
|
|
State(state): State<ApiState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
) -> impl IntoResponse {
|
|
let mut settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let mut store = ProfileStore::load(&store_path);
|
|
match store.delete(&store_path, &mut settings, &state.settings_path, &name) {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => profile_error_response(err),
|
|
}
|
|
}
|
|
|
|
/// Removes every currently-declared param/metric from the engine, then replays `profile`'s declarations into it.
|
|
/// Auto-derived params (`ParamDeclarationDto::auto_derived`) are left alone — they aren't part of
|
|
/// any profile, just facts discovered from the log stream, so a profile switch shouldn't discard
|
|
/// them (they'd otherwise only reappear once a new line re-introduces that key).
|
|
async fn reload_engine_declarations(engine: &EngineHandle, profile: &Profile) {
|
|
if let Ok(EngineResponse::ParamDeclarations(params)) =
|
|
ask(engine, EngineQuery::ParamDeclarations).await
|
|
{
|
|
for param in params.into_iter().filter(|param| !param.auto_derived) {
|
|
let (respond_to, response) = oneshot::channel();
|
|
if engine
|
|
.send(EngineMessage::RemoveParamDeclaration { name: param.name, respond_to })
|
|
.await
|
|
.is_ok()
|
|
{
|
|
let _ = response.await;
|
|
}
|
|
}
|
|
}
|
|
if let Ok(EngineResponse::MetricDeclarations(metrics)) =
|
|
ask(engine, EngineQuery::MetricDeclarations).await
|
|
{
|
|
for metric in metrics {
|
|
let (respond_to, response) = oneshot::channel();
|
|
if engine
|
|
.send(EngineMessage::RemoveMetricDeclaration { name: metric.name, respond_to })
|
|
.await
|
|
.is_ok()
|
|
{
|
|
let _ = response.await;
|
|
}
|
|
}
|
|
}
|
|
profile::load_into_engine(engine, profile).await;
|
|
}
|
|
|
|
async fn activate_profile(
|
|
State(state): State<ApiState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
) -> impl IntoResponse {
|
|
let activated_profile = {
|
|
let mut settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let store = ProfileStore::load(&store_path);
|
|
match store.set_active(&mut settings, &state.settings_path, Some(name.clone())) {
|
|
Ok(()) => Ok(store.get(&name).cloned()),
|
|
Err(err) => Err(err),
|
|
}
|
|
};
|
|
|
|
match activated_profile {
|
|
Ok(profile) => {
|
|
if let Some(profile) = profile {
|
|
reload_engine_declarations(&state.engine, &profile).await;
|
|
}
|
|
let settings = state.settings.lock().unwrap();
|
|
Json(SettingsDto::from(&*settings)).into_response()
|
|
}
|
|
Err(err) => profile_error_response(err),
|
|
}
|
|
}
|
|
|
|
async fn list_dashboards(
|
|
State(state): State<ApiState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
|
match store.list_dashboards(&name) {
|
|
Ok(dashboards) => {
|
|
let dtos: Vec<DashboardDto> = dashboards.iter().map(DashboardDto::from).collect();
|
|
Json(dtos).into_response()
|
|
}
|
|
Err(err) => profile_error_response(err),
|
|
}
|
|
}
|
|
|
|
async fn create_dashboard(
|
|
State(state): State<ApiState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
Json(body): Json<CreateDashboardRequest>,
|
|
) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let mut store = ProfileStore::load(&store_path);
|
|
match store.create_dashboard(&store_path, &name, body.name) {
|
|
Ok(dashboard) => (StatusCode::CREATED, Json(DashboardDto::from(&dashboard))).into_response(),
|
|
Err(err) => profile_error_response(err),
|
|
}
|
|
}
|
|
|
|
async fn get_dashboard(
|
|
State(state): State<ApiState>,
|
|
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
|
|
match store.list_dashboards(&name) {
|
|
Ok(dashboards) => match dashboards.iter().find(|d| d.id == dashboard_id) {
|
|
Some(dashboard) => Json(DashboardDto::from(dashboard)).into_response(),
|
|
None => profile_error_response(ProfileError::NotFound),
|
|
},
|
|
Err(err) => profile_error_response(err),
|
|
}
|
|
}
|
|
|
|
async fn save_dashboard(
|
|
State(state): State<ApiState>,
|
|
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
|
Json(body): Json<SaveDashboardRequest>,
|
|
) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let mut store = ProfileStore::load(&store_path);
|
|
let dashboard = crate::profile::Dashboard {
|
|
id: dashboard_id.clone(),
|
|
name: body.name,
|
|
panels: body.panels.iter().map(|p| p.into()).collect(),
|
|
};
|
|
match store.save_dashboard(&store_path, &name, &dashboard_id, dashboard) {
|
|
Ok(dashboard) => Json(DashboardDto::from(&dashboard)).into_response(),
|
|
Err(err) => profile_error_response(err),
|
|
}
|
|
}
|
|
|
|
async fn delete_dashboard(
|
|
State(state): State<ApiState>,
|
|
AxumPath((name, dashboard_id)): AxumPath<(String, String)>,
|
|
) -> impl IntoResponse {
|
|
let settings = state.settings.lock().unwrap();
|
|
let profiles_dir = profile::resolve_profiles_dir(&settings, &state.settings_path);
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let mut store = ProfileStore::load(&store_path);
|
|
match store.delete_dashboard(&store_path, &name, &dashboard_id) {
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => profile_error_response(err),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::engine::{self, IngestedLine};
|
|
use crate::settings::Settings;
|
|
use crate::timestamp::Timestamp;
|
|
use axum::body::Body;
|
|
use axum::http::Request;
|
|
use http_body_util::BodyExt;
|
|
use serde_json::{json, Value};
|
|
use std::sync::Mutex;
|
|
use tokio::sync::mpsc;
|
|
use tower::ServiceExt;
|
|
|
|
/// Spins up a fresh engine + router pair backed by a temp settings file.
|
|
fn test_app() -> (Router, EngineHandle, SettingsHandle, tempfile::TempDir) {
|
|
let (engine_tx, engine_rx) = mpsc::channel(128);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let settings_path = dir.path().join("settings.json");
|
|
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
|
|
|
|
tokio::spawn(engine::run(engine_rx, settings.clone()));
|
|
|
|
let app = router(engine_tx.clone(), settings.clone(), settings_path);
|
|
(app, engine_tx, settings, dir)
|
|
}
|
|
|
|
async fn send_json(app: &Router, method: &str, uri: &str, body: Value) -> (StatusCode, Value) {
|
|
let request = Request::builder()
|
|
.method(method)
|
|
.uri(uri)
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
let status = response.status();
|
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
|
let value = if bytes.is_empty() {
|
|
Value::Null
|
|
} else {
|
|
serde_json::from_slice(&bytes).unwrap()
|
|
};
|
|
(status, value)
|
|
}
|
|
|
|
async fn get_json(app: &Router, uri: &str) -> (StatusCode, Value) {
|
|
let request = Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap();
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
let status = response.status();
|
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
|
let value = if bytes.is_empty() {
|
|
Value::Null
|
|
} else {
|
|
serde_json::from_slice(&bytes).unwrap()
|
|
};
|
|
(status, value)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn alert_rule_crud_round_trips_through_http() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/settings/alerts",
|
|
json!({
|
|
"name": "error spike",
|
|
"enabled": true,
|
|
"condition": { "kind": "immediate", "query": "regex(\"error\")" }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert_eq!(body["name"], "error spike");
|
|
let id = body["id"].as_str().unwrap().to_string();
|
|
|
|
let (status, body) = get_json(&app, "/api/settings/alerts").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body.as_array().unwrap().len(), 1);
|
|
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"PUT",
|
|
&format!("/api/settings/alerts/{id}"),
|
|
json!({
|
|
"name": "renamed",
|
|
"enabled": false,
|
|
"condition": { "kind": "immediate", "query": "regex(\"warn\")" }
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["name"], "renamed");
|
|
assert_eq!(body["enabled"], false);
|
|
|
|
let status = delete_request(&app, &format!("/api/settings/alerts/{id}")).await;
|
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
|
let (_, body) = get_json(&app, "/api/settings/alerts").await;
|
|
assert!(body.as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn alert_rule_routes_404_for_unknown_id() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
|
|
let status = delete_request(&app, "/api/settings/alerts/does-not-exist").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
|
// AlertError::NotFound maps to a plain-text (not JSON) 404 body, so check status directly instead of via send_json.
|
|
let request = Request::builder()
|
|
.method("PUT")
|
|
.uri("/api/settings/alerts/does-not-exist")
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"name": "x",
|
|
"enabled": true,
|
|
"condition": { "kind": "immediate", "query": "regex(\"x\")" }
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(request).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn declares_param_and_returns_201() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/params",
|
|
json!({
|
|
"name": "level",
|
|
"message_match_regex": r"level=(?P<level>\w+)",
|
|
"capture_group": "level",
|
|
"kind": "string"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert_eq!(body["name"], "level");
|
|
assert_eq!(body["capture_group"], "level");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_invalid_param_regex_with_400_and_error_body() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/params",
|
|
json!({
|
|
"name": "level",
|
|
"message_match_regex": "level=(",
|
|
"capture_group": "level",
|
|
"kind": "string"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(body["error"].as_str().unwrap().contains("invalid regex"));
|
|
}
|
|
|
|
/// Declares a standalone param via `POST /api/params` — most metric-declaration tests need one
|
|
/// or more already-declared params to reference by name (ticket 037), rather than embedding a
|
|
/// regex/capture-group/kind inline in the metric request.
|
|
async fn declare_test_param(app: &Router, name: &str, pattern: &str, capture_group: &str, kind: &str) {
|
|
let (status, _body) = send_json(
|
|
app,
|
|
"POST",
|
|
"/api/params",
|
|
json!({
|
|
"name": name,
|
|
"message_match_regex": pattern,
|
|
"capture_group": capture_group,
|
|
"kind": kind,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn declares_metric_and_shows_up_in_list() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/metrics",
|
|
json!({
|
|
"name": "requests",
|
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
|
"label_params": ["path"]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert_eq!(body["name"], "requests");
|
|
|
|
let (status, body) = get_json(&app, "/api/metrics").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let metrics = body.as_array().unwrap();
|
|
assert_eq!(metrics.len(), 1);
|
|
assert_eq!(metrics[0]["name"], "requests");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rejects_metric_referencing_undeclared_param() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/metrics",
|
|
json!({
|
|
"name": "requests",
|
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
|
"label_params": ["duration"]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(body["error"].as_str().unwrap().contains("duration"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn updates_metric_declaration_in_place_rather_than_duplicating() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
|
declare_test_param(&app, "duration", r"duration=(?P<duration>\d+)", "duration", "number").await;
|
|
send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/metrics",
|
|
json!({
|
|
"name": "requests",
|
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
|
"label_params": ["path"]
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"PUT",
|
|
"/api/metrics/requests",
|
|
json!({
|
|
"name": "requests",
|
|
"message_match_regex": r"request path=(?P<path>\S+) duration=(?P<duration>\d+)",
|
|
"label_params": ["path", "duration"]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["name"], "requests");
|
|
assert_eq!(body["label_params"].as_array().unwrap().len(), 2);
|
|
|
|
// Still exactly one declaration named "requests", now with the updated regex/params — not a second declaration alongside the original.
|
|
let (status, body) = get_json(&app, "/api/metrics").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let metrics = body.as_array().unwrap();
|
|
assert_eq!(metrics.len(), 1);
|
|
assert_eq!(metrics[0]["label_params"].as_array().unwrap().len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_metric_rejects_unknown_metric_name() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"PUT",
|
|
"/api/metrics/does-not-exist",
|
|
json!({
|
|
"name": "does-not-exist",
|
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
|
"label_params": []
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(body["error"].as_str().unwrap().contains("does-not-exist"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_metric_reuses_declare_metric_validation() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/metrics",
|
|
json!({
|
|
"name": "requests",
|
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
|
"label_params": []
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"PUT",
|
|
"/api/metrics/requests",
|
|
json!({
|
|
"name": "requests",
|
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
|
"label_params": ["duration"]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(body["error"].as_str().unwrap().contains("duration"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queries_bucket_values_with_label_filter_and_range() {
|
|
let (app, engine, _settings, _dir) = test_app();
|
|
|
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
|
send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/metrics",
|
|
json!({
|
|
"name": "requests",
|
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
|
"label_params": ["path"]
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
for (sequence, text) in [
|
|
(0u64, "request path=/a"),
|
|
(1u64, "request path=/b"),
|
|
(2u64, "request path=/a"),
|
|
] {
|
|
engine
|
|
.send(EngineMessage::Ingest(IngestedLine {
|
|
sequence,
|
|
timestamp: Timestamp::from_millis(sequence as i64 * 1000),
|
|
text: text.to_string(),
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
let (status, body) = get_json(&app, "/api/metrics/requests").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body.as_array().unwrap().len(), 3);
|
|
|
|
let (status, body) = get_json(&app, "/api/metrics/requests?labels=path=/a").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let values = body.as_array().unwrap();
|
|
assert_eq!(values.len(), 2);
|
|
for value in values {
|
|
// MetricPrimitive derives Serialize as an externally-tagged enum, so JSON is {"String": "/a"}.
|
|
assert_eq!(value["labels"]["path"]["String"], "/a");
|
|
}
|
|
|
|
// Bucket values group by label (BTreeMap-ordered "/a" before "/b"), not by sequence, so limit=1 picks sequence 2 from "/a" ahead of sequence 1 from "/b".
|
|
let (status, body) = get_json(&app, "/api/metrics/requests?since_sequence=0&limit=1").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let values = body.as_array().unwrap();
|
|
assert_eq!(values.len(), 1);
|
|
assert_eq!(values[0]["sequence"], 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn promql_query_returns_series_for_seeded_fixture() {
|
|
let (app, engine, _settings, _dir) = test_app();
|
|
|
|
declare_test_param(&app, "path", r"request path=(?P<path>\S+)", "path", "string").await;
|
|
send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/metrics",
|
|
json!({
|
|
"name": "requests",
|
|
"message_match_regex": r"request path=(?P<path>\S+)",
|
|
"label_params": ["path"]
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
for (sequence, text) in [
|
|
(0u64, "request path=/a"),
|
|
(1u64, "request path=/b"),
|
|
(2u64, "request path=/a"),
|
|
] {
|
|
engine
|
|
.send(EngineMessage::Ingest(IngestedLine {
|
|
sequence,
|
|
timestamp: Timestamp::from_millis(sequence as i64 * 1000),
|
|
text: text.to_string(),
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
let (status, body) =
|
|
get_json(&app, "/api/query?promql=requests%7Bpath%3D%22%2Fa%22%7D").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let series = body.as_array().unwrap();
|
|
assert_eq!(series.len(), 1);
|
|
assert_eq!(series[0]["labels"]["path"], "/a");
|
|
let points = series[0]["points"].as_array().unwrap();
|
|
assert_eq!(points.len(), 2);
|
|
assert_eq!(points[0], json!([0, 1.0]));
|
|
assert_eq!(points[1], json!([2000, 1.0]));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn promql_query_rejects_malformed_query_with_400() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
let (status, body) = get_json(&app, "/api/query?promql=not+valid(").await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(body["error"].as_str().is_some_and(|s| !s.is_empty()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn declaring_param_persists_into_active_profile() {
|
|
let (app, _engine, settings_handle, dir) = test_app();
|
|
|
|
// Use the same shared settings handle the router was built with — a separately-loaded Settings wouldn't be visible to the running app.
|
|
let settings_path = dir.path().join("settings.json");
|
|
let profiles_dir = {
|
|
let settings = settings_handle.lock().unwrap();
|
|
profile::resolve_profiles_dir(&settings, &settings_path)
|
|
};
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let mut store = ProfileStore::load(&store_path);
|
|
{
|
|
let mut settings = settings_handle.lock().unwrap();
|
|
store
|
|
.create(&store_path, &mut settings, &settings_path, "web".to_string())
|
|
.unwrap();
|
|
store
|
|
.set_active(&mut settings, &settings_path, Some("web".to_string()))
|
|
.unwrap();
|
|
}
|
|
|
|
let (status, _body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/params",
|
|
json!({
|
|
"name": "level",
|
|
"message_match_regex": r"level=(?P<level>\w+)",
|
|
"capture_group": "level",
|
|
"kind": "string"
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
|
let reloaded_store = ProfileStore::load(&store_path);
|
|
let profile = reloaded_store.get("web").unwrap();
|
|
assert_eq!(profile.params.len(), 1);
|
|
assert_eq!(profile.params[0].name, "level");
|
|
}
|
|
|
|
/// Regression test: declaring a metric against a param that only exists as auto-derived engine
|
|
/// state (discovered from an ingested logfmt key, never manually declared via `POST
|
|
/// /api/params`) must persist that param into the active profile too, not just the metric —
|
|
/// otherwise a restart replays the metric before any log line re-derives the auto param, and the
|
|
/// engine rejects it as referencing an undeclared param (see `promoted_param_records`).
|
|
#[tokio::test]
|
|
async fn declaring_metric_against_auto_derived_param_promotes_it_into_the_profile() {
|
|
let (app, engine, settings_handle, dir) = test_app();
|
|
|
|
let settings_path = dir.path().join("settings.json");
|
|
let profiles_dir = {
|
|
let settings = settings_handle.lock().unwrap();
|
|
profile::resolve_profiles_dir(&settings, &settings_path)
|
|
};
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let mut store = ProfileStore::load(&store_path);
|
|
{
|
|
let mut settings = settings_handle.lock().unwrap();
|
|
store
|
|
.create(&store_path, &mut settings, &settings_path, "web".to_string())
|
|
.unwrap();
|
|
store
|
|
.set_active(&mut settings, &settings_path, Some("web".to_string()))
|
|
.unwrap();
|
|
}
|
|
|
|
// Ingest a line carrying a logfmt key before any param is manually declared, so "dx" only
|
|
// exists as engine-internal auto-derived state (never persisted anywhere).
|
|
ingest_lines(&engine, 0, &["dx=1 moved"]).await;
|
|
|
|
let (status, _body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/metrics",
|
|
json!({
|
|
"name": "direction_x",
|
|
"message_match_regex": r"dx=(?P<dx>\S+) moved",
|
|
"label_params": ["dx"]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
|
let reloaded_store = ProfileStore::load(&store_path);
|
|
let profile = reloaded_store.get("web").unwrap();
|
|
assert_eq!(profile.metrics.len(), 1);
|
|
assert_eq!(profile.metrics[0].name, "direction_x");
|
|
// The auto-derived "dx" param got promoted into the profile alongside the metric.
|
|
assert_eq!(profile.params.len(), 1);
|
|
assert_eq!(profile.params[0].name, "dx");
|
|
|
|
// Simulate a restart: replay the persisted profile into a fresh engine with no prior ingestion.
|
|
let (fresh_engine_tx, fresh_engine_rx) = mpsc::channel(16);
|
|
let fresh_settings_handle = std::sync::Arc::new(std::sync::Mutex::new(Settings::default()));
|
|
tokio::spawn(crate::engine::run(fresh_engine_rx, fresh_settings_handle));
|
|
profile::load_into_engine(&fresh_engine_tx, profile).await;
|
|
|
|
let (respond_to, response) = oneshot::channel();
|
|
fresh_engine_tx
|
|
.send(EngineMessage::Query { query: EngineQuery::MetricDeclarations, respond_to })
|
|
.await
|
|
.unwrap();
|
|
let EngineResponse::MetricDeclarations(metrics) = response.await.unwrap() else {
|
|
panic!("unexpected response");
|
|
};
|
|
assert_eq!(metrics.len(), 1);
|
|
assert_eq!(metrics[0].name, "direction_x");
|
|
}
|
|
|
|
async fn delete_request(app: &Router, uri: &str) -> StatusCode {
|
|
let request = Request::builder().method("DELETE").uri(uri).body(Body::empty()).unwrap();
|
|
app.clone().oneshot(request).await.unwrap().status()
|
|
}
|
|
|
|
async fn get_status(app: &Router, uri: &str) -> StatusCode {
|
|
let request = Request::builder().method("GET").uri(uri).body(Body::empty()).unwrap();
|
|
app.clone().oneshot(request).await.unwrap().status()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dashboard_crud_round_trips_through_http() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
|
|
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/settings/profiles/web/dashboards",
|
|
json!({ "name": "Overview" }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
assert_eq!(body["name"], "Overview");
|
|
assert!(body["panels"].as_array().unwrap().is_empty());
|
|
let dashboard_id = body["id"].as_str().unwrap().to_string();
|
|
|
|
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body.as_array().unwrap().len(), 1);
|
|
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"PUT",
|
|
&format!("/api/settings/profiles/web/dashboards/{dashboard_id}"),
|
|
json!({
|
|
"name": "Overview",
|
|
"panels": [
|
|
{ "id": "p1", "title": "Requests", "promql": "requests", "chart_type": "line" }
|
|
]
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["id"], dashboard_id);
|
|
let panels = body["panels"].as_array().unwrap();
|
|
assert_eq!(panels.len(), 1);
|
|
assert_eq!(panels[0]["chart_type"], "line");
|
|
|
|
let (status, body) =
|
|
get_json(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["panels"].as_array().unwrap().len(), 1);
|
|
|
|
let status = delete_request(&app, &format!("/api/settings/profiles/web/dashboards/{dashboard_id}")).await;
|
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
|
let (status, body) = get_json(&app, "/api/settings/profiles/web/dashboards").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert!(body.as_array().unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn dashboard_routes_404_for_unknown_profile_or_dashboard() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
let status = get_status(&app, "/api/settings/profiles/ghost/dashboards").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
|
|
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
let status = get_status(&app, "/api/settings/profiles/web/dashboards/does-not-exist").await;
|
|
assert_eq!(status, StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn saved_dashboard_persists_across_simulated_restart() {
|
|
let (app, _engine, settings_handle, dir) = test_app();
|
|
|
|
let (status, _body) = send_json(&app, "POST", "/api/settings/profiles", json!({ "name": "web" })).await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
let (status, body) = send_json(
|
|
&app,
|
|
"POST",
|
|
"/api/settings/profiles/web/dashboards",
|
|
json!({ "name": "Overview" }),
|
|
)
|
|
.await;
|
|
assert_eq!(status, StatusCode::CREATED);
|
|
let dashboard_id = body["id"].as_str().unwrap().to_string();
|
|
|
|
send_json(
|
|
&app,
|
|
"PUT",
|
|
&format!("/api/settings/profiles/web/dashboards/{dashboard_id}"),
|
|
json!({
|
|
"name": "Overview",
|
|
"panels": [
|
|
{ "id": "p1", "title": "Requests", "promql": "requests", "chart_type": "bar" }
|
|
]
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
// Simulate a restart by reloading the profile store fresh from disk, independent of the running app/engine.
|
|
let settings_path = dir.path().join("settings.json");
|
|
let profiles_dir = {
|
|
let settings = settings_handle.lock().unwrap();
|
|
profile::resolve_profiles_dir(&settings, &settings_path)
|
|
};
|
|
let store_path = profile::profiles_store_path(&profiles_dir);
|
|
let reloaded_store = ProfileStore::load(&store_path);
|
|
let dashboards = reloaded_store.list_dashboards("web").unwrap();
|
|
assert_eq!(dashboards.len(), 1);
|
|
assert_eq!(dashboards[0].name, "Overview");
|
|
assert_eq!(dashboards[0].panels.len(), 1);
|
|
assert_eq!(dashboards[0].panels[0].promql, "requests");
|
|
}
|
|
|
|
async fn ingest_lines(engine: &EngineHandle, start_sequence: u64, texts: &[&str]) {
|
|
for (offset, text) in texts.iter().enumerate() {
|
|
let sequence = start_sequence + offset as u64;
|
|
engine
|
|
.send(EngineMessage::Ingest(IngestedLine {
|
|
sequence,
|
|
timestamp: Timestamp::from_millis(sequence as i64 * 1000),
|
|
text: text.to_string(),
|
|
}))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn setting_max_logs_evicts_oldest_on_next_ingest() {
|
|
let (app, engine, _settings, _dir) = test_app();
|
|
|
|
ingest_lines(&engine, 0, &["a", "b", "c"]).await;
|
|
let (status, body) = get_json(&app, "/api/logs").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body.as_array().unwrap().len(), 3);
|
|
|
|
let (status, body) = send_json(&app, "PATCH", "/api/settings", json!({ "max_logs": 2 })).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["max_logs"], 2);
|
|
|
|
// Existing over-cap logs are evicted as soon as the setting change
|
|
// reaches the engine, not just on the next ingest.
|
|
let (_, body) = get_json(&app, "/api/logs").await;
|
|
assert_eq!(body.as_array().unwrap().len(), 2);
|
|
|
|
ingest_lines(&engine, 3, &["d"]).await;
|
|
let (_, body) = get_json(&app, "/api/logs").await;
|
|
let texts: Vec<_> = body
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|entry| entry["text"].as_str().unwrap())
|
|
.collect();
|
|
assert_eq!(texts, vec!["c", "d"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn max_logs_defaults_to_unlimited() {
|
|
let (app, engine, _settings, _dir) = test_app();
|
|
|
|
let (_, body) = get_json(&app, "/api/settings").await;
|
|
assert_eq!(body["max_logs"], Value::Null);
|
|
|
|
ingest_lines(&engine, 0, &["a", "b", "c", "d", "e"]).await;
|
|
let (_, body) = get_json(&app, "/api/logs").await;
|
|
assert_eq!(body.as_array().unwrap().len(), 5);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn max_logs_clears_back_to_unlimited() {
|
|
let (app, engine, _settings, _dir) = test_app();
|
|
|
|
send_json(&app, "PATCH", "/api/settings", json!({ "max_logs": 1 })).await;
|
|
ingest_lines(&engine, 0, &["a", "b"]).await;
|
|
let (_, body) = get_json(&app, "/api/logs").await;
|
|
assert_eq!(body.as_array().unwrap().len(), 1);
|
|
|
|
let (status, body) = send_json(&app, "PATCH", "/api/settings", json!({ "max_logs": null })).await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
assert_eq!(body["max_logs"], Value::Null);
|
|
|
|
ingest_lines(&engine, 2, &["c", "d"]).await;
|
|
let (_, body) = get_json(&app, "/api/logs").await;
|
|
assert_eq!(body.as_array().unwrap().len(), 3);
|
|
}
|
|
|
|
/// Ticket 045: seeded fixture mixing lines that carry the sort param with lines that don't,
|
|
/// exercised through the actual HTTP endpoint rather than just `handle_query` directly.
|
|
#[tokio::test]
|
|
async fn get_logs_sort_by_orders_by_param_and_toggles_missing_param_handling() {
|
|
let (app, engine, _settings, _dir) = test_app();
|
|
|
|
declare_test_param(&app, "priority", r"priority=(?P<priority>\d+)", "priority", "number").await;
|
|
|
|
ingest_lines(
|
|
&engine,
|
|
0,
|
|
&[
|
|
"priority=3 boom", // sequence 0, has param
|
|
"no param here", // sequence 1, missing
|
|
"priority=1 boom", // sequence 2, has param
|
|
"also missing", // sequence 3, missing
|
|
],
|
|
)
|
|
.await;
|
|
|
|
// Default order (no sort_by): unchanged, plain ingest/ottyl_seq order.
|
|
let (status, body) = get_json(&app, "/api/logs").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
|
assert_eq!(sequences, vec![0, 1, 2, 3]);
|
|
|
|
// sort_by=priority with the fallback toggle on (the default): entries carrying the param
|
|
// sorted ascending by its value, then the missing-param entries ordered by ottyl_seq.
|
|
let (status, body) = get_json(&app, "/api/logs?sort_by=priority").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
|
assert_eq!(sequences, vec![2, 0, 1, 3]);
|
|
|
|
// Same sort_by, but with the fallback toggle explicitly off: missing-param lines are dropped.
|
|
let (status, body) = get_json(&app, "/api/logs?sort_by=priority&sort_missing_fallback=false").await;
|
|
assert_eq!(status, StatusCode::OK);
|
|
let sequences: Vec<u64> = body.as_array().unwrap().iter().map(|e| e["sequence"].as_u64().unwrap()).collect();
|
|
assert_eq!(sequences, vec![2, 0]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_logs_sort_by_unknown_param_returns_400() {
|
|
let (app, _engine, _settings, _dir) = test_app();
|
|
let (status, body) = get_json(&app, "/api/logs?sort_by=does_not_exist").await;
|
|
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
assert!(body["error"].as_str().unwrap().contains("does_not_exist"));
|
|
}
|
|
}
|