Split src/ into an api/cli/engine/config module hierarchy
- src/api/: HTTP router + DTOs + the MCP server (the HTTP/MCP-facing surface): mod.rs (formerly api.rs), dto.rs, mcp_server.rs. - src/cli/: argument parsing and process entry (formerly inline in main.rs) plus session registry: mod.rs, sessions.rs. main.rs is now a thin entry point that just declares modules and calls cli::run(). - src/engine/: the actor + everything it depends on to parse/index/ query log lines: mod.rs (formerly engine.rs), alerts.rs, promql.rs, rate.rs, ingest.rs, logfmt.rs, filter_parser.rs/.pest, filter_datetime.rs, index_key.rs, data_value.rs, metric_primitive.rs, timestamp.rs. - src/config/: persisted configuration: mod.rs (formerly settings.rs), profile.rs. - src/consts.rs stays at the crate root, used from every module above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
311713986a
commit
a810ecf396
@@ -0,0 +1,369 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use rmcp::handler::server::router::tool::ToolRouter;
|
||||
use rmcp::handler::server::wrapper::Parameters;
|
||||
use rmcp::model::{
|
||||
CallToolResult, ContentBlock, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
|
||||
};
|
||||
use rmcp::{ErrorData as McpError, ServerHandler, schemars, tool, tool_handler, tool_router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct QueryLogsParams {
|
||||
#[serde(default)]
|
||||
pub filter: Option<String>,
|
||||
#[serde(default)]
|
||||
pub count_only: bool,
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct DeclareParamParams {
|
||||
pub name: String,
|
||||
pub message_match_regex: String,
|
||||
pub capture_group: String,
|
||||
#[serde(default)]
|
||||
pub json_field_path: Option<String>,
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub elapsed_since_last: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct DeclareMetricParams {
|
||||
pub name: String,
|
||||
pub message_match_regex: String,
|
||||
#[serde(default)]
|
||||
pub label_params: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub value_param: Option<String>,
|
||||
#[serde(default)]
|
||||
pub matched_labels: Option<BTreeMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub unmatched_labels: Option<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct CreateDashboardParams {
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct AddPanelParams {
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
pub dashboard_id: String,
|
||||
#[serde(default)]
|
||||
pub panel_id: Option<String>,
|
||||
pub title: String,
|
||||
pub promql: String,
|
||||
#[serde(default = "default_chart_type")]
|
||||
pub chart_type: String,
|
||||
}
|
||||
|
||||
fn default_chart_type() -> String {
|
||||
"line".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct GetDashboardParams {
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
pub dashboard_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OttylMcp {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
#[allow(dead_code)]
|
||||
tool_router: ToolRouter<OttylMcp>,
|
||||
}
|
||||
|
||||
fn mcp_error(context: &str, err: impl std::fmt::Display) -> McpError {
|
||||
McpError::internal_error(format!("{context}: {err}"), None)
|
||||
}
|
||||
|
||||
fn json_result(value: Value) -> Result<CallToolResult, McpError> {
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()),
|
||||
)]))
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl OttylMcp {
|
||||
pub fn new(base_url: String) -> Self {
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
base_url,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{path}", self.base_url)
|
||||
}
|
||||
|
||||
async fn resolve_profile(&self, explicit: Option<String>) -> Result<String, McpError> {
|
||||
if let Some(profile) = explicit {
|
||||
return Ok(profile);
|
||||
}
|
||||
let settings: Value = self
|
||||
.client
|
||||
.get(self.url("/api/settings"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to reach ottyl", err))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to parse settings response", err))?;
|
||||
settings
|
||||
.get("active_profile")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
McpError::invalid_params(
|
||||
"no active profile on the target instance; pass `profile` explicitly",
|
||||
None,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tool(description = "Count or read log lines matching a FilterBar query against the running ottyl instance.")]
|
||||
async fn query_logs(
|
||||
&self,
|
||||
Parameters(params): Parameters<QueryLogsParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut query: Vec<(&str, String)> = Vec::new();
|
||||
if let Some(filter) = ¶ms.filter {
|
||||
query.push(("filter", filter.clone()));
|
||||
}
|
||||
if let Some(limit) = params.limit {
|
||||
query.push(("limit", limit.to_string()));
|
||||
}
|
||||
let response = self
|
||||
.client
|
||||
.get(self.url("/api/logs"))
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(mcp_error("ottyl rejected the query", response.status()));
|
||||
}
|
||||
let logs: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to parse logs response", err))?;
|
||||
if params.count_only {
|
||||
let count = logs.as_array().map(Vec::len).unwrap_or(0);
|
||||
json_result(serde_json::json!({ "count": count }))
|
||||
} else {
|
||||
json_result(logs)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "Declare a param: a named value extracted from matching log lines.")]
|
||||
async fn declare_param(
|
||||
&self,
|
||||
Parameters(params): Parameters<DeclareParamParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url("/api/params"))
|
||||
.json(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
|
||||
let status = response.status();
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to parse declare_param response", err))?;
|
||||
if !status.is_success() {
|
||||
return Err(mcp_error("ottyl rejected the param declaration", body));
|
||||
}
|
||||
json_result(body)
|
||||
}
|
||||
|
||||
#[tool(description = "Declare a metric: named event counts/values bucketed by label, derived from matching log lines.")]
|
||||
async fn declare_metric(
|
||||
&self,
|
||||
Parameters(params): Parameters<DeclareMetricParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url("/api/metrics"))
|
||||
.json(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
|
||||
let status = response.status();
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to parse declare_metric response", err))?;
|
||||
if !status.is_success() {
|
||||
return Err(mcp_error("ottyl rejected the metric declaration", body));
|
||||
}
|
||||
json_result(body)
|
||||
}
|
||||
|
||||
#[tool(description = "Create a new, empty dashboard in a profile.")]
|
||||
async fn create_dashboard(
|
||||
&self,
|
||||
Parameters(params): Parameters<CreateDashboardParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let profile = self.resolve_profile(params.profile).await?;
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url(&format!(
|
||||
"/api/settings/profiles/{}/dashboards",
|
||||
urlencoding_path(&profile)
|
||||
)))
|
||||
.json(&serde_json::json!({ "name": params.name }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
|
||||
let status = response.status();
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to parse create_dashboard response", err))?;
|
||||
if !status.is_success() {
|
||||
return Err(mcp_error("ottyl rejected the dashboard creation", body));
|
||||
}
|
||||
json_result(body)
|
||||
}
|
||||
|
||||
#[tool(description = "Read a dashboard's current panels.")]
|
||||
async fn get_dashboard(
|
||||
&self,
|
||||
Parameters(params): Parameters<GetDashboardParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let profile = self.resolve_profile(params.profile).await?;
|
||||
let body = self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?;
|
||||
json_result(body)
|
||||
}
|
||||
|
||||
#[tool(description = "Add a new panel to a dashboard, or update an existing one by panel_id.")]
|
||||
async fn add_panel(
|
||||
&self,
|
||||
Parameters(params): Parameters<AddPanelParams>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let profile = self.resolve_profile(params.profile).await?;
|
||||
let mut dashboard = self.fetch_dashboard(&profile, ¶ms.dashboard_id).await?;
|
||||
|
||||
let panel_id = params
|
||||
.panel_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| generate_panel_id(¶ms.title, ¶ms.promql));
|
||||
let new_panel = serde_json::json!({
|
||||
"id": panel_id,
|
||||
"title": params.title,
|
||||
"promql": params.promql,
|
||||
"chart_type": params.chart_type,
|
||||
});
|
||||
|
||||
let panels = dashboard
|
||||
.get_mut("panels")
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or_else(|| mcp_error("unexpected dashboard shape", "missing panels array"))?;
|
||||
match panels.iter().position(|panel| panel.get("id") == new_panel.get("id")) {
|
||||
Some(index) => panels[index] = new_panel,
|
||||
None => panels.push(new_panel),
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.put(self.url(&format!(
|
||||
"/api/settings/profiles/{}/dashboards/{}",
|
||||
urlencoding_path(&profile),
|
||||
urlencoding_path(¶ms.dashboard_id)
|
||||
)))
|
||||
.json(&serde_json::json!({ "name": dashboard.get("name"), "panels": dashboard.get("panels") }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
|
||||
let status = response.status();
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to parse add_panel response", err))?;
|
||||
if !status.is_success() {
|
||||
return Err(mcp_error("ottyl rejected the panel update", body));
|
||||
}
|
||||
json_result(body)
|
||||
}
|
||||
|
||||
async fn fetch_dashboard(&self, profile: &str, dashboard_id: &str) -> Result<Value, McpError> {
|
||||
let response = self
|
||||
.client
|
||||
.get(self.url(&format!(
|
||||
"/api/settings/profiles/{}/dashboards/{}",
|
||||
urlencoding_path(profile),
|
||||
urlencoding_path(dashboard_id)
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to reach ottyl", err))?;
|
||||
let status = response.status();
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|err| mcp_error("failed to parse dashboard response", err))?;
|
||||
if !status.is_success() {
|
||||
return Err(mcp_error("ottyl rejected the dashboard lookup", body));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
|
||||
fn urlencoding_path(segment: &str) -> String {
|
||||
segment
|
||||
.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
(b as char).to_string()
|
||||
}
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_panel_id(title: &str, promql: &str) -> String {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
title.hash(&mut hasher);
|
||||
promql.hash(&mut hasher);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0);
|
||||
nanos.hash(&mut hasher);
|
||||
format!("panel-{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for OttylMcp {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(
|
||||
ServerCapabilities::builder().enable_tools().build(),
|
||||
)
|
||||
.with_server_info(Implementation::from_build_env())
|
||||
.with_protocol_version(ProtocolVersion::V_2024_11_05)
|
||||
.with_instructions(
|
||||
"Proxies to a running ottyl instance's HTTP API. Tools: query_logs, declare_param, \
|
||||
declare_metric, create_dashboard, add_panel, get_dashboard. See the embedded \
|
||||
SKILL.md (GET /SKILL.md on the target instance) for examples."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user