flams_router_logging/
lib.rs

1#![allow(clippy::must_use_candidate)]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3
4#[cfg(any(
5    all(feature = "ssr", feature = "hydrate", not(feature = "docs-only")),
6    not(any(feature = "ssr", feature = "hydrate"))
7))]
8compile_error!("exactly one of the features \"ssr\" or \"hydrate\" must be enabled");
9
10#[cfg(feature = "ssr")]
11use flams_router_base::LoginState;
12use flams_router_base::require_login;
13use flams_router_base::ws;
14use flams_utils::logs::{LogFileLine, LogLevel, LogMessage, LogTree};
15use flams_utils::vecmap::VecMap;
16use flams_web_utils::components::{Header, LazySubtree, Leaf, Spinner, Tree};
17use ftml_ontology::utils::time::Timestamp;
18use leptos::{either::Either, prelude::*};
19use std::num::NonZeroU64;
20use thaw::Caption1Strong;
21
22#[cfg(feature = "ssr")]
23async fn full_log() -> Result<flams_utils::logs::LogTree, ()> {
24    use tokio::io::AsyncBufReadExt;
25
26    let path = flams_system::logging::logger().log_file();
27
28    let reader = tokio::io::BufReader::new(tokio::fs::File::open(path).await.map_err(|_| ())?);
29    let mut lines = reader.lines();
30    let mut tree = flams_utils::logs::LogTree::default();
31    while let Ok(Some(line)) = lines.next_line().await {
32        if !line.is_empty() {
33            if let Ok(line) = serde_json::from_str(&line) {
34                tree.add_line(line);
35            }
36        }
37    }
38
39    Ok(tree)
40}
41
42#[component]
43pub fn Logger() -> AnyView {
44    use ftml_dom::utils::css::inject_css;
45    require_login(Box::new(|| {
46        inject_css("flams-logging", include_str!("logs.css"));
47        let signals = LogSignals {
48            top: RwSignal::new(Vec::new()),
49            open_span_paths: RwSignal::new(VecMap::default()),
50            warnings: RwSignal::new(Vec::new()),
51        };
52        Effect::new(move |_| {
53            #[cfg(feature = "hydrate")]
54            {
55                use ws::WebSocketClient;
56                let _ = LogSocket::start(move |msg| {
57                    LogSocket::ws(signals, msg);
58                    None
59                });
60            }
61        });
62        view! {
63            <div class="flams-log-frame">{ move || {
64                if signals.top.with(Vec::is_empty) {
65                    Either::Left(view!(<div class="flams-spinner-frame"><Spinner/></div>))
66                } else {Either::Right(view!{<Tree>
67                    {do_ls(signals.top)}
68                </Tree>})}
69            }}</div>
70            <div class="flams-warn-frame">
71            <Caption1Strong><span style="color:var(--colorPaletteRedForeground1)">"Warnings"</span></Caption1Strong>{ move || {
72                if signals.top.with(Vec::is_empty) {
73                    Either::Left(view!(<div class="flams-spinner-frame"><Spinner/></div>))
74                } else {Either::Right(view!{<Tree>
75                    <For each=move || signals.warnings.get() key=|e| e.0 children=move |e| view!(
76                        <Leaf><LogLine e=e.1/></Leaf>
77                    )/>
78                </Tree>})}
79            }}</div>
80        }.into_any()
81    }))
82}
83
84fn do_ls(v: RwSignal<Vec<LogEntrySignal>>) -> AnyView {
85    view! {
86        <For each=move || v.get() key=|e| e.id().to_string() children=|e| {
87            match e {
88                LogEntrySignal::Simple(_,e) => view!(<Leaf><span class="flams-log-elem"><LogLine e/></span></Leaf>).into_any(),
89                LogEntrySignal::Span(_,e) => do_span(e).into_any()
90            }
91        }/>
92    }.into_any()
93}
94fn do_span(s: SpanSignal) -> AnyView {
95    let children = s.children;
96    view! {<LazySubtree>
97        <Header slot>{move || {let s = s.clone(); match s.message.get() {
98            SpanMessage::Open {name,timestamp} => view!(<LogLineHelper message=name timestamp target=s.target level=s.level args=s.args spinner=true/>),
99            SpanMessage::Closed(message) => view!(<LogLineHelper message target=s.target level=s.level args=s.args spinner=false />)
100        }}}</Header>
101        {move || do_ls(children)}
102    </LazySubtree>}.into_any()
103}
104
105#[component]
106fn LogLine(e: LogMessage) -> impl IntoView {
107    let LogMessage {
108        message,
109        timestamp,
110        target,
111        level,
112        args,
113    } = e;
114    view!(<LogLineHelper message timestamp target level args/>)
115}
116
117#[component]
118fn LogLineHelper(
119    message: String,
120    #[prop(optional)] timestamp: Option<Timestamp>,
121    target: Option<String>,
122    level: LogLevel,
123    args: VecMap<String, String>,
124    #[prop(optional)] spinner: bool,
125) -> AnyView {
126    use flams_web_utils::components::SpinnerSize;
127    use std::fmt::Write;
128    let cls = class_from_level(level);
129    let mut str = timestamp.map_or_else(
130        || format!("<{level}> "),
131        |timestamp| format!("{timestamp} <{level}> "),
132    );
133    if let Some(target) = target {
134        write!(str, "[{target}] ").expect("this is a bug");
135    }
136    str.push_str(&message);
137    if !args.is_empty() {
138        str.push_str(" (");
139        for (k, v) in args {
140            write!(str, "{k}:{v} ").expect("this is a bug");
141        }
142        str.push(')');
143    }
144    if spinner {
145        view!(<span class=cls>
146            <span class="flams-spinner-inline">
147            <Spinner size=SpinnerSize::Tiny/>
148            </span>{str}
149        </span>).into_any()
150    } else {
151        view!(<span class=cls>{str}</span>).into_any()
152    }
153}
154
155const fn class_from_level(lvl: LogLevel) -> &'static str {
156    match lvl {
157        LogLevel::ERROR => "flams-log-error",
158        LogLevel::WARN => "flams-log-warn",
159        LogLevel::INFO => "flams-log-info",
160        LogLevel::DEBUG => "flams-log-debug",
161        LogLevel::TRACE => "flams-log-trace",
162    }
163}
164pub struct LogSocket {
165    #[cfg(feature = "ssr")]
166    listener: flams_utils::change_listener::ChangeListener<LogFileLine>,
167    #[cfg(all(feature = "hydrate", not(feature = "docs-only")))]
168    socket: leptos::web_sys::WebSocket,
169    #[cfg(all(feature = "hydrate", feature = "docs-only"))]
170    socket: (),
171}
172
173//#[async_trait]
174impl ws::WebSocket<(), Log> for LogSocket {
175    const SERVER_ENDPOINT: &'static str = "/ws/log";
176}
177
178#[cfg(feature = "ssr")]
179#[async_trait::async_trait]
180impl ws::WebSocketServer<(), Log> for LogSocket {
181    async fn new(account: LoginState, _db: ws::DBBackend) -> Option<Self> {
182        match account {
183            LoginState::Admin
184            | LoginState::NoAccounts
185            | LoginState::User { is_admin: true, .. } => {
186                let listener = flams_system::logging::logger().listener();
187                Some(Self {
188                    listener,
189                    #[cfg(feature = "hydrate")]
190                    socket: unreachable!(),
191                })
192            }
193            _ => None,
194        }
195    }
196    async fn next(&mut self) -> Option<Log> {
197        self.listener.read().await.map(Log::Update)
198    }
199    async fn handle_message(&mut self, _msg: ()) -> Option<Log> {
200        None
201    }
202    async fn on_start(&mut self, socket: &mut ws::AxumWS) {
203        if let Ok(init) = full_log().await {
204            let _ = socket
205                .send(ws::WSMessage::Text({
206                    let Ok(s) = serde_json::to_string(&Log::Initial(init)) else {
207                        return;
208                    };
209                    s.into()
210                }))
211                .await;
212        }
213    }
214}
215
216#[cfg(feature = "hydrate")]
217impl ws::WebSocketClient<(), Log> for LogSocket {
218    fn new(ws: leptos::web_sys::WebSocket) -> Self {
219        Self {
220            #[cfg(not(feature = "docs-only"))]
221            socket: ws,
222            #[cfg(feature = "docs-only")]
223            socket: (),
224            #[cfg(feature = "ssr")]
225            listener: unreachable!(),
226        }
227    }
228    fn socket(&mut self) -> &mut leptos::web_sys::WebSocket {
229        #[cfg(not(feature = "docs-only"))]
230        {
231            &mut self.socket
232        }
233        #[cfg(feature = "docs-only")]
234        {
235            unreachable!()
236        }
237    }
238}
239
240#[cfg(feature = "hydrate")]
241impl LogSocket {
242    fn ws(signals: LogSignals, l: Log) {
243        match l {
244            Log::Initial(tree) => Self::populate(signals, tree),
245            Log::Update(up) => Self::update(signals, up),
246        }
247    }
248
249    fn convert(
250        e: flams_utils::logs::LogTreeElem,
251        warnings: &mut Vec<(NonZeroU64, LogMessage)>,
252    ) -> LogEntrySignal {
253        use flams_utils::logs::{LogSpan, LogTreeElem};
254        match e {
255            LogTreeElem::Message(e) => {
256                let id = next_id();
257                if e.level >= LogLevel::WARN {
258                    warnings.push((id, e.clone()));
259                }
260                LogEntrySignal::Simple(next_id(), e)
261            }
262            LogTreeElem::Span(e @ LogSpan { closed: None, .. }) => LogEntrySignal::Span(
263                next_id(),
264                SpanSignal {
265                    message: RwSignal::new(SpanMessage::Open {
266                        name: e.name,
267                        timestamp: e.timestamp,
268                    }),
269                    target: e.target,
270                    level: e.level,
271                    args: e.args,
272                    children: RwSignal::new(
273                        e.children
274                            .into_iter()
275                            .map(|e| Self::convert(e, warnings))
276                            .collect(),
277                    ),
278                },
279            ),
280            LogTreeElem::Span(e) => {
281                let closed = e.closed.unwrap_or_else(|| unreachable!());
282                LogEntrySignal::Span(
283                    next_id(),
284                    SpanSignal {
285                        message: RwSignal::new(SpanMessage::Closed(format!(
286                            "{} (finished after {})",
287                            e.name,
288                            closed.since(e.timestamp)
289                        ))),
290                        target: e.target,
291                        level: e.level,
292                        args: e.args,
293                        children: RwSignal::new(
294                            e.children
295                                .into_iter()
296                                .map(|e| Self::convert(e, warnings))
297                                .collect(),
298                        ),
299                    },
300                )
301            }
302        }
303    }
304
305    fn populate(signals: LogSignals, tree: LogTree) {
306        signals
307            .open_span_paths
308            .try_update_untracked(|v| *v = tree.open_span_paths);
309        signals.warnings.try_update(|ws| {
310            signals.top.try_update(|v| {
311                *v = tree
312                    .children
313                    .into_iter()
314                    .map(|e| Self::convert(e, ws))
315                    .collect();
316            })
317        });
318    }
319    fn update(signals: LogSignals, update: LogFileLine) {
320        let (msg, parent, is_span) = match update {
321            LogFileLine::Message {
322                message,
323                timestamp,
324                target,
325                level,
326                args,
327                span,
328            } => {
329                let id = next_id();
330                let message = LogMessage {
331                    message,
332                    timestamp,
333                    target,
334                    level,
335                    args,
336                };
337                if level >= LogLevel::WARN {
338                    signals
339                        .warnings
340                        .try_update(|v| v.push((id, message.clone())));
341                }
342                (LogEntrySignal::Simple(id, message), span, None)
343            }
344            LogFileLine::SpanOpen {
345                name,
346                id,
347                timestamp,
348                target,
349                level,
350                args,
351                parent,
352            } => {
353                let span = SpanSignal {
354                    message: RwSignal::new(SpanMessage::Open { name, timestamp }),
355                    target,
356                    level,
357                    args,
358                    children: RwSignal::new(Vec::new()),
359                };
360                (LogEntrySignal::Span(next_id(), span), parent, Some(id))
361            }
362            LogFileLine::SpanClose { timestamp, id, .. } => {
363                signals.close(id, timestamp);
364                return;
365            }
366        };
367        signals.merge(msg, parent, is_span);
368    }
369}
370
371#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
372pub enum Log {
373    Initial(LogTree),
374    Update(LogFileLine),
375}
376
377#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize)]
378struct LogSignals {
379    top: RwSignal<Vec<LogEntrySignal>>,
380    open_span_paths: RwSignal<VecMap<NonZeroU64, Vec<usize>>>,
381    warnings: RwSignal<Vec<(NonZeroU64, LogMessage)>>,
382}
383
384#[cfg(feature = "hydrate")]
385impl LogSignals {
386    fn close(&self, id: NonZeroU64, timestamp: Timestamp) {
387        if let Some(path) = self
388            .open_span_paths
389            .try_update_untracked(|v| v.remove(&id))
390            .flatten()
391        {
392            Self::close_i(self.top, &path, timestamp);
393        };
394    }
395    fn close_i(sigs: RwSignal<Vec<LogEntrySignal>>, path: &[usize], timestamp: Timestamp) {
396        if path.is_empty() {
397            return;
398        }
399        let i = path[0];
400        let path = &path[1..];
401
402        sigs.try_with_untracked(|v| {
403            if let Some(LogEntrySignal::Span(_, s)) = v.get(i) {
404                if path.is_empty() {
405                    s.message.try_update(|m| {
406                        if let SpanMessage::Open {
407                            name,
408                            timestamp: old,
409                        } = &m
410                        {
411                            let msg =
412                                format!("{} (finished after {})", name, timestamp.since(*old));
413                            *m = SpanMessage::Closed(msg);
414                        }
415                    });
416                } else {
417                    Self::close_i(s.children, path, timestamp);
418                }
419            }
420        });
421    }
422
423    fn merge(&self, line: LogEntrySignal, parent: Option<NonZeroU64>, is_span: Option<NonZeroU64>) {
424        match parent {
425            None => {
426                if let Some(id) = is_span {
427                    self.top.try_update(|ch| {
428                        self.open_span_paths
429                            .try_update_untracked(|v| v.insert(id, vec![ch.len()]));
430                        ch.push(line);
431                    });
432                } else {
433                    self.top.try_update(|ch| ch.push(line));
434                }
435            }
436            Some(parent) => {
437                let mut path = Vec::new();
438                self.open_span_paths.try_update_untracked(|v| {
439                    if let Some(p) = v.get(&parent) {
440                        path.clone_from(p);
441                    } /*else {
442                    leptos::logging::log!("Parent not found: {line:?}!");
443                    }*/
444                });
445                self.merge_i(self.top, 0, path, is_span, line);
446            }
447        }
448    }
449    fn merge_i(
450        &self,
451        sigs: RwSignal<Vec<LogEntrySignal>>,
452        i: usize,
453        mut path: Vec<usize>,
454        is_span: Option<NonZeroU64>,
455        line: LogEntrySignal,
456    ) {
457        if i == path.len() {
458            return;
459        }
460        let e = path[i];
461        if path.len() == i + 1 {
462            sigs.try_update(|v| {
463                if let Some(s) = is_span {
464                    path.push(v.len());
465                    self.open_span_paths
466                        .try_update_untracked(|v| v.insert(s, path));
467                }
468                v.push(line);
469            });
470        } else {
471            sigs.try_with_untracked(|v| {
472                let LogEntrySignal::Span(_, s) = &v[e] else {
473                    unreachable!()
474                };
475                self.merge_i(s.children, i + 1, path, is_span, line);
476            });
477        }
478    }
479}
480
481#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
482enum LogEntrySignal {
483    Simple(NonZeroU64, LogMessage),
484    Span(NonZeroU64, SpanSignal),
485}
486impl LogEntrySignal {
487    #[inline]
488    const fn id(&self) -> NonZeroU64 {
489        match self {
490            Self::Simple(id, _) | Self::Span(id, _) => *id,
491        }
492    }
493}
494
495#[cfg(feature = "hydrate")]
496static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
497#[cfg(feature = "hydrate")]
498fn next_id() -> NonZeroU64 {
499    NonZeroU64::new(COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
500        .unwrap_or_else(|| unreachable!())
501}
502
503#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
504enum SpanMessage {
505    Open { name: String, timestamp: Timestamp },
506    Closed(String),
507}
508#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
509struct SpanSignal {
510    pub message: RwSignal<SpanMessage>,
511    pub target: Option<String>,
512    pub level: LogLevel,
513    pub args: VecMap<String, String>,
514    pub children: RwSignal<Vec<LogEntrySignal>>,
515}