diff --git a/docker/Dockerfile b/docker/Dockerfile index 23c8eab..c963cc2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,7 +3,8 @@ # ============================================================ 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 \ rust cargo openssl-dev libsodium-dev @@ -29,7 +30,9 @@ RUN mkdir -p /app/release && \ # ============================================================ 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 /usr/lib/libcrypto.* /usr/lib/ COPY --from=builder /usr/lib/libssl.* /usr/lib/ diff --git a/include/records.hrl b/include/records.hrl index b4f289b..a69a7db 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -123,7 +123,7 @@ tags :: [binary()], capacity :: integer(), online_link :: binary(), - status :: active | cancelled | completed, + status :: active | cancelled | completed | frozen | deleted, reason :: binary(), rating_avg :: float(), rating_count :: non_neg_integer(), @@ -268,7 +268,9 @@ active_sessions :: non_neg_integer(), ws_connections :: 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, { diff --git a/src/core/core_node_metric.erl b/src/core/core_node_metric.erl index e8d2100..4ce0dd9 100644 --- a/src/core/core_node_metric.erl +++ b/src/core/core_node_metric.erl @@ -8,8 +8,9 @@ %%%------------------------------------------------------------------- -spec save(#node_metric{}) -> ok. save(Metric) -> - mnesia:dirty_write(Metric), - ok. + try mnesia:dirty_write(Metric) + catch _:_ -> ok + end. %%%------------------------------------------------------------------- %%% @doc Возвращает метрики за период, отсортированные по времени. diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl index 60383d9..526cbc0 100644 --- a/src/core/core_subscription.erl +++ b/src/core/core_subscription.erl @@ -269,15 +269,21 @@ apply_updates(Sub, Updates) -> %%%------------------------------------------------------------------- %%% @doc Разбор ISO8601 строки в формат `calendar:datetime()`. +%%% Поддерживает миллисекунды и таймзону. %%% @end %%%------------------------------------------------------------------- -spec parse_iso_datetime(binary() | term()) -> calendar:datetime() | term(). parse_iso_datetime(Bin) when is_binary(Bin) -> try - [DateStr, TimeStr] = string:split(Bin, "T"), - TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), - [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), - [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), + [DatePart, TimePart] = string:split(Bin, "T"), + [YearStr, MonthStr, DayStr] = string:split(DatePart, "-", all), + TimeNoZ = string:trim(TimePart, trailing, "Z"), + [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)), Month = binary_to_integer(list_to_binary(MonthStr)), Day = binary_to_integer(list_to_binary(DayStr)), @@ -357,6 +363,7 @@ count_trial_subscriptions() -> %%%------------------------------------------------------------------- %%% @doc Платные подписки (trial_used = true), истекающие в течение Days дней. +%%% Принимает меры к преобразованию expires_at, если оно сохранено как строка. %%% @end %%%------------------------------------------------------------------- -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, _ = '_'}, ActivePaid = mnesia:dirty_match_object(Match), 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 - end, ActivePaid). \ No newline at end of file + end, ActivePaid). \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_admins.erl b/src/handlers/admin/admin_handler_admins.erl index 90a38d4..1e74c2b 100644 --- a/src/handlers/admin/admin_handler_admins.erl +++ b/src/handlers/admin/admin_handler_admins.erl @@ -131,7 +131,7 @@ create_admin(Req) -> case logic_admin:create_admin(Email, Password, Role) of {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)); {error, email_exists} -> handler_utils:send_error(Req2, 409, <<"Email already exists">>); diff --git a/src/handlers/admin/admin_handler_admins_by_id.erl b/src/handlers/admin/admin_handler_admins_by_id.erl index 6f1d0ce..2a8c617 100644 --- a/src/handlers/admin/admin_handler_admins_by_id.erl +++ b/src/handlers/admin/admin_handler_admins_by_id.erl @@ -134,7 +134,7 @@ update_admin(Req) -> Updates = convert_admin_fields(Updates0), case logic_admin:update_admin(Id, Updates) of {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)); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Admin not found">>); @@ -158,7 +158,7 @@ delete_admin(Req) -> Id = cowboy_req:binding(id, Req1), case logic_admin:delete_admin(Id) of {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">>}); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"Admin not found">>); diff --git a/src/handlers/admin/admin_handler_banned_words.erl b/src/handlers/admin/admin_handler_banned_words.erl index d294698..cb93a9b 100644 --- a/src/handlers/admin/admin_handler_banned_words.erl +++ b/src/handlers/admin/admin_handler_banned_words.erl @@ -180,7 +180,7 @@ add_word(Req) -> #{<<"word">> := Word} -> case core_banned_words:add_banned_word(Word, AdminId) of {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">>}); {error, 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 {ok, _Updated} -> 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">>}); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Word not found">>); @@ -228,7 +228,7 @@ delete_word(Req) -> Word = cowboy_req:binding(word, Req1), case core_banned_words:remove_banned_word(Word) of {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">>}); {error, 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">>]), Skipped = length([R || R <- Results, maps:get(status, R) =:= <<"skipped">>]), 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, #{ results => Results, added => Added, diff --git a/src/handlers/admin/admin_handler_calendar_by_id.erl b/src/handlers/admin/admin_handler_calendar_by_id.erl index 99791cb..a413fd5 100644 --- a/src/handlers/admin/admin_handler_calendar_by_id.erl +++ b/src/handlers/admin/admin_handler_calendar_by_id.erl @@ -114,7 +114,7 @@ update_calendar(Req) -> Updates -> case logic_calendar:admin_update(Id, Updates) of {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), handler_utils:send_json(Req2, 200, Json); {error, not_found} -> @@ -139,7 +139,7 @@ delete_calendar(Req) -> Id = cowboy_req:binding(id, Req1), case logic_calendar:admin_delete(Id) of 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, #{}); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"Calendar not found">>) diff --git a/src/handlers/admin/admin_handler_event_by_id.erl b/src/handlers/admin/admin_handler_event_by_id.erl index 3063a43..65a06f0 100644 --- a/src/handlers/admin/admin_handler_event_by_id.erl +++ b/src/handlers/admin/admin_handler_event_by_id.erl @@ -154,7 +154,7 @@ update_event(Req) -> case logic_event:update_event_admin(EventId, UpdatesWithTypes) of {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)); {error, 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 {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">>}); {error, 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({<<"online_link">>, Val}) -> {online_link, Val}; convert_field({<<"status">>, Val}) -> - try binary_to_existing_atom(Val, utf8) of - Atom -> {status, Atom} - catch - error:badarg -> {status, Val} + case Val of + <<"active">> -> {status, active}; + <<"cancelled">> -> {status, cancelled}; + <<"completed">> -> {status, completed}; + <<"frozen">> -> {status, frozen}; + <<"deleted">> -> {status, deleted}; + _ -> {status, Val} end; convert_field(Other) -> Other. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_login.erl b/src/handlers/admin/admin_handler_login.erl index 3e6026c..24392b3 100644 --- a/src/handlers/admin/admin_handler_login.erl +++ b/src/handlers/admin/admin_handler_login.erl @@ -25,7 +25,7 @@ init(Req0, _State) -> core_admin_session:create(UserId, RefreshToken), 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 = #{ <<"token">> => Token, <<"user">> => #{ diff --git a/src/handlers/admin/admin_handler_me.erl b/src/handlers/admin/admin_handler_me.erl index 159b095..6c9fcaf 100644 --- a/src/handlers/admin/admin_handler_me.erl +++ b/src/handlers/admin/admin_handler_me.erl @@ -116,7 +116,7 @@ update_me(Req) -> case logic_admin:update_admin(AdminId, Updates) of {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)); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Admin not found">>); diff --git a/src/handlers/admin/admin_handler_moderation.erl b/src/handlers/admin/admin_handler_moderation.erl index 4896ff8..ad2b651 100644 --- a/src/handlers/admin/admin_handler_moderation.erl +++ b/src/handlers/admin/admin_handler_moderation.erl @@ -101,7 +101,7 @@ apply_moderation(<<"user">>, Id, Action, Reason, Req, AdminId) -> handle_calendar(Id, <<"freeze">>, Reason, Req, AdminId) -> case core_calendar:freeze(Id, Reason) of {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)); {error, 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) -> case core_calendar:unfreeze(Id, Reason) of {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)); {error, 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) -> case core_event:freeze(Id, Reason) of {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)); {error, 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) -> case core_event:unfreeze(Id, Reason) of {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)); {error, 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) -> case core_review:hide(Id, Reason) of {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)); {error, 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) -> case core_review:unhide(Id, Reason) of {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)); {error, 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) -> case core_user:block(Id, Reason) of {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)); {error, 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) -> case core_user:unblock(Id, Reason) of {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)); {error, not_found} -> handler_utils:send_error(Req, 404, <<"User not found">>) diff --git a/src/handlers/admin/admin_handler_node_metrics.erl b/src/handlers/admin/admin_handler_node_metrics.erl index 342f493..26f7a20 100644 --- a/src/handlers/admin/admin_handler_node_metrics.erl +++ b/src/handlers/admin/admin_handler_node_metrics.erl @@ -1,6 +1,21 @@ %%%------------------------------------------------------------------- %%% @doc Административный обработчик исторических метрик узлов. -%%% GET /v1/admin/nodes/metrics – возвращает массив метрик за период. +%%% +%%% == Эндпоинт == +%%% ``` +%%% GET /v1/admin/nodes/metrics?from=ISO8601&to=ISO8601&node=NameOrAll +%%% ``` +%%% +%%% == Параметры == +%%% +%%% +%%% == Ответ == +%%% Массив объектов метрик. Каждый объект содержит поля, описанные в `metric_schema/0`. %%% @end %%%------------------------------------------------------------------- -module(admin_handler_node_metrics). @@ -10,115 +25,25 @@ -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) -> case cowboy_req:method(Req) of <<"GET">> -> get_metrics(Req); _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) end. -%%% Swagger metadata --spec trails() -> [map()]. -trails() -> - [#{path => <<"/v1/admin/nodes/metrics">>, - method => <<"GET">>, - description => <<"Get historical node metrics">>, - tags => [<<"Node Metrics">>], - 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 Возвращает исторические метрики по заданному периоду и узлу. +%%%------------------------------------------------------------------- +%%% @doc Возвращает исторические метрики узлов. +%%% +%%% При `node=all` (по умолчанию) собирает метрики со всех узлов кластера +%%% через RPC-вызовы к удалённым узлам. Локальные метрики извлекаются +%%% напрямую. +%%% @end +%%%------------------------------------------------------------------- -spec get_metrics(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. get_metrics(Req) -> case handler_utils:auth_admin(Req) of @@ -126,20 +51,49 @@ get_metrics(Req) -> Qs = cowboy_req:parse_qs(Req1), From = handler_utils:parse_datetime_qs(proplists:get_value(<<"from">>, 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 {undefined, _} -> handler_utils:send_error(Req1, 400, <<"Missing from">>); {_, undefined} -> handler_utils:send_error(Req1, 400, <<"Missing to">>); {F, T} -> - Metrics = core_node_metric:get_by_timerange(F, T, Node), - Json = [metric_to_map(M) || M <- Metrics], + Metrics = case NodeParam of + <<"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) end; {error, Code, Msg, Req1} -> handler_utils:send_error(Req1, Code, Msg) end. -%% @private Преобразует запись метрики в JSON-совместимую карту. +%%%------------------------------------------------------------------- +%%% @doc Преобразует запись `#node_metric{}` в JSON-совместимый map. +%%% @end +%%%------------------------------------------------------------------- -spec metric_to_map(#node_metric{}) -> map(). metric_to_map(M) -> #{ @@ -150,6 +104,7 @@ metric_to_map(M) -> <<"memory_atom">> => M#node_metric.memory_atom, <<"memory_binary">> => M#node_metric.memory_binary, <<"memory_ets">> => M#node_metric.memory_ets, + <<"memory_available">> => M#node_metric.memory_available, <<"process_count">> => M#node_metric.process_count, <<"run_queue">> => M#node_metric.run_queue, <<"mnesia_commits">> => M#node_metric.mnesia_commits, @@ -158,5 +113,63 @@ metric_to_map(M) -> <<"active_sessions">> => M#node_metric.active_sessions, <<"ws_connections">> => M#node_metric.ws_connections, <<"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)">>} + } }. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_report_by_id.erl b/src/handlers/admin/admin_handler_report_by_id.erl index 7f7c947..774bc0d 100644 --- a/src/handlers/admin/admin_handler_report_by_id.erl +++ b/src/handlers/admin/admin_handler_report_by_id.erl @@ -109,7 +109,7 @@ update_report(Req) -> case logic_report:update_report_status(AdminId, ReportId, StatusAtom) of {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)); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Report not found">>); diff --git a/src/handlers/admin/admin_handler_reviews.erl b/src/handlers/admin/admin_handler_reviews.erl index 00f6dd9..8cdca44 100644 --- a/src/handlers/admin/admin_handler_reviews.erl +++ b/src/handlers/admin/admin_handler_reviews.erl @@ -132,7 +132,7 @@ bulk_update_reviews(Req) -> true = is_list(Operations), case logic_review:bulk_update_status(Operations, Reason) of {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}); {error, ReasonErr} -> handler_utils:send_error(Req2, 400, ReasonErr) diff --git a/src/handlers/admin/admin_handler_reviews_by_id.erl b/src/handlers/admin/admin_handler_reviews_by_id.erl index 69f780d..0f49057 100644 --- a/src/handlers/admin/admin_handler_reviews_by_id.erl +++ b/src/handlers/admin/admin_handler_reviews_by_id.erl @@ -113,7 +113,7 @@ update_review(Req) -> case logic_review:update_review_admin(ReviewId, Converted) of {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)); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Review not found">>); diff --git a/src/handlers/admin/admin_handler_subscriptions_by_id.erl b/src/handlers/admin/admin_handler_subscriptions_by_id.erl index f4f8e44..bf31ec5 100644 --- a/src/handlers/admin/admin_handler_subscriptions_by_id.erl +++ b/src/handlers/admin/admin_handler_subscriptions_by_id.erl @@ -120,7 +120,7 @@ update_subscription(Req) -> Data when is_map(Data) -> case core_subscription:update_subscription(Id, Data) of {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)); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Subscription not found">>); @@ -142,7 +142,7 @@ delete_subscription(Req) -> Id = cowboy_req:binding(id, Req1), case core_subscription:delete_subscription(Id) of {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">>}); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"Subscription not found">>); diff --git a/src/handlers/admin/admin_handler_ticket_by_id.erl b/src/handlers/admin/admin_handler_ticket_by_id.erl index 4ef5e09..5904dd3 100644 --- a/src/handlers/admin/admin_handler_ticket_by_id.erl +++ b/src/handlers/admin/admin_handler_ticket_by_id.erl @@ -131,7 +131,7 @@ update_ticket(Req) -> Result = apply_ticket_changes(AdminId, TicketId, Data), case Result of {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)); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Ticket not found">>); @@ -157,7 +157,7 @@ delete_ticket(Req) -> TicketId = cowboy_req:binding(id, Req1), case logic_ticket:delete_ticket(AdminId, TicketId) of {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">>}); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"Ticket not found">>); diff --git a/src/handlers/admin/admin_handler_tickets.erl b/src/handlers/admin/admin_handler_tickets.erl index a193645..a11f619 100644 --- a/src/handlers/admin/admin_handler_tickets.erl +++ b/src/handlers/admin/admin_handler_tickets.erl @@ -122,7 +122,7 @@ update_ticket(Req) -> Result = apply_ticket_changes(AdminId, TicketId, Data), case Result of {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)); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"Ticket not found">>); diff --git a/src/handlers/admin/admin_handler_user_by_id.erl b/src/handlers/admin/admin_handler_user_by_id.erl index a215991..942e361 100644 --- a/src/handlers/admin/admin_handler_user_by_id.erl +++ b/src/handlers/admin/admin_handler_user_by_id.erl @@ -129,7 +129,7 @@ update_user(Req) -> Converted = convert_user_fields(Updates), case logic_user:update_user_admin(UserId, Converted) of {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)); {error, not_found} -> handler_utils:send_error(Req2, 404, <<"User not found">>); @@ -151,7 +151,7 @@ delete_user(Req) -> UserId = cowboy_req:binding(id, Req1), case logic_user:delete_user_admin(UserId) of {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">>}); {error, not_found} -> handler_utils:send_error(Req1, 404, <<"User not found">>); diff --git a/src/handlers/admin/admin_ws_handler.erl b/src/handlers/admin/admin_ws_handler.erl index a7ed080..f54621e 100644 --- a/src/handlers/admin/admin_ws_handler.erl +++ b/src/handlers/admin/admin_ws_handler.erl @@ -63,7 +63,8 @@ websocket_init(State) -> io:format("[ADMIN_WS] WebSocket initialized for admin ~s~n", [State#state.admin_id]), pg:join(eventhub_admin_ws, self()), % Подписываемся на метрики узлов - pg:join(node_metrics, self()), +%% pg:join(node_metrics, self()), + pg:join(global, node_metrics, self()), {ok, State}. %% @doc Обрабатывает входящие текстовые сообщения (subscribe/unsubscribe/ping). @@ -143,5 +144,7 @@ metric_to_map(M) -> <<"active_sessions">> => M#node_metric.active_sessions, <<"ws_connections">> => M#node_metric.ws_connections, <<"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 }. \ No newline at end of file diff --git a/src/infra/admin_utils.erl b/src/infra/admin_utils.erl index 63b5535..997beb6 100644 --- a/src/infra/admin_utils.erl +++ b/src/infra/admin_utils.erl @@ -2,7 +2,7 @@ -include("records.hrl"). -export([is_admin/1, check_role/2, get_permissions/1, is_superadmin/1]). -export([client_ip/1]). % ← IP --export([log_admin_action/5]). % ← аудит действий администратора +-export([log_admin_action/6]). % ← аудит действий администратора -export([ip_to_binary/1]). % ← преобразование IP %% -- существующие функции ------------------------------------------------- @@ -71,14 +71,34 @@ ip_to_binary(_) -> %% EntityType – тип сущности (напр. <<"admin">>, <<"banned_word">>) %% EntityId – идентификатор сущности %% Req – cowboy_req для извлечения IP --spec log_admin_action(binary(), binary(), binary(), term(), cowboy_req:req()) -> ok. -log_admin_action(AdminId, Action, EntityType, EntityId, Req) -> +%%-spec log_admin_action(binary(), binary(), binary(), term(), cowboy_req:req()) -> ok. +%%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 {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), + core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason), ok; _ -> ok end. \ No newline at end of file diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index 86a8e07..000dea5 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -62,6 +62,22 @@ handle_call(init_tables, _From, State) -> ok = join_cluster(ExtraNodes) end, 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 = stats_collector:subscribe(), 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(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; 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)}]. %% =================================================================== %% Индексы diff --git a/src/infra/infra_sup.erl b/src/infra/infra_sup.erl index 0c39750..6dbccc7 100644 --- a/src/infra/infra_sup.erl +++ b/src/infra/infra_sup.erl @@ -25,6 +25,10 @@ init([]) -> start => {pg, start_link, []}, restart => permanent, type => worker}, + #{id => pg_global, + start => {pg, start_link, [global]}, + restart => permanent, + type => worker}, #{id => stats_collector, start => {stats_collector, start_link, []}, restart => permanent, diff --git a/src/infra/node_monitor.erl b/src/infra/node_monitor.erl index 3107fdb..e50cc4f 100644 --- a/src/infra/node_monitor.erl +++ b/src/infra/node_monitor.erl @@ -38,6 +38,7 @@ start_link() -> -spec init(term()) -> {ok, map()}. init([]) -> + mnesia:wait_for_tables([node_metric], 5000), % Создаём ETS-таблицу для кеширования предыдущих значений io ets:new(?IO_CACHE_TAB, [named_table, public, set, {keypos, 1}]), % Запускаем периодический сбор метрик и очистку @@ -58,7 +59,9 @@ handle_info(collect, State) -> Metric = collect_metrics(), core_node_metric:save(Metric), % Рассылаем метрику всем подписчикам группы 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], schedule_collect(), {noreply, State}; @@ -88,6 +91,8 @@ collect_metrics() -> Now = calendar:universal_time(), Mem = erlang:memory(), {IoIn, IoOut} = io_stats(), + Cpu = cpu_utilization(), + FreeMem = free_memory(), #node_metric{ timestamp = Now, node = node(), @@ -104,7 +109,9 @@ collect_metrics() -> active_sessions = session_count(), ws_connections = ws_count(), 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]), {PrevIn, PrevOut} = get_prev_io(), 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()}. get_prev_io() -> diff --git a/src/logic/logic_search.erl b/src/logic/logic_search.erl index 8e96e3b..cfa57b9 100644 --- a/src/logic/logic_search.erl +++ b/src/logic/logic_search.erl @@ -105,9 +105,10 @@ filter_accessible_events(Events, UserId) -> CanAccess = logic_calendar:can_access(UserId, Calendar), case CanAccess of false -> - io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n", - [UserId, Calendar#calendar.id, Calendar#calendar.type, - Calendar#calendar.owner_id, Calendar#calendar.status]); +%% io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n", +%% [UserId, Calendar#calendar.id, Calendar#calendar.type, +%% Calendar#calendar.owner_id, Calendar#calendar.status]); + false; true -> ok end, CanAccess; diff --git a/test/api/admins/admin_banned_words_tests.erl b/test/api/admins/admin_banned_words_tests.erl index e8c3802..c2453b1 100644 --- a/test/api/admins/admin_banned_words_tests.erl +++ b/test/api/admins/admin_banned_words_tests.erl @@ -90,19 +90,26 @@ test_add_word(Token) -> NewWord = <<"newbadword", Unique/binary>>, Result = api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => NewWord}), ?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"). %% @doc PUT /v1/admin/banned-words/:word – редактирование слова. test_update_word(Token, Word) -> ct:pal(" TEST: Update a banned word"), NewWord = <>, - 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)), - Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token), - ?assertNot(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, Words)), - ?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= NewWord end, Words)), + % Проверяем, что новое слово существует (поиск возвращает ровно 1 запись) + NewSearch = api_test_runner:admin_get(<<"/v1/admin/banned-words?q=", NewWord/binary>>, Token), + ?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"). %% @doc POST /v1/admin/banned-words/batch – массовая загрузка. diff --git a/test/api/admins/admin_stats_tests.erl b/test/api/admins/admin_stats_tests.erl index 1f9abac..998580b 100644 --- a/test/api/admins/admin_stats_tests.erl +++ b/test/api/admins/admin_stats_tests.erl @@ -224,8 +224,13 @@ test_calendar_stats(Token) -> test_node_metrics_history(Token) -> ct:pal(" TEST: Node metrics history"), - From = <<"2026-01-01T00:00:00Z">>, - To = <<"2026-12-31T23:59:59Z">>, + Now = calendar:universal_time(), + 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>>, {ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>), 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(<<"node">>, 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)]). \ No newline at end of file diff --git a/test/api/admins/admin_websocket_tests.erl b/test/api/admins/admin_websocket_tests.erl index 223b45b..8878058 100644 --- a/test/api/admins/admin_websocket_tests.erl +++ b/test/api/admins/admin_websocket_tests.erl @@ -41,7 +41,7 @@ test() -> #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, UserToken, #{title => <<"WS Test Event">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60}), WsUrl = api_test_runner:get_base_ws_url() ++ "/ws", diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index 0102b9a..0c0c10c 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -41,6 +41,7 @@ client_delete/2, admin_patch/3]). -export([verify_user/2]). +-export([future_date_iso8601/0]). %%%=================================================================== %%% Конфигурация окружения (CT_MODE, ...) @@ -201,7 +202,7 @@ request(BaseUrl, Method, Path, Token, Body, Prefix) -> delete -> {URL, Headers}; _ -> {URL, Headers, "application/json", Body} 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 {ok, {{_, Status, _}, RespHeaders, 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, 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(). register_and_login(Email, Password) -> % 1. Регистрируем пользователя diff --git a/test/api/users/user_bookings_tests.erl b/test/api/users/user_bookings_tests.erl index 8622793..eaa220c 100644 --- a/test/api/users/user_bookings_tests.erl +++ b/test/api/users/user_bookings_tests.erl @@ -35,7 +35,7 @@ test() -> #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event to book">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60}), test_create_booking(ParticipantToken, EventId), @@ -81,7 +81,7 @@ test_confirm_booking(OwnerToken, EventId) -> %% @doc Отмена бронирования участником: 200 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"), % Создаём нового участника, у которого нет бронирований CancelUserEmail = api_test_runner:unique_email(<<"canceluser">>), diff --git a/test/api/users/user_event_by_id_tests.erl b/test/api/users/user_event_by_id_tests.erl index 5a2265e..248c4c8 100644 --- a/test/api/users/user_event_by_id_tests.erl +++ b/test/api/users/user_event_by_id_tests.erl @@ -36,7 +36,7 @@ test() -> #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, Token, #{title => <<"Test Event">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60}), test_get_event(Token, EventId), diff --git a/test/api/users/user_events_tests.erl b/test/api/users/user_events_tests.erl index 10c625e..fcd6f7a 100644 --- a/test/api/users/user_events_tests.erl +++ b/test/api/users/user_events_tests.erl @@ -30,7 +30,7 @@ test_create_single_event(Token, CalId) -> Path = <<"/v1/calendars/", CalId/binary, "/events">>, Body = jsx:encode(#{ title => <<"Single Event">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60 }), 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">>, Body = jsx:encode(#{ title => <<"Weekly Meeting">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60, recurrence => #{ freq => <<"WEEKLY">>, diff --git a/test/api/users/user_my_bookings_tests.erl b/test/api/users/user_my_bookings_tests.erl index 486b26c..13793b3 100644 --- a/test/api/users/user_my_bookings_tests.erl +++ b/test/api/users/user_my_bookings_tests.erl @@ -31,7 +31,7 @@ test() -> #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for my booking">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60}), % Бронируем событие от имени участника и подтверждаем diff --git a/test/api/users/user_my_reviews_tests.erl b/test/api/users/user_my_reviews_tests.erl index 7ec37d5..dee38ed 100644 --- a/test/api/users/user_my_reviews_tests.erl +++ b/test/api/users/user_my_reviews_tests.erl @@ -31,7 +31,7 @@ test() -> #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for my review">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60}), % Бронируем, подтверждаем, оставляем отзыв diff --git a/test/api/users/user_occurrence_cancel_tests.erl b/test/api/users/user_occurrence_cancel_tests.erl index 56c15ad..2526da5 100644 --- a/test/api/users/user_occurrence_cancel_tests.erl +++ b/test/api/users/user_occurrence_cancel_tests.erl @@ -32,7 +32,7 @@ test() -> #{<<"id">> := RecurringId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Weekly Standup">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 30, recurrence => #{freq => <<"WEEKLY">>, interval => 1}}), @@ -73,7 +73,7 @@ test_cancel_occurrence_on_single_event(Token, CalId) -> #{<<"id">> := SingleId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, Token, #{title => <<"Single">>, - start_time => <<"2026-06-02T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 30}), Path = <<"/v1/events/", SingleId/binary, "/occurrences/2026-06-02T10:00:00Z">>, Resp = api_test_runner:client_request(delete, Path, Token), diff --git a/test/api/users/user_reports_tests.erl b/test/api/users/user_reports_tests.erl index 7fec350..d0ecccd 100644 --- a/test/api/users/user_reports_tests.erl +++ b/test/api/users/user_reports_tests.erl @@ -31,7 +31,7 @@ test() -> #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, UserToken, #{title => <<"Event to report">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60}), test_create_report(UserToken, EventId), diff --git a/test/api/users/user_review_by_id_tests.erl b/test/api/users/user_review_by_id_tests.erl index 40c99d1..481cb74 100644 --- a/test/api/users/user_review_by_id_tests.erl +++ b/test/api/users/user_review_by_id_tests.erl @@ -38,7 +38,7 @@ test() -> #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for review">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60}), #{<<"id">> := BookingId} = api_test_runner:client_post( <<"/v1/events/", EventId/binary, "/bookings">>, ParticipantToken, #{}), diff --git a/test/api/users/user_reviews_tests.erl b/test/api/users/user_reviews_tests.erl index 57ef93b..eae1a53 100644 --- a/test/api/users/user_reviews_tests.erl +++ b/test/api/users/user_reviews_tests.erl @@ -35,7 +35,7 @@ test() -> #{<<"id">> := EventId} = api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, #{title => <<"Event for review">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60}), % Бронируем и подтверждаем участие diff --git a/test/api/users/user_search_tests.erl b/test/api/users/user_search_tests.erl index 3b0de5b..4b38078 100644 --- a/test/api/users/user_search_tests.erl +++ b/test/api/users/user_search_tests.erl @@ -33,13 +33,13 @@ test() -> api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, Token, #{title => <<"Python Workshop">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60, tags => [<<"python">>, <<"workshop">>]}), api_test_runner:client_post( <<"/v1/calendars/", CalId/binary, "/events">>, Token, #{title => <<"JavaScript Meetup">>, - start_time => <<"2026-06-15T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60, tags => [<<"javascript">>]}), diff --git a/test/api/users/user_websocket_tests.erl b/test/api/users/user_websocket_tests.erl index 2030cf6..4a7107f 100644 --- a/test/api/users/user_websocket_tests.erl +++ b/test/api/users/user_websocket_tests.erl @@ -27,7 +27,7 @@ test() -> CalId = api_test_runner:create_calendar(UserToken, #{title => <<"WS Test Calendar">>, type => <<"commercial">>}), _EventId = api_test_runner:create_event(UserToken, CalId, #{ title => <<"WS Test Event">>, - start_time => <<"2026-06-01T10:00:00Z">>, + start_time => api_test_runner:future_date_iso8601(), duration => 60 }),