diff --git a/priv/assets/css/ldf.css b/priv/assets/css/ldf.css index 5de2ef8..af9c7ea 100644 --- a/priv/assets/css/ldf.css +++ b/priv/assets/css/ldf.css @@ -89,12 +89,14 @@ body::after { label.seg { display: inline-flex; align-items: center; gap: 7px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 4px; color: var(--dim); cursor: pointer; font-size: 12px; } label.seg:has(input:checked) { color: var(--live); border-color: var(--live-dim); background: rgba(46,230,166,.05); } input[type=radio] { accent-color: var(--live); } -input[type=text] { +input[type=text], input[type="datetime-local"] { flex: 1; min-width: 220px; background: var(--bg); border: 1px solid var(--line); color: var(--text); font-family: var(--mono); font-size: 13px; padding: 10px 12px; border-radius: 4px; } -input[type=text]:focus { outline: none; border-color: var(--live-dim); box-shadow: 0 0 0 3px rgba(46,230,166,.12); } +input[type="datetime-local"] { color-scheme: dark; } +input[type=text]:focus, input[type="datetime-local"]:focus { outline: none; border-color: var(--live-dim); box-shadow: 0 0 0 3px rgba(46,230,166,.12); } input::placeholder { color: var(--faint); } +.hint { color: var(--faint); font-size: 11px; margin: 8px 0 0; } .btn { font-family: var(--mono); font-size: 12px; letter-spacing: .1em; text-transform: uppercase; @@ -150,6 +152,17 @@ tbody tr.new { animation: flashin .7s ease-out; } .rec-meta i { color: var(--faint); font-style: normal; text-transform: uppercase; letter-spacing: .08em; margin-right: 6px; } .rec-meta .rec-agent { flex-basis: 100%; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* per-message ETSI conversion */ +.rec-etsi { display: flex; gap: 8px; margin-top: 10px; } +.etsi-btn { padding: 5px 11px; font-size: 10.5px; letter-spacing: .08em; } +.etsi:empty { display: none; } +.etsi-bar { display: flex; align-items: center; gap: 10px; margin-top: 12px; } +.etsi-label { color: var(--live); font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase; } +.etsi-label.etsi-err { color: var(--danger); } +.etsi-hide { margin-left: auto; padding: 4px 10px; font-size: 10.5px; } +.etsi-pre { margin: 8px 0 0; padding: 12px 14px; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; max-height: 360px; overflow: auto; font-size: 11.5px; line-height: 1.45; white-space: pre; } +.etsi-pre code { color: var(--text); } + .empty { padding: 40px 18px; text-align: center; color: var(--faint); font-size: 12.5px; } .empty::before { content: "▱ "; color: var(--live-dim); } diff --git a/src/controllers/ldf_www_controller.erl b/src/controllers/ldf_www_controller.erl index 068bfcf..4d5aac7 100644 --- a/src/controllers/ldf_www_controller.erl +++ b/src/controllers/ldf_www_controller.erl @@ -12,6 +12,7 @@ add_listener/1, remove_listener/1, submit_history/1, + message_etsi/1, publish_message/2 ]). @@ -66,7 +67,7 @@ remove_listener(#{bindings := #{~"callbackid" := CallbackId}}) -> submit_history(Req0) -> case datastar_nova:read_signals(Req0) of {{ok, #{~"type" := Type, ~"value" := Value} = Signals}, _Req} when Value =/= ~"" -> - Timestamp = maps:get(~"timestamp", Signals, ~""), + Timestamp = ldf_format:datetime_to_ms(maps:get(~"timestamp", Signals, ~"")), ldf_srv:get_history( thoas:encode(#{type => Type, value => Value, timestamp => Timestamp}) ), @@ -75,6 +76,22 @@ submit_history(Req0) -> status_patch(~"Target type and value are required.") end. +%% Convert a stored message to the requested ETSI XML and inject it, pretty +%% printed, under its row. Any other format (e.g. "hide") clears the block. +message_etsi(#{bindings := #{~"messageid" := MessageId, ~"format" := Format}}) when + Format =:= ~"103707"; Format =:= ~"103120" +-> + Block = + case ldf_db:get_message(MessageId) of + {ok, [#{payload := Payload} | _]} -> + render_etsi(Format, MessageId, safe_decode(Payload)); + _ -> + etsi_error(~"Message not found.") + end, + {datastar, [patch_xml(MessageId, Block)]}; +message_etsi(#{bindings := #{~"messageid" := MessageId}}) -> + {datastar, [patch_xml(MessageId, ~"")]}. + %% --- published by the message API on each intercepted message ------------- publish_message(_MessageId, Payload) -> @@ -126,7 +143,8 @@ fields(Msg) -> {sender, short(maps:get(~"sender", Msg, ~""))}, {recipient, short(maps:get(~"to", Msg, ~""))}, {chat, short(maps:get(~"chat_id", Msg, ~""))}, - {id, short(maps:get(~"id", Msg, ~""))} + {id, short(maps:get(~"id", Msg, ~""))}, + {full_id, maps:get(~"id", Msg, ~"")} | Payload ]. @@ -141,7 +159,8 @@ blank_fields() -> {sender, ~""}, {recipient, ~""}, {chat, ~""}, - {id, ~""} + {id, ~""}, + {full_id, ~""} ]. safe_decode(Bin) -> @@ -156,6 +175,40 @@ fmt_time(Ms) when is_integer(Ms) -> fmt_time(_) -> ~"". +patch_xml(MessageId, Block) -> + datastar:patch_elements(Block, #{selector => <<"#xml-", MessageId/binary>>, mode => inner}). + +render_etsi(Format, MessageId, Msg) when is_map(Msg) -> + try to_etsi(Format, Msg) of + Xml -> + etsi_block(Format, MessageId, ldf_format:html_escape(ldf_format:pretty_xml(Xml))) + catch + _:_ -> etsi_error(~"Could not convert this message.") + end; +render_etsi(_Format, _MessageId, _Msg) -> + etsi_error(~"Malformed message payload."). + +to_etsi(~"103120", Msg) -> + etsi103120:json_to_xml(Msg, ~"undefined", ~"undefined", ~"undefined"); +to_etsi(_, Msg) -> + etsi103707:json_to_xml(Msg). + +etsi_block(Format, MessageId, EscapedXml) -> + iolist_to_binary([ + ~"
",
+ EscapedXml,
+ ~""
+ ]).
+
+etsi_error(Text) ->
+ iolist_to_binary([
+ ~""
+ ]).
+
short(<Replays messages from this point in time onward (UTC).