Skip to main content

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