This commit is contained in:
+5
-2
@@ -3,7 +3,8 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
FROM erlang:28-alpine AS builder
|
FROM erlang:28-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
# RUN apk add --no-cache \
|
||||||
|
RUN apk add \
|
||||||
git curl make gcc musl-dev \
|
git curl make gcc musl-dev \
|
||||||
rust cargo openssl-dev libsodium-dev
|
rust cargo openssl-dev libsodium-dev
|
||||||
|
|
||||||
@@ -29,7 +30,9 @@ RUN mkdir -p /app/release && \
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|
||||||
RUN apk add --no-cache openssl libstdc++ libgcc ncurses-libs libsodium
|
# RUN apk add --no-cache \
|
||||||
|
RUN apk add \
|
||||||
|
openssl libstdc++ libgcc ncurses-libs libsodium
|
||||||
COPY --from=builder /app/release /app
|
COPY --from=builder /app/release /app
|
||||||
COPY --from=builder /usr/lib/libcrypto.* /usr/lib/
|
COPY --from=builder /usr/lib/libcrypto.* /usr/lib/
|
||||||
COPY --from=builder /usr/lib/libssl.* /usr/lib/
|
COPY --from=builder /usr/lib/libssl.* /usr/lib/
|
||||||
|
|||||||
+4
-2
@@ -123,7 +123,7 @@
|
|||||||
tags :: [binary()],
|
tags :: [binary()],
|
||||||
capacity :: integer(),
|
capacity :: integer(),
|
||||||
online_link :: binary(),
|
online_link :: binary(),
|
||||||
status :: active | cancelled | completed,
|
status :: active | cancelled | completed | frozen | deleted,
|
||||||
reason :: binary(),
|
reason :: binary(),
|
||||||
rating_avg :: float(),
|
rating_avg :: float(),
|
||||||
rating_count :: non_neg_integer(),
|
rating_count :: non_neg_integer(),
|
||||||
@@ -268,7 +268,9 @@
|
|||||||
active_sessions :: non_neg_integer(),
|
active_sessions :: non_neg_integer(),
|
||||||
ws_connections :: non_neg_integer(),
|
ws_connections :: non_neg_integer(),
|
||||||
io_in :: non_neg_integer(),
|
io_in :: non_neg_integer(),
|
||||||
io_out :: non_neg_integer()
|
io_out :: non_neg_integer(),
|
||||||
|
memory_available :: non_neg_integer(),
|
||||||
|
cpu_utilization :: float()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
-record(schema_migration, {
|
-record(schema_migration, {
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec save(#node_metric{}) -> ok.
|
-spec save(#node_metric{}) -> ok.
|
||||||
save(Metric) ->
|
save(Metric) ->
|
||||||
mnesia:dirty_write(Metric),
|
try mnesia:dirty_write(Metric)
|
||||||
ok.
|
catch _:_ -> ok
|
||||||
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Возвращает метрики за период, отсортированные по времени.
|
%%% @doc Возвращает метрики за период, отсортированные по времени.
|
||||||
|
|||||||
@@ -269,15 +269,21 @@ apply_updates(Sub, Updates) ->
|
|||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Разбор ISO8601 строки в формат `calendar:datetime()`.
|
%%% @doc Разбор ISO8601 строки в формат `calendar:datetime()`.
|
||||||
|
%%% Поддерживает миллисекунды и таймзону.
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec parse_iso_datetime(binary() | term()) -> calendar:datetime() | term().
|
-spec parse_iso_datetime(binary() | term()) -> calendar:datetime() | term().
|
||||||
parse_iso_datetime(Bin) when is_binary(Bin) ->
|
parse_iso_datetime(Bin) when is_binary(Bin) ->
|
||||||
try
|
try
|
||||||
[DateStr, TimeStr] = string:split(Bin, "T"),
|
[DatePart, TimePart] = string:split(Bin, "T"),
|
||||||
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
|
[YearStr, MonthStr, DayStr] = string:split(DatePart, "-", all),
|
||||||
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
|
TimeNoZ = string:trim(TimePart, trailing, "Z"),
|
||||||
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
|
[HourStr, MinuteStr, SecondPart] = string:split(TimeNoZ, ":", all),
|
||||||
|
% удаляем миллисекунды, если они есть
|
||||||
|
SecondStr = case string:split(SecondPart, ".") of
|
||||||
|
[Sec, _] -> Sec;
|
||||||
|
_ -> SecondPart
|
||||||
|
end,
|
||||||
Year = binary_to_integer(list_to_binary(YearStr)),
|
Year = binary_to_integer(list_to_binary(YearStr)),
|
||||||
Month = binary_to_integer(list_to_binary(MonthStr)),
|
Month = binary_to_integer(list_to_binary(MonthStr)),
|
||||||
Day = binary_to_integer(list_to_binary(DayStr)),
|
Day = binary_to_integer(list_to_binary(DayStr)),
|
||||||
@@ -357,6 +363,7 @@ count_trial_subscriptions() ->
|
|||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней.
|
%%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней.
|
||||||
|
%%% Принимает меры к преобразованию expires_at, если оно сохранено как строка.
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec get_ending_paid_subscriptions(Days :: non_neg_integer()) -> [#subscription{}].
|
-spec get_ending_paid_subscriptions(Days :: non_neg_integer()) -> [#subscription{}].
|
||||||
@@ -366,6 +373,11 @@ get_ending_paid_subscriptions(Days) ->
|
|||||||
Match = #subscription{status = active, trial_used = true, _ = '_'},
|
Match = #subscription{status = active, trial_used = true, _ = '_'},
|
||||||
ActivePaid = mnesia:dirty_match_object(Match),
|
ActivePaid = mnesia:dirty_match_object(Match),
|
||||||
lists:filter(fun(#subscription{expires_at = Exp}) ->
|
lists:filter(fun(#subscription{expires_at = Exp}) ->
|
||||||
ExpSec = calendar:datetime_to_gregorian_seconds(Exp),
|
ExpDateTime = case Exp of
|
||||||
|
{{_,_,_}, {_,_,_}} -> Exp; % уже datetime
|
||||||
|
Bin when is_binary(Bin) -> parse_iso_datetime(Bin);
|
||||||
|
_ -> Exp
|
||||||
|
end,
|
||||||
|
ExpSec = calendar:datetime_to_gregorian_seconds(ExpDateTime),
|
||||||
ExpSec >= NowSec andalso ExpSec =< ThresholdSec
|
ExpSec >= NowSec andalso ExpSec =< ThresholdSec
|
||||||
end, ActivePaid).
|
end, ActivePaid).
|
||||||
@@ -131,7 +131,7 @@ create_admin(Req) ->
|
|||||||
case logic_admin:create_admin(Email, Password, Role) of
|
case logic_admin:create_admin(Email, Password, Role) of
|
||||||
{ok, Admin} ->
|
{ok, Admin} ->
|
||||||
% Аудит создания администратора
|
% Аудит создания администратора
|
||||||
admin_utils:log_admin_action(SuperAdminId, <<"create_admin">>, <<"admin">>, Email, Req2),
|
admin_utils:log_admin_action(SuperAdminId, <<"create_admin">>, <<"admin">>, Email, <<>>,Req2),
|
||||||
handler_utils:send_json(Req2, 201, handler_utils:admin_to_json(Admin));
|
handler_utils:send_json(Req2, 201, handler_utils:admin_to_json(Admin));
|
||||||
{error, email_exists} ->
|
{error, email_exists} ->
|
||||||
handler_utils:send_error(Req2, 409, <<"Email already exists">>);
|
handler_utils:send_error(Req2, 409, <<"Email already exists">>);
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ update_admin(Req) ->
|
|||||||
Updates = convert_admin_fields(Updates0),
|
Updates = convert_admin_fields(Updates0),
|
||||||
case logic_admin:update_admin(Id, Updates) of
|
case logic_admin:update_admin(Id, Updates) of
|
||||||
{ok, Admin} ->
|
{ok, Admin} ->
|
||||||
admin_utils:log_admin_action(SuperAdminId, <<"update_admin">>, <<"admin">>, Id, Req2),
|
admin_utils:log_admin_action(SuperAdminId, <<"update_admin">>, <<"admin">>, Id, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:admin_to_json(Admin));
|
handler_utils:send_json(Req2, 200, handler_utils:admin_to_json(Admin));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Admin not found">>);
|
handler_utils:send_error(Req2, 404, <<"Admin not found">>);
|
||||||
@@ -158,7 +158,7 @@ delete_admin(Req) ->
|
|||||||
Id = cowboy_req:binding(id, Req1),
|
Id = cowboy_req:binding(id, Req1),
|
||||||
case logic_admin:delete_admin(Id) of
|
case logic_admin:delete_admin(Id) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
admin_utils:log_admin_action(SuperAdminId, <<"delete_admin">>, <<"admin">>, Id, Req1),
|
admin_utils:log_admin_action(SuperAdminId, <<"delete_admin">>, <<"admin">>, Id, <<>>,Req1),
|
||||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"Admin not found">>);
|
handler_utils:send_error(Req1, 404, <<"Admin not found">>);
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ add_word(Req) ->
|
|||||||
#{<<"word">> := Word} ->
|
#{<<"word">> := Word} ->
|
||||||
case core_banned_words:add_banned_word(Word, AdminId) of
|
case core_banned_words:add_banned_word(Word, AdminId) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"add">>, <<"banned_word">>, Word, Req2),
|
admin_utils:log_admin_action(AdminId, <<"add">>, <<"banned_word">>, Word, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 201, #{status => <<"added">>});
|
handler_utils:send_json(Req2, 201, #{status => <<"added">>});
|
||||||
{error, already_exists} ->
|
{error, already_exists} ->
|
||||||
handler_utils:send_error(Req2, 409, <<"Word already exists">>);
|
handler_utils:send_error(Req2, 409, <<"Word already exists">>);
|
||||||
@@ -205,7 +205,7 @@ update_word(Req) ->
|
|||||||
case core_banned_words:update_banned_word(OldWord, NewWord) of
|
case core_banned_words:update_banned_word(OldWord, NewWord) of
|
||||||
{ok, _Updated} ->
|
{ok, _Updated} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"update">>, <<"banned_word">>,
|
admin_utils:log_admin_action(AdminId, <<"update">>, <<"banned_word">>,
|
||||||
#{old_word => OldWord, new_word => NewWord}, Req2),
|
#{old_word => OldWord, new_word => NewWord}, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, #{status => <<"updated">>});
|
handler_utils:send_json(Req2, 200, #{status => <<"updated">>});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Word not found">>);
|
handler_utils:send_error(Req2, 404, <<"Word not found">>);
|
||||||
@@ -228,7 +228,7 @@ delete_word(Req) ->
|
|||||||
Word = cowboy_req:binding(word, Req1),
|
Word = cowboy_req:binding(word, Req1),
|
||||||
case core_banned_words:remove_banned_word(Word) of
|
case core_banned_words:remove_banned_word(Word) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"delete">>, <<"banned_word">>, Word, Req1),
|
admin_utils:log_admin_action(AdminId, <<"delete">>, <<"banned_word">>, Word, <<>>, Req1),
|
||||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"Word not found">>);
|
handler_utils:send_error(Req1, 404, <<"Word not found">>);
|
||||||
@@ -260,7 +260,7 @@ batch_add_words(Req) ->
|
|||||||
Added = length([R || R <- Results, maps:get(status, R) =:= <<"added">>]),
|
Added = length([R || R <- Results, maps:get(status, R) =:= <<"added">>]),
|
||||||
Skipped = length([R || R <- Results, maps:get(status, R) =:= <<"skipped">>]),
|
Skipped = length([R || R <- Results, maps:get(status, R) =:= <<"skipped">>]),
|
||||||
admin_utils:log_admin_action(AdminId, <<"batch_add">>, <<"banned_words">>,
|
admin_utils:log_admin_action(AdminId, <<"batch_add">>, <<"banned_words">>,
|
||||||
#{count => length(Words), added => Added, skipped => Skipped}, Req2),
|
#{count => length(Words), added => Added, skipped => Skipped}, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, #{
|
handler_utils:send_json(Req2, 200, #{
|
||||||
results => Results,
|
results => Results,
|
||||||
added => Added,
|
added => Added,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ update_calendar(Req) ->
|
|||||||
Updates ->
|
Updates ->
|
||||||
case logic_calendar:admin_update(Id, Updates) of
|
case logic_calendar:admin_update(Id, Updates) of
|
||||||
{ok, Updated} ->
|
{ok, Updated} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"update">>, <<"calendar">>, Id, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update">>, <<"calendar">>, Id, <<>>, Req2),
|
||||||
Json = handler_utils:calendar_to_json(Updated),
|
Json = handler_utils:calendar_to_json(Updated),
|
||||||
handler_utils:send_json(Req2, 200, Json);
|
handler_utils:send_json(Req2, 200, Json);
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
@@ -139,7 +139,7 @@ delete_calendar(Req) ->
|
|||||||
Id = cowboy_req:binding(id, Req1),
|
Id = cowboy_req:binding(id, Req1),
|
||||||
case logic_calendar:admin_delete(Id) of
|
case logic_calendar:admin_delete(Id) of
|
||||||
ok ->
|
ok ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"delete">>, <<"calendar">>, Id, Req1),
|
admin_utils:log_admin_action(AdminId, <<"delete">>, <<"calendar">>, Id, <<>>, Req1),
|
||||||
handler_utils:send_json(Req1, 200, #{});
|
handler_utils:send_json(Req1, 200, #{});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>)
|
handler_utils:send_error(Req1, 404, <<"Calendar not found">>)
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ update_event(Req) ->
|
|||||||
case logic_event:update_event_admin(EventId, UpdatesWithTypes) of
|
case logic_event:update_event_admin(EventId, UpdatesWithTypes) of
|
||||||
{ok, Event} ->
|
{ok, Event} ->
|
||||||
% Аудит обновления события
|
% Аудит обновления события
|
||||||
admin_utils:log_admin_action(AdminId, <<"update_event">>, <<"event">>, EventId, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update_event">>, <<"event">>, EventId, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:event_to_json(Event));
|
handler_utils:send_json(Req2, 200, handler_utils:event_to_json(Event));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||||
@@ -177,7 +177,7 @@ delete_event(Req) ->
|
|||||||
case logic_event:delete_event_admin(EventId) of
|
case logic_event:delete_event_admin(EventId) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
% Аудит удаления события
|
% Аудит удаления события
|
||||||
admin_utils:log_admin_action(AdminId, <<"delete_event">>, <<"event">>, EventId, Req1),
|
admin_utils:log_admin_action(AdminId, <<"delete_event">>, <<"event">>, EventId, <<>>, Req1),
|
||||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||||
@@ -218,9 +218,12 @@ convert_field({<<"tags">>, Val}) -> {tags, Val};
|
|||||||
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
|
||||||
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
|
||||||
convert_field({<<"status">>, Val}) ->
|
convert_field({<<"status">>, Val}) ->
|
||||||
try binary_to_existing_atom(Val, utf8) of
|
case Val of
|
||||||
Atom -> {status, Atom}
|
<<"active">> -> {status, active};
|
||||||
catch
|
<<"cancelled">> -> {status, cancelled};
|
||||||
error:badarg -> {status, Val}
|
<<"completed">> -> {status, completed};
|
||||||
|
<<"frozen">> -> {status, frozen};
|
||||||
|
<<"deleted">> -> {status, deleted};
|
||||||
|
_ -> {status, Val}
|
||||||
end;
|
end;
|
||||||
convert_field(Other) -> Other.
|
convert_field(Other) -> Other.
|
||||||
@@ -25,7 +25,7 @@ init(Req0, _State) ->
|
|||||||
core_admin_session:create(UserId, RefreshToken),
|
core_admin_session:create(UserId, RefreshToken),
|
||||||
core_admin:update_last_login(UserId),
|
core_admin:update_last_login(UserId),
|
||||||
% Аудит успешного входа
|
% Аудит успешного входа
|
||||||
admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, Req1),
|
admin_utils:log_admin_action(UserId, <<"login">>, <<"admin">>, Email, <<>>, Req1),
|
||||||
Resp = #{
|
Resp = #{
|
||||||
<<"token">> => Token,
|
<<"token">> => Token,
|
||||||
<<"user">> => #{
|
<<"user">> => #{
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ update_me(Req) ->
|
|||||||
case logic_admin:update_admin(AdminId, Updates) of
|
case logic_admin:update_admin(AdminId, Updates) of
|
||||||
{ok, Admin} ->
|
{ok, Admin} ->
|
||||||
% Аудит обновления профиля
|
% Аудит обновления профиля
|
||||||
admin_utils:log_admin_action(AdminId, <<"update_me">>, <<"admin">>, AdminId, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update_me">>, <<"admin">>, AdminId, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:admin_to_json(Admin));
|
handler_utils:send_json(Req2, 200, handler_utils:admin_to_json(Admin));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Admin not found">>);
|
handler_utils:send_error(Req2, 404, <<"Admin not found">>);
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ apply_moderation(<<"user">>, Id, Action, Reason, Req, AdminId) ->
|
|||||||
handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) ->
|
handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) ->
|
||||||
case core_calendar:freeze(Id, Reason) of
|
case core_calendar:freeze(Id, Reason) of
|
||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"freeze_calendar">>, <<"calendar">>, #{id => Id, reason => Reason}, Req),
|
admin_utils:log_admin_action(AdminId, <<"freeze_calendar">>, <<"calendar">>, #{id => Id}, #{reason => Reason}, Req),
|
||||||
handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
|
handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req, 404, <<"Calendar not found">>)
|
handler_utils:send_error(Req, 404, <<"Calendar not found">>)
|
||||||
@@ -109,7 +109,7 @@ handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) ->
|
|||||||
handle_calendar(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
|
handle_calendar(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
|
||||||
case core_calendar:unfreeze(Id, Reason) of
|
case core_calendar:unfreeze(Id, Reason) of
|
||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"unfreeze_calendar">>, <<"calendar">>, #{id => Id, reason => Reason}, Req),
|
admin_utils:log_admin_action(AdminId, <<"unfreeze_calendar">>, <<"calendar">>, #{id => Id}, #{reason => Reason}, Req),
|
||||||
handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
|
handler_utils:send_json(Req, 200, handler_utils:calendar_to_json(Calendar));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req, 404, <<"Calendar not found">>)
|
handler_utils:send_error(Req, 404, <<"Calendar not found">>)
|
||||||
@@ -120,7 +120,7 @@ handle_calendar(_Id, _Action, _Reason, Req, _AdminId) ->
|
|||||||
handle_event(Id, <<"freeze">>, Reason, Req, AdminId) ->
|
handle_event(Id, <<"freeze">>, Reason, Req, AdminId) ->
|
||||||
case core_event:freeze(Id, Reason) of
|
case core_event:freeze(Id, Reason) of
|
||||||
{ok, Event} ->
|
{ok, Event} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"freeze_event">>, <<"event">>, #{id => Id, reason => Reason}, Req),
|
admin_utils:log_admin_action(AdminId, <<"freeze_event">>, <<"event">>, #{id => Id}, #{reason => Reason}, Req),
|
||||||
handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
|
handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req, 404, <<"Event not found">>)
|
handler_utils:send_error(Req, 404, <<"Event not found">>)
|
||||||
@@ -128,7 +128,7 @@ handle_event(Id, <<"freeze">>, Reason, Req, AdminId) ->
|
|||||||
handle_event(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
|
handle_event(Id, <<"unfreeze">>, Reason, Req, AdminId) ->
|
||||||
case core_event:unfreeze(Id, Reason) of
|
case core_event:unfreeze(Id, Reason) of
|
||||||
{ok, Event} ->
|
{ok, Event} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"unfreeze_event">>, <<"event">>, #{id => Id, reason => Reason}, Req),
|
admin_utils:log_admin_action(AdminId, <<"unfreeze_event">>, <<"event">>, #{id => Id}, #{reason => Reason}, Req),
|
||||||
handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
|
handler_utils:send_json(Req, 200, handler_utils:event_to_json(Event));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req, 404, <<"Event not found">>)
|
handler_utils:send_error(Req, 404, <<"Event not found">>)
|
||||||
@@ -139,7 +139,7 @@ handle_event(_Id, _Action, _Reason, Req, _AdminId) ->
|
|||||||
handle_review(Id, <<"hide">>, Reason, Req, AdminId) ->
|
handle_review(Id, <<"hide">>, Reason, Req, AdminId) ->
|
||||||
case core_review:hide(Id, Reason) of
|
case core_review:hide(Id, Reason) of
|
||||||
{ok, Review} ->
|
{ok, Review} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"hide_review">>, <<"review">>, #{id => Id, reason => Reason}, Req),
|
admin_utils:log_admin_action(AdminId, <<"hide_review">>, <<"review">>, #{id => Id}, #{reason => Reason}, Req),
|
||||||
handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
|
handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req, 404, <<"Review not found">>)
|
handler_utils:send_error(Req, 404, <<"Review not found">>)
|
||||||
@@ -147,7 +147,7 @@ handle_review(Id, <<"hide">>, Reason, Req, AdminId) ->
|
|||||||
handle_review(Id, <<"unhide">>, Reason, Req, AdminId) ->
|
handle_review(Id, <<"unhide">>, Reason, Req, AdminId) ->
|
||||||
case core_review:unhide(Id, Reason) of
|
case core_review:unhide(Id, Reason) of
|
||||||
{ok, Review} ->
|
{ok, Review} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"unhide_review">>, <<"review">>, #{id => Id, reason => Reason}, Req),
|
admin_utils:log_admin_action(AdminId, <<"unhide_review">>, <<"review">>, #{id => Id}, #{reason => Reason}, Req),
|
||||||
handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
|
handler_utils:send_json(Req, 200, handler_utils:review_to_json(Review));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req, 404, <<"Review not found">>)
|
handler_utils:send_error(Req, 404, <<"Review not found">>)
|
||||||
@@ -158,7 +158,7 @@ handle_review(_Id, _Action, _Reason, Req, _AdminId) ->
|
|||||||
handle_user(Id, <<"block">>, Reason, Req, AdminId) ->
|
handle_user(Id, <<"block">>, Reason, Req, AdminId) ->
|
||||||
case core_user:block(Id, Reason) of
|
case core_user:block(Id, Reason) of
|
||||||
{ok, User} ->
|
{ok, User} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"block_user">>, <<"user">>, #{id => Id, reason => Reason}, Req),
|
admin_utils:log_admin_action(AdminId, <<"block_user">>, <<"user">>, #{id => Id}, #{reason => Reason}, Req),
|
||||||
handler_utils:send_json(Req, 200, handler_utils:user_to_json(User));
|
handler_utils:send_json(Req, 200, handler_utils:user_to_json(User));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req, 404, <<"User not found">>)
|
handler_utils:send_error(Req, 404, <<"User not found">>)
|
||||||
@@ -166,7 +166,7 @@ handle_user(Id, <<"block">>, Reason, Req, AdminId) ->
|
|||||||
handle_user(Id, <<"unblock">>, Reason, Req, AdminId) ->
|
handle_user(Id, <<"unblock">>, Reason, Req, AdminId) ->
|
||||||
case core_user:unblock(Id, Reason) of
|
case core_user:unblock(Id, Reason) of
|
||||||
{ok, User} ->
|
{ok, User} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"unblock_user">>, <<"user">>, #{id => Id, reason => Reason}, Req),
|
admin_utils:log_admin_action(AdminId, <<"unblock_user">>, <<"user">>, #{id => Id}, #{reason => Reason}, Req),
|
||||||
handler_utils:send_json(Req, 200, handler_utils:user_to_json(User));
|
handler_utils:send_json(Req, 200, handler_utils:user_to_json(User));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req, 404, <<"User not found">>)
|
handler_utils:send_error(Req, 404, <<"User not found">>)
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Административный обработчик исторических метрик узлов.
|
%%% @doc Административный обработчик исторических метрик узлов.
|
||||||
%%% GET /v1/admin/nodes/metrics – возвращает массив метрик за период.
|
%%%
|
||||||
|
%%% == Эндпоинт ==
|
||||||
|
%%% ```
|
||||||
|
%%% GET /v1/admin/nodes/metrics?from=ISO8601&to=ISO8601&node=NameOrAll
|
||||||
|
%%% ```
|
||||||
|
%%%
|
||||||
|
%%% == Параметры ==
|
||||||
|
%%% <ul>
|
||||||
|
%%% <li>`from` (обязательный) – начало временного диапазона в ISO8601.</li>
|
||||||
|
%%% <li>`to` (обязательный) – конец временного диапазона в ISO8601.</li>
|
||||||
|
%%% <li>`node` (опциональный) – имя узла Erlang или `<<"all">>` (по умолчанию).
|
||||||
|
%%% При `<<"all">>` возвращаются метрики всех узлов кластера.</li>
|
||||||
|
%%% </ul>
|
||||||
|
%%%
|
||||||
|
%%% == Ответ ==
|
||||||
|
%%% Массив объектов метрик. Каждый объект содержит поля, описанные в `metric_schema/0`.
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-module(admin_handler_node_metrics).
|
-module(admin_handler_node_metrics).
|
||||||
@@ -10,115 +25,25 @@
|
|||||||
|
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
|
|
||||||
%%% cowboy_handler callback
|
%%%-------------------------------------------------------------------
|
||||||
-spec init(cowboy_req:req(), any()) -> {ok, binary(), cowboy_req:req()}.
|
%%% @doc Инициализация Cowboy-обработчика.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
init(Req, _Opts) ->
|
init(Req, _Opts) ->
|
||||||
case cowboy_req:method(Req) of
|
case cowboy_req:method(Req) of
|
||||||
<<"GET">> -> get_metrics(Req);
|
<<"GET">> -> get_metrics(Req);
|
||||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%%% Swagger metadata
|
%%%-------------------------------------------------------------------
|
||||||
-spec trails() -> [map()].
|
%%% @doc Возвращает исторические метрики узлов.
|
||||||
trails() ->
|
%%%
|
||||||
[#{path => <<"/v1/admin/nodes/metrics">>,
|
%%% При `node=all` (по умолчанию) собирает метрики со всех узлов кластера
|
||||||
method => <<"GET">>,
|
%%% через RPC-вызовы к удалённым узлам. Локальные метрики извлекаются
|
||||||
description => <<"Get historical node metrics">>,
|
%%% напрямую.
|
||||||
tags => [<<"Node Metrics">>],
|
%%% @end
|
||||||
parameters => [
|
%%%-------------------------------------------------------------------
|
||||||
#{name => <<"node">>, in => <<"query">>, schema => #{type => string}, description => <<"Node name, default current">>},
|
|
||||||
#{name => <<"from">>, in => <<"query">>, required => true, schema => #{type => string, format => <<"date-time">>}},
|
|
||||||
#{name => <<"to">>, in => <<"query">>, required => true, schema => #{type => string, format => <<"date-time">>}}
|
|
||||||
],
|
|
||||||
responses => #{
|
|
||||||
200 => #{
|
|
||||||
description => <<"Array of node metrics">>,
|
|
||||||
content => #{<<"application/json">> => #{schema => #{
|
|
||||||
type => array,
|
|
||||||
items => metric_schema()
|
|
||||||
}}}
|
|
||||||
},
|
|
||||||
400 => #{description => <<"Missing required parameters">>},
|
|
||||||
403 => #{description => <<"Admin access required">>}
|
|
||||||
}}
|
|
||||||
].
|
|
||||||
|
|
||||||
metric_schema() ->
|
|
||||||
#{
|
|
||||||
type => object,
|
|
||||||
properties => #{
|
|
||||||
<<"timestamp">> => #{
|
|
||||||
type => string,
|
|
||||||
format => <<"date-time">>,
|
|
||||||
description => <<"Metric collection timestamp (ISO8601)">>
|
|
||||||
},
|
|
||||||
<<"node">> => #{
|
|
||||||
type => string,
|
|
||||||
description => <<"Erlang node name">>
|
|
||||||
},
|
|
||||||
<<"memory_total">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Total memory allocated by the Erlang VM (bytes)">>
|
|
||||||
},
|
|
||||||
<<"memory_processes">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Memory used by processes (bytes)">>
|
|
||||||
},
|
|
||||||
<<"memory_atom">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Memory used by atoms (bytes)">>
|
|
||||||
},
|
|
||||||
<<"memory_binary">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Memory used by binaries (bytes)">>
|
|
||||||
},
|
|
||||||
<<"memory_ets">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Memory used by ETS tables (bytes)">>
|
|
||||||
},
|
|
||||||
<<"process_count">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Number of live Erlang processes">>
|
|
||||||
},
|
|
||||||
<<"run_queue">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Length of the scheduler run queue">>
|
|
||||||
},
|
|
||||||
<<"mnesia_commits">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Number of successful Mnesia transactions since node start">>
|
|
||||||
},
|
|
||||||
<<"mnesia_failures">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Number of failed Mnesia transactions since node start">>
|
|
||||||
},
|
|
||||||
<<"table_sizes">> => #{
|
|
||||||
type => object,
|
|
||||||
description => <<"Map of Mnesia table names to their sizes (number of records)">>,
|
|
||||||
additionalProperties => #{type => integer}
|
|
||||||
},
|
|
||||||
<<"active_sessions">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Total number of active user and admin sessions">>
|
|
||||||
},
|
|
||||||
<<"ws_connections">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Number of open WebSocket connections">>
|
|
||||||
},
|
|
||||||
<<"io_in">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Bytes received on sockets since last collection (delta)">>
|
|
||||||
},
|
|
||||||
<<"io_out">> => #{
|
|
||||||
type => integer,
|
|
||||||
description => <<"Bytes sent over sockets since last collection (delta)">>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.
|
|
||||||
|
|
||||||
%%% Internal functions
|
|
||||||
|
|
||||||
%% @doc Возвращает исторические метрики по заданному периоду и узлу.
|
|
||||||
-spec get_metrics(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
-spec get_metrics(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||||
get_metrics(Req) ->
|
get_metrics(Req) ->
|
||||||
case handler_utils:auth_admin(Req) of
|
case handler_utils:auth_admin(Req) of
|
||||||
@@ -126,20 +51,49 @@ get_metrics(Req) ->
|
|||||||
Qs = cowboy_req:parse_qs(Req1),
|
Qs = cowboy_req:parse_qs(Req1),
|
||||||
From = handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)),
|
From = handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, Qs)),
|
||||||
To = handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)),
|
To = handler_utils:parse_datetime_qs(proplists:get_value(<<"to">>, Qs)),
|
||||||
Node = proplists:get_value(<<"node">>, Qs, node()),
|
NodeParam = proplists:get_value(<<"node">>, Qs, <<"all">>),
|
||||||
case {From, To} of
|
case {From, To} of
|
||||||
{undefined, _} -> handler_utils:send_error(Req1, 400, <<"Missing from">>);
|
{undefined, _} -> handler_utils:send_error(Req1, 400, <<"Missing from">>);
|
||||||
{_, undefined} -> handler_utils:send_error(Req1, 400, <<"Missing to">>);
|
{_, undefined} -> handler_utils:send_error(Req1, 400, <<"Missing to">>);
|
||||||
{F, T} ->
|
{F, T} ->
|
||||||
Metrics = core_node_metric:get_by_timerange(F, T, Node),
|
Metrics = case NodeParam of
|
||||||
Json = [metric_to_map(M) || M <- Metrics],
|
<<"all">> ->
|
||||||
|
% Локальные метрики текущего узла
|
||||||
|
Local = core_node_metric:get_by_timerange(F, T, node()),
|
||||||
|
% Остальные узлы кластера
|
||||||
|
OtherNodes = mnesia:system_info(db_nodes) -- [node()],
|
||||||
|
io:format("[NODE_METRICS] Aggregating metrics from ~p (local) and remote nodes: ~p~n", [node(), OtherNodes]),
|
||||||
|
Remote = lists:flatmap(fun(N) ->
|
||||||
|
io:format("[NODE_METRICS] Requesting metrics from node ~p...~n", [N]),
|
||||||
|
try rpc:call(N, core_node_metric, get_by_timerange, [F, T, N], 5000) of
|
||||||
|
List when is_list(List) ->
|
||||||
|
io:format("[NODE_METRICS] Received ~p metrics from node ~p~n", [length(List), N]),
|
||||||
|
List;
|
||||||
|
Other ->
|
||||||
|
io:format("[NODE_METRICS] Unexpected response from node ~p: ~p~n", [N, Other]),
|
||||||
|
[]
|
||||||
|
catch
|
||||||
|
Class:Reason:Stacktrace ->
|
||||||
|
io:format("[NODE_METRICS] RPC error for node ~p: ~p:~p~nStacktrace: ~p~n", [N, Class, Reason, Stacktrace]),
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end, OtherNodes),
|
||||||
|
Local ++ Remote;
|
||||||
|
SpecificNode ->
|
||||||
|
core_node_metric:get_by_timerange(F, T, SpecificNode)
|
||||||
|
end,
|
||||||
|
Sorted = lists:sort(fun(A, B) -> A#node_metric.timestamp =< B#node_metric.timestamp end, Metrics),
|
||||||
|
Json = [metric_to_map(M) || M <- Sorted],
|
||||||
handler_utils:send_json(Req1, 200, Json)
|
handler_utils:send_json(Req1, 200, Json)
|
||||||
end;
|
end;
|
||||||
{error, Code, Msg, Req1} ->
|
{error, Code, Msg, Req1} ->
|
||||||
handler_utils:send_error(Req1, Code, Msg)
|
handler_utils:send_error(Req1, Code, Msg)
|
||||||
end.
|
end.
|
||||||
|
|
||||||
%% @private Преобразует запись метрики в JSON-совместимую карту.
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Преобразует запись `#node_metric{}` в JSON-совместимый map.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
-spec metric_to_map(#node_metric{}) -> map().
|
-spec metric_to_map(#node_metric{}) -> map().
|
||||||
metric_to_map(M) ->
|
metric_to_map(M) ->
|
||||||
#{
|
#{
|
||||||
@@ -150,6 +104,7 @@ metric_to_map(M) ->
|
|||||||
<<"memory_atom">> => M#node_metric.memory_atom,
|
<<"memory_atom">> => M#node_metric.memory_atom,
|
||||||
<<"memory_binary">> => M#node_metric.memory_binary,
|
<<"memory_binary">> => M#node_metric.memory_binary,
|
||||||
<<"memory_ets">> => M#node_metric.memory_ets,
|
<<"memory_ets">> => M#node_metric.memory_ets,
|
||||||
|
<<"memory_available">> => M#node_metric.memory_available,
|
||||||
<<"process_count">> => M#node_metric.process_count,
|
<<"process_count">> => M#node_metric.process_count,
|
||||||
<<"run_queue">> => M#node_metric.run_queue,
|
<<"run_queue">> => M#node_metric.run_queue,
|
||||||
<<"mnesia_commits">> => M#node_metric.mnesia_commits,
|
<<"mnesia_commits">> => M#node_metric.mnesia_commits,
|
||||||
@@ -158,5 +113,63 @@ metric_to_map(M) ->
|
|||||||
<<"active_sessions">> => M#node_metric.active_sessions,
|
<<"active_sessions">> => M#node_metric.active_sessions,
|
||||||
<<"ws_connections">> => M#node_metric.ws_connections,
|
<<"ws_connections">> => M#node_metric.ws_connections,
|
||||||
<<"io_in">> => M#node_metric.io_in,
|
<<"io_in">> => M#node_metric.io_in,
|
||||||
<<"io_out">> => M#node_metric.io_out
|
<<"io_out">> => M#node_metric.io_out,
|
||||||
|
<<"cpu_utilization">> => M#node_metric.cpu_utilization
|
||||||
|
}.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Возвращает Swagger-трассы для модуля.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec trails() -> [map()].
|
||||||
|
trails() ->
|
||||||
|
[#{path => <<"/v1/admin/nodes/metrics">>,
|
||||||
|
method => <<"GET">>,
|
||||||
|
description => <<"Get historical node metrics (aggregated from all nodes by default)">>,
|
||||||
|
tags => [<<"Node Metrics">>],
|
||||||
|
parameters => [
|
||||||
|
#{name => <<"node">>, in => <<"query">>, schema => #{type => string},
|
||||||
|
description => <<"Node name or 'all' (default). When 'all', returns metrics from every cluster node.">>},
|
||||||
|
#{name => <<"from">>, in => <<"query">>, required => true,
|
||||||
|
schema => #{type => string, format => <<"date-time">>},
|
||||||
|
description => <<"Start timestamp in ISO8601 format (milliseconds are supported)">>},
|
||||||
|
#{name => <<"to">>, in => <<"query">>, required => true,
|
||||||
|
schema => #{type => string, format => <<"date-time">>},
|
||||||
|
description => <<"End timestamp in ISO8601 format (milliseconds are supported)">>}
|
||||||
|
],
|
||||||
|
responses => #{200 => #{description => <<"Array of node metrics">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => #{
|
||||||
|
type => array,
|
||||||
|
items => metric_schema()
|
||||||
|
}}}}}}
|
||||||
|
].
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Swagger-схема для одного объекта метрики.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec metric_schema() -> map().
|
||||||
|
metric_schema() ->
|
||||||
|
#{
|
||||||
|
type => object,
|
||||||
|
properties => #{
|
||||||
|
<<"timestamp">> => #{type => string, format => <<"date-time">>, description => <<"Timestamp of the metric sample (ISO8601)">>},
|
||||||
|
<<"node">> => #{type => string, description => <<"Erlang node name">>},
|
||||||
|
<<"memory_total">> => #{type => integer, description => <<"Total memory used by Erlang VM (bytes)">>},
|
||||||
|
<<"memory_processes">> => #{type => integer, description => <<"Memory used by processes (bytes)">>},
|
||||||
|
<<"memory_atom">> => #{type => integer, description => <<"Memory used for atoms (bytes)">>},
|
||||||
|
<<"memory_binary">> => #{type => integer, description => <<"Memory used for binary data (bytes)">>},
|
||||||
|
<<"memory_ets">> => #{type => integer, description => <<"Memory used by ETS tables (bytes)">>},
|
||||||
|
<<"memory_available">> => #{type => integer, description => <<"Available system memory (bytes), 0 if unavailable">>},
|
||||||
|
<<"process_count">> => #{type => integer, description => <<"Number of Erlang processes">>},
|
||||||
|
<<"run_queue">> => #{type => integer, description => <<"Run queue length (0 means idle)">>},
|
||||||
|
<<"mnesia_commits">> => #{type => integer, description => <<"Mnesia transaction commits since boot">>},
|
||||||
|
<<"mnesia_failures">> => #{type => integer, description => <<"Mnesia transaction failures since boot">>},
|
||||||
|
<<"table_sizes">> => #{type => object, description => <<"Map of Mnesia table names to record counts">>, additionalProperties => #{type => integer}},
|
||||||
|
<<"active_sessions">> => #{type => integer, description => <<"Number of active user/admin sessions">>},
|
||||||
|
<<"ws_connections">> => #{type => integer, description => <<"Number of active WebSocket connections">>},
|
||||||
|
<<"io_in">> => #{type => integer, description => <<"Delta of bytes received since last sample">>},
|
||||||
|
<<"io_out">> => #{type => integer, description => <<"Delta of bytes sent since last sample">>},
|
||||||
|
<<"cpu_utilization">> => #{type => number, format => float, description => <<"CPU utilization (0.0-100.0 percent)">>}
|
||||||
|
}
|
||||||
}.
|
}.
|
||||||
@@ -109,7 +109,7 @@ update_report(Req) ->
|
|||||||
case logic_report:update_report_status(AdminId, ReportId, StatusAtom) of
|
case logic_report:update_report_status(AdminId, ReportId, StatusAtom) of
|
||||||
{ok, Report} ->
|
{ok, Report} ->
|
||||||
% Аудит изменения статуса жалобы
|
% Аудит изменения статуса жалобы
|
||||||
admin_utils:log_admin_action(AdminId, <<"update_report_status">>, <<"report">>, ReportId, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update_report_status">>, <<"report">>, ReportId, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:report_to_json(Report));
|
handler_utils:send_json(Req2, 200, handler_utils:report_to_json(Report));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Report not found">>);
|
handler_utils:send_error(Req2, 404, <<"Report not found">>);
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ bulk_update_reviews(Req) ->
|
|||||||
true = is_list(Operations),
|
true = is_list(Operations),
|
||||||
case logic_review:bulk_update_status(Operations, Reason) of
|
case logic_review:bulk_update_status(Operations, Reason) of
|
||||||
{ok, UpdatedCount} ->
|
{ok, UpdatedCount} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"bulk_update_reviews">>, <<"review">>, UpdatedCount, Req2),
|
admin_utils:log_admin_action(AdminId, <<"bulk_update_reviews">>, <<"review">>, UpdatedCount, Reason, Req2),
|
||||||
handler_utils:send_json(Req2, 200, #{updated_count => UpdatedCount});
|
handler_utils:send_json(Req2, 200, #{updated_count => UpdatedCount});
|
||||||
{error, ReasonErr} ->
|
{error, ReasonErr} ->
|
||||||
handler_utils:send_error(Req2, 400, ReasonErr)
|
handler_utils:send_error(Req2, 400, ReasonErr)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ update_review(Req) ->
|
|||||||
case logic_review:update_review_admin(ReviewId, Converted) of
|
case logic_review:update_review_admin(ReviewId, Converted) of
|
||||||
{ok, Review} ->
|
{ok, Review} ->
|
||||||
% Аудит изменения отзыва
|
% Аудит изменения отзыва
|
||||||
admin_utils:log_admin_action(AdminId, <<"update_review">>, <<"review">>, ReviewId, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update_review">>, <<"review">>, ReviewId, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review));
|
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Review));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Review not found">>);
|
handler_utils:send_error(Req2, 404, <<"Review not found">>);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ update_subscription(Req) ->
|
|||||||
Data when is_map(Data) ->
|
Data when is_map(Data) ->
|
||||||
case core_subscription:update_subscription(Id, Data) of
|
case core_subscription:update_subscription(Id, Data) of
|
||||||
{ok, Updated} ->
|
{ok, Updated} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"update_subscription">>, <<"subscription">>, Id, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update_subscription">>, <<"subscription">>, Id, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:subscription_to_json(Updated));
|
handler_utils:send_json(Req2, 200, handler_utils:subscription_to_json(Updated));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Subscription not found">>);
|
handler_utils:send_error(Req2, 404, <<"Subscription not found">>);
|
||||||
@@ -142,7 +142,7 @@ delete_subscription(Req) ->
|
|||||||
Id = cowboy_req:binding(id, Req1),
|
Id = cowboy_req:binding(id, Req1),
|
||||||
case core_subscription:delete_subscription(Id) of
|
case core_subscription:delete_subscription(Id) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"delete_subscription">>, <<"subscription">>, Id, Req1),
|
admin_utils:log_admin_action(AdminId, <<"delete_subscription">>, <<"subscription">>, Id, <<>>, Req1),
|
||||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"Subscription not found">>);
|
handler_utils:send_error(Req1, 404, <<"Subscription not found">>);
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ update_ticket(Req) ->
|
|||||||
Result = apply_ticket_changes(AdminId, TicketId, Data),
|
Result = apply_ticket_changes(AdminId, TicketId, Data),
|
||||||
case Result of
|
case Result of
|
||||||
{ok, Ticket} ->
|
{ok, Ticket} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"update_ticket">>, <<"ticket">>, TicketId, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update_ticket">>, <<"ticket">>, TicketId, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket));
|
handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Ticket not found">>);
|
handler_utils:send_error(Req2, 404, <<"Ticket not found">>);
|
||||||
@@ -157,7 +157,7 @@ delete_ticket(Req) ->
|
|||||||
TicketId = cowboy_req:binding(id, Req1),
|
TicketId = cowboy_req:binding(id, Req1),
|
||||||
case logic_ticket:delete_ticket(AdminId, TicketId) of
|
case logic_ticket:delete_ticket(AdminId, TicketId) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"delete_ticket">>, <<"ticket">>, TicketId, Req1),
|
admin_utils:log_admin_action(AdminId, <<"delete_ticket">>, <<"ticket">>, TicketId, <<>>, Req1),
|
||||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"Ticket not found">>);
|
handler_utils:send_error(Req1, 404, <<"Ticket not found">>);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ update_ticket(Req) ->
|
|||||||
Result = apply_ticket_changes(AdminId, TicketId, Data),
|
Result = apply_ticket_changes(AdminId, TicketId, Data),
|
||||||
case Result of
|
case Result of
|
||||||
{ok, Ticket} ->
|
{ok, Ticket} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"update_ticket">>, <<"ticket">>, TicketId, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update_ticket">>, <<"ticket">>, TicketId, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket));
|
handler_utils:send_json(Req2, 200, handler_utils:ticket_to_json(Ticket));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Ticket not found">>);
|
handler_utils:send_error(Req2, 404, <<"Ticket not found">>);
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ update_user(Req) ->
|
|||||||
Converted = convert_user_fields(Updates),
|
Converted = convert_user_fields(Updates),
|
||||||
case logic_user:update_user_admin(UserId, Converted) of
|
case logic_user:update_user_admin(UserId, Converted) of
|
||||||
{ok, User} ->
|
{ok, User} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"update_user">>, <<"user">>, UserId, Req2),
|
admin_utils:log_admin_action(AdminId, <<"update_user">>, <<"user">>, UserId, <<>>, Req2),
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
|
handler_utils:send_json(Req2, 200, handler_utils:user_to_json(User));
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"User not found">>);
|
handler_utils:send_error(Req2, 404, <<"User not found">>);
|
||||||
@@ -151,7 +151,7 @@ delete_user(Req) ->
|
|||||||
UserId = cowboy_req:binding(id, Req1),
|
UserId = cowboy_req:binding(id, Req1),
|
||||||
case logic_user:delete_user_admin(UserId) of
|
case logic_user:delete_user_admin(UserId) of
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
admin_utils:log_admin_action(AdminId, <<"delete_user">>, <<"user">>, UserId, Req1),
|
admin_utils:log_admin_action(AdminId, <<"delete_user">>, <<"user">>, UserId, <<>>, Req1),
|
||||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req1, 404, <<"User not found">>);
|
handler_utils:send_error(Req1, 404, <<"User not found">>);
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ websocket_init(State) ->
|
|||||||
io:format("[ADMIN_WS] WebSocket initialized for admin ~s~n", [State#state.admin_id]),
|
io:format("[ADMIN_WS] WebSocket initialized for admin ~s~n", [State#state.admin_id]),
|
||||||
pg:join(eventhub_admin_ws, self()),
|
pg:join(eventhub_admin_ws, self()),
|
||||||
% Подписываемся на метрики узлов
|
% Подписываемся на метрики узлов
|
||||||
pg:join(node_metrics, self()),
|
%% pg:join(node_metrics, self()),
|
||||||
|
pg:join(global, node_metrics, self()),
|
||||||
{ok, State}.
|
{ok, State}.
|
||||||
|
|
||||||
%% @doc Обрабатывает входящие текстовые сообщения (subscribe/unsubscribe/ping).
|
%% @doc Обрабатывает входящие текстовые сообщения (subscribe/unsubscribe/ping).
|
||||||
@@ -143,5 +144,7 @@ metric_to_map(M) ->
|
|||||||
<<"active_sessions">> => M#node_metric.active_sessions,
|
<<"active_sessions">> => M#node_metric.active_sessions,
|
||||||
<<"ws_connections">> => M#node_metric.ws_connections,
|
<<"ws_connections">> => M#node_metric.ws_connections,
|
||||||
<<"io_in">> => M#node_metric.io_in,
|
<<"io_in">> => M#node_metric.io_in,
|
||||||
<<"io_out">> => M#node_metric.io_out
|
<<"io_out">> => M#node_metric.io_out,
|
||||||
|
<<"memory_available">> => M#node_metric.memory_available,
|
||||||
|
<<"cpu_utilization">> => M#node_metric.cpu_utilization
|
||||||
}.
|
}.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
-export([is_admin/1, check_role/2, get_permissions/1, is_superadmin/1]).
|
-export([is_admin/1, check_role/2, get_permissions/1, is_superadmin/1]).
|
||||||
-export([client_ip/1]). % ← IP
|
-export([client_ip/1]). % ← IP
|
||||||
-export([log_admin_action/5]). % ← аудит действий администратора
|
-export([log_admin_action/6]). % ← аудит действий администратора
|
||||||
-export([ip_to_binary/1]). % ← преобразование IP
|
-export([ip_to_binary/1]). % ← преобразование IP
|
||||||
|
|
||||||
%% -- существующие функции -------------------------------------------------
|
%% -- существующие функции -------------------------------------------------
|
||||||
@@ -71,14 +71,34 @@ ip_to_binary(_) ->
|
|||||||
%% EntityType – тип сущности (напр. <<"admin">>, <<"banned_word">>)
|
%% EntityType – тип сущности (напр. <<"admin">>, <<"banned_word">>)
|
||||||
%% EntityId – идентификатор сущности
|
%% EntityId – идентификатор сущности
|
||||||
%% Req – cowboy_req для извлечения IP
|
%% Req – cowboy_req для извлечения IP
|
||||||
-spec log_admin_action(binary(), binary(), binary(), term(), cowboy_req:req()) -> ok.
|
%%-spec log_admin_action(binary(), binary(), binary(), term(), cowboy_req:req()) -> ok.
|
||||||
log_admin_action(AdminId, Action, EntityType, EntityId, Req) ->
|
%%log_admin_action(AdminId, Action, EntityType, EntityId, Req) ->
|
||||||
|
%% case core_admin:get_by_id(AdminId) of
|
||||||
|
%% {ok, Admin} ->
|
||||||
|
%% Email = Admin#admin.email,
|
||||||
|
%% Role = atom_to_binary(Admin#admin.role, utf8),
|
||||||
|
%% Ip = ip_to_binary(cowboy_req:peer(Req)),
|
||||||
|
%% core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip),
|
||||||
|
%% ok;
|
||||||
|
%% _ -> ok
|
||||||
|
%% end.
|
||||||
|
|
||||||
|
%% @doc Логирует действие администратора.
|
||||||
|
%% Параметры:
|
||||||
|
%% AdminId – ID администратора, выполнившего действие
|
||||||
|
%% Action – бинарная строка действия (напр. <<"create">>)
|
||||||
|
%% EntityType – тип сущности (напр. <<"admin">>, <<"banned_word">>)
|
||||||
|
%% EntityId – идентификатор сущности
|
||||||
|
%% Reason - причина
|
||||||
|
%% Req – cowboy_req для извлечения IP
|
||||||
|
-spec log_admin_action(binary(), binary(), binary(), term(), binary(), cowboy_req:req()) -> ok.
|
||||||
|
log_admin_action(AdminId, Action, EntityType, EntityId, Reason, Req) ->
|
||||||
case core_admin:get_by_id(AdminId) of
|
case core_admin:get_by_id(AdminId) of
|
||||||
{ok, Admin} ->
|
{ok, Admin} ->
|
||||||
Email = Admin#admin.email,
|
Email = Admin#admin.email,
|
||||||
Role = atom_to_binary(Admin#admin.role, utf8),
|
Role = atom_to_binary(Admin#admin.role, utf8),
|
||||||
Ip = ip_to_binary(cowboy_req:peer(Req)),
|
Ip = ip_to_binary(cowboy_req:peer(Req)),
|
||||||
core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip),
|
core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason),
|
||||||
ok;
|
ok;
|
||||||
_ -> ok
|
_ -> ok
|
||||||
end.
|
end.
|
||||||
@@ -62,6 +62,22 @@ handle_call(init_tables, _From, State) ->
|
|||||||
ok = join_cluster(ExtraNodes)
|
ok = join_cluster(ExtraNodes)
|
||||||
end,
|
end,
|
||||||
lists:foreach(fun create_table/1, ?TABLES),
|
lists:foreach(fun create_table/1, ?TABLES),
|
||||||
|
% Принудительное создание node_metric на каждом узле
|
||||||
|
case mnesia:create_table(node_metric, [
|
||||||
|
{disc_copies, [node()]}, % хранить на диске
|
||||||
|
{local_content, true}, % не реплицировать
|
||||||
|
{attributes, record_info(fields, node_metric)}
|
||||||
|
]) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, node_metric}} -> ok;
|
||||||
|
_ -> ok
|
||||||
|
end,
|
||||||
|
% ГАРАНТИРУЕМ, что узел имеет локальную копию node_metric (критично для присоединяющихся узлов)
|
||||||
|
case lists:member(node(), mnesia:table_info(node_metric, disc_copies) ++
|
||||||
|
mnesia:table_info(node_metric, ram_copies)) of
|
||||||
|
false -> mnesia:add_table_copy(node_metric, node(), disc_copies);
|
||||||
|
true -> ok
|
||||||
|
end,
|
||||||
ok = create_indices(),
|
ok = create_indices(),
|
||||||
ok = stats_collector:subscribe(),
|
ok = stats_collector:subscribe(),
|
||||||
ok = start_cleanup_timer(),
|
ok = start_cleanup_timer(),
|
||||||
@@ -223,7 +239,7 @@ table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_in
|
|||||||
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
|
||||||
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}];
|
||||||
table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}];
|
table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}];
|
||||||
table_opts(node_metric) -> [{ram_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}].
|
table_opts(node_metric) -> [{disc_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}].
|
||||||
|
|
||||||
%% ===================================================================
|
%% ===================================================================
|
||||||
%% Индексы
|
%% Индексы
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ init([]) ->
|
|||||||
start => {pg, start_link, []},
|
start => {pg, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
type => worker},
|
type => worker},
|
||||||
|
#{id => pg_global,
|
||||||
|
start => {pg, start_link, [global]},
|
||||||
|
restart => permanent,
|
||||||
|
type => worker},
|
||||||
#{id => stats_collector,
|
#{id => stats_collector,
|
||||||
start => {stats_collector, start_link, []},
|
start => {stats_collector, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ start_link() ->
|
|||||||
|
|
||||||
-spec init(term()) -> {ok, map()}.
|
-spec init(term()) -> {ok, map()}.
|
||||||
init([]) ->
|
init([]) ->
|
||||||
|
mnesia:wait_for_tables([node_metric], 5000),
|
||||||
% Создаём ETS-таблицу для кеширования предыдущих значений io
|
% Создаём ETS-таблицу для кеширования предыдущих значений io
|
||||||
ets:new(?IO_CACHE_TAB, [named_table, public, set, {keypos, 1}]),
|
ets:new(?IO_CACHE_TAB, [named_table, public, set, {keypos, 1}]),
|
||||||
% Запускаем периодический сбор метрик и очистку
|
% Запускаем периодический сбор метрик и очистку
|
||||||
@@ -58,7 +59,9 @@ handle_info(collect, State) ->
|
|||||||
Metric = collect_metrics(),
|
Metric = collect_metrics(),
|
||||||
core_node_metric:save(Metric),
|
core_node_metric:save(Metric),
|
||||||
% Рассылаем метрику всем подписчикам группы node_metrics
|
% Рассылаем метрику всем подписчикам группы node_metrics
|
||||||
Members = pg:get_local_members(?METRICS_GROUP),
|
%% Members = pg:get_local_members(?METRICS_GROUP),
|
||||||
|
% Рассылаем метрику всем подписчикам глобальной группы node_metrics
|
||||||
|
Members = pg:get_members(global, node_metrics),
|
||||||
[Pid ! {node_metric, Metric} || Pid <- Members],
|
[Pid ! {node_metric, Metric} || Pid <- Members],
|
||||||
schedule_collect(),
|
schedule_collect(),
|
||||||
{noreply, State};
|
{noreply, State};
|
||||||
@@ -88,6 +91,8 @@ collect_metrics() ->
|
|||||||
Now = calendar:universal_time(),
|
Now = calendar:universal_time(),
|
||||||
Mem = erlang:memory(),
|
Mem = erlang:memory(),
|
||||||
{IoIn, IoOut} = io_stats(),
|
{IoIn, IoOut} = io_stats(),
|
||||||
|
Cpu = cpu_utilization(),
|
||||||
|
FreeMem = free_memory(),
|
||||||
#node_metric{
|
#node_metric{
|
||||||
timestamp = Now,
|
timestamp = Now,
|
||||||
node = node(),
|
node = node(),
|
||||||
@@ -104,7 +109,9 @@ collect_metrics() ->
|
|||||||
active_sessions = session_count(),
|
active_sessions = session_count(),
|
||||||
ws_connections = ws_count(),
|
ws_connections = ws_count(),
|
||||||
io_in = IoIn,
|
io_in = IoIn,
|
||||||
io_out = IoOut
|
io_out = IoOut,
|
||||||
|
memory_available = FreeMem,
|
||||||
|
cpu_utilization = Cpu
|
||||||
}.
|
}.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
@@ -165,7 +172,71 @@ io_stats() ->
|
|||||||
TotalOut = lists:sum([send_oct(Port) || Port <- Ports]),
|
TotalOut = lists:sum([send_oct(Port) || Port <- Ports]),
|
||||||
{PrevIn, PrevOut} = get_prev_io(),
|
{PrevIn, PrevOut} = get_prev_io(),
|
||||||
ets:insert(?IO_CACHE_TAB, [{in, TotalIn}, {out, TotalOut}]),
|
ets:insert(?IO_CACHE_TAB, [{in, TotalIn}, {out, TotalOut}]),
|
||||||
{TotalIn - PrevIn, TotalOut - PrevOut}.
|
DeltaIn = max(0, TotalIn - PrevIn),
|
||||||
|
DeltaOut = max(0, TotalOut - PrevOut),
|
||||||
|
{DeltaIn, DeltaOut}.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Возвращает объём свободной системной памяти (в байтах).
|
||||||
|
%%% Безопасно при отсутствии поддержки в ОС.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec free_memory() -> non_neg_integer().
|
||||||
|
free_memory() ->
|
||||||
|
try erlang:system_info(os_free_memory) of
|
||||||
|
Free when Free > 0 -> Free;
|
||||||
|
_ -> read_meminfo()
|
||||||
|
catch
|
||||||
|
_:_ -> read_meminfo()
|
||||||
|
end.
|
||||||
|
|
||||||
|
read_meminfo() ->
|
||||||
|
try
|
||||||
|
{ok, Binary} = file:read_file("/proc/meminfo"),
|
||||||
|
Content = binary_to_list(Binary),
|
||||||
|
case re:run(Content, "MemAvailable:\\s+(\\d+) kB", [{capture, [1], list}]) of
|
||||||
|
{match, [MemKbStr]} -> list_to_integer(MemKbStr) * 1024; % кБ -> байты
|
||||||
|
_ -> 0
|
||||||
|
end
|
||||||
|
catch _:_ -> 0
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Вычисляет утилизацию CPU (в процентах) за интервал сбора.
|
||||||
|
%%% Использует статистику /proc/stat.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec cpu_utilization() -> float().
|
||||||
|
cpu_utilization() ->
|
||||||
|
try
|
||||||
|
{ok, Binary} = file:read_file("/proc/stat"),
|
||||||
|
Content = binary_to_list(Binary),
|
||||||
|
{match, [TotalStr]} = re:run(Content, "^cpu\\s+(.*)$", [{capture, [1], list}, multiline]),
|
||||||
|
Tokens = string:tokens(TotalStr, " "),
|
||||||
|
[User, Nice, System, Idle, Iowait, Irq, Softirq, Steal | _] = lists:map(fun list_to_integer/1, Tokens),
|
||||||
|
Total = User + Nice + System + Idle + Iowait + Irq + Softirq + Steal,
|
||||||
|
IdleTotal = Idle + Iowait,
|
||||||
|
|
||||||
|
% Получаем предыдущие значения безопасным способом
|
||||||
|
{PrevIdle, PrevTotal} = case ets:lookup(?IO_CACHE_TAB, cpu_stats) of
|
||||||
|
[{cpu_stats, PI, PT}] -> {PI, PT};
|
||||||
|
[] -> {0, 0}
|
||||||
|
end,
|
||||||
|
ets:insert(?IO_CACHE_TAB, [{cpu_stats, IdleTotal, Total}]),
|
||||||
|
|
||||||
|
case PrevTotal of
|
||||||
|
0 -> 0.0; % первый замер, нет данных для расчёта
|
||||||
|
_ ->
|
||||||
|
DeltaTotal = Total - PrevTotal,
|
||||||
|
DeltaIdle = IdleTotal - PrevIdle,
|
||||||
|
if DeltaTotal > 0 ->
|
||||||
|
(1.0 - DeltaIdle / DeltaTotal) * 100.0;
|
||||||
|
true -> 0.0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
_:_ -> 0.0
|
||||||
|
end.
|
||||||
|
|
||||||
-spec get_prev_io() -> {non_neg_integer(), non_neg_integer()}.
|
-spec get_prev_io() -> {non_neg_integer(), non_neg_integer()}.
|
||||||
get_prev_io() ->
|
get_prev_io() ->
|
||||||
|
|||||||
@@ -105,9 +105,10 @@ filter_accessible_events(Events, UserId) ->
|
|||||||
CanAccess = logic_calendar:can_access(UserId, Calendar),
|
CanAccess = logic_calendar:can_access(UserId, Calendar),
|
||||||
case CanAccess of
|
case CanAccess of
|
||||||
false ->
|
false ->
|
||||||
io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n",
|
%% io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n",
|
||||||
[UserId, Calendar#calendar.id, Calendar#calendar.type,
|
%% [UserId, Calendar#calendar.id, Calendar#calendar.type,
|
||||||
Calendar#calendar.owner_id, Calendar#calendar.status]);
|
%% Calendar#calendar.owner_id, Calendar#calendar.status]);
|
||||||
|
false;
|
||||||
true -> ok
|
true -> ok
|
||||||
end,
|
end,
|
||||||
CanAccess;
|
CanAccess;
|
||||||
|
|||||||
@@ -90,19 +90,26 @@ test_add_word(Token) ->
|
|||||||
NewWord = <<"newbadword", Unique/binary>>,
|
NewWord = <<"newbadword", Unique/binary>>,
|
||||||
Result = api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => NewWord}),
|
Result = api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => NewWord}),
|
||||||
?assertEqual(<<"added">>, maps:get(<<"status">>, Result)),
|
?assertEqual(<<"added">>, maps:get(<<"status">>, Result)),
|
||||||
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
|
% Проверяем, что слово найдено через поиск по точному совпадению
|
||||||
?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= NewWord end, Words)),
|
Found = api_test_runner:admin_get(<<"/v1/admin/banned-words?q=", NewWord/binary>>, Token),
|
||||||
|
?assertEqual(1, length(Found)),
|
||||||
|
?assertEqual(NewWord, maps:get(<<"word">>, hd(Found))),
|
||||||
ct:pal(" OK").
|
ct:pal(" OK").
|
||||||
|
|
||||||
%% @doc PUT /v1/admin/banned-words/:word – редактирование слова.
|
%% @doc PUT /v1/admin/banned-words/:word – редактирование слова.
|
||||||
test_update_word(Token, Word) ->
|
test_update_word(Token, Word) ->
|
||||||
ct:pal(" TEST: Update a banned word"),
|
ct:pal(" TEST: Update a banned word"),
|
||||||
NewWord = <<Word/binary, "_updated">>,
|
NewWord = <<Word/binary, "_updated">>,
|
||||||
Result = api_test_runner:admin_put(<<"/v1/admin/banned-words/", Word/binary>>, Token, #{<<"word">> => NewWord}),
|
Path = <<"/v1/admin/banned-words/", Word/binary>>,
|
||||||
|
Result = api_test_runner:admin_put(Path, Token, #{<<"word">> => NewWord}),
|
||||||
?assertEqual(<<"updated">>, maps:get(<<"status">>, Result)),
|
?assertEqual(<<"updated">>, maps:get(<<"status">>, Result)),
|
||||||
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
|
% Проверяем, что новое слово существует (поиск возвращает ровно 1 запись)
|
||||||
?assertNot(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, Words)),
|
NewSearch = api_test_runner:admin_get(<<"/v1/admin/banned-words?q=", NewWord/binary>>, Token),
|
||||||
?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= NewWord end, Words)),
|
?assertEqual(1, length(NewSearch)),
|
||||||
|
?assertEqual(NewWord, maps:get(<<"word">>, hd(NewSearch))),
|
||||||
|
% Проверяем, что старое слово отсутствует: все найденные слова не равны старому
|
||||||
|
OldSearch = api_test_runner:admin_get(<<"/v1/admin/banned-words?q=", Word/binary>>, Token),
|
||||||
|
?assert(lists:all(fun(W) -> maps:get(<<"word">>, W) =/= Word end, OldSearch)),
|
||||||
ct:pal(" OK").
|
ct:pal(" OK").
|
||||||
|
|
||||||
%% @doc POST /v1/admin/banned-words/batch – массовая загрузка.
|
%% @doc POST /v1/admin/banned-words/batch – массовая загрузка.
|
||||||
|
|||||||
@@ -224,8 +224,13 @@ test_calendar_stats(Token) ->
|
|||||||
|
|
||||||
test_node_metrics_history(Token) ->
|
test_node_metrics_history(Token) ->
|
||||||
ct:pal(" TEST: Node metrics history"),
|
ct:pal(" TEST: Node metrics history"),
|
||||||
From = <<"2026-01-01T00:00:00Z">>,
|
Now = calendar:universal_time(),
|
||||||
To = <<"2026-12-31T23:59:59Z">>,
|
FromSec = calendar:datetime_to_gregorian_seconds(Now) - 300,
|
||||||
|
ToSec = calendar:datetime_to_gregorian_seconds(Now) + 300,
|
||||||
|
FromDT = calendar:gregorian_seconds_to_datetime(FromSec),
|
||||||
|
ToDT = calendar:gregorian_seconds_to_datetime(ToSec),
|
||||||
|
From = handler_utils:datetime_to_iso8601(FromDT),
|
||||||
|
To = handler_utils:datetime_to_iso8601(ToDT),
|
||||||
Path = <<"/v1/admin/nodes/metrics?from=", From/binary, "&to=", To/binary>>,
|
Path = <<"/v1/admin/nodes/metrics?from=", From/binary, "&to=", To/binary>>,
|
||||||
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
|
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
|
||||||
Metrics = jsx:decode(list_to_binary(Body), [return_maps]),
|
Metrics = jsx:decode(list_to_binary(Body), [return_maps]),
|
||||||
@@ -237,4 +242,6 @@ test_node_metrics_history(Token) ->
|
|||||||
?assert(maps:is_key(<<"timestamp">>, First)),
|
?assert(maps:is_key(<<"timestamp">>, First)),
|
||||||
?assert(maps:is_key(<<"node">>, First)),
|
?assert(maps:is_key(<<"node">>, First)),
|
||||||
?assert(maps:is_key(<<"memory_total">>, First)),
|
?assert(maps:is_key(<<"memory_total">>, First)),
|
||||||
|
?assert(maps:is_key(<<"memory_available">>, First)),
|
||||||
|
?assert(maps:is_key(<<"cpu_utilization">>, First)),
|
||||||
ct:pal(" OK: ~p metrics received", [length(Metrics)]).
|
ct:pal(" OK: ~p metrics received", [length(Metrics)]).
|
||||||
@@ -41,7 +41,7 @@ test() ->
|
|||||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, UserToken,
|
<<"/v1/calendars/", CalId/binary, "/events">>, UserToken,
|
||||||
#{title => <<"WS Test Event">>,
|
#{title => <<"WS Test Event">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60}),
|
duration => 60}),
|
||||||
|
|
||||||
WsUrl = api_test_runner:get_base_ws_url() ++ "/ws",
|
WsUrl = api_test_runner:get_base_ws_url() ++ "/ws",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
client_delete/2,
|
client_delete/2,
|
||||||
admin_patch/3]).
|
admin_patch/3]).
|
||||||
-export([verify_user/2]).
|
-export([verify_user/2]).
|
||||||
|
-export([future_date_iso8601/0]).
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Конфигурация окружения (CT_MODE, ...)
|
%%% Конфигурация окружения (CT_MODE, ...)
|
||||||
@@ -201,7 +202,7 @@ request(BaseUrl, Method, Path, Token, Body, Prefix) ->
|
|||||||
delete -> {URL, Headers};
|
delete -> {URL, Headers};
|
||||||
_ -> {URL, Headers, "application/json", Body}
|
_ -> {URL, Headers, "application/json", Body}
|
||||||
end,
|
end,
|
||||||
Response = httpc:request(Method, RequestArg, [{timeout, 15000}, {ssl, [{verify, verify_none}]}], []),
|
Response = httpc:request(Method, RequestArg, [{timeout, 25000}, {ssl, [{verify, verify_none}]}], []),
|
||||||
case Response of
|
case Response of
|
||||||
{ok, {{_, Status, _}, RespHeaders, RespBody}} ->
|
{ok, {{_, Status, _}, RespHeaders, RespBody}} ->
|
||||||
ct:pal("~s RESPONSE: ~p ~s", [Prefix, Status, RespBody]),
|
ct:pal("~s RESPONSE: ~p ~s", [Prefix, Status, RespBody]),
|
||||||
@@ -280,6 +281,13 @@ future_date() ->
|
|||||||
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
|
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
|
||||||
calendar:gregorian_seconds_to_datetime(Seconds).
|
calendar:gregorian_seconds_to_datetime(Seconds).
|
||||||
|
|
||||||
|
-spec future_date_iso8601() -> binary().
|
||||||
|
future_date_iso8601() ->
|
||||||
|
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
|
||||||
|
{{Y,M,D},{H,Min,S}} = calendar:gregorian_seconds_to_datetime(Seconds),
|
||||||
|
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
|
||||||
|
[Y, M, D, H, Min, S])).
|
||||||
|
|
||||||
-spec register_and_login(binary(), binary()) -> binary().
|
-spec register_and_login(binary(), binary()) -> binary().
|
||||||
register_and_login(Email, Password) ->
|
register_and_login(Email, Password) ->
|
||||||
% 1. Регистрируем пользователя
|
% 1. Регистрируем пользователя
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test() ->
|
|||||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||||
#{title => <<"Event to book">>,
|
#{title => <<"Event to book">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60}),
|
duration => 60}),
|
||||||
|
|
||||||
test_create_booking(ParticipantToken, EventId),
|
test_create_booking(ParticipantToken, EventId),
|
||||||
@@ -81,7 +81,7 @@ test_confirm_booking(OwnerToken, EventId) ->
|
|||||||
|
|
||||||
%% @doc Отмена бронирования участником: 200 OK.
|
%% @doc Отмена бронирования участником: 200 OK.
|
||||||
-spec test_cancel_booking(binary(), binary()) -> ok.
|
-spec test_cancel_booking(binary(), binary()) -> ok.
|
||||||
test_cancel_booking(OwnerToken, EventId) ->
|
test_cancel_booking(_OwnerToken, EventId) ->
|
||||||
ct:pal(" TEST: Cancel booking as participant"),
|
ct:pal(" TEST: Cancel booking as participant"),
|
||||||
% Создаём нового участника, у которого нет бронирований
|
% Создаём нового участника, у которого нет бронирований
|
||||||
CancelUserEmail = api_test_runner:unique_email(<<"canceluser">>),
|
CancelUserEmail = api_test_runner:unique_email(<<"canceluser">>),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ test() ->
|
|||||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
|
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
|
||||||
#{title => <<"Test Event">>,
|
#{title => <<"Test Event">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60}),
|
duration => 60}),
|
||||||
|
|
||||||
test_get_event(Token, EventId),
|
test_get_event(Token, EventId),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test_create_single_event(Token, CalId) ->
|
|||||||
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
|
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
|
||||||
Body = jsx:encode(#{
|
Body = jsx:encode(#{
|
||||||
title => <<"Single Event">>,
|
title => <<"Single Event">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60
|
duration => 60
|
||||||
}),
|
}),
|
||||||
Resp = api_test_runner:client_request(post, Path, Token, Body),
|
Resp = api_test_runner:client_request(post, Path, Token, Body),
|
||||||
@@ -58,7 +58,7 @@ test_create_recurring_event(Token, CalId) ->
|
|||||||
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
|
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
|
||||||
Body = jsx:encode(#{
|
Body = jsx:encode(#{
|
||||||
title => <<"Weekly Meeting">>,
|
title => <<"Weekly Meeting">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60,
|
duration => 60,
|
||||||
recurrence => #{
|
recurrence => #{
|
||||||
freq => <<"WEEKLY">>,
|
freq => <<"WEEKLY">>,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ test() ->
|
|||||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||||
#{title => <<"Event for my booking">>,
|
#{title => <<"Event for my booking">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60}),
|
duration => 60}),
|
||||||
|
|
||||||
% Бронируем событие от имени участника и подтверждаем
|
% Бронируем событие от имени участника и подтверждаем
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ test() ->
|
|||||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||||
#{title => <<"Event for my review">>,
|
#{title => <<"Event for my review">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60}),
|
duration => 60}),
|
||||||
|
|
||||||
% Бронируем, подтверждаем, оставляем отзыв
|
% Бронируем, подтверждаем, оставляем отзыв
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test() ->
|
|||||||
#{<<"id">> := RecurringId} = api_test_runner:client_post(
|
#{<<"id">> := RecurringId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||||
#{title => <<"Weekly Standup">>,
|
#{title => <<"Weekly Standup">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 30,
|
duration => 30,
|
||||||
recurrence => #{freq => <<"WEEKLY">>, interval => 1}}),
|
recurrence => #{freq => <<"WEEKLY">>, interval => 1}}),
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ test_cancel_occurrence_on_single_event(Token, CalId) ->
|
|||||||
#{<<"id">> := SingleId} = api_test_runner:client_post(
|
#{<<"id">> := SingleId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
|
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
|
||||||
#{title => <<"Single">>,
|
#{title => <<"Single">>,
|
||||||
start_time => <<"2026-06-02T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 30}),
|
duration => 30}),
|
||||||
Path = <<"/v1/events/", SingleId/binary, "/occurrences/2026-06-02T10:00:00Z">>,
|
Path = <<"/v1/events/", SingleId/binary, "/occurrences/2026-06-02T10:00:00Z">>,
|
||||||
Resp = api_test_runner:client_request(delete, Path, Token),
|
Resp = api_test_runner:client_request(delete, Path, Token),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ test() ->
|
|||||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, UserToken,
|
<<"/v1/calendars/", CalId/binary, "/events">>, UserToken,
|
||||||
#{title => <<"Event to report">>,
|
#{title => <<"Event to report">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60}),
|
duration => 60}),
|
||||||
|
|
||||||
test_create_report(UserToken, EventId),
|
test_create_report(UserToken, EventId),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ test() ->
|
|||||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||||
#{title => <<"Event for review">>,
|
#{title => <<"Event for review">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60}),
|
duration => 60}),
|
||||||
#{<<"id">> := BookingId} = api_test_runner:client_post(
|
#{<<"id">> := BookingId} = api_test_runner:client_post(
|
||||||
<<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}),
|
<<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test() ->
|
|||||||
#{<<"id">> := EventId} = api_test_runner:client_post(
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||||
#{title => <<"Event for review">>,
|
#{title => <<"Event for review">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60}),
|
duration => 60}),
|
||||||
|
|
||||||
% Бронируем и подтверждаем участие
|
% Бронируем и подтверждаем участие
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ test() ->
|
|||||||
api_test_runner:client_post(
|
api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
|
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
|
||||||
#{title => <<"Python Workshop">>,
|
#{title => <<"Python Workshop">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60,
|
duration => 60,
|
||||||
tags => [<<"python">>, <<"workshop">>]}),
|
tags => [<<"python">>, <<"workshop">>]}),
|
||||||
api_test_runner:client_post(
|
api_test_runner:client_post(
|
||||||
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
|
<<"/v1/calendars/", CalId/binary, "/events">>, Token,
|
||||||
#{title => <<"JavaScript Meetup">>,
|
#{title => <<"JavaScript Meetup">>,
|
||||||
start_time => <<"2026-06-15T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60,
|
duration => 60,
|
||||||
tags => [<<"javascript">>]}),
|
tags => [<<"javascript">>]}),
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ test() ->
|
|||||||
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"WS Test Calendar">>, type => <<"commercial">>}),
|
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"WS Test Calendar">>, type => <<"commercial">>}),
|
||||||
_EventId = api_test_runner:create_event(UserToken, CalId, #{
|
_EventId = api_test_runner:create_event(UserToken, CalId, #{
|
||||||
title => <<"WS Test Event">>,
|
title => <<"WS Test Event">>,
|
||||||
start_time => <<"2026-06-01T10:00:00Z">>,
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
duration => 60
|
duration => 60
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user