diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index d694b5ca..94b81267 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -90,6 +90,11 @@ Log Suppression + + + + Backups + diff --git a/frontend/pages/backend/[backend]/index.vue b/frontend/pages/backend/[backend]/index.vue index 843f6293..34bd3901 100644 --- a/frontend/pages/backend/[backend]/index.vue +++ b/frontend/pages/backend/[backend]/index.vue @@ -84,8 +84,9 @@
  - - {{ moment.unix(history.updated).fromNow() }} + + {{ moment.unix(history.updated_at ?? history.updated).fromNow() }}
@@ -139,7 +140,7 @@ diff --git a/frontend/pages/console.vue b/frontend/pages/console.vue index 25919d09..54c409d7 100644 --- a/frontend/pages/console.vue +++ b/frontend/pages/console.vue @@ -60,6 +60,13 @@ input. You should use the command directly, For example i.e db:list --output yaml

+

+ + + The command contains [...] which are considered a placeholder, So, please replace + [...] with the intended value if applicable. + +

@@ -121,6 +128,7 @@ const command = ref(fromCommand); const isLoading = ref(false); const outputConsole = ref(); const hasPrefix = computed(() => command.value.startsWith('console') || command.value.startsWith('docker')); +const hasPlaceholder = computed(() => command.value && command.value.match(/\[.*\]/)); const show_page_tips = useStorage('show_page_tips', true) const RunCommand = async () => { @@ -136,6 +144,13 @@ const RunCommand = async () => { return } + // use regex to check if command contains [...] + if (userCommand.match(/\[.*\]/)) { + if (!confirm(`The command contains placeholders '[...]'. Are you sure you want to run as it is?`)) { + return + } + } + response.value = [] const searchParams = new URLSearchParams(); @@ -165,6 +180,15 @@ const finished = () => { } isLoading.value = false; + + const route = useRoute(); + const router = useRouter(); + + if (route.query?.cmd || route.query?.task) { + route.query.cmd = ''; + route.query.task = ''; + router.push({path: '/console'}); + } } onUpdated(() => outputConsole.value.scrollTop = outputConsole.value.scrollHeight); diff --git a/frontend/pages/history/[id]/index.vue b/frontend/pages/history/[id]/index.vue index 661a348e..2b132009 100644 --- a/frontend/pages/history/[id]/index.vue +++ b/frontend/pages/history/[id]/index.vue @@ -138,7 +138,8 @@ Updated:  - + {{ moment.unix(data.updated).fromNow() }} @@ -226,6 +227,32 @@ +
+ + + + Created:  + + {{ moment.unix(data.created_at).fromNow() }} + + + +
+ +
+ + + + Updated:  + + {{ moment.unix(data.updated_at).fromNow() }} + + + +
+ @@ -300,7 +327,7 @@ Updated:  + v-tooltip="`Backend last activity: ${getMoment(ag(data.extra, `${key}.received_at`, data.updated)).format(TOOLTIP_DATE_FORMAT)}`"> {{ getMoment(ag(data.extra, `${key}.received_at`, data.updated)).fromNow() }} @@ -435,7 +462,16 @@ diff --git a/frontend/utils/index.js b/frontend/utils/index.js index 48237e47..dabd1e08 100644 --- a/frontend/utils/index.js +++ b/frontend/utils/index.js @@ -4,6 +4,8 @@ const {notify} = useNotification(); const AG_SEPARATOR = '.' +const TOOLTIP_DATE_FORMAT = 'YYYY-MM-DD h:mm:ss A' + const guid_links = { 'episode': { 'imdb': 'https://www.imdb.com/title/{_guid}', @@ -425,4 +427,5 @@ export { dEvent, makeName, makePagination, + TOOLTIP_DATE_FORMAT, } diff --git a/src/API/Logs/Index.php b/src/API/Logs/Index.php index 89fc736b..36eb8ac2 100644 --- a/src/API/Logs/Index.php +++ b/src/API/Logs/Index.php @@ -186,7 +186,7 @@ private function download(string $filePath): iResponse 'Content-Type' => false === $mime ? 'application/octet-stream' : $mime, 'Content-Length' => filesize($filePath), ], - body: stream::make($filePath, 'r') + body: Stream::make($filePath, 'r') ); } diff --git a/src/API/System/Backup.php b/src/API/System/Backup.php new file mode 100644 index 00000000..6675b58a --- /dev/null +++ b/src/API/System/Backup.php @@ -0,0 +1,86 @@ + basename($file), + 'type' => $isAuto ? 'automatic' : 'manual', + 'size' => filesize($file), + 'date' => filemtime($file), + ]; + + $list[] = $builder; + } + + $sorter = array_column($list, 'date'); + array_multisort($sorter, SORT_DESC, $list); + + foreach ($list as &$item) { + $item['date'] = makeDate(ag($item, 'date')); + } + + return api_response(HTTP_STATUS::HTTP_OK, $list); + } + + #[Route(['GET', 'DELETE'], self::URL . '/{filename}[/]', name: 'system.backup.view')] + public function logView(iRequest $request, array $args = []): iResponse + { + if (null === ($filename = ag($args, 'filename'))) { + return api_error('Invalid value for filename path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST); + } + + $path = realpath(fixPath(Config::get('path') . '/backup')); + + $filePath = realpath($path . '/' . $filename); + + if (false === $filePath) { + return api_error('File not found.', HTTP_STATUS::HTTP_NOT_FOUND); + } + + if (false === str_starts_with($filePath, $path)) { + return api_error('Invalid file path.', HTTP_STATUS::HTTP_BAD_REQUEST); + } + + if ('DELETE' === $request->getMethod()) { + unlink($filePath); + return api_response(HTTP_STATUS::HTTP_OK); + } + + $mime = (new finfo(FILEINFO_MIME_TYPE))->file($filePath); + + return new Response( + status: HTTP_STATUS::HTTP_OK->value, + headers: [ + 'Content-Type' => false === $mime ? 'application/octet-stream' : $mime, + 'Content-Length' => filesize($filePath), + ], + body: Stream::make($filePath, 'r') + ); + } +} diff --git a/src/API/System/Command.php b/src/API/System/Command.php index ba0f3a78..850b697d 100644 --- a/src/API/System/Command.php +++ b/src/API/System/Command.php @@ -18,8 +18,13 @@ final class Command { public const string URL = '%{api.prefix}/system/command'; + private const int TIMES_BEFORE_PING = 6; + private const int PING_INTERVAL = 200000; + private int $counter = 1; + private bool $toBackground = false; + public function __construct() { set_time_limit(0); @@ -59,10 +64,14 @@ public function __invoke(iRequest $request): iResponse command: "{$path}/bin/console {$command}", cwd: $path, env: $_ENV, - timeout: $data->get('timeout', 3600), + timeout: $data->get('timeout', 7200), ); $process->start(callback: function ($type, $data) use ($process) { + if (true === $this->toBackground) { + return; + } + echo "id: " . hrtime(true) . "\n"; echo "event: data\n"; $data = (string)$data; @@ -82,26 +91,26 @@ function ($data) { flush(); - $this->counter = 3; + $this->counter = self::TIMES_BEFORE_PING; if (ob_get_length() > 0) { ob_end_flush(); } if (connection_aborted()) { - $process->stop(1, 9); + $this->toBackground = true; } }); - while ($process->isRunning()) { - usleep(500000); + while (false === $this->toBackground && $process->isRunning()) { + usleep(self::PING_INTERVAL); $this->counter--; if ($this->counter > 1) { continue; } - $this->counter = 3; + $this->counter = self::TIMES_BEFORE_PING; echo "id: " . hrtime(true) . "\n"; echo "event: ping\n"; @@ -113,13 +122,13 @@ function ($data) { } if (connection_aborted()) { - $process->stop(1, 9); + $this->toBackground = true; } } } catch (ProcessTimedOutException) { } - if (!connection_aborted()) { + if (false === $this->toBackground && !connection_aborted()) { echo "id: " . hrtime(true) . "\n"; echo "event: close\n"; echo 'data: ' . makeDate() . "\n\n"; @@ -132,16 +141,12 @@ function ($data) { exit; }; - return new Response( - status: HTTP_STATUS::HTTP_OK->value, - headers: [ - 'Content-Type' => 'text/event-stream', - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'X-Accel-Buffering' => 'no', - 'Last-Event-Id' => time(), - ], - body: StreamClosure::create($callable) - ); + return new Response(status: HTTP_STATUS::HTTP_OK->value, headers: [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + 'Last-Event-Id' => time(), + ], body: StreamClosure::create($callable)); } } diff --git a/src/API/Tasks/Index.php b/src/API/Tasks/Index.php index 0b583efd..844c5542 100644 --- a/src/API/Tasks/Index.php +++ b/src/API/Tasks/Index.php @@ -80,6 +80,9 @@ public function taskQueue(iRequest $request, array $args = []): iResponse ]); } + /** + * @throws InvalidArgumentException + */ #[Get(self::URL . '/{id:[a-zA-Z0-9_-]+}[/]', name: 'tasks.task.view')] public function taskView(iRequest $request, array $args = []): iResponse { @@ -93,7 +96,12 @@ public function taskView(iRequest $request, array $args = []): iResponse return api_error('Task not found.', HTTP_STATUS::HTTP_NOT_FOUND); } - return api_response(HTTP_STATUS::HTTP_OK, Index::formatTask($task)); + $queuedTasks = $this->cache->get('queued_tasks', []); + + $data = Index::formatTask($task); + $data['queued'] = in_array(ag($task, 'name'), $queuedTasks); + + return api_response(HTTP_STATUS::HTTP_OK, $data); } private function formatTask(array $task): array diff --git a/src/Commands/System/PruneCommand.php b/src/Commands/System/PruneCommand.php index 1fab1928..ee274614 100644 --- a/src/Commands/System/PruneCommand.php +++ b/src/Commands/System/PruneCommand.php @@ -77,33 +77,39 @@ protected function runCommand(InputInterface $input, OutputInterface $output): i $directories = [ [ + 'name' => 'logs_remover', 'path' => Config::get('tmpDir') . '/logs', 'base' => Config::get('tmpDir'), 'filter' => '*.log', 'time' => strtotime('-7 DAYS', $time) ], [ + 'name' => 'webhooks_remover', 'path' => Config::get('tmpDir') . '/webhooks', 'base' => Config::get('tmpDir'), 'filter' => '*.json', 'time' => strtotime('-3 DAYS', $time) ], [ + 'name' => 'profiler_remover', 'path' => Config::get('tmpDir') . '/profiler', 'base' => Config::get('tmpDir'), 'filter' => '*.json', 'time' => strtotime('-3 DAYS', $time) ], [ + 'name' => 'debug_remover', 'path' => Config::get('tmpDir') . '/debug', 'base' => Config::get('tmpDir'), 'filter' => '*.json', 'time' => strtotime('-3 DAYS', $time) ], [ + 'name' => 'backup_remover', 'path' => Config::get('path') . '/backup', 'base' => Config::get('path'), 'filter' => '*.*.json', + 'validate' => fn(SplFileInfo $f): bool => 1 === @preg_match('/\w+\.\d{8}\.json/i', $f->getBasename()), 'time' => strtotime('-9 DAYS', $time) ], ]; @@ -111,10 +117,12 @@ protected function runCommand(InputInterface $input, OutputInterface $output): i $inDryRunMode = $input->getOption('dry-run'); foreach ($directories as $item) { + $name = ag($item, 'name'); $path = ag($item, 'path'); if (null === ($expiresAt = ag($item, 'time'))) { - $this->logger->warning('Error No expected time to live was found for [{path}].', [ + $this->logger->warning("No expected time to live was found for '{name}' - '{path}'.", [ + 'name' => $name, 'path' => $path ]); continue; @@ -122,34 +130,48 @@ protected function runCommand(InputInterface $input, OutputInterface $output): i if (null === $path || !is_dir($path)) { if (true === (bool)ag($item, 'report', true)) { - $this->logger->warning('Path [{path}] not found or inaccessible.', [ + $this->logger->warning("{name}: Path '{path}' not found or is inaccessible.", [ + 'name' => $name, 'path' => $path ]); } continue; } + $validate = ag($item, 'validate', null); + foreach (glob(ag($item, 'path') . '/' . ag($item, 'filter')) as $file) { $file = new SplFileInfo($file); $fileName = $file->getBasename(); if ('.' === $fileName || '..' === $fileName || true === $file->isDir() || false === $file->isFile()) { - $this->logger->debug('Path [{path}] is not considered valid file.', [ + $this->logger->debug("{name}: Path '{path}' is not considered valid file.", [ + 'name' => $name, 'path' => $file->getRealPath(), ]); continue; } + if (null !== $validate && false === $validate($file)) { + $this->logger->debug("{name}: File '{file}' did not pass validation checks.", [ + 'name' => $name, + 'file' => after($file->getRealPath(), ag($item, 'base') . '/'), + ]); + continue; + } + if ($file->getMTime() > $expiresAt) { - $this->logger->debug('File [{file}] Not yet expired. {ttl} left seconds.', [ + $this->logger->debug("{name}: File '{file}' Not yet expired. '{ttl}' seconds left.", [ + 'name' => $name, 'file' => after($file->getRealPath(), ag($item, 'base') . '/'), 'ttl' => number_format($file->getMTime() - $expiresAt), ]); continue; } - $this->logger->notice('Removing [{file}].', [ + $this->logger->notice("{name}: Removing '{file}'. expired TTL.", [ + 'name' => $name, 'file' => after($file->getRealPath(), ag($item, 'base') . '/') ]); diff --git a/src/Libs/Entity/StateEntity.php b/src/Libs/Entity/StateEntity.php index b63cd917..37ae4db9 100644 --- a/src/Libs/Entity/StateEntity.php +++ b/src/Libs/Entity/StateEntity.php @@ -143,6 +143,14 @@ public function __construct(array $data) $this->{$key} = $val; } + if (0 === $this->updated_at && $this->updated > 0) { + $this->updated_at = $this->updated; + } + + if (0 === $this->created_at && $this->updated > 0) { + $this->created_at = $this->updated; + } + $this->data = $this->getAll(); }