aboutsummaryrefslogtreecommitdiff
path: root/apps/dreki_web/src/ui
diff options
context:
space:
mode:
authorJordan Bracco <href@random.sh>2022-04-17 03:25:09 +0000
committerJordan Bracco <href@random.sh>2022-04-17 03:25:09 +0000
commit93d3514676cad95b94bbb3e483d02b7ea0076bba (patch)
tree0f7548c1cd274ec01873b5ebf39b6a39bea4e282 /apps/dreki_web/src/ui
parentguess it was time for an initial commit (diff)
Diffstat (limited to 'apps/dreki_web/src/ui')
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui.erl44
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_error.erl14
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_index.erl31
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_json_form.erl160
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_node.erl23
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_stores.erl189
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_task.erl20
-rw-r--r--apps/dreki_web/src/ui/dreki_web_ui_tasks.erl14
8 files changed, 495 insertions, 0 deletions
diff --git a/apps/dreki_web/src/ui/dreki_web_ui.erl b/apps/dreki_web/src/ui/dreki_web_ui.erl
new file mode 100644
index 0000000..e394632
--- /dev/null
+++ b/apps/dreki_web/src/ui/dreki_web_ui.erl
@@ -0,0 +1,44 @@
+-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()},
+ {"trace_id", maps:get(<<"trace-id">>, Req, <<"no-trace">>)}
+ | 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/ui/dreki_web_ui_error.erl b/apps/dreki_web/src/ui/dreki_web_ui_error.erl
new file mode 100644
index 0000000..ccda150
--- /dev/null
+++ b/apps/dreki_web/src/ui/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/ui/dreki_web_ui_index.erl b/apps/dreki_web/src/ui/dreki_web_ui_index.erl
new file mode 100644
index 0000000..9f4684e
--- /dev/null
+++ b/apps/dreki_web/src/ui/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/ui/dreki_web_ui_json_form.erl b/apps/dreki_web/src/ui/dreki_web_ui_json_form.erl
new file mode 100644
index 0000000..51a9de4
--- /dev/null
+++ b/apps/dreki_web/src/ui/dreki_web_ui_json_form.erl
@@ -0,0 +1,160 @@
+-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([List | Rest], Acc) when is_list(List) ->
+ to_html(Rest, [to_html(List) | 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.
+
+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))).
+
+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(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]);
+ 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(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 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);
+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/ui/dreki_web_ui_node.erl b/apps/dreki_web/src/ui/dreki_web_ui_node.erl
new file mode 100644
index 0000000..fdb7c77
--- /dev/null
+++ b/apps/dreki_web/src/ui/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/ui/dreki_web_ui_stores.erl b/apps/dreki_web/src/ui/dreki_web_ui_stores.erl
new file mode 100644
index 0000000..e39cd48
--- /dev/null
+++ b/apps/dreki_web/src/ui/dreki_web_ui_stores.erl
@@ -0,0 +1,189 @@
+-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),
+ 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},
+ {actions, [
+ [{title, "New"}, {href, 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)},
+ 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">>},
+ 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.
+
+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);
+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/ui/dreki_web_ui_task.erl b/apps/dreki_web/src/ui/dreki_web_ui_task.erl
new file mode 100644
index 0000000..1ced583
--- /dev/null
+++ b/apps/dreki_web/src/ui/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/ui/dreki_web_ui_tasks.erl b/apps/dreki_web/src/ui/dreki_web_ui_tasks.erl
new file mode 100644
index 0000000..e9748b8
--- /dev/null
+++ b/apps/dreki_web/src/ui/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"}).
+