feat: Add poller and case list to desktop client
This commit introduces a new poller to the desktop client, responsible
for fetching case markers and their one-liners. It also integrates a
case list UI, allowing users to view and interact with their cases.
Key changes include:
- **New Dependencies**: Added `doctate-client-core`, `time`, and
`webbrowser` to `Cargo.lock`.
- **Background Task Spawning**: Refactored `spawn_uploader` to
`spawn_background` to include the new `OnelinerPoller` and its
dependencies.
- **Case Store Integration**: The `DoctateApp` now initializes and uses
a `CaseStore` to manage case markers.
- **UI Enhancements**:
- A new `render_case_list` function displays recent cases with their
last activity time and one-liner.
- Buttons to "Continue" recording for a case and "Open" the case in
a web browser are added.
- A staleness indicator for the poller's connection is displayed.
- **State Management**:
- The `AppState` is updated to reflect the new UI elements and
interactions.
- Actions like `Continue` and `OpenWeb` are handled.
- **Configuration**: Poll interval and window hours are now configurable
and used by the poller.
- **Error Handling**: Improved error handling for saving configuration
and background task operations.
- **Code Cleanup**: Minor refactoring and documentation updates.
This commit is contained in:
Generated
+3
@@ -1177,6 +1177,7 @@ name = "doctate-desktop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"doctate-client-core",
|
||||
"doctate-common",
|
||||
"eframe",
|
||||
"libc",
|
||||
@@ -1185,11 +1186,13 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"time",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"webbrowser",
|
||||
"which",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
+303
-38
@@ -1,21 +1,24 @@
|
||||
//! The egui application: config panel, record/stop buttons, state
|
||||
//! transitions driven by recorder + uploader channels.
|
||||
//! The egui application: config panel, record/stop controls, case list,
|
||||
//! state transitions driven by recorder + uploader + poller channels.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use doctate_client_core::{
|
||||
CaseMarker, CaseStore, OnelinerPoller, PollerConfig, SnapshotCache,
|
||||
};
|
||||
use doctate_common::timestamp::{now_rfc3339, recorded_at_to_filename_stem};
|
||||
use eframe::egui;
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::recorder::{Recorder, RecorderEvent};
|
||||
use crate::state::AppState;
|
||||
use crate::uploader::{PendingUpload, UploadEvent, Uploader, write_sidecar};
|
||||
use crate::uploader::{write_sidecar, PendingUpload, UploadEvent, Uploader};
|
||||
|
||||
pub struct DoctateApp {
|
||||
runtime: Arc<Runtime>,
|
||||
@@ -26,22 +29,36 @@ pub struct DoctateApp {
|
||||
server_url_input: String,
|
||||
api_key_input: String,
|
||||
|
||||
http_client: Arc<reqwest::Client>,
|
||||
case_store: Arc<CaseStore>,
|
||||
snapshot_rx: watch::Receiver<Arc<Vec<CaseMarker>>>,
|
||||
snapshot_cache: Arc<SnapshotCache>,
|
||||
|
||||
recorder: Option<Recorder>,
|
||||
recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,
|
||||
|
||||
uploader: Option<Uploader>,
|
||||
upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,
|
||||
|
||||
poller: Option<OnelinerPoller>,
|
||||
}
|
||||
|
||||
enum UiAction {
|
||||
SaveConfig,
|
||||
StartNew,
|
||||
Continue(Uuid),
|
||||
OpenWeb(Uuid),
|
||||
Stop,
|
||||
DismissError,
|
||||
}
|
||||
|
||||
impl DoctateApp {
|
||||
pub fn new(runtime: Arc<Runtime>, pending_dir: PathBuf) -> Self {
|
||||
pub fn new(
|
||||
runtime: Arc<Runtime>,
|
||||
pending_dir: PathBuf,
|
||||
cases_dir: PathBuf,
|
||||
snapshot_cache_path: PathBuf,
|
||||
) -> Self {
|
||||
let config = match Config::load() {
|
||||
Ok(Some(c)) => Some(c),
|
||||
Ok(None) => None,
|
||||
@@ -60,11 +77,27 @@ impl DoctateApp {
|
||||
.map(|c| c.api_key.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let (state, uploader, upload_events) = if let Some(cfg) = &config {
|
||||
let (up, rx) = spawn_uploader(&runtime, cfg.clone(), pending_dir.clone());
|
||||
(AppState::Idle, Some(up), Some(rx))
|
||||
// Open the case store before any background task — the store
|
||||
// owns the watch::Sender that drives the UI list.
|
||||
let (store, snapshot_rx) = runtime
|
||||
.block_on(async { CaseStore::open(cases_dir).await })
|
||||
.expect("open case store");
|
||||
let case_store = Arc::new(store);
|
||||
let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path));
|
||||
let http_client = Arc::new(reqwest::Client::new());
|
||||
|
||||
let (state, uploader, upload_events, poller) = if let Some(cfg) = &config {
|
||||
let (up, rx, pol) = spawn_background(
|
||||
&runtime,
|
||||
cfg.clone(),
|
||||
pending_dir.clone(),
|
||||
http_client.clone(),
|
||||
case_store.clone(),
|
||||
snapshot_cache.clone(),
|
||||
);
|
||||
(AppState::Idle, Some(up), Some(rx), Some(pol))
|
||||
} else {
|
||||
(AppState::NotConfigured, None, None)
|
||||
(AppState::NotConfigured, None, None, None)
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -74,10 +107,15 @@ impl DoctateApp {
|
||||
state,
|
||||
server_url_input,
|
||||
api_key_input,
|
||||
http_client,
|
||||
case_store,
|
||||
snapshot_rx,
|
||||
snapshot_cache,
|
||||
recorder: None,
|
||||
recorder_events: None,
|
||||
uploader,
|
||||
upload_events,
|
||||
poller,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,26 +123,70 @@ impl DoctateApp {
|
||||
let cfg = Config {
|
||||
server_url: self.server_url_input.trim().to_string(),
|
||||
api_key: self.api_key_input.trim().to_string(),
|
||||
oneliner_poll_interval_seconds: self
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.oneliner_poll_interval_seconds),
|
||||
oneliner_window_hours: self
|
||||
.config
|
||||
.as_ref()
|
||||
.and_then(|c| c.oneliner_window_hours),
|
||||
};
|
||||
match cfg.save() {
|
||||
Ok(()) => {
|
||||
info!("config saved");
|
||||
let (up, rx) = spawn_uploader(&self.runtime, cfg.clone(), self.pending_dir.clone());
|
||||
self.uploader = Some(up);
|
||||
self.upload_events = Some(rx);
|
||||
self.config = Some(cfg);
|
||||
self.state = AppState::Idle;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "config save failed");
|
||||
self.state = AppState::Error {
|
||||
message: format!("Speichern: {e}"),
|
||||
};
|
||||
}
|
||||
if let Err(e) = cfg.save() {
|
||||
error!(error = %e, "config save failed");
|
||||
self.state = AppState::Error {
|
||||
message: format!("Speichern: {e}"),
|
||||
};
|
||||
return;
|
||||
}
|
||||
info!("config saved");
|
||||
|
||||
// New API key = potentially different user. Wipe local state so
|
||||
// we don't leak old user's cases into the new user's view.
|
||||
let store = self.case_store.clone();
|
||||
let cache = self.snapshot_cache.clone();
|
||||
self.runtime.block_on(async move {
|
||||
if let Err(e) = store.clear_all().await {
|
||||
warn!(error = %e, "case store clear failed on config change");
|
||||
}
|
||||
if let Err(e) = cache.clear().await {
|
||||
warn!(error = %e, "snapshot cache clear failed on config change");
|
||||
}
|
||||
});
|
||||
|
||||
// Drop old background tasks explicitly so their AbortHandle /
|
||||
// channel-close fires before the new ones spawn.
|
||||
self.poller = None;
|
||||
self.uploader = None;
|
||||
self.upload_events = None;
|
||||
|
||||
let (up, rx, pol) = spawn_background(
|
||||
&self.runtime,
|
||||
cfg.clone(),
|
||||
self.pending_dir.clone(),
|
||||
self.http_client.clone(),
|
||||
self.case_store.clone(),
|
||||
self.snapshot_cache.clone(),
|
||||
);
|
||||
self.uploader = Some(up);
|
||||
self.upload_events = Some(rx);
|
||||
self.poller = Some(pol);
|
||||
self.config = Some(cfg);
|
||||
self.state = AppState::Idle;
|
||||
}
|
||||
|
||||
fn on_start_new(&mut self) {
|
||||
self.start_recording_for(Uuid::new_v4(), /* is_continue */ false);
|
||||
}
|
||||
|
||||
fn on_continue(&mut self, case_id: Uuid) {
|
||||
self.start_recording_for(case_id, /* is_continue */ true);
|
||||
}
|
||||
|
||||
fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) {
|
||||
if !matches!(self.state, AppState::Idle) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(&self.pending_dir) {
|
||||
self.state = AppState::Error {
|
||||
message: format!("Pending-Verzeichnis: {e}"),
|
||||
@@ -112,11 +194,26 @@ impl DoctateApp {
|
||||
return;
|
||||
}
|
||||
|
||||
let case_id = Uuid::new_v4();
|
||||
let recorded_at = now_rfc3339();
|
||||
let stem = format!("{}_{}", case_id, recorded_at_to_filename_stem(&recorded_at));
|
||||
let output_path = self.pending_dir.join(format!("{stem}.m4a"));
|
||||
|
||||
// Persist the case marker before recording so the UI list
|
||||
// reflects the new case immediately (even if the recorder fails
|
||||
// to start, the marker stays — harmless and reproducible).
|
||||
let store = self.case_store.clone();
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
self.runtime.block_on(async move {
|
||||
let res = if is_continue {
|
||||
store.mark_activity(case_id, now).await.map(|_| ())
|
||||
} else {
|
||||
store.create_local(case_id, now).await.map(|_| ())
|
||||
};
|
||||
if let Err(e) = res {
|
||||
warn!(error = %e, "case store write failed");
|
||||
}
|
||||
});
|
||||
|
||||
let _guard = self.runtime.enter();
|
||||
match Recorder::start(output_path) {
|
||||
Ok((recorder, events)) => {
|
||||
@@ -156,6 +253,20 @@ impl DoctateApp {
|
||||
};
|
||||
}
|
||||
|
||||
fn on_open_web(&self, case_id: Uuid) {
|
||||
let Some(cfg) = &self.config else {
|
||||
return;
|
||||
};
|
||||
let url = format!(
|
||||
"{}/web/cases/{}",
|
||||
cfg.server_url.trim_end_matches('/'),
|
||||
case_id
|
||||
);
|
||||
if let Err(e) = webbrowser::open(&url) {
|
||||
warn!(url = %url, error = %e, "open browser failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_recorder_events(&mut self) {
|
||||
let Some(rx) = self.recorder_events.as_mut() else {
|
||||
return;
|
||||
@@ -201,6 +312,15 @@ impl DoctateApp {
|
||||
}
|
||||
};
|
||||
|
||||
// Bump activity on the marker so the case rises in the list.
|
||||
let store = self.case_store.clone();
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
self.runtime.block_on(async move {
|
||||
if let Err(e) = store.mark_activity(case_id, now).await {
|
||||
warn!(error = %e, "mark_activity on recording finished failed");
|
||||
}
|
||||
});
|
||||
|
||||
let upload = PendingUpload {
|
||||
case_id,
|
||||
recorded_at,
|
||||
@@ -271,7 +391,7 @@ impl DoctateApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_body(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
|
||||
fn render_controls(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
|
||||
match &self.state {
|
||||
AppState::NotConfigured => {
|
||||
ui.label("Server URL:");
|
||||
@@ -331,23 +451,142 @@ impl DoctateApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_case_list(&self, ui: &mut egui::Ui) -> Option<UiAction> {
|
||||
let snapshot = self.snapshot_rx.borrow().clone();
|
||||
let window = self
|
||||
.config
|
||||
.as_ref()
|
||||
.map(Config::window_hours)
|
||||
.unwrap_or(72);
|
||||
let cutoff = time::OffsetDateTime::now_utc() - time::Duration::hours(window as i64);
|
||||
|
||||
let visible: Vec<&CaseMarker> = snapshot
|
||||
.iter()
|
||||
.filter(|m| parse_rfc3339(&m.last_activity_at).is_none_or(|t| t >= cutoff))
|
||||
.collect();
|
||||
|
||||
if visible.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
ui.colored_label(egui::Color32::GRAY, "Noch keine Fälle heute.");
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut action = None;
|
||||
let idle = matches!(self.state, AppState::Idle);
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for marker in visible {
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
|
||||
let time_str = extract_hhmm(&marker.last_activity_at);
|
||||
let oneliner = marker
|
||||
.oneliner
|
||||
.as_deref()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| "⏳".to_owned());
|
||||
ui.label(format!("{time_str} · {oneliner}"));
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(idle, egui::Button::new("▶ Fortsetzen"))
|
||||
.clicked()
|
||||
{
|
||||
action = Some(UiAction::Continue(marker.case_id));
|
||||
}
|
||||
if ui.button("🌐 Öffnen").clicked() {
|
||||
action = Some(UiAction::OpenWeb(marker.case_id));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
fn render_staleness(&self, ui: &mut egui::Ui) {
|
||||
let Some(poller) = &self.poller else {
|
||||
return;
|
||||
};
|
||||
let Some(last) = poller.last_success() else {
|
||||
ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
|
||||
return;
|
||||
};
|
||||
let age = last.elapsed();
|
||||
let secs = age.as_secs();
|
||||
let (color, text) = if secs < 30 {
|
||||
(egui::Color32::DARK_GREEN, format!("🟢 zuletzt: vor {secs} s"))
|
||||
} else if secs < 120 {
|
||||
(
|
||||
egui::Color32::from_rgb(200, 140, 0),
|
||||
format!("🟡 zuletzt: vor {secs} s"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
egui::Color32::DARK_RED,
|
||||
format!("🔴 zuletzt: vor {} s", secs),
|
||||
)
|
||||
};
|
||||
ui.colored_label(color, text);
|
||||
}
|
||||
|
||||
fn handle_action(&mut self, action: UiAction) {
|
||||
match action {
|
||||
UiAction::SaveConfig => self.on_save_config(),
|
||||
UiAction::StartNew => self.on_start_new(),
|
||||
UiAction::Continue(id) => self.on_continue(id),
|
||||
UiAction::OpenWeb(id) => self.on_open_web(id),
|
||||
UiAction::Stop => self.on_stop(),
|
||||
UiAction::DismissError => self.state = AppState::Idle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_uploader(
|
||||
fn spawn_background(
|
||||
runtime: &Runtime,
|
||||
config: Config,
|
||||
pending_dir: PathBuf,
|
||||
) -> (Uploader, mpsc::UnboundedReceiver<UploadEvent>) {
|
||||
http: Arc<reqwest::Client>,
|
||||
case_store: Arc<CaseStore>,
|
||||
snapshot_cache: Arc<SnapshotCache>,
|
||||
) -> (Uploader, mpsc::UnboundedReceiver<UploadEvent>, OnelinerPoller) {
|
||||
let _guard = runtime.enter();
|
||||
Uploader::spawn(config, pending_dir)
|
||||
let poll_interval = config.poll_interval();
|
||||
let window_hours = config.window_hours();
|
||||
let (uploader, rx) = Uploader::spawn(config.clone(), pending_dir);
|
||||
let poller = OnelinerPoller::spawn(
|
||||
http,
|
||||
PollerConfig {
|
||||
server_url: config.server_url,
|
||||
api_key: config.api_key,
|
||||
poll_interval,
|
||||
window_hours,
|
||||
},
|
||||
case_store,
|
||||
snapshot_cache,
|
||||
);
|
||||
(uploader, rx, poller)
|
||||
}
|
||||
|
||||
fn parse_rfc3339(s: &str) -> Option<time::OffsetDateTime> {
|
||||
time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
|
||||
}
|
||||
|
||||
/// Extract `HH:MM` from an RFC3339 UTC timestamp. Returns the raw string
|
||||
/// truncated to the first 16 chars as a fallback on parse failure — the
|
||||
/// user still gets something readable.
|
||||
fn extract_hhmm(ts: &str) -> String {
|
||||
match parse_rfc3339(ts) {
|
||||
Some(t) => {
|
||||
let local = t
|
||||
.to_offset(
|
||||
time::UtcOffset::current_local_offset()
|
||||
.unwrap_or(time::UtcOffset::UTC),
|
||||
);
|
||||
format!("{:02}:{:02}", local.hour(), local.minute())
|
||||
}
|
||||
None => ts.chars().take(16).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for DoctateApp {
|
||||
@@ -355,27 +594,53 @@ impl eframe::App for DoctateApp {
|
||||
self.drain_recorder_events();
|
||||
self.drain_upload_events();
|
||||
|
||||
let mut pending_action: Option<UiAction> = None;
|
||||
|
||||
egui::TopBottomPanel::top("header").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.heading("doctate");
|
||||
ui.label(self.state.label());
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("footer").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
self.render_staleness(ui);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.heading("doctate");
|
||||
ui.add_space(4.0);
|
||||
ui.label(self.state.label());
|
||||
ui.add_space(12.0);
|
||||
|
||||
let action = self.render_body(ui);
|
||||
if let Some(action) = action {
|
||||
self.handle_action(action);
|
||||
if let Some(a) = self.render_controls(ui) {
|
||||
pending_action = Some(a);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
// Case list fills the remaining space.
|
||||
if !matches!(self.state, AppState::NotConfigured)
|
||||
&& let Some(a) = self.render_case_list(ui)
|
||||
{
|
||||
pending_action = Some(a);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(action) = pending_action {
|
||||
self.handle_action(action);
|
||||
}
|
||||
|
||||
// Repaint periodically so the staleness indicator + watch
|
||||
// snapshots reach the UI without a user event.
|
||||
match self.state {
|
||||
AppState::Recording { .. } => ctx.request_repaint_after(Duration::from_millis(250)),
|
||||
AppState::FinalizingRecording { .. } | AppState::Uploading { .. } => {
|
||||
ctx.request_repaint_after(Duration::from_millis(100));
|
||||
}
|
||||
_ => {}
|
||||
_ => ctx.request_repaint_after(Duration::from_secs(1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::app::DoctateApp;
|
||||
use crate::paths::pending_dir;
|
||||
use crate::paths::{cases_dir, pending_dir, snapshot_cache_path};
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -39,11 +39,26 @@ fn main() -> eframe::Result {
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let cases = match cases_dir() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cannot determine cases directory: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let cache_path = match snapshot_cache_path() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("cannot determine snapshot cache path: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([320.0, 280.0])
|
||||
.with_resizable(false),
|
||||
.with_inner_size([400.0, 560.0])
|
||||
.with_min_inner_size([320.0, 400.0])
|
||||
.with_resizable(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -51,7 +66,7 @@ fn main() -> eframe::Result {
|
||||
"doctate",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
let app = DoctateApp::new(runtime, pending);
|
||||
let app = DoctateApp::new(runtime, pending, cases, cache_path);
|
||||
Ok(Box::new(app) as Box<dyn eframe::App>)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -330,6 +330,8 @@ mod tests {
|
||||
let config = Config {
|
||||
server_url: server.uri(),
|
||||
api_key: "test-key".into(),
|
||||
oneliner_poll_interval_seconds: None,
|
||||
oneliner_window_hours: None,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
@@ -370,6 +372,8 @@ mod tests {
|
||||
let config = Config {
|
||||
server_url: server.uri(),
|
||||
api_key: "test-key".into(),
|
||||
oneliner_poll_interval_seconds: None,
|
||||
oneliner_window_hours: None,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
@@ -400,6 +404,8 @@ mod tests {
|
||||
let config = Config {
|
||||
server_url: server.uri(),
|
||||
api_key: "bad-key".into(),
|
||||
oneliner_poll_interval_seconds: None,
|
||||
oneliner_window_hours: None,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
Reference in New Issue
Block a user