mirror of
https://codeberg.org/Toasterson/solstice-ci.git
synced 2026-04-10 13:20:41 +00:00
Add configuration support and enhance logs handling in TUI
- Introduce `Config` command to manage local `ciadm` settings, including `set-base-url` for persisting logs-service URLs. - Improve TUI with log category selection and navigation using the Tab key. - Refactor logs retrieval to support category-based display and enhance error handling. - Add local configuration file utilities for storing and loading settings. - Update dependencies to include the `kdl` crate for configuration management. Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
parent
9306de0acf
commit
e76f4c0278
3 changed files with 184 additions and 21 deletions
|
|
@ -15,3 +15,4 @@ crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
kdl = "6"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io::{self, Stdout};
|
use std::io::{self, Stdout};
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|
@ -44,6 +45,11 @@ enum Commands {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
job_id: String,
|
job_id: String,
|
||||||
},
|
},
|
||||||
|
/// Configure local CLI settings
|
||||||
|
Config {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ConfigCommand,
|
||||||
|
},
|
||||||
/// List recent jobs from the logs service
|
/// List recent jobs from the logs service
|
||||||
Jobs {
|
Jobs {
|
||||||
/// Logs service base URL (e.g., https://logs.prod.example.com)
|
/// Logs service base URL (e.g., https://logs.prod.example.com)
|
||||||
|
|
@ -76,6 +82,16 @@ enum Commands {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum ConfigCommand {
|
||||||
|
/// Persist the logs-service base URL in a local config file
|
||||||
|
SetBaseUrl {
|
||||||
|
/// Logs service base URL (e.g., https://logs.prod.example.com)
|
||||||
|
#[arg(long)]
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let _t = common::init_tracing("ciadm")?;
|
let _t = common::init_tracing("ciadm")?;
|
||||||
|
|
@ -95,6 +111,12 @@ async fn main() -> Result<()> {
|
||||||
// TODO: Query orchestrator for job status
|
// TODO: Query orchestrator for job status
|
||||||
println!("Job {job_id} status: PENDING (stub)");
|
println!("Job {job_id} status: PENDING (stub)");
|
||||||
}
|
}
|
||||||
|
Commands::Config { command } => match command {
|
||||||
|
ConfigCommand::SetBaseUrl { url } => {
|
||||||
|
save_logs_base_url(&url)?;
|
||||||
|
println!("Saved LOGS_BASE_URL to {}", config_path()?.display());
|
||||||
|
}
|
||||||
|
},
|
||||||
Commands::Jobs {
|
Commands::Jobs {
|
||||||
logs_base_url,
|
logs_base_url,
|
||||||
repo,
|
repo,
|
||||||
|
|
@ -121,7 +143,15 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_logs_base_url(arg: Option<String>) -> Result<String> {
|
fn resolve_logs_base_url(arg: Option<String>) -> Result<String> {
|
||||||
arg.ok_or_else(|| miette::miette!("LOGS_BASE_URL is required (set via env or --logs-base-url)"))
|
if let Some(arg) = arg {
|
||||||
|
return Ok(arg);
|
||||||
|
}
|
||||||
|
if let Some(saved) = load_logs_base_url()? {
|
||||||
|
return Ok(saved);
|
||||||
|
}
|
||||||
|
Err(miette::miette!(
|
||||||
|
"LOGS_BASE_URL is required (set via env, --logs-base-url, or ciadm config set-base-url)"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_repo_url(arg: Option<String>) -> Option<String> {
|
fn resolve_repo_url(arg: Option<String>) -> Option<String> {
|
||||||
|
|
@ -147,9 +177,57 @@ fn detect_git_remote() -> Option<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn config_path() -> Result<PathBuf> {
|
||||||
|
let base = if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
PathBuf::from(dir)
|
||||||
|
} else if let Ok(home) = std::env::var("HOME") {
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
} else if let Ok(home) = std::env::var("USERPROFILE") {
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
} else {
|
||||||
|
return Err(miette::miette!("Unable to determine home directory for config storage"));
|
||||||
|
};
|
||||||
|
Ok(base.join("solstice").join("ciadm.conf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_logs_base_url(url: &str) -> Result<()> {
|
||||||
|
let path = config_path()?;
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir).into_diagnostic()?;
|
||||||
|
}
|
||||||
|
let mut doc = kdl::KdlDocument::new();
|
||||||
|
let mut node = kdl::KdlNode::new("logs_base_url");
|
||||||
|
node.entries_mut().push(kdl::KdlEntry::new(url));
|
||||||
|
doc.nodes_mut().push(node);
|
||||||
|
std::fs::write(&path, doc.to_string()).into_diagnostic()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_logs_base_url() -> Result<Option<String>> {
|
||||||
|
let path = config_path()?;
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let doc: kdl::KdlDocument = contents.parse().into_diagnostic()?;
|
||||||
|
let Some(node) = doc.get("logs_base_url") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(value) = node.entries().first().and_then(|entry| entry.value().as_string()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Ok(Some(value.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
async fn cmd_jobs(base_url: &str, repo: Option<&str>) -> Result<()> {
|
async fn cmd_jobs(base_url: &str, repo: Option<&str>) -> Result<()> {
|
||||||
let client = LogsClient::new(base_url)?;
|
let client = LogsClient::new(base_url)
|
||||||
let groups = client.list_jobs().await?;
|
.map_err(|err| miette::Report::msg(err.to_string()))?;
|
||||||
|
let groups = client
|
||||||
|
.list_jobs()
|
||||||
|
.await
|
||||||
|
.map_err(|err| miette::Report::msg(err.to_string()))?;
|
||||||
let groups = filter_groups_by_repo(groups, repo);
|
let groups = filter_groups_by_repo(groups, repo);
|
||||||
if groups.is_empty() {
|
if groups.is_empty() {
|
||||||
println!("No jobs found.");
|
println!("No jobs found.");
|
||||||
|
|
@ -176,12 +254,20 @@ async fn cmd_jobs(base_url: &str, repo: Option<&str>) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_logs(base_url: &str, job_id: &str, category: Option<&str>) -> Result<()> {
|
async fn cmd_logs(base_url: &str, job_id: &str, category: Option<&str>) -> Result<()> {
|
||||||
let client = LogsClient::new(base_url)?;
|
let client = LogsClient::new(base_url)
|
||||||
|
.map_err(|err| miette::Report::msg(err.to_string()))?;
|
||||||
let request_id = uuid::Uuid::parse_str(job_id).into_diagnostic()?;
|
let request_id = uuid::Uuid::parse_str(job_id).into_diagnostic()?;
|
||||||
let text = if let Some(cat) = category {
|
let text = if let Some(cat) = category {
|
||||||
client.get_logs_by_category(request_id, cat).await?
|
client
|
||||||
|
.get_logs_by_category(request_id, cat)
|
||||||
|
.await
|
||||||
|
.map_err(|err| miette::Report::msg(err.to_string()))?
|
||||||
} else {
|
} else {
|
||||||
client.get_default_logs(request_id).await?.0
|
client
|
||||||
|
.get_default_logs(request_id)
|
||||||
|
.await
|
||||||
|
.map_err(|err| miette::Report::msg(err.to_string()))?
|
||||||
|
.0
|
||||||
};
|
};
|
||||||
print!("{text}");
|
print!("{text}");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -207,7 +293,7 @@ async fn run_tui_loop(
|
||||||
let mut tick = tokio::time::interval(Duration::from_millis(200));
|
let mut tick = tokio::time::interval(Duration::from_millis(200));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| app.draw(frame))?;
|
terminal.draw(|frame| app.draw(frame)).into_diagnostic()?;
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = tick.tick() => {}
|
_ = tick.tick() => {}
|
||||||
maybe_event = events.next() => {
|
maybe_event = events.next() => {
|
||||||
|
|
@ -222,6 +308,7 @@ async fn run_tui_loop(
|
||||||
warn!(error = %e, "failed to read terminal event");
|
warn!(error = %e, "failed to read terminal event");
|
||||||
}
|
}
|
||||||
None => break,
|
None => break,
|
||||||
|
_ => todo!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -303,6 +390,8 @@ struct TuiApp {
|
||||||
selected_job: usize,
|
selected_job: usize,
|
||||||
logs_text: String,
|
logs_text: String,
|
||||||
logs_category: Option<String>,
|
logs_category: Option<String>,
|
||||||
|
logs_categories: Vec<String>,
|
||||||
|
selected_category: usize,
|
||||||
logs_lines: usize,
|
logs_lines: usize,
|
||||||
logs_scroll: u16,
|
logs_scroll: u16,
|
||||||
status: String,
|
status: String,
|
||||||
|
|
@ -320,7 +409,8 @@ struct JobEntry {
|
||||||
impl TuiApp {
|
impl TuiApp {
|
||||||
fn new(base_url: &str, repo_hint: Option<String>) -> Result<Self> {
|
fn new(base_url: &str, repo_hint: Option<String>) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client: LogsClient::new(base_url)?,
|
client: LogsClient::new(base_url)
|
||||||
|
.map_err(|err| miette::Report::msg(err.to_string()))?,
|
||||||
repo_hint,
|
repo_hint,
|
||||||
repos: Vec::new(),
|
repos: Vec::new(),
|
||||||
selected_repo: 0,
|
selected_repo: 0,
|
||||||
|
|
@ -328,6 +418,8 @@ impl TuiApp {
|
||||||
selected_job: 0,
|
selected_job: 0,
|
||||||
logs_text: String::new(),
|
logs_text: String::new(),
|
||||||
logs_category: None,
|
logs_category: None,
|
||||||
|
logs_categories: Vec::new(),
|
||||||
|
selected_category: 0,
|
||||||
logs_lines: 0,
|
logs_lines: 0,
|
||||||
logs_scroll: 0,
|
logs_scroll: 0,
|
||||||
status: String::new(),
|
status: String::new(),
|
||||||
|
|
@ -335,7 +427,11 @@ impl TuiApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn refresh_jobs(&mut self) -> Result<()> {
|
async fn refresh_jobs(&mut self) -> Result<()> {
|
||||||
let groups = self.client.list_jobs().await?;
|
let groups = self
|
||||||
|
.client
|
||||||
|
.list_jobs()
|
||||||
|
.await
|
||||||
|
.map_err(|err| miette::Report::msg(err.to_string()))?;
|
||||||
let mut repos_map: BTreeMap<String, Vec<JobEntry>> = BTreeMap::new();
|
let mut repos_map: BTreeMap<String, Vec<JobEntry>> = BTreeMap::new();
|
||||||
for group in groups {
|
for group in groups {
|
||||||
let entries = group
|
let entries = group
|
||||||
|
|
@ -384,14 +480,40 @@ impl TuiApp {
|
||||||
let Some(job) = self.jobs.get(self.selected_job) else {
|
let Some(job) = self.jobs.get(self.selected_job) else {
|
||||||
self.logs_text = "No job selected.".to_string();
|
self.logs_text = "No job selected.".to_string();
|
||||||
self.logs_category = None;
|
self.logs_category = None;
|
||||||
|
self.logs_categories.clear();
|
||||||
|
self.selected_category = 0;
|
||||||
self.logs_lines = self.logs_text.lines().count();
|
self.logs_lines = self.logs_text.lines().count();
|
||||||
self.logs_scroll = 0;
|
self.logs_scroll = 0;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
match self.client.get_default_logs(job.request_id).await {
|
match self.client.list_log_categories(job.request_id).await {
|
||||||
Ok((text, category)) => {
|
Ok(categories) => {
|
||||||
|
self.logs_categories = categories
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| c.category)
|
||||||
|
.collect();
|
||||||
|
if self.logs_categories.is_empty() {
|
||||||
|
self.logs_text = "No logs available.".to_string();
|
||||||
|
self.logs_category = None;
|
||||||
|
self.selected_category = 0;
|
||||||
|
} else {
|
||||||
|
if let Some(idx) = self
|
||||||
|
.logs_categories
|
||||||
|
.iter()
|
||||||
|
.position(|c| c == "default")
|
||||||
|
{
|
||||||
|
self.selected_category = idx;
|
||||||
|
} else if self.selected_category >= self.logs_categories.len() {
|
||||||
|
self.selected_category = 0;
|
||||||
|
}
|
||||||
|
let category = self.logs_categories[self.selected_category].clone();
|
||||||
|
let text = self
|
||||||
|
.client
|
||||||
|
.get_logs_by_category(job.request_id, &category)
|
||||||
|
.await?;
|
||||||
self.logs_text = text;
|
self.logs_text = text;
|
||||||
self.logs_category = category;
|
self.logs_category = Some(category);
|
||||||
|
}
|
||||||
self.logs_lines = self.logs_text.lines().count();
|
self.logs_lines = self.logs_text.lines().count();
|
||||||
self.logs_scroll = 0;
|
self.logs_scroll = 0;
|
||||||
self.status.clear();
|
self.status.clear();
|
||||||
|
|
@ -400,6 +522,8 @@ impl TuiApp {
|
||||||
let report = miette::Report::new(err);
|
let report = miette::Report::new(err);
|
||||||
self.logs_text = format!("{report}");
|
self.logs_text = format!("{report}");
|
||||||
self.logs_category = None;
|
self.logs_category = None;
|
||||||
|
self.logs_categories.clear();
|
||||||
|
self.selected_category = 0;
|
||||||
self.logs_lines = self.logs_text.lines().count();
|
self.logs_lines = self.logs_text.lines().count();
|
||||||
self.logs_scroll = 0;
|
self.logs_scroll = 0;
|
||||||
self.status = "Failed to load logs.".to_string();
|
self.status = "Failed to load logs.".to_string();
|
||||||
|
|
@ -417,8 +541,7 @@ impl TuiApp {
|
||||||
(KeyCode::Char('q'), _) => return Ok(true),
|
(KeyCode::Char('q'), _) => return Ok(true),
|
||||||
(KeyCode::Char('r'), _) => {
|
(KeyCode::Char('r'), _) => {
|
||||||
if let Err(err) = self.refresh_jobs().await {
|
if let Err(err) = self.refresh_jobs().await {
|
||||||
let report = miette::Report::new(err);
|
self.status = format!("{err}");
|
||||||
self.status = format!("{report}");
|
|
||||||
} else {
|
} else {
|
||||||
self.status = "Refreshed jobs.".to_string();
|
self.status = "Refreshed jobs.".to_string();
|
||||||
}
|
}
|
||||||
|
|
@ -440,6 +563,7 @@ impl TuiApp {
|
||||||
if self.selected_repo > 0 {
|
if self.selected_repo > 0 {
|
||||||
self.selected_repo -= 1;
|
self.selected_repo -= 1;
|
||||||
self.selected_job = 0;
|
self.selected_job = 0;
|
||||||
|
self.selected_category = 0;
|
||||||
self.refresh_jobs().await?;
|
self.refresh_jobs().await?;
|
||||||
self.refresh_logs().await?;
|
self.refresh_logs().await?;
|
||||||
}
|
}
|
||||||
|
|
@ -448,10 +572,41 @@ impl TuiApp {
|
||||||
if self.selected_repo + 1 < self.repos.len() {
|
if self.selected_repo + 1 < self.repos.len() {
|
||||||
self.selected_repo += 1;
|
self.selected_repo += 1;
|
||||||
self.selected_job = 0;
|
self.selected_job = 0;
|
||||||
|
self.selected_category = 0;
|
||||||
self.refresh_jobs().await?;
|
self.refresh_jobs().await?;
|
||||||
self.refresh_logs().await?;
|
self.refresh_logs().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(KeyCode::Tab, _) => {
|
||||||
|
if !self.logs_categories.is_empty() {
|
||||||
|
self.selected_category =
|
||||||
|
(self.selected_category + 1) % self.logs_categories.len();
|
||||||
|
if let Some(job) = self.jobs.get(self.selected_job) {
|
||||||
|
let category = self.logs_categories[self.selected_category].clone();
|
||||||
|
match self
|
||||||
|
.client
|
||||||
|
.get_logs_by_category(job.request_id, &category)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(text) => {
|
||||||
|
self.logs_text = text;
|
||||||
|
self.logs_category = Some(category);
|
||||||
|
self.logs_lines = self.logs_text.lines().count();
|
||||||
|
self.logs_scroll = 0;
|
||||||
|
self.status.clear();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let report = miette::Report::new(err);
|
||||||
|
self.logs_text = format!("{report}");
|
||||||
|
self.logs_category = None;
|
||||||
|
self.logs_lines = self.logs_text.lines().count();
|
||||||
|
self.logs_scroll = 0;
|
||||||
|
self.status = "Failed to load logs.".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
(KeyCode::PageUp, _) => {
|
(KeyCode::PageUp, _) => {
|
||||||
self.logs_scroll = self.logs_scroll.saturating_sub(5);
|
self.logs_scroll = self.logs_scroll.saturating_sub(5);
|
||||||
}
|
}
|
||||||
|
|
@ -520,8 +675,15 @@ impl TuiApp {
|
||||||
.iter()
|
.iter()
|
||||||
.map(|job| {
|
.map(|job| {
|
||||||
let runs_on = job.runs_on.clone().unwrap_or_else(|| "-".to_string());
|
let runs_on = job.runs_on.clone().unwrap_or_else(|| "-".to_string());
|
||||||
|
let request = format!("{}", job.request_id);
|
||||||
|
let request_short = if request.len() > 8 {
|
||||||
|
&request[..8]
|
||||||
|
} else {
|
||||||
|
request.as_str()
|
||||||
|
};
|
||||||
let line = format!(
|
let line = format!(
|
||||||
"{} {:<10} {:<8} {}",
|
"{} {:<8} {:<10} {:<8} {}",
|
||||||
|
request_short,
|
||||||
short_sha(&job.commit_sha),
|
short_sha(&job.commit_sha),
|
||||||
job.state,
|
job.state,
|
||||||
runs_on,
|
runs_on,
|
||||||
|
|
@ -562,7 +724,7 @@ impl TuiApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_footer(&self, frame: &mut Frame, area: Rect) {
|
fn draw_footer(&self, frame: &mut Frame, area: Rect) {
|
||||||
let help = "Up/Down: jobs Left/Right: repo PgUp/PgDn: scroll r: refresh q: quit";
|
let help = "Up/Down: jobs Left/Right: repo Tab: category PgUp/PgDn: scroll r: refresh q: quit";
|
||||||
let mut line = vec![Span::raw(help)];
|
let mut line = vec![Span::raw(help)];
|
||||||
if !self.status.is_empty() {
|
if !self.status.is_empty() {
|
||||||
line.push(Span::raw(" "));
|
line.push(Span::raw(" "));
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,13 @@ pub struct LogCategorySummary {
|
||||||
pub enum LogsClientError {
|
pub enum LogsClientError {
|
||||||
#[error("invalid logs base URL")]
|
#[error("invalid logs base URL")]
|
||||||
#[diagnostic(
|
#[diagnostic(
|
||||||
code(logs_client.invalid_base_url),
|
code("logs_client.invalid_base_url"),
|
||||||
help("Set LOGS_BASE_URL to the logs-service base URL, e.g. https://logs.prod.example.com")
|
help("Set LOGS_BASE_URL to the logs-service base URL, e.g. https://logs.prod.example.com")
|
||||||
)]
|
)]
|
||||||
InvalidBaseUrl(#[source] url::ParseError),
|
InvalidBaseUrl(#[source] url::ParseError),
|
||||||
#[error("request failed for {url}")]
|
#[error("request failed for {url}")]
|
||||||
#[diagnostic(
|
#[diagnostic(
|
||||||
code(logs_client.request_failed),
|
code("logs_client.request_failed"),
|
||||||
help("Check network connectivity and TLS configuration for the logs service.")
|
help("Check network connectivity and TLS configuration for the logs service.")
|
||||||
)]
|
)]
|
||||||
Request {
|
Request {
|
||||||
|
|
@ -58,7 +58,7 @@ pub enum LogsClientError {
|
||||||
},
|
},
|
||||||
#[error("logs service returned {status} for {url}")]
|
#[error("logs service returned {status} for {url}")]
|
||||||
#[diagnostic(
|
#[diagnostic(
|
||||||
code(logs_client.http_status),
|
code("logs_client.http_status"),
|
||||||
help("Inspect the response body for details and verify the logs-service is reachable.")
|
help("Inspect the response body for details and verify the logs-service is reachable.")
|
||||||
)]
|
)]
|
||||||
HttpStatus {
|
HttpStatus {
|
||||||
|
|
@ -68,7 +68,7 @@ pub enum LogsClientError {
|
||||||
},
|
},
|
||||||
#[error("failed to parse JSON from {url}")]
|
#[error("failed to parse JSON from {url}")]
|
||||||
#[diagnostic(
|
#[diagnostic(
|
||||||
code(logs_client.json_decode),
|
code("logs_client.json_decode"),
|
||||||
help("Confirm the logs-service response matches the expected schema.")
|
help("Confirm the logs-service response matches the expected schema.")
|
||||||
)]
|
)]
|
||||||
Json {
|
Json {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue