%% @author Maas-Maarten Zeeman %% @copyright 2019-2026 Maas-Maarten Zeeman %% @doc Zotonic: admin status model %% @end %% Copyright 2019-2026 Maas-Maarten Zeeman %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. -module(m_admin_status). -moduledoc(" Model for admin status endpoints, including Zotonic version and health/status overview values. Available Model API Paths ------------------------- | Method | Path pattern | Description | | --- | --- | --- | | `get` | `/zotonic_version/...` | Return the running Zotonic version string for admin users (ACL-protected). | | `get` | `/zotonic_git_version/...` | Return the running Zotonic's git version string for admin users (ACL-protected). | | `get` | `/database_version/...` | Return the database server version string (admin ACL-gated via `m_get/3`). | | `get` | `/otp_version/...` | Return the exact Erlang/OTP version string (admin ACL-gated via `m_get/3`). | | `get` | `/config_dir/...` | Return the active configuration directory path. | | `get` | `/security_dir/...` | Return the configured security directory path (`z_config:get(security_dir)`). | | `get` | `/log_dir/...` | Return the configured log directory path (`z_config:get(log_dir)`). | | `get` | `/data_dir/...` | Return the configured data directory path (`z_config:get(data_dir)`). | | `get` | `/cache_dir/...` | Return the configured cache directory path (`z_config:get(cache_dir)`). | | `get` | `/work_dir/...` | Return the current working directory of the running node. | | `get` | `/files_dir/...` | Return the site files base directory derived from `z_path:files_subdir/2`. | | `get` | `/tcp_connection_count/...` | Return the current number of open TCP sockets (`recon:tcp/0`). | | `get` | `/group_sockets/...` | Return open TCP sockets grouped by peer IP with counts and ports. | | `get` | `/memory/used/...` | Return memory usage grouped by allocator (`recon_alloc:memory(used)`). | | `get` | `/memory/allocated/...` | Return allocated memory grouped by allocator (`recon_alloc:memory(allocated)`). | | `get` | `/memory/unused/...` | Return unused memory grouped by allocator (`recon_alloc:memory(unused)`). | | `get` | `/memory/usage/...` | Return allocator memory usage ratios (`recon_alloc:memory(usage)`). | | `get` | `/disks/...` | Return disk space information for mounted disks. | | `get` | `/disks/alert/...` | Return whether any disk usage crosses configured alert thresholds. | | `get` | `/os_memory/...` | Return operating-system memory statistics. | | `get` | `/os_memory/alert/...` | Return whether OS memory pressure crosses alert thresholds. | | `get` | `/task_queue/...` | Return pivot/task queue counters from `z_pivot_rsc:count_tasks/1`. | | `get` | `/is_ssl_application_configured/...` | Return whether the `ssl` application has session settings configured. | | `get` | `/init_arguments/...` | Return all Erlang VM init arguments (`init:get_arguments/0`). | | `get` | `/init_arguments/config/...` | Return only `-config` init argument values. | `/+name` marks a variable path segment. A trailing `/...` means extra path segments are accepted for further lookups. "). -author("Maas-Maarten Zeeman "). -behaviour(zotonic_model). -include_lib("zotonic_core/include/zotonic_release.hrl"). -include_lib("zotonic_core/include/zotonic.hrl"). %% interface functions -export([ m_get/3, otp_version/0, zotonic_version/0, zotonic_git_version/0, database_version/1, tcp_connection_count/0, group_sockets/0, close_sockets/2, disks/0, disks_alert/0 ]). -spec m_get( list(), zotonic_model:opt_msg(), z:context()) -> zotonic_model:return(). m_get([ <<"zotonic_version">> | Rest ], _Msg, Context) -> case z_acl:is_admin(Context) of true -> {ok, {zotonic_version(), Rest}}; false -> {error, eacces} end; m_get([ <<"zotonic_git_version">> | Rest ], _Msg, Context) -> case z_acl:is_admin(Context) of true -> {ok, {zotonic_git_version(), Rest}}; false -> {error, eacces} end; m_get(Path, Msg, Context) -> case z_acl:is_admin(Context) of true -> m_get_1(Path, Msg, Context); false -> {error, eacces} end. m_get_1([ <<"database_version">> | Rest ], _Msg, Context) -> case z_acl:is_admin(Context) of true -> {ok, {database_version(Context), Rest}}; false -> {error, eacces} end; m_get_1([ <<"otp_version">> | Rest ], _Msg, Context) -> case z_acl:is_admin(Context) of true -> {ok, {otp_version(), Rest}}; false -> {error, eacces} end; m_get_1([ <<"config_dir">> | Rest ], _Msg, _Context) -> case z_config_files:config_dir() of {ok, Dir} -> {ok, {Dir, Rest}}; {error, _} = Error -> Error end; m_get_1([ <<"security_dir">> | Rest ], _Msg, _Context) -> {ok, {z_config:get(security_dir), Rest}}; m_get_1([ <<"log_dir">> | Rest ], _Msg, _Context) -> {ok, {z_config:get(log_dir), Rest}}; m_get_1([ <<"data_dir">> | Rest ], _Msg, _Context) -> {ok, {z_config:get(data_dir), Rest}}; m_get_1([ <<"cache_dir">> | Rest ], _Msg, _Context) -> {ok, {z_config:get(cache_dir), Rest}}; m_get_1([ <<"work_dir">> | Rest ], _Msg, _Context) -> case file:get_cwd() of {ok, Dir} -> {ok, {unicode:characters_to_binary(Dir), Rest}}; {error, _} = Error -> Error end; m_get_1([ <<"files_dir">> | Rest ], _Msg, Context) -> Dir = z_path:files_subdir(".", Context), {ok, {filename:dirname(unicode:characters_to_binary(Dir)), Rest}}; m_get_1([ <<"tcp_connection_count">> | Rest ], _Msg, _Context) -> {ok, {tcp_connection_count(), Rest}}; m_get_1([ <<"group_sockets">> | Rest ], _Msg, _Context) -> {ok, {group_sockets(), Rest}}; m_get_1([ <<"memory">>, <<"used">> | Rest ], _Msg, _Context) -> {ok, {recon_alloc:memory(used), Rest}}; m_get_1([ <<"memory">>, <<"allocated">> | Rest ], _Msg, _Context) -> {ok, {recon_alloc:memory(allocated), Rest}}; m_get_1([ <<"memory">>, <<"unused">> | Rest ], _Msg, _Context) -> {ok, {recon_alloc:memory(unused), Rest}}; m_get_1([ <<"memory">>, <<"usage">> | Rest ], _Msg, _Context) -> {ok, {recon_alloc:memory(usage), Rest}}; m_get_1([ <<"disks">>, <<"alert">> | Rest ], _Msg, _Context) -> {ok, {disks_alert(), Rest}}; m_get_1([ <<"disks">> | Rest ], _Msg, _Context) -> {ok, {disks(), Rest}}; m_get_1([ <<"os_memory">>, <<"alert">> | Rest ], _Msg, _Context) -> {ok, {os_memory_alert(), Rest}}; m_get_1([ <<"os_memory">> | Rest ], _Msg, _Context) -> {ok, {os_memory(), Rest}}; m_get_1([ <<"task_queue">> | Rest ], _Msg, Context) -> case z_pivot_rsc:count_tasks(Context) of {ok, Ts} -> {ok, {Ts, Rest}}; {error, _} = Error -> Error end; m_get_1([ <<"is_ssl_application_configured">> | Rest ], _Msg, _Context) -> IsConf = case application:get_env(ssl, session_lifetime) of undefined -> false; {ok, _} -> true end, {ok, {IsConf, Rest}}; m_get_1([ <<"init_arguments">>, <<"config">> | Rest ], _Msg, _Context) -> {ok, {proplists:get_all_values(config, init:get_arguments()), Rest}}; m_get_1([ <<"init_arguments">> | Rest ], _Msg, _Context) -> {ok, {init:get_arguments(), Rest}}; m_get_1(_Path, _Msg, _Context) -> {error, unknown_path}. %% %% Helpers %% % Return the total number of open tcp connections in the system. % This includes local sockets. tcp_connection_count() -> length(recon:tcp()). %% @doc Return the exact OTP version. -spec otp_version() -> binary(). otp_version() -> OtpVersionFile = filename:join([ code:root_dir(), "releases", erlang:system_info(otp_release), "OTP_VERSION" ]), {ok, Version} = file:read_file(OtpVersionFile), z_string:trim(Version). %% @doc Return the zotonic version. -spec zotonic_version() -> binary(). zotonic_version() -> z_convert:to_binary(?ZOTONIC_VERSION). %% @doc Return the zotonic's git version. -spec zotonic_git_version() -> binary() | undefined. zotonic_git_version() -> case z_memo:get(zotonic_git_version) of {cached, Version} -> Version; _ -> % NOTE: this cached value is flushed by 'mod_admin:observe_filewatcher/2' GitVersion = z_utils:git_version(z_path:get_path()), z_memo:set(zotonic_git_version, {cached, GitVersion}), GitVersion end. %% @doc Return the version string of the used database. -spec database_version( z:context() ) -> binary(). database_version(Context) -> z_db:database_version_string(Context). % Group open sockets per ip-address, returns a list of proplists. group_sockets() -> Dict = group_sockets(recon:tcp(), dict:new()), [[{count, length(Ports)}, {ip, inet:ntoa(IP)}, {ports, Ports}] || {IP, Ports} <- dict:to_list(Dict)]. % Return a dict with as key the ip-address. group_sockets([], Dict) -> Dict; group_sockets([Port|Rest], Dict) -> case inet:peername(Port) of {ok, {Addr, _}} -> group_sockets(Rest, dict:append_list(Addr, [Port], Dict)); _ -> group_sockets(Rest, Dict) end. % Close sockets close_sockets(Max, _Context) -> socket_reaper(Max). % Close sockets of ip-addresses which have socket_reaper(Max) -> Dict = group_sockets(recon:tcp(), dict:new()), socket_reaper(dict:to_list(Dict), Max, 0). socket_reaper([], _Max, Acc) -> Acc; socket_reaper([{_Ip, Ports}|Rest], Max, Acc) when length(Ports) >= Max -> [inet:close(Port) || Port <- Ports], socket_reaper(Rest, Max, length(Ports) + Acc); socket_reaper([_|Rest], Max, Acc) -> socket_reaper(Rest, Max, Acc). %% @doc Return disk space information -spec disks() -> list( map() ). disks() -> DiskData = disksup:get_disk_data(), Threshold = disks_threshold(), lists:filtermap( fun({Disk, Size, Capacity}) -> DiskBin = unicode:characters_to_binary(Disk), case is_hidden_disk(DiskBin) of true -> false; false -> {true, #{ disk => DiskBin, size => Size, percent_used => Capacity, alert => Capacity > Threshold }} end end, DiskData). %% @doc Hide macOS system disks from the overview and disk space checks. is_hidden_disk(<<"/Library/Developer/CoreSimulator/", _/binary>>) -> true; is_hidden_disk(<<"/System/Volumes/xarts">>) -> true; is_hidden_disk(<<"/System/Volumes/iSCPreboot">>) -> true; is_hidden_disk(<<"/System/Volumes/Hardware">>) -> true; is_hidden_disk(<<"/System/Volumes/Preboot">>) -> true; is_hidden_disk(<<"/System/Volumes/VM">>) -> true; is_hidden_disk(_) -> false. %% @doc Return disk space information -spec disks_alert() -> boolean(). disks_alert() -> DiskData = disksup:get_disk_data(), Threshold = disks_threshold(), lists:any( fun({Disk, _Size, Capacity}) -> DiskBin = unicode:characters_to_binary(Disk), not is_hidden_disk(DiskBin) andalso (Capacity > Threshold) end, DiskData). %% @doc Return the percentage to be used as threshold. disks_threshold() -> disksup:get_almost_full_threshold(). %% @doc Return true iff the system_memory alert is set. -spec os_memory_alert() -> boolean(). os_memory_alert() -> false. % FIXME % This can be enabled again after the following fix has been merged to OTP: % https://github.com/erlang/otp/pull/8776 % % Alarms = alarm_handler:get_alarms(), % lists:any( % fun % ({system_memory_high_watermark, _}) -> true; % (_) -> false % end, % Alarms). %% @doc Return a list with os memory statistics. os_memory() -> memsup:get_system_memory_data().