Skip to content

Commit 992a83e

Browse files
authored
feat: add incidents, logs, SQL query API, and CLI improvements (Phase 2) (#10)
Incidents: - Add `bs incidents list/get/create/ack/resolve/escalate/delete/timeline` - Full CRUD and lifecycle management via Uptime API v2/v3 Logs & SQL Query API: - Add `bs logs sources` and `bs logs source <id>` for source management - Add `bs logs query` with filter-to-SQL compiler supporting field comparisons (>=, <=, >, <), wildcards, quoted contains, AND/OR - Add `bs logs tail` with two-phase polling (historical dump + live), Ctrl+C handling, and colored pretty-print output - Add `bs logs sql` for raw ClickHouse SQL queries - SQL Query API integration with auto-provisioning of connections and interactive credential setup in `bs auth init` - Parse raw JSON from SQL results: table mode shows dt/level/message, JSON mode outputs parsed objects, CSV extracts fields - Query compiler uses top-level JSON fields only (configure VRL transforms on sources for field mapping) Auth: - Add SQL credential prompts (host, username, password) to `bs auth init` - Show SQL connection status in `bs auth status` - Add Telemetry API token support Output: - Terminal-width-aware unicode-safe table rendering with proportional column sizing using `unicode-width` crate - Color level cells in table output (red=ERROR, yellow=WARN, green=INFO) - Dim timestamps in log output - Convert UTC timestamps to local time - Add `CommandOutput::Raw` variant for pre-formatted output - Status coloring for incidents (started/acknowledged/resolved) UX: - Add `arg_required_else_help` across all subcommands - Show help pages for bare subcommands instead of errors - Fix SQL client to not append LIMIT/FORMAT to non-SELECT queries - Add monitor expand/create/pause/resume/update/delete commands
1 parent 38d4d99 commit 992a83e

17 files changed

Lines changed: 881 additions & 183 deletions

File tree

‎Cargo.lock‎

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
1818
serde = { version = "1", features = ["derive"] }
1919
serde_json = "1"
2020
tempfile = "3"
21-
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
21+
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
2222
toml = "1.0"
23+
unicode-width = "0.2"
2324

2425
[dev-dependencies]
2526
wiremock = "0.6"

‎src/adapters/config/schema.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ pub struct AuthConfig {
1616
pub sql: Option<SqlAuthConfig>,
1717
}
1818

19-
#[derive(Debug, Default, Serialize, Deserialize)]
19+
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
2020
pub struct SqlAuthConfig {
21+
pub host: Option<String>,
2122
pub username: Option<String>,
2223
pub password: Option<String>,
23-
pub region: Option<String>,
2424
}
2525

2626
#[derive(Debug, Default, Serialize, Deserialize)]

‎src/adapters/http/connections.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use anyhow::{Result, bail};
2+
use serde::Deserialize;
3+
4+
use super::HttpClient;
5+
use super::retry::with_retry;
6+
7+
#[derive(Debug, Deserialize)]
8+
struct ConnectionResponse {
9+
data: ConnectionData,
10+
}
11+
12+
#[derive(Debug, Deserialize)]
13+
struct ConnectionData {
14+
attributes: ConnectionAttributes,
15+
}
16+
17+
#[derive(Debug, Deserialize)]
18+
struct ConnectionAttributes {
19+
host: Option<String>,
20+
username: Option<String>,
21+
password: Option<String>,
22+
}
23+
24+
pub struct ConnectionCredentials {
25+
pub host: String,
26+
pub username: String,
27+
pub password: String,
28+
}
29+
30+
impl HttpClient {
31+
pub async fn create_sql_connection(&self) -> Result<ConnectionCredentials> {
32+
let body = serde_json::json!({
33+
"client_type": "clickhouse"
34+
});
35+
36+
let resp = with_retry(|| async { Ok(self.post("/connections").json(&body).send().await?) })
37+
.await?;
38+
39+
let status = resp.status();
40+
if !status.is_success() {
41+
let body = resp.text().await.unwrap_or_default();
42+
bail!("Failed to create SQL connection ({}): {}", status, body);
43+
}
44+
45+
let parsed: ConnectionResponse = resp.json().await?;
46+
let attrs = parsed.data.attributes;
47+
48+
let host = attrs
49+
.host
50+
.ok_or_else(|| anyhow::anyhow!("No host in connection response"))?;
51+
let username = attrs
52+
.username
53+
.ok_or_else(|| anyhow::anyhow!("No username in connection response"))?;
54+
let password = attrs
55+
.password
56+
.ok_or_else(|| anyhow::anyhow!("No password in connection response"))?;
57+
58+
Ok(ConnectionCredentials {
59+
host,
60+
username,
61+
password,
62+
})
63+
}
64+
}

‎src/adapters/http/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod connections;
12
pub mod incidents;
23
pub mod pagination;
34
pub mod retry;

‎src/adapters/http/sql.rs‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ pub struct SqlClient {
1010
}
1111

1212
impl SqlClient {
13-
pub fn new(region: &str, username: &str, password: &str) -> Self {
14-
let base_url = format!("https://{region}-connect.betterstackdata.com");
13+
pub fn new(host: &str, username: &str, password: &str) -> Self {
14+
let base_url = if host.starts_with("https://") {
15+
host.to_string()
16+
} else {
17+
format!("https://{host}")
18+
};
1519
Self {
1620
client: reqwest::Client::new(),
1721
base_url,
@@ -22,14 +26,23 @@ impl SqlClient {
2226

2327
pub async fn query(&self, sql: &str) -> Result<String> {
2428
let url = &self.base_url;
25-
let resp = with_retry(|| async {
26-
Ok(self
27-
.client
28-
.post(url)
29-
.basic_auth(&self.username, Some(&self.password))
30-
.body(format!("{sql} FORMAT JSONEachRow"))
31-
.send()
32-
.await?)
29+
let is_select = sql.trim_start().to_uppercase().starts_with("SELECT");
30+
let body = if is_select {
31+
format!("{sql} FORMAT JSONEachRow")
32+
} else {
33+
sql.to_string()
34+
};
35+
let resp = with_retry(|| {
36+
let body = body.clone();
37+
async move {
38+
Ok(self
39+
.client
40+
.post(url)
41+
.basic_auth(&self.username, Some(&self.password))
42+
.body(body)
43+
.send()
44+
.await?)
45+
}
3346
})
3447
.await?;
3548

0 commit comments

Comments
 (0)