$params */ private function __construct( public readonly bool $isCli, public readonly array $params, public readonly string $selfUri ) { } public static function fromGlobals(): self { $isCli = PHP_SAPI === 'cli'; if ($isCli) { return new self(true, self::parseCliArgs($_SERVER['argv'] ?? []), 'this script'); } return new self(false, self::parseWebParams($_GET), $_SERVER['PHP_SELF'] ?? ''); } /** * @param string[] $argv * @return array */ private static function parseCliArgs(array $argv): array { $params = []; foreach (array_slice($argv, 1) as $arg) { if (! preg_match('/^--([a-zA-Z][a-zA-Z0-9_-]*)(?:=(.*))?$/', $arg, $m)) { continue; } $params[str_replace('-', '_', $m[1])] = $m[2] ?? true; } return $params; } /** * @param array $get * @return array */ private static function parseWebParams(array $get): array { $params = []; foreach (['action', 'daemon_uri', 'override_tz'] as $key) { if (isset($get[$key])) { $params[$key] = (string) $get[$key]; } } // phpcs:ignore SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator if (isset($get['force_apply']) && $get['force_apply'] == 1) { $params['force_apply'] = true; } if (isset($get['confirm'])) { $params['confirm'] = true; } return $params; } public function get(string $key, string|bool|null $default = null): string|bool|null { return $this->params[$key] ?? $default; } public function has(string $key): bool { return isset($this->params[$key]); } } /** * Output — one interface, one implementation per surface. No business * logic lives here; every method just renders data it is handed. */ interface OutputRenderer { public function start(): void; public function message(string $text, string $type = 'info'): void; public function heading(string $text): void; /** @param array $changes */ public function previewTable(array $changes): void; /** @param array $mutated */ public function appliedTable(array $mutated): void; /** @param string[] $tzList */ public function timezonePicker(array $tzList, RequestContext $context): void; /** @param array $links */ public function linkList(array $links): void; public function link(string $url, string $label, string $class): void; public function redirect(string $location): void; } final class HtmlRenderer implements OutputRenderer { private const TABLE_STYLE = <<<'CSS' width: 100%; border-collapse: collapse; margin: 15px 0; background: #fff; border-radius: 4px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.05); CSS; private const HEAD_CELL_STYLE = <<<'CSS' background:#edf2f7; padding: 10px; text-align: left; font-weight: bold; color: #4a5568; border-bottom: 1px solid #edf2f7; CSS; private const BODY_CELL_STYLE = <<<'CSS' padding: 10px; border-bottom: 1px solid #edf2f7; CSS; public function start(): void { if (ob_get_level()) { ob_end_clean(); } header('Content-Type: text/html; charset=utf-8'); echo <<<'CSS' CSS; } public function message(string $text, string $type = 'info'): void { echo "
{$text}
"; } public function heading(string $text): void { $escaped = htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); echo "

{$escaped}

"; } public function previewTable(array $changes): void { if (empty($changes)) { return; } $tableStyle = $this->prepareInlineStyle(self::TABLE_STYLE); $headStyle = $this->prepareInlineStyle(self::HEAD_CELL_STYLE); echo << Active Job ID Current Production Hour Projected Shift Target HTML; foreach ($changes as $c) { $id = $c['id']; $cellStyle = self::BODY_CELL_STYLE; $old = htmlspecialchars($c['old'], ENT_QUOTES, 'UTF-8'); $new = htmlspecialchars($c['new'], ENT_QUOTES, 'UTF-8'); echo << {$id} {$old} {$new} HTML; } echo ''; } public function appliedTable(array $mutated): void { if (empty($mutated)) { return; } $tableStyle = $this->prepareInlineStyle(self::TABLE_STYLE); $headStyle = $this->prepareInlineStyle(self::HEAD_CELL_STYLE); echo << Job ID Old Pattern New Pattern Applied HTML; foreach ($mutated as $m) { $id = $m['id']; $cellStyle = self::BODY_CELL_STYLE; $old = htmlspecialchars($m['old'], ENT_QUOTES, 'UTF-8'); $new = htmlspecialchars($m['new'], ENT_QUOTES, 'UTF-8'); echo << {$id} {$old} {$new} HTML; } echo ''; } public function timezonePicker(array $tzList, RequestContext $context): void { $selfUri = htmlspecialchars($context->selfUri, ENT_QUOTES, 'UTF-8'); echo << Timezone Unpopulated: Please select your current active operations timezone baseline below:

HTML; if ($context->has('daemon_uri')) { $daemonUri = htmlspecialchars((string) $context->get('daemon_uri'), ENT_QUOTES, 'UTF-8'); echo ""; } echo <<<'HTML'   
HTML; } public function linkList(array $links): void { if (empty($links)) { return; } echo "
    "; foreach ($links as $link) { $url = htmlspecialchars($link['url'], ENT_QUOTES, 'UTF-8'); $label = $link['label']; $class = $link['class']; echo "
  • {$label}
  • "; } echo '
'; } public function link(string $url, string $label, string $class): void { $safeUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8'); echo "
{$label}"; } public function redirect(string $location): void { header("Location: {$location}"); exit; } private function prepareInlineStyle(string $style): string { return preg_replace('/(?:\r|\n|\s)+/', ' ', $style); } } final class CliRenderer implements OutputRenderer { public function start(): void { // No page shell needed for CLI output. } public function message(string $text, string $type = 'info'): void { $level = strtoupper($type); $body = $this->flatten($text); echo "[{$level}] {$body}\n"; } public function heading(string $text): void { $body = $this->flatten($text); echo "{$body}\n"; } /** * Collapses any whitespace introduced by wrapping a message across * multiple source lines (e.g. a heredoc) into single spaces, so CLI * output stays one line regardless of how the message was authored. */ private function flatten(string $text): string { return trim(preg_replace('/\s+/', ' ', strip_tags($text))); } public function previewTable(array $changes): void { foreach ($changes as $c) { echo " #{$c['id']}: {$c['old']} -> {$c['new']}\n"; } } public function appliedTable(array $mutated): void { // CLI only reports the summary count via message(); no table detail. } public function timezonePicker(array $tzList, RequestContext $context): void { // Unreachable: CLI requires --override-tz upfront instead of an interactive picker. } public function linkList(array $links): void { // Navigation links are a web-only affordance. } public function link(string $url, string $label, string $class): void { // Navigation links are a web-only affordance. } public function redirect(string $location): void { // Unreachable in CLI mode. } } /* * Domain logic — pure functions/data. Nothing here echoes, sets headers, * or reads STDIN; everything comes in as arguments and goes out as * return values so it can be tested or reused independent of the * renderer or the request context. */ // phpcs:ignore Squiz.Commenting.FunctionComment.WrongStyle function resolveStatePaths(): array { $stateFile = __DIR__ . '/cron_tz_state.txt'; $bkpDb = __DIR__ . '/jobqueueTZ.db'; if (! is_writable(__DIR__) && file_exists('/tmp')) { $stateFile = '/tmp/cron_tz_state.txt'; $bkpDb = '/tmp/jobqueueTZ.db'; } return [$stateFile, $bkpDb]; } function detectOs(): string { $os = strtoupper(substr(PHP_OS, 0, 3)); if ($os === 'WIN') { return 'Windows'; } if (file_exists('/qopensys') || $os === 'AEX') { return 'IBMi'; } return 'Linux'; } function resolveIniPath(string $detectedOs): ?string { $iniPath = php_ini_loaded_file(); if ($iniPath && file_exists($iniPath)) { return $iniPath; } if ($detectedOs === 'IBMi') { preg_match('/^(?P\d+)\.(?P\d+)/', PHP_VERSION, $versionMatch); return "/qopensys/etc/php/{$versionMatch['major']}{$versionMatch['minor']}zend/php.ini"; } if ($detectedOs === 'Linux') { return '/opt/zend/zendphp/etc/php.ini'; } return null; } function readIniTimezone(?string $iniPath): ?string { $content = $iniPath && file_exists($iniPath) ? file_get_contents($iniPath) : ''; if (preg_match('/^\s*date\.timezone\s*=\s*["\']?([^"\';\s\r\n]+)/m', $content, $m)) { return trim($m[1]); } return null; } /** * @return array{targetTz: DateTimeZone, nextTransition: ?DateTime, forward: ?bool, error: ?string} */ function resolveTimezone(string $timezoneName): array { try { $targetTz = new DateTimeZone($timezoneName); } catch (Exception $e) { return [ 'targetTz' => new DateTimeZone('UTC'), 'nextTransition' => null, 'forward' => null, 'error' => $e->getMessage(), ]; } $transitions = $targetTz->getTransitions(time(), time() + 31536000); if (! is_array($transitions) || count($transitions) <= 1) { return [ 'targetTz' => $targetTz, 'nextTransition' => null, 'forward' => null, 'error' => null, ]; } $next = $transitions[1]; $dt = new DateTime($next['time'], new DateTimeZone('UTC')); $dt->setTimezone($targetTz); return [ 'targetTz' => $targetTz, 'nextTransition' => $dt, 'forward' => (bool) $next['isdst'], 'error' => null, ]; } function detectDbEngine(): string { $hqIni = '/opt/zend/zendphp/etc/zendhqd.ini'; if (! file_exists($hqIni)) { return 'sqlite'; } foreach (file($hqIni, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { $cleaned = trim($line); if (strpos($cleaned, ';') === 0 || strpos($cleaned, '#') === 0) { continue; } if (! preg_match('/^\s*include\s*=\s*["\']?([^"\';\s\r\n]+)/i', $cleaned, $m)) { continue; } $included = strtolower($m[1]); if (strpos($included, 'mysql') !== false || strpos($included, 'mariadb') !== false) { return 'mariadb'; } if (strpos($included, 'psql') !== false || strpos($included, 'postgres') !== false) { return 'postgres'; } if (strpos($included, 'sqlite') !== false) { return 'sqlite'; } } return 'sqlite'; } /** @return string[] missing PDO driver names, empty when all required drivers are present */ function findMissingDrivers(string $engine): array { $required = match ($engine) { 'sqlite' => ['sqlite'], 'mariadb' => ['mysql'], 'postgres' => ['pgsql'], default => [], }; $available = PDO::getAvailableDrivers(); return array_values( array_filter($required, static fn ($driver) => ! in_array($driver, $available, true)) ); } function readDbCredentials(string $engine): array { $creds = ['host' => '127.0.0.1', 'port' => '', 'user' => '', 'pass' => '', 'dbname' => 'zendhq']; if ($engine === 'sqlite') { return $creds; } $etcDir = '/opt/zend/zendphp/etc/'; $iniName = $engine === 'mariadb' ? 'mysql' : 'psql'; $subFile = "{$etcDir}zendhqd_{$iniName}.ini"; $iniKeys = [ 'host' => 'hostname', 'port' => 'port', 'user' => 'username', 'pass' => 'password', 'dbname' => 'dbname', ]; if (file_exists($subFile)) { $text = file_get_contents($subFile); foreach ($iniKeys as $key => $iniKey) { if (preg_match('/database\.' . $iniKey . '\s*=\s*(.*)/i', $text, $m)) { $creds[$key] = trim($m[1], " \t\n\r\0\x0B\"'"); } } } if ($creds['host'] === 'localhost') { $creds['host'] = '127.0.0.1'; } return $creds; } function checkDaemonStatus(string $uri): bool { $parsed = parse_url($uri); $host = $parsed['host'] ?? '127.0.0.1'; $port = $parsed['port'] ?? 10090; $socket = @fsockopen($host, $port, $errno, $errstr, 1.5); if ($socket === false) { return false; } fclose($socket); return true; } /** @return array{current: float, previous: float, direction: string} */ function computeShiftDirection(DateTimeZone $targetTz, string $stateFile): array { $currentOffset = $targetTz->getOffset(new DateTime('now', $targetTz)) / 3600; if (! file_exists($stateFile)) { @file_put_contents($stateFile, $currentOffset); @chmod($stateFile, 0777); } $prevOffset = file_exists($stateFile) ? (float) file_get_contents($stateFile) : $currentOffset; return [ 'current' => $currentOffset, 'previous' => $prevOffset, 'direction' => $currentOffset < $prevOffset ? 'backward' : 'forward', ]; } function persistOffset(string $stateFile, float $offset): void { @file_put_contents($stateFile, $offset); @chmod($stateFile, 0777); } /** @return array */ function getJobsAndMutate( string $engine, array $creds, string $direction, bool $apply, string $bkpDb ): array { try { if ($engine === 'sqlite') { $live = '/opt/zend/zendphp/var/db/jobqueue.db'; $targetDbFile = $live; if ($apply) { if (! empty($bkpDb) && file_exists($bkpDb)) { @unlink($bkpDb); } @copy($live, $bkpDb); @chmod($bkpDb, 0777); } $db = new PDO("sqlite:{$targetDbFile}"); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } else { $driverName = $engine === 'mariadb' ? 'mysql' : 'pgsql'; $portDsn = ! empty($creds['port']) ? ";port={$creds['port']}" : ''; $dsn = "{$driverName}:host={$creds['host']};dbname={$creds['dbname']}{$portDsn}"; $db = new PDO($dsn, $creds['user'], $creds['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); } // Universal parent query: gathers active jobs (status=1) across all engines using 'cron_schedule'. $selectJobsSql = 'SELECT id, cron_schedule FROM jobqueue_jobs WHERE cron_schedule IS NOT NULL AND status = 1'; $jobs = $db->query($selectJobsSql)->fetchAll(PDO::FETCH_ASSOC); $changes = []; if ($apply) { if ($engine === 'sqlite') { $db->exec('BEGIN IMMEDIATE TRANSACTION'); } else { $db->beginTransaction(); } } $updateJobSql = 'UPDATE jobqueue_jobs SET cron_schedule = :ns WHERE id = :id'; $updateTaskSql = 'UPDATE jobqueue_tasks SET cron_schedule = :ns WHERE job_id = :job_id AND status = 1'; $updJob = $apply ? $db->prepare($updateJobSql) : null; $updTask = $apply ? $db->prepare($updateTaskSql) : null; foreach ($jobs as $j) { $cron = trim($j['cron_schedule']); $parts = explode(' ', $cron); if (strpos($cron, '*/') !== false || count($parts) < 5) { continue; } $idx = isset($parts[2]) && is_numeric($parts[2]) ? 2 : 1; if (isset($parts[$idx]) && is_numeric($parts[$idx])) { $curr = (int) $parts[$idx]; $parts[$idx] = $direction === 'forward' ? ($curr + 1) % 24 : ($curr - 1 + 24) % 24; $new = implode(' ', $parts); $changes[] = ['id' => $j['id'], 'old' => $cron, 'new' => $new]; if ($apply) { $jobIdInt = (int) $j['id']; $updJob->execute([':ns' => $new, ':id' => $jobIdInt]); $updTask->execute([':ns' => $new, ':job_id' => $jobIdInt]); } } } if ($apply) { if ($engine === 'sqlite') { $db->exec('COMMIT'); } else { $db->commit(); } } return $changes; } catch (Exception $e) { if ($apply && isset($db)) { try { if ($engine === 'sqlite') { $db->exec('ROLLBACK'); } else { $db->rollBack(); } } catch (Exception) { } } throw $e; } } /* * Orchestration — wires context, logic, and renderer together. No HTML * or CLI-formatting details live below this line, only decisions about * which logic to run and which renderer method to hand the result to. */ $context = RequestContext::fromGlobals(); $renderer = $context->isCli ? new CliRenderer() : new HtmlRenderer(); [$stateFile, $bkpDb] = resolveStatePaths(); if ($context->get('action') === 'reset') { @unlink($stateFile); @unlink($bkpDb); if ($context->isCli) { $renderer->message('Baseline cleared.', 'success'); exit(0); } $renderer->redirect($context->selfUri); } $renderer->start(); $detectedOs = detectOs(); if ($detectedOs === 'WIN') { $renderer->message('ZendHQ does not exist for the Windows platform.', 'err'); exit(1); } $renderer->message("OS Architecture: {$detectedOs}"); $iniPath = resolveIniPath($detectedOs); if ($iniPath) { $renderer->message("php.ini Loaded: {$iniPath}"); } $timezoneName = readIniTimezone($iniPath); if ($context->has('override_tz')) { $timezoneName = (string) $context->get('override_tz'); } if (empty($timezoneName)) { if ($context->isCli) { $renderer->message(<<<'MSG' Timezone not configured. Re-run with --override-tz=Region/City (e.g. --override-tz=UTC). MSG, 'err'); exit(1); } $renderer->timezonePicker(DateTimeZone::listIdentifiers(), $context); exit; } $safeTimezoneName = htmlspecialchars($timezoneName, ENT_QUOTES, 'UTF-8'); $renderer->message("Active Timezone Eval: {$safeTimezoneName}"); $tzResolution = resolveTimezone($timezoneName); $targetTz = $tzResolution['targetTz']; if ($tzResolution['error'] !== null) { $renderer->message("TZ Error: {$tzResolution['error']}", 'err'); } elseif ($tzResolution['nextTransition'] !== null) { $transitionTime = $tzResolution['nextTransition']->format('Y-m-d H:i:s T'); $directionLabel = $tzResolution['forward'] ? 'Forward' : 'Backward'; $renderer->message("Next DST Switch: {$transitionTime} ({$directionLabel})"); } else { $renderer->message("DST transitions not tracked for {$timezoneName}."); } $engine = detectDbEngine(); $engineLabel = strtoupper($engine); $renderer->message("Database Target: {$engineLabel}"); $missingDrivers = findMissingDrivers($engine); if (! empty($missingDrivers)) { foreach ($missingDrivers as $driver) { $renderer->message(<<Missing Dependency Error: The PDO driver [pdo_{$driver}] is not installed or enabled in your active php.ini configuration. MSG, 'err'); } exit(1); } $daemonUri = (string) $context->get('daemon_uri', 'tcp://127.0.0.1:10090'); $daemonActive = checkDaemonStatus($daemonUri); $daemonStatusLabel = $daemonActive ? 'ACTIVE' : 'INACTIVE'; $daemonMessageType = $daemonActive ? 'success' : 'err'; $renderer->message("ZendHQ Daemon Status: {$daemonStatusLabel}", $daemonMessageType); $creds = readDbCredentials($engine); $shift = computeShiftDirection($targetTz, $stateFile); $currentOffset = $shift['current']; $prevOffset = $shift['previous']; $direction = $shift['direction']; $resetLink = [ 'url' => "{$context->selfUri}?action=reset", 'label' => 'Wipe Tracker Logs & Reset Baseline', 'class' => 'btn-grey', ]; try { $timeToShift = $currentOffset !== $prevOffset; $forceRun = $context->isCli || $context->get('force_apply') === true; $overrideTzValue = $context->has('override_tz') ? urlencode((string) $context->get('override_tz')) : null; $overrideTzQuery = $overrideTzValue !== null ? "&override_tz={$overrideTzValue}" : ''; if (! $context->has('confirm') && ! $context->isCli) { $changes = getJobsAndMutate($engine, $creds, $direction, false, $bkpDb); if ($timeToShift) { $shiftDirectionLabel = strtoupper($direction); $renderer->message("Clock Mismatch Detected! Direction: {$shiftDirectionLabel}", 'warn'); } else { $renderer->message(<<message('No active scheduled tasks (status=1) found to manipulate in the jobqueue schema.'); $renderer->linkList([$resetLink]); } else { $renderer->heading('Pre-Flight Job & Task Manipulation Simulation Matrix (Status = 1 Only)'); $renderer->previewTable($changes); $confirmLink = $timeToShift ? [ 'url' => "{$context->selfUri}?confirm=1{$overrideTzQuery}", 'label' => 'Execute Time Shift Alignment Automation', 'class' => 'btn-green', ] : [ 'url' => "{$context->selfUri}?confirm=1&force_apply=1{$overrideTzQuery}", 'label' => '⚠️ Override Warning: Apply to LIVE Production Anyway', 'class' => 'btn-red', ]; $renderer->linkList([$confirmLink, $resetLink]); } } else { if ($timeToShift || $forceRun) { $mutated = getJobsAndMutate($engine, $creds, $direction, true, $bkpDb); persistOffset($stateFile, $currentOffset); $mutatedCount = count($mutated); $renderer->message(<<{$mutatedCount} MSG, 'success'); $renderer->appliedTable($mutated); } else { $renderer->message('Execution Blocked: Current system offset matches standard log baselines.', 'err'); } $returnQuery = rtrim(ltrim($overrideTzQuery, '&'), '&'); $renderer->link("{$context->selfUri}?{$returnQuery}", 'Return to Control Panel', 'btn-blue'); } } catch (Exception $e) { $renderer->message("Fatal Processing Failure: {$e->getMessage()}", 'err'); }