aboutsummaryrefslogtreecommitdiff
path: root/apps/dreki_web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dreki_web')
-rw-r--r--apps/dreki_web/.gitignore1
-rw-r--r--apps/dreki_web/assets/package-lock.json13
-rw-r--r--apps/dreki_web/assets/package.json3
-rw-r--r--apps/dreki_web/rebar.config3
-rw-r--r--apps/dreki_web/src/admin/dreki_web_admin_tasks.erl (renamed from apps/dreki_web/src/dreki_web_admin_tasks.erl)0
-rw-r--r--apps/dreki_web/src/admin/dreki_web_admin_world.erl (renamed from apps/dreki_web/src/dreki_web_admin_world.erl)0
-rw-r--r--apps/dreki_web/src/api/dreki_web_index.erl (renamed from apps/dreki_web/src/dreki_web_index.erl)0
-rw-r--r--apps/dreki_web/src/api/dreki_web_task.erl (renamed from apps/dreki_web/src/dreki_web_task.erl)0
-rw-r--r--apps/dreki_web/src/cowboy_access_log_h.erl238
-rw-r--r--apps/dreki_web/src/dreki_web.app.src3
-rw-r--r--apps/dreki_web/src/dreki_web_app.erl12
-rw-r--r--apps/dreki_web/src/dreki_web_handler.erl99
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui.erl (renamed from apps/dreki_web/src/dreki_web_ui.erl)3
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_error.erl (renamed from apps/dreki_web/src/dreki_web_ui_error.erl)0
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_index.erl (renamed from apps/dreki_web/src/dreki_web_ui_index.erl)0
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_json_form.erl (renamed from apps/dreki_web/src/dreki_web_ui_json_form.erl)79
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_node.erl (renamed from apps/dreki_web/src/dreki_web_ui_node.erl)0
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_stores.erl (renamed from apps/dreki_web/src/dreki_web_ui_stores.erl)75
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_task.erl (renamed from apps/dreki_web/src/dreki_web_ui_task.erl)0
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_tasks.erl (renamed from apps/dreki_web/src/dreki_web_ui_tasks.erl)0
-rw-r--r--apps/dreki_web/templates/crash.dtl52
-rw-r--r--apps/dreki_web/templates/layout.dtl7
-rw-r--r--apps/dreki_web/templates/store_list.dtl8
-rw-r--r--apps/dreki_web/templates/store_new.dtl10
-rw-r--r--apps/dreki_web/templates/store_show.dtl8
25 files changed, 568 insertions, 46 deletions
diff --git a/apps/dreki_web/.gitignore b/apps/dreki_web/.gitignore
index 8dec94f..8946870 100644
--- a/apps/dreki_web/.gitignore
+++ b/apps/dreki_web/.gitignore
@@ -18,3 +18,4 @@ _build
rebar3.crashdump
*~
/priv/static/
+/assets/node_modules/
diff --git a/apps/dreki_web/assets/package-lock.json b/apps/dreki_web/assets/package-lock.json
index c99c8b3..b6aa028 100644
--- a/apps/dreki_web/assets/package-lock.json
+++ b/apps/dreki_web/assets/package-lock.json
@@ -12,7 +12,8 @@
"@tailwindcss/forms": "^0.5.0",
"@tailwindcss/line-clamp": "^0.3.1",
"@tailwindcss/typography": "^0.5.2",
- "crossfilter": "^1.3.12"
+ "crossfilter": "^1.3.12",
+ "xterm": "^4.18.0"
},
"devDependencies": {
"@hotwired/stimulus": "^3.0.1",
@@ -2063,6 +2064,11 @@
"node": ">=0.4"
}
},
+ "node_modules/xterm": {
+ "version": "4.18.0",
+ "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.18.0.tgz",
+ "integrity": "sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ=="
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -3567,6 +3573,11 @@
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
},
+ "xterm": {
+ "version": "4.18.0",
+ "resolved": "https://registry.npmjs.org/xterm/-/xterm-4.18.0.tgz",
+ "integrity": "sha512-JQoc1S0dti6SQfI0bK1AZvGnAxH4MVw45ZPFSO6FHTInAiau3Ix77fSxNx3mX4eh9OL4AYa8+4C8f5UvnSfppQ=="
+ },
"y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/apps/dreki_web/assets/package.json b/apps/dreki_web/assets/package.json
index 07072c5..1aa07ff 100644
--- a/apps/dreki_web/assets/package.json
+++ b/apps/dreki_web/assets/package.json
@@ -27,6 +27,7 @@
"@tailwindcss/forms": "^0.5.0",
"@tailwindcss/line-clamp": "^0.3.1",
"@tailwindcss/typography": "^0.5.2",
- "crossfilter": "^1.3.12"
+ "crossfilter": "^1.3.12",
+ "xterm": "^4.18.0"
}
}
diff --git a/apps/dreki_web/rebar.config b/apps/dreki_web/rebar.config
index b16060a..e439af6 100644
--- a/apps/dreki_web/rebar.config
+++ b/apps/dreki_web/rebar.config
@@ -5,7 +5,8 @@
{erlydtl, "0.14.0"},
{oauth2c, {git, "https://github.com/kivra/oauth2_client", {branch, "master"}}},
{cowboy_telemetry, "~> 0.4.0"},
- {opentelemetry_cowboy, "~> 0.1.0"}
+ {opentelemetry_cowboy, "~> 0.1.0"},
+ {prometheus_cowboy, "0.1.8"}
]}.
{plugins, [
diff --git a/apps/dreki_web/src/dreki_web_admin_tasks.erl b/apps/dreki_web/src/admin/dreki_web_admin_tasks.erl
index adc2689..adc2689 100644
--- a/apps/dreki_web/src/dreki_web_admin_tasks.erl
+++ b/apps/dreki_web/src/admin/dreki_web_admin_tasks.erl
diff --git a/apps/dreki_web/src/dreki_web_admin_world.erl b/apps/dreki_web/src/admin/dreki_web_admin_world.erl
index 1ceaaee..1ceaaee 100644
--- a/apps/dreki_web/src/dreki_web_admin_world.erl
+++ b/apps/dreki_web/src/admin/dreki_web_admin_world.erl
diff --git a/apps/dreki_web/src/dreki_web_index.erl b/apps/dreki_web/src/api/dreki_web_index.erl
index 2ed8a38..2ed8a38 100644
--- a/apps/dreki_web/src/dreki_web_index.erl
+++ b/apps/dreki_web/src/api/dreki_web_index.erl
diff --git a/apps/dreki_web/src/dreki_web_task.erl b/apps/dreki_web/src/api/dreki_web_task.erl
index fdcf9cd..fdcf9cd 100644
--- a/apps/dreki_web/src/dreki_web_task.erl
+++ b/apps/dreki_web/src/api/dreki_web_task.erl
diff --git a/apps/dreki_web/src/cowboy_access_log_h.erl b/apps/dreki_web/src/cowboy_access_log_h.erl
new file mode 100644
index 0000000..433d706
--- /dev/null
+++ b/apps/dreki_web/src/cowboy_access_log_h.erl
@@ -0,0 +1,238 @@
+-module(cowboy_access_log_h).
+-behaviour(cowboy_stream).
+
+-dialyzer(no_undefined_callbacks).
+
+-type extra_info_fun() :: fun((cowboy_req:req()) -> #{atom() => term()}).
+-export_type([extra_info_fun/0]).
+
+%% API exports
+
+-export([set_extra_info_fun/2]).
+
+%% callback exports
+
+-export([init/3]).
+-export([data/4]).
+-export([info/3]).
+-export([terminate/3]).
+-export([early_error/5]).
+
+-type state() :: #{
+ next := any(),
+ req := cowboy_req:req(),
+ meta := #{started_at => genlib_time:ts()},
+ ext_fun := extra_info_fun()
+}.
+
+%% API
+
+-spec set_extra_info_fun(extra_info_fun(), cowboy:opts())
+ -> cowboy:opts().
+set_extra_info_fun(Fun, Opts) when is_function(Fun, 1) ->
+ Opts#{extra_info_fun => Fun}.
+
+%% callbacks
+
+-spec init(cowboy_stream:streamid(), cowboy_req:req(), cowboy:opts())
+ -> {cowboy_stream:commands(), state()}.
+init(StreamID, Req, Opts) ->
+ State = make_state(Req, Opts),
+ {Commands0, Next} = cowboy_stream:init(StreamID, Req, Opts),
+ {Commands0, State#{next => Next}}.
+
+-spec data(cowboy_stream:streamid(), cowboy_stream:fin(), cowboy_req:resp_body(), State)
+ -> {cowboy_stream:commands(), State} when State::state().
+data(StreamID, IsFin, Data, #{next := Next0} = State) ->
+ {Commands0, Next} = cowboy_stream:data(StreamID, IsFin, Data, Next0),
+ {Commands0, State#{next => Next}}.
+
+-spec info(cowboy_stream:streamid(), any(), State)
+ -> {cowboy_stream:commands(), State} when State::state().
+info(StreamID, {IsResponse, Code, Headers, _} = Info, #{req := Req, next := Next0} = State) when
+ IsResponse == response;
+ IsResponse == error_response
+->
+ _ = log_access_safe(Code, Headers, State, get_request_body_length(Req)),
+ {Commands0, Next} = cowboy_stream:info(StreamID, Info, Next0),
+ {Commands0, State#{next => Next}};
+info(StreamID, Info, #{next := Next0} = State) ->
+ {Commands0, Next} = cowboy_stream:info(StreamID, Info, Next0),
+ {Commands0, State#{next => Next}}.
+
+-spec terminate(cowboy_stream:streamid(), cowboy_stream:reason(), state()) -> any().
+terminate(StreamID, Reason, #{next := Next}) ->
+ cowboy_stream:terminate(StreamID, Reason, Next).
+
+-spec early_error(cowboy_stream:streamid(), cowboy_stream:reason(),
+ cowboy_stream:partial_req(), Resp, cowboy:opts()) -> Resp
+ when Resp::cowboy_stream:resp_command().
+
+%% NOTE: in early_error cowboy uses PartialReq, a cowboy_req:req() - like structure
+%% for more info see https://ninenines.eu/docs/en/cowboy/2.7/manual/cowboy_stream/#_callbacks
+
+early_error(StreamID, Reason, PartialReq, {_, Code, Headers, _} = Resp, Opts) ->
+ State = make_state(PartialReq, Opts),
+ _ = log_access_safe(Code, Headers, State, undefined),
+ cowboy_stream:early_error(StreamID, Reason, PartialReq, Resp, State).
+
+%% private functions
+
+log_access_safe(Code, Headers, #{req := Req} = State, ReqBodyLength) ->
+ try
+ logger:log(info, prepare_meta(Code, Headers, State, ReqBodyLength)),
+ Req
+ catch
+ Class:Reason:Stacktrace ->
+ Stack = genlib_format:format_stacktrace(Stacktrace, [newlines]),
+ _ = logger:error(
+ "Log access failed for: [~p, ~p, ~p]~nwith: ~p:~p~nstacktrace: ~ts",
+ [Code, Headers, Req, Class, Reason, Stack]
+ ),
+ Req
+ end.
+
+get_process_meta() ->
+ case logger:get_process_metadata() of
+ undefined ->
+ #{};
+ Meta ->
+ Meta
+ end.
+
+% domain field specifies the functional area that send log event
+% as we want to save logs from this app in a separate file,
+% we can easily filter logs by their domain using OTP filter functions.
+prepare_meta(Code, Headers, #{req := Req, meta:= Meta0, ext_fun := F}, ReqBodyLength) ->
+ AccessMeta = genlib_map:compact(#{
+ domain => [cowboy_access_log],
+ status => Code,
+ remote_addr => get_remote_addr(Req),
+ peer_addr => get_peer_addr(Req),
+ request_method => cowboy_req:method(Req),
+ request_path => cowboy_req:path(Req),
+ request_length => ReqBodyLength,
+ response_length => get_response_len(Headers),
+ request_time => get_request_duration(Meta0),
+ trace_id => maps:get(<<"trace-id">>, Req, <<"no-trace">>),
+ 'http_x-request-id' => cowboy_req:header(<<"x-request-id">>, Req, undefined)
+ }),
+ AccessMeta1 = maps:merge(get_process_meta(), AccessMeta),
+ maps:merge(F(Req), AccessMeta1).
+
+get_request_body_length(Req) ->
+ case cowboy_req:has_body(Req) of
+ false -> undefined;
+ true -> cowboy_req:body_length(Req)
+ end.
+
+get_peer_addr(Req) ->
+ {IP, _Port} = cowboy_req:peer(Req),
+ genlib:to_binary(inet:ntoa(IP)).
+
+get_remote_addr(Req) ->
+ case determine_remote_addr(Req) of
+ {ok, RemoteAddr} ->
+ genlib:to_binary(inet:ntoa(RemoteAddr));
+ _ ->
+ undefined
+ end.
+
+determine_remote_addr(Req) ->
+ Peer = cowboy_req:peer(Req),
+ Value = cowboy_req:header(<<"x-forwarded-for">>, Req),
+ determine_remote_addr_from_header(Value, Peer).
+
+determine_remote_addr_from_header(undefined, {IP, _Port}) ->
+ % undefined, assuming no proxies were involved
+ {ok, IP};
+determine_remote_addr_from_header(Value, _Peer) when is_binary(Value) ->
+ ClientPeer = string:strip(binary_to_list(Value)),
+ case string:tokens(ClientPeer, ", ") of
+ [ClientIP | _Proxies] ->
+ inet:parse_strict_address(ClientIP);
+ _ ->
+ {error, malformed}
+ end.
+
+get_request_duration(Meta) ->
+ case maps:get(started_at, Meta, undefined) of
+ undefined ->
+ undefined;
+ StartTime ->
+ (genlib_time:ticks() - StartTime) / 1000000
+ end.
+
+get_response_len(Headers) ->
+ case maps:get(<<"content-length">>, Headers, undefined) of
+ undefined ->
+ undefined;
+ Len ->
+ genlib:to_int(Len)
+ end.
+
+make_state(Req, Opts) ->
+ ExtFun = make_ext_fun(Opts),
+ set_meta(#{req => Req, ext_fun => ExtFun}).
+
+set_meta(State) ->
+ State#{meta => #{started_at => genlib_time:ticks()}}.
+
+make_ext_fun(Opts) ->
+ maps:get(extra_info_fun, Opts, fun(_Req) -> #{} end).
+
+-ifdef(TEST).
+-include_lib("eunit/include/eunit.hrl").
+
+-spec test() -> _.
+
+-spec filter_meta_test() -> _.
+filter_meta_test() ->
+ Req = #{
+ pid => self(),
+ peer => {{42, 42, 42, 42}, 4242},
+ method => <<"GET">>,
+ path => <<>>,
+ qs => <<>>,
+ version => 'HTTP/1.1',
+ headers => #{},
+ host => <<>>,
+ port => undefined,
+ has_body => true
+ },
+ State = make_state(Req, #{}),
+ #{
+ request_method := <<"GET">>,
+ request_path := <<>>,
+ request_time := _,
+ response_length := 33,
+ request_length := 100,
+ peer_addr := <<"42.42.42.42">>,
+ status := 200
+ } = prepare_meta(200, #{<<"content-length">> => <<"33">>}, State, 100).
+
+-spec filter_meta_for_error_test() -> _.
+filter_meta_for_error_test() ->
+ Req = #{
+ pid => self(),
+ peer => {{42, 42, 42, 42}, 4242},
+ method => <<"GET">>,
+ path => <<>>,
+ qs => <<>>,
+ version => 'HTTP/1.1',
+ headers => #{},
+ host => <<>>,
+ port => undefined,
+ has_body => true
+ },
+ State = make_state(Req, #{}),
+ #{
+ peer_addr := <<"42.42.42.42">>,
+ remote_addr := <<"42.42.42.42">>,
+ request_method := <<"GET">>,
+ request_path := <<>>,
+ request_time := _,
+ status := 400
+ } = prepare_meta(400, #{}, State, undefined).
+
+-endif.
diff --git a/apps/dreki_web/src/dreki_web.app.src b/apps/dreki_web/src/dreki_web.app.src
index cf731fc..4122e3b 100644
--- a/apps/dreki_web/src/dreki_web.app.src
+++ b/apps/dreki_web/src/dreki_web.app.src
@@ -10,7 +10,8 @@
cowboy,
trails,
cowboy_telemetry,
- opentelemetry_cowboy
+ opentelemetry_cowboy,
+ prometheus_cowboy
]},
{env,[]},
{modules, []},
diff --git a/apps/dreki_web/src/dreki_web_app.erl b/apps/dreki_web/src/dreki_web_app.erl
index 5b6454e..5b3c1a0 100644
--- a/apps/dreki_web/src/dreki_web_app.erl
+++ b/apps/dreki_web/src/dreki_web_app.erl
@@ -13,8 +13,12 @@ start(_StartType, _StartArgs) ->
Config = application:get_all_env(dreki_web),
Transport = proplists:get_value(transport, Config),
CowboyEnv = #{
- middlewares => [dreki_web_auth, cowboy_router, cowboy_handler],
- stream_handlers => [cowboy_telemetry_h, cowboy_stream_h],
+ middlewares => [dreki_web_auth, cowboy_router, dreki_web_handler],
+ stream_handlers => [cowboy_telemetry_h,
+ cowboy_access_log_h,
+ cowboy_metrics_h,
+ cowboy_stream_h],
+ metrics_callback => fun prometheus_cowboy2_instrumenter:observe/1,
env => #{
dispatch => routes()
}
@@ -35,7 +39,8 @@ routes() ->
Trails = [
{"/", dreki_web_index, undefined},
{"/static/[...]", cowboy_static,
- {priv_dir, dreki_web, "static", [{mimetypes, dreki_web, detect_web_mimetype}]}},
+ {priv_dir, dreki_web, "static", [{mimetypes, dreki_web, detect_web_mimetype}]}},
+ {"/metrics/[:registry]", prometheus_cowboy2_handler, []},
%% API
{"/api/tasks/:id", dreki_web_task, undefined},
@@ -59,6 +64,7 @@ routes() ->
{"/admin/:location/:namespace/:directory", dreki_web_ui_stores, undefined},
{"/admin/:location/:namespace/:directory/_/:action", dreki_web_ui_stores, action},
{"/admin/:location/:namespace/:directory/:id", dreki_web_ui_stores, undefined},
+ {"/admin/:location/:namespace/:directory/:id/_/:action", dreki_web_ui_stores, action},
{"/admin/[...]", dreki_web_ui_error, #{code => 404, status => <<"Not found">>}},
diff --git a/apps/dreki_web/src/dreki_web_handler.erl b/apps/dreki_web/src/dreki_web_handler.erl
new file mode 100644
index 0000000..a30fd1d
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_handler.erl
@@ -0,0 +1,99 @@
+%% Copyright (c) 2011-2017, Loïc Hoguin <essen@ninenines.eu>
+%%
+%% Permission to use, copy, modify, and/or distribute this software for any
+%% purpose with or without fee is hereby granted, provided that the above
+%% copyright notice and this permission notice appear in all copies.
+%%
+%% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+%% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+%% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+%% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+%% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+%% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+%% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+%% Handler middleware.
+%%
+%% Execute the handler given by the <em>handler</em> and <em>handler_opts</em>
+%% environment values. The result of this execution is added to the
+%% environment under the <em>result</em> value.
+-module(dreki_web_handler).
+-include_lib("opentelemetry_api/include/otel_tracer.hrl").
+-behaviour(cowboy_middleware).
+
+-export([execute/2]).
+-export([terminate/4]).
+
+-callback init(Req, any())
+ -> {ok | module(), Req, any()}
+ | {module(), Req, any(), any()}
+ when Req::cowboy_req:req().
+
+-callback terminate(any(), map(), any()) -> ok.
+-optional_callbacks([terminate/3]).
+
+-spec execute(Req, Env) -> {ok, Req, Env}
+ when Req::cowboy_req:req(), Env::cowboy_middleware:env().
+execute(Req, Env=#{handler := Handler}) ->
+ Headers = maps:get(headers, Req),
+ otel_propagator_text_map:extract(maps:to_list(Headers)),
+ HandlerB = atom_to_binary(Handler),
+ Method = maps:get(method, Req),
+ Attributes = [
+ {'http.host', maps:get(host, Req)},
+ {'http.host.port', maps:get(port, Req)},
+ {'http.method', Method},
+ {'http.scheme', maps:get(scheme, Req)},
+ {'http.target', maps:get(path, Req)},
+ {'http.user_agent', maps:get(<<"user-agent">>, Headers, <<"">>)}
+ ],
+ SpanName = iolist_to_binary([<<"HTTP ">>, Method, <<" ">>, HandlerB]),
+ logger:debug("SpanName: ~p", [SpanName]),
+ ?with_span(SpanName, #{}, fun(Ctx) -> execute_(Req, Env, Ctx) end).
+
+execute_(Req0, Env=#{handler := Handler, handler_opts := HandlerOpts}, Ctx) ->
+ TraceId = otel_span:hex_trace_id(otel_tracer:current_span_ctx()),
+ Req = Req0#{<<"trace-id">> => TraceId},
+ try Handler:init(Req, HandlerOpts) of
+ {ok, Req2, State} ->
+ Headers0 = maps:get(headers, Req2),
+ Headers = otel_propagator_text_map:inject(Headers0,
+ fun(Headers, Key, Value) ->
+ maps:put(Key, Value, Headers)
+ end),
+ Req3 = maps:put(headers, Headers, Req2),
+ Result = terminate(normal, Req3, State, Handler),
+ {ok, Req3, Env#{result => Result, trace_id => TraceId}};
+ {Mod, Req2, State} ->
+ ?add_event(<<"HTTP_UPGRADE">>, #{module => Mod}),
+ Mod:upgrade(Req2, Env, Handler, State);
+ {Mod, Req2, State, Opts} ->
+ ?add_event(<<"HTTP_UPGRADE">>, #{module => Mod}),
+ Mod:upgrade(Req2, Env, Handler, State, Opts)
+ catch Class:Reason:Stacktrace ->
+ ?set_status(error, iolist_to_binary([atom_to_binary(Class)])),
+ render_error(Class, Reason, Stacktrace, Handler, Req, Env, Ctx)
+ end.
+
+render_error(Class, Reason, Stacktrace, Handler, Req, Env, Ctx) ->
+ ReasonS = lists:flatten(io_lib:format("~p", [Reason])),
+ Assigns = [
+ {dreki_node, node()},
+ {site_title, "Dreki"},
+ {class, atom_to_binary(Class)},
+ {reason, ReasonS},
+ {stacktrace, [lists:flatten(io_lib:format("~p", [Line])) || Line <- Stacktrace]},
+ {trace_id, maps:get(<<"trace-id">>, Req, <<"no-trace">>)}
+ ],
+ logger:error(#{app => dreki_web, handler => Handler, error => {Class, Reason, Stacktrace}}),
+ {ok, Html} = crash_dtl:render(Assigns),
+ cowboy_req:reply(500, #{<<"content-type">> => <<"text/html">>}, Html, Req).
+
+-spec terminate(any(), Req | undefined, any(), module()) -> ok when Req::cowboy_req:req().
+terminate(Reason, Req, State, Handler) ->
+ case erlang:function_exported(Handler, terminate, 3) of
+ true ->
+ Handler:terminate(Reason, Req, State);
+ false ->
+ ok
+ end.
diff --git a/apps/dreki_web/src/dreki_web_ui.erl b/apps/dreki_web/src/ui/dreki_web_ui.erl
index d60cc6e..e394632 100644
--- a/apps/dreki_web/src/dreki_web_ui.erl
+++ b/apps/dreki_web/src/ui/dreki_web_ui.erl
@@ -24,7 +24,8 @@ assigns(Req, Assigns0) ->
{"identity", maps:get(identity, Req)},
{"identity_name", dreki_web:identity_name(Req)},
{"dreki_node", node()},
- {"dreki_world", dreki_world:to_map()}
+ {"dreki_world", dreki_world:to_map()},
+ {"trace_id", maps:get(<<"trace-id">>, Req, <<"no-trace">>)}
| Assigns].
content_types_accepted(Req, State) ->
diff --git a/apps/dreki_web/src/dreki_web_ui_error.erl b/apps/dreki_web/src/ui/dreki_web_ui_error.erl
index ccda150..ccda150 100644
--- a/apps/dreki_web/src/dreki_web_ui_error.erl
+++ b/apps/dreki_web/src/ui/dreki_web_ui_error.erl
diff --git a/apps/dreki_web/src/dreki_web_ui_index.erl b/apps/dreki_web/src/ui/dreki_web_ui_index.erl
index 9f4684e..9f4684e 100644
--- a/apps/dreki_web/src/dreki_web_ui_index.erl
+++ b/apps/dreki_web/src/ui/dreki_web_ui_index.erl
diff --git a/apps/dreki_web/src/dreki_web_ui_json_form.erl b/apps/dreki_web/src/ui/dreki_web_ui_json_form.erl
index 49f69f8..51a9de4 100644
--- a/apps/dreki_web/src/dreki_web_ui_json_form.erl
+++ b/apps/dreki_web/src/ui/dreki_web_ui_json_form.erl
@@ -28,6 +28,8 @@ to_html([{Node, Attrs, Content} | Rest], Acc) ->
AttrsS = attrs_to_html(Attrs),
logger:debug("Node ~p Attrs ~p Content ~p", [Node, Attrs, Content]),
to_html(Rest, [[<<"<">>, BNode, <<" ">>, AttrsS, <<">">>, to_html(Content), <<"</">>, BNode, <<">">>] | Acc]);
+to_html([List | Rest], Acc) when is_list(List) ->
+ to_html(Rest, [to_html(List) | Acc]);
to_html([], Acc) ->
lists:reverse(Acc).
@@ -45,13 +47,27 @@ attrs_to_html([{Attr, Value} | Rest], Acc) ->
attrs_to_html([], Acc) ->
Acc.
+put_new(Key, Value, Map) ->
+ case maps:is_key(Key, Map) of
+ true ->
+ Map;
+ false ->
+ maps:put(Key, Value, Map)
+ end.
+
render(Schema, Opts) ->
+ render(Schema, Opts, {'div', [{class, <<"mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">>}]}).
+
+render(Schema, Opts, {Elem, Attrs}) ->
+ [{Elem, Attrs, render(Schema, Opts, undefined)}];
+render(Schema, Opts0, undefined) ->
+ Opts = put_new(name, <<"form">>, Opts0),
lists:reverse(maps:fold(fun (Key, Value, Acc) ->
- case render_property(Key, Value, Schema, Schema, Opts) of
- {ok, Html} -> [Html | Acc];
- ignore -> Acc
- end
- end, [], maps:get(properties, Schema))).
+ case render_property(Key, Value, Schema, Schema, Opts) of
+ {ok, Html} -> [Html | Acc];
+ ignore -> Acc
+ end
+ end, [], maps:get(properties, Schema))).
render_property(Field, Config = #{enum := Enum}, Parent, Schema, Opts) ->
FormAttrs = maps:get(<<"dreki:form">>, Config, #{}),
@@ -69,6 +85,19 @@ render_property(Field, Config = #{type := Type}, Parent, Schema, Opts) ->
InputOpts = #{required => Required, label => Label, input_type => InputType},
{ok, render_input(Input, Field, Type, InputOpts, Opts)};
+render_property(Key, SubSchema = #{<<"@schema">> := SubSchemaUrn}, Parent, Schema, Opts) ->
+ Name = maps:get(name, Opts),
+ KeyB = atom_to_binary(Key),
+ InnerForm = render(SubSchema, Opts#{name => <<Name/binary, "[", KeyB/binary, "]">>}, undefined),
+ Head = {'div', [{class, <<"sm:col-span-6 mb-3">>}], [
+ {h3, [{class, <<"text-lg leading-6 font-medium text-gray-900 dark:text-gray-200">>}], maps:get(title, SubSchema, SubSchemaUrn)},
+ {p, [{class, <<"mt-1 text-sm text-gray-500 dark:text-gray-400">>}], maps:get(description, SubSchema, <<>>)}
+ ]},
+ {ok, {'div', [{class, <<"sm:col-span-6">>}],
+ [
+ {'div', [{class, <<"mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">>}], [Head, InnerForm]}
+ ]}};
+
render_property(Field, #{'$ref' := Ref}, Parent, Schema = #{'$defs' := Defs}, Opts) ->
case maps:get(Ref, Defs, undefined) of
undefined -> logger:error("didn't get ref ~p", [Ref]);
@@ -88,26 +117,30 @@ base_attributes(Field, IOpts, FOpts) ->
render_input(select, Field, _, IOpts, FOpts) ->
{Name, Attributes} = base_attributes(Field, IOpts, FOpts),
OptionsHtml = lists:map(fun (Opt) -> {option, [], Opt} end, maps:get(values, IOpts, [])),
- {'div', [{class, <<"json-field">>}], [
- {label, [{for, Name}], maps:get(label, IOpts, Field)},
+ {'div', [{class, <<"json-field">>}],
+ [
+ label(Field, Name, maps:get(label, IOpts, Field)),
{select, Attributes, OptionsHtml}
- ]};
+ ]};
render_input(input, Field, Type, IOpts, FOpts) ->
- {Name, Attributes0} = base_attributes(Field, IOpts, FOpts),
- Attributes = [
- {value, maps:get(value, IOpts, undefined)},
- {placeholder, maps:get(placeholder, IOpts, undefined)},
- {readonly, maps:get(readonly, IOpts, false)},
- {autocomplete, maps:get(autocomplete, IOpts, <<"off">>)},
- {type, maps:get(input_type, IOpts)},
- {class, <<"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md">>}
- | Attributes0],
- HtmlNode = {'div', [{class, <<"json-field">>}], [
- {label, [{for, Name}, {class, <<"block text-sm font-medium text-gray-700">>}], maps:get(label, IOpts, Field)},
- {'div', [{class, <<"mt-1">>}], [{input, Attributes}]}
- ]},
- HtmlNode.
+ {Name, Attributes0} = base_attributes(Field, IOpts, FOpts),
+ Attributes = [
+ {value, maps:get(value, IOpts, undefined)},
+ {placeholder, maps:get(placeholder, IOpts, undefined)},
+ {readonly, maps:get(readonly, IOpts, false)},
+ {autocomplete, maps:get(autocomplete, IOpts, <<"off">>)},
+ {type, maps:get(input_type, IOpts)},
+ {class, <<"shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md dark:bg-gray-800">>}
+ | Attributes0],
+ {'div', [{class, <<"json-field sm:col-span-6">>}],
+ [
+ label(Field, Name, maps:get(label, IOpts, Field)),
+ {'div', [{class, <<"mt-1">>}], [{input, Attributes}]}
+ ]}.
+
+label(_Field, Name, Label) ->
+ {label, [{for, Name}, {class, <<"block text-sm font-medium dark:text-gray-400 text-gray-700 dark:text-gray-400">>}], Label}.
as_binary(Atom) when is_atom(Atom) ->
atom_to_binary(Atom);
@@ -119,7 +152,7 @@ input_type(_) -> <<"text">>.
field_name(Field, Opts) ->
FB = as_binary(Field),
BaseName = maps:get(name, Opts, <<"form">>),
- <<"[", BaseName/binary, "]", FB/binary>>.
+ <<BaseName/binary, "[", FB/binary, "]">>.
field_id(Field, Opts) ->
FB = as_binary(Field),
diff --git a/apps/dreki_web/src/dreki_web_ui_node.erl b/apps/dreki_web/src/ui/dreki_web_ui_node.erl
index fdb7c77..fdb7c77 100644
--- a/apps/dreki_web/src/dreki_web_ui_node.erl
+++ b/apps/dreki_web/src/ui/dreki_web_ui_node.erl
diff --git a/apps/dreki_web/src/dreki_web_ui_stores.erl b/apps/dreki_web/src/ui/dreki_web_ui_stores.erl
index d39e571..e39cd48 100644
--- a/apps/dreki_web/src/dreki_web_ui_stores.erl
+++ b/apps/dreki_web/src/ui/dreki_web_ui_stores.erl
@@ -63,13 +63,17 @@ request(<<"GET">>, undefined, #{resource := #{namespace := NS}}, Req) ->
%% List
request(<<"GET">>, undefined, Urn = #{location := Loc, resource := #{directory := #{directory := Dir, namespace := NS}}}, Req) ->
{ok, Result} = dreki_store:list(Urn),
- Results = lists:map(fun(Result) ->
+ Results0 = lists:map(fun(Result) ->
Result#{href => urn_to_path(maps:get('@id', Result))}
end, maps:get(data, Result)),
+ Results = deep_map_to_list(Results0),
Html = dreki_web_ui:render(Req, store_list_dtl, [
- {location, Loc}, {namespace, NS}, {directory, Dir}, {results, Results},
- {new, href(Req, <<"_/new">>)}
- ]),
+ {location, Loc}, {namespace, NS}, {directory, Dir},
+ {results, Results},
+ {actions, [
+ [{title, "New"}, {href, href(Req, <<"_/new">>)}]
+ ]}
+ ]),
{ok, dreki_web_ui:reply_html(Req, 200, Html), undefined};
%% New
@@ -90,8 +94,54 @@ request(<<"GET">>, <<"new">>, #{urn := Urn, location := Loc, resource := #{direc
request(<<"GET">>, undefined, Urn = #{location := Loc, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}, Req) ->
{ok, Result0} = dreki_store:get(Urn),
Result = Result0#{'@href' => urn_to_path(Urn)},
- Html = dreki_web_ui:render(Req, store_show_dtl, [{location, Loc}, {id, Id}, {directory, Dir}, {namespace, NS}, {result, Result}]),
- {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined}.
+ Actions = lists:foldr(fun (#{id := Id, title := Title}, Acc) ->
+ IdB = atom_to_binary(Id),
+ Action = [{id, Id}, {title, Title}, {href, href(Req, <<"_/", IdB/binary>>)}],
+ [Action | Acc]
+ end, [], maps:get('@actions', Result)),
+ Html = dreki_web_ui:render(Req, store_show_dtl, [{location, Loc}, {id, Id}, {directory, Dir}, {namespace, NS},
+ {result, deep_map_to_list(Result)},
+ {actions, [
+ [{title, "Edit (UI)"}, {href, href(Req, <<"_/edit">>)}]
+ | Actions
+ ]}
+ ]),
+ {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined};
+
+%% Show action
+request(<<"GET">>, Action0, Urn = #{location := Loc, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}, Req) ->
+ {ok, Result0} = dreki_store:get(Urn),
+ Result = Result0#{'@href' => urn_to_path(Urn)},
+ Action = binary_to_existing_atom(Action0),
+ Path = href(Req, <<"_/", Action0/binary>>),
+ Act = lists:filter(fun (A) -> maps:get(id, A) =:= Action end, maps:get('@actions', Result)),
+ case Act of
+ [#{title := Title, new := {Mod, Fun, Args}}] ->
+ {ok, Schema} = apply(Mod, Fun, [Result | Args]),
+ Form = dreki_web_ui_json_form:render_html(Schema, #{}),
+ Html = dreki_web_ui:render(Req, store_new_dtl, [
+ {location, Loc}, {id, Id}, {directory, Dir}, {namespace, NS},
+ {method, <<"POST">>},
+ {title, Title},
+ {result, deep_map_to_list(Result)},
+ {target, Path},
+ {form, Form}
+ ]),
+ {dreki_web_ui:reply_html(Req, 200, Html), undefined};
+ _ ->
+ dreki_web_ui_error:init(Req, #{code => 404, status => "Not Found", message => "No such action"})
+ end;
+request(<<"POST">>, Action0, Urn = #{location := Loc, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}, Req) ->
+ {ok, Result0} = dreki_store:get(Urn),
+ Result = Result0#{'@href' => urn_to_path(Urn)},
+ Action = binary_to_existing_atom(Action0),
+ Act = lists:filter(fun (A) -> maps:get(id, A) =:= Action end, maps:get('@actions', Result)),
+ case Act of
+ [#{title := Title, new := {Mod, Fun, Args}}] ->
+ Req;
+ _ ->
+ dreki_web_ui_error:init(Req, #{code => 404, status => "Not Found", message => "No such action"})
+ end.
derpinit(Req, _) ->
Json = #{<<"error">> => false, <<"service">> => <<"dreki">>},
@@ -112,6 +162,19 @@ location_to_path(Location) ->
false -> binary:replace(Location, <<Root/binary, ":">>, <<"/admin/">>)
end.
+deep_map_to_list(List) when is_list(List) ->
+ [deep_map_to_list(Map) || Map <- List];
+deep_map_to_list(Map) when is_map(Map) ->
+ deep_map_to_list(maps:to_list(Map), []).
+
+deep_map_to_list([{Key, Map} | Rest], Acc) when is_map(Map) ->
+ List = deep_map_to_list(Map),
+ deep_map_to_list(Rest, [{Key, List} | Acc]);
+deep_map_to_list([Item | Rest], Acc) ->
+ deep_map_to_list(Rest, [Item | Acc]);
+deep_map_to_list([], Acc) ->
+ Acc.
+
urn_to_path(Urn) when is_binary(Urn) ->
{ok, XUrn} = dreki_urn:expand(Urn),
urn_to_path(XUrn);
diff --git a/apps/dreki_web/src/dreki_web_ui_task.erl b/apps/dreki_web/src/ui/dreki_web_ui_task.erl
index 1ced583..1ced583 100644
--- a/apps/dreki_web/src/dreki_web_ui_task.erl
+++ b/apps/dreki_web/src/ui/dreki_web_ui_task.erl
diff --git a/apps/dreki_web/src/dreki_web_ui_tasks.erl b/apps/dreki_web/src/ui/dreki_web_ui_tasks.erl
index e9748b8..e9748b8 100644
--- a/apps/dreki_web/src/dreki_web_ui_tasks.erl
+++ b/apps/dreki_web/src/ui/dreki_web_ui_tasks.erl
diff --git a/apps/dreki_web/templates/crash.dtl b/apps/dreki_web/templates/crash.dtl
new file mode 100644
index 0000000..b0a291c
--- /dev/null
+++ b/apps/dreki_web/templates/crash.dtl
@@ -0,0 +1,52 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>{% if page_title %}{{ page_title }} - {% endif %}{{site_title}}</title>
+ <link rel="stylesheet" href="/static/app.css">
+ <script src="/static/app.js" defer></script>
+ <link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
+ <link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
+ <link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
+</head>
+<body class="dark:bg-[#2E0E02] dark:text-orange-100 bg-orange-100 text-orange-900">
+<header class="bg-orange-600 dark:bg-red-900">
+<div class="container mx-auto sm:px-6 lg:px-8">
+
+ <nav class="" aria-label="Top">
+ <div class="w-full py-6 flex items-center justify-between border-b border-primary-500 lg:border-none">
+ <div class="flex items-center">
+ <a href="/admin" class="text-primary-100 hover:text-white text-lg">
+ <span class="sr-only">{{site_title}}</span>
+<div class="text-[2rem] inline-block">😈</span>
+<!--<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
+</svg>-->
+ </a>
+ </nav>
+</div>
+</header>
+
+<div class="container mx-auto sm:px-6 lg:px-8 sm:py-4 lg:py-6">
+<div class="content">
+ <h1 class="text-7xl text-red-600 dark:text-red-100 font-bold font-mono">😤<br/>{{ class }}</h1>
+ <h2 class="text-4xl text-orange-800 dark:text-orange-300 font-bold font-mono mt-10 mb-10">{{ reason }}</h2>
+ {% for line in stacktrace %}
+ <h3 class="text-lg text-stone-400 dark:text-stone:500 -mb-2 font-mono">{{ line }}</h3>
+ {% endfor %}
+</div>
+</div>
+
+<footer class="">
+ <div class="container mx-auto sm:px-6 lg:px-8 sm:py-4 lg:py-6">
+ <p class="mt-8 text-xs text-gray-400 font-mono">
+ node: {{ dreki_node }}
+ </p>
+ <p class="mt-2 text-xs text-gray-400 font-mono">
+ trace: <a href="https://grafana.adm.random.sh/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22Tempo%20(Stairway)%22,%7B%22refId%22:%22A%22,%22queryType%22:%22traceId%22,%22query%22:%22{{trace_id}}%22%7D%5D" target="_blank">{{ trace_id }}</a>
+ </p>
+ </footer>
+</div>
+</body>
+</html>
diff --git a/apps/dreki_web/templates/layout.dtl b/apps/dreki_web/templates/layout.dtl
index b27fe30..a326305 100644
--- a/apps/dreki_web/templates/layout.dtl
+++ b/apps/dreki_web/templates/layout.dtl
@@ -73,10 +73,11 @@
<footer class="">
<div class="container mx-auto sm:px-6 lg:px-8 sm:py-4 lg:py-6">
<p class="mt-8 text-center text-sm text-gray-400">
- dreki $nodename
+ dreki {{dreki_node}}
</p>
- <p class="mt-2 text-center text-xs text-gray-300">
- identity:{{identity_id}}
+ <p class="mt-2 text-center text-xs text-gray-400">
+ identity: {{identity_id}}<br />
+ trace: <a href="https://grafana.adm.random.sh/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22Tempo%20(Stairway)%22,%7B%22refId%22:%22A%22,%22queryType%22:%22traceId%22,%22query%22:%22{{trace_id}}%22%7D%5D" target="_blank">{{trace_id}}>
</p>
</footer>
</div>
diff --git a/apps/dreki_web/templates/store_list.dtl b/apps/dreki_web/templates/store_list.dtl
index 65dc5af..81209a6 100644
--- a/apps/dreki_web/templates/store_list.dtl
+++ b/apps/dreki_web/templates/store_list.dtl
@@ -1,9 +1,11 @@
<h1 class="text-lg">{{ location }} :: {{ namespace }}:{{ directory }}</h1>
<ul>
-{% for res in result %}
-<li><a href="{{ res.href }}">{{ res.id }}</a></li>
+{% for res in results %}
+<li><a href="{{ res.href }}" class="text-primary-700 dark:text-primary-100">{{ res.id }}</a> {{ res.params.title }}</li>
{% endfor %}
</ul>
-<a href="{{ new }}">Create</a>
+{% for action in actions %}
+<a href="{{ action.href }}">{{ action.title }}</a>
+{% endfor %}
diff --git a/apps/dreki_web/templates/store_new.dtl b/apps/dreki_web/templates/store_new.dtl
index 5a85854..0ae8d59 100644
--- a/apps/dreki_web/templates/store_new.dtl
+++ b/apps/dreki_web/templates/store_new.dtl
@@ -2,14 +2,18 @@
<div class="text-base">
{{ location }} / {{ namespace }} / {{ directory }}
</div>
- New
+ {% if result %}
+ {{ result.id }}: {{ title }}
+ {% else %}
+ New
+ {% endif %}
</h1>
-<form action="{{ target }}" method="{{ method }}">
+<form action="{{ target }}" method="{{ method }}" class="space-y-8 divide-y divide-gray-200">
{{ form | safe }}
<div class="pt-5">
- <div class="flex justify-end">
+ <div class="flex justify-start">
<button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Save</button>
</div>
</div>
diff --git a/apps/dreki_web/templates/store_show.dtl b/apps/dreki_web/templates/store_show.dtl
index e944bee..3b139bb 100644
--- a/apps/dreki_web/templates/store_show.dtl
+++ b/apps/dreki_web/templates/store_show.dtl
@@ -5,4 +5,12 @@
{{ result.id }}
</h1>
+<ul>
+{% for res in result %}
+<li>{{res}}</li>
+{% endfor %}
+</ul>
+{% for action in actions %}
+<a href="{{ action.href }}">{{ action.title }}</a>
+{% endfor %}