flams_router_logging/
lib.rs

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