aboutsummaryrefslogtreecommitdiff
path: root/apps/dreki_web/src
diff options
context:
space:
mode:
authorJordan Bracco <href@random.sh>2022-04-07 23:54:23 +0000
committerJordan Bracco <href@random.sh>2022-04-07 23:54:23 +0000
commit6d7887c51ba7664688bc568aa0c8538da0e64e7b (patch)
tree452ded9651d17bdd3fe941f15dfec2325f008db9 /apps/dreki_web/src
guess it was time for an initial commit
Diffstat (limited to 'apps/dreki_web/src')
-rw-r--r--apps/dreki_web/src/dreki_web.app.src20
-rw-r--r--apps/dreki_web/src/dreki_web.erl71
-rw-r--r--apps/dreki_web/src/dreki_web_admin_tasks.erl30
-rw-r--r--apps/dreki_web/src/dreki_web_admin_world.erl28
-rw-r--r--apps/dreki_web/src/dreki_web_app.erl68
-rw-r--r--apps/dreki_web/src/dreki_web_auth.erl45
-rw-r--r--apps/dreki_web/src/dreki_web_error.erl20
-rw-r--r--apps/dreki_web/src/dreki_web_index.erl9
-rw-r--r--apps/dreki_web/src/dreki_web_sup.erl35
-rw-r--r--apps/dreki_web/src/dreki_web_task.erl21
-rw-r--r--apps/dreki_web/src/dreki_web_ui.erl43
-rw-r--r--apps/dreki_web/src/dreki_web_ui_error.erl14
-rw-r--r--apps/dreki_web/src/dreki_web_ui_index.erl31
-rw-r--r--apps/dreki_web/src/dreki_web_ui_json_form.erl127
-rw-r--r--apps/dreki_web/src/dreki_web_ui_node.erl23
-rw-r--r--apps/dreki_web/src/dreki_web_ui_stores.erl126
-rw-r--r--apps/dreki_web/src/dreki_web_ui_task.erl20
-rw-r--r--apps/dreki_web/src/dreki_web_ui_tasks.erl14
18 files changed, 745 insertions, 0 deletions
diff --git a/apps/dreki_web/src/dreki_web.app.src b/apps/dreki_web/src/dreki_web.app.src
new file mode 100644
index 0000000..cf731fc
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web.app.src
@@ -0,0 +1,20 @@
+{application, dreki_web,
+ [{description, "An OTP application"},
+ {vsn, "0.1.0"},
+ {registered, []},
+ {mod, {dreki_web_app, []}},
+ {applications,
+ [kernel,
+ stdlib,
+ erlydtl,
+ cowboy,
+ trails,
+ cowboy_telemetry,
+ opentelemetry_cowboy
+ ]},
+ {env,[]},
+ {modules, []},
+
+ {licenses, ["Apache 2.0"]},
+ {links, []}
+ ]}.
diff --git a/apps/dreki_web/src/dreki_web.erl b/apps/dreki_web/src/dreki_web.erl
new file mode 100644
index 0000000..f9e01e6
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web.erl
@@ -0,0 +1,71 @@
+-module(dreki_web).
+
+-export([reply/5, reply_json/3, reply_json/4, temporary_redirect/2, req_param/2, identity_name/1]).
+-export([content_types_accepted/2, content_types_provided/2]).
+-export([detect_web_mimetype/1]).
+
+identity_name(#{identity := Identity}) ->
+ identity_name(Identity);
+identity_name(#{<<"identity">> := Identity}) ->
+ identity_name(Identity);
+identity_name(#{<<"traits">> := #{<<"name">> := #{<<"first">> := F, <<"last">> := L}}}) when is_binary(F), is_binary(L) ->
+ [F, " ", L];
+identity_name(#{<<"traits">> := #{<<"name">> := N}}) when is_binary(N) ->
+ N;
+identity_name(#{<<"traits">> := #{<<"username">> := U}}) when is_binary(U) ->
+ U;
+identity_name(#{<<"traits">> := #{<<"email">> := E}}) when is_binary(E) ->
+ E;
+identity_name(#{<<"id">> := Id}) ->
+ Id.
+
+reply(Req, Code, Json, Headers, json) ->
+ reply_json(Req, Code, Json, Headers);
+reply(Req, Code, Json, Headers, yaml) ->
+ reply_yaml(Req, Code, Json, Headers).
+
+reply_json(Req, Code, Json) ->
+ reply_json(Req, Code, Json, #{}).
+reply_json(Req, Code, Json, Headers0) when is_binary(Json) ->
+ Headers = maps:put(<<"content-type">>, <<"application/json">>, Headers0),
+ cowboy_req:reply(Code, Headers, Json, Req);
+reply_json(Req, Code, Json, Headers0) ->
+ reply_json(Req, Code, jsone:encode(Json), Headers0).
+
+reply_yaml(Req, Code, Yaml) ->
+ reply_yaml(Req, Code, Yaml, #{}).
+reply_yaml(Req, Code, Yaml, Headers0) when is_binary(Yaml) ->
+ Headers = maps:put(<<"content-type">>, <<"application/yaml">>, Headers0),
+ cowboy_req:reply(Code, Headers, Yaml, Req);
+reply_yaml(Req, Code, Yaml, Headers0) ->
+ reply_yaml(Req, Code, fast_yaml:encode(Yaml), Headers0).
+
+temporary_redirect(Req0, Url) ->
+ cowboy_req:reply(307, #{<<"location">> => Url}, Req0).
+
+req_param(Req, Param) ->
+ Qs = cowboy_req:parse_qs(Req),
+ case lists:keyfind(Param, 1, Qs) of
+ {_, Value} ->
+ {ok, Value};
+ _ ->
+ {error, {missing_param, Param}}
+ end.
+
+content_types_accepted(Req, State) ->
+ {[
+ {{ <<"application">>, <<"json">>, '*'}, from_json},
+ {{ <<"application">>, <<"yaml">>, '*'}, from_yaml},
+ {{ <<"multipart">>, <<"form-data">>, '*'}, from_form}
+ ], Req, State}.
+
+content_types_provided(Req, State) ->
+ {[{{ <<"application">>, <<"json">>, '*'}, to_json}], Req, State}.
+
+detect_web_mimetype(Path) when is_binary(Path) ->
+ detect_web_mimetype(lists:reverse(binary_to_list(Path)), Path).
+
+detect_web_mimetype([$m, $s, $a, $w, $. | _], _) ->
+ {<<"application">>, <<"wasm">>, []};
+detect_web_mimetype(P, Path) ->
+ cow_mimetypes:web(Path).
diff --git a/apps/dreki_web/src/dreki_web_admin_tasks.erl b/apps/dreki_web/src/dreki_web_admin_tasks.erl
new file mode 100644
index 0000000..adc2689
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_admin_tasks.erl
@@ -0,0 +1,30 @@
+-module(dreki_web_admin_tasks).
+-behaviour(cowboy_rest).
+-export([init/2]).
+-export([allowed_methods/2]).
+-export([content_types_accepted/2]).
+-export([content_types_provided/2]).
+
+init(Req, State) ->
+ {cowboy_rest, Req, State}.
+
+allowed_methods(Req, State) ->
+ {[<<"GET">>, <<"HEAD">>, <<"OPTIONS">>, <<"POST">>], Req, State}.
+
+content_types_accepted(Req, State) ->
+ dreki_web:content_types_accepted(Req, State).
+
+content_types_provided(Req, State) ->
+ dreki_web:content_types_provided(Req, State).
+
+list(Req, State, Format) ->
+ {ok, Db} = dreki_tasks:open(),
+ {ok, Tasks} = dreki_tasks:all(Db),
+ MTasks = lists:foldl(fun (T, Acc) -> [dreki_task:to_map(T) | Acc] end, [], Tasks),
+ dreki_web:reply(Req, 200, #{error => false, tasks => MTasks}, [], Format).
+
+to_json(Req, State) ->
+ list(Req, State, json).
+
+to_yaml(Req, State) ->
+ list(Req, State, yaml).
diff --git a/apps/dreki_web/src/dreki_web_admin_world.erl b/apps/dreki_web/src/dreki_web_admin_world.erl
new file mode 100644
index 0000000..1ceaaee
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_admin_world.erl
@@ -0,0 +1,28 @@
+-module(dreki_web_admin_world).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+init(Req, index) ->
+ Json = #{<<"data">> => #{<<"service">> => <<"dreki">>}},
+ {ok, dreki_web:reply_json(Req, 200, Json), undefined};
+
+init(Req, graph) ->
+ World = dreki_world_dns:as_map(),
+ Json = #{<<"data">> => World},
+ {ok, dreki_web:reply_json(Req, 200, Json), undefined};
+
+init(Req, {graph, dot}) ->
+ World = dreki_world_dns:as_map(),
+ Vertices = [dot_format_vertex(N) || N <- maps:get(vertices, World)],
+ {ok, Dot} = world_graph_dot_dtl:render([{vertices, Vertices}, {edges, maps:get(edges, World)}]),
+ cowboy_req:reply(200, #{<<"content-type">> => <<"text/vnd.graphviz">>}, Dot, Req);
+
+init(Req, _) ->
+ dreki_web_error:init(Req, #{code => 404, status => "Not found"}).
+
+dot_format_vertex(V = #{type := node}) ->
+ V#{shape => <<"box">>, color => <<"#c2410c">>, class => <<"dreki-world-graph-node">>};
+dot_format_vertex(V = #{type := root}) ->
+ V#{shape => <<"polygon">>, color => <<"#a16207">>, class => <<"dreki-world-graph-root">>};
+dot_format_vertex(V = #{type := region}) ->
+ V#{shape => <<"egg">>, color => <<"#4d7c0f">>, class => <<"dreki-world-graph-region">>}.
diff --git a/apps/dreki_web/src/dreki_web_app.erl b/apps/dreki_web/src/dreki_web_app.erl
new file mode 100644
index 0000000..5b6454e
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_app.erl
@@ -0,0 +1,68 @@
+%%%-------------------------------------------------------------------
+%% @doc dreki_web public API
+%% @end
+%%%-------------------------------------------------------------------
+
+-module(dreki_web_app).
+
+-behaviour(application).
+
+-export([start/2, stop/1]).
+
+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],
+ env => #{
+ dispatch => routes()
+ }
+ },
+ {ok, _} = cowboy:start_clear(dreki_web_listener, Transport, CowboyEnv),
+ opentelemetry_cowboy:setup(),
+ IP = proplists:get_value(ip, Transport),
+ Port = proplists:get_value(port, Transport),
+ logger:notice("dreki_web listening on ~p:~p", [IP, Port]),
+ dreki_web_sup:start_link().
+
+stop(_State) ->
+ ok.
+
+%% internal functions
+
+routes() ->
+ Trails = [
+ {"/", dreki_web_index, undefined},
+ {"/static/[...]", cowboy_static,
+ {priv_dir, dreki_web, "static", [{mimetypes, dreki_web, detect_web_mimetype}]}},
+
+ %% API
+ {"/api/tasks/:id", dreki_web_task, undefined},
+
+ %% Admin API
+ {"/api/admin/world", dreki_web_admin_world, index},
+ {"/api/admin/world/graph", dreki_web_admin_world, graph},
+ {"/api/admin/world/graph.dot", dreki_web_admin_world, {graph, dot}},
+ {"/api/admin/tasks", dreki_web_admin_tasks, undefined},
+ {"/api/admin/tasks/:id", dreki_web_admin_task, undefined},
+
+ %% Admin UI
+ {"/admin", dreki_web_ui_index, undefined},
+ {"/admin/nodes/:id", dreki_web_ui_node, undefined},
+ {"/admin/tasks", dreki_web_ui_tasks, undefined},
+ {"/admin/tasks/:id", dreki_web_ui_task, undefined},
+
+ %%{"/admin/stores", dreki_web_ui_stores, undefined},
+ %%{"/admin/:location/stores", dreki_web_ui_stores, undefined},
+ {"/admin/:location/:namespace", dreki_web_ui_stores, undefined},
+ {"/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/[...]", dreki_web_ui_error, #{code => 404, status => <<"Not found">>}},
+
+ %% 404 Catch all
+ {'_', dreki_web_error, #{code => 404, status => <<"Not found">>}}
+ ],
+ trails:single_host_compile(Trails).
diff --git a/apps/dreki_web/src/dreki_web_auth.erl b/apps/dreki_web/src/dreki_web_auth.erl
new file mode 100644
index 0000000..48b0609
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_auth.erl
@@ -0,0 +1,45 @@
+-module(dreki_web_auth).
+-behaviour(cowboy_middleware).
+-export([execute/2]).
+
+execute(Req0, Env) ->
+ Header = cowboy_req:header(<<"authorization">>, Req0),
+ Cookie = cowboy_req:header(<<"cookie">>, Req0),
+ case {Header, Cookie} of
+ {<<"Basic ", Basic/binary>>, _} -> basic_oauth2_client_credentials(Basic, Req0, Env);
+ {_, Cookie} when is_binary(Cookie) -> cookie(Cookie, Req0, Env);
+ _ -> error(Req0, 401, <<"Unauthorized">>, <<"Unauthorized">>, Env)
+ end.
+
+basic_oauth2_client_credentials(Base64, Req0, Env) ->
+ Binary = base64:decode(Base64),
+ [User, Pass] = binary:split(Binary, <<":">>),
+ oauth2_client_credentials(User, Pass, Req0, Env).
+
+oauth2_client_credentials(User, Pass, Req0, Env) ->
+ logger:debug("Trying with oauth2 ~p ~p", [User, Pass]),
+ Url = [ory_hydra:url(), "/oauth2/token"],
+ case oauth2c:retrieve_access_token(<<"client_credentials">>, Url, User, Pass) of
+ {ok, Headers, Client} ->
+ logger:debug("Headers ~p Client ~p", [Headers, Client]),
+ identity(Req0, Headers, Env)
+ end.
+
+cookie(Cookie, Req0, Env) ->
+ case ory_kratos:whoami(Cookie) of
+ {ok, Session = #{<<"active">> := true, <<"identity">> := Identity}} ->
+ identity(Req0, Session, Env);
+ {error, #{<<"code">> := Code, <<"status">> := Status, <<"message">> := Msg}} ->
+ error(Req0, Code, Status, Msg, Env)
+ end.
+
+identity(Req0, Identity, Env0) ->
+ IdentityId = maps:get(<<"id">>, Identity),
+ Env1 = maps:put(<<"identity">>, Identity, Env0),
+ Env2 = maps:put(<<"identity_id">>, IdentityId, Env1),
+ {ok, Req0#{identity => Identity, identity_id => IdentityId}, Env2}.
+
+error(Req0, Code, Status, Msg, _Env) ->
+ Json = #{<<"error">> => #{<<"code">> => Code, <<"status">> => Status, <<"message">> => Msg}},
+ Req = dreki_web:reply_json(Req0, Code, Json),
+ {stop, Req}.
diff --git a/apps/dreki_web/src/dreki_web_error.erl b/apps/dreki_web/src/dreki_web_error.erl
new file mode 100644
index 0000000..e300582
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_error.erl
@@ -0,0 +1,20 @@
+-module(dreki_web_error).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+init(Req, not_found) ->
+ reply(Req, 404, <<"Not Found">>, undefined);
+init(Req, State = #{code := Code, status := Status}) ->
+ reply(Req, Code, Status, maps:get(message, State, undefined));
+init(Req, oauth2) ->
+ {ok, ErrorDescription} = dreki_web:req_param(Req, <<"error_description">>),
+ reply(Req, 500, <<"Error">>, ErrorDescription);
+init(Req = #{method := <<"GET">>}, _) ->
+ {ok, ErrorId} = dreki_web:req_param(Req, <<"id">>),
+ {ok, #{<<"error">> := #{<<"status">> := Status, <<"code">> := Code, <<"message">> := Msg}}} = ory_kratos:error(ErrorId),
+ reply(Req, Code, Status, Msg).
+
+reply(Req0, Code, Status, Msg) ->
+ Json = #{<<"error">> => #{<<"code">> => Code, <<"status">> => Status, <<"message">> => Msg}},
+ Req = dreki_web:reply_json(Req0, Code, Json),
+ {ok, Req, undefined}.
diff --git a/apps/dreki_web/src/dreki_web_index.erl b/apps/dreki_web/src/dreki_web_index.erl
new file mode 100644
index 0000000..2ed8a38
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_index.erl
@@ -0,0 +1,9 @@
+-module(dreki_web_index).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+init(Req = #{method := <<"GET">>}, _) ->
+ Json = #{<<"error">> => false, <<"service">> => <<"dreki">>},
+ {ok, dreki_web:reply_json(Req, 200, Json), undefined};
+init(Req, _) ->
+ dreki_web_error:init(Req, #{code => 400, status => "Bad request"}).
diff --git a/apps/dreki_web/src/dreki_web_sup.erl b/apps/dreki_web/src/dreki_web_sup.erl
new file mode 100644
index 0000000..8414aa7
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_sup.erl
@@ -0,0 +1,35 @@
+%%%-------------------------------------------------------------------
+%% @doc dreki_web top level supervisor.
+%% @end
+%%%-------------------------------------------------------------------
+
+-module(dreki_web_sup).
+
+-behaviour(supervisor).
+
+-export([start_link/0]).
+
+-export([init/1]).
+
+-define(SERVER, ?MODULE).
+
+start_link() ->
+ supervisor:start_link({local, ?SERVER}, ?MODULE, []).
+
+%% sup_flags() = #{strategy => strategy(), % optional
+%% intensity => non_neg_integer(), % optional
+%% period => pos_integer()} % optional
+%% child_spec() = #{id => child_id(), % mandatory
+%% start => mfargs(), % mandatory
+%% restart => restart(), % optional
+%% shutdown => shutdown(), % optional
+%% type => worker(), % optional
+%% modules => modules()} % optional
+init([]) ->
+ SupFlags = #{strategy => one_for_all,
+ intensity => 0,
+ period => 1},
+ ChildSpecs = [],
+ {ok, {SupFlags, ChildSpecs}}.
+
+%% internal functions
diff --git a/apps/dreki_web/src/dreki_web_task.erl b/apps/dreki_web/src/dreki_web_task.erl
new file mode 100644
index 0000000..fdcf9cd
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_task.erl
@@ -0,0 +1,21 @@
+-module(dreki_web_task).
+-behaviour(cowboy_handler).
+-behaviour(cowboy_rest).
+-export([init/2]).
+-export([allowed_methods/2]).
+-export([content_types_accepted/2]).
+-export([content_types_provided/2]).
+
+init(Req, State) ->
+ {cowboy_rest, Req, State}.
+
+allowed_methods(Req, State) ->
+ {[<<"GET">>, <<"HEAD">>, <<"OPTIONS">>, <<"POST">>], Req, State}.
+
+content_types_accepted(Req, State) ->
+ dreki_web:content_types_accepted(Req, State).
+
+content_types_provided(Req, State) ->
+ dreki_web:content_types_provided(Req, State).
+
+
diff --git a/apps/dreki_web/src/dreki_web_ui.erl b/apps/dreki_web/src/dreki_web_ui.erl
new file mode 100644
index 0000000..d60cc6e
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_ui.erl
@@ -0,0 +1,43 @@
+-module(dreki_web_ui).
+-export([render/3, reply_html/3, reply_html/4]).
+
+render(Req, InnerModule, Assigns0) ->
+ Assigns = assigns(Req, Assigns0),
+ {ok, InnerHtml} = InnerModule:render(Assigns),
+ render_layout(Req, InnerHtml, Assigns).
+
+reply_html(Req, Code, Html) ->
+ reply_html(Req, Code, Html, #{}).
+
+reply_html(Req, Code, Html, Headers0) ->
+ Headers = maps:put(<<"content-type">>, <<"text/html">>, Headers0),
+ cowboy_req:reply(Code, Headers, Html, Req).
+
+render_layout(Req, InnerHtml, Assigns) ->
+ {ok, Html} = layout_dtl:render([{"inner", InnerHtml} | Assigns]),
+ Html.
+
+assigns(Req, Assigns0) ->
+ Assigns = clean_assigns(Assigns0),
+ [{"site_title", "Dreki"},
+ {"identity_id", maps:get(identity_id, Req)},
+ {"identity", maps:get(identity, Req)},
+ {"identity_name", dreki_web:identity_name(Req)},
+ {"dreki_node", node()},
+ {"dreki_world", dreki_world:to_map()}
+ | Assigns].
+
+content_types_accepted(Req, State) ->
+ {[
+ {{ <<"multipart">>, <<"form-data">>, '*'}, from_form}
+ ], Req, State}.
+
+content_types_provided(Req, State) ->
+ {[{{ <<"text">>, <<"html">>, '*'}, to_html}], Req, State}.
+
+clean_assigns(Assigns) when is_list(Assigns) ->
+ Assigns;
+clean_assigns(Map) when is_map(Map) ->
+ maps:fold(fun (Key, Value, Acc) -> [{Key, clean_assigns(Value)} | Acc] end, [], Map);
+clean_assigns(Other) ->
+ Other.
diff --git a/apps/dreki_web/src/dreki_web_ui_error.erl b/apps/dreki_web/src/dreki_web_ui_error.erl
new file mode 100644
index 0000000..ccda150
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_ui_error.erl
@@ -0,0 +1,14 @@
+-module(dreki_web_ui_error).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+init(Req, not_found) ->
+ reply(Req, 404, <<"Not Found">>, undefined);
+init(Req, State = #{code := Code, status := Status}) ->
+ reply(Req, Code, Status, maps:get(message, State, undefined)).
+
+reply(Req0, Code, Status, Msg) ->
+ Assigns = [{"message", Msg}, {"status", Status}],
+ Html = dreki_web_ui:render(Req0, error_dtl, Assigns),
+ Req = dreki_web_ui:reply_html(Req0, Code, Html),
+ {ok, Req, undefined}.
diff --git a/apps/dreki_web/src/dreki_web_ui_index.erl b/apps/dreki_web/src/dreki_web_ui_index.erl
new file mode 100644
index 0000000..9f4684e
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_ui_index.erl
@@ -0,0 +1,31 @@
+-module(dreki_web_ui_index).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+init(Req = #{method := <<"GET">>}, State) ->
+ PrettyWorld = jsone:encode(dreki_world:to_map(), [canonical_form, {space, 1}, {indent, 4}]),
+ LocalTasksStores = dreki_tasks:local_stores(),
+ {ok, Peers} = partisan_peer_service:members(),
+ NavTree = nav_tree(dreki_world_dns:as_map()),
+ Html = dreki_web_ui:render(Req, index_dtl, [
+ {"page_title", "Admin UI"},
+ {"dreki_world_pretty_json", PrettyWorld},
+ {"dreki_local_tasks_stores", LocalTasksStores},
+ {"dreki_peers", Peers},
+ {"nav_tree", NavTree}
+ ]),
+ {ok, dreki_web_ui:reply_html(Req, 200, Html), State};
+init(Req, _) ->
+ dreki_web_ui_error:init(Req, #{code => 400, status => "Bad request"}).
+
+nav_tree(#{vertices := Vertices}) ->
+ nav_tree(Vertices, []).
+
+nav_tree([Region = #{type := region, name := Name, data := #{display_name := Display}} | Rest], Acc) ->
+ nav_tree(Rest, [Region#{href => <<"/admin/regions/", Name/binary>>, title => Display} | Acc]);
+nav_tree([Node = #{type := node, name := Name, data := #{display_name := Display}} | Rest], Acc) ->
+ nav_tree(Rest, [Node#{href => <<"/admin/nodes/", Name/binary>>, title => Display} | Acc]);
+nav_tree([#{type := Root} | Rest], Acc) ->
+ nav_tree(Rest, Acc);
+nav_tree([], Acc) ->
+ Acc.
diff --git a/apps/dreki_web/src/dreki_web_ui_json_form.erl b/apps/dreki_web/src/dreki_web_ui_json_form.erl
new file mode 100644
index 0000000..49f69f8
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_ui_json_form.erl
@@ -0,0 +1,127 @@
+-module(dreki_web_ui_json_form).
+-export([render_html/2]).
+-export([render/2]).
+-export([to_html/1]).
+
+-type dreki_form() :: #{
+ input => binary(),
+ label => binary()
+}.
+
+render_html(Schema, Opts) ->
+ Abstract = render(Schema, Opts),
+ to_html(Abstract).
+
+to_html(Atom) when is_atom(Atom) ->
+ as_binary(Atom);
+to_html(Binary) when is_binary(Binary) ->
+ Binary;
+to_html(Abstract) ->
+ to_html(Abstract, []).
+
+to_html([{Node, Attrs} | Rest], Acc) ->
+ BNode = as_binary(Node),
+ AttrsS = attrs_to_html(Attrs),
+ to_html(Rest, [[<<"<">>, BNode, <<" ">>, AttrsS, <<">">>] | Acc]);
+to_html([{Node, Attrs, Content} | Rest], Acc) ->
+ BNode = as_binary(Node),
+ 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([], Acc) ->
+ lists:reverse(Acc).
+
+attrs_to_html(Attrs) -> attrs_to_html(Attrs, []).
+attrs_to_html([{Attr, undefined} | Rest], Acc) ->
+ attrs_to_html(Rest, Acc);
+attrs_to_html([{Attr, false} | Rest], Acc) ->
+ attrs_to_html(Rest, Acc);
+attrs_to_html([{Attr, true} | Rest], Acc) ->
+ attrs_to_html(Rest, [[as_binary(Attr), <<" ">>] | Acc]);
+attrs_to_html([{Attr, Num} | Rest], Acc) when is_integer(Num) ->
+ attrs_to_html(Rest, [[as_binary(Attr), <<"=">>, Num, <<" ">>] | Acc]);
+attrs_to_html([{Attr, Value} | Rest], Acc) ->
+ attrs_to_html(Rest, [[as_binary(Attr), <<"=">>, <<"\"">>, as_binary(Value), <<"\"">>, <<" ">>] | Acc]);
+attrs_to_html([], Acc) ->
+ Acc.
+
+render(Schema, Opts) ->
+ 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))).
+
+render_property(Field, Config = #{enum := Enum}, Parent, Schema, Opts) ->
+ FormAttrs = maps:get(<<"dreki:form">>, Config, #{}),
+ Required = lists:member(Field, maps:get(required, Parent, [])),
+ Label = maps:get(label, FormAttrs, maps:get(title, Config, Field)),
+ InputOpts = #{required => Required, label => Label, values => Enum},
+ {ok, render_input(select, Field, undefined, InputOpts, Opts)};
+
+render_property(Field, Config = #{type := Type}, Parent, Schema, Opts) ->
+ FormAttrs = maps:get(<<"dreki:form">>, Config, #{}),
+ Input = maps:get(input, FormAttrs, input),
+ InputType = maps:get(input_type, FormAttrs, input_type(Type)),
+ Required = lists:member(Field, maps:get(required, Parent, [])),
+ Label = maps:get(label, FormAttrs, maps:get(title, Config, Field)),
+ InputOpts = #{required => Required, label => Label, input_type => InputType},
+ {ok, render_input(Input, Field, Type, InputOpts, Opts)};
+
+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]);
+ ExpandedRef ->
+ logger:debug("Skipping ref for now: ~p ~p", [Ref, ExpandedRef])
+ end,
+ ignore.
+
+base_attributes(Field, IOpts, FOpts) ->
+ Name = field_name(Field, FOpts),
+ {Name, [
+ {id, field_id(Field, FOpts)},
+ {name, Name},
+ {required, maps:get(required, IOpts, false)}
+ ]}.
+
+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)},
+ {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.
+
+as_binary(Atom) when is_atom(Atom) ->
+ atom_to_binary(Atom);
+as_binary(B) when is_binary(B) ->
+ B.
+
+input_type(_) -> <<"text">>.
+
+field_name(Field, Opts) ->
+ FB = as_binary(Field),
+ BaseName = maps:get(name, Opts, <<"form">>),
+ <<"[", BaseName/binary, "]", FB/binary>>.
+
+field_id(Field, Opts) ->
+ FB = as_binary(Field),
+ BaseId = maps:get(id, Opts, <<"form">>),
+ <<BaseId/binary, "-", FB/binary>>.
diff --git a/apps/dreki_web/src/dreki_web_ui_node.erl b/apps/dreki_web/src/dreki_web_ui_node.erl
new file mode 100644
index 0000000..fdb7c77
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_ui_node.erl
@@ -0,0 +1,23 @@
+-module(dreki_web_ui_node).
+-behaviour(cowboy_handler).
+-behaviour(cowboy_rest).
+-export([init/2]).
+-export([allowed_methods/2]).
+-export([content_types_accepted/2]).
+-export([content_types_provided/2]).
+
+init(Req, State) ->
+ {cowboy_rest, Req, State}.
+
+allowed_methods(Req, State) ->
+ {[<<"GET">>, <<"HEAD">>, <<"OPTIONS">>, <<"POST">>], Req, State}.
+
+content_types_accepted(Req, State) ->
+ dreki_web:content_types_accepted(Req, State).
+
+content_types_provided(Req, State) ->
+ dreki_web:content_types_provided(Req, State).
+
+to_html(Req, State) ->
+ Html = dreki_web_ui:render(Req, node, []),
+ {Html, Req, State}.
diff --git a/apps/dreki_web/src/dreki_web_ui_stores.erl b/apps/dreki_web/src/dreki_web_ui_stores.erl
new file mode 100644
index 0000000..d39e571
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_ui_stores.erl
@@ -0,0 +1,126 @@
+-module(dreki_web_ui_stores).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+
+init(Req, action) ->
+ with_location(Req#{action => cowboy_req:binding(action, Req)}, cowboy_req:binding(location, Req, undefined));
+init(Req, Action) ->
+ with_location(Req#{action => Action}, cowboy_req:binding(location, Req, undefined)).
+
+with_location(Req, undefined) ->
+ with_namespace(Req#{urn => dreki_world:root_path()});
+with_location(Req, <<"-">>) ->
+ with_namespace(Req#{urn => dreki_world:root_path()});
+with_location(Req0, Location) ->
+ Root = dreki_world:root_path(),
+ {ok, XUrn} = dreki_urn:expand(<<Root/binary, ":", Location/binary>>),
+ Req = Req0#{urn => maps:get(urn, XUrn)},
+ with_namespace(Req).
+
+with_namespace(Req) ->
+ with_namespace(Req, cowboy_req:binding(namespace, Req)).
+with_namespace(Req, undefined) ->
+ request(Req);
+with_namespace(Req = #{urn := Urn0}, Namespace) ->
+ Urn = <<Urn0/binary, "::", Namespace/binary>>,
+ with_directory(Req#{urn => Urn}).
+
+with_directory(Req) ->
+ with_directory(Req, cowboy_req:binding(directory, Req)).
+with_directory(Req, undefined) ->
+ request(Req);
+with_directory(Req = #{urn := Urn0}, Directory) ->
+ Urn = <<Urn0/binary, ":", Directory/binary>>,
+ with_id(Req#{urn => Urn}).
+
+with_id(Req) ->
+ with_id(Req, cowboy_req:binding(id, Req)).
+with_id(Req, undefined) ->
+ request(Req);
+with_id(Req = #{urn := Urn0}, Id) ->
+ Urn = <<Urn0/binary, ":", Id/binary>>,
+ request(Req#{urn => Urn}).
+
+request(Req = #{action := Action, method := Method, urn := Urn}) ->
+ logger:debug("Faked Urn ~p", [Urn]),
+ case dreki_urn:expand(Urn) of
+ {ok, XUrn} -> request(Method, Action, XUrn, Req);
+ {error, EMap=#{}} -> dreki_web_error:init(Req, EMap);
+ {error, _Error} -> dreki_web_error:init(Req, #{code => 404, status => "Not Found"})
+ end.
+
+%% Stores list
+request(<<"GET">>, undefined, #{resource := #{namespace := NS}}, Req) ->
+ {ok, Stores0} = dreki_store:stores(NS),
+ Stores = lists:map(fun (Store) ->
+ S = dreki_store:store_as_map(Store),
+ S#{href => urn_to_path(maps:get(urn, S))}
+ end, Stores0),
+ Html = dreki_web_ui:render(Req, namespace_dtl, [{namespace, NS}, {stores, Stores}]),
+ {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined};
+
+%% 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) ->
+ Result#{href => urn_to_path(maps:get('@id', Result))}
+ end, maps:get(data, Result)),
+ Html = dreki_web_ui:render(Req, store_list_dtl, [
+ {location, Loc}, {namespace, NS}, {directory, Dir}, {results, Results},
+ {new, href(Req, <<"_/new">>)}
+ ]),
+ {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined};
+
+%% New
+request(<<"GET">>, <<"new">>, #{urn := Urn, location := Loc, resource := #{directory := #{directory := Dir, namespace := NS}}}, Req) ->
+ logger:debug("Actual Urn: ~p", [Urn]),
+ {ok, Schemas} = dreki_store:list(<<Urn/binary, "::", "schemas">>),
+ {ok, Schema} = dreki_store:get(<<Urn/binary, "::", "schemas::">>),
+ Form = dreki_web_ui_json_form:render_html(Schema, #{}),
+ Html = dreki_web_ui:render(Req, store_new_dtl, [
+ {location, Loc}, {namespace, NS}, {directory, Dir},
+ {schema, Schema}, {schemas, Schemas},
+ {target, urn_to_path(Urn)}, {method, <<"POST">>},
+ {form, Form}
+ ]),
+ {ok, dreki_web_ui:reply_html(Req, 200, Html), undefined};
+
+%% Show
+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}.
+
+derpinit(Req, _) ->
+ Json = #{<<"error">> => false, <<"service">> => <<"dreki">>},
+ logger:debug("REQ: ~p", [Req]),
+ {ok, dreki_web:reply_json(Req, 200, Json), undefined}.
+
+
+bad_request(Req) ->
+ dreki_web_error:init(Req, #{code => 400, status => "Bad request"}).
+
+href(Req = #{path := Path}, Append) ->
+ <<Path/binary, "/", Append/binary>>.
+
+location_to_path(Location) ->
+ Root = dreki_world:root_path(),
+ case Location =:= Root of
+ true -> <<"/admin/-">>;
+ false -> binary:replace(Location, <<Root/binary, ":">>, <<"/admin/">>)
+ end.
+
+urn_to_path(Urn) when is_binary(Urn) ->
+ {ok, XUrn} = dreki_urn:expand(Urn),
+ urn_to_path(XUrn);
+urn_to_path(#{location := Location, resource := #{namespace := NS}}) ->
+ LP = location_to_path(Location),
+ <<LP/binary, "/", NS/binary>>;
+urn_to_path(#{location := Location, resource := #{directory := #{directory := Dir, namespace := NS}}}) ->
+ LP = location_to_path(Location),
+ <<LP/binary, "/", NS/binary, "/", Dir/binary>>;
+urn_to_path(#{location := Location, resource := #{resource := #{id := Id, directory := Dir, namespace := NS}}}) ->
+ LP = location_to_path(Location),
+ <<LP/binary, "/", NS/binary, "/", Dir/binary, "/", Id/binary>>.
diff --git a/apps/dreki_web/src/dreki_web_ui_task.erl b/apps/dreki_web/src/dreki_web_ui_task.erl
new file mode 100644
index 0000000..1ced583
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_ui_task.erl
@@ -0,0 +1,20 @@
+-module(dreki_web_ui_task).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+init(Req = #{method := <<"GET">>}, State) ->
+ Id = cowboy_req:binding(id, Req),
+ {ok, Db} = dreki_tasks:open(),
+ case dreki_tasks:get(Db, Id) of
+ {ok, Task} ->
+ MTask = dreki_task:to_map(Task),
+ PrettyParams = jsone:encode(maps:get(params, MTask), [canonical_form, {space, 1}, {indent, 4}]),
+ Html = dreki_web_ui:render(Req, task_dtl, [{"page_title", <<"Task: ", Id/binary>>}, {"task", MTask}, {"task_pretty_params", PrettyParams}]),
+ {ok, dreki_web_ui:reply_html(Req, 200, Html), State};
+ Error ->
+ logger:debug("Failed to lookup task ~p: ~p", [Id, Error]),
+ dreki_web_ui_error:init(Req, #{code => 404, status => "Not found"})
+ end;
+init(Req, _) ->
+ dreki_web_ui_error:init(Req, #{code => 400, status => "Bad request"}).
+
diff --git a/apps/dreki_web/src/dreki_web_ui_tasks.erl b/apps/dreki_web/src/dreki_web_ui_tasks.erl
new file mode 100644
index 0000000..e9748b8
--- /dev/null
+++ b/apps/dreki_web/src/dreki_web_ui_tasks.erl
@@ -0,0 +1,14 @@
+-module(dreki_web_ui_tasks).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+init(Req = #{method := <<"GET">>}, State) ->
+ Local = maps:fold(fun
+ (Ln, #{mod := Mod, path := Path}, Acc) ->
+ [#{name => Ln, mod => Mod, path => Path, url => <<"/api/admin/tasks/", Path>>} | Acc]
+ end, [], dreki_tasks:local_stores()),
+ Html = dreki_web_ui:render(Req, tasks_dtl, [{"page_title", "Tasks"}, {"stores", Local}, {"tasks", []}]),
+ {ok, dreki_web_ui:reply_html(Req, 200, Html), State};
+init(Req, _) ->
+ dreki_web_ui_error:init(Req, #{code => 400, status => "Bad request"}).
+