shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.
The submodule caused every deployment failure this site has had:
- .gitmodules named no `branch`, so the update fell back to
refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
"Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
revision in submodule path".
- Its URL was http://, which the origin answers with 523.
- This install is deployed by uploading files, and a submodule is invisible
to that. The tree moved forward while shared/platform-tools stayed on the
old pin, config/functions_secrets.php died on a missing compat_secrets.php,
and the whole site — public pages included — went down.
Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.
The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2164 lines
122 KiB
PHP
2164 lines
122 KiB
PHP
<?php
|
||
// ─── Shared platform_update view ─────────────────────────────────────────────
|
||
//
|
||
// This file is loaded via include from each platform's
|
||
// views/sysadmin/platform_update.php stub. The stub is responsible for:
|
||
// 1. Loading the host's DB bootstrap (so $pdo is in scope)
|
||
// 2. Running the auth guard (require_sysadmin() or equivalent)
|
||
// 3. Setting $repo_root (absolute path to the host repo)
|
||
// 4. Optionally setting these override variables BEFORE the include:
|
||
// $platform_layout_header — absolute path to header include (default: $repo_root/includes/header.php)
|
||
// $platform_layout_navbar — absolute path to navbar include (default: $repo_root/includes/navbar.php)
|
||
// $platform_layout_footer — absolute path to footer include (default: $repo_root/includes/footer.php)
|
||
// $platform_dashboard_url — URL for the "Voltar" button (default: base_url('views/admin/dashboard.php') if available)
|
||
// $platform_error_log_file — absolute path to PHP error JSONL log (default: ERROR_LOG_FILE constant if defined, else null → card hidden)
|
||
// $GLOBALS['MIGRATIONS_DIR'] — override migrations dir (default: $repo_root/database)
|
||
//
|
||
// Optional integrations the page picks up if defined in the host:
|
||
// settings_cache_clear() — called after pull/migrations to invalidate settings cache
|
||
// base_url($path) — used to build the dashboard link
|
||
// env($key) — used by functions_git.php for HTTP credentials
|
||
|
||
require_once __DIR__ . '/functions_git.php';
|
||
require_once __DIR__ . '/functions_migrations.php';
|
||
require_once __DIR__ . '/functions_schema.php';
|
||
require_once __DIR__ . '/functions_composer.php';
|
||
require_once __DIR__ . '/functions_csrf.php';
|
||
require_once __DIR__ . '/functions_changelog.php';
|
||
require_once __DIR__ . '/functions_auto_update.php';
|
||
|
||
/**
|
||
* Read .gitmodules and probe each submodule's state.
|
||
*
|
||
* Returns array of:
|
||
* name — submodule name from [submodule "..."]
|
||
* path — relative to repo root
|
||
* url — remote URL
|
||
* branch — branch tracked for `submodule update --remote`
|
||
* initialized — bool; false if working tree is empty
|
||
* pinned_sha — SHA recorded in the parent's index (gitlink)
|
||
* wt_sha — SHA currently checked out inside the submodule
|
||
* remote_sha — SHA of origin/<branch> after the last fetch (empty if unfetched)
|
||
* pinned_info — "%cr|%s" of pinned commit (relative time + subject)
|
||
* remote_info — "%cr|%s" of remote head commit
|
||
* behind — int; commits between pinned and remote (0 = up to date)
|
||
* wt_dirty — bool; working tree of the submodule no longer at pinned (rare)
|
||
*/
|
||
function platform_submodules(string $repo_root): array {
|
||
$gm = $repo_root . '/.gitmodules';
|
||
if (!is_file($gm)) return [];
|
||
|
||
$raw = @file_get_contents($gm);
|
||
if ($raw === false) return [];
|
||
|
||
// Parse `[submodule "name"]` blocks manually — parse_ini_file chokes on
|
||
// section names with quotes/spaces.
|
||
$sections = [];
|
||
$current = null;
|
||
foreach (preg_split('/\R/', $raw) as $line) {
|
||
$line = trim($line);
|
||
if ($line === '' || str_starts_with($line, '#')) continue;
|
||
if (preg_match('/^\[submodule\s+"(.+)"\]$/', $line, $m)) {
|
||
$current = $m[1];
|
||
$sections[$current] = [];
|
||
continue;
|
||
}
|
||
if ($current && preg_match('/^([a-z]+)\s*=\s*(.+)$/i', $line, $m)) {
|
||
$sections[$current][strtolower($m[1])] = trim($m[2]);
|
||
}
|
||
}
|
||
|
||
$subs = [];
|
||
foreach ($sections as $name => $vals) {
|
||
$path = $vals['path'] ?? $name;
|
||
$url = $vals['url'] ?? '';
|
||
$branch = $vals['branch'] ?? '';
|
||
$full = $repo_root . '/' . $path;
|
||
|
||
$entry = [
|
||
'name' => $name, 'path' => $path, 'url' => $url,
|
||
'branch' => $branch ?: 'main',
|
||
'initialized' => false,
|
||
'pinned_sha' => '', 'wt_sha' => '', 'remote_sha' => '',
|
||
'pinned_info' => '', 'remote_info' => '',
|
||
'behind' => 0, 'wt_dirty' => false,
|
||
];
|
||
|
||
// A submodule is "initialized" when it has a .git entry (file or dir).
|
||
if (!file_exists($full . '/.git')) {
|
||
$subs[] = $entry;
|
||
continue;
|
||
}
|
||
$entry['initialized'] = true;
|
||
|
||
// Pinned SHA from the parent's HEAD tree (the gitlink).
|
||
$ls = git_run(['ls-tree', 'HEAD', $path], $repo_root);
|
||
if ($ls['ok'] && preg_match('/^\d{6}\s+commit\s+([0-9a-f]{40})\s/', $ls['output'], $m)) {
|
||
$entry['pinned_sha'] = $m[1];
|
||
}
|
||
|
||
// Working-tree SHA.
|
||
$rp = git_run(['rev-parse', 'HEAD'], $full);
|
||
if ($rp['ok'] && preg_match('/^[0-9a-f]{40}$/', trim($rp['output']))) {
|
||
$entry['wt_sha'] = trim($rp['output']);
|
||
}
|
||
|
||
// Resolve the branch to track. If .gitmodules didn't say, fall back to
|
||
// origin/HEAD's symbolic ref (set by `git remote set-head`), else "main".
|
||
if (!$branch) {
|
||
$sh = git_run(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], $full);
|
||
if ($sh['ok'] && $sh['output'] !== '') {
|
||
$b = trim($sh['output']);
|
||
$entry['branch'] = str_starts_with($b, 'origin/') ? substr($b, 7) : $b;
|
||
}
|
||
}
|
||
|
||
// Remote SHA (after fetch). Empty if origin/<branch> doesn't exist yet.
|
||
$rs = git_run(['rev-parse', '--verify', '--quiet', 'origin/' . $entry['branch']], $full);
|
||
if ($rs['ok'] && preg_match('/^[0-9a-f]{40}$/', trim($rs['output']))) {
|
||
$entry['remote_sha'] = trim($rs['output']);
|
||
}
|
||
|
||
// Commit details.
|
||
if ($entry['pinned_sha']) {
|
||
$li = git_run(['log', '-1', '--format=%cr|%s', $entry['pinned_sha']], $full);
|
||
if ($li['ok']) $entry['pinned_info'] = $li['output'];
|
||
}
|
||
if ($entry['remote_sha']) {
|
||
$li = git_run(['log', '-1', '--format=%cr|%s', $entry['remote_sha']], $full);
|
||
if ($li['ok']) $entry['remote_info'] = $li['output'];
|
||
}
|
||
|
||
// Commits between pinned and remote.
|
||
if ($entry['pinned_sha'] && $entry['remote_sha'] && $entry['pinned_sha'] !== $entry['remote_sha']) {
|
||
$rc = git_run(['rev-list', '--count', $entry['pinned_sha'] . '..' . $entry['remote_sha']], $full);
|
||
if ($rc['ok']) $entry['behind'] = (int) trim($rc['output']);
|
||
}
|
||
|
||
$entry['wt_dirty'] = $entry['pinned_sha'] && $entry['wt_sha']
|
||
&& $entry['pinned_sha'] !== $entry['wt_sha'];
|
||
|
||
$subs[] = $entry;
|
||
}
|
||
|
||
return $subs;
|
||
}
|
||
|
||
@set_time_limit(0);
|
||
|
||
if (!isset($repo_root) || !is_string($repo_root)) {
|
||
$repo_root = realpath(dirname(__DIR__, 2)) ?: dirname(__DIR__, 2);
|
||
}
|
||
$repo_root = rtrim($repo_root, '/');
|
||
|
||
// Tools that shell out (git, composer) need the proc_* family; shared hosts
|
||
// often kill it via disable_functions. When missing, those tools are hidden
|
||
// from the UI and their POSTs refused — migrations, esquema, OPcache e log de
|
||
// erros continuam totalmente funcionais.
|
||
$can_exec = platform_proc_open_available();
|
||
$git_available = $can_exec && file_exists($repo_root . '/.git');
|
||
|
||
$action_output = null;
|
||
$action_label = null;
|
||
$action_ok = null;
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$action = $_POST['action'] ?? '';
|
||
$uid = (int) ($_SESSION['user_id'] ?? ($_SESSION['user']['id'] ?? 0)) ?: null;
|
||
|
||
// Every action on this page mutates server state (reset --hard, composer
|
||
// update, migrations…) — none may run off a cross-site form post.
|
||
if (!platform_csrf_ok()) {
|
||
$action = '';
|
||
$action_label = 'Pedido rejeitado';
|
||
$action_ok = false;
|
||
$action_output = 'Token CSRF ausente ou inválido. Recarrega a página e repete a ação.';
|
||
}
|
||
|
||
// Defense-in-depth: a UI esconde estas ferramentas quando a capacidade
|
||
// falta, mas um POST direto tem de ser recusado na mesma — nunca fatal.
|
||
$git_actions = ['fetch', 'pull', 'sync_submodules', 'force_sync', 'discard_local',
|
||
'fix_filemode', 'stash_push', 'stash_pop', 'stash_drop', 'push',
|
||
'reset_upstream', 'submodule_fetch', 'submodule_update', 'switch_branch',
|
||
'auto_update_run', 'restore_point_revert'];
|
||
if ($action !== '' && in_array($action, $git_actions, true) && !$git_available) {
|
||
$action_label = 'Ação indisponível';
|
||
$action_ok = false;
|
||
$action_output = $can_exec
|
||
? 'Esta pasta não é um repositório git — inicializa com git init + remote.'
|
||
: 'proc_open() está desativado no PHP deste servidor (disable_functions) — as operações git via web não estão disponíveis.';
|
||
$action = '';
|
||
}
|
||
if (in_array($action, ['composer_install', 'composer_update', 'composer_check', 'composer_update_pkg'], true) && !$can_exec) {
|
||
$action_label = 'Ação indisponível';
|
||
$action_ok = false;
|
||
$action_output = 'proc_open() está desativado no PHP deste servidor (disable_functions) — o composer via web não está disponível.';
|
||
$action = '';
|
||
}
|
||
|
||
switch ($action) {
|
||
case 'fetch':
|
||
$res = git_run(['fetch', '--all', '--prune'], $repo_root);
|
||
$action_label = 'git fetch';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: ($res['ok'] ? '(sem alterações remotas)' : '(sem saída)');
|
||
break;
|
||
|
||
case 'pull':
|
||
// Robust, submodule-aware, mode-bit-tolerant update. Hardens
|
||
// core.fileMode first, tries a fast-forward, and only force-syncs
|
||
// when the divergence is provably nothing but mode/submodule drift.
|
||
// Real edits/commits are never discarded — it stops and reports them.
|
||
//
|
||
// O backup pré-atualização corre primeiro e é bloqueante: se estiver
|
||
// ligado, ou existe ponto de restauro ou não se mexe em nada. É o
|
||
// mesmo caminho que o webhook percorre.
|
||
$action_label = 'Atualizar plataforma';
|
||
$bk = platform_pre_update_backup($pdo, $repo_root);
|
||
if (!$bk['ok']) {
|
||
$action_ok = false;
|
||
$action_output = implode("\n", $bk['log'])
|
||
. "\n\nNada foi atualizado.";
|
||
break;
|
||
}
|
||
$before_sha = update_head_sha($repo_root);
|
||
$res = git_safe_update($repo_root);
|
||
$action_ok = $res['ok'];
|
||
$action_output = ($bk['log'] ? implode("\n", $bk['log']) . "\n\n" : '')
|
||
. ($res['output'] ?: '(sem saída)');
|
||
// Ponto de restauro só quando HEAD mexeu mesmo — ver
|
||
// update_restore_point_store(). Migrations aqui são um passo à parte
|
||
// (têm botões próprios), por isso a lista vai vazia.
|
||
$after_sha = update_head_sha($repo_root);
|
||
if ($res['ok'] && $before_sha !== '' && $after_sha !== '' && $before_sha !== $after_sha) {
|
||
update_restore_point_store($pdo, $repo_root, $before_sha, [], $bk['created']);
|
||
$action_output .= "\n\nPonto de restauro guardado: " . substr($before_sha, 0, 8)
|
||
. ' — podes voltar atrás no cartão "Ponto de restauro".';
|
||
}
|
||
// Invalidate bytecode cache so updated PHP files take effect
|
||
// immediately (only matters when opcache.validate_timestamps=0).
|
||
if ($res['ok'] && function_exists('opcache_reset')) {
|
||
@opcache_reset();
|
||
$action_output .= "\n\n(OPcache invalidado)";
|
||
}
|
||
// Settings cache (DSA/fotografia-style): drop so a setting that
|
||
// changed upstream is read fresh from the DB on next request.
|
||
if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
|
||
break;
|
||
|
||
case 'sync_submodules':
|
||
// Normalize the mode bit everywhere, then realign every submodule
|
||
// working tree to the SHA pinned by the parent (discarding only mode
|
||
// drift). Safe no-op when there are no submodules.
|
||
$hard = git_harden_repo($repo_root);
|
||
$sync = git_sync_submodules($repo_root);
|
||
$action_label = 'Sincronizar submódulos';
|
||
$action_ok = $sync['ok'];
|
||
$action_output = implode("\n", array_merge(
|
||
['── Normalização (core.fileMode) ──'],
|
||
$hard['log'],
|
||
['', '── Submódulos ──'],
|
||
$sync['log']
|
||
));
|
||
if ($sync['ok'] && function_exists('opcache_reset')) @opcache_reset();
|
||
if ($sync['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
|
||
break;
|
||
|
||
case 'force_sync':
|
||
// Destructive escape hatch: discard local state and hard-reset to
|
||
// origin/<branch>. Gated behind an explicit confirmation checkbox so
|
||
// it can never run by accident.
|
||
if (empty($_POST['confirm_force'])) {
|
||
$action_label = 'Sincronização forçada';
|
||
$action_ok = false;
|
||
$action_output = 'Recusado: marca "Confirmo descartar o estado local" para alinhar à força com o remoto.';
|
||
break;
|
||
}
|
||
$res = git_force_sync($repo_root);
|
||
$action_label = 'Sincronização forçada com o remoto';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: '(sem saída)';
|
||
if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset();
|
||
if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
|
||
break;
|
||
|
||
case 'switch_branch':
|
||
$target = trim((string) ($_POST['branch'] ?? ''));
|
||
if ($target === '' || !preg_match('#^[a-zA-Z0-9._/-]+$#', $target)) {
|
||
$action_label = 'Mudar de ramo';
|
||
$action_ok = false;
|
||
$action_output = 'Nome de ramo inválido.';
|
||
break;
|
||
}
|
||
$fetch = git_run(['fetch', 'origin', $target], $repo_root);
|
||
if (!$fetch['ok']) {
|
||
$action_label = 'Mudar de ramo: ' . $target;
|
||
$action_ok = false;
|
||
$action_output = 'fetch falhou: ' . trim((string) $fetch['output']);
|
||
break;
|
||
}
|
||
$checkout = git_run(['checkout', '-B', $target, 'origin/' . $target], $repo_root);
|
||
if (!$checkout['ok']) {
|
||
$action_label = 'Mudar de ramo: ' . $target;
|
||
$action_ok = false;
|
||
$action_output = 'checkout falhou: ' . trim((string) $checkout['output']);
|
||
break;
|
||
}
|
||
$sub = git_run(['submodule', 'update', '--init', '--recursive'], $repo_root);
|
||
$action_label = 'Mudar de ramo: ' . $target;
|
||
$action_ok = $sub['ok'];
|
||
$action_output = 'checkout ' . $target . ': ok' . PHP_EOL
|
||
. ($sub['ok'] ? 'submódulos: ok' : 'submódulos falharam: ' . trim((string) $sub['output']));
|
||
if ($sub['ok'] && function_exists('opcache_reset')) @opcache_reset();
|
||
if ($sub['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
|
||
break;
|
||
|
||
case 'composer_check':
|
||
// Network call -- only ever on explicit request, never on page load.
|
||
$out = composer_outdated($repo_root, true);
|
||
$_SESSION['platform_composer_outdated'] = $out + ['checked_at' => time()];
|
||
$action_label = 'composer outdated --direct';
|
||
$action_ok = $out['ok'];
|
||
if (!$out['ok']) {
|
||
$action_output = $out['error'];
|
||
} elseif (!$out['packages']) {
|
||
$action_output = $out['note'] ?? 'Todas as dependências diretas estão atualizadas.';
|
||
} else {
|
||
$lines = [];
|
||
foreach ($out['packages'] as $pk) {
|
||
$lines[] = sprintf('%-34s %-12s -> %-12s %s', $pk['name'], $pk['version'], $pk['latest'],
|
||
$pk['updatable']
|
||
? '(atualizável agora)'
|
||
: '(bloqueado pela restrição ' . ($pk['constraint'] ?: '?') . ')');
|
||
}
|
||
$action_output = implode("\n", $lines);
|
||
}
|
||
break;
|
||
|
||
case 'composer_update_pkg':
|
||
// The package name arrives from a POST. Validate it against what
|
||
// composer.lock actually contains before it becomes a shell
|
||
// argument -- never hand a request-supplied string to composer.
|
||
$want = (string) ($_POST['package'] ?? '');
|
||
$known = composer_known_packages($repo_root);
|
||
if ($want === '' || !in_array($want, $known, true)) {
|
||
$action_label = 'Atualizar pacote';
|
||
$action_ok = false;
|
||
$action_output = 'Pacote desconhecido: ' . $want;
|
||
break;
|
||
}
|
||
$res = composer_update_packages([$want], $repo_root);
|
||
$action_label = 'composer update ' . $want . ' --with-dependencies';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: '(sem saída)';
|
||
// The cached list is now stale for this package -- drop it so the
|
||
// page does not keep offering an update that already happened.
|
||
unset($_SESSION['platform_composer_outdated']);
|
||
break;
|
||
|
||
case 'composer_install':
|
||
// Rebuild vendor/ from composer.json + composer.lock (reproducible).
|
||
$res = composer_run(['install', '--no-dev', '--optimize-autoloader'], $repo_root);
|
||
$action_label = 'composer install --no-dev --optimize-autoloader';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'];
|
||
if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset();
|
||
break;
|
||
|
||
case 'composer_update':
|
||
// Resolve newer versions within the constraints + rewrite composer.lock.
|
||
unset($_SESSION['platform_composer_outdated']);
|
||
$res = composer_run(['update', '--no-dev', '--optimize-autoloader'], $repo_root);
|
||
$action_label = 'composer update --no-dev --optimize-autoloader';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'];
|
||
if ($res['ok'] && function_exists('opcache_reset')) @opcache_reset();
|
||
break;
|
||
|
||
case 'discard_local':
|
||
// `git reset --hard HEAD` forcefully realigns the working tree
|
||
// with HEAD for all tracked files. Stronger than `git restore .`,
|
||
// which can silently no-op on diffs caused by CRLF normalization
|
||
// or filesystem-mode quirks visible to PHP-FPM but not the CLI
|
||
// shell user. Gitignored content (.env, config/cache, uploads/…)
|
||
// is left alone.
|
||
$res = git_run(['reset', '--hard', 'HEAD'], $repo_root);
|
||
$action_label = 'Descartar alterações locais';
|
||
$action_ok = $res['ok'];
|
||
$combined = $res['output'];
|
||
|
||
// If a stubborn diff survives (e.g. CRLF mismatch with a permissive
|
||
// .gitattributes), force-extract every tracked file from the index.
|
||
if ($res['ok']) {
|
||
$check = git_run(['status', '--porcelain'], $repo_root);
|
||
if ($check['ok'] && $check['output'] !== '') {
|
||
$force = git_run(['checkout-index', '--force', '-a'], $repo_root);
|
||
$combined .= ($combined !== '' ? "\n" : '') . 'Estado ainda sujo após reset --hard — re-extracção forçada do index:';
|
||
if ($force['output'] !== '') $combined .= "\n" . $force['output'];
|
||
$action_ok = $force['ok'];
|
||
}
|
||
}
|
||
|
||
$action_output = $combined !== '' ? $combined : ($action_ok ? 'Alterações descartadas.' : '(sem saída)');
|
||
break;
|
||
|
||
case 'fix_filemode':
|
||
// Stop git from treating filesystem executable-bit differences as
|
||
// a diff. Common cause of "Descartar alterações locais" doing
|
||
// nothing — `git reset --hard` doesn't touch the local config, so
|
||
// the same phantom diff reappears immediately.
|
||
//
|
||
// Only per-repo config is written (main repo + every submodule via
|
||
// git_harden_repo). A `--global` write would be pointless here:
|
||
// git_run points GIT_CONFIG_GLOBAL at a throwaway temp file, so it
|
||
// would never persist.
|
||
$harden = git_harden_repo($repo_root);
|
||
$lines = $harden['log'];
|
||
|
||
$check = git_run(['status', '--porcelain'], $repo_root);
|
||
$lines[] = '';
|
||
$lines[] = 'git status --porcelain:';
|
||
$lines[] = $check['output'] !== '' ? $check['output'] : ' (working tree limpo)';
|
||
|
||
$action_label = 'Corrigir filemode';
|
||
$action_ok = $harden['ok'];
|
||
$action_output = implode("\n", $lines);
|
||
break;
|
||
|
||
case 'stash_push':
|
||
$msg = 'platform_update ' . date('Y-m-d H:i');
|
||
$res = git_run(['stash', 'push', '-u', '-m', $msg], $repo_root);
|
||
$action_label = 'git stash push';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: ($res['ok'] ? 'Alterações guardadas no stash.' : '(sem saída)');
|
||
break;
|
||
|
||
case 'stash_pop':
|
||
$idx = (string) ($_POST['stash_ref'] ?? 'stash@{0}');
|
||
if (!preg_match('/^stash@\{\d+\}$/', $idx)) $idx = 'stash@{0}';
|
||
$res = git_run(['stash', 'pop', $idx], $repo_root);
|
||
$action_label = 'git stash pop ' . $idx;
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: ($res['ok'] ? 'Stash aplicado.' : '(sem saída)');
|
||
break;
|
||
|
||
case 'stash_drop':
|
||
$idx = (string) ($_POST['stash_ref'] ?? '');
|
||
if (!preg_match('/^stash@\{\d+\}$/', $idx)) {
|
||
$action_label = 'git stash drop';
|
||
$action_ok = false;
|
||
$action_output = 'Referência de stash inválida.';
|
||
break;
|
||
}
|
||
$res = git_run(['stash', 'drop', $idx], $repo_root);
|
||
$action_label = 'git stash drop ' . $idx;
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: ($res['ok'] ? 'Stash removido.' : '(sem saída)');
|
||
break;
|
||
|
||
case 'push':
|
||
$force_protected = !empty($_POST['force_protected']);
|
||
$branch_now = trim(git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root)['output']);
|
||
$is_protected = in_array(strtolower($branch_now), ['main', 'master'], true);
|
||
if ($is_protected && !$force_protected) {
|
||
$action_label = 'git push';
|
||
$action_ok = false;
|
||
$action_output = "Recusado: o branch atual ({$branch_now}) é protegido. Marca \"Confirmo push de branch protegido\" se realmente queres empurrar a partir do servidor.";
|
||
break;
|
||
}
|
||
$res = git_run(['push'], $repo_root);
|
||
$action_label = 'git push';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: ($res['ok'] ? 'Push concluído.' : '(sem saída)');
|
||
break;
|
||
|
||
case 'reset_upstream':
|
||
$res = git_run(['reset', '--hard', '@{u}'], $repo_root);
|
||
$action_label = 'git reset --hard @{u}';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: ($res['ok'] ? 'Working tree alinhado com o upstream.' : '(sem saída)');
|
||
if ($res['ok']) {
|
||
if (function_exists('opcache_reset')) @opcache_reset();
|
||
if (function_exists('settings_cache_clear')) settings_cache_clear();
|
||
$action_output .= "\n\n(OPcache + settings cache invalidados)";
|
||
}
|
||
break;
|
||
|
||
case 'reset_opcache':
|
||
if (function_exists('opcache_reset')) {
|
||
@opcache_reset();
|
||
$action_label = 'opcache_reset()';
|
||
$action_ok = true;
|
||
$action_output = 'OPcache invalidado.';
|
||
} else {
|
||
$action_label = 'opcache_reset()';
|
||
$action_ok = false;
|
||
$action_output = 'OPcache não está disponível neste servidor (extensão não instalada ou desativada).';
|
||
}
|
||
break;
|
||
|
||
case 'apply_migration':
|
||
$file = $_POST['filename'] ?? '';
|
||
$res = migrations_apply_file($pdo, $file, $uid);
|
||
$action_label = 'Aplicar migration: ' . $file;
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['ok']
|
||
? "OK — {$res['applied']}/{$res['total']} statements aplicados."
|
||
: ($res['error'] . (isset($res['applied']) ? "\n(falhou após {$res['applied']}/{$res['total']} statements)" : ''));
|
||
// Migrations may seed new rows into system_settings.
|
||
if ($res['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
|
||
break;
|
||
|
||
case 'apply_pending':
|
||
$applied = []; $errors = [];
|
||
foreach (migrations_status($pdo) as $row) {
|
||
if ($row['applied_at']) continue;
|
||
$res = migrations_apply_file($pdo, $row['filename'], $uid);
|
||
if ($res['ok']) {
|
||
$applied[] = $row['filename'] . " ({$res['applied']}/{$res['total']})";
|
||
} else {
|
||
$errors[] = $row['filename'] . ': ' . $res['error'];
|
||
break; // stop on first failure — manual intervention needed
|
||
}
|
||
}
|
||
$action_label = 'Aplicar migrations pendentes';
|
||
$action_ok = empty($errors);
|
||
$lines = [];
|
||
if ($applied) $lines[] = "Aplicadas:\n " . implode("\n ", $applied);
|
||
if ($errors) $lines[] = "Erros:\n " . implode("\n ", $errors);
|
||
if (!$lines) $lines[] = 'Nada pendente.';
|
||
$action_output = implode("\n\n", $lines);
|
||
if ($applied && function_exists('settings_cache_clear')) settings_cache_clear();
|
||
break;
|
||
|
||
case 'dump_schema':
|
||
// Structure-only, deterministic export to database/schema.sql. No git
|
||
// (nem proc_open) needed to produce it — funciona mesmo em alojamento
|
||
// partilhado onde as ferramentas git estão ocultas.
|
||
$res = schema_dump_to_file($pdo);
|
||
$action_label = 'Exportar esquema → ' . basename(schema_dump_path());
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['ok']
|
||
? sprintf(
|
||
"OK — %d tabela(s)%s escritas em:\n%s\n(%s)",
|
||
$res['tables'],
|
||
$res['views'] ? " + {$res['views']} view(s)" : '',
|
||
$res['path'],
|
||
number_format($res['bytes']) . ' bytes'
|
||
)
|
||
: $res['error'];
|
||
break;
|
||
|
||
case 'auto_update_toggle':
|
||
$on = !auto_update_enabled($pdo);
|
||
platform_settings_set($pdo, 'auto_update_enabled', $on ? '1' : '0', $uid);
|
||
$action_label = $on ? 'Auto-update ligado' : 'Auto-update desligado';
|
||
$action_ok = true;
|
||
$action_output = $on
|
||
? "Cada push para este ramo passa a atualizar a plataforma.\nConfirma que o webhook está criado no Gitea e que AUTO_UPDATE_SECRET está no .env."
|
||
: 'Os pedidos do webhook passam a ser respondidos com "desligado", sem tocar no código.';
|
||
break;
|
||
|
||
case 'auto_update_migrations_toggle':
|
||
$on = !auto_update_migrations_enabled($pdo);
|
||
platform_settings_set($pdo, 'auto_update_migrations', $on ? '1' : '0', $uid);
|
||
$action_label = $on ? 'Migrations automáticas ligadas' : 'Migrations automáticas desligadas';
|
||
$action_ok = true;
|
||
$action_output = $on
|
||
? 'Um deploy passa a aplicar as migrations que vieram no mesmo commit.'
|
||
: 'O deploy passa a trazer só código — as migrations ficam pendentes para aplicares aqui.';
|
||
break;
|
||
|
||
case 'restore_point_revert':
|
||
// O SHA vem das definições, nunca do pedido: um POST forjado não
|
||
// consegue apontar isto para lado nenhum.
|
||
$res = update_restore_point_apply($pdo, $repo_root, !empty($_POST['confirm_discard']));
|
||
$action_label = 'Reverter atualização';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'];
|
||
break;
|
||
|
||
case 'restore_point_forget':
|
||
update_restore_point_clear($pdo);
|
||
$action_label = 'Ponto de restauro descartado';
|
||
$action_ok = true;
|
||
$action_output = 'A versão atual passa a ser o ponto de partida. '
|
||
. 'A próxima atualização volta a criar um ponto de restauro.';
|
||
break;
|
||
|
||
case 'update_backup_db_toggle':
|
||
case 'update_backup_files_toggle':
|
||
$which = $action === 'update_backup_db_toggle' ? 'db' : 'files';
|
||
$key = $which === 'db' ? 'update_backup_db' : 'update_backup_files';
|
||
$what = $which === 'db' ? 'da base de dados' : 'dos ficheiros (uploads)';
|
||
$on = !update_backup_settings($pdo)[$which];
|
||
platform_settings_set($pdo, $key, $on ? '1' : '0', $uid);
|
||
$action_label = 'Backup ' . $what . ' antes de atualizar: ' . ($on ? 'ligado' : 'desligado');
|
||
$action_ok = true;
|
||
$action_output = $on
|
||
? "Cada atualização — botão ou webhook — passa a esperar por este backup antes de tocar em código.\nSe o backup falhar ou demorar de mais, a atualização é cancelada e nada muda."
|
||
: 'As atualizações deixam de esperar por este backup.';
|
||
break;
|
||
|
||
case 'auto_update_run':
|
||
// O mesmo caminho que o webhook percorre, disparado à mão. É assim
|
||
// que se testa a configuração sem ter de fazer um push a sério.
|
||
$res = auto_update_run($pdo, $repo_root, null);
|
||
$action_label = 'Auto-update (execução manual)';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['summary'] . "\n\n" . $res['output'];
|
||
break;
|
||
|
||
case 'mark_all_applied':
|
||
$n = migrations_mark_all_applied($pdo, $uid);
|
||
$action_label = 'Marcar todas como aplicadas';
|
||
$action_ok = true;
|
||
$action_output = $n > 0 ? "{$n} migration(s) marcada(s) como aplicada(s)." : 'Nenhuma migration nova para marcar.';
|
||
break;
|
||
|
||
case 'submodule_fetch':
|
||
$sub_path = (string) ($_POST['submodule_path'] ?? '');
|
||
$valid = git_submodule_paths($repo_root);
|
||
if (!in_array($sub_path, $valid, true)) {
|
||
$action_label = 'Fetch submódulo';
|
||
$action_ok = false;
|
||
$action_output = 'Caminho de submódulo inválido.';
|
||
break;
|
||
}
|
||
$full = $repo_root . '/' . $sub_path;
|
||
$res = git_run(['fetch', 'origin', '--prune'], $full);
|
||
$action_label = 'git fetch (submódulo: ' . $sub_path . ')';
|
||
$action_ok = $res['ok'];
|
||
$action_output = $res['output'] ?: ($res['ok'] ? '(sem alterações remotas)' : '(sem saída)');
|
||
break;
|
||
|
||
case 'submodule_update':
|
||
$sub_path = (string) ($_POST['submodule_path'] ?? '');
|
||
$valid = git_submodule_paths($repo_root);
|
||
if (!in_array($sub_path, $valid, true)) {
|
||
$action_label = 'Atualizar submódulo';
|
||
$action_ok = false;
|
||
$action_output = 'Caminho de submódulo inválido.';
|
||
break;
|
||
}
|
||
// 1. Move the gitlink in the working tree to the latest remote ref.
|
||
$r1 = git_run(['submodule', 'update', '--remote', $sub_path], $repo_root);
|
||
if (!$r1['ok']) {
|
||
$action_label = 'Atualizar submódulo (' . $sub_path . ')';
|
||
$action_ok = false;
|
||
$action_output = $r1['output'] ?: '(sem saída)';
|
||
break;
|
||
}
|
||
$new_sha = trim(git_run(['rev-parse', 'HEAD'], $repo_root . '/' . $sub_path)['output']);
|
||
$short_sha = $new_sha ? substr($new_sha, 0, 7) : '???????';
|
||
|
||
// 2. Stage the gitlink change in the parent.
|
||
$r2 = git_run(['add', $sub_path], $repo_root);
|
||
if (!$r2['ok']) {
|
||
$action_label = 'Atualizar submódulo (' . $sub_path . ')';
|
||
$action_ok = false;
|
||
$action_output = "Bump OK, mas git add falhou:\n" . ($r2['output'] ?: '(sem saída)');
|
||
break;
|
||
}
|
||
|
||
// 3. If nothing was actually staged, we were already at HEAD.
|
||
$diff = git_run(['diff', '--cached', '--quiet', '--', $sub_path], $repo_root);
|
||
if ($diff['ok']) {
|
||
$action_label = 'Atualizar submódulo (' . $sub_path . ')';
|
||
$action_ok = true;
|
||
$action_output = 'Já estava no commit mais recente (' . $short_sha . '). Nada para committar.';
|
||
break;
|
||
}
|
||
|
||
// 4. Commit the bump. Inject identity flags so this works even
|
||
// when the server has no user.email/user.name configured.
|
||
$r3 = git_run([
|
||
'-c', 'user.email=platform-update@local',
|
||
'-c', 'user.name=Platform Update',
|
||
'commit', '-m', "Bump {$sub_path} to {$short_sha}"
|
||
], $repo_root);
|
||
$action_label = 'Atualizar submódulo (' . $sub_path . ')';
|
||
$action_ok = $r3['ok'];
|
||
$action_output = ($r3['ok']
|
||
? "Submódulo {$sub_path} atualizado para {$short_sha}.\nCommit criado — usa o botão git push para publicar."
|
||
: 'Bump aplicado, mas o commit falhou:')
|
||
. ($r3['output'] !== '' ? "\n\n" . $r3['output'] : '');
|
||
if ($r3['ok'] && function_exists('opcache_reset')) @opcache_reset();
|
||
if ($r3['ok'] && function_exists('settings_cache_clear')) settings_cache_clear();
|
||
break;
|
||
|
||
case 'clear_error_log':
|
||
$logFile = $platform_error_log_file
|
||
?? (defined('ERROR_LOG_FILE') ? ERROR_LOG_FILE : null);
|
||
if ($logFile) {
|
||
@file_put_contents($logFile, '');
|
||
$action_label = 'Limpar log de erros';
|
||
$action_ok = true;
|
||
$action_output = 'Log de erros PHP limpo.';
|
||
} else {
|
||
$action_label = 'Limpar log de erros';
|
||
$action_ok = false;
|
||
$action_output = 'Sem ficheiro de log configurado (define ERROR_LOG_FILE ou $platform_error_log_file).';
|
||
}
|
||
break;
|
||
}
|
||
|
||
// ── Post/Redirect/Get ────────────────────────────────────────────────────
|
||
// Stash the result in the session and redirect to a GET of the same URL, so
|
||
// a browser refresh re-issues the GET instead of re-POSTing the action
|
||
// (no "Confirm form resubmission?" prompt, no accidental double-apply).
|
||
if ($action_label !== null) {
|
||
$_SESSION['platform_update_flash'] = [
|
||
'label' => $action_label,
|
||
'ok' => $action_ok,
|
||
'output' => $action_output,
|
||
];
|
||
}
|
||
header('Location: ' . $_SERVER['REQUEST_URI'], true, 303);
|
||
exit;
|
||
}
|
||
|
||
// On the GET that follows a POST, surface the flash the action left behind.
|
||
if ($action_label === null && !empty($_SESSION['platform_update_flash'])) {
|
||
$flash = $_SESSION['platform_update_flash'];
|
||
unset($_SESSION['platform_update_flash']);
|
||
$action_label = $flash['label'] ?? null;
|
||
$action_ok = $flash['ok'] ?? null;
|
||
$action_output = $flash['output'] ?? null;
|
||
}
|
||
|
||
// ─── Gather state for the page ───────────────────────────────────────────────
|
||
$git_branch = $git_available ? git_run(['rev-parse', '--abbrev-ref', 'HEAD'], $repo_root)['output'] : '—';
|
||
$remote_branches = $git_available ? git_remote_branches($repo_root) : [];
|
||
$git_sha = $git_available ? git_run(['rev-parse', '--short', 'HEAD'], $repo_root)['output'] : '—';
|
||
$git_remote = $git_available ? git_run(['config', '--get', 'remote.origin.url'], $repo_root)['output'] : '';
|
||
$git_last = $git_available
|
||
? git_run(['log', '-1', '--format=%cd|%s', '--date=format:%Y-%m-%d %H:%M:%S'], $repo_root)['output']
|
||
: '';
|
||
[$last_date, $last_msg] = $git_last !== '' ? array_pad(explode('|', $git_last, 2), 2, '') : ['', ''];
|
||
|
||
// Harden BEFORE reading status: setting core.fileMode=false in the main repo and
|
||
// every submodule makes the phantom `old mode → new mode` diffs (and the
|
||
// resulting `modified: <submodule>`) disappear, so the status shown below
|
||
// reflects only real changes. Idempotent — must run on every load because
|
||
// core.fileMode is local config that doesn't travel in commits.
|
||
$filemode_normalized = null;
|
||
$filemode_log = [];
|
||
if ($git_available) {
|
||
$harden = git_harden_repo($repo_root);
|
||
$filemode_normalized = $harden['normalized'];
|
||
$filemode_log = $harden['log'];
|
||
}
|
||
|
||
$git_dirty = false;
|
||
$git_dirty_files = '';
|
||
if ($git_available) {
|
||
$status = git_run(['status', '--porcelain'], $repo_root);
|
||
$git_dirty_files = $status['ok'] ? $status['output'] : '';
|
||
$git_dirty = $git_dirty_files !== '';
|
||
}
|
||
|
||
$git_ahead = 0; $git_behind = 0;
|
||
if ($git_available) {
|
||
$rev = git_run(['rev-list', '--left-right', '--count', '@{u}...HEAD'], $repo_root);
|
||
if ($rev['ok'] && preg_match('/^(\d+)\s+(\d+)$/', $rev['output'], $m)) {
|
||
$git_behind = (int) $m[1];
|
||
$git_ahead = (int) $m[2];
|
||
}
|
||
}
|
||
|
||
$git_stash_list = [];
|
||
if ($git_available) {
|
||
$sl = git_run(['stash', 'list', '--format=%gd|%s|%cr'], $repo_root);
|
||
if ($sl['ok'] && $sl['output'] !== '') {
|
||
foreach (explode("\n", $sl['output']) as $line) {
|
||
$parts = explode('|', $line, 3);
|
||
if (count($parts) >= 2) {
|
||
$git_stash_list[] = [
|
||
'ref' => $parts[0],
|
||
'desc' => $parts[1],
|
||
'when' => $parts[2] ?? '',
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$git_log = '';
|
||
if ($git_available) {
|
||
$lg = git_run(
|
||
['log', '-n', '20', '--format=%h %cd %s', '--date=format:%Y-%m-%d %H:%M'],
|
||
$repo_root
|
||
);
|
||
if ($lg['ok']) $git_log = $lg['output'];
|
||
}
|
||
|
||
$git_diff = '';
|
||
if ($git_available && $git_dirty) {
|
||
$df = git_run(['diff', '--no-color'], $repo_root);
|
||
if ($df['ok']) $git_diff = $df['output'];
|
||
}
|
||
|
||
$git_protected_branch = in_array(strtolower((string) $git_branch), ['main', 'master'], true);
|
||
|
||
// Divergence that a plain fast-forward can't resolve: the remote has commits we
|
||
// don't, AND we have either local commits or uncommitted changes in the way.
|
||
// "Atualizar" handles the mode-only case automatically; this only gates the
|
||
// manual destructive escape hatch (force sync) for the real-divergence case.
|
||
$git_diverged = $git_available && $git_behind > 0 && ($git_ahead > 0 || $git_dirty);
|
||
|
||
$migrations = migrations_status($pdo);
|
||
$pending = array_filter($migrations, fn($r) => $r['applied_at'] === null);
|
||
$applied_n = count($migrations) - count($pending);
|
||
|
||
// Pendentes primeiro. A lista só cresce, e com dezenas de ficheiros aplicados a
|
||
// única linha que interessa — a que ainda não correu — acabava fora do ecrã.
|
||
// usort mantém a ordem por nome dentro de cada grupo (ordenação estável no PHP 8).
|
||
usort($migrations, fn($a, $b) => ($a['applied_at'] === null ? 0 : 1) <=> ($b['applied_at'] === null ? 0 : 1));
|
||
|
||
$submodules = $git_available ? platform_submodules($repo_root) : [];
|
||
|
||
// ─── Auto-update (webhook de deploy) ─────────────────────────────────────────
|
||
$au_on = auto_update_enabled($pdo);
|
||
$au_migrations = auto_update_migrations_enabled($pdo);
|
||
$au_secret_set = auto_update_secret() !== '';
|
||
$au_last = auto_update_last($pdo);
|
||
// O URL que se cola no Gitea. base_url() aponta para a docroot pública, que é
|
||
// exactamente onde vive o stub api/auto_update.php.
|
||
$au_hook_url = function_exists('base_url') ? base_url('api/auto_update.php') : '/api/auto_update.php';
|
||
|
||
$restore_point = update_restore_point($pdo);
|
||
|
||
// ─── Backup antes de atualizar ───────────────────────────────────────────────
|
||
$ub = update_backup_settings($pdo);
|
||
// O último backup de cada tipo serve de estimativa do custo: "ligar isto vai
|
||
// juntar 4 GB e uns minutos a cada deploy" é uma coisa que se deve ver ANTES de
|
||
// ligar, não descobrir no primeiro push.
|
||
$ub_last = ['db' => null, 'files' => null];
|
||
if (platform_backup_bootstrap($repo_root)) {
|
||
foreach (backup_list() as $b) {
|
||
if ($ub_last[$b['type']] === null) $ub_last[$b['type']] = $b;
|
||
}
|
||
}
|
||
|
||
$composer = composer_state($repo_root);
|
||
// Only probe the binary when the card will actually render (and when the
|
||
// server can spawn processes at all).
|
||
$composer_avail = ($composer['has_json'] && $can_exec) ? composer_available($repo_root) : null;
|
||
// Populated only by the explicit "Verificar atualizações" action -- composer
|
||
// outdated hits the network and must not run on every page load.
|
||
$composer_outdated = $_SESSION['platform_composer_outdated'] ?? null;
|
||
|
||
// ─── Error log (optional) ────────────────────────────────────────────────────
|
||
$error_log_file = $platform_error_log_file
|
||
?? (defined('ERROR_LOG_FILE') ? ERROR_LOG_FILE : null);
|
||
$error_log_entries = [];
|
||
$error_log_total = 0;
|
||
if ($error_log_file && is_readable($error_log_file)) {
|
||
$lines = file($error_log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||
$error_log_total = count($lines);
|
||
foreach (array_reverse(array_slice($lines, -200)) as $line) {
|
||
$entry = json_decode($line, true);
|
||
if (is_array($entry)) $error_log_entries[] = $entry;
|
||
}
|
||
}
|
||
|
||
// ─── Layout resolution ───────────────────────────────────────────────────────
|
||
$layout_header = $platform_layout_header ?? ($repo_root . '/includes/header.php');
|
||
$layout_navbar = $platform_layout_navbar ?? ($repo_root . '/includes/navbar.php');
|
||
$layout_footer = $platform_layout_footer ?? ($repo_root . '/includes/footer.php');
|
||
|
||
$dashboard_url = $platform_dashboard_url
|
||
?? (function_exists('base_url') ? base_url('views/admin/dashboard.php') : 'dashboard.php');
|
||
|
||
$pageTitle = 'Atualizar plataforma';
|
||
if (is_file($layout_header)) include $layout_header;
|
||
if (is_file($layout_navbar)) include $layout_navbar;
|
||
?>
|
||
<style>
|
||
/* Tabelas com altura limitada: a página nunca cresce sem limite, e o
|
||
cabeçalho fica fixo durante o scroll. */
|
||
.pt-scroll { max-height: 380px; overflow: auto; }
|
||
.pt-scroll thead th { position: sticky; top: 0; z-index: 1; }
|
||
/* Cabeçalho de card clicável (acordeões). */
|
||
.pt-toggle { cursor: pointer; }
|
||
.pt-toggle .pt-chevron { transition: transform .2s ease; }
|
||
.pt-toggle[aria-expanded="true"] .pt-chevron { transform: rotate(180deg); }
|
||
|
||
/* Cabeçalho de estado: um filete da marca chega para o separar dos cartões
|
||
de trabalho sem lhe dar o peso de mais uma caixa colorida. */
|
||
.pt-hero { border-top: 3px solid var(--am-photo-gold, var(--bs-primary)); }
|
||
|
||
/* Separadores. Em ecrã estreito rolam na horizontal: quatro etiquetas a
|
||
partir em duas ou três linhas ocupavam meia página antes do conteúdo. */
|
||
.pt-tabs { flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
|
||
.pt-tabs::-webkit-scrollbar { display: none; }
|
||
.pt-tabs .nav-link { white-space: nowrap; }
|
||
</style>
|
||
<div class="container py-4" style="max-width:1200px;">
|
||
<?php
|
||
// ── Cabeçalho de estado ──────────────────────────────────────────────
|
||
// O que se vem cá saber (que versão está de pé, se está atualizada, se há
|
||
// trabalho local no caminho) e a ação principal, os dois no mesmo sítio.
|
||
// Antes era preciso descer até ao cartão Git para perceber o estado e para
|
||
// encontrar o botão.
|
||
$changelog = changelog_latest();
|
||
$hero_pills = [];
|
||
if ($git_available) {
|
||
if ($git_behind > 0) {
|
||
$hero_pills[] = ['warning text-dark', 'bi-cloud-arrow-down', $git_behind . ' por atualizar'];
|
||
} elseif (!$git_dirty && $git_ahead === 0) {
|
||
$hero_pills[] = ['success', 'bi-check2-circle', 'atualizado'];
|
||
}
|
||
if ($git_dirty) $hero_pills[] = ['danger', 'bi-pencil', 'alterações locais'];
|
||
if ($git_ahead > 0) $hero_pills[] = ['info', 'bi-cloud-arrow-up', $git_ahead . ' por enviar'];
|
||
}
|
||
if ($pending) $hero_pills[] = ['warning text-dark', 'bi-database', count($pending) . ' migration(s)'];
|
||
if ($au_on) $hero_pills[] = ['success', 'bi-arrow-repeat', 'auto-update'];
|
||
if ($ub['db'] || $ub['files']) $hero_pills[] = ['secondary', 'bi-shield-check', 'backup pré-update'];
|
||
?>
|
||
<div class="card shadow-sm mb-3 pt-hero">
|
||
<div class="card-body d-flex flex-wrap align-items-center gap-3">
|
||
<div class="me-auto" style="min-width:15rem">
|
||
<div class="text-uppercase text-muted" style="font-size:.7rem;letter-spacing:.1em">Atualizar plataforma</div>
|
||
<div class="d-flex align-items-baseline gap-2 flex-wrap mt-1">
|
||
<span class="fs-4 fw-semibold">
|
||
<?= $changelog['version'] !== null ? htmlspecialchars($changelog['version']) : 'Versão não declarada' ?>
|
||
</span>
|
||
<?php if ($git_available && $git_branch): ?>
|
||
<code class="small"><?= htmlspecialchars($git_branch) ?></code>
|
||
<?php endif; ?>
|
||
<?php if ($git_sha): ?>
|
||
<span class="text-muted small font-monospace"><?= htmlspecialchars($git_sha) ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php if ($hero_pills): ?>
|
||
<div class="d-flex gap-2 flex-wrap mt-2">
|
||
<?php foreach ($hero_pills as [$cls, $icon, $text]): ?>
|
||
<span class="badge text-bg-<?= $cls ?>"><i class="bi <?= $icon ?> me-1"></i><?= htmlspecialchars($text) ?></span>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||
<?php if ($git_available): ?>
|
||
<form method="POST" class="m-0" onsubmit="return confirm('Atualizar a plataforma?\n\n• normaliza core.fileMode (repo + submódulos)\n• git pull --ff-only\n• sincroniza submódulos\n\nNão destrutivo: se houver alterações locais reais, pára e mostra-as sem apagar nada.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="pull">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="bi bi-arrow-down-circle me-1"></i>Atualizar
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<?php if ($restore_point): ?>
|
||
<a href="#tab-atualizar" class="btn btn-outline-danger" onclick="ptShowTab('atualizar')">
|
||
<i class="bi bi-arrow-counterclockwise me-1"></i>Reverter
|
||
</a>
|
||
<?php endif; ?>
|
||
<a href="<?= htmlspecialchars($dashboard_url) ?>" class="btn btn-outline-secondary">Voltar</a>
|
||
</div>
|
||
</div>
|
||
<?php if ($changelog['changes']): ?>
|
||
<div class="collapse" id="heroChanges">
|
||
<div class="card-body border-top pt-3">
|
||
<ul class="mb-0 small">
|
||
<?php foreach ($changelog['changes'] as $ch): ?>
|
||
<li><?= htmlspecialchars($ch) ?></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div class="card-footer py-1 pt-toggle" data-bs-toggle="collapse" data-bs-target="#heroChanges"
|
||
role="button" aria-expanded="false" aria-controls="heroChanges">
|
||
<span class="small text-muted">
|
||
<i class="bi bi-chevron-down small pt-chevron me-1"></i>O que mudou nesta versão
|
||
</span>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php if (!$can_exec): ?>
|
||
<div class="alert alert-warning">
|
||
<strong>Execução de processos indisponível neste servidor.</strong>
|
||
O PHP tem <code>proc_open()</code> desativado (<code>disable_functions</code> no php.ini), por isso as
|
||
ferramentas git e composer estão ocultas. Migrations, esquema, OPcache e log de erros continuam disponíveis.
|
||
</div>
|
||
<?php elseif (!$git_available): ?>
|
||
<div class="alert alert-warning">
|
||
<strong>Esta pasta não é um repositório git.</strong>
|
||
Inicializa com <code>git init</code> + remote para o Gitea. A actualização via web só funciona quando existe um <code>.git</code>.
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($action_label !== null): ?>
|
||
<div class="alert alert-<?= $action_ok ? 'success' : 'danger' ?>">
|
||
<strong><?= htmlspecialchars($action_label) ?></strong>
|
||
<pre class="mb-0 mt-2 small" style="white-space:pre-wrap;max-height:280px;overflow:auto;"><?= htmlspecialchars($action_output ?? '') ?></pre>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($git_stash_list)): ?>
|
||
<!-- Persistent stash banner: stashed work is invisible until popped, so we surface it. -->
|
||
<div class="alert alert-warning">
|
||
<strong><i class="bi bi-archive me-1"></i>Stashes guardados (<?= count($git_stash_list) ?>):</strong>
|
||
<ul class="mb-2 mt-2 small">
|
||
<?php foreach ($git_stash_list as $st): ?>
|
||
<li class="d-flex flex-wrap gap-2 align-items-center">
|
||
<code><?= htmlspecialchars($st['ref']) ?></code>
|
||
<span><?= htmlspecialchars($st['desc']) ?></span>
|
||
<?php if ($st['when']): ?><span class="text-muted">(<?= htmlspecialchars($st['when']) ?>)</span><?php endif; ?>
|
||
<span class="ms-auto d-flex gap-1">
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('Aplicar <?= htmlspecialchars(addslashes($st['ref'])) ?> ao working tree e remover do stash?');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="stash_pop">
|
||
<input type="hidden" name="stash_ref" value="<?= htmlspecialchars($st['ref']) ?>">
|
||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||
<i class="bi bi-box-arrow-up"></i> Pop
|
||
</button>
|
||
</form>
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('Apagar <?= htmlspecialchars(addslashes($st['ref'])) ?>? Esta acção não pode ser desfeita.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="stash_drop">
|
||
<input type="hidden" name="stash_ref" value="<?= htmlspecialchars($st['ref']) ?>">
|
||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||
title="Apagar stash" aria-label="Apagar <?= htmlspecialchars($st['ref']) ?>">
|
||
<i class="bi bi-trash"></i>
|
||
</button>
|
||
</form>
|
||
</span>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<div class="small mb-0">Pop aplica o stash ao working tree e remove-o; drop apaga sem aplicar.</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php
|
||
// Depois de um POST volta-se ao separador onde a ação foi pedida — cair
|
||
// sempre no primeiro obrigava a reencontrar o sítio a cada clique.
|
||
$tab_of_action = [
|
||
'atualizar' => ['fetch', 'pull', 'sync_submodules', 'force_sync', 'switch_branch', 'discard_local',
|
||
'fix_filemode', 'stash_push', 'stash_pop', 'stash_drop', 'push', 'reset_upstream',
|
||
'submodule_fetch', 'submodule_update', 'restore_point_revert', 'restore_point_forget'],
|
||
'automacao' => ['auto_update_toggle', 'auto_update_migrations_toggle', 'auto_update_run',
|
||
'update_backup_db_toggle', 'update_backup_files_toggle'],
|
||
'dados' => ['apply_migration', 'apply_pending', 'mark_all_applied', 'dump_schema'],
|
||
'servidor' => ['composer_check', 'composer_install', 'composer_update', 'composer_update_pkg',
|
||
'reset_opcache', 'clear_error_log'],
|
||
];
|
||
$active_tab = 'atualizar';
|
||
$posted = (string) ($_POST['action'] ?? '');
|
||
foreach ($tab_of_action as $tab => $acts) {
|
||
if ($posted !== '' && in_array($posted, $acts, true)) { $active_tab = $tab; break; }
|
||
}
|
||
$tabs = [
|
||
'atualizar' => ['Atualizar', 'bi-arrow-down-circle', $git_available ? null : 'indisponível'],
|
||
'automacao' => ['Automação', 'bi-robot', $au_on ? 'on' : null],
|
||
'dados' => ['Base de dados', 'bi-database', $pending ? (string) count($pending) : null],
|
||
'servidor' => ['Servidor', 'bi-hdd-stack', $error_log_entries ? (string) count($error_log_entries) : null],
|
||
];
|
||
?>
|
||
<ul class="nav nav-tabs pt-tabs mb-3" role="tablist">
|
||
<?php foreach ($tabs as $id => [$label, $icon, $badge]): ?>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link <?= $active_tab === $id ? 'active' : '' ?>" type="button"
|
||
id="tabbtn-<?= $id ?>" data-bs-toggle="tab" data-bs-target="#tab-<?= $id ?>"
|
||
role="tab" aria-controls="tab-<?= $id ?>"
|
||
aria-selected="<?= $active_tab === $id ? 'true' : 'false' ?>">
|
||
<i class="bi <?= $icon ?> me-1"></i><?= htmlspecialchars($label) ?>
|
||
<?php if ($badge !== null): ?>
|
||
<span class="badge text-bg-secondary ms-1"><?= htmlspecialchars($badge) ?></span>
|
||
<?php endif; ?>
|
||
</button>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
|
||
<div class="tab-content">
|
||
<div class="tab-pane show active" id="tab-atualizar" role="tabpanel" aria-labelledby="tabbtn-atualizar" tabindex="0">
|
||
<!-- ── Ponto de restauro ──────────────────────────────────────────── -->
|
||
<?php if ($restore_point): ?>
|
||
<div class="card shadow-sm mb-3 border-warning">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<strong><i class="bi bi-arrow-counterclockwise me-1"></i>Ponto de restauro</strong>
|
||
<span class="badge text-bg-warning text-dark">disponível</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="small text-muted mb-3">
|
||
A última atualização partiu daqui. Voltar atrás põe o servidor exactamente no código que
|
||
estava antes — os commits novos ficam no remote e podem ser puxados outra vez a seguir.
|
||
</p>
|
||
<div class="small">
|
||
<div>
|
||
<span class="text-muted">Commit:</span>
|
||
<code><?= htmlspecialchars($restore_point['short']) ?></code>
|
||
<?php if ($restore_point['commit_at']): ?>
|
||
<span class="text-muted ms-1"><?= htmlspecialchars($restore_point['commit_at']) ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php if ($restore_point['subject']): ?>
|
||
<div class="text-muted text-truncate" title="<?= htmlspecialchars($restore_point['subject']) ?>">
|
||
<?= htmlspecialchars($restore_point['subject']) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php if ($restore_point['updated_at']): ?>
|
||
<div class="text-muted mt-1">
|
||
Atualizado em <?= htmlspecialchars(date('Y-m-d H:i', strtotime($restore_point['updated_at'] . ' UTC'))) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php if ($restore_point['migrations']): ?>
|
||
<div class="alert alert-warning small mt-3 mb-0 py-2">
|
||
<strong><i class="bi bi-database-exclamation me-1"></i>A base de dados não recua com o código.</strong>
|
||
Estas migrations correram nesta atualização e continuam aplicadas:
|
||
<ul class="mb-1 mt-1">
|
||
<?php foreach ($restore_point['migrations'] as $m): ?>
|
||
<li><code><?= htmlspecialchars($m) ?></code></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php if ($restore_point['backups']): ?>
|
||
Backup tirado imediatamente antes:
|
||
<?php foreach ($restore_point['backups'] as $b): ?>
|
||
<code><?= htmlspecialchars($b) ?></code>
|
||
<?php endforeach; ?>
|
||
— restaura-o pela página <em>Restaurar base de dados</em> se o esquema novo for o problema.
|
||
<?php else: ?>
|
||
Não há backup pré-atualização deste deploy; liga-o no separador <em>Automação</em> para o próximo.
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="card-footer d-flex gap-2 flex-wrap align-items-center">
|
||
<form method="POST" class="m-0 d-flex gap-2 flex-wrap align-items-center"
|
||
onsubmit="return confirm('Voltar ao commit <?= htmlspecialchars(addslashes($restore_point['short'])) ?>?\n\nO código do servidor recua. As migrations já aplicadas NÃO são desfeitas.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="restore_point_revert">
|
||
<button type="submit" class="btn btn-warning btn-sm">
|
||
<i class="bi bi-arrow-counterclockwise me-1"></i>Reverter para <?= htmlspecialchars($restore_point['short']) ?>
|
||
</button>
|
||
<?php // Sem isto, um reset --hard apagava alterações por comitar — e quem
|
||
// está a depurar um deploy mau é precisamente quem pode ter editado
|
||
// um ficheiro no servidor. ?>
|
||
<div class="form-check form-check-inline m-0 small">
|
||
<input class="form-check-input" type="checkbox" name="confirm_discard" id="rpDiscard" value="1">
|
||
<label class="form-check-label text-muted" for="rpDiscard">Descartar alterações locais</label>
|
||
</div>
|
||
</form>
|
||
<form method="POST" class="m-0 ms-auto"
|
||
onsubmit="return confirm('Descartar o ponto de restauro? A versão atual passa a ser o ponto de partida.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="restore_point_forget">
|
||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||
<i class="bi bi-check2 me-1"></i>Está bem assim
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<!-- ── Git status & pull (só quando o servidor consegue correr git) ── -->
|
||
<?php if ($git_available): ?>
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<strong><i class="bi bi-git me-1"></i>Git</strong>
|
||
<?php if ($filemode_normalized !== null): ?>
|
||
<span class="small" title="<?= htmlspecialchars(implode("\n", $filemode_log)) ?>">
|
||
<?php if ($filemode_normalized): ?>
|
||
<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>core.fileMode normalizado</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>core.fileMode não normalizado</span>
|
||
<?php endif; ?>
|
||
</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row g-3 small">
|
||
<div class="col-md-6">
|
||
<div><span class="text-muted">Branch:</span> <code><?= htmlspecialchars($git_branch ?: '—') ?></code></div>
|
||
<form method="POST" class="mt-2 d-flex gap-2 flex-wrap"
|
||
onsubmit="return confirm('Mudar para o ramo selecionado? Faz fetch + checkout + sync de submódulos.');">
|
||
<?= platform_csrf_field() ?>
|
||
<input type="hidden" name="action" value="switch_branch">
|
||
<select name="branch" class="form-select form-select-sm" style="width:auto;min-width:170px;">
|
||
<option value="">— Mudar de ramo —</option>
|
||
<?php foreach ($remote_branches as $rb): ?>
|
||
<option value="<?= htmlspecialchars($rb) ?>" <?= $rb === $git_branch ? 'selected' : '' ?>><?= htmlspecialchars($rb) ?></option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="bi bi-arrow-left-right me-1"></i>Mudar</button>
|
||
</form>
|
||
<div><span class="text-muted">Commit actual:</span> <code><?= htmlspecialchars($git_sha ?: '—') ?></code></div>
|
||
<?php if ($git_remote): ?>
|
||
<div><span class="text-muted">Remote:</span> <code><?= htmlspecialchars($git_remote) ?></code></div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<?php if ($last_date): ?>
|
||
<div><span class="text-muted">Último commit:</span> <?= htmlspecialchars($last_date) ?></div>
|
||
<div class="text-muted text-truncate" title="<?= htmlspecialchars($last_msg) ?>"><?= htmlspecialchars($last_msg) ?></div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($git_available && ($git_ahead || $git_behind)): ?>
|
||
<div class="mt-2">
|
||
<?php if ($git_behind): ?>
|
||
<span class="badge text-bg-warning"><?= $git_behind ?> commit(s) por puxar</span>
|
||
<?php endif; ?>
|
||
<?php if ($git_ahead): ?>
|
||
<span class="badge text-bg-info"><?= $git_ahead ?> commit(s) local não-empurrado(s)</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($git_log !== ''): ?>
|
||
<div class="mt-3">
|
||
<a class="small text-muted text-decoration-none" data-bs-toggle="collapse" href="#gitRecentLog" role="button">
|
||
<i class="bi bi-clock-history me-1"></i>Últimos 20 commits
|
||
</a>
|
||
<div class="collapse mt-2" id="gitRecentLog">
|
||
<pre class="mb-0 small bg-body-tertiary p-2 rounded" style="white-space:pre-wrap;max-height:280px;overflow:auto;font-size:.75rem;"><?= htmlspecialchars($git_log) ?></pre>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($git_dirty): ?>
|
||
<div class="alert alert-warning mt-3 mb-0 small">
|
||
<strong>Atenção:</strong> há alterações locais não-comitidas. <code>git pull --ff-only</code> vai falhar até que sejam comitidas ou descartadas.
|
||
<pre class="mb-0 mt-2" style="white-space:pre-wrap;font-size:.75rem;max-height:140px;overflow:auto;"><?= htmlspecialchars($git_dirty_files) ?></pre>
|
||
<div class="mt-2 d-flex gap-2 flex-wrap">
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('Guardar TODAS as alterações locais num stash? Podes recuperá-las depois com Pop. Inclui ficheiros não-rastreados.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="stash_push">
|
||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||
<i class="bi bi-archive me-1"></i>Guardar em stash
|
||
</button>
|
||
</form>
|
||
<?php if ($git_diff !== ''): ?>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||
data-bs-toggle="modal" data-bs-target="#gitDiffModal">
|
||
<i class="bi bi-file-diff me-1"></i>Ver diff
|
||
</button>
|
||
<?php endif; ?>
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('Executar git config core.filemode false no repo principal e em todos os submódulos?\n\nDeve resolver diferenças locais causadas por bits de execução (chmod) vistos pelo PHP-FPM mas não pela shell. Não destrutivo.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="fix_filemode">
|
||
<button type="submit" class="btn btn-sm btn-outline-info">
|
||
<i class="bi bi-wrench-adjustable me-1"></i>Corrigir filemode
|
||
</button>
|
||
</form>
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('Descartar TODAS as alterações locais? Os ficheiros listados acima voltam ao estado do último commit. Ficheiros gitignored (.env, uploads, cache) não são tocados.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="discard_local">
|
||
<button type="submit" class="btn btn-sm btn-outline-warning">
|
||
<i class="bi bi-arrow-counterclockwise me-1"></i>Descartar alterações locais
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="card-footer d-flex gap-2 flex-wrap">
|
||
<form method="POST" class="m-0" onsubmit="return confirm('Atualizar a plataforma?\n\n• normaliza core.fileMode (repo + submódulos)\n• git pull --ff-only\n• sincroniza submódulos\n\nNão destrutivo: se houver alterações locais reais, pára e mostra-as sem apagar nada.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="pull">
|
||
<button type="submit" class="btn btn-primary btn-sm">
|
||
<i class="bi bi-arrow-down-circle me-1"></i>Atualizar (git pull)
|
||
</button>
|
||
</form>
|
||
<form method="POST" class="m-0">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="fetch">
|
||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||
<i class="bi bi-cloud-download me-1"></i>Verificar atualizações (fetch)
|
||
</button>
|
||
</form>
|
||
<?php if ($git_ahead > 0): ?>
|
||
<form method="POST" class="m-0 d-flex gap-2 align-items-center"
|
||
onsubmit="return confirm('Executar git push? Vai enviar <?= $git_ahead ?> commit(s) locais para o remote.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="push">
|
||
<?php if ($git_protected_branch): ?>
|
||
<div class="form-check m-0">
|
||
<input class="form-check-input" type="checkbox" name="force_protected" value="1" id="pushProtectedAck">
|
||
<label class="form-check-label small text-warning" for="pushProtectedAck">
|
||
Confirmo push de branch protegido (<?= htmlspecialchars($git_branch) ?>)
|
||
</label>
|
||
</div>
|
||
<?php endif; ?>
|
||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||
<i class="bi bi-arrow-up-circle me-1"></i>git push (<?= $git_ahead ?>)
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($git_diverged): ?>
|
||
<!-- Real divergence the fast-forward can't resolve. "Atualizar" already
|
||
handles the mode-only case automatically; this is the explicit,
|
||
destructive path for when there ARE local commits/edits in the way. -->
|
||
<form method="POST" class="m-0 d-flex gap-2 align-items-center ms-auto"
|
||
onsubmit="return confirm('SINCRONIZAÇÃO FORÇADA\n\ngit reset --hard origin/<?= htmlspecialchars(addslashes((string) $git_branch)) ?>\n\nDescarta TODOS os commits/alterações locais rastreados e alinha com o remoto. Esta ação NÃO pode ser desfeita. Continuar?');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="force_sync">
|
||
<div class="form-check m-0">
|
||
<input class="form-check-input" type="checkbox" name="confirm_force" value="1" id="forceSyncAck">
|
||
<label class="form-check-label small text-danger" for="forceSyncAck">
|
||
Confirmo descartar o estado local
|
||
</label>
|
||
</div>
|
||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||
<i class="bi bi-cloud-download me-1"></i>Sincronização forçada (reset --hard)
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<!-- ── Submódulos ─────────────────────────────────────────────────── -->
|
||
<?php if (!empty($submodules)): ?>
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<strong><i class="bi bi-diagram-3 me-1"></i>Submódulos</strong>
|
||
<span class="small text-muted">
|
||
<?= count($submodules) ?>
|
||
<?php $behind_total = array_sum(array_column($submodules, 'behind')); ?>
|
||
<?php if ($behind_total > 0): ?>
|
||
· <span class="text-warning"><?= $behind_total ?> commit(s) por puxar</span>
|
||
<?php else: ?>
|
||
· <span class="text-success">tudo atualizado</span>
|
||
<?php endif; ?>
|
||
</span>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div class="table-responsive pt-scroll">
|
||
<table class="table table-sm align-middle mb-0">
|
||
<thead class="table-dark">
|
||
<tr>
|
||
<th>Submódulo</th>
|
||
<th>Pinned</th>
|
||
<th>Remoto (<code>origin/<branch></code>)</th>
|
||
<th style="width:140px;">Estado</th>
|
||
<th style="width:300px;" class="text-end">Ações</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($submodules as $s): ?>
|
||
<tr>
|
||
<td>
|
||
<div class="font-monospace small"><?= htmlspecialchars($s['path']) ?></div>
|
||
<div class="text-muted" style="font-size:.72rem;">
|
||
<code><?= htmlspecialchars($s['branch']) ?></code>
|
||
<?php if ($s['url']): ?>
|
||
· <span title="<?= htmlspecialchars($s['url']) ?>"><?= htmlspecialchars(parse_url($s['url'], PHP_URL_HOST) ?: '') ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
<?php if (!$s['initialized']): ?>
|
||
<td colspan="3" class="text-muted small">
|
||
Não inicializado. Corre <code>git submodule update --init <?= htmlspecialchars($s['path']) ?></code> no servidor.
|
||
</td>
|
||
<?php else: ?>
|
||
<td>
|
||
<code class="small"><?= htmlspecialchars(substr($s['pinned_sha'], 0, 7) ?: '—') ?></code>
|
||
<?php if ($s['pinned_info']):
|
||
[$p_when, $p_msg] = array_pad(explode('|', $s['pinned_info'], 2), 2, ''); ?>
|
||
<div class="text-muted text-truncate" style="font-size:.72rem;max-width:240px;" title="<?= htmlspecialchars($p_msg) ?>">
|
||
<?= htmlspecialchars($p_when) ?> · <?= htmlspecialchars($p_msg) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<?php if ($s['remote_sha']): ?>
|
||
<code class="small"><?= htmlspecialchars(substr($s['remote_sha'], 0, 7)) ?></code>
|
||
<?php if ($s['remote_info']):
|
||
[$r_when, $r_msg] = array_pad(explode('|', $s['remote_info'], 2), 2, ''); ?>
|
||
<div class="text-muted text-truncate" style="font-size:.72rem;max-width:280px;" title="<?= htmlspecialchars($r_msg) ?>">
|
||
<?= htmlspecialchars($r_when) ?> · <?= htmlspecialchars($r_msg) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php else: ?>
|
||
<span class="text-muted small">Faz fetch primeiro</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td>
|
||
<?php if (!$s['remote_sha']): ?>
|
||
<span class="badge text-bg-secondary">Por verificar</span>
|
||
<?php elseif ($s['behind'] > 0): ?>
|
||
<span class="badge text-bg-warning text-dark"><?= $s['behind'] ?> behind</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-success">Atualizado</span>
|
||
<?php endif; ?>
|
||
<?php if ($s['wt_dirty']): ?>
|
||
<span class="badge text-bg-danger" title="Working tree do submódulo difere do pinned">WT difere</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td class="text-end">
|
||
<div class="d-flex justify-content-end gap-1 flex-wrap">
|
||
<form method="POST" class="m-0">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="submodule_fetch">
|
||
<input type="hidden" name="submodule_path" value="<?= htmlspecialchars($s['path']) ?>">
|
||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||
<i class="bi bi-cloud-download me-1"></i>Verificar
|
||
</button>
|
||
</form>
|
||
<?php if ($s['behind'] > 0): ?>
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('Atualizar <?= htmlspecialchars(addslashes($s['path'])) ?> para o último commit de origin/<?= htmlspecialchars(addslashes($s['branch'])) ?> e fazer commit do bump no repo da plataforma?');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="submodule_update">
|
||
<input type="hidden" name="submodule_path" value="<?= htmlspecialchars($s['path']) ?>">
|
||
<button type="submit" class="btn btn-primary btn-sm">
|
||
<i class="bi bi-arrow-up-circle me-1"></i>Atualizar
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</td>
|
||
<?php endif; ?>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="card-footer">
|
||
<form method="POST" class="m-0 mb-2"
|
||
onsubmit="return confirm('Sincronizar submódulos?\n\nNormaliza core.fileMode e repõe cada submódulo no commit fixado pelo repo principal (descarta só o drift de modo, o conteúdo é idêntico). Não toca em commits reais.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="sync_submodules">
|
||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||
<i class="bi bi-arrow-repeat me-1"></i>Sincronizar submódulos (repor nos commits fixados)
|
||
</button>
|
||
</form>
|
||
<div class="small text-muted">
|
||
<i class="bi bi-info-circle me-1"></i>
|
||
"Verificar" faz <code>git fetch origin</code> dentro do submódulo (precisa de <code>GIT_HTTP_USERNAME</code>/<code>GIT_HTTP_TOKEN</code> no <code>.env</code>).
|
||
"Atualizar" faz <code>git submodule update --remote</code>, regista o novo SHA no parent com um commit local, e deixa-o pronto para o botão <em>git push</em> acima.
|
||
"Sincronizar submódulos" corre <code>git submodule sync --recursive</code> + <code>update --init --recursive --force</code> — repõe o drift de modo sem perder conteúdo.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
</div><!-- /tab-atualizar -->
|
||
|
||
<div class="tab-pane" id="tab-automacao" role="tabpanel" aria-labelledby="tabbtn-automacao" tabindex="0">
|
||
<!-- ── Auto-update ────────────────────────────────────────────────── -->
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<strong><i class="bi bi-arrow-repeat me-1"></i>Auto-update</strong>
|
||
<?php if ($au_on): ?>
|
||
<span class="badge text-bg-success">Ligado</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-secondary">Desligado</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="small text-muted mb-3">
|
||
Um push para <code><?= htmlspecialchars($git_branch ?: 'este ramo') ?></code> atualiza a plataforma
|
||
sozinha: <code>git pull</code>, migrations que vieram no mesmo commit, e caches invalidadas.
|
||
Nunca descarta trabalho local — se houver alterações por comitar no servidor, pára e diz.
|
||
</p>
|
||
|
||
<div class="row g-3 small">
|
||
<div class="col-md-7">
|
||
<div class="text-muted mb-1">URL do webhook (Gitea → Definições → Webhooks → Gitea):</div>
|
||
<input type="text" class="form-control form-control-sm font-monospace" readonly
|
||
onclick="this.select()" value="<?= htmlspecialchars($au_hook_url) ?>">
|
||
<div class="mt-2">
|
||
<span class="text-muted">Segredo:</span>
|
||
<?php if ($au_secret_set): ?>
|
||
<span class="badge text-bg-success"><i class="bi bi-check2 me-1"></i>AUTO_UPDATE_SECRET definido</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>falta AUTO_UPDATE_SECRET no .env</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="text-muted mt-1" style="font-size:.78rem">
|
||
O mesmo valor vai no campo "Secret" do webhook. Sem ele o endpoint recusa tudo com 401.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="col-md-5">
|
||
<div class="text-muted mb-1">Última execução:</div>
|
||
<?php if ($au_last['at'] === null): ?>
|
||
<div class="text-muted">Ainda nunca correu.</div>
|
||
<?php else: ?>
|
||
<div>
|
||
<?php if ($au_last['ok']): ?>
|
||
<span class="badge text-bg-success">OK</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-danger">Falhou</span>
|
||
<?php endif; ?>
|
||
<span class="ms-1"><?= htmlspecialchars(date('Y-m-d H:i', strtotime($au_last['at'] . ' UTC'))) ?></span>
|
||
<?php if ($au_last['ref'] !== ''): ?>
|
||
<code class="ms-1"><?= htmlspecialchars($au_last['ref']) ?></code>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="text-muted mt-1"><?= htmlspecialchars($au_last['summary']) ?></div>
|
||
<?php if ($au_last['output'] !== ''): ?>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
|
||
data-bs-toggle="collapse" data-bs-target="#au-output">
|
||
<i class="bi bi-terminal me-1"></i>Ver saída
|
||
</button>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($au_last['output'] !== ''): ?>
|
||
<div class="collapse mt-3" id="au-output">
|
||
<pre class="mb-0 p-3 small" style="white-space:pre-wrap;max-height:320px;overflow:auto;background:rgba(0,0,0,.15);"><?= htmlspecialchars($au_last['output']) ?></pre>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="card-footer d-flex gap-2 flex-wrap align-items-center">
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('<?= $au_on ? 'Desligar' : 'Ligar' ?> o auto-update?');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="auto_update_toggle">
|
||
<button type="submit" class="btn btn-sm <?= $au_on ? 'btn-outline-danger' : 'btn-primary' ?>">
|
||
<i class="bi bi-power me-1"></i><?= $au_on ? 'Desligar' : 'Ligar' ?>
|
||
</button>
|
||
</form>
|
||
|
||
<form method="POST" class="m-0">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="auto_update_migrations_toggle">
|
||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||
<i class="bi bi-database me-1"></i>Migrations no deploy:
|
||
<strong><?= $au_migrations ? 'sim' : 'não' ?></strong>
|
||
</button>
|
||
</form>
|
||
|
||
<?php if ($git_available): ?>
|
||
<form method="POST" class="m-0 ms-auto"
|
||
onsubmit="return confirm('Correr agora o mesmo processo que o webhook dispara?');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="auto_update_run">
|
||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||
<i class="bi bi-play-circle me-1"></i>Correr agora
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Backup antes de atualizar ──────────────────────────────────── -->
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<strong><i class="bi bi-shield-check me-1"></i>Backup antes de atualizar</strong>
|
||
<?php if ($ub['db'] || $ub['files']): ?>
|
||
<span class="badge text-bg-success">
|
||
<?= $ub['db'] && $ub['files'] ? 'BD + ficheiros' : ($ub['db'] ? 'Base de dados' : 'Ficheiros') ?>
|
||
</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-secondary">Sem backup</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="small text-muted mb-3">
|
||
Aplica-se às duas vias de atualização — o botão <em>Atualizar</em> e o webhook. O backup corre
|
||
<strong>antes</strong> de o código se mexer e a atualização <strong>espera</strong> por ele: se falhar
|
||
ou demorar de mais, nada é atualizado. Um interruptor que promete ponto de restauro e depois avança
|
||
sem ele seria pior do que não existir.
|
||
</p>
|
||
|
||
<div class="row g-3 small">
|
||
<?php foreach ([
|
||
['key' => 'db', 'on' => $ub['db'], 'action' => 'update_backup_db_toggle',
|
||
'icon' => 'bi-database', 'title' => 'Base de dados',
|
||
'note' => 'Dump completo, encriptado quando há APP_SECRET_KEY. É o que permite voltar atrás de uma migration que correu mal.'],
|
||
['key' => 'files', 'on' => $ub['files'], 'action' => 'update_backup_files_toggle',
|
||
'icon' => 'bi-folder', 'title' => 'Ficheiros (uploads)',
|
||
'note' => 'ZIP de uploads/. O git nunca lhe toca, por isso raramente é o que falta a seguir a um deploy — e num site com fotografias é o que faz cada atualização demorar minutos.'],
|
||
] as $row): ?>
|
||
<div class="col-md-6">
|
||
<div class="border rounded p-3 h-100 d-flex flex-column gap-2">
|
||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||
<strong><i class="bi <?= $row['icon'] ?> me-1"></i><?= htmlspecialchars($row['title']) ?></strong>
|
||
<?php if ($row['on']): ?>
|
||
<span class="badge text-bg-success">Ligado</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-secondary">Desligado</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="text-muted" style="font-size:.78rem"><?= htmlspecialchars($row['note']) ?></div>
|
||
<div class="text-muted mt-auto" style="font-size:.78rem">
|
||
<?php $last = $ub_last[$row['key']]; ?>
|
||
<?php if ($last): ?>
|
||
Último: <?= htmlspecialchars($last['size_human']) ?>
|
||
· <?= htmlspecialchars(date('Y-m-d H:i', $last['created_at'])) ?>
|
||
<?php else: ?>
|
||
Ainda não há nenhum backup deste tipo.
|
||
<?php endif; ?>
|
||
</div>
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('<?= $row['on'] ? 'Desligar' : 'Ligar' ?> o backup <?= $row['key'] === 'db' ? 'da base de dados' : 'dos ficheiros' ?> antes de cada atualização?');">
|
||
<?= platform_csrf_field() ?>
|
||
<input type="hidden" name="action" value="<?= $row['action'] ?>">
|
||
<button type="submit" class="btn btn-sm w-100 <?= $row['on'] ? 'btn-outline-danger' : 'btn-outline-primary' ?>">
|
||
<i class="bi bi-power me-1"></i><?= $row['on'] ? 'Desligar' : 'Ligar' ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<?php if ($ub['files']): ?>
|
||
<div class="alert alert-warning small mt-3 mb-0 py-2">
|
||
<i class="bi bi-clock-history me-1"></i>
|
||
Com o backup de ficheiros ligado, cada atualização espera que <code>uploads/</code> seja arquivado.
|
||
Num site com galerias isso são minutos e vários GB por deploy — e o git nunca mexe em
|
||
<code>uploads/</code>, por isso este é quase sempre o interruptor a deixar desligado.
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /tab-automacao -->
|
||
|
||
<div class="tab-pane" id="tab-dados" role="tabpanel" aria-labelledby="tabbtn-dados" tabindex="0">
|
||
<!-- ── Migrations ─────────────────────────────────────────────────── -->
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<strong><i class="bi bi-database me-1"></i>Migrations</strong>
|
||
<?php // O filtro abre em "Pendentes" quando há alguma: é a única
|
||
// resposta que se procura ao abrir esta página. Sem nenhuma
|
||
// pendente abre em "Todas", que é a lista completa e correta. ?>
|
||
<?php // Os três botões partilham a mesma variante neutra: a cor fica
|
||
// nos badges, e o preenchimento passa a significar UMA coisa
|
||
// só — "é esta a vista activa". Com variantes coloridas, um
|
||
// outline-warning nesta marca lê-se como preenchido mesmo
|
||
// inactivo, e deixava de se perceber qual estava escolhida. ?>
|
||
<div class="btn-group btn-group-sm" role="group" aria-label="Filtrar migrations">
|
||
<button type="button" class="btn btn-outline-secondary" data-mig-filter="all">
|
||
Todas <span class="badge text-bg-secondary ms-1"><?= count($migrations) ?></span>
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" data-mig-filter="pending">
|
||
Pendentes <span class="badge text-bg-warning text-dark ms-1"><?= count($pending) ?></span>
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" data-mig-filter="applied">
|
||
Aplicadas <span class="badge text-bg-success ms-1"><?= $applied_n ?></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<?php if (empty($migrations)): ?>
|
||
<div class="text-muted text-center py-4 small">Sem migrations na pasta <code><?= htmlspecialchars(migrations_dir()) ?></code>.</div>
|
||
<?php else: ?>
|
||
<div class="table-responsive pt-scroll">
|
||
<table class="table table-sm align-middle mb-0">
|
||
<thead class="table-dark">
|
||
<tr>
|
||
<th>Ficheiro</th>
|
||
<th style="width:200px;">Estado</th>
|
||
<th style="width:160px;" class="text-end">Ação</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($migrations as $mi => $m): ?>
|
||
<?php $mig_state = $m['applied_at'] ? 'applied' : 'pending'; ?>
|
||
<tr data-mig-state="<?= $mig_state ?>">
|
||
<td class="font-monospace small"><?= htmlspecialchars($m['filename']) ?></td>
|
||
<td>
|
||
<?php if ($m['applied_at']): ?>
|
||
<span class="badge text-bg-success">Aplicada</span>
|
||
<small class="text-muted ms-1"><?= htmlspecialchars($m['applied_at']) ?></small>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-warning text-dark">Pendente</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td class="text-end">
|
||
<?php if (!$m['applied_at']): ?>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||
data-bs-toggle="collapse" data-bs-target="#migsql-<?= $mi ?>"
|
||
title="Ver SQL" aria-label="Ver SQL de <?= htmlspecialchars($m['filename']) ?>">
|
||
<i class="bi bi-eye"></i>
|
||
</button>
|
||
<form method="POST" class="m-0 d-inline"
|
||
onsubmit="return confirm('Aplicar <?= htmlspecialchars(addslashes($m['filename'])) ?>?');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="apply_migration">
|
||
<input type="hidden" name="filename" value="<?= htmlspecialchars($m['filename']) ?>">
|
||
<button type="submit" class="btn btn-sm btn-primary">
|
||
<i class="bi bi-play-fill"></i> Aplicar
|
||
</button>
|
||
</form>
|
||
<?php else: ?>
|
||
<span class="text-muted small">—</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php if (!$m['applied_at']): ?>
|
||
<?php $mig_sql = @file_get_contents(migrations_dir() . '/' . $m['filename']); ?>
|
||
<tr class="collapse" id="migsql-<?= $mi ?>" data-mig-state="<?= $mig_state ?>">
|
||
<td colspan="3" class="p-0">
|
||
<pre class="mb-0 p-3" style="font-size:.72rem;white-space:pre-wrap;max-height:320px;overflow:auto;background:rgba(0,0,0,.15);"><?= htmlspecialchars($mig_sql !== false ? $mig_sql : '(não foi possível ler o ficheiro)') ?></pre>
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div id="mig-empty" class="text-muted text-center py-4 small d-none">Nada nesta vista.</div>
|
||
<script>
|
||
(function () {
|
||
const rows = document.querySelectorAll('tr[data-mig-state]');
|
||
const buttons = document.querySelectorAll('[data-mig-filter]');
|
||
const empty = document.getElementById('mig-empty');
|
||
if (!buttons.length) return;
|
||
|
||
function apply(which) {
|
||
let shown = 0;
|
||
rows.forEach(function (tr) {
|
||
const match = which === 'all' || tr.dataset.migState === which;
|
||
tr.classList.toggle('d-none', !match);
|
||
// Só esconder a linha do SQL não chega: o collapse do
|
||
// Bootstrap volta a mostrá-la ao carregar no olho, por
|
||
// isso a linha filtrada fora fecha-se também.
|
||
if (!match && tr.classList.contains('show')) tr.classList.remove('show');
|
||
if (match && !tr.id.startsWith('migsql-')) shown++;
|
||
});
|
||
empty.classList.toggle('d-none', shown > 0);
|
||
buttons.forEach(function (b) {
|
||
const on = b.dataset.migFilter === which;
|
||
b.classList.toggle('active', on);
|
||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
});
|
||
}
|
||
|
||
buttons.forEach(function (b) {
|
||
b.addEventListener('click', function () { apply(b.dataset.migFilter); });
|
||
});
|
||
apply(<?= $pending ? "'pending'" : "'all'" ?>);
|
||
}());
|
||
</script>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="card-footer d-flex gap-2 flex-wrap align-items-center">
|
||
<?php $schema_path = schema_dump_path(); $schema_exists = is_file($schema_path); ?>
|
||
<form method="POST" class="m-0"
|
||
onsubmit="return confirm('Exportar o esquema atual da base de dados para <?= htmlspecialchars(addslashes(basename($schema_path))) ?>? O ficheiro existente é substituído.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="dump_schema">
|
||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||
<i class="bi bi-filetype-sql me-1"></i>Exportar esquema
|
||
</button>
|
||
</form>
|
||
<span class="small text-muted">
|
||
<code><?= htmlspecialchars(basename($schema_path)) ?></code>
|
||
<?php if ($schema_exists): ?>
|
||
· <?= htmlspecialchars(date('Y-m-d H:i', filemtime($schema_path))) ?>
|
||
<?php else: ?>
|
||
· <span class="text-warning">ainda não exportado</span>
|
||
<?php endif; ?>
|
||
</span>
|
||
<?php if ($pending): ?>
|
||
<form method="POST" class="m-0 ms-auto"
|
||
onsubmit="return confirm('Aplicar todas as migrations pendentes (<?= count($pending) ?>)?');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="apply_pending">
|
||
<button type="submit" class="btn btn-primary btn-sm">
|
||
<i class="bi bi-play-circle me-1"></i>Aplicar pendentes (<?= count($pending) ?>)
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<?php if ($migrations): ?>
|
||
<form method="POST" class="m-0 <?= $pending ? '' : 'ms-auto' ?>"
|
||
onsubmit="return confirm('Marcar TODAS as migrations como aplicadas, sem executar SQL? Útil quando já correste manualmente.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="mark_all_applied">
|
||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||
<i class="bi bi-check2-all me-1"></i>Marcar tudo como aplicado
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /tab-dados -->
|
||
|
||
<div class="tab-pane" id="tab-servidor" role="tabpanel" aria-labelledby="tabbtn-servidor" tabindex="0">
|
||
<div class="row g-3">
|
||
<div class="col-lg-6">
|
||
<!-- ── OPcache ────────────────────────────────────────────────────── -->
|
||
<?php
|
||
$opcache_status = function_exists('opcache_get_status') ? @opcache_get_status(false) : false;
|
||
$opcache_enabled = is_array($opcache_status) && !empty($opcache_status['opcache_enabled']);
|
||
?>
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<strong><i class="bi bi-lightning-charge me-1"></i>PHP OPcache</strong>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<?php if ($opcache_enabled): ?>
|
||
<span class="badge text-bg-success">Ativo</span>
|
||
<?php elseif (is_array($opcache_status)): ?>
|
||
<span class="badge text-bg-warning text-dark">Inativo</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-secondary">Não disponível</span>
|
||
<?php endif; ?>
|
||
<?php if ($opcache_enabled): ?>
|
||
<form method="POST" class="m-0" onsubmit="return confirm('Invalidar OPcache? Será reconstruído nas próximas requests.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="reset_opcache">
|
||
<button type="submit" class="btn btn-outline-secondary btn-sm py-0"
|
||
title="Invalidar cache" aria-label="Invalidar OPcache">
|
||
<i class="bi bi-arrow-clockwise"></i>
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php if ($opcache_enabled): ?>
|
||
<div class="card-body small">
|
||
<?php
|
||
$mem = $opcache_status['memory_usage'] ?? [];
|
||
$stats = $opcache_status['opcache_statistics'] ?? [];
|
||
$hit = isset($stats['opcache_hit_rate']) ? number_format($stats['opcache_hit_rate'], 1) : '—';
|
||
$used = isset($mem['used_memory']) ? round($mem['used_memory'] / 1048576, 1) : '—';
|
||
$free = isset($mem['free_memory']) ? round($mem['free_memory'] / 1048576, 1) : '—';
|
||
$files = $stats['num_cached_scripts'] ?? '—';
|
||
$max = $stats['max_cached_keys'] ?? '—';
|
||
?>
|
||
<div class="row g-2">
|
||
<div class="col-6"><span class="text-muted d-block">Hit rate</span><strong><?= $hit ?>%</strong></div>
|
||
<div class="col-6"><span class="text-muted d-block">Scripts em cache</span><?= $files ?> / <?= $max ?></div>
|
||
<div class="col-6"><span class="text-muted d-block">Memória</span><?= $used ?> MB usados · <?= $free ?> MB livres</div>
|
||
<div class="col-6"><span class="text-muted d-block">Hits</span><?= number_format((int) ($stats['hits'] ?? 0), 0, ',', '.') ?></div>
|
||
</div>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="card-body small text-muted">
|
||
OPcache acelera o PHP em ~5–10× ao guardar o bytecode dos scripts em memória partilhada.
|
||
Para o ativar em produção, no <code>php.ini</code>:
|
||
<pre class="mb-0 mt-2" style="white-space:pre-wrap;font-size:.75rem;">opcache.enable=1
|
||
opcache.memory_consumption=128
|
||
opcache.max_accelerated_files=10000
|
||
opcache.validate_timestamps=0 ; em prod (em dev: 1)</pre>
|
||
Com <code>validate_timestamps=0</code>, esta página invalida o cache automaticamente após cada <code>git pull</code>.
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
</div>
|
||
<div class="col-lg-6">
|
||
<!-- ── Dependências (Composer) ───────────────────────────────────── -->
|
||
<?php if ($composer['has_json']): ?>
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<strong><i class="bi bi-box-seam me-1"></i>Composer</strong>
|
||
<span class="small">
|
||
<?php if ($composer['has_autoload']): ?>
|
||
<span class="badge text-bg-success">vendor/ instalado</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-warning text-dark">vendor/ em falta</span>
|
||
<?php endif; ?>
|
||
</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<?php if ($composer_outdated && !empty($composer_outdated['ok'])): ?>
|
||
<?php if (empty($composer_outdated['packages'])): ?>
|
||
<div class="alert alert-<?= !empty($composer_outdated['note']) ? 'secondary' : 'success' ?> small py-2 mb-2">
|
||
<i class="bi bi-<?= !empty($composer_outdated['note']) ? 'info-circle' : 'check-circle' ?> me-1"></i>
|
||
<?= !empty($composer_outdated['note'])
|
||
? htmlspecialchars((string) $composer_outdated['note'])
|
||
: 'Todas as dependências diretas estão atualizadas' ?>
|
||
<span class="text-muted">(verificado <?= date('H:i', (int) ($composer_outdated['checked_at'] ?? time())) ?>)</span>.
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="table-responsive mb-2">
|
||
<table class="table table-sm align-middle small mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Pacote</th><th>Instalado</th><th>Disponível</th>
|
||
<th>Restrição</th><th class="text-end">Ação</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($composer_outdated['packages'] as $pk): ?>
|
||
<tr>
|
||
<td class="font-monospace text-break"><?= htmlspecialchars($pk['name']) ?></td>
|
||
<td class="font-monospace text-muted"><?= htmlspecialchars($pk['version']) ?></td>
|
||
<td class="font-monospace">
|
||
<?= htmlspecialchars($pk['latest']) ?>
|
||
<?php if ($pk['updatable']): ?>
|
||
<span class="badge text-bg-success ms-1">atualizável</span>
|
||
<?php else: ?>
|
||
<span class="badge text-bg-secondary ms-1">bloqueado</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td class="font-monospace text-muted"><?= htmlspecialchars($pk['constraint'] ?: '—') ?></td>
|
||
<td class="text-end">
|
||
<?php if ($pk['updatable']): ?>
|
||
<?php $mv = $pk['would_move']; ?>
|
||
<form method="POST" class="d-inline m-0"
|
||
onsubmit="return confirm('Atualizar <?= htmlspecialchars(addslashes($pk['name'])) ?> (<?= htmlspecialchars(addslashes((string) ($mv['from'] ?? $pk['version']))) ?> => <?= htmlspecialchars(addslashes((string) ($mv['to'] ?? $pk['latest']))) ?>)?');">
|
||
<?= platform_csrf_field() ?>
|
||
<input type="hidden" name="action" value="composer_update_pkg">
|
||
<input type="hidden" name="package" value="<?= htmlspecialchars($pk['name']) ?>">
|
||
<button type="submit" class="btn btn-sm btn-outline-primary"
|
||
title="composer confirmou que move para <?= htmlspecialchars((string) ($mv['to'] ?? '?')) ?>">
|
||
<i class="bi bi-arrow-up-circle"></i>
|
||
atualizar<?= isset($mv['to']) && $mv['to'] !== $pk['latest'] ? ' → ' . htmlspecialchars((string) $mv['to']) : '' ?>
|
||
</button>
|
||
</form>
|
||
<?php else: ?>
|
||
<!-- No button here on purpose. composer's dry run says this package
|
||
will not move under the declared constraint, so a button would
|
||
run, change nothing, and report success. Show the constraint
|
||
that has to be widened instead. -->
|
||
<span class="text-muted small"
|
||
title="composer update não move este pacote enquanto a restrição for <?= htmlspecialchars($pk['constraint'] ?: '?') ?>">
|
||
restrição <code><?= htmlspecialchars($pk['constraint'] ?: '?') ?></code> impede
|
||
</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php elseif ($composer_outdated && empty($composer_outdated['ok'])): ?>
|
||
<div class="alert alert-danger small py-2 mb-2">
|
||
Falha ao verificar: <?= htmlspecialchars((string) ($composer_outdated['error'] ?? '')) ?>
|
||
</div>
|
||
<?php elseif ($composer['declared']): ?>
|
||
<div class="small mb-2" style="max-height:120px;overflow-y:auto;">
|
||
<span class="text-muted">Pacotes declarados:</span>
|
||
<?php foreach ($composer['declared'] as $pkg => $ver): ?>
|
||
<code class="d-block text-truncate"><?= htmlspecialchars($pkg) ?>:<?= htmlspecialchars((string) $ver) ?></code>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<p class="small text-muted mb-2">
|
||
<i class="bi bi-info-circle me-1"></i>Carrega em <strong>verificar atualizações</strong> para
|
||
comparar com o packagist.
|
||
</p>
|
||
<?php else: ?>
|
||
<p class="small text-muted mb-2"><code>composer.json</code> sem pacotes em <code>require</code>.</p>
|
||
<?php endif; ?>
|
||
<p class="small text-muted mb-0">
|
||
<code>install</code> reconstrói o <code>vendor/</code> a partir do lock (reproduzível);
|
||
<code>update</code> resolve versões novas e reescreve o lock.
|
||
Ambos com <code>--no-dev --optimize-autoloader</code>.
|
||
</p>
|
||
</div>
|
||
<div class="card-footer">
|
||
<?php if ($can_exec): ?>
|
||
<div class="d-flex gap-2 flex-wrap mb-2">
|
||
<form method="POST" class="m-0" onsubmit="return confirm('Correr composer install? Reconstrói o vendor/ a partir do composer.lock.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="composer_install">
|
||
<button type="submit" class="btn btn-primary btn-sm">
|
||
<i class="bi bi-download me-1"></i>install
|
||
</button>
|
||
</form>
|
||
<form method="POST" class="m-0">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="composer_check">
|
||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||
<i class="bi bi-search me-1"></i>verificar atualizações
|
||
</button>
|
||
</form>
|
||
<form method="POST" class="m-0" onsubmit="return confirm('Correr composer update em TODOS os pacotes? Atualiza dentro das restrições e reescreve o composer.lock.');">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="composer_update">
|
||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||
<i class="bi bi-arrow-up-circle me-1"></i>update (todos)
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<?php if ($composer_avail && $composer_avail['ok']): ?>
|
||
<div class="small text-muted text-truncate" title="<?= htmlspecialchars(($composer_avail['version'] ?? '') . ' · ' . $composer_avail['how']) ?>">
|
||
<i class="bi bi-check2 text-success me-1"></i><?= htmlspecialchars($composer_avail['version'] ?? 'composer') ?>
|
||
· <?= htmlspecialchars($composer_avail['how']) ?>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="small text-danger">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>composer não encontrado —
|
||
requer <code>composer</code> no PATH ou <code>composer.phar</code> na raiz.
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php else: ?>
|
||
<div class="small text-muted">
|
||
<i class="bi bi-slash-circle me-1"></i>Instalação via web indisponível:
|
||
<code>proc_open()</code> está desativado no PHP deste servidor.
|
||
Corre o composer localmente e faz upload do <code>vendor/</code>.
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── PHP Error Log (only if configured) ─────────────────────────── -->
|
||
<?php if ($error_log_file): ?>
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<?php if ($error_log_entries): ?>
|
||
<!-- Acordeão: só a zona do título alterna o painel; o botão Limpar
|
||
fica fora do alvo do toggle para não abrir/fechar ao limpar. -->
|
||
<div class="pt-toggle d-flex align-items-center gap-2 flex-grow-1"
|
||
data-bs-toggle="collapse" data-bs-target="#errorLogPanel"
|
||
role="button" aria-expanded="false" aria-controls="errorLogPanel">
|
||
<strong><i class="bi bi-bug me-1"></i>Log de erros PHP</strong>
|
||
<span class="badge text-bg-danger" title="<?= $error_log_total ?> entrada(s) no total; a tabela mostra as últimas 200">
|
||
<?= $error_log_total > 200 ? '200+' : count($error_log_entries) ?>
|
||
</span>
|
||
<i class="bi bi-chevron-down small pt-chevron ms-1"></i>
|
||
</div>
|
||
<?php else: ?>
|
||
<strong><i class="bi bi-bug me-1"></i>Log de erros PHP</strong>
|
||
<?php endif; ?>
|
||
<form method="POST" class="m-0">
|
||
<?= platform_csrf_field() ?><input type="hidden" name="action" value="clear_error_log">
|
||
<button class="btn btn-sm btn-outline-danger"
|
||
onclick="return confirm('Limpar todo o log de erros?')">
|
||
<i class="bi bi-trash me-1"></i>Limpar
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<?php if (!$error_log_entries): ?>
|
||
<div class="card-body small text-muted">Nenhum erro registado.</div>
|
||
<?php else: ?>
|
||
<div class="collapse" id="errorLogPanel">
|
||
<div class="table-responsive pt-scroll" style="max-height:420px;">
|
||
<table class="table table-sm mb-0" style="font-size:.78rem;">
|
||
<thead class="table-dark">
|
||
<tr>
|
||
<th style="min-width:130px;">Data/hora</th>
|
||
<th style="min-width:90px;">Tipo</th>
|
||
<th>Mensagem</th>
|
||
<th style="min-width:80px;">Utilizador</th>
|
||
<th style="min-width:160px;">URL</th>
|
||
<th style="min-width:60px;"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($error_log_entries as $i => $err): ?>
|
||
<?php
|
||
$badgeClass = match(true) {
|
||
str_contains(strtolower($err['type'] ?? ''), 'fatal'),
|
||
str_contains(strtolower($err['type'] ?? ''), 'exception'),
|
||
str_contains(strtolower($err['type'] ?? ''), 'error') => 'text-bg-danger',
|
||
str_contains(strtolower($err['type'] ?? ''), 'warning') => 'text-bg-warning',
|
||
str_contains(strtolower($err['type'] ?? ''), 'notice'),
|
||
str_contains(strtolower($err['type'] ?? ''), 'deprecat') => 'text-bg-secondary',
|
||
default => 'text-bg-secondary',
|
||
};
|
||
$ts = isset($err['ts']) ? date('d/m/Y H:i:s', strtotime($err['ts'])) : '—';
|
||
$uid = !empty($err['user_id'])
|
||
? '(' . (int) $err['user_id'] . ') ' . htmlspecialchars($err['user_name'] ?? '')
|
||
: '<span class="text-muted">anónimo</span>';
|
||
?>
|
||
<tr>
|
||
<td class="text-muted"><?= $ts ?></td>
|
||
<td><span class="badge <?= $badgeClass ?>"><?= htmlspecialchars($err['type'] ?? '—') ?></span></td>
|
||
<td title="<?= htmlspecialchars($err['message'] ?? '') ?>">
|
||
<?= htmlspecialchars(mb_strimwidth($err['message'] ?? '', 0, 80, '…')) ?>
|
||
<?php if (!empty($err['file'])): ?>
|
||
<div class="text-muted" style="font-size:.72rem;">
|
||
<?= htmlspecialchars(basename($err['file'])) ?>:<?= (int)($err['line'] ?? 0) ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= $uid ?></td>
|
||
<td class="text-muted text-truncate" style="max-width:180px;"
|
||
title="<?= htmlspecialchars($err['url'] ?? '') ?>">
|
||
<?= htmlspecialchars(mb_strimwidth($err['url'] ?? '', 0, 40, '…')) ?>
|
||
</td>
|
||
<td>
|
||
<?php if (!empty($err['trace']) || !empty($err['file'])): ?>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1"
|
||
data-bs-toggle="collapse" data-bs-target="#errdetail-<?= $i ?>"
|
||
title="Ver detalhes" aria-label="Ver detalhes do erro">
|
||
<i class="bi bi-chevron-down" style="font-size:.7rem;"></i>
|
||
</button>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php if (!empty($err['trace']) || !empty($err['file'])): ?>
|
||
<tr class="collapse" id="errdetail-<?= $i ?>">
|
||
<td colspan="6" class="p-0">
|
||
<pre class="mb-0 p-3" style="font-size:.72rem;white-space:pre-wrap;word-break:break-all;background:rgba(0,0,0,.2);border-radius:0;"><?php
|
||
if (!empty($err['file'])) echo htmlspecialchars($err['file'] . ':' . ($err['line'] ?? 0)) . "\n\n";
|
||
if (!empty($err['trace'])) echo htmlspecialchars($err['trace']);
|
||
?></pre>
|
||
</td>
|
||
</tr>
|
||
<?php endif; ?>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div><!-- /collapse -->
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?><!-- ── Pré-requisitos (acordeão, fechado por omissão) ─────────────── -->
|
||
<div class="card shadow-sm mb-3">
|
||
<div class="card-header d-flex justify-content-between align-items-center pt-toggle"
|
||
data-bs-toggle="collapse" data-bs-target="#preReqsPanel"
|
||
role="button" aria-expanded="false" aria-controls="preReqsPanel">
|
||
<strong><i class="bi bi-info-circle me-1"></i>Pré-requisitos no servidor</strong>
|
||
<i class="bi bi-chevron-down small pt-chevron"></i>
|
||
</div>
|
||
<div class="collapse" id="preReqsPanel">
|
||
<div class="card-body small">
|
||
<ul class="mb-0 ps-3">
|
||
<li><code>proc_open()</code> ativo no PHP (fora de <code>disable_functions</code>) — necessário para as ferramentas git e composer; sem ele, esses cartões ficam ocultos.</li>
|
||
<li>O utilizador do Apache/PHP-FPM tem permissão de escrita nesta pasta.</li>
|
||
<li><strong>Autenticação:</strong> para remotes HTTPS/HTTP (Gitea típico), define <code>GIT_HTTP_USERNAME</code> e <code>GIT_HTTP_TOKEN</code> (Personal Access Token) no <code>.env</code>. Para remotes SSH, autoriza a chave do utilizador no Gitea.</li>
|
||
<li>O <em>dubious ownership</em> guard do Git é tratado em-process via gitconfig temporário com <code>safe.directory = <?= htmlspecialchars($repo_root) ?></code>.</li>
|
||
<li>Depois de <code>git pull</code> com mudanças a configurações em cache, as caches são invalidadas automaticamente quando o host define <code>settings_cache_clear()</code>.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /tab-servidor -->
|
||
|
||
</div><!-- /tab-content -->
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// O separador ativo fica no endereço, para que F5, "voltar" e um link partilhado
|
||
// aterrem onde se estava. ptShowTab() também serve os atalhos fora das tabs
|
||
// (o botão Reverter do cabeçalho).
|
||
function ptShowTab(id) {
|
||
var btn = document.getElementById('tabbtn-' + id);
|
||
if (btn) new bootstrap.Tab(btn).show();
|
||
}
|
||
(function () {
|
||
var hash = (location.hash || '').replace('#tab-', '');
|
||
if (hash) ptShowTab(hash);
|
||
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(function (b) {
|
||
b.addEventListener('shown.bs.tab', function (e) {
|
||
history.replaceState(null, '', '#' + e.target.dataset.bsTarget.slice(1));
|
||
});
|
||
});
|
||
}());
|
||
</script>
|
||
|
||
<script>
|
||
// Anti-duplo-clique: depois do confirm() passar, bloqueia reenvios e mostra um
|
||
// spinner no botão. Os botões não transportam name/value (as ações vão em
|
||
// inputs hidden), por isso desativá-los não altera o POST.
|
||
document.addEventListener('submit', function (e) {
|
||
var form = e.target;
|
||
if (e.defaultPrevented || !(form instanceof HTMLFormElement)) return;
|
||
if (form.dataset.ptSubmitted) { e.preventDefault(); return; }
|
||
form.dataset.ptSubmitted = '1';
|
||
form.querySelectorAll('button[type="submit"]').forEach(function (btn) {
|
||
btn.disabled = true;
|
||
btn.insertAdjacentHTML('afterbegin',
|
||
'<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>');
|
||
});
|
||
});
|
||
</script>
|
||
|
||
<?php if ($git_diff !== ''): ?>
|
||
<div class="modal fade" id="gitDiffModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title"><i class="bi bi-file-diff me-2"></i>Diff das alterações locais</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fechar"></button>
|
||
</div>
|
||
<div class="modal-body p-0">
|
||
<pre class="mb-0 small p-3" style="white-space:pre-wrap;font-size:.75rem;"><?= htmlspecialchars($git_diff) ?></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (is_file($layout_footer)) include $layout_footer; ?>
|