Архивирование с быстрым подключением и серверный рендеринг для веб-клиента #15

This commit is contained in:
2026-05-04 15:19:53 +03:00
parent 83ce92afa4
commit 574d0d2e43
7 changed files with 374 additions and 18 deletions
+101
View File
@@ -0,0 +1,101 @@
-module(archive_controller).
-compile([{nowarn_deprecated_function, [{slave, start, 3}, {slave, stop, 1}]}]).
-include("records.hrl").
-export([archive_day/1]).
archive_day(Day) ->
ArchiveNode = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
case start_archive_node(ArchiveNode) of
{ok, PeerOrSlave} ->
try
rpc:call(ArchiveNode, mnesia, create_schema, [[ArchiveNode]]),
rpc:call(ArchiveNode, mnesia, start, []),
rpc:call(ArchiveNode, code, ensure_loaded, [archive_fetcher]),
create_archive_table(ArchiveNode, event,
[id, calendar_id, start_time, end_time, event_type,
master_id, specialist_id, title, description,
attachments, edit_history, status, created_at, updated_at],
[calendar_id, start_time, event_type, master_id,
specialist_id, status]),
create_archive_table(ArchiveNode, booking,
[id, event_id, user_id, status, confirmed_at,
created_at, updated_at, notes, reminder_sent],
[event_id, user_id, status]),
create_archive_table(ArchiveNode, review,
[id, user_id, target_type, target_id, rating, comment,
status, reason, created_at, updated_at, likes,
dislikes, edited_at], []),
create_archive_table(ArchiveNode, report,
[id, reporter_id, target_type, target_id, reason,
status, created_at, resolved_at, resolved_by], []),
ok = transfer_data(ArchiveNode, Day),
io:format("Archived day ~s successfully.~n", [Day])
after
stop_archive_node(PeerOrSlave, ArchiveNode)
end;
{error, Reason} ->
io:format("Failed to start archive node: ~p~n", [Reason]),
{error, Reason}
end.
start_archive_node(Node) ->
case os:getenv("CLUSTER_MODE") of
"true" ->
peer:start_link(#{name => Node, host => host()});
_ ->
CookieStr = atom_to_list(erlang:get_cookie()),
case slave:start(host(), Node, "-setcookie " ++ CookieStr) of
{ok, Slave} -> {ok, Slave};
Error -> Error
end
end.
stop_archive_node(PeerOrSlave, Node) ->
case os:getenv("CLUSTER_MODE") of
"true" -> peer:stop(PeerOrSlave);
_ -> slave:stop(Node)
end.
create_archive_table(Node, Tab, Attributes, Indices) ->
Opts = [{disc_only_copies, [Node]},
{attributes, Attributes},
{type, set}] ++ case Indices of
[] -> [];
_ -> [{index, Indices}]
end,
rpc:call(Node, mnesia, create_table, [Tab, Opts]).
transfer_data(ArchiveNode, Day) ->
Tables = [event, booking, review, report],
lists:foreach(fun(Tab) ->
Records = fetch_records(Tab, Day),
rpc:call(ArchiveNode, mnesia, transaction, [
fun() -> [mnesia:write(Rec) || Rec <- Records] end
]),
mnesia:transaction(fun() ->
[mnesia:delete({Tab, element(2, Rec)}) || Rec <- Records]
end)
end, Tables).
fetch_records(event, Day) ->
Start = list_to_binary(Day ++ " 00:00:00"),
End = list_to_binary(Day ++ " 23:59:59"),
mnesia:dirty_select(event, [{#event{start_time = '$1', _ = '_'},
[{'>=','$1', Start},{'=<','$1', End}],
['$_']}]);
fetch_records(booking, Day) ->
mnesia:dirty_select(booking, [{#booking{created_at = '$1', _ = '_'},
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
['$_']}]);
fetch_records(review, Day) ->
mnesia:dirty_select(review, [{#review{created_at = '$1', _ = '_'},
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
['$_']}]);
fetch_records(report, Day) ->
mnesia:dirty_select(report, [{#report{created_at = '$1', _ = '_'},
[{'>=','$1', Day},{'=<','$1', Day ++ " 23:59:59"}],
['$_']}]).
host() ->
{ok, Name} = inet:gethostname(),
Name.
+12
View File
@@ -0,0 +1,12 @@
-module(archive_fetcher).
-include("records.hrl").
-export([fetch/3]).
fetch(CalendarId, Year, Month) ->
Start = {Year, Month, 1, 0, 0, 0},
End = {Year, Month, 31, 23, 59, 59},
mnesia:dirty_select(event, [{#event{calendar_id = CalendarId,
start_time = '$1', _ = '_'},
[{'>=','$1', Start},{'=<','$1', End}],
['$_']}]).
+71
View File
@@ -0,0 +1,71 @@
-module(archive_manager).
-behaviour(gen_server).
-export([start_link/0, get_archive_node/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
-define(TIMEOUT, 30000). % 30 секунд неактивности
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
get_archive_node(Day) ->
gen_server:call(?MODULE, {get_node, Day}).
init([]) ->
{ok, #{}}.
handle_call({get_node, Day}, _From, State) ->
Node = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
case maps:find(Node, State) of
{ok, _Info} -> % переменная не используется, ок
{reply, {ok, Node}, update_access(State, Node)};
error ->
case start_archive_node(Day) of
{ok, Node} ->
Ref = erlang:send_after(?TIMEOUT, self(), {release, Node}),
NewState = State#{Node => #{timer => Ref, last_access => erlang:monotonic_time()}},
{reply, {ok, Node}, NewState};
{error, Reason} ->
{reply, {error, Reason}, State}
end
end.
handle_cast(_, State) -> {noreply, State}.
handle_info({release, Node}, State) ->
case maps:find(Node, State) of
{ok, #{last_access := Last}} ->
Now = erlang:monotonic_time(),
if Now - Last >= (?TIMEOUT * 1000) ->
stop_archive_node(Node),
{noreply, maps:remove(Node, State)};
true ->
Ref = erlang:send_after(?TIMEOUT, self(), {release, Node}),
{noreply, State#{Node => #{timer => Ref, last_access => Last}}}
end;
error ->
{noreply, State}
end;
handle_info(_, State) -> {noreply, State}.
update_access(State, Node) ->
Info = maps:get(Node, State),
Ref = erlang:send_after(?TIMEOUT, self(), {release, Node}),
State#{Node => Info#{timer => Ref, last_access => erlang:monotonic_time()}}.
start_archive_node(Day) ->
Node = list_to_atom("eventhub_archive_" ++ Day ++ "@" ++ host()),
case slave:start(host(), Node) of
{ok, _} ->
rpc:call(Node, mnesia, start, []),
{ok, Node};
Error -> Error
end.
stop_archive_node(Node) ->
rpc:cast(Node, init, stop, []).
host() ->
{ok, Name} = inet:gethostname(),
Name.
+57
View File
@@ -0,0 +1,57 @@
-module(calendar_html_renderer).
-include("records.hrl").
-export([render_month/3, init_cache/0]).
init_cache() ->
case ets:info(archive_html_cache) of
undefined ->
ets:new(archive_html_cache, [set, public, named_table, {keypos, 1}]);
_ -> ok
end.
render_month(Year, Month, Events) ->
Key = {Year, Month},
try ets:lookup(archive_html_cache, Key) of
[{Key, Html}] -> Html;
[] ->
Html = generate_html(Year, Month, Events),
ets:insert(archive_html_cache, {Key, Html}),
Html
catch
_:_ ->
generate_html(Year, Month, Events)
end.
generate_html(Year, Month, Events) ->
DaysInMonth = calendar:last_day_of_the_month(Year, Month),
EventsByDay = group_events_by_day(Events),
DayList = lists:seq(1, DaysInMonth),
DayCells = lists:map(fun(D) ->
DayEvents = maps:get(D, EventsByDay, []),
["<td>", integer_to_list(D), format_events(DayEvents), "</td>"]
end, DayList),
["<html><body><table>",
"<tr>", DayCells, "</tr>",
"</table></body></html>"].
group_events_by_day(Events) ->
lists:foldl(fun(Evt, Acc) ->
case Evt of
#event{start_time = {{_, _, Day}, {_, _, _}}} ->
maps:update_with(Day, fun(List) -> [Evt | List] end, [Evt], Acc);
_ -> Acc
end
end, #{}, Events).
format_events(Events) ->
case Events of
[] -> [];
_ ->
["<ul>",
lists:map(fun(#event{title = Title, start_time = {{_, _, _}, {H, M, _}}}) ->
["<li>", Title, " (", integer_to_list(H), ":",
io_lib:format("~2..0B", [M]), ")</li>"]
end, Events),
"</ul>"]
end.