diff --git a/src/Cli/Commands/MonitorCommand.php b/src/Cli/Commands/MonitorCommand.php index 4d512a8b..2baa6b8e 100644 --- a/src/Cli/Commands/MonitorCommand.php +++ b/src/Cli/Commands/MonitorCommand.php @@ -12,7 +12,6 @@ use XcVm\Domain\Server\ServerRepository; use XcVm\Domain\Stream\StreamProcess; use XcVm\Domain\Stream\StreamSorter; use XcVm\Streaming\Codec\FFprobeRunner; -use XcVm\Streaming\Fanout\FanoutClient; /** * `monitor [restart]` — the per-stream watchdog. @@ -82,21 +81,6 @@ class MonitorCommand implements CommandInterface { } $rStreamInfo = $db->get_row(); - - // Stand down if the fanout daemon is already supervising this stream's - // encoder. Two watchdogs on one ffmpeg fight: each reads the other's kill - // as a stream failure and restarts, so the channel flaps indefinitely. - // - // The daemon is asked rather than assumed, and an unreachable daemon - // answers false — so this watchdog keeps doing its job whenever the - // daemon is not actually holding the stream. That is the rollback path, - // and it is why the check is here rather than at the call sites: however - // a monitor gets started, it defers to whoever really owns the process. - if (FanoutClient::isSupervised($rStreamID)) { - echo "Stream is supervised by the fanout daemon; monitor standing down.\n"; - return 0; - } - $db->query('UPDATE `streams_servers` SET `monitor_pid` = ? WHERE `server_stream_id` = ?', getmypid(), $rStreamInfo['server_stream_id']); if (SettingsManager::get('enable_cache')) { diff --git a/src/Cli/CronJobs/StreamsCronJob.php b/src/Cli/CronJobs/StreamsCronJob.php index e53f658b..efbd1f33 100644 --- a/src/Cli/CronJobs/StreamsCronJob.php +++ b/src/Cli/CronJobs/StreamsCronJob.php @@ -36,73 +36,6 @@ class StreamsCronJob implements CommandInterface { return 'Cron: check live streams, monitors, on-demand, rogue PIDs'; } - /** - * Copy what the fanout daemon knows about a supervised stream back into - * `streams_servers`, so the panel's own view stays true while the daemon owns - * the process. - * - * The daemon cannot write to the database — XC_VM's credentials are not - * available to it — so this is the only path by which its view reaches the - * panel. It reports the codecs, picture size and MEASURED bitrate derived - * from the bytes it is fanning out, which is both cheaper and more accurate - * than the per-segment ffprobe this replaces. - * - * Only fields the daemon could actually determine are written. It reports an - * unknown as unknown rather than guessing, and overwriting a correct value - * with a blank would be worse than leaving it alone. - * - * @param object $db Database handle. - * @param array $rStream The `streams_servers` row being reconciled. - * @return int The encoder pid the daemon reports, or 0. - */ - private function reconcileSupervisedStream($db, array $rStream): int { - $rState = FanoutClient::monitorState((int) $rStream['stream_id']); - if (!is_array($rState)) { - return 0; // daemon unreachable, or it no longer holds this stream - } - - // stream_status: 0 = running, 1 = failed. The daemon knows which, and it - // has given up entirely when `gave_up` is set. - $rStatus = (!empty($rState['running']) && empty($rState['gave_up'])) ? 0 : 1; - $rPID = isset($rState['pid']) ? (int) $rState['pid'] : 0; - - $rSets = array('`stream_status` = ?'); - $rArgs = array($rStatus); - if ($rPID > 0) { - $rSets[] = '`pid` = ?'; - $rArgs[] = $rPID; - } - if (!empty($rState['source'])) { - $rSets[] = '`current_source` = ?'; - $rArgs[] = (string) $rState['source']; - } - - $rMeta = isset($rState['meta']) && is_array($rState['meta']) ? $rState['meta'] : array(); - if (!empty($rMeta['video_codec'])) { - $rSets[] = '`video_codec` = ?'; - $rArgs[] = (string) $rMeta['video_codec']; - } - if (!empty($rMeta['audio_codec'])) { - $rSets[] = '`audio_codec` = ?'; - $rArgs[] = (string) $rMeta['audio_codec']; - } - if (!empty($rMeta['height'])) { - $rSets[] = '`resolution` = ?'; - $rArgs[] = StreamSorter::getNearest(array(240, 360, 480, 576, 720, 1080, 1440, 2160), (int) $rMeta['height']); - } - if (!empty($rMeta['bitrate_kbps'])) { - $rSets[] = '`bitrate` = ?'; - $rArgs[] = (int) $rMeta['bitrate_kbps']; - } - - $rArgs[] = $rStream['server_stream_id']; - $db->query('UPDATE `streams_servers` SET ' . implode(', ', $rSets) . ' WHERE `server_stream_id` = ?', ...$rArgs); - - echo 'Supervised by daemon (pid ' . $rPID . ', ' . ($rStatus === 0 ? 'running' : 'failed') . ")\n"; - - return $rPID; - } - public function execute(array $rArgs): int { if (!$this->assertRunAsXcVm()) { return 1; @@ -129,22 +62,6 @@ class StreamsCronJob implements CommandInterface { $rActivePIDs = array(); $rStreamIDs = array(); - // Which streams the fanout daemon is supervising, asked once per pass - // rather than once per stream: it is a socket round-trip, and this loop - // runs over every live stream on the node. - // - // null means the daemon could not be asked. That is deliberately NOT the - // same as "it supervises nothing": treating an unreachable daemon as - // supervising nothing would make this cron start a PHP monitor for every - // stream on the node the moment the socket blinked. - $rSupervised = FanoutClient::supervisedIDs(); - $rSupervisedSet = array(); - if (is_array($rSupervised)) { - foreach ($rSupervised as $rID) { - $rSupervisedSet[(string) $rID] = true; - } - } - if ($rRedis) { $db->query('SELECT t2.stream_display_name, t1.stream_started, t1.stream_info, t2.fps_restart, t1.stream_status, t1.progress_info, t1.stream_id, t1.monitor_pid, t1.on_demand, t1.server_stream_id, t1.pid, servers_attached.attached, t2.vframes_server_id, t2.vframes_pid, t2.tv_archive_server_id, t2.tv_archive_pid FROM `streams_servers` t1 INNER JOIN `streams` t2 ON t2.id = t1.stream_id AND t2.direct_source = 0 INNER JOIN `streams_types` t3 ON t3.type_id = t2.type LEFT JOIN (SELECT `stream_id`, COUNT(*) AS `attached` FROM `streams_servers` WHERE `parent_id` = ? AND `pid` IS NOT NULL AND `pid` > 0 AND `monitor_pid` IS NOT NULL AND `monitor_pid` > 0) AS `servers_attached` ON `servers_attached`.`stream_id` = t1.`stream_id` WHERE (t1.pid IS NOT NULL OR t1.stream_status <> 0 OR t1.to_analyze = 1) AND t1.server_id = ? AND t3.live = 1', SERVER_ID, SERVER_ID); } else { @@ -156,28 +73,7 @@ class StreamsCronJob implements CommandInterface { echo 'Stream ID: ' . $rStream['stream_id'] . "\n"; $rStreamIDs[] = $rStream['stream_id']; - // `monitor_pid` names a PHP process, and isMonitorAlive checks the - // pid's /proc//exe is the PHP binary. For a stream the daemon - // supervises there IS no PHP monitor, so that check answers false - // and the branch below would spawn one on every single pass — which - // would then immediately stand down again. Something IS watching the - // stream; ask who before concluding nobody is. - $rDaemonMonitored = isset($rSupervisedSet[(string) $rStream['stream_id']]); - if ($rDaemonMonitored) { - // Record the daemon's own idea of the live pid before anything - // else runs. The rogue-ffmpeg sweep at the end of this pass kills - // every ffmpeg writing an .m3u8 that is not on the active list, - // and an encoder the daemon restarted a moment ago would not - // otherwise be on it — the database pid was read before that - // restart. Shooting it would be a self-inflicted outage, logged - // as having killed a "rogue" process. - $rDaemonPID = $this->reconcileSupervisedStream($db, $rStream); - if ($rDaemonPID > 0) { - $rActivePIDs[] = $rDaemonPID; - } - } - - if ($rDaemonMonitored || ProcessManager::isMonitorAlive($rStream['monitor_pid'], $rStream['stream_id']) || $rStream['on_demand']) { + if (ProcessManager::isMonitorAlive($rStream['monitor_pid'], $rStream['stream_id']) || $rStream['on_demand']) { if ($rStream['on_demand'] == 1 && $rStream['attached'] == 0) { if ($rRedis) { $rCount = 0; diff --git a/src/Core/Localization/lang/en.ini b/src/Core/Localization/lang/en.ini index 0db4e6a1..dac3e88c 100644 --- a/src/Core/Localization/lang/en.ini +++ b/src/Core/Localization/lang/en.ini @@ -1778,7 +1778,6 @@ fanout_source_grace = "Fanout Source Grace" fanout_write_timeout = "Fanout Write Timeout" fanout_idle_buffer_grace = "Fanout Idle Buffer Grace" fanout_idle_buffer_ratio = "Fanout Idle Buffer Ratio" -fanout_supervise = "Fanout Encoder Supervision" fanout_source_chunk_bytes = "Fanout Source Chunk Bytes" fanout_max_gop_bytes = "Fanout Max GOP Bytes" fanout_source_insecure_tls = "Fanout Source Insecure TLS" diff --git a/src/Domain/Server/SettingsService.php b/src/Domain/Server/SettingsService.php index 2909e570..484ad026 100644 --- a/src/Domain/Server/SettingsService.php +++ b/src/Domain/Server/SettingsService.php @@ -34,7 +34,7 @@ class SettingsService { $rArray = QueryHelper::verifyPostTable('settings', $rData, true); - foreach (array('php_loopback', 'restreamer_bypass_proxy', 'request_prebuffer', 'modal_edit', 'group_buttons', 'enable_search', 'on_demand_checker', 'ondemand_balance_equal', 'disable_mag_token', 'allow_cdn_access', 'dts_legacy_ffmpeg', 'mag_load_all_channels', 'disable_xmltv_restreamer', 'disable_playlist_restreamer', 'ffmpeg_warnings', 'reseller_ssl_domain', 'extract_subtitles', 'show_category_duplicates', 'vod_sort_newest', 'header_stats', 'mag_keep_extension', 'keep_protocol', 'read_native_hls', 'player_allow_playlist', 'player_allow_bouquet', 'player_hide_incompatible', 'player_allow_hevc', 'force_epg_timezone', 'check_vod', 'ignore_keyframes', 'save_login_logs', 'save_restart_logs', 'mag_legacy_redirect', 'restrict_playlists', 'monitor_connection_status', 'kill_rogue_ffmpeg', 'show_images', 'on_demand_instant_off', 'on_demand_failure_exit', 'playlist_from_mysql', 'ignore_invalid_users', 'legacy_mag_auth', 'ministra_allow_blank', 'block_proxies', 'block_streaming_servers', 'ip_subnet_match', 'auto_unban_ip', 'debug_show_errors', 'enable_debug_stalker', 'restart_php_fpm', 'restream_deny_unauthorised', 'api_probe', 'legacy_panel_api', 'hide_failures', 'verify_host', 'encrypt_playlist', 'encrypt_playlist_restreamer', 'mag_disable_ssl', 'legacy_get', 'legacy_xmltv', 'save_closed_connection', 'show_tickets', 'stream_logs_save', 'client_logs_save', 'streams_grouped', 'cloudflare', 'cleanup', 'dashboard_stats', 'dashboard_status', 'dashboard_map', 'dashboard_display_alt', 'recaptcha_enable', 'ip_logout', 'disable_player_api', 'disable_playlist', 'disable_xmltv', 'disable_enigma2', 'disable_ministra', 'enable_isp_lock', 'block_svp', 'disable_ts', 'disable_ts_allow_restream', 'disable_hls', 'disable_hls_allow_restream', 'disable_rtmp', 'disable_rtmp_allow_restream', 'case_sensitive_line', 'county_override_1st', 'disallow_2nd_ip_con', 'use_mdomain_in_lists', 'encrypt_hls', 'disallow_empty_user_agents', 'detect_restream_block_user', 'download_images', 'api_redirect', 'use_buffer', 'audio_restart_loss', 'show_isps', 'priority_backup', 'rtmp_random', 'show_connected_video', 'show_not_on_air_video', 'show_banned_video', 'show_expired_video', 'show_expiring_video', 'show_all_category_mag', 'always_enabled_subtitles', 'enable_connection_problem_indication', 'show_tv_channel_logo', 'show_channel_logo_in_preview', 'disable_trial', 'restrict_same_ip', 'fanout_source_insecure', 'fanout_supervise', 'js_navigate') as $rSetting) { + foreach (array('php_loopback', 'restreamer_bypass_proxy', 'request_prebuffer', 'modal_edit', 'group_buttons', 'enable_search', 'on_demand_checker', 'ondemand_balance_equal', 'disable_mag_token', 'allow_cdn_access', 'dts_legacy_ffmpeg', 'mag_load_all_channels', 'disable_xmltv_restreamer', 'disable_playlist_restreamer', 'ffmpeg_warnings', 'reseller_ssl_domain', 'extract_subtitles', 'show_category_duplicates', 'vod_sort_newest', 'header_stats', 'mag_keep_extension', 'keep_protocol', 'read_native_hls', 'player_allow_playlist', 'player_allow_bouquet', 'player_hide_incompatible', 'player_allow_hevc', 'force_epg_timezone', 'check_vod', 'ignore_keyframes', 'save_login_logs', 'save_restart_logs', 'mag_legacy_redirect', 'restrict_playlists', 'monitor_connection_status', 'kill_rogue_ffmpeg', 'show_images', 'on_demand_instant_off', 'on_demand_failure_exit', 'playlist_from_mysql', 'ignore_invalid_users', 'legacy_mag_auth', 'ministra_allow_blank', 'block_proxies', 'block_streaming_servers', 'ip_subnet_match', 'auto_unban_ip', 'debug_show_errors', 'enable_debug_stalker', 'restart_php_fpm', 'restream_deny_unauthorised', 'api_probe', 'legacy_panel_api', 'hide_failures', 'verify_host', 'encrypt_playlist', 'encrypt_playlist_restreamer', 'mag_disable_ssl', 'legacy_get', 'legacy_xmltv', 'save_closed_connection', 'show_tickets', 'stream_logs_save', 'client_logs_save', 'streams_grouped', 'cloudflare', 'cleanup', 'dashboard_stats', 'dashboard_status', 'dashboard_map', 'dashboard_display_alt', 'recaptcha_enable', 'ip_logout', 'disable_player_api', 'disable_playlist', 'disable_xmltv', 'disable_enigma2', 'disable_ministra', 'enable_isp_lock', 'block_svp', 'disable_ts', 'disable_ts_allow_restream', 'disable_hls', 'disable_hls_allow_restream', 'disable_rtmp', 'disable_rtmp_allow_restream', 'case_sensitive_line', 'county_override_1st', 'disallow_2nd_ip_con', 'use_mdomain_in_lists', 'encrypt_hls', 'disallow_empty_user_agents', 'detect_restream_block_user', 'download_images', 'api_redirect', 'use_buffer', 'audio_restart_loss', 'show_isps', 'priority_backup', 'rtmp_random', 'show_connected_video', 'show_not_on_air_video', 'show_banned_video', 'show_expired_video', 'show_expiring_video', 'show_all_category_mag', 'always_enabled_subtitles', 'enable_connection_problem_indication', 'show_tv_channel_logo', 'show_channel_logo_in_preview', 'disable_trial', 'restrict_same_ip', 'fanout_source_insecure', 'js_navigate') as $rSetting) { if (isset($rData[$rSetting])) { $rArray[$rSetting] = 1; } else { diff --git a/src/Domain/Stream/StreamProcess.php b/src/Domain/Stream/StreamProcess.php index acc3ac78..8a98b5b7 100644 --- a/src/Domain/Stream/StreamProcess.php +++ b/src/Domain/Stream/StreamProcess.php @@ -894,11 +894,7 @@ class StreamProcess { $rFFMPEG .= '{MAP} -individual_header_trailer 0 -f hls -hls_time ' . intval($rSegmentSettings['seg_time']) . ' -hls_list_size ' . intval($rStream['stream_info']['delay_minutes']) * 6 . ' -hls_delete_threshold 4 -start_number ' . $rSegmentStart . ' -hls_flags delete_segments+discont_start+omit_endlist -hls_segment_type mpegts -hls_segment_filename "' . DELAY_PATH . intval($rStreamID) . '_%d.ts" "' . DELAY_PATH . intval($rStreamID) . '_.m3u8" '; } - // NB: the redirect-and-background tail is NOT appended here any more. - // buildLive returns the BARE command so it can be handed to the fanout - // daemon to supervise (it has to be the process's parent, and a - // backgrounded command leaves it nothing to supervise). The legacy path - // appends liveRedirectTail() itself, so its behaviour is unchanged. + $rFFMPEG .= ' >/dev/null 2>>' . STREAMS_PATH . intval($rStreamID) . '.errors & echo $! > ' . STREAMS_PATH . intval($rStreamID) . '_.pid'; $ffprobeContainer = (isset($rFFProbeOutput['container']) && is_string($rFFProbeOutput['container'])) ? $rFFProbeOutput['container'] : ''; @@ -925,85 +921,6 @@ class StreamProcess { return $rFFMPEG; } - /** - * The shell tail that runs a live command the legacy way: stderr appended to - * the stream's .errors file, the process backgrounded, and its pid written to - * `/_.pid`. - * - * Split out of buildLive so the same command can either be run here or handed - * to the fanout daemon, which supervises the process itself and therefore - * must NOT have it backgrounded — it does the redirection and the pid file on - * its own. Keeping the two in one place is what stops them drifting. - * - * @param int $rStreamID Stream id. - * @return string Shell fragment to append to a bare live command. - */ - public static function liveRedirectTail($rStreamID): string { - return ' >/dev/null 2>>' . STREAMS_PATH . intval($rStreamID) . '.errors & echo $! > ' . STREAMS_PATH . intval($rStreamID) . '_.pid'; - } - - /** - * Whether this node hands live encoders to the fanout daemon to supervise - * instead of running them itself under the PHP watchdog. - * - * Off unless `fanout_supervise` is set. A panel that predates the setting - * reads null and keeps the legacy behaviour exactly, and clearing it is the - * rollback: MonitorCommand resumes, because its stand-down check asks the - * daemon rather than assuming, and an unreachable daemon answers "no". - * - * @return bool - */ - public static function daemonSupervises(): bool { - return (bool) SettingsManager::get('fanout_supervise'); - } - - /** - * Translate this stream's watchdog settings into the daemon's health policy. - * - * These are the same conditions MonitorCommand.php checked in its inner loop; - * the daemon judges them against the bytes it is already fanning out instead - * of by hashing a playlist, ffprobing a segment and reading a progress file. - * Each is omitted when the panel has it switched off, and an omitted check is - * simply not made. - * - * @param array $rStream Stream row (stream_info + server_info). - * @param array $rSettings Resolved panel settings. - * @return array Health policy for FanoutClient::supervise(). - */ - private static function daemonHealthPolicy($rStream, $rSettings): array { - $rHealth = array(); - - // Stall: the panel restarted when the playlist stopped changing for - // seg_time * 6. Same window, measured from the last byte published. - $rSegTime = max(1, intval($rSettings['seg_time'])); - $rHealth['stall_sec'] = $rSegTime * 6; - - if (!empty($rSettings['audio_restart_loss'])) { - // The panel could only notice on its 300s ffprobe cycle; the daemon - // sees the audio PID go quiet, so the window can be a real one. - $rHealth['audio_loss_sec'] = 30; - } - - if (!empty($rStream['stream_info']['fps_restart'])) { - // fps_threshold is a percentage in the panel and a fraction here. - $rThreshold = floatval($rStream['stream_info']['fps_threshold'] ?: 100) / 100.0; - if ($rThreshold > 0 && $rThreshold < 1) { - $rHealth['fps_threshold'] = $rThreshold; - $rHealth['fps_grace_sec'] = intval($rSettings['fps_delay']); - } - } - - $rAutoRestart = json_decode((string) $rStream['stream_info']['auto_restart'], true); - if (!empty($rAutoRestart['days']) && !empty($rAutoRestart['at'])) { - $rHealth['auto_restart'] = array( - 'days' => array_values((array) $rAutoRestart['days']), - 'at' => (string) $rAutoRestart['at'], - ); - } - - return $rHealth; - } - public static function createChannelItem($rStreamID, $rSource) { global $rSettings, $rServers, $rFFMPEG_CPU, $rFFMPEG_GPU; $db = self::db(); @@ -1063,13 +980,6 @@ class StreamProcess { * @return mixed Stop result. */ public static function stopStream($rStreamID, $rStop = false) { - // Stop the SUPERVISOR before killing anything. If the fanout daemon is - // watching this stream's encoder, killing the process is exactly the event - // it exists to react to — it would start a replacement and the stream - // would refuse to stop. Releasing first makes the daemon let go and kill - // the encoder itself; a no-op when it is not supervising or not reachable. - FanoutClient::releaseSupervision(intval($rStreamID)); - $rMonitor = self::pidFromFileOrColumn($rStreamID, 'monitor_pid', '_.monitor'); if (0 < $rMonitor && \XcVm\Streaming\Health\ProcessChecker::checkPID($rMonitor, array('XC_VM[' . $rStreamID . ']')) && is_numeric($rMonitor)) { @@ -1616,41 +1526,8 @@ class StreamProcess { 'ingestSock' => $rIngestSock, )); - // Hand the encoder to the daemon when this node supervises there. - // The daemon starts it, watches it and restarts it, writing the - // same pid file the rest of PHP still reads — so everything below - // (and stopStream, and isStreamRunning) keeps working unchanged. - // A daemon that is unreachable or declines falls through to the - // legacy shell_exec, which is the rollback path. - $rHandedOver = false; - if (self::daemonSupervises() && !$rDelayActive) { - $rHandedOver = FanoutClient::supervise( - $rStreamID, - $rFFMPEG, - (string) $rRealSource, - array( - 'stop_failures' => intval($rSettings['stop_failures']), - 'stream_fail_sleep' => intval($rSettings['stream_fail_sleep']), - 'on_demand' => (bool) $rStream['server_info']['on_demand'], - 'on_demand_failure_exit' => (bool) $rSettings['on_demand_failure_exit'], - ), - self::daemonHealthPolicy($rStream, $rSettings) - ); - } - if (!$rHandedOver) { - $rFFMPEG .= self::liveRedirectTail($rStreamID); - shell_exec($rFFMPEG); - } - // Record what actually ran, under a name that says WHO ran it: - // `_.fanout` when the daemon owns the process (the bare command it - // was handed), `_.ffmpeg` when this node ran it itself (with the - // redirect-and-background tail). Two names rather than one because - // the first question in any incident is which path the stream took, - // and a single file cannot answer it. The stale one is removed so a - // stream that switched paths does not leave a lie behind. - $rCmdFile = $rHandedOver ? '_.fanout' : '_.ffmpeg'; - file_put_contents(STREAMS_PATH . $rStreamID . $rCmdFile, $rFFMPEG); - @unlink(STREAMS_PATH . $rStreamID . ($rHandedOver ? '_.ffmpeg' : '_.fanout')); + shell_exec($rFFMPEG); + file_put_contents(STREAMS_PATH . $rStreamID . '_.ffmpeg', $rFFMPEG); // Wait briefly for PID file to be written, with retry $rPID = 0; diff --git a/src/Public/Controllers/Api/InternalApiController.php b/src/Public/Controllers/Api/InternalApiController.php index 595a96bb..63cd73f1 100644 --- a/src/Public/Controllers/Api/InternalApiController.php +++ b/src/Public/Controllers/Api/InternalApiController.php @@ -227,14 +227,7 @@ class InternalApiController { $rForceID = intval($rRequest['force_id']); if ($rStreamID > 0) { - // A supervised stream's watchdog is the fanout daemon, and it does - // not read this signal file — MonitorCommand, which did, has stood - // down for that stream. Without the control call, forcing a source - // would silently do nothing at all. Falls back to the file whenever - // the daemon does not hold the stream, or cannot be reached. - if (!FanoutClient::forceSource($rStreamID, $rForceID)) { - file_put_contents(SIGNALS_TMP_PATH . $rStreamID . '.force', $rForceID); - } + file_put_contents(SIGNALS_TMP_PATH . $rStreamID . '.force', $rForceID); } exit(json_encode(array('result' => true))); diff --git a/src/Public/Views/admin/settings.php b/src/Public/Views/admin/settings.php index 06967703..7eba4971 100644 --- a/src/Public/Views/admin/settings.php +++ b/src/Public/Views/admin/settings.php @@ -1555,16 +1555,6 @@ use XcVm\Streaming\Codec\FfmpegBinaries; // Code reconstruction by Squallp -
- -
-
class="form-check-input">
-
-
-