From 2dd618585109cc5b752cd771dd6ac6f5aeb0b7cc Mon Sep 17 00:00:00 2001 From: euzu Date: Fri, 29 Aug 2025 22:40:17 +0200 Subject: [PATCH] save config --- frontend/public/assets/i18n/en.json | 3 +- frontend/scss/app/_component.scss | 3 +- .../scss/app/components/_keyvalue_editor.scss | 23 +++ .../app/components/config/api_config_view.rs | 68 ++++++- .../src/app/components/config/config_page.rs | 4 +- .../config/reverse_proxy_config_view.rs | 24 ++- .../components/config/video_config_view.rs | 182 +++++++++++++----- .../components/config/webui_config_view.rs | 5 + .../src/app/components/key_value_editor.rs | 165 ++++++++++++++++ frontend/src/app/components/mod.rs | 2 + shared/src/model/config/web_auth.rs | 14 +- shared/src/model/messaging.rs | 8 +- 12 files changed, 436 insertions(+), 65 deletions(-) create mode 100644 frontend/scss/app/components/_keyvalue_editor.scss create mode 100644 frontend/src/app/components/key_value_editor.rs diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 27d78e378..d44341343 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -275,7 +275,8 @@ "USER_STATUS_PENDING": "Pending", "VIEW": "View", "ADD_CHAT_ID": "Add ChatId...", - "ADD_ATTRIBUTE": "Add Attribute..." + "ADD_ATTRIBUTE": "Add Attribute...", + "ADD_EXTENSION": "Add Extension..." }, "TITLE": { "USER_BOUQUET_EDITOR": "User group editor" diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index 8ef6a2b29..e941c8a46 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -68,4 +68,5 @@ @forward "components/tabset"; @forward "components/select"; @forward "components/form"; -@forward "components/api_user/api_user_view"; \ No newline at end of file +@forward "components/api_user/api_user_view"; +@forward "components/keyvalue_editor"; \ No newline at end of file diff --git a/frontend/scss/app/components/_keyvalue_editor.scss b/frontend/scss/app/components/_keyvalue_editor.scss new file mode 100644 index 000000000..1027a854e --- /dev/null +++ b/frontend/scss/app/components/_keyvalue_editor.scss @@ -0,0 +1,23 @@ +.tp__keyvalue-editor { + display: flex; + flex-flow: column; + box-sizing: border-box; + gap: var(--gap-small); + + &__entries { + display: flex; + flex-flow: row wrap; + box-sizing: border-box; + gap: var(--gap-small); + } + + label{ + font-size: 1.1rem; + } + &__inputs { + display: flex; + flex-flow: row; + box-sizing: border-box; + gap: var(--gap-small); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/config/api_config_view.rs b/frontend/src/app/components/config/api_config_view.rs index 68d067542..0485ba3f4 100644 --- a/frontend/src/app/components/config/api_config_view.rs +++ b/frontend/src/app/components/config/api_config_view.rs @@ -39,6 +39,70 @@ pub fn ApiConfigView() -> Html { }); } + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.form.clone(), form_state.modified); + use_effect_with(deps, move |(form, modified)| { + on_form_change.emit(ConfigForm::Api(*modified, form.clone())); + || () + }); + } + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.form.clone(), form_state.modified); + use_effect_with(deps, move |(form, modified)| { + on_form_change.emit(ConfigForm::Api(*modified, form.clone())); + || () + }); + } + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.form.clone(), form_state.modified); + use_effect_with(deps, move |(form, modified)| { + on_form_change.emit(ConfigForm::Api(*modified, form.clone())); + || () + }); + } + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.form.clone(), form_state.modified); + use_effect_with(deps, move |(form, modified)| { + on_form_change.emit(ConfigForm::Api(*modified, form.clone())); + || () + }); + } + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.form.clone(), form_state.modified); + use_effect_with(deps, move |(form, modified)| { + on_form_change.emit(ConfigForm::Api(*modified, form.clone())); + || () + }); + } + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.form.clone(), form_state.modified); + use_effect_with(deps, move |(form, modified)| { + on_form_change.emit(ConfigForm::Api(*modified, form.clone())); + || () + }); + } + + { + let on_form_change = config_view_ctx.on_form_change.clone(); + let deps = (form_state.form.clone(), form_state.modified); + use_effect_with(deps, move |(form, modified)| { + on_form_change.emit(ConfigForm::Api(*modified, form.clone())); + || () + }); + } + + { let form_state = form_state.clone(); let api_config = config_ctx @@ -46,7 +110,8 @@ pub fn ApiConfigView() -> Html { .as_ref() .map(|c| c.config.api.clone()); - use_effect_with((api_config, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { + let deps = (api_config, *config_view_ctx.edit_mode); + use_effect_with(deps, move |(cfg, _mode)| { if let Some(api) = cfg { form_state.dispatch(ApiConfigFormAction::SetAll(api.clone())); } else { @@ -54,6 +119,7 @@ pub fn ApiConfigView() -> Html { } || () }); + } let render_empty = || { diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index b8deb4dc9..cc593b62a 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -1,6 +1,6 @@ use shared::error::TuliproxError; use shared::info_err; -use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, WebUiConfigDto}; +use shared::model::{ConfigApiDto, HdHomeRunConfigDto, IpCheckConfigDto, MainConfigDto, MessagingConfigDto, ProxyConfigDto, ReverseProxyConfigDto, ScheduleConfigDto, VideoConfigDto, WebUiConfigDto}; use std::fmt; use std::str::FromStr; @@ -72,7 +72,7 @@ pub enum ConfigForm { Main(bool, MainConfigDto), Api(bool, ConfigApiDto), Schedules(bool, ScheduleConfigDto), - Video(bool, MessagingConfigDto), + Video(bool, VideoConfigDto), Messaging(bool, MessagingConfigDto), WebUi(bool, WebUiConfigDto), ReverseProxy(bool, ReverseProxyConfigDto), diff --git a/frontend/src/app/components/config/reverse_proxy_config_view.rs b/frontend/src/app/components/config/reverse_proxy_config_view.rs index 297a71155..9afc89365 100644 --- a/frontend/src/app/components/config/reverse_proxy_config_view.rs +++ b/frontend/src/app/components/config/reverse_proxy_config_view.rs @@ -123,15 +123,23 @@ pub fn ReverseProxyConfigView() -> Html { use_effect_with((reverse_proxy_cfg, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { if let Some(rp) = cfg { reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll((*rp).clone())); + if let Some(cache) = &rp.cache { - cache_state.dispatch(CacheConfigFormAction::SetAll(cache.clone())); - } - if let Some(rl) = &rp.rate_limit { - rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(rl.clone())); - } - if let Some(stream) = &rp.stream { - stream_state.dispatch(StreamConfigFormAction::SetAll(stream.clone())); - } + cache_state.dispatch(CacheConfigFormAction::SetAll(cache.clone())); + } else { + cache_state.dispatch(CacheConfigFormAction::SetAll(CacheConfigDto::default())); + } + if let Some(rl) = &rp.rate_limit { + rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(rl.clone())); + } else { + rate_limit_state.dispatch(RateLimitConfigFormAction::SetAll(RateLimitConfigDto::default())); + } + if let Some(stream) = &rp.stream { + stream_state.dispatch(StreamConfigFormAction::SetAll(stream.clone())); + } else { + stream_state.dispatch(StreamConfigFormAction::SetAll(StreamConfigDto::default())); + } + } else { reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll(ReverseProxyConfigDto::default())); cache_state.dispatch(CacheConfigFormAction::SetAll(CacheConfigDto::default())); diff --git a/frontend/src/app/components/config/video_config_view.rs b/frontend/src/app/components/config/video_config_view.rs index 85d3ec46d..59666b5bd 100644 --- a/frontend/src/app/components/config/video_config_view.rs +++ b/frontend/src/app/components/config/video_config_view.rs @@ -1,9 +1,13 @@ -use crate::app::components::{Card, Chip}; +use std::collections::HashMap; +use crate::app::components::{Card, Chip, KeyValueEditor}; use crate::app::context::ConfigContext; -use crate::{config_field_bool, config_field_child, config_field_empty, config_field_optional}; -use shared::model::VideoDownloadConfigDto; +use crate::{config_field_bool, config_field_child, config_field_empty, config_field_optional, edit_field_bool, edit_field_text_option, edit_field_list, generate_form_reducer, config_field_bool_empty}; +use shared::model::{VideoDownloadConfigDto, VideoConfigDto}; use yew::prelude::*; use yew_i18n::use_translation; +use crate::app::components::config::config_page::ConfigForm; +use crate::app::components::config::config_view_context::ConfigViewContext; +use crate::app::components::config::macros::HasFormData; const LABEL_DOWNLOAD: &str = "LABEL.DOWNLOAD"; const LABEL_ORGANIZE_INTO_DIRECTORIES: &str = "LABEL.ORGANIZE_INTO_DIRECTORIES"; @@ -12,26 +16,97 @@ const LABEL_EPISODE_PATTERN: &str = "LABEL.EPISODE_PATTERN"; const LABEL_HEADERS: &str = "LABEL.HEADERS"; const LABEL_EXTENSIONS: &str = "LABEL.EXTENSIONS"; const LABEL_WEB_SEARCH: &str = "LABEL.WEB_SEARCH"; +const LABEL_ADD_EXTENSION: &str = "LABEL.ADD_EXTENSION"; + +generate_form_reducer!( + state: VideoDownloadConfigFormState { form: VideoDownloadConfigDto }, + action_name: VideoDownloadConfigFormAction, + fields { + OrganizeIntoDirectories => organize_into_directories: bool, + Directory => directory: Option, + EpisodePattern => episode_pattern: Option, + Headers => headers: HashMap, + } +); + +generate_form_reducer!( + state: VideoConfigFormState { form: VideoConfigDto }, + action_name: VideoConfigFormAction, + fields { + WebSearch => web_search: Option, + Extensions => extensions: Vec, + } +); #[function_component] pub fn VideoConfigView() -> Html { let translate = use_translation(); let config_ctx = use_context::().expect("ConfigContext not found"); + let config_view_ctx = use_context::().expect("ConfigViewContext not found"); + + let download_state: UseReducerHandle = use_reducer(|| { + VideoDownloadConfigFormState { form: VideoDownloadConfigDto::default(), modified: false } + }); + let video_state: UseReducerHandle = use_reducer(|| { + VideoConfigFormState { form: VideoConfigDto::default(), modified: false } + }); + + let handle_headers = { + let download_state = download_state.clone(); + Callback::from(move |headers: HashMap| { + download_state.dispatch(VideoDownloadConfigFormAction::Headers(headers)); + }) + }; + + { + let video_state = video_state.clone(); + let download_state = download_state.clone(); + let video_cfg = config_ctx.config.as_ref().and_then(|c| c.config.video.clone()); + use_effect_with(video_cfg, move |video_cfg| { + if let Some(video) = video_cfg { + video_state.dispatch(VideoConfigFormAction::Extensions(video.extensions.clone())); + video_state.dispatch(VideoConfigFormAction::WebSearch(video.web_search.clone())); + if let Some(download) = video.download.as_ref() { + download_state.dispatch(VideoDownloadConfigFormAction::OrganizeIntoDirectories(download.organize_into_directories)); + download_state.dispatch(VideoDownloadConfigFormAction::Directory(download.directory.clone())); + download_state.dispatch(VideoDownloadConfigFormAction::EpisodePattern(download.episode_pattern.clone())); + download_state.dispatch(VideoDownloadConfigFormAction::Headers(download.headers.clone())); + } else { + download_state.dispatch(VideoDownloadConfigFormAction::SetAll(VideoDownloadConfigDto::default())); + } + } else { + video_state.dispatch(VideoConfigFormAction::SetAll(VideoConfigDto::default())); + download_state.dispatch(VideoDownloadConfigFormAction::SetAll(VideoDownloadConfigDto::default())); + } + || () + }); + } + + { + // Sync form changes with parent context + let on_form_change = config_view_ctx.on_form_change.clone(); + let download_state = download_state.clone(); + let video_state = video_state.clone(); + use_effect_with((video_state, download_state), move |(v, d)| { + let mut form = v.form.clone(); + form.download = Some(d.form.clone()); + let modified = v.modified || d.modified; + on_form_change.emit(ConfigForm::Video(modified, form)); + }); + } let render_extensions = |extensions: &Vec| html! { { config_field_child!(translate.t(LABEL_EXTENSIONS), { html! {
- { - html! { for extensions.iter().map(|t| html! { }) } - } + { html! { for extensions.iter().map(|t| html! { }) } }
}})}
}; - let render_download = |download: Option<&VideoDownloadConfigDto>| html! { + let render_download_view = |download: Option<&VideoDownloadConfigDto>| html! { match download { Some(entry) => html! { @@ -43,17 +118,7 @@ pub fn VideoConfigView() -> Html { html! {
    - { - if entry.headers.is_empty() { - html! {} - } else { - html! { - for entry.headers.iter().map(|(key, value)| html! { -
  • {key}{":"} {value}
  • - }) - } - } - } + { for entry.headers.iter().map(|(k,v)| html!{
  • {k}{":"} {v}
  • }) }
} @@ -63,52 +128,75 @@ pub fn VideoConfigView() -> Html { None => html! {

{translate.t(LABEL_DOWNLOAD)}

- { config_field_empty!(translate.t(LABEL_ORGANIZE_INTO_DIRECTORIES)) } + { config_field_bool_empty!(translate.t(LABEL_ORGANIZE_INTO_DIRECTORIES)) } { config_field_empty!(translate.t(LABEL_DIRECTORY)) } { config_field_empty!(translate.t(LABEL_EPISODE_PATTERN)) } { config_field_empty!(translate.t(LABEL_HEADERS)) }
- }, + } } }; - let render_empty = || { - html! { - <> + let render_empty = || html! { + <>
{ config_field_empty!(translate.t(LABEL_WEB_SEARCH)) }
{ config_field_empty!(translate.t(LABEL_EXTENSIONS)) } - { render_download(None) } + { render_download_view(None) }
- + + }; + + let render_view_mode = || { + if let Some(config) = &config_ctx.config { + if let Some(video) = &config.config.video { + html! { + <> +
+ { config_field_optional!(video, translate.t(LABEL_WEB_SEARCH), web_search) } +
+
+ { render_extensions(&video.extensions) } + { render_download_view(video.download.as_ref()) } +
+ + } + } else { render_empty() } + } else { render_empty() } + }; + + let render_edit_mode = || { + html! { + <> +
+ { edit_field_text_option!(video_state, translate.t(LABEL_WEB_SEARCH), web_search, VideoConfigFormAction::WebSearch) } +
+
+ + { edit_field_list!(video_state, translate.t(LABEL_EXTENSIONS), extensions, VideoConfigFormAction::Extensions, translate.t(LABEL_ADD_EXTENSION)) } + + +

{translate.t(LABEL_DOWNLOAD)}

+ { edit_field_bool!(download_state, translate.t(LABEL_ORGANIZE_INTO_DIRECTORIES), organize_into_directories, VideoDownloadConfigFormAction::OrganizeIntoDirectories) } + { edit_field_text_option!(download_state, translate.t(LABEL_DIRECTORY), directory, VideoDownloadConfigFormAction::Directory) } + { edit_field_text_option!(download_state, translate.t(LABEL_EPISODE_PATTERN), episode_pattern, VideoDownloadConfigFormAction::EpisodePattern) } + +
+
+ } }; html! {
- { - if let Some(config) = &config_ctx.config { - if let Some(video) = &config.config.video { - html! { - <> -
- { config_field_optional!(video, translate.t(LABEL_WEB_SEARCH), web_search) } -
-
- { render_extensions(&video.extensions) } - { render_download(video.download.as_ref()) } -
- - } - } else { - { render_empty() } - } - } else { - { render_empty() } - } - } + { if *config_view_ctx.edit_mode { render_edit_mode() } else { render_view_mode() } }
} -} \ No newline at end of file +} diff --git a/frontend/src/app/components/config/webui_config_view.rs b/frontend/src/app/components/config/webui_config_view.rs index 7f9164705..b15353db2 100644 --- a/frontend/src/app/components/config/webui_config_view.rs +++ b/frontend/src/app/components/config/webui_config_view.rs @@ -102,10 +102,15 @@ pub fn WebUiConfigView() -> Html { webui_state.dispatch(WebUiConfigFormAction::SetAll((*webui).clone())); if let Some(auth) = &webui.auth { auth_state.dispatch(WebUiAuthConfigFormAction::SetAll(auth.clone())); + } else { + auth_state.dispatch(WebUiAuthConfigFormAction::SetAll(WebAuthConfigDto::default())); } if let Some(csp) = &webui.content_security_policy { csp_state.dispatch(CspConfigFormAction::SetAll(csp.clone())); + } else { + csp_state.dispatch(CspConfigFormAction::SetAll(ContentSecurityPolicyConfigDto::default())); } + } else { webui_state.dispatch(WebUiConfigFormAction::SetAll(WebUiConfigDto::default())); auth_state.dispatch(WebUiAuthConfigFormAction::SetAll(WebAuthConfigDto::default())); diff --git a/frontend/src/app/components/key_value_editor.rs b/frontend/src/app/components/key_value_editor.rs new file mode 100644 index 000000000..c1cef7ef8 --- /dev/null +++ b/frontend/src/app/components/key_value_editor.rs @@ -0,0 +1,165 @@ +use std::collections::HashMap; +use std::rc::Rc; +use web_sys::HtmlInputElement; +use yew::prelude::*; +use crate::app::components::chip::Chip; + +#[derive(Clone, PartialEq, Debug)] +pub struct KeyValue { + pub key: String, + pub value: String, +} + +#[derive(Properties, Clone, PartialEq)] +pub struct KeyValueEditorProps { + #[prop_or_default] + pub label: Option, + pub entries: HashMap, + #[prop_or_else(Callback::noop)] + pub on_change: Callback>, + #[prop_or(true)] + pub readonly: bool, + #[prop_or_else(|| "Add key".to_string())] + pub key_placeholder: String, + #[prop_or_else(|| "Add value".to_string())] + pub value_placeholder: String, +} + +#[function_component] +pub fn KeyValueEditor(props: &KeyValueEditorProps) -> Html { + let KeyValueEditorProps { + label, + entries, + on_change, + readonly, + key_placeholder, + value_placeholder, + } = props.clone(); + + // local state for editing + let entry_state = use_state(|| { + entries.iter().map(|(k,v)| Rc::new(KeyValue { key: k.clone(), value: v.clone() })).collect::>() + }); + let new_key = use_state(String::default); + let new_value = use_state(String::default); + + // keep local state in sync when parent updates + { + let entry_state = entry_state.clone(); + use_effect_with(entries.clone(), move |entries| { + entry_state.set( + entries.iter() + .map(|(k,v)| Rc::new(KeyValue { key: k.clone(), value: v.clone() })) + .collect() + ); + || () + }); + } + + // remove existing entry + let on_remove = { + let entry_state = entry_state.clone(); + let on_change = on_change.clone(); + Callback::from(move |key: String| { + let mut updated = (*entry_state).clone(); + updated.retain(|kv| kv.key != key); + // emit new HashMap + let map = updated.iter().map(|kv| (kv.key.clone(), kv.value.clone())).collect::>(); + on_change.emit(map.clone()); + entry_state.set(updated); + }) + }; + + // input change for new key/value + let on_input_key = { + let new_key = new_key.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + new_key.set(input.value()); + }) + }; + + let on_input_value = { + let new_value = new_value.clone(); + Callback::from(move |e: InputEvent| { + let input: HtmlInputElement = e.target_unchecked_into(); + new_value.set(input.value()); + }) + }; + + // add new entry on enter in value field + let on_keydown_value = { + let new_key = new_key.clone(); + let new_value = new_value.clone(); + let entry_state = entry_state.clone(); + let on_change = on_change.clone(); + Callback::from(move |e: KeyboardEvent| { + if e.key() == "Enter" { + e.prevent_default(); + let key = (*new_key).trim().to_string(); + let value = (*new_value).trim().to_string(); + if !key.is_empty() && !value.is_empty() && !entry_state.iter().any(|kv| kv.key == key) { + let mut updated = (*entry_state).clone(); + updated.push(Rc::new(KeyValue { key: key.clone(), value: value.clone() })); + // emit new HashMap + let map = updated.iter().map(|kv| (kv.key.clone(), kv.value.clone())).collect::>(); + on_change.emit(map.clone()); + entry_state.set(updated); + } + new_key.set(String::new()); + new_value.set(String::new()); + } + }) + }; + + html! { +
+ { if let Some(lbl) = &label { + html! { } + } else { html!{} } } +
+ { for (*entry_state).iter().map(|kv| { + let key_clone = kv.key.clone(); + html! { + + } + })} +
+ { + if readonly { + html! {} + } else { + html! { +
+
+
+ +
+
+
+
+ +
+
+
+ } + } + } +
+ } +} diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index 0d446989f..d64e4c9a7 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -41,6 +41,7 @@ mod date_input; mod role_based_content; mod api_user; mod cell_value; +mod key_value_editor; // pub use self::input::*; // pub use self::menu_item::*; // pub use self::popup_menu::*; @@ -79,5 +80,6 @@ pub use self::csv_table::*; pub use self::tabset::*; pub use self::role_based_content::*; pub use self::cell_value::*; +pub use self::key_value_editor::*; //pub use self::number_input::*; //pub use self::date_input::*; \ No newline at end of file diff --git a/shared/src/model/config/web_auth.rs b/shared/src/model/config/web_auth.rs index 4807b3a70..912fa90c8 100644 --- a/shared/src/model/config/web_auth.rs +++ b/shared/src/model/config/web_auth.rs @@ -4,7 +4,7 @@ fn default_token_ttl_mins() -> u32 { 30 } -#[derive(Default, Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct WebAuthConfigDto { #[serde(default = "default_as_true")] @@ -16,3 +16,15 @@ pub struct WebAuthConfigDto { #[serde(default, skip_serializing_if = "Option::is_none")] pub userfile: Option, } + +impl Default for WebAuthConfigDto { + fn default() -> Self { + Self { + enabled: default_as_true(), + issuer: String::new(), + secret: String::new(), + token_ttl_mins: default_token_ttl_mins(), + userfile: None, + } + } +} \ No newline at end of file diff --git a/shared/src/model/messaging.rs b/shared/src/model/messaging.rs index c68f15e13..4a7216de9 100644 --- a/shared/src/model/messaging.rs +++ b/shared/src/model/messaging.rs @@ -30,13 +30,13 @@ impl FromStr for MsgKind { type Err = TuliproxError; fn from_str(s: &str) -> Result { - if s.eq("Info") { + if s.eq_ignore_ascii_case("info") { Ok(Self::Info) - } else if s.eq("Stats") { + } else if s.eq_ignore_ascii_case("stats") { Ok(Self::Stats) - } else if s.eq("Error") { + } else if s.eq_ignore_ascii_case("error") { Ok(Self::Error) - } else if s.eq("Watch") { + } else if s.eq_ignore_ascii_case("watch") { Ok(Self::Watch) } else { create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown MsgKind: {}", s)