%%%-------------------------------------------------------------------
%% @doc Minimal read-only N2O/Nitro administration dashboard skeleton.
%%%-------------------------------------------------------------------
-module(vpn_n2o_admin).
-include_lib("nitro/include/cx.hrl").
-include_lib("nitro/include/nitro.hrl").
-export([init/2,
cx/2,
event/1,
refresh_dashboard/0]).
init(Req0, State) ->
case cowboy_req:method(Req0) of
<<"GET">> ->
reply_page(Req0, State);
_Other ->
Req = cowboy_req:reply(
405,
#{<<"allow">> => <<"GET">>},
Req0),
{ok, Req, State}
end.
reply_page(Req0, State) ->
try render(vpn_admin:summary_view()) of
Html ->
Req = cowboy_req:reply(
200,
#{<<"content-type">> => <<"text/html; charset=utf-8">>},
Html,
Req0),
{ok, Req, State}
catch
Class:Reason:Stack ->
logger:error("N2O dashboard render failed: ~p:~p~n~p",
[Class, Reason, Stack]),
Req = cowboy_req:reply(
500,
#{<<"content-type">> => <<"text/html; charset=utf-8">>},
<<"
VPN Dashboard N2O Error"
"VPN Dashboard N2O Error
">>,
Req0),
{ok, Req, State}
end.
render(Summary) ->
nitro:actions([]),
Counts = maps:get(counts, Summary, #{}),
Peers = maps:get(peers, Summary, []),
Body = nitro:render([
#main{
body = [
#h1{body = <<"VPN Dashboard (N2O)">>},
render_actions(),
render_message(<<>>),
render_counts(Counts),
render_peer_table(Peers),
#p{class = <<"runtime">>,
body = [<<"N2O ">>, n2o_version(), <<" / Nitro rendered">>]}
]
}
]),
iolist_to_binary([
<<"">>,
<<"">>,
<<"VPN Dashboard (N2O)">>,
style(),
<<"">>,
Body,
scripts(),
<<"">>
]).
render_actions() ->
#section{
class = <<"actions">>,
body = [
#button{
body = <<"Reload Config">>,
onclick = <<"direct(atom('reload_config'));">>
}
]
}.
render_message(Message) ->
#panel{
id = <<"dashboard_messages">>,
class = <<"messages">>,
body = Message
}.
render_counts(Counts) ->
#section{
id = <<"dashboard_counts">>,
class = <<"counts">>,
body = [
count_card(<<"Configured Peers">>, maps:get(configured, Counts, 0)),
count_card(<<"Running Peers">>, maps:get(running, Counts, 0)),
count_card(<<"Stopped Peers">>, maps:get(stopped, Counts, 0)),
count_card(<<"Certificates">>, maps:get(certificates, Counts, 0))
]
}.
count_card(Label, Value) ->
#panel{
class = <<"count">>,
body = [
#span{body = Label},
#span{class = <<"value">>, body = integer_to_binary(Value)}
]
}.
render_peer_table(Peers) ->
HeaderRow = #tr{cells = [header_cell(Label) || Label <- table_headers()]},
Rows = [render_peer_row(Peer) || Peer <- Peers],
#table{
id = <<"dashboard_peers">>,
header = HeaderRow,
body = #tbody{body = Rows}
}.
table_headers() ->
[
<<"Peer">>,
<<"Running">>,
<<"Mode">>,
<<"IP">>,
<<"Remote Peer">>,
<<"Trusted">>,
<<"Key Match">>,
<<"Expires">>,
<<"Crypto Failures">>,
<<"Frames Rejected">>,
<<"Actions">>
].
header_cell(Label) ->
#th{body = Label}.
render_peer_row(Peer) ->
Certificate = maps:get(certificate, Peer, #{}),
#tr{
cells = [
cell(maps:get(id, Peer, null)),
cell(yes_no(maps:get(running, Peer, false))),
cell(maps:get(mode, Peer, null)),
cell(maps:get(ip, Peer, null)),
cell(maps:get(remote_peer_id, Peer, null)),
cell(yes_no(maps:get(trusted, Certificate, false))),
cell(yes_no(maps:get(key_match, Certificate, false))),
cell(maps:get(not_after, Certificate, null)),
cell(maps:get(crypto_failures, Peer, 0)),
cell(maps:get(frames_rejected, Peer, 0)),
action_cell(Peer)
]
}.
cell(Value) ->
#td{body = value(Value)}.
action_cell(Peer) ->
PeerId = maps:get(id, Peer, null),
Running = maps:get(running, Peer, false),
#td{body = action_button(PeerId, Running)}.
action_button(PeerId, true) ->
#button{
body = <<"Stop">>,
onclick = direct_peer_event(<<"stop_peer">>, PeerId)
};
action_button(PeerId, false) ->
#button{
body = <<"Start">>,
onclick = direct_peer_event(<<"start_peer">>, PeerId)
}.
yes_no(true) ->
<<"yes">>;
yes_no(false) ->
<<"no">>;
yes_no(_Value) ->
<<"no">>.
value(null) ->
<<>>;
value(undefined) ->
<<>>;
value(Value) when is_binary(Value) ->
Value;
value(Value) when is_integer(Value) ->
integer_to_binary(Value);
value(Value) when is_float(Value) ->
float_to_binary(Value, [{decimals, 6}, compact]);
value(true) ->
<<"true">>;
value(false) ->
<<"false">>;
value(Value) when is_atom(Value) ->
atom_to_binary(Value, utf8);
value(Value) when is_list(Value) ->
unicode:characters_to_binary(Value);
value(_Value) ->
<<>>.
style() ->
<<"">>.
scripts() ->
[
<<"">>,
<<"">>,
<<"">>,
<<"">>,
<<"">>,
<<"">>,
<<"">>
].
direct_peer_event(Event, PeerId) ->
EscapedPeerId = nitro:js_escape(value(PeerId)),
[<<"direct(tuple(atom('">>, Event, <<"'),bin('">>, EscapedPeerId, <<"')));">>].
n2o_version() ->
case n2o:version() of
undefined ->
<<"unknown">>;
Version ->
nitro:to_binary(Version)
end.
event({start_peer, PeerIdValue}) ->
PeerIdBin = value(PeerIdValue),
Message =
case find_peer_id(PeerIdBin) of
{ok, PeerId} ->
action_message(start, PeerIdBin, vpn_manager:start_peer(PeerId));
{error, not_found} ->
log_action_error(start, PeerIdBin, not_found),
<<"Peer not found">>
end,
refresh_dashboard(Message);
event({stop_peer, PeerIdValue}) ->
PeerIdBin = value(PeerIdValue),
Message =
case find_peer_id(PeerIdBin) of
{ok, PeerId} ->
action_message(stop, PeerIdBin, vpn_manager:stop_peer(PeerId));
{error, not_found} ->
log_action_error(stop, PeerIdBin, not_found),
<<"Peer not found">>
end,
refresh_dashboard(Message);
event(reload_config) ->
Message = reload_message(vpn_manager:reload_config()),
refresh_dashboard(Message);
event(init) ->
ok;
event(_Event) ->
ok.
refresh_dashboard() ->
refresh_dashboard(<<>>).
refresh_dashboard(Message) ->
Summary = vpn_admin:summary_view(),
Counts = maps:get(counts, Summary, #{}),
Peers = maps:get(peers, Summary, []),
nitro:update(dashboard_messages, render_message(Message)),
nitro:update(dashboard_counts, render_counts(Counts)),
nitro:update(dashboard_peers, render_peer_table(Peers)),
ok.
action_message(start, PeerIdBin, {ok, _Pid}) ->
[<<"Peer ">>, PeerIdBin, <<" started">>];
action_message(start, PeerIdBin, {error, already_started}) ->
log_action_error(start, PeerIdBin, already_started),
[<<"Peer ">>, PeerIdBin, <<" already started">>];
action_message(start, PeerIdBin, {error, Reason}) ->
log_action_error(start, PeerIdBin, Reason),
[<<"Failed to start peer ">>, PeerIdBin];
action_message(stop, PeerIdBin, ok) ->
[<<"Peer ">>, PeerIdBin, <<" stopped">>];
action_message(stop, PeerIdBin, {error, Reason}) ->
log_action_error(stop, PeerIdBin, Reason),
[<<"Failed to stop peer ">>, PeerIdBin].
reload_message(#{failed := []}) ->
<<"Configuration reloaded">>;
reload_message(#{failed := Failed}) ->
logger:warning("N2O dashboard reload completed with failures: ~p", [Failed]),
<<"Configuration reloaded with failures">>;
reload_message(Result) ->
logger:info("N2O dashboard reload completed: ~p", [Result]),
<<"Configuration reloaded">>.
find_peer_id(PeerIdBin) ->
case [PeerId || PeerId <- vpn_manager:list_peers(),
peer_id_binary(PeerId) =:= PeerIdBin] of
[PeerId | _] ->
{ok, PeerId};
[] ->
{error, not_found}
end.
peer_id_binary(PeerId) when is_binary(PeerId) ->
PeerId;
peer_id_binary(PeerId) when is_atom(PeerId) ->
atom_to_binary(PeerId, utf8).
log_action_error(Action, PeerIdBin, Reason) ->
logger:warning("N2O dashboard peer action failed action=~p peer=~s reason=~p",
[Action, PeerIdBin, Reason]).
cx(Cookies, Req) ->
Token =
case lists:keyfind(<<"X-Auth-Token">>, 1, Cookies) of
{_, Value} ->
Value;
false ->
<<>>
end,
Sid =
case n2o:depickle(Token) of
{{Session, _}, _} ->
Session;
_Other ->
<<>>
end,
#cx{actions = [],
path = cowboy_req:path(Req),
req = Req,
params = [],
session = Sid,
token = Token,
module = ?MODULE,
handlers = []}.