🆙 Add cms i using 🆙

This commit is contained in:
Remco
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,3 @@
# Changelog
All notable changes to `log-viewer` will be documented in this file.
@@ -0,0 +1,196 @@
# CONTRIBUTING
## Introduction
Hello and thank you for your interest in contributing to Log Viewer.
Contributions are welcome and there are many ways you can get involved!
To get started, choose your area of interest:
<table>
<tr>
<td align="center">
<a href="#-issues--discussions">👥 Issues & Discussions</a> |
<a href="#-documentation">📚 Documentation</a> |
<a href="#-spread-the-word">📣 Spread the word</a> |
<a href="#-code-contribution">💻 Code Contribution</a>
</td>
</tr>
</table>
<br/>
---
### 👥 Issues & Discussions
You can interact with users by sharing information and asking/answering questions in our [Discussions](https://github.com/opcodesio/log-viewer/discussions) tab.
Also, you can contribute by reporting bugs, patching problems or providing technical support in our [Issues](https://github.com/opcodesio/log-viewer/issues) tab.
<br/>
---
### 📚 Documentation
Documentation is key for any project success!
Currently, our documentation is stored at the [README](https://github.com/opcodesio/log-viewer/blob/main/README.md) file of this repository.
You may contribute by improving existing information, covering missing topics, or fixing typos and grammar errors.
The documentation official language is in English.
<br/>
---
### 📣 Spread the word
If you enjoy Log Viewer, please consider talking about our project in your community.
Share this [repository link](https://github.com/opcodesio/log-viewer/) on Twitter, YouTube, Discord or any other social network you are part of.
You are also welcome to write articles, reviews and tutorials about this project on your blog or programming website.
Ah! Don't forget to let the author know about your work. Say hello to [@arukompas](https://github.com/arukompas).
<br/>
---
### 💻 Code Contribution
Please follow the steps below to contribute with code.
## Steps
### 📌 Step 1
Fork this repository and enter its directory.
Replace the placeholder `<YOUR-USERNAME>` with your GitHub username and run the command:
```shell
git clone https://github.com/<YOUR-USERNAME>/log-viewer.git && cd log-viewer
```
### 📌 Step 2
Install all PHP dependencies using Composer, run the command:
```shell
composer install
```
Once finished, proceed to install Node dependencies. Run the command:
```shell
npm install
```
### 📌 Step 3
Create a new branch for your code. You may call it `feature-` / `fix-` / `enhancement-` followed by the name of what you are developing.
For example:
```shell
git checkout -b feature/feature-new_about_page
```
Now, you can work on this newly created branch.
### 📌 Step 4
If you're working on the front-end of Log Viewer, you want to run the command `npm run watch` to automatically rebuild any CSS and JavaScript files.
Keep in mind that any front-end changes will need to be re-published to your Laravel application:
```shell
php artisan log-viewer:publish
```
The command also takes an additional parameter, `--watch` which continuously watches for new front-end changes and re-publishes them.
```shell
php artisan log-viewer:publish --watch
```
### 📌 Step 5
After you are done coding, please run Laravel Pint for code formatting:
```Shell
composer format
```
Finally, run the Pest PHP for tests:
```Shell
composer test
```
### 📌 Step 6
You may want to install your modified version of Log Viewer inside a Laravel application, and test if it performs as expected.
In your Laravel application, modify the `composer.json` adding a `repositories` key with the `path` of Log-Viewer on your machine.
This will instruct composer to install Log Viewer from your local folder instead of using the version on the official repository.
Example:
```json
// File: composer.json
{
"scripts": { ... },
"repositories": [
{
"type": "path",
"url": "/home/myuser/projects/log-viewer"
}
]
}
```
Proceed with `composer require opcodesio/log-viewer`.
### 📌 Step 7
If you changed any CSS or JavaScript files, you must build the assets for production before committing.
Run the command:
```shell
npm run production
```
### 📌 Step 8
Commit your changes. Please send short and descriptive commits.
For example:
```Shell
git commit -m "adds route for about page"
```
### 📌 Step 9
If all tests are ✅ passing, you may push your code and submit a Pull Request.
Please write a summary of your contribution, detailing what you are changing/fixing/proposing.
When necessary, please provide usage examples, code snippets and screenshots. You may also include links related to Issues or other Pull Requests.
Once submitted, your Pull Request will be marked for review and people will send questions, comments and eventually request changes.
---
🙏 Thank you for your contribution!
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) arukompas <arukomp@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
@@ -0,0 +1,126 @@
<div align="center">
<p>
<h1>Log Viewer<br/>Easy-to-use, fast, and beautiful</h1>
</p>
</div>
<p align="center">
<a href="https://log-viewer.opcodes.io/">Documentation</a> |
<a href="#features">Features</a> |
<a href="#installation">Installation</a> |
<a href="#troubleshooting">Troubleshooting</a> |
<a href="#credits">Credits</a>
</p>
<p align="center">
<a href="https://packagist.org/packages/opcodesio/log-viewer"><img src="https://img.shields.io/packagist/v/opcodesio/log-viewer.svg?style=flat-square" alt="Packagist"></a>
<a href="https://packagist.org/packages/opcodesio/log-viewer"><img src="https://img.shields.io/packagist/dm/opcodesio/log-viewer.svg?style=flat-square" alt="Packagist"></a>
<a href="https://packagist.org/packages/opcodesio/log-viewer"><img src="https://img.shields.io/packagist/php-v/opcodesio/log-viewer.svg?style=flat-square" alt="PHP from Packagist"></a>
<a href="https://packagist.org/packages/opcodesio/log-viewer"><img src="https://img.shields.io/badge/Laravel-8.x,%209.x,%2010.x,%2011.x,%2012.x-brightgreen.svg?style=flat-square" alt="Laravel Version"></a>
</p>
![log-viewer-light-dark](https://user-images.githubusercontent.com/8697942/186705175-d51db6ef-1615-4f94-aa1e-3ecbcb29ea24.png)
[OPcodes's](https://www.opcodes.io/) **Log Viewer** is a perfect companion for your [Laravel](https://laravel.com/) app.
You will no longer need to read the raw Laravel log files (and other types of logs) trying to find what you're looking for.
Log Viewer helps you quickly and clearly see individual log entries, to **search**, **filter**, and make sense of your Laravel logs **fast**. It is free and easy to install.
> 📺 **[Watch a quick 4-minute video](https://www.youtube.com/watch?v=q7SnF2vubRE)** showcasing some Log Viewer features.
### Features
- 📂 **View all the Laravel logs** in your `storage/logs` directory,
- 📂 **View other types of logs** - Horizon, Apache, Nginx, Redis, Supervisor, Postgres, and more,
- 🔍 **Search** the logs,
- 🎚 **Filter** by log level (error, info, debug, etc.),
- 🔗 **Sharable links** to individual log entries,
- 🌑 **Dark mode**,
- 📱 **Mobile-friendly** UI,
- 🖥️ **Multiple host support**,
- ⌨️ **Keyboard accessible**,
- 💾 **Download & delete** log files from the UI,
- ☑️ **Horizon** log support (up to Horizon v9.20),
- ☎️ **API access** for folders, files & log entries,
- 💌 **Mail previews** for e-mails sent to the logs,
- and more...
### Documentation
Documentation can be found on the [official website](https://log-viewer.opcodes.io/).
## Get Started
### Requirements
- **PHP 8.0+**
- **Laravel 8+**
### Installation
To install the package via composer, Run:
```bash
composer require opcodesio/log-viewer
```
After installing the package, publish the front-end assets by running:
```bash
php artisan log-viewer:publish
```
### Usage
Once the installation is complete, you will be able to access **Log Viewer** directly in your browser.
By default, the application is available at: `{APP_URL}/log-viewer`.
(for example: `https://my-app.test/log-viewer`)
## Configuration
Please visit the **[Log Viewer Docs](https://log-viewer.opcodes.io/docs)** to learn about configuring Log Viewer to your needs.
## Troubleshooting
Here are some common problems and solutions.
### Problem: Logs not loading
Please see [this page](https://log-viewer.opcodes.io/docs/3.x/log-types/default) for support log formats. If your log has a custom format, or is not supported by Log Viewer out of the box, you will need to [define your own custom log parser](https://log-viewer.opcodes.io/docs/3.x/log-types/custom).
If your logs are still not showing up, make sure the web process, which Log Viewer runs on, has permission to read these logs.
For example, if you want to read the Apache HTTP access logs in `/var/log/httpd`, you will need to make sure that your web process (apache/httpd) has permission to read these files. On unix systems, you can do this with [file ACLs](https://www.thegeekdiary.com/unix-linux-access-control-lists-acls-basics/#:~:text=Every%20file%20on%20any%20UNIX,their%20permission%20to%20the%20file).
## Screenshots
Read the **[release blog post](https://arunas.dev/log-viewer-for-laravel/)** for screenshots and more information about Log Viewer's features.
The **[release of v2](https://arunas.dev/log-viewer-v2/)** includes a few new features in v2.
The **[release of v3](https://arunas.dev/log-viewer-v3/)** includes a few new features in v3.
## Changelog
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
## Contributing
Please see [CONTRIBUTING](CONTRIBUTING.md) for details.
## Security Vulnerabilities
Please review [our security policy](../../security/policy) on how to report security vulnerabilities.
## Credits
- [Arunas Skirius](https://github.com/arukompas)
- [All Contributors](../../contributors)
## License
The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
@@ -0,0 +1,76 @@
{
"name": "opcodesio/log-viewer",
"description": "Fast and easy-to-use log viewer for your Laravel application",
"keywords": [
"arukompas",
"opcodesio",
"laravel",
"logs",
"log viewer",
"better-log-viewer"
],
"homepage": "https://github.com/opcodesio/log-viewer",
"license": "MIT",
"authors": [
{
"name": "Arunas Skirius",
"email": "arukomp@gmail.com",
"role": "Developer"
}
],
"require": {
"php": "^8.0",
"illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0",
"opcodesio/mail-parser": "^0.1.6"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.2",
"itsgoingd/clockwork": "^5.1",
"laravel/pint": "^1.0",
"nunomaduro/collision": "^7.0|^8.0",
"orchestra/testbench": "^7.6|^8.0|^9.0|^10.0",
"pestphp/pest": "^2.0|^3.7",
"pestphp/pest-plugin-laravel": "^2.0|^3.1",
"spatie/test-time": "^1.3"
},
"suggest": {
"guzzlehttp/guzzle": "Required for multi-host support. ^7.2"
},
"autoload": {
"psr-4": {
"Opcodes\\LogViewer\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Opcodes\\LogViewer\\Tests\\": "tests"
}
},
"scripts": {
"analyse": "echo \"Static analysis not configured yet.\" && exit 0",
"test": "vendor/bin/pest --order-by random",
"test-coverage": "echo \"Test coverage not configured yet.\" && exit 0",
"format": "vendor/bin/pint"
},
"config": {
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"extra": {
"laravel": {
"providers": [
"Opcodes\\LogViewer\\LogViewerServiceProvider"
],
"aliases": {
"LogViewer": "Opcodes\\LogViewer\\Facades\\LogViewer"
}
}
},
"conflict": {
"arcanedev/log-viewer": "^8.0"
},
"minimum-stability": "dev",
"prefer-stable": true
}
@@ -0,0 +1,332 @@
<?php
use Opcodes\LogViewer\Enums\SortingMethod;
use Opcodes\LogViewer\Enums\SortingOrder;
use Opcodes\LogViewer\Enums\Theme;
return [
/*
|--------------------------------------------------------------------------
| Log Viewer
|--------------------------------------------------------------------------
| Log Viewer can be disabled, so it's no longer accessible via browser.
|
*/
'enabled' => env('LOG_VIEWER_ENABLED', true),
'api_only' => env('LOG_VIEWER_API_ONLY', false),
'require_auth_in_production' => true,
/*
|--------------------------------------------------------------------------
| Log Viewer Domain
|--------------------------------------------------------------------------
| You may change the domain where Log Viewer should be active.
| If the domain is empty, all domains will be valid.
|
*/
'route_domain' => null,
/*
|--------------------------------------------------------------------------
| Log Viewer Route
|--------------------------------------------------------------------------
| Log Viewer will be available under this URL.
|
*/
'route_path' => 'log-viewer',
/*
|--------------------------------------------------------------------------
| Log Viewer Assets Path
|--------------------------------------------------------------------------
| The path to the Log Viewer assets.
|
*/
'assets_path' => 'vendor/log-viewer',
/*
|--------------------------------------------------------------------------
| Back to system URL
|--------------------------------------------------------------------------
| When set, displays a link to easily get back to this URL.
| Set to `null` to hide this link.
|
| Optional label to display for the above URL.
|
*/
'back_to_system_url' => config('app.url', null),
'back_to_system_label' => null, // Displayed by default: "Back to {{ app.name }}"
/*
|--------------------------------------------------------------------------
| Log Viewer time zone.
|--------------------------------------------------------------------------
| The time zone in which to display the times in the UI. Defaults to
| the application's timezone defined in config/app.php.
|
*/
'timezone' => null,
/*
|--------------------------------------------------------------------------
| Log Viewer datetime format.
|--------------------------------------------------------------------------
| The format used to display timestamps in the UI.
|
*/
'datetime_format' => 'Y-m-d H:i:s',
/*
|--------------------------------------------------------------------------
| Log Viewer route middleware.
|--------------------------------------------------------------------------
| Optional middleware to use when loading the initial Log Viewer page.
|
*/
'middleware' => [
'web',
\Opcodes\LogViewer\Http\Middleware\AuthorizeLogViewer::class,
],
/*
|--------------------------------------------------------------------------
| Log Viewer API middleware.
|--------------------------------------------------------------------------
| Optional middleware to use on every API request. The same API is also
| used from within the Log Viewer user interface.
|
*/
'api_middleware' => [
\Opcodes\LogViewer\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Opcodes\LogViewer\Http\Middleware\AuthorizeLogViewer::class,
],
'api_stateful_domains' => env('LOG_VIEWER_API_STATEFUL_DOMAINS') ? explode(',', env('LOG_VIEWER_API_STATEFUL_DOMAINS')) : null,
/*
|--------------------------------------------------------------------------
| Log Viewer Remote hosts.
|--------------------------------------------------------------------------
| Log Viewer supports viewing Laravel logs from remote hosts. They must
| be running Log Viewer as well. Below you can define the hosts you
| would like to show in this Log Viewer instance.
|
*/
'hosts' => [
'local' => [
'name' => ucfirst(env('APP_ENV', 'local')),
],
// 'staging' => [
// 'name' => 'Staging',
// 'host' => 'https://staging.example.com/log-viewer',
// 'auth' => [ // Example of HTTP Basic auth
// 'username' => 'username',
// 'password' => 'password',
// ],
// 'verify_server_certificate' => true,
// ],
//
// 'production' => [
// 'name' => 'Production',
// 'host' => 'https://example.com/log-viewer',
// 'auth' => [ // Example of Bearer token auth
// 'token' => env('LOG_VIEWER_PRODUCTION_TOKEN'),
// ],
// 'headers' => [
// 'X-Foo' => 'Bar',
// ],
// 'verify_server_certificate' => true,
// ],
],
/*
|--------------------------------------------------------------------------
| Include file patterns
|--------------------------------------------------------------------------
|
*/
'include_files' => [
'*.log',
'**/*.log',
// You can include paths to other log types as well, such as apache, nginx, and more.
// This key => value pair can be used to rename and group multiple paths into one folder in the UI.
'/var/log/httpd/*' => 'Apache',
'/var/log/nginx/*' => 'Nginx',
// MacOS Apple Silicon logs
'/opt/homebrew/var/log/nginx/*',
'/opt/homebrew/var/log/httpd/*',
'/opt/homebrew/var/log/php-fpm.log',
'/opt/homebrew/var/log/postgres*log',
'/opt/homebrew/var/log/redis*log',
'/opt/homebrew/var/log/supervisor*log',
// '/absolute/paths/supported',
],
/*
|--------------------------------------------------------------------------
| Exclude file patterns.
|--------------------------------------------------------------------------
| This will take precedence over included files.
|
*/
'exclude_files' => [
// 'my_secret.log'
],
/*
|--------------------------------------------------------------------------
| Hide unknown files.
|--------------------------------------------------------------------------
| The include/exclude options above might catch files which are not
| logs supported by Log Viewer. In that case, you can hide them
| from the UI and API calls by setting this to true.
|
*/
'hide_unknown_files' => true,
/*
|--------------------------------------------------------------------------
| Shorter stack trace filters.
|--------------------------------------------------------------------------
| Lines containing any of these strings will be excluded from the full log.
| This setting is only active when the function is enabled via the user interface.
|
*/
'shorter_stack_trace_excludes' => [
'/vendor/symfony/',
'/vendor/laravel/framework/',
'/vendor/barryvdh/laravel-debugbar/',
],
/*
|--------------------------------------------------------------------------
| Cache driver
|--------------------------------------------------------------------------
| Cache driver to use for storing the log indices. Indices are used to speed up
| log navigation. Defaults to your application's default cache driver.
|
*/
'cache_driver' => env('LOG_VIEWER_CACHE_DRIVER', null),
/*
|--------------------------------------------------------------------------
| Cache key prefix
|--------------------------------------------------------------------------
| Log Viewer prefixes all the cache keys created with this value. If for
| some reason you would like to change this prefix, you can do so here.
| The format of Log Viewer cache keys is:
| {prefix}:{version}:{rest-of-the-key}
|
*/
'cache_key_prefix' => 'lv',
/*
|--------------------------------------------------------------------------
| Chunk size when scanning log files lazily
|--------------------------------------------------------------------------
| The size in MB of files to scan before updating the progress bar when searching across all files.
|
*/
'lazy_scan_chunk_size_in_mb' => 50,
'strip_extracted_context' => true,
/*
|--------------------------------------------------------------------------
| Per page options
|--------------------------------------------------------------------------
| Define the available options for number of results per page
|
*/
'per_page_options' => [10, 25, 50, 100, 250, 500],
/*
|--------------------------------------------------------------------------
| Default settings for Log Viewer
|--------------------------------------------------------------------------
| These settings determine the default behaviour of Log Viewer. Many of
| these can be persisted for the user in their browser's localStorage,
| if the `use_local_storage` option is set to true.
|
*/
'defaults' => [
// Whether to use browser's localStorage to store user preferences.
// If true, user preferences saved in the browser will take precedence over the defaults below.
'use_local_storage' => true,
// Method to sort the folders. Other options: `Alphabetical`, `ModifiedTime`
'folder_sorting_method' => SortingMethod::ModifiedTime,
// Order to sort the folders. Other options: `Ascending`, `Descending`
'folder_sorting_order' => SortingOrder::Descending,
// Method for sorting log-files into directories. Other options: `Alphabetical`, `ModifiedTime`
'file_sorting_method' => SortingMethod::ModifiedTime,
// Order to sort the logs. Other options: `Ascending`, `Descending`
'log_sorting_order' => SortingOrder::Descending,
// Number of results per page. Must be one of the above `per_page_options` values
'per_page' => 25,
// Color scheme for the Log Viewer. Other options: `System`, `Light`, `Dark`
'theme' => Theme::System,
// Whether to enable `Shorter Stack Traces` option by default
'shorter_stack_traces' => false,
],
/*
|--------------------------------------------------------------------------
| Exclude IP from identifiers
|--------------------------------------------------------------------------
| By default, file and folder identifiers include the server's IP address
| to ensure uniqueness. In load-balanced environments with shared storage,
| this can cause "No results" errors. Set to true to exclude IP addresses
| from identifier generation for consistent results across servers.
|
*/
'exclude_ip_from_identifiers' => env('LOG_VIEWER_EXCLUDE_IP_FROM_IDENTIFIERS', false),
/*
|--------------------------------------------------------------------------
| Root folder prefix
|--------------------------------------------------------------------------
| The prefix for log files inside Laravel's `storage/logs` folder.
| Log Viewer does not show the full path to these files in the UI,
| but only the filename prefixed with this value.
|
*/
'root_folder_prefix' => 'root',
];
@@ -0,0 +1,4 @@
{
"/public/app.js": "/public/app.js",
"/public/app.css": "/public/app.css"
}
@@ -0,0 +1,38 @@
{
"name": "opcodesio-log-viewer",
"author": "Arunas Skirius <arunas@belltastic.com>",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/opcodesio/log-viewer.git"
},
"scripts": {
"dev": "npm run development",
"development": "mix",
"watch": "mix watch",
"watch-poll": "mix watch -- --watch-options-poll=1000",
"hot": "mix watch --hot",
"prod": "npm run production",
"production": "mix --production"
},
"devDependencies": {
"@headlessui/vue": "^1.7.9",
"@heroicons/vue": "^2.0.15",
"@vueuse/core": "^9.12.0",
"autoprefixer": "^10.4.7",
"axios": "^1.8.2",
"laravel-mix": "^6.0.49",
"loader-utils": ">=2.0.3",
"lodash": "^4.17.21",
"pinia": "^2.0.30",
"postcss": "^8.4.14",
"resolve-url-loader": "^5.0.0",
"sass": "^1.58.0",
"sass-loader": "^13.2.0",
"tailwindcss": "^3.1.6",
"vue": "^3.2.47",
"vue-loader": "^17.0.1",
"vue-router": "^4.1.6",
"vue-template-compiler": "^2.7.14"
}
}
@@ -0,0 +1,14 @@
{
"preset": "laravel",
"rules": {
"class_attributes_separation": {
"elements": {
"const": "none",
"method": "one",
"property": "none",
"trait_import": "none",
"case": "none"
}
}
}
}
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
/*!
* The buffer module from node.js, for the browser.
*
* @author Feross Aboukhadijeh <http://feross.org>
* @license MIT
*/
/*!
* pinia v2.2.2
* (c) 2024 Eduardo San Martin Morote
* @license MIT
*/
/*! #__NO_SIDE_EFFECTS__ */
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/**
* @license
* Lodash <https://lodash.com/>
* Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/
/**
* @vue/shared v3.4.38
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

@@ -0,0 +1,7 @@
{
"/app.js": "/app.js?id=b5eb6497b80ecd00237a857b35fcc1d6",
"/app.css": "/app.css?id=bf9e77abce3da8caacd004d57e4e8429",
"/img/log-viewer-128.png": "/img/log-viewer-128.png?id=d576c6d2e16074d3f064e60fe4f35166",
"/img/log-viewer-32.png": "/img/log-viewer-32.png?id=f8ec67d10f996aa8baf00df3b61eea6d",
"/img/log-viewer-64.png": "/img/log-viewer-64.png?id=8902d596fc883ca9eb8105bb683568c6"
}
@@ -0,0 +1,696 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './loader.scss';
html.dark {
color-scheme: dark
}
#bmc-wbtn {
height: 48px !important;
width: 48px !important;
&>img {
height: 32px !important;
width: 32px !important;
}
}
.log-levels-selector {
.dropdown-toggle {
@apply whitespace-nowrap;
&:focus {
@apply outline-none ring-2 ring-brand-500 dark:ring-brand-700;
}
& > svg {
@apply w-4 h-4 ml-1 opacity-75;
}
}
.dropdown {
.log-level {
@apply font-semibold;
&.success, &.notice {
@apply text-emerald-700 dark:text-emerald-500;
}
&.info {
@apply text-sky-700 dark:text-sky-500;
}
&.warning {
@apply text-amber-700 dark:text-amber-400;
}
&.danger {
@apply text-rose-700 dark:text-rose-400;
}
&.none {
@apply text-gray-700 dark:text-gray-400;
}
}
.log-count {
@apply text-gray-500 dark:text-gray-400 ml-8 whitespace-nowrap;
}
button.active {
.log-level {
&.success, &.notice {
@apply text-emerald-100;
}
&.info {
@apply text-sky-100;
}
&.warning {
@apply text-amber-100;
}
&.danger {
@apply text-rose-100;
}
&.none {
@apply text-gray-100;
}
}
.log-count {
@apply text-gray-200 dark:text-gray-300;
}
}
.no-results {
@apply text-xs text-gray-500 dark:text-gray-400 text-center px-4 py-1;
}
}
}
.log-item {
@apply bg-white dark:bg-gray-800 transition duration-200 cursor-pointer;
&.success, &.notice {
&:hover > td, &.active > td, &:focus-within > td {
@apply bg-emerald-50 dark:bg-emerald-800 dark:bg-opacity-40;
}
.log-level-indicator {
@apply bg-emerald-700 dark:bg-emerald-500;
}
.log-level {
@apply text-emerald-700 dark:text-emerald-500;
}
}
&.info {
&:hover > td, &.active > td, &:focus-within > td {
@apply bg-sky-50 dark:bg-sky-800 dark:bg-opacity-40;
}
.log-level-indicator {
@apply bg-sky-700 dark:bg-sky-500;
}
.log-level {
@apply text-sky-700 dark:text-sky-500;
}
}
&.warning {
&:hover > td, &.active > td, &:focus-within > td {
@apply bg-amber-50 dark:bg-amber-800 dark:bg-opacity-40;
}
.log-level-indicator {
@apply bg-amber-700 dark:bg-amber-400;
}
.log-level {
@apply text-amber-700 dark:text-amber-400;
}
}
&.danger {
&:hover > td, &.active > td, &:focus-within > td {
@apply bg-rose-50 dark:bg-rose-800 dark:bg-opacity-40;
}
.log-level-indicator {
@apply bg-rose-700 dark:bg-rose-400;
}
.log-level {
@apply text-rose-700 dark:text-rose-400;
}
}
&.none {
&:hover > td, &.active > td, &:focus-within > td {
@apply bg-gray-50 dark:bg-gray-800 dark:bg-opacity-40;
}
.log-level-indicator {
@apply bg-gray-700 dark:bg-gray-400;
}
.log-level {
@apply text-gray-700 dark:text-gray-400;
}
}
&:hover .log-level-icon {
@apply opacity-100;
}
}
/** Level Badges **/
.badge {
@apply inline-flex items-center text-sm cursor-pointer px-3 py-1 rounded-md mr-2 mt-1 transition duration-200;
&.success, &.notice {
@apply border border-emerald-200 bg-emerald-50 text-gray-600
dark:border-emerald-800 dark:bg-emerald-900 dark:bg-opacity-40 dark:text-gray-400;
&:hover {
@apply bg-emerald-100
dark:bg-emerald-900 dark:bg-opacity-75;
}
.checkmark {
@apply border-emerald-200
dark:border-emerald-800;
}
&.active {
@apply border-emerald-700 bg-emerald-600 text-white
dark:border-emerald-600 dark:bg-emerald-700;
&:hover {
@apply bg-emerald-500
dark:bg-emerald-800;
}
.checkmark {
@apply border-emerald-600
dark:border-emerald-700 dark:bg-emerald-100;
}
.checkmark > svg {
@apply text-emerald-600
dark:text-emerald-700;
}
}
}
&.info {
@apply border border-sky-200 bg-sky-50 text-gray-600
dark:border-sky-800 dark:bg-sky-900 dark:bg-opacity-40 dark:text-gray-400;
&:hover {
@apply bg-sky-100
dark:bg-sky-900 dark:bg-opacity-75;
}
.checkmark {
@apply border-sky-200
dark:border-sky-800;
}
&.active {
@apply border-sky-700 bg-sky-600 text-white
dark:border-sky-600 dark:bg-sky-700;
&:hover {
@apply bg-sky-500
dark:bg-sky-800;
}
.checkmark {
@apply border-sky-600
dark:border-sky-700 dark:bg-sky-100;
}
.checkmark > svg {
@apply text-sky-600
dark:text-sky-700;
}
}
}
&.warning {
@apply border border-amber-200 bg-amber-50 text-gray-600
dark:border-amber-800 dark:bg-amber-900 dark:bg-opacity-40 dark:text-gray-400;
&:hover {
@apply bg-amber-100
dark:bg-amber-900 dark:bg-opacity-75;
}
& .checkmark {
@apply border-amber-200
dark:border-amber-800;
}
&.active {
@apply border-amber-700 bg-amber-600 text-white
dark:border-amber-600 dark:bg-amber-700;
&:hover {
@apply bg-amber-500
dark:bg-amber-800;
}
& .checkmark {
@apply border-amber-600
dark:border-amber-700 dark:bg-amber-100;
}
& .checkmark > svg {
@apply text-amber-600
dark:text-amber-700;
}
}
}
&.danger {
@apply border border-rose-200 bg-rose-50 text-gray-600
dark:border-rose-800 dark:bg-rose-900 dark:bg-opacity-40 dark:text-gray-400;
&:hover {
@apply bg-rose-100
dark:bg-rose-900 dark:bg-opacity-75;
}
& .checkmark {
@apply border-rose-200
dark:border-rose-800;
}
&.active {
@apply border-rose-700 bg-rose-600 text-white
dark:border-rose-600 dark:bg-rose-700;
&:hover {
@apply bg-rose-500
dark:bg-rose-800;
}
& .checkmark {
@apply border-rose-600
dark:border-rose-700 dark:bg-rose-100;
}
& .checkmark > svg {
@apply text-rose-600
dark:text-rose-700;
}
}
}
&.none {
@apply border border-gray-200 bg-gray-100 text-gray-600
dark:border-gray-800 dark:bg-gray-900 dark:bg-opacity-40 dark:text-gray-400;
&:hover {
@apply bg-gray-50
dark:bg-gray-900 dark:bg-opacity-75;
}
& .checkmark {
@apply border-gray-200
dark:border-gray-800;
}
&.active {
@apply bg-white text-gray-800
dark:bg-gray-800 dark:text-gray-100 dark:border-gray-600;
&:hover {
@apply bg-gray-50
dark:bg-gray-900;
}
& .checkmark {
@apply border-gray-600
dark:border-gray-700 dark:bg-gray-100;
}
& .checkmark > svg {
@apply text-gray-600
dark:text-gray-700;
}
}
}
}
.log-list {
table > thead th {
@apply sticky top-0 z-10 bg-gray-100 dark:bg-gray-900 py-2 px-1 lg:px-2 text-left text-xs lg:text-sm font-semibold text-gray-500 dark:text-gray-400;
}
.log-group {
@apply bg-white dark:bg-gray-800 dark:text-gray-200 relative;
.log-item > td {
@apply border-t border-gray-200 dark:border-gray-700 px-1 lg:px-2 py-1.5 lg:py-2 text-xs lg:text-sm;
}
&.first {
.log-item > td {
@apply border-t-transparent;
}
}
.mail-preview-attributes {
@apply text-xs lg:text-sm w-full border border-brand-100 dark:border-brand-800 rounded bg-brand-50/30 dark:bg-brand-900/20 overflow-x-auto lg:overflow-hidden mb-4 lg:mb-6;
table {
@apply w-full;
}
td {
@apply px-2 py-1 lg:px-6 lg:py-2;
}
td:not(:first-child) {
overflow-wrap: anywhere;
}
tr:first-child td {
@apply pt-1.5 lg:pt-3;
}
tr:last-child td {
@apply pb-1.5 lg:pb-3;
}
tr:not(:last-child) td {
@apply border-b border-brand-100 dark:border-brand-900;
}
}
.mail-preview-html {
@apply w-full border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-900 overflow-auto mb-4 lg:mb-6;
}
.mail-preview-text {
@apply w-full border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-900 text-sm whitespace-pre-wrap mb-4 lg:mb-6 p-4;
}
.mail-attachment-button {
@apply flex items-center justify-between px-2 py-1 lg:px-4 lg:py-2 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded;
max-width: 460px;
&:not(:last-child) {
@apply mb-2;
}
a {
@apply focus:outline-brand-500;
}
}
.tabs-container {
@apply text-xs lg:text-sm;
}
.tabs-container,
.mail-preview,
.log-stack {
@apply px-2 py-1 lg:py-2 lg:px-8;
}
.log-stack {
@apply border-gray-200 dark:border-gray-700 text-[10px] leading-3 lg:text-xs lg:leading-4 whitespace-pre-wrap break-all;
}
.log-link {
@apply flex items-center justify-end w-full -my-0.5 py-0.5 pl-1 rounded;
@screen sm {
min-width: 64px;
}
& > svg {
@apply h-4 w-4 ml-1 transition duration-200;
}
&:focus {
@apply outline-none ring-2 ring-brand-500 dark:ring-brand-400;
}
}
code, mark {
@apply bg-amber-200 text-gray-900 px-1 py-0.5 rounded
dark:bg-yellow-800 dark:text-white;
}
}
}
.pagination {
@apply sm:mt-2 sm:px-4 w-full flex items-center justify-center lg:px-0;
.previous {
@apply -mt-px w-0 flex-1 flex justify-start md:justify-end;
button {
@apply border-t-2 border-transparent pt-3 pr-1 inline-flex items-center text-sm font-medium text-gray-500 dark:text-gray-400;
&:hover {
@apply text-gray-700 border-gray-300
dark:text-gray-300 border-gray-400;
}
&:focus {
@apply rounded-md outline-none ring-2 ring-brand-500 dark:ring-brand-400;
}
svg {
@apply mx-3 h-5 w-5 text-current;
}
}
}
.next {
@apply -mt-px w-0 flex-1 flex justify-end md:justify-start;
button {
@apply border-t-2 border-transparent pt-3 pl-1 inline-flex items-center text-sm font-medium text-gray-500 dark:text-gray-400;
&:hover {
@apply text-gray-700 border-gray-300
dark:text-gray-300 border-gray-400;
}
&:focus {
@apply rounded-md outline-none ring-2 ring-brand-500 dark:ring-brand-400;
}
svg {
@apply mx-3 h-5 w-5 text-current;
}
}
}
.pages {
@apply hidden sm:-mt-px sm:flex;
span {
@apply border-transparent text-gray-500 dark:text-gray-400 border-t-2 pt-3 px-4 inline-flex items-center text-sm font-medium;
}
button {
@apply border-t-2 pt-3 px-4 inline-flex items-center text-sm font-medium;
&:focus {
@apply rounded-md outline-none ring-2 ring-brand-500 dark:ring-brand-400;
}
}
}
}
.search {
@apply relative bg-white border rounded-md text-sm w-full flex items-center dark:bg-gray-800 border-gray-300 dark:border-gray-600 dark:text-gray-100 transition duration-200;
.prefix-icon {
@apply ml-3 mr-1 text-gray-400 dark:text-gray-500;
}
input {
@apply w-full px-1 py-1 rounded flex-1 bg-inherit ring-2 ring-transparent transition duration-200;
&:focus {
@apply outline-none ring-brand-500 dark:ring-brand-700 border-transparent;
}
}
&.has-error {
@apply border-red-600;
}
.submit-search {
button {
@apply flex rounded-r items-center bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-2 transition duration-200;
&:hover { @apply bg-gray-200 dark:bg-gray-600; }
&:focus { @apply outline-none ring-2 ring-brand-500 dark:ring-brand-700; }
& > svg { @apply h-5 w-5 ml-1 opacity-75; }
}
}
.clear-search {
@apply absolute right-0 top-0;
button {
@apply rounded text-gray-400 dark:text-gray-500 p-1 transition duration-200;
&:hover { @apply text-gray-600 dark:text-gray-400; }
&:focus { @apply outline-none ring-2 ring-brand-500 dark:ring-brand-700; }
& > svg { @apply h-5 w-5; }
}
}
}
.search-progress-bar {
@apply absolute top-1 bg-brand-500 dark:bg-brand-600 h-0.5 rounded transition-width ease-linear duration-300;
}
.dropdown {
@apply absolute top-full z-40 right-1 -mt-1 overflow-hidden text-gray-900 dark:text-gray-200 rounded-md bg-white border-gray-200 dark:bg-gray-800 border dark:border-gray-700 shadow-md;
&:focus {
@apply outline-none ring-1 ring-brand-500 ring-opacity-50 dark:ring-brand-700 dark:ring-opacity-50;
}
& {
transform-origin: top right !important;
}
&.up {
transform-origin: bottom right !important;
}
&.up {
@apply top-auto bottom-full mt-0 -mb-1;
}
&.left {
@apply left-1 right-auto;
& {
transform-origin: top left !important;
}
&.up {
transform-origin: bottom left !important;
}
}
button:not(.inline-link), a:not(.inline-link) {
@apply block flex items-center w-full px-4 py-2 text-left text-sm outline-brand-500 dark:outline-brand-800;
& > svg {
@apply w-4 h-4 mr-3 text-gray-400;
&.spin {
// Spinner is slightly dimmer by default, especially because it's constantly spinning
// and it's also a little smaller in size
@apply text-gray-600;
}
}
}
button:hover, a:hover, button.active, a.active {
@apply text-white bg-brand-600;
& > .checkmark {
@apply bg-brand-600 dark:border-gray-300;
}
& > svg {
@apply text-white;
}
}
.divider {
@apply w-full border-t my-2 dark:border-t-gray-700;
}
.label {
@apply text-xs font-semibold text-gray-400 mx-4 my-1;
}
}
// Dark border: border-brand-800
// Dark border (hover): border-brand-700
.file-list {
@apply relative h-full overflow-y-auto pb-4 px-3 md:pr-0 md:pl-0;
.file-item-container,
.folder-item-container {
@apply relative mt-2 text-gray-800 dark:text-gray-200 rounded-md bg-white dark:bg-gray-800 top-0;
.file-item {
@apply relative flex justify-between items-center rounded-md border cursor-pointer border-transparent transition duration-100;
.file-item-info {
@apply flex-1 text-left flex items-center justify-between pl-4 pr-3 py-2 rounded-l-md outline-brand-500 dark:outline-brand-700 transition duration-200;
&:hover {
@apply bg-brand-50 dark:bg-brand-900;
}
}
.file-icon {
@apply mr-2 text-gray-400 dark:text-gray-500;
&>svg {
@apply w-4 h-4;
}
}
.file-name {
@apply text-sm mr-3 w-full;
word-break: break-word;
}
.file-size {
@apply text-xs text-gray-500 dark:text-gray-300 dark:opacity-90 whitespace-nowrap;
}
}
&.active .file-item {
@apply border-brand-500 dark:border-brand-900 bg-brand-50 dark:bg-brand-900 dark:bg-opacity-40;
}
&.active-folder .file-item {
@apply border-gray-300 dark:border-gray-700;
}
&:hover .file-item {
@apply border-brand-600 dark:border-brand-800;
}
.file-dropdown-toggle {
@apply self-stretch w-8 flex rounded-r-md items-center justify-center border-l border-transparent text-gray-500 dark:text-gray-400 outline-brand-500 dark:outline-brand-700 transition duration-200;
&:hover {
@apply border-brand-600 bg-brand-50 dark:border-brand-800 dark:bg-brand-900;
}
}
}
.folder-container {
.folder-item-container {
&.sticky {
position: -webkit-sticky;
position: sticky;
}
}
}
.folder-container:first-child > .folder-item-container {
@apply mt-0;
}
}
.menu-button {
@apply relative p-2 text-gray-400 rounded-md outline-brand-500 cursor-pointer transition duration-200;
&:hover {
@apply text-gray-500 dark:text-gray-300;
}
&:focus {
@apply outline-none ring-2 ring-brand-500 dark:ring-brand-700;
}
}
button.button, a.button {
@apply block flex items-center w-full px-4 py-2 text-left text-sm outline-brand-500 dark:outline-brand-800 text-gray-900 dark:text-gray-200 rounded-md;
& > svg {
@apply w-4 h-4 text-gray-600 dark:text-gray-400;
&.spin {
// Spinner is slightly dimmer by default, especially because it's constantly spinning
// and it's also a little smaller in size
@apply text-gray-600;
}
}
}
button.button:hover, a.button:hover {
@apply bg-gray-50 dark:bg-gray-700;
}
.select {
@apply bg-gray-100 dark:bg-gray-900 text-gray-700 dark:text-gray-300 rounded font-normal outline-none px-1 -my-0.5 py-0.5;
}
.select:hover {
@apply bg-gray-200 dark:bg-gray-800;
}
.select:focus {
@apply ring-2 ring-brand-500 dark:ring-brand-700;
}
.keyboard-shortcut {
@apply text-sm flex items-center justify-start mb-3 w-full text-gray-600 dark:text-gray-300;
.shortcut {
@apply font-mono text-base dark:text-gray-300 inline-flex w-6 h-6 mr-2 items-center justify-center rounded border border-gray-400 dark:border-gray-500 ring-1 ring-gray-100 dark:ring-gray-900;
}
}
@@ -0,0 +1,54 @@
.spin {
-webkit-animation-name: spin;
-webkit-animation-duration: 1500ms;
-webkit-animation-iteration-count: infinite;
-webkit-animation-timing-function: linear;
-moz-animation-name: spin;
-moz-animation-duration: 1500ms;
-moz-animation-iteration-count: infinite;
-moz-animation-timing-function: linear;
-ms-animation-name: spin;
-ms-animation-duration: 1500ms;
-ms-animation-iteration-count: infinite;
-ms-animation-timing-function: linear;
animation-name: spin;
animation-duration: 1500ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@-ms-keyframes spin {
from {
-ms-transform: rotate(0deg);
}
to {
-ms-transform: rotate(360deg);
}
}
@-moz-keyframes spin {
from {
-moz-transform: rotate(0deg);
}
to {
-moz-transform: rotate(360deg);
}
}
@-webkit-keyframes spin {
from {
-webkit-transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

@@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'App',
};
</script>
@@ -0,0 +1,53 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import axios from 'axios';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import Home from './pages/Home.vue';
let token = document.head.querySelector('meta[name="csrf-token"]');
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
if (token) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
}
for (const [key, value] of Object.entries(window.LogViewer.headers || {})) {
axios.defaults.headers.common[key] = value;
}
window.LogViewer.basePath = '/' + window.LogViewer.path;
if (! window.location.pathname.startsWith(window.LogViewer.basePath)) {
window.LogViewer.basePath = window.location.pathname;
}
let routerBasePath = window.LogViewer.basePath + '/';
if (window.LogViewer.path === '' || window.LogViewer.path === '/') {
routerBasePath = '/';
window.LogViewer.basePath = '';
}
const router = createRouter({
routes: [{
path: window.LogViewer.basePath,
name: 'home',
component: Home,
}],
history: createWebHistory(),
base: routerBasePath,
});
const pinia = createPinia();
const app = createApp(App);
app.use(router);
app.use(pinia);
app.mixin({
computed: {
LogViewer: () => window.LogViewer,
},
});
app.mount('#log-viewer');
@@ -0,0 +1,235 @@
<template>
<table class="table-fixed min-w-full max-w-full border-separate" style="border-spacing: 0">
<thead class="bg-gray-50">
<tr>
<th class="hidden lg:table-cell"><span class="sr-only">Expand/Collapse</span></th>
<th v-for="(column) in logViewerStore.columns" scope="col">
<div>{{ column.label }}</div>
</th>
<th scope="col" class="hidden lg:table-cell"><span class="sr-only">Log index</span></th>
</tr>
</thead>
<template v-if="logViewerStore.logs && logViewerStore.logs.length > 0">
<tbody v-for="(log, index) in logViewerStore.logs" :key="index"
:class="[index === 0 ? 'first' : '', 'log-group']"
:id="`tbody-${index}`" :data-index="index"
>
<tr @click="logViewerStore.toggle(index)"
:class="['log-item group', log.level_class, logViewerStore.isOpen(index) ? 'active' : '', logViewerStore.shouldBeSticky(index) ? 'sticky z-2' : '']"
:style="{ top: logViewerStore.stackTops[index] || 0 }"
>
<td class="log-level hidden lg:table-cell">
<div class="flex items-center lg:pl-2">
<button :aria-expanded="logViewerStore.isOpen(index)"
@keydown="handleLogToggleKeyboardNavigation"
class="log-level-icon opacity-75 w-5 h-5 hidden lg:block group focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-brand-500 rounded-md"
>
<span class="sr-only" v-if="!logViewerStore.isOpen(index)">Expand log entry</span>
<span class="sr-only" v-if="logViewerStore.isOpen(index)">Collapse log entry</span>
<span class="w-full h-full group-hover:hidden group-focus:hidden">
<ExclamationCircleIcon v-if="log.level_class === 'danger'" />
<ExclamationTriangleIcon v-else-if="log.level_class === 'warning'" />
<CheckCircleIcon v-else-if="log.level_class === 'success'" />
<InformationCircleIcon v-else />
</span>
<span class="w-full h-full hidden group-hover:inline-block group-focus:inline-block">
<ChevronRightIcon :class="[logViewerStore.isOpen(index) ? 'rotate-90' : '', 'transition duration-100']" />
</span>
</button>
</div>
</td>
<template v-for="(column, colIndex) in logViewerStore.columns">
<!-- Severity -->
<td :key="`${log.index}-column-${colIndex}`" v-if="column.data_path === 'level'" class="log-level truncate">
<span>{{ log.level_name }}</span>
</td>
<!-- /Severity -->
<!-- Datetime -->
<td :key="`${log.index}-column-${colIndex}`" v-else-if="column.data_path === 'datetime'" class="whitespace-nowrap text-gray-900 dark:text-gray-200">
<span class="hidden lg:inline" v-html="highlightSearchResult(log.datetime, searchStore.query)"></span>
<span class="lg:hidden">{{ log.time }}</span>
</td>
<!-- /Datetime -->
<!-- Message -->
<td :key="`${log.index}-column-${colIndex}`" v-else-if="column.data_path === 'message'" class="max-w-[1px] w-full truncate text-gray-500 dark:text-gray-300 dark:opacity-90">
<span v-html="highlightSearchResult(`${log.message}`, searchStore.query)"></span>
</td>
<!-- /Message -->
<td :key="`${log.index}-column-${colIndex}`" v-else class="text-gray-500 dark:text-gray-300 dark:opacity-90" :class="column.class || ''">
<span v-html="highlightSearchResult(getDataAtPath(log, column.data_path), searchStore.query)"></span>
</td>
</template>
<td class="whitespace-nowrap text-gray-500 dark:text-gray-300 dark:opacity-90 text-xs hidden lg:table-cell">
<LogCopyButton :log="log" class="pr-2 large-screen" />
</td>
</tr>
<tr v-show="logViewerStore.isOpen(index)">
<td :colspan="tableColumns">
<div class="lg:hidden flex justify-between px-2 pt-2 pb-1 text-xs">
<div class="flex-1"><span class="font-semibold">Datetime:</span> {{ log.datetime }}</div>
<div>
<LogCopyButton :log="log" />
</div>
</div>
<tab-container v-if="logViewerStore.isOpen(index)" :tabs="getTabsForLog(log)">
<tab-content v-if="log.extra && log.extra.mail_preview && log.extra.mail_preview.html" tab-value="mail_html_preview">
<mail-html-preview :mail="log.extra.mail_preview" />
</tab-content>
<tab-content v-if="log.extra && log.extra.mail_preview && log.extra.mail_preview.text" tab-value="mail_text_preview">
<mail-text-preview :mail="log.extra.mail_preview" />
</tab-content>
<tab-content v-if="hasLaravelStackTrace(log)" tab-value="laravel_stack_trace">
<LaravelStackTraceDisplay :log="log" />
</tab-content>
<tab-content tab-value="raw">
<pre class="log-stack" v-html="highlightSearchResult(log.full_text, searchStore.query)"></pre>
<template v-if="hasContext(log)">
<p class="mx-2 lg:mx-8 pt-2 border-t font-semibold text-gray-700 dark:text-gray-400 text-xs lg:text-sm">Context:</p>
<pre class="log-stack" v-html="highlightSearchResult(prepareContextForOutput(log.context), searchStore.query)"></pre>
</template>
<div v-if="log.extra && log.extra.log_text_incomplete" class="py-4 px-8 text-gray-500 italic">
The contents of this log have been cut short to the first {{ LogViewer.max_log_size_formatted }}.
The full size of this log entry is <strong>{{ log.extra.log_size_formatted }}</strong>
</div>
</tab-content>
</tab-container>
</td>
</tr>
</tbody>
</template>
<tbody v-else class="log-group">
<tr>
<td colspan="6">
<div class="bg-white text-gray-600 dark:bg-gray-800 dark:text-gray-200 p-12">
<div class="text-center font-semibold">No results</div>
<div class="text-center mt-6">
<button v-if="searchStore.query?.length > 0"
class="px-3 py-2 border dark:border-gray-700 text-gray-800 dark:text-gray-200 hover:border-brand-600 dark:hover:border-brand-700 rounded-md"
@click="clearQuery">Clear search query
</button>
<button v-if="searchStore.query?.length > 0 && fileStore.selectedFile"
class="px-3 ml-3 py-2 border dark:border-gray-700 text-gray-800 dark:text-gray-200 hover:border-brand-600 dark:hover:border-brand-700 rounded-md"
@click.prevent="clearSelectedFile">Search all files
</button>
<button
v-if="severityStore.levelsFound.length > 0 && severityStore.levelsSelected.length === 0"
class="px-3 ml-3 py-2 border dark:border-gray-700 text-gray-800 dark:text-gray-200 hover:border-brand-600 dark:hover:border-brand-700 rounded-md"
@click="severityStore.selectAllLevels">Select all severities
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
import {
ChevronRightIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
InformationCircleIcon,
} from '@heroicons/vue/24/solid';
import { highlightSearchResult } from '../helpers.js';
import { useLogViewerStore } from '../stores/logViewer.js';
import { useSearchStore } from '../stores/search.js';
import { useFileStore } from '../stores/files.js';
import LogCopyButton from './LogCopyButton.vue';
import { handleLogToggleKeyboardNavigation } from '../keyboardNavigation';
import { useSeverityStore } from '../stores/severity.js';
import TabContainer from "./TabContainer.vue";
import TabContent from "./TabContent.vue";
import MailHtmlPreview from "./MailHtmlPreview.vue";
import MailTextPreview from "./MailTextPreview.vue";
import LaravelStackTraceDisplay from "./LaravelStackTraceDisplay.vue";
import {computed} from "vue";
const fileStore = useFileStore();
const logViewerStore = useLogViewerStore();
const searchStore = useSearchStore();
const severityStore = useSeverityStore();
const emit = defineEmits(['clearSelectedFile', 'clearQuery']);
const clearSelectedFile = () => {
emit('clearSelectedFile');
}
const clearQuery = () => {
emit('clearQuery');
}
const getDataAtPath = (obj, path) => {
const value = path.split('.').reduce((acc, part) => acc && acc[part], obj);
return typeof value === 'undefined' ? '' : String(value);
}
const hasContext = (log) => {
return log.context && Object.keys(log.context).length > 0;
}
const getExtraTabsForLog = (log) => {
let tabs = [];
if (hasLaravelStackTrace(log)) {
tabs.push({ name: 'Stack Trace', value: 'laravel_stack_trace' });
}
if (! log.extra || ! log.extra.mail_preview) {
return tabs;
}
if (log.extra.mail_preview.html) {
tabs.push({ name: 'HTML preview', value: 'mail_html_preview' });
}
if (log.extra.mail_preview.text) {
tabs.push({ name: 'Text preview', value: 'mail_text_preview' });
}
return tabs;
}
const getTabsForLog = (log) => {
const tabs = [...getExtraTabsForLog(log)];
tabs.push({ name: 'Raw', value: 'raw' });
return tabs.filter(Boolean);
}
const prepareContextForOutput = (context) => {
return JSON.stringify(context, function (key, value) {
if (typeof value === 'string') {
return value.replaceAll('\n', '<br/>');
}
return value;
}, 2);
}
const hasLaravelStackTrace = (log) => {
const exception = Array.isArray(log.context)
? log.context.find(item => item.exception)?.exception
: log.context.exception;
return exception && typeof exception === 'string' && exception.includes('[stacktrace]');
}
const tableColumns = computed(() => {
// the extra two columns are for the expand/collapse and log index columns
return logViewerStore.columns.length + 2;
});
</script>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
<template>
<div class="checkmark w-[18px] h-[18px] bg-gray-50 dark:bg-gray-800 rounded border dark:border-gray-600 inline-flex items-center justify-center">
<CheckIcon v-if="checked" width="18" height="18" class="w-full h-full" />
</div>
</template>
<script setup>
import { CheckIcon } from '@heroicons/vue/20/solid'
defineProps({
checked: {
type: Boolean,
required: true,
},
})
</script>
@@ -0,0 +1,37 @@
<script setup>
import { CloudArrowDownIcon } from '@heroicons/vue/24/outline';
import axios from 'axios';
const props = defineProps(['url']);
const requestFileDownload = () => {
axios.get(`${props.url}/request`)
.then((response) => {
downloadFromUrl(response.data.url);
}).catch((error) => {
console.log(error);
if (error.response && error.response.data) {
alert(`${error.message}: ${error.response.data.message}. Check developer console for more info.`);
}
});
};
const downloadFromUrl = (url) => {
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', '');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script>
<template>
<button @click="requestFileDownload">
<slot>
<CloudArrowDownIcon class="w-4 h-4 mr-2" />
Download
</slot>
</button>
</template>
@@ -0,0 +1,270 @@
<template>
<nav class="flex flex-col h-full py-5">
<div class="mx-3 md:mx-0 mb-1">
<div class="sm:flex sm:flex-col-reverse">
<h1 class="font-semibold text-brand-700 dark:text-brand-600 text-2xl flex items-center">
Log Viewer
<a href="https://www.github.com/opcodesio/log-viewer" target="_blank"
class="rounded ml-3 text-gray-400 hover:text-brand-800 dark:hover:text-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:focus:ring-brand-700 p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" title="">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
</svg>
</a>
<span class="md:hidden flex-1 flex justify-end">
<SiteSettingsDropdown class="ml-2" />
<button type="button" class="menu-button">
<XMarkIcon class="w-5 h-5 ml-2" @click="fileStore.toggleSidebar" />
</button>
</span>
</h1>
<div v-if="LogViewer.back_to_system_url">
<a :href="LogViewer.back_to_system_url"
class="rounded shrink inline-flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-brand-800 dark:hover:text-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:focus:ring-brand-700 mt-0">
<ArrowLeftIcon class="h-3 w-3 mr-1.5" />
{{ LogViewer.back_to_system_label || `Back to ${LogViewer.app_name}` }}
</a>
</div>
</div>
<div v-if="LogViewer.assets_outdated" class="bg-yellow-100 dark:bg-yellow-900 bg-opacity-75 dark:bg-opacity-40 border border-yellow-300 dark:border-yellow-800 rounded-md px-2 py-1 mt-2 text-xs leading-5 text-yellow-700 dark:text-yellow-400">
<ExclamationTriangleIcon class="h-4 w-4 mr-1 inline" />
Front-end assets are outdated. To update, please run <code class="font-mono px-2 py-1 bg-gray-100 dark:bg-gray-900 rounded">php artisan log-viewer:publish</code>
</div>
<template v-if="hostStore.supportsHosts && hostStore.hasRemoteHosts">
<host-selector class="mb-8 mt-6" />
</template>
<template v-if="fileStore.fileTypesAvailable && fileStore.fileTypesAvailable.length > 1">
<file-type-selector class="mb-8 mt-6" />
</template>
<div class="flex justify-between items-baseline mt-6" v-if="fileStore.filteredFolders?.length > 0">
<div class="ml-1 block text-sm text-gray-500 dark:text-gray-400 truncate">Log files on {{ fileStore.selectedHost?.name }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
<label for="file-sort-direction" class="sr-only">Sort direction</label>
<select id="file-sort-direction" class="select" v-model="fileStore.direction">
<option v-if="!LogViewer.files_sort_by_time" value="asc">From A to Z</option>
<option v-if="!LogViewer.files_sort_by_time" value="desc">From Z to A</option>
<option v-if="LogViewer.files_sort_by_time" value="desc">Newest first</option>
<option v-if="LogViewer.files_sort_by_time" value="asc">Oldest first</option>
</select>
</div>
</div>
<p v-if="fileStore.error" class="mx-1 mt-1 text-red-600 text-xs">
{{ fileStore.error }}
</p>
</div>
<div v-show="fileStore.checkBoxesVisibility">
<p class="text-sm text-gray-600 dark:text-gray-400">Please select files to delete and confirm or cancel deletion.</p>
<div class="grid grid-flow-col pr-4 mt-2"
:class="[fileStore.hasFilesChecked ? 'justify-between' : 'justify-end']"
>
<button v-show="fileStore.hasFilesChecked"
@click.stop="confirmDeleteSelectedFiles"
class="button inline-flex">
<TrashIcon class="w-5 mr-1" />
Delete selected files
</button>
<button class="button inline-flex" @click.stop="fileStore.resetChecks()">
Cancel
<XMarkIcon class="w-5 ml-1" />
</button>
</div>
</div>
<div id="file-list-container" class="relative h-full overflow-hidden">
<div class="file-list" @scroll="(event) => fileStore.onScroll(event)">
<div v-for="folder in fileStore.filteredFolders"
:key="folder.identifier"
:id="`folder-${folder.identifier}`"
class="relative folder-container"
>
<Menu v-slot="{ open }">
<div class="folder-item-container"
@click="fileStore.toggle(folder)"
:class="[fileStore.isOpen(folder) ? 'active-folder' : '', fileStore.shouldBeSticky(folder) ? 'sticky ' + (open ? 'z-20' : 'z-10') : '' ]"
>
<div class="file-item group">
<button class="file-item-info group" @keydown="handleKeyboardFileNavigation">
<span class="sr-only" v-if="!fileStore.isOpen(folder)">Open folder</span>
<span class="sr-only" v-if="fileStore.isOpen(folder)">Close folder</span>
<span class="file-icon group-hover:hidden group-focus:hidden">
<FolderIcon v-show="!fileStore.isOpen(folder)" class="w-5 h-5" />
<FolderOpenIcon v-show="fileStore.isOpen(folder)" class="w-5 h-5" />
</span>
<span class="file-icon hidden group-hover:inline-block group-focus:inline-block">
<ChevronRightIcon :class="[fileStore.isOpen(folder) ? 'rotate-90' : '', 'transition duration-100']" />
</span>
<span class="file-name">
<span v-if="String(folder.clean_path || '').startsWith(rootFolderPrefix)">
<span class="text-gray-500 dark:text-gray-400">{{ rootFolderPrefix }}</span>{{ String(folder.clean_path).substring(rootFolderPrefix.length) }}
</span>
<span v-else>{{ folder.clean_path }}</span>
</span>
</button>
<MenuButton as="button" class="file-dropdown-toggle group-hover:border-brand-600 group-hover:dark:border-brand-800"
:data-toggle-id="folder.identifier"
@keydown="handleKeyboardFileSettingsNavigation"
@click.stop="calculateDropdownDirection($event.target)">
<span class="sr-only">Open folder options</span>
<EllipsisVerticalIcon class="w-4 h-4 pointer-events-none" />
</MenuButton>
</div>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
>
<MenuItems static v-show="open" as="div" class="dropdown w-48" :class="[dropdownDirections[folder.identifier]]">
<div class="py-2">
<MenuItem @click.stop.prevent="fileStore.clearCacheForFolder(folder)" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<CircleStackIcon v-show="!fileStore.clearingCache[folder.identifier]" class="w-4 h-4 mr-2"/>
<SpinnerIcon v-show="fileStore.clearingCache[folder.identifier]" class="w-4 h-4 mr-2" />
<span v-show="!fileStore.cacheRecentlyCleared[folder.identifier] && !fileStore.clearingCache[folder.identifier]">Clear indices</span>
<span v-show="!fileStore.cacheRecentlyCleared[folder.identifier] && fileStore.clearingCache[folder.identifier]">Clearing...</span>
<span v-show="fileStore.cacheRecentlyCleared[folder.identifier]" class="text-brand-500">Indices cleared</span>
</button>
</MenuItem>
<MenuItem v-if="folder.can_download" v-slot="{ active }">
<DownloadLink :url="folder.download_url" @click.stop :class="[active ? 'active' : '']" />
</MenuItem>
<template v-if="folder.can_delete">
<div class="divider"></div>
<MenuItem v-slot="{ active }">
<button @click.stop="confirmDeleteFolder(folder)" :disabled="fileStore.deleting[folder.identifier]" :class="[active ? 'active' : '']">
<TrashIcon v-show="!fileStore.deleting[folder.identifier]" class="w-4 h-4 mr-2" />
<SpinnerIcon v-show="fileStore.deleting[folder.identifier]" />
Delete
</button>
</MenuItem>
</template>
</div>
</MenuItems>
</transition>
</div>
</Menu>
<div class="folder-files pl-3 ml-1 border-l border-gray-200 dark:border-gray-800"
v-show="fileStore.isOpen(folder)">
<file-list-item
v-for="logFile in (folder.files || [])"
:key="logFile.identifier"
:log-file="logFile"
@click="selectFile(logFile.identifier)"
/>
</div>
</div>
<div v-if="fileStore.folders.length === 0" class="text-center text-sm text-gray-600 dark:text-gray-400">
<p class="mb-5">No log files were found.</p>
<div class="flex items-center justify-center px-1">
<button @click.prevent="fileStore.loadFolders()"
class="inline-flex items-center px-4 py-2 text-left text-sm bg-white hover:bg-gray-50 outline-brand-500 dark:outline-brand-800 text-gray-900 dark:text-gray-200 rounded-md dark:bg-gray-700 dark:hover:bg-gray-600"
>
<ArrowPathIcon class="w-4 h-4 mr-1.5" />
Refresh file list
</button>
</div>
</div>
</div>
<!-- gradient to hide the bottom of the file list -->
<div class="pointer-events-none absolute z-10 bottom-0 h-4 w-full bg-gradient-to-t from-gray-100 dark:from-gray-900 to-transparent"></div>
<!-- loading state overlay -->
<div class="absolute inset-y-0 left-3 right-7 lg:left-0 lg:right-0 z-10" v-show="fileStore.loading">
<div class="rounded-md bg-white text-gray-800 dark:bg-gray-700 dark:text-gray-200 opacity-90 w-full h-full flex items-center justify-center">
<SpinnerIcon class="w-14 h-14" />
</div>
</div>
</div>
</nav>
</template>
<script setup>
import { onMounted, watch } from 'vue';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import {
ArrowLeftIcon,
ArrowPathIcon,
CircleStackIcon,
EllipsisVerticalIcon,
ExclamationTriangleIcon,
FolderIcon,
FolderOpenIcon,
TrashIcon,
XMarkIcon,
ChevronRightIcon,
} from '@heroicons/vue/24/outline';
import { useHostStore } from '../stores/hosts.js';
import { useFileStore } from '../stores/files.js';
import { useRoute, useRouter } from 'vue-router';
import { replaceQuery, useDropdownDirection } from '../helpers.js';
import FileListItem from './FileListItem.vue';
import SpinnerIcon from './SpinnerIcon.vue';
import SiteSettingsDropdown from './SiteSettingsDropdown.vue';
import HostSelector from './HostSelector.vue';
import { handleKeyboardFileNavigation, handleKeyboardFileSettingsNavigation } from '../keyboardNavigation';
import FileTypeSelector from './FileTypeSelector.vue';
import DownloadLink from "./DownloadLink.vue";
const router = useRouter();
const route = useRoute();
const hostStore = useHostStore();
const fileStore = useFileStore();
const { dropdownDirections, calculateDropdownDirection } = useDropdownDirection();
const confirmDeleteFolder = async (folder) => {
if (confirm(`Are you sure you want to delete the log folder '${folder.path}'? THIS ACTION CANNOT BE UNDONE.`)) {
await fileStore.deleteFolder(folder);
if (folder.files.some(file => file.identifier === fileStore.selectedFileIdentifier)) {
replaceQuery(router, 'file', null);
}
}
}
const confirmDeleteSelectedFiles = async () => {
if (confirm('Are you sure you want to delete selected log files? THIS ACTION CANNOT BE UNDONE.')) {
await fileStore.deleteSelectedFiles();
if (fileStore.filesChecked.includes(fileStore.selectedFileIdentifier)) {
replaceQuery(router, 'file', null);
}
fileStore.resetChecks();
await fileStore.loadFolders();
}
}
const selectFile = (fileIdentifier) => {
if (route.query.file && route.query.file === fileIdentifier) {
replaceQuery(router, 'file', null);
} else {
replaceQuery(router, 'file', fileIdentifier);
}
};
const rootFolderPrefix = window.LogViewer?.root_folder_prefix || 'root';
onMounted(async () => {
hostStore.selectHost(route.query.host || null);
});
watch(
() => fileStore.direction,
() => fileStore.loadFolders()
);
</script>
@@ -0,0 +1,126 @@
<template>
<div class="file-item-container" :class="[isSelected ? 'active' : '']">
<Menu>
<div class="file-item group">
<button class="file-item-info" @keydown="handleKeyboardFileNavigation">
<span class="sr-only" v-if="!isSelected">Select log file</span>
<span class="sr-only" v-if="isSelected">Deselect log file</span>
<span v-if="logFile.can_delete" class="my-auto mr-2" v-show="fileStore.checkBoxesVisibility">
<input type="checkbox"
@click.stop="toggleCheckbox"
:checked="fileStore.isChecked(logFile)"
:value="fileStore.isChecked(logFile)"
/>
</span>
<span class="file-name"><span class="sr-only">Name:</span>{{ logFile.name }}</span>
<span class="file-size"><span class="sr-only">Size:</span>{{ logFile.size_formatted }}</span>
</button>
<MenuButton as="button" class="file-dropdown-toggle group-hover:border-brand-600 group-hover:dark:border-brand-800"
:data-toggle-id="logFile.identifier"
@keydown="handleKeyboardFileSettingsNavigation"
@click.stop="calculateDropdownDirection($event.target)">
<EllipsisVerticalIcon class="w-4 h-4 pointer-events-none" />
</MenuButton>
</div>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
>
<MenuItems as="div" class="dropdown w-48" :class="[dropdownDirections[logFile.identifier]]">
<div class="py-2">
<MenuItem @click.stop.prevent="fileStore.clearCacheForFile(logFile)" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<CircleStackIcon v-show="!fileStore.clearingCache[logFile.identifier]" class="h-4 w-4 mr-2" />
<SpinnerIcon v-show="fileStore.clearingCache[logFile.identifier]" />
<span v-show="!fileStore.cacheRecentlyCleared[logFile.identifier] && !fileStore.clearingCache[logFile.identifier]">Clear index</span>
<span v-show="!fileStore.cacheRecentlyCleared[logFile.identifier] && fileStore.clearingCache[logFile.identifier]">Clearing...</span>
<span v-show="fileStore.cacheRecentlyCleared[logFile.identifier]" class="text-brand-500">Index cleared</span>
</button>
</MenuItem>
<MenuItem v-if="logFile.can_download" @click.stop v-slot="{ active }">
<DownloadLink :url="logFile.download_url" :class="[active ? 'active' : '']" />
</MenuItem>
<template v-if="logFile.can_delete">
<div class="divider"></div>
<MenuItem @click.stop.prevent="confirmDeletion" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<TrashIcon class="w-4 h-4 mr-2" />
Delete
</button>
</MenuItem>
<MenuItem @click.stop="deleteMultiple" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<TrashIcon class="w-4 h-4 mr-2" />
Delete Multiple
</button>
</MenuItem>
</template>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import { CircleStackIcon, EllipsisVerticalIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { useFileStore } from '../stores/files.js';
import SpinnerIcon from './SpinnerIcon.vue';
import { replaceQuery, useDropdownDirection } from '../helpers.js';
import { useRouter } from 'vue-router';
import { handleKeyboardFileNavigation, handleKeyboardFileSettingsNavigation } from '../keyboardNavigation';
import DownloadLink from "./DownloadLink.vue";
const props = defineProps({
logFile: {
type: Object,
required: true,
},
showSelectToggle: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['selectForDeletion']);
const fileStore = useFileStore();
const router = useRouter();
const { dropdownDirections, calculateDropdownDirection } = useDropdownDirection();
// data
const isSelected = computed(() => {
return fileStore.selectedFile && fileStore.selectedFile.identifier === props.logFile.identifier;
})
const confirmDeletion = async () => {
if (confirm(`Are you sure you want to delete the log file '${props.logFile.name}'? THIS ACTION CANNOT BE UNDONE.`)) {
await fileStore.deleteFile(props.logFile);
if (props.logFile.identifier === fileStore.selectedFileIdentifier) {
replaceQuery(router, 'file', null);
}
await fileStore.loadFolders();
}
}
const toggleCheckbox = () => {
fileStore.checkBoxToggle(props.logFile.identifier);
}
const deleteMultiple = () => {
fileStore.toggleCheckboxVisibility();
toggleCheckbox();
}
</script>
@@ -0,0 +1,38 @@
<template>
<Listbox as="div" v-model="fileStore.selectedFileTypes" multiple>
<ListboxLabel class="ml-1 block text-sm text-gray-500 dark:text-gray-400">Selected file types</ListboxLabel>
<div class="relative mt-1">
<ListboxButton id="hosts-toggle-button" class="cursor-pointer relative text-gray-800 dark:text-gray-200 w-full cursor-default rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 py-2 pl-4 pr-10 text-left hover:border-brand-600 hover:dark:border-brand-800 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 text-sm">
<span class="block truncate">{{ fileStore.selectedFileTypesString }}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<ListboxOptions class="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md shadow-md bg-white dark:bg-gray-800 py-1 border border-gray-200 dark:border-gray-700 ring-1 ring-brand ring-opacity-5 focus:outline-none text-sm">
<ListboxOption as="template" v-for="fileType in fileStore.fileTypesAvailable" :key="fileType.identifier" :value="fileType.identifier" v-slot="{ active, selected }">
<li :class="[active ? 'text-white bg-brand-600' : 'text-gray-900 dark:text-gray-300', 'relative cursor-default select-none py-2 pl-3 pr-9']">
<span :class="[selected ? 'font-semibold' : 'font-normal', 'block truncate']">{{ fileType.name }}</span>
<span v-if="selected" :class="[active ? 'text-white' : 'text-brand-600', 'absolute inset-y-0 right-0 flex items-center pr-4']">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup>
import { Listbox, ListboxButton, ListboxLabel, ListboxOption, ListboxOptions } from '@headlessui/vue'
import { CheckIcon, ChevronDownIcon } from '@heroicons/vue/20/solid'
import { useRouter } from 'vue-router';
import { useFileStore } from '../stores/files.js';
const router = useRouter();
const fileStore = useFileStore();
</script>
@@ -0,0 +1,47 @@
<template>
<Listbox as="div" v-model="hostStore.selectedHostIdentifier">
<ListboxLabel class="ml-1 block text-sm text-gray-500 dark:text-gray-400">Select host</ListboxLabel>
<div class="relative mt-1">
<ListboxButton id="hosts-toggle-button" class="cursor-pointer relative text-gray-800 dark:text-gray-200 w-full cursor-default rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 py-2 pl-4 pr-10 text-left hover:border-brand-600 hover:dark:border-brand-800 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 text-sm">
<span class="block truncate">{{ hostStore.selectedHost?.name || 'Please select a server' }}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<ListboxOptions class="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md shadow-md bg-white dark:bg-gray-800 py-1 border border-gray-200 dark:border-gray-700 ring-1 ring-brand ring-opacity-5 focus:outline-none text-sm">
<ListboxOption as="template" v-for="host in hostStore.hosts" :key="host.identifier" :value="host.identifier" v-slot="{ active, selected }">
<li :class="[active ? 'text-white bg-brand-600' : 'text-gray-900 dark:text-gray-300', 'relative cursor-default select-none py-2 pl-3 pr-9']">
<span :class="[selected ? 'font-semibold' : 'font-normal', 'block truncate']">{{ host.name }}</span>
<span v-if="selected" :class="[active ? 'text-white' : 'text-brand-600', 'absolute inset-y-0 right-0 flex items-center pr-4']">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup>
import { watch } from 'vue'
import { Listbox, ListboxButton, ListboxLabel, ListboxOption, ListboxOptions } from '@headlessui/vue'
import { CheckIcon, ChevronDownIcon } from '@heroicons/vue/20/solid'
import { useHostStore } from '../stores/hosts.js';
import { useRouter } from 'vue-router';
import { replaceQuery } from '../helpers.js';
const router = useRouter();
const hostStore = useHostStore();
watch(
() => hostStore.selectedHost,
(value) => {
replaceQuery(router, 'host', value?.is_remote ? value.identifier : null);
}
);
</script>
@@ -0,0 +1,98 @@
<template>
<TransitionRoot as="template" :show="logViewerStore.helpSlideOverOpen">
<Dialog as="div" class="relative z-20" @close="logViewerStore.helpSlideOverOpen = false">
<div class="fixed inset-0" />
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<TransitionChild
as="template"
enter="transform transition ease-in-out duration-200 sm:duration-300"
enter-from="translate-x-full"
enter-to="translate-x-0"
leave="transform transition ease-in-out duration-200 sm:duration-300"
leave-from="translate-x-0"
leave-to="translate-x-full"
>
<DialogPanel class="pointer-events-auto w-screen max-w-md">
<div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl dark:bg-gray-700">
<div class="px-4 sm:px-6">
<div class="flex items-start justify-between">
<DialogTitle class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">Keyboard Shortcuts</DialogTitle>
<div class="ml-3 flex h-7 items-center">
<button type="button" class="rounded-md bg-white dark:bg-gray-700 text-gray-400 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:focus:ring-brand-300 focus:ring-offset-2" @click="logViewerStore.helpSlideOverOpen = false">
<span class="sr-only">Close panel</span>
<XMarkIcon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div class="relative mt-6 flex-1 px-4 sm:px-6">
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Hosts }}</span>
<span class="description">Select a host</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Files }}</span>
<span class="description">Jump to file selection</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Logs }}</span>
<span class="description">Jump to logs</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.NextLog }}</span>
<span class="description">Open next log</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.PreviousLog }}</span>
<span class="description">Open previous log</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Next }}</span>
<span class="description">Next (file or log)</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Previous }}</span>
<span class="description">Previous (file or log)</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Severity }}</span>
<span class="description">Severity selection</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Settings }}</span>
<span class="description">Settings</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Search }}</span>
<span class="description">Search</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Refresh }}</span>
<span class="description">Refresh logs</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.ShortcutHelp }}</span>
<span class="description">Keyboard shortcuts help</span>
</div>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
import { XMarkIcon } from '@heroicons/vue/24/outline'
import { useLogViewerStore } from '../stores/logViewer.js';
import { KeyShortcuts } from '../keyboardNavigation/shared.js';
const logViewerStore = useLogViewerStore();
</script>
@@ -0,0 +1,113 @@
<template>
<div class="p-4 lg:p-8">
<!-- Exception Header -->
<div v-if="stackTrace.header" class="mb-6 pb-4 border-b border-gray-200 dark:border-gray-600">
<div class="text-red-600 dark:text-red-400 font-semibold text-lg mb-2">
{{ stackTrace.header.type }}
</div>
<div class="text-gray-800 dark:text-gray-200 text-base mb-2">
{{ stackTrace.header.message }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
in {{ stackTrace.header.file }}:{{ stackTrace.header.line }}
</div>
</div>
<!-- Stack Trace Frames -->
<div class="space-y-2">
<div v-for="(frame, frameIndex) in stackTrace.frames" :key="frameIndex"
class="mb-2 border-b border-gray-100 dark:border-gray-700 pb-2 last:border-b-0">
<div class="flex items-start gap-2">
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono w-8 flex-shrink-0 pt-1">
#{{ frame.number }}
</div>
<div class="flex-1 min-w-0">
<div v-if="frame.file" class="text-xs mb-1">
<span class="font-mono text-blue-600 dark:text-blue-400 break-all">{{ frame.file }}</span>
<span class="text-gray-500 dark:text-gray-400 mx-0.5">:</span>
<span class="font-mono text-orange-600 dark:text-orange-400">{{ frame.line }}</span>
</div>
<div class="text-xs text-gray-800 dark:text-gray-200 font-mono break-all">
{{ frame.call }}
</div>
</div>
</div>
</div>
</div>
<!-- Error Fallback -->
<div v-if="!stackTrace.header && stackTrace.frames.length === 0" class="text-gray-500 dark:text-gray-400 text-sm italic">
Unable to parse stack trace. View the Raw tab for full details.
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
log: {
type: Object,
required: true
}
});
/**
* Parses the Laravel exception stack trace from the log context.
* This computed property ensures the expensive parsing operation only happens once per log.
*/
const stackTrace = computed(() => {
try {
const exception = Array.isArray(props.log.context)
? props.log.context.find(item => item.exception)?.exception
: props.log.context?.exception;
if (!exception || typeof exception !== 'string') {
return { header: null, frames: [] };
}
// Parse exception header
// Format: [object] (ExceptionType(code: 0): Message at /path/file.php:123)
const headerMatch = exception.match(/^\[object\]\s*\(([^(]+)\(code:\s*\d+\):\s*(.+?)\s+at\s+(.+?):(\d+)\)/);
const header = headerMatch ? {
type: headerMatch[1].trim(),
message: headerMatch[2].trim(),
file: headerMatch[3].trim(),
line: parseInt(headerMatch[4])
} : null;
// Parse stack trace frames
// Format: #0 /path/file.php(123): Class::method()
const stacktraceMatch = exception.match(/\[stacktrace\]([\s\S]*?)(?:\n\n|\n$|$)/);
const frames = [];
if (stacktraceMatch) {
const frameRegex = /#(\d+)\s+(.+?)(?:\n|$)/g;
let match;
while ((match = frameRegex.exec(stacktraceMatch[1])) !== null) {
const frameLine = match[2].trim();
const fileMatch = frameLine.match(/^(.+?)\((\d+)\):\s*(.+)$/);
frames.push(fileMatch ? {
number: parseInt(match[1]),
file: fileMatch[1],
line: parseInt(fileMatch[2]),
call: fileMatch[3]
} : {
number: parseInt(match[1]),
file: '',
line: 0,
call: frameLine
});
}
}
return { header, frames };
} catch (error) {
// Gracefully handle parsing errors
console.error('Error parsing stack trace:', error);
return { header: null, frames: [] };
}
});
</script>
@@ -0,0 +1,86 @@
<template>
<div class="flex items-center">
<Menu as="div" class="mr-5 relative log-levels-selector">
<MenuButton as="button" id="severity-dropdown-toggle" class="dropdown-toggle badge none" :class="severityStore.levelsSelected.length > 0 ? 'active' : ''">
<template v-if="severityStore.levelsSelected.length > 2">
<span class="opacity-90 mr-1">{{ severityStore.totalResultsSelected.toLocaleString() + (logViewerStore.hasMoreResults ? '+' : '') }} entries in</span>
<strong class="font-semibold">{{ severityStore.levelsSelected[0].level_name }} + {{ severityStore.levelsSelected.length - 1 }} more</strong>
</template>
<template v-else-if="severityStore.levelsSelected.length > 0">
<span class="opacity-90 mr-1">{{ severityStore.totalResultsSelected.toLocaleString() + (logViewerStore.hasMoreResults ? '+' : '') }} entries in</span>
<strong class="font-semibold">{{ severityStore.levelsSelected.map(levelCount => levelCount.level_name).join(', ') }}</strong>
</template>
<span v-else-if="severityStore.levelsFound.length > 0" class="opacity-90">{{ severityStore.totalResults.toLocaleString() + (logViewerStore.hasMoreResults ? '+' : '') }} entries found. None selected</span>
<span v-else class="opacity-90">No entries found</span>
<ChevronDownIcon class="w-4 h-4" />
</MenuButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
>
<MenuItems as="div" class="dropdown down left min-w-[240px]">
<div class="py-2">
<div class="label flex justify-between">
Severity
<template v-if="severityStore.levelsFound.length > 0">
<MenuItem v-if="severityStore.levelsSelected.length === severityStore.levelsFound.length" @click.stop="severityStore.deselectAllLevels" v-slot="{ active }">
<a class="inline-link px-2 -mr-2 py-1 -my-1 rounded-md cursor-pointer text-brand-700 dark:text-brand-500 font-normal" :class="[active ? 'active' : '']">
Deselect all
</a>
</MenuItem>
<MenuItem v-else @click.stop="severityStore.selectAllLevels" v-slot="{ active }">
<a class="inline-link px-2 -mr-2 py-1 -my-1 rounded-md cursor-pointer text-brand-700 dark:text-brand-500 font-normal" :class="[active ? 'active' : '']">
Select all
</a>
</MenuItem>
</template>
</div>
<template v-if="severityStore.levelsFound.length === 0">
<div class="no-results">There are no severity filters to display because no entries have been found.</div>
</template>
<template v-else>
<MenuItem v-for="levelCount in severityStore.levelsFound"
@click.stop.prevent="severityStore.toggleLevel(levelCount.level)"
v-slot="{ active }"
>
<button :class="[active ? 'active' : '']">
<Checkmark class="checkmark mr-2.5" :checked="levelCount.selected" />
<span class="flex-1 inline-flex justify-between">
<span :class="['log-level', levelCount.level_class]">{{ levelCount.level_name }}</span>
<span class="log-count">{{ Number(levelCount.count).toLocaleString() }}</span>
</span>
</button>
</MenuItem>
</template>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup>
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import { ChevronDownIcon } from '@heroicons/vue/24/outline';
import Checkmark from './Checkmark.vue';
import { useLogViewerStore } from '../stores/logViewer.js';
import { useSeverityStore } from '../stores/severity.js';
import { watch } from 'vue';
const logViewerStore = useLogViewerStore();
const severityStore = useSeverityStore();
watch(
() => severityStore.excludedLevels,
() => logViewerStore.loadLogs()
);
</script>
@@ -0,0 +1,36 @@
<template>
<button class="log-link group"
@click.stop="copy"
@keydown="handleLogLinkKeyboardNavigation"
title="Copy link to this log entry"
>
<span class="sr-only">Log index {{ log.index }}. Click the button to copy link to this log entry.</span>
<span v-show="!copied" class="hidden md:inline group-hover:underline">{{ Number(log.index).toLocaleString() }}</span>
<LinkIcon v-show="!copied" class="md:opacity-75 group-hover:opacity-100" />
<HandThumbUpIcon v-show="copied" class="text-green-600 dark:text-green-500 md:hidden" />
<span v-show="copied" class="text-green-600 dark:text-green-500 hidden md:inline">Copied!</span>
</button>
</template>
<script setup>
import { ref } from 'vue';
import { copyToClipboard } from '../helpers.js';
import { LinkIcon } from '@heroicons/vue/24/outline';
import { HandThumbUpIcon } from '@heroicons/vue/24/solid';
import { handleLogLinkKeyboardNavigation } from '../keyboardNavigation';
const props = defineProps({
log: {
type: Object,
required: true,
},
})
const copied = ref(false);
const copy = () => {
copyToClipboard(props.log.url);
copied.value = true;
setTimeout(() => copied.value = false, 1000);
}
</script>
@@ -0,0 +1,115 @@
<template>
<div class="h-full w-full py-5 log-list">
<div class="flex flex-col h-full w-full md:mx-3 mb-4">
<div class="md:px-4 mb-4 flex flex-col-reverse lg:flex-row items-start">
<div class="flex items-center mr-5 mt-3 md:mt-0" v-if="showLevelsDropdown">
<LevelButtons />
</div>
<div class="w-full lg:w-auto flex-1 flex justify-end min-h-[38px]">
<SearchInput />
<div class="hidden md:block ml-5">
<button @click="logViewerStore.loadLogs()" id="reload-logs-button" title="Reload current results" class="menu-button">
<ArrowPathIcon class="w-5 h-5" />
</button>
</div>
<div class="hidden md:block">
<SiteSettingsDropdown class="ml-2" id="desktop-site-settings" />
</div>
<div class="md:hidden">
<button type="button" class="menu-button">
<Bars3Icon class="w-5 h-5 ml-2" @click="fileStore.toggleSidebar" />
</button>
</div>
</div>
</div>
<div v-if="!inlinePaginationSettingsIntoTableHeader" class="flex justify-end md:px-4 my-1 mx-2">
<pagination-options />
</div>
<div v-if="displayLogs" class="relative overflow-hidden h-full text-sm">
<!-- pagination settings -->
<pagination-options
v-if="inlinePaginationSettingsIntoTableHeader"
class="mx-2 mt-1 mb-2 text-right lg:mx-0 lg:mt-0 lg:mb-0 lg:absolute lg:top-2 lg:right-6 z-20"
/>
<div class="log-item-container h-full overflow-y-auto md:px-4" @scroll="(event) => logViewerStore.onScroll(event)">
<div class="inline-block min-w-full max-w-full align-middle">
<base-log-table />
</div>
</div>
<!-- loading state for logs -->
<div class="absolute inset-0 top-9 md:px-4 z-20" v-show="logViewerStore.loading && (!logViewerStore.isMobile || !fileStore.sidebarOpen)">
<div
class="rounded-md bg-white text-gray-800 dark:bg-gray-700 dark:text-gray-200 opacity-90 w-full h-full flex items-center justify-center">
<SpinnerIcon class="w-14 h-14" />
</div>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-gray-600 dark:text-gray-400">
<span v-if="logViewerStore.hasMoreResults">Searching...</span>
<span v-else>Select a file or start searching...</span>
</div>
<div v-if="displayLogs && paginationStore.hasPages" class="md:px-4">
<div class="hidden lg:block">
<Pagination :loading="logViewerStore.loading" />
</div>
<div class="lg:hidden">
<Pagination :loading="logViewerStore.loading" :short="true" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import {computed, ref, watch} from 'vue';
import { useRouter } from 'vue-router';
import { ArrowPathIcon, Bars3Icon } from '@heroicons/vue/24/solid';
import { useLogViewerStore } from '../stores/logViewer.js';
import { useSearchStore } from '../stores/search.js';
import { useFileStore } from '../stores/files.js';
import { usePaginationStore } from '../stores/pagination.js';
import Pagination from './Pagination.vue';
import LevelButtons from './LevelButtons.vue';
import SearchInput from './SearchInput.vue';
import SiteSettingsDropdown from './SiteSettingsDropdown.vue';
import SpinnerIcon from './SpinnerIcon.vue';
import BaseLogTable from './BaseLogTable.vue';
import PaginationOptions from './PaginationOptions.vue';
const router = useRouter();
const fileStore = useFileStore();
const logViewerStore = useLogViewerStore();
const searchStore = useSearchStore();
const paginationStore = usePaginationStore();
const showLevelsDropdown = computed(() => {
return fileStore.selectedFile || String(searchStore.query || '').trim().length > 0;
});
const displayLogs = computed(() => {
return logViewerStore.logs && (logViewerStore.logs.length > 0 || !logViewerStore.hasMoreResults) && (logViewerStore.selectedFile || searchStore.hasQuery);
});
watch(
[
() => logViewerStore.direction,
() => logViewerStore.resultsPerPage,
],
() => logViewerStore.loadLogs()
)
const inlinePaginationSettingsIntoTableHeader = ref(true);
watch(() => logViewerStore.columns, () => {
// only if the last column is the message column, which is usually a wide column
// and leaves space for the pagination settings to be displayed in the table's header.
inlinePaginationSettingsIntoTableHeader.value =
logViewerStore.columns[logViewerStore.columns.length - 1].data_path === 'message';
});
</script>
@@ -0,0 +1,34 @@
<template>
<div class="mail-preview">
<!-- headers -->
<mail-preview-attributes :mail="mail"/>
<!-- HTML preview -->
<iframe
v-if="mail.html"
class="mail-preview-html"
:style="{height: `${iframeHeight}px`}"
:srcdoc="mail.html"
@load="setIframeHeight"
ref="iframe"
></iframe>
</div>
</template>
<script setup>
import {ref} from "vue";
import MailPreviewAttributes from "./MailPreviewAttributes.vue";
defineProps({
mail: {
type: Object,
},
})
const iframe = ref(null);
const iframeHeight = ref(600);
const setIframeHeight = () => {
iframeHeight.value = (iframe.value?.contentWindow?.document?.body?.clientHeight || 580) + 20;
}
</script>
@@ -0,0 +1,69 @@
<template>
<div class="mail-preview-attributes">
<table>
<tr v-if="mail.from">
<td class="font-semibold">From</td>
<td>{{ mail.from }}</td>
</tr>
<tr v-if="mail.to">
<td class="font-semibold">To</td>
<td>{{ mail.to }}</td>
</tr>
<tr v-if="mail.id">
<td class="font-semibold">Message ID</td>
<td>{{ mail.id }}</td>
</tr>
<tr v-if="mail.subject">
<td class="font-semibold">Subject</td>
<td>{{ mail.subject }}</td>
</tr>
<tr v-if="mail.attachments && mail.attachments.length > 0">
<td class="font-semibold">Attachments</td>
<td>
<div v-for="(attachment, index) in mail.attachments" :key="`mail-${mail.id}-attachment-${index}`"
class="mail-attachment-button"
>
<div class="flex items-center">
<PaperClipIcon class="h-4 w-4 text-gray-500 dark:text-gray-400 mr-1"/>
<span>{{ attachment.filename }} <span class="opacity-60">({{ attachment.size_formatted }})</span></span>
</div>
<div>
<a href="#" @click.prevent="downloadAttachment(attachment)"
class="text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>Download</a>
</div>
</div>
</td>
</tr>
</table>
</div>
</template>
<script setup>
import { PaperClipIcon } from '@heroicons/vue/24/outline';
defineProps(['mail']);
const downloadAttachment = (attachment) => {
// Decode the base64 encoded string
const decodedContent = atob(attachment.content);
// Convert decoded base64 string to a Uint8Array
const byteNumbers = new Array(decodedContent.length);
for (let i = 0; i < decodedContent.length; i++) {
byteNumbers[i] = decodedContent.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: attachment.content_type || 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
downloadLink.download = attachment.filename;
downloadLink.click();
// Clean up the temporary URL after the download
URL.revokeObjectURL(blobUrl);
}
</script>
@@ -0,0 +1,31 @@
<template>
<div class="mail-preview">
<!-- headers -->
<mail-preview-attributes :mail="mail"/>
<!-- Text preview -->
<pre
v-if="mail.text"
class="mail-preview-text"
v-text="mail.text"
></pre>
</div>
</template>
<script setup>
import {ref} from "vue";
import MailPreviewAttributes from "./MailPreviewAttributes.vue";
defineProps({
mail: {
type: Object,
},
})
const iframe = ref(null);
const iframeHeight = ref(600);
const setIframeHeight = () => {
iframeHeight.value = (iframe.value?.contentWindow?.document?.body?.clientHeight || 580) + 20;
}
</script>
@@ -0,0 +1,82 @@
<template>
<nav class="pagination">
<div class="previous">
<button v-if="paginationStore.page !== 1" @click="previousPage" :disabled="loading" rel="prev">
<ArrowLeftIcon class="h-5 w-5" />
<span class="sm:hidden">Previous page</span>
</button>
</div>
<div class="sm:hidden border-transparent text-gray-500 dark:text-gray-400 border-t-2 pt-3 px-4 inline-flex items-center text-sm font-medium">
<span>{{ paginationStore.page }}</span>
</div>
<div class="pages">
<template v-for="link in (short ? paginationStore.linksShort : paginationStore.links)">
<button v-if="link.active" class="border-brand-500 text-brand-600 dark:border-brand-600 dark:text-brand-500"
aria-current="page">
{{ Number(link.label).toLocaleString() }}
</button>
<span v-else-if="link.label === '...'">{{ link.label }}</span>
<button v-else @click="gotoPage(Number(link.label))"
class="border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 hover:border-gray-300 dark:hover:text-gray-300 dark:hover:border-gray-400">
{{ Number(link.label).toLocaleString() }}
</button>
</template>
</div>
<div class="next">
<button v-if="paginationStore.hasMorePages" @click="nextPage" :disabled="loading" rel="next">
<span class="sm:hidden">Next page</span>
<ArrowRightIcon class="h-5 w-5" />
</button>
</div>
</nav>
</template>
<script setup>
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/vue/24/outline';
import { usePaginationStore } from '../stores/pagination.js';
import { useRoute, useRouter } from 'vue-router';
import {computed, onBeforeUnmount, onMounted} from 'vue';
import { replaceQuery } from '../helpers.js';
const props = defineProps({
loading: {
type: Boolean,
required: true,
},
short: {
type: Boolean,
default: false,
}
})
const paginationStore = usePaginationStore();
const router = useRouter();
const route = useRoute();
const currentPage = computed(() => Number(route.query.page) || 1);
const gotoPage = (page) => {
if (page < 1) {
page = 1;
}
if (paginationStore.pagination && page > paginationStore.pagination.last_page) {
page = paginationStore.pagination.last_page;
}
replaceQuery(router, 'page', page > 1 ? Number(page) : null);
}
const nextPage = () => gotoPage(paginationStore.page + 1);
const previousPage = () => gotoPage(paginationStore.page - 1);
onMounted(() => {
document.addEventListener('goToNextPage', nextPage);
document.addEventListener('goToPreviousPage', previousPage);
})
onBeforeUnmount(() => {
document.removeEventListener('goToNextPage', nextPage);
document.removeEventListener('goToPreviousPage', previousPage);
})
</script>
@@ -0,0 +1,19 @@
<script setup>
import {useLogViewerStore} from "../stores/logViewer";
const logViewerStore = useLogViewerStore();
</script>
<template>
<div class="text-sm text-gray-500 dark:text-gray-400">
<label for="log-sort-direction" class="sr-only">Sort direction</label>
<select id="log-sort-direction" v-model="logViewerStore.direction" class="select mr-4">
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
<label for="items-per-page" class="sr-only">Items per page</label>
<select id="items-per-page" v-model="logViewerStore.resultsPerPage" class="select">
<option v-for="option in logViewerStore.perPageOptions" :key="option" :value="option">{{ option }} items per page</option>
</select>
</div>
</template>
@@ -0,0 +1,68 @@
<template>
<div class="flex-1">
<div class="search" :class="{'has-error': logViewerStore.error}">
<div class="prefix-icon">
<label for="query" class="sr-only">Search</label>
<MagnifyingGlassIcon v-show="!logViewerStore.hasMoreResults" class="h-4 w-4" />
<SpinnerIcon v-show="logViewerStore.hasMoreResults" class="w-4 h-4" />
</div>
<div class="relative flex-1 m-1">
<input v-model="tempQuery" name="query" id="query" type="text"
@keydown.enter="submitQuery"
@keydown.esc="(event) => event.target.blur()"
/>
<div v-show="searchStore.hasQuery" class="clear-search">
<button @click="clearQuery">
<XMarkIcon class="h-4 w-4" />
</button>
</div>
</div>
<div class="submit-search">
<button v-if="logViewerStore.hasMoreResults" disabled="disabled">
<span>Searching<span class="hidden xl:inline ml-1"> {{ selectedFile ? selectedFile.name : 'all files' }}</span>...</span>
</button>
<button v-else @click="submitQuery" id="query-submit">
<span>Search<span class="hidden xl:inline ml-1"> {{ selectedFile ? 'in "' + selectedFile.name + '"' : 'all files' }}</span></span>
<ArrowRightIcon class="h-4 w-4" />
</button>
</div>
</div>
<div class="relative h-0 w-full overflow-visible">
<div class="search-progress-bar" v-show="logViewerStore.hasMoreResults"
:style="{ width: logViewerStore.percentScanned + '%' }"></div>
</div>
<p class="mt-1 text-red-600 text-xs" v-show="logViewerStore.error" v-html="logViewerStore.error"></p>
</div>
</template>
<script setup>
import { useSearchStore } from '../stores/search.js';
import { ArrowRightIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { useLogViewerStore } from '../stores/logViewer.js';
import { computed, ref, watch } from 'vue';
import SpinnerIcon from './SpinnerIcon.vue';
import { replaceQuery } from '../helpers.js';
import { useRoute, useRouter } from 'vue-router';
const searchStore = useSearchStore();
const logViewerStore = useLogViewerStore();
const router = useRouter();
const route = useRoute();
const selectedFile = computed(() => logViewerStore.selectedFile);
const tempQuery = ref(route.query.query || '');
const submitQuery = () => {
replaceQuery(router, 'query', tempQuery.value === '' ? null : tempQuery.value);
document.getElementById('query-submit')?.focus();
}
const clearQuery = () => {
tempQuery.value = '';
submitQuery();
}
watch(
() => route.query.query,
(query) => tempQuery.value = query || '',
)
</script>
@@ -0,0 +1,132 @@
<template>
<Menu as="div" class="relative">
<MenuButton as="button" class="menu-button">
<span class="sr-only">Settings dropdown</span>
<Cog8ToothIcon class="w-5 h-5" />
</MenuButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
>
<MenuItems as="div" style="min-width: 250px;" class="dropdown">
<div class="py-2">
<div class="label">Settings</div>
<MenuItem v-slot="{ active }">
<button :class="[active ? 'active' : '']" @click.stop.prevent="logViewerStore.shorterStackTraces = !logViewerStore.shorterStackTraces">
<Checkmark :checked="logViewerStore.shorterStackTraces" />
<span class="ml-3">Shorter stack traces</span>
</button>
</MenuItem>
<div class="divider"></div>
<div class="label">Actions</div>
<MenuItem @click.stop.prevent="fileStore.clearCacheForAllFiles" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<CircleStackIcon v-show="!fileStore.clearingCache['*']" class="w-4 h-4 mr-1.5" />
<SpinnerIcon v-show="fileStore.clearingCache['*']" class="w-4 h-4 mr-1.5" />
<span v-show="!fileStore.cacheRecentlyCleared['*'] && !fileStore.clearingCache['*']">Clear indices for all files</span>
<span v-show="!fileStore.cacheRecentlyCleared['*'] && fileStore.clearingCache['*']">Please wait...</span>
<span v-show="fileStore.cacheRecentlyCleared['*']" class="text-brand-500">File indices cleared</span>
</button>
</MenuItem>
<MenuItem @click.stop.prevent="copyUrlToClipboard" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<ShareIcon class="w-4 h-4" />
<span v-show="!copied">Share this page</span>
<span v-show="copied" class="text-brand-500">Link copied!</span>
</button>
</MenuItem>
<div class="divider"></div>
<MenuItem @click.stop.prevent="logViewerStore.toggleTheme()" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<ComputerDesktopIcon v-show="logViewerStore.theme === Theme.System" class="w-4 h-4" />
<SunIcon v-show="logViewerStore.theme === Theme.Light" class="w-4 h-4" />
<MoonIcon v-show="logViewerStore.theme === Theme.Dark" class="w-4 h-4" />
<span>Theme: <span v-html="logViewerStore.theme" class="font-semibold"></span></span>
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button @click="logViewerStore.helpSlideOverOpen = true" :class="[active ? 'active' : '']">
<QuestionMarkCircleIcon class="w-4 h-4" />
Keyboard Shortcuts
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<a href="https://log-viewer.opcodes.io/docs" target="_blank" :class="[active ? 'active' : '']">
<QuestionMarkCircleIcon class="w-4 h-4" />
Documentation
</a>
</MenuItem>
<MenuItem v-slot="{ active }">
<a href="https://www.github.com/opcodesio/log-viewer" target="_blank" :class="[active ? 'active' : '']">
<QuestionMarkCircleIcon class="w-4 h-4" />
Help
</a>
</MenuItem>
<div class="divider"></div>
<MenuItem v-slot="{ active }">
<a href="https://www.buymeacoffee.com/arunas" target="_blank" :class="[active ? 'active' : '']">
<div class="w-4 h-4 mr-3 flex flex-col items-center">
<bmc-icon class="h-4 w-auto" />
</div>
<strong :class="[active ? 'text-white' : 'text-brand-500']">Show your support</strong>
<ArrowTopRightOnSquareIcon class="ml-2 w-4 h-4 opacity-75" />
</a>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup>
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import {
ArrowTopRightOnSquareIcon,
CircleStackIcon,
Cog8ToothIcon,
ComputerDesktopIcon,
MoonIcon,
QuestionMarkCircleIcon,
ShareIcon,
SunIcon,
} from '@heroicons/vue/24/outline';
import { Theme, useLogViewerStore } from '../stores/logViewer.js';
import { ref, watch } from 'vue';
import Checkmark from './Checkmark.vue';
import SpinnerIcon from './SpinnerIcon.vue';
import { copyToClipboard } from '../helpers.js';
import BmcIcon from './BmcIcon.vue';
import { useFileStore } from '../stores/files.js';
const logViewerStore = useLogViewerStore();
const fileStore = useFileStore();
const copied = ref(false);
const copyUrlToClipboard = () => {
copyToClipboard(window.location.href);
copied.value = true;
setTimeout(() => copied.value = false, 2000);
};
watch(
() => logViewerStore.shorterStackTraces,
() => logViewerStore.loadLogs()
);
</script>
@@ -0,0 +1,8 @@
<template>
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
@@ -0,0 +1,33 @@
<template>
<div>
<div class="tabs-container" v-if="tabs && tabs.length > 1">
<div class="border-b border-gray-200 dark:border-gray-800">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<a v-for="tab in tabs" :key="tab.name" href="#" @click.prevent="currentTab = tab"
:class="[isCurrent(tab) ? 'border-brand-500 dark:border-brand-400 text-brand-600 dark:text-brand-500' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-200', 'whitespace-nowrap border-b-2 py-2 px-1 text-sm font-medium focus:outline-brand-500']"
:aria-current="isCurrent(tab) ? 'page' : undefined">{{ tab.name }}</a>
</nav>
</div>
</div>
<slot></slot>
</div>
</template>
<script setup>
import {provide, ref} from "vue";
const props = defineProps({
tabs: {
type: Array,
required: true,
},
})
const currentTab = ref(props.tabs[0]);
provide('currentTab', currentTab);
const isCurrent = (tab) => {
return currentTab.value && currentTab.value.value === tab.value;
}
</script>
@@ -0,0 +1,22 @@
<template>
<div v-if="isSelected">
<slot></slot>
</div>
</template>
<script setup>
import {computed, inject} from "vue";
const props = defineProps({
tabValue: {
type: String,
required: true,
},
})
const currentTab = inject('currentTab');
const isSelected = computed(() => {
return currentTab.value && currentTab.value.value === props.tabValue;
})
</script>
@@ -0,0 +1,97 @@
import { ref } from 'vue';
export const highlightSearchResult = (text, query = null) => {
text = text || '';
if (query) {
try {
text = text.replace(new RegExp(query, 'gi'), '<mark>$&</mark>');
} catch (e) {
// in case the regex is invalid, we want to just continue without marking any text.
}
}
// Let's return the <mark> tags which we use for highlighting the search results
// while escaping the rest of the HTML entities
return escapeHtml(text)
.replace(/&lt;mark&gt;/g, '<mark>')
.replace(/&lt;\/mark&gt;/g, '</mark>')
.replace(/&lt;br\/&gt;/g, '<br/>');
};
export const escapeHtml = (text) => {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
export const copyToClipboard = (str) => {
const el = document.createElement('textarea');
el.value = str;
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
const selected =
document.getSelection().rangeCount > 0
? document.getSelection().getRangeAt(0)
: false;
el.select();
document.execCommand('copy');
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
};
export const replaceQuery = (router, key, value) => {
const route = router.currentRoute.value;
const query = {
host: route.query.host || undefined,
file: route.query.file || undefined,
query: route.query.query || undefined,
page: route.query.page || undefined,
};
// maybe this logic shouldn't be here, but that's what works for now.
// calling `replaceQuery` twice in a single "tick" can cause previous change to be reverted.
if (key === 'host') {
query.file = undefined;
query.page = undefined;
} else if (key === 'file' && query.page !== undefined) {
query.page = undefined;
}
query[key] = value ? String(value) : undefined;
router.push({ name: 'home', query });
};
export const useDropdownDirection = () => {
const dropdownDirections = ref({});
const getDropdownDirection = (buttonElement) => {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const boundingRect = buttonElement.getBoundingClientRect();
return (boundingRect.bottom + 190) < viewportHeight ? 'down' : 'up';
}
const calculateDropdownDirection = (toggleButton) => {
dropdownDirections.value[toggleButton.dataset.toggleId] = getDropdownDirection(toggleButton);
}
return { dropdownDirections, calculateDropdownDirection };
}
export const isMobile = () => {
return window.matchMedia('(max-width: 768px)').matches;
}
@@ -0,0 +1,27 @@
import {focusNextFile, focusPreviousFile, logToggleButtonClass} from './shared.js';
export const handleKeyboardFileNavigation = (event) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
focusPreviousFile();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
focusNextFile();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
document.activeElement.nextElementSibling.focus();
}
};
export const handleKeyboardFileSettingsNavigation = (event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
document.activeElement.previousElementSibling.focus();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
const logToggleButtons = Array.from(document.querySelectorAll(`.${logToggleButtonClass}`));
if (logToggleButtons.length > 0) {
logToggleButtons[0].focus();
}
}
}
@@ -0,0 +1,92 @@
import {
ensureIsExpanded,
fileItemClass,
focusActiveOrFirstFile,
focusFirstLogEntry,
focusLastLogEntry, focusNextFile, focusPreviousFile,
KeyShortcuts,
logToggleButtonClass,
openNextLogEntry,
openPreviousLogEntry
} from './shared.js';
import {useLogViewerStore} from '../stores/logViewer.js';
const globalKeyboardEventHandler = (event) => {
// if event.target is an <input> element, we don't want to handle the keyboard shortcuts
if (event.target.tagName === 'INPUT') return;
if (event.metaKey || event.ctrlKey) return;
if (event.key === KeyShortcuts.ShortcutHelp) {
event.preventDefault();
const logViewerStore = useLogViewerStore();
logViewerStore.helpSlideOverOpen = !logViewerStore.helpSlideOverOpen;
} else if (event.key === KeyShortcuts.Files) {
event.preventDefault();
focusActiveOrFirstFile();
} else if (event.key === KeyShortcuts.Logs) {
event.preventDefault();
focusFirstLogEntry();
} else if (event.key === KeyShortcuts.Hosts) {
event.preventDefault();
const hostsButton = document.getElementById('hosts-toggle-button');
hostsButton?.click();
} else if (event.key === KeyShortcuts.Severity) {
event.preventDefault();
const severityButton = document.getElementById('severity-dropdown-toggle');
severityButton?.click();
} else if (event.key === KeyShortcuts.Settings) {
event.preventDefault();
const settingsButton = document.querySelector('#desktop-site-settings .menu-button');
settingsButton?.click();
} else if (event.key === KeyShortcuts.Search) {
event.preventDefault();
const searchInput = document.getElementById('query');
searchInput?.focus();
} else if (event.key === KeyShortcuts.Refresh) {
event.preventDefault();
const refreshButton = document.getElementById('reload-logs-button');
refreshButton?.click();
} else if (event.key === KeyShortcuts.NextLog) {
event.preventDefault();
if (!document.activeElement.classList.contains(logToggleButtonClass)) {
focusFirstLogEntry();
ensureIsExpanded(document.activeElement);
return;
}
openNextLogEntry();
} else if (event.key === KeyShortcuts.PreviousLog) {
event.preventDefault();
if (!document.activeElement.classList.contains(logToggleButtonClass)) {
focusLastLogEntry();
ensureIsExpanded(document.activeElement);
return;
}
openPreviousLogEntry();
} else if (event.key === KeyShortcuts.Next) {
event.preventDefault();
const isLogEntry = document.activeElement.classList.contains(logToggleButtonClass);
const isFile = document.activeElement.classList.contains(fileItemClass);
if (isLogEntry) {
openNextLogEntry();
} else if (isFile) {
focusNextFile();
}
} else if (event.key === KeyShortcuts.Previous) {
event.preventDefault();
const isLogEntry = document.activeElement.classList.contains(logToggleButtonClass);
const isFile = document.activeElement.classList.contains(fileItemClass);
if (isLogEntry) {
openPreviousLogEntry();
} else if (isFile) {
focusPreviousFile();
}
}
}
export const registerGlobalShortcuts = () => {
document.addEventListener('keydown', globalKeyboardEventHandler);
}
export const unregisterGlobalShortcuts = () => {
document.removeEventListener('keydown', globalKeyboardEventHandler);
}
@@ -0,0 +1,3 @@
export * from './global.js';
export * from './logs.js';
export * from './files.js';
@@ -0,0 +1,62 @@
import {
focusActiveOrFirstFileSettings,
getPreviousElementWithClass,
getIndexOfElementWithClass,
getNextElementWithClass,
logToggleButtonClass,
logLinkClass,
} from './shared.js';
export const handleLogToggleKeyboardNavigation = (event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
focusActiveOrFirstFileSettings();
} else if (event.key === 'ArrowRight') {
const logIndex = getIndexOfElementWithClass(document.activeElement, logToggleButtonClass);
const logLinks = Array.from(document.querySelectorAll(`.${logLinkClass}`));
if (logLinks.length > logIndex) {
event.preventDefault();
logLinks[logIndex].focus();
}
} else if (event.key === 'ArrowUp') {
const previousElement = getPreviousElementWithClass(document.activeElement, logToggleButtonClass);
if (previousElement) {
event.preventDefault();
previousElement.focus();
}
} else if (event.key === 'ArrowDown') {
const nextElement = getNextElementWithClass(document.activeElement, logToggleButtonClass);
if (nextElement) {
event.preventDefault();
nextElement.focus();
}
}
}
export const handleLogLinkKeyboardNavigation = (event) => {
if (event.key === 'ArrowLeft') {
const logIndex = getIndexOfElementWithClass(document.activeElement, logLinkClass);
const logToggleButtons = Array.from(document.querySelectorAll(`.${logToggleButtonClass}`));
if (logToggleButtons.length > logIndex) {
event.preventDefault();
logToggleButtons[logIndex].focus();
}
} else if (event.key === 'ArrowUp') {
const previousElement = getPreviousElementWithClass(document.activeElement, logLinkClass);
if (previousElement) {
event.preventDefault();
previousElement.focus();
}
} else if (event.key === 'ArrowDown') {
const nextElement = getNextElementWithClass(document.activeElement, logLinkClass);
if (nextElement) {
event.preventDefault();
nextElement.focus();
}
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const el = document.activeElement;
el.click();
el.focus();
}
}
@@ -0,0 +1,152 @@
export const fileItemClass = 'file-item-info';
export const fileSettingsButtonClass = 'file-dropdown-toggle';
export const logToggleButtonClass = 'log-level-icon';
export const logLinkClass = 'log-link.large-screen';
export const KeyShortcuts = {
Files: 'f',
Logs: 'l',
Next: 'j',
Previous: 'k',
NextLog: 'n',
PreviousLog: 'p',
Hosts: 'h',
Severity: 's',
Settings: 'g',
Search: '/',
Refresh: 'r',
ShortcutHelp: '?',
}
export const focusFirstLogEntry = () => {
const logToggleButtons = Array.from(document.querySelectorAll(`.${logToggleButtonClass}`));
if (logToggleButtons.length > 0) {
logToggleButtons[0].focus();
}
}
export const focusLastLogEntry = () => {
const logToggleButtons = Array.from(document.querySelectorAll(`.${logToggleButtonClass}`));
if (logToggleButtons.length > 0) {
logToggleButtons[logToggleButtons.length - 1].focus();
}
}
export const ensureIsExpanded = (element) => {
const isExpanded = element.getAttribute('aria-expanded') === 'true';
if (!isExpanded) {
element.click();
}
}
export const ensureIsCollapsed = (element) => {
const isExpanded = element.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
element.click();
}
}
export const openNextLogEntry = () => {
const el = document.activeElement;
const nextElement = getNextElementWithClass(el, logToggleButtonClass);
if (!nextElement) {
const onNextPageLoad = () => {
setTimeout(() => {
focusFirstLogEntry();
ensureIsExpanded(document.activeElement);
}, 50)
document.removeEventListener('logsPageLoaded', onNextPageLoad);
};
document.addEventListener('logsPageLoaded', onNextPageLoad);
document.dispatchEvent(new Event('goToNextPage'));
return;
}
ensureIsCollapsed(el);
nextElement.focus();
ensureIsExpanded(nextElement);
}
export const openPreviousLogEntry = () => {
const el = document.activeElement;
const previousElement = getPreviousElementWithClass(el, logToggleButtonClass);
if (!previousElement) {
const onPreviousPageLoad = () => {
setTimeout(() => {
focusLastLogEntry();
ensureIsExpanded(document.activeElement);
}, 50)
document.removeEventListener('logsPageLoaded', onPreviousPageLoad);
};
document.addEventListener('logsPageLoaded', onPreviousPageLoad);
document.dispatchEvent(new Event('goToPreviousPage'));
return;
}
ensureIsCollapsed(el);
previousElement.focus();
ensureIsExpanded(previousElement);
}
export const focusActiveOrFirstFile = () => {
const activeFile = document.querySelector('.file-item-container.active .file-item-info');
if (activeFile) {
activeFile.focus();
} else {
const firstFile = document.querySelector('.file-item-container .file-item-info');
firstFile?.focus();
}
};
export const focusActiveOrFirstFileSettings = () => {
const activeFile = document.querySelector('.file-item-container.active .file-item-info');
if (activeFile) {
activeFile.nextElementSibling.focus();
} else {
const firstFile = document.querySelector('.file-item-container .file-item-info');
firstFile?.nextElementSibling?.focus();
}
};
export const focusNextFile = () => {
const nextElement = getNextElementWithClass(document.activeElement, fileItemClass);
if (nextElement) {
nextElement.focus();
}
}
export const focusPreviousFile = () => {
const previousElement = getPreviousElementWithClass(document.activeElement, fileItemClass);
if (previousElement) {
previousElement.focus();
}
}
export const getPreviousElementWithClass = (element, className) => {
const elements = Array.from(document.querySelectorAll(`.${className}`));
const currentIndex = elements.findIndex(el => el === element);
// Let's find the first previous element that's not hidden
let previousIndex = currentIndex - 1;
while (previousIndex >= 0 && elements[previousIndex].offsetParent === null) {
previousIndex--;
}
return elements[previousIndex] ? elements[previousIndex] : null;
};
export const getNextElementWithClass = (element, className) => {
const elements = Array.from(document.querySelectorAll(`.${className}`));
const currentIndex = elements.findIndex(el => el === element);
// Let's find the first next element that's not hidden
let nextIndex = currentIndex + 1;
while (nextIndex < elements.length && elements[nextIndex].offsetParent === null) {
nextIndex++;
}
return elements[nextIndex] ? elements[nextIndex] : null;
};
export const getIndexOfElementWithClass = (element, className) => {
const elements = Array.from(document.querySelectorAll(`.${className}`));
return elements.findIndex(el => el === element);
}
@@ -0,0 +1,102 @@
<template>
<div class="absolute z-20 top-0 bottom-10 bg-gray-100 dark:bg-gray-900 md:left-0 md:flex md:w-88 md:flex-col md:fixed md:inset-y-0"
:class="[fileStore.sidebarOpen ? 'left-0 right-0 md:left-auto md:right-auto' : '-left-[200%] right-[200%] md:left-auto md:right-auto']"
>
<file-list></file-list>
</div>
<div class="md:pl-88 flex flex-col flex-1 min-h-screen max-h-screen max-w-full">
<log-list class="pb-16 md:pb-12"></log-list>
</div>
<div class="absolute bottom-4 right-4 flex items-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mr-5 -mb-0.5">
<template v-if="logViewerStore.performance?.requestTime">
<span><span class="hidden md:inline">Memory: </span><span class="font-semibold">{{ logViewerStore.performance.memoryUsage }}</span></span>
<span class="mx-1.5">&middot;</span>
<span><span class="hidden md:inline">Duration: </span><span class="font-semibold">{{ logViewerStore.performance.requestTime }}</span></span>
<span class="mx-1.5">&middot;</span>
</template>
<span><span class="hidden md:inline">Version: </span><span class="font-semibold">{{ LogViewer.version }}</span></span>
</p>
<a href="https://www.buymeacoffee.com/arunas" target="_blank" v-if="LogViewer.show_support_link">
<bmc-logo class="h-6 w-auto" title="Support me by buying me a cup of coffee ❤️" />
</a>
</div>
<keyboard-shortcuts-overlay />
</template>
<script setup>
import FileList from '../components/FileList.vue';
import LogList from '../components/LogList.vue';
import { useHostStore } from '../stores/hosts.js';
import { useLogViewerStore } from '../stores/logViewer.js';
import { useFileStore } from '../stores/files.js';
import { useSearchStore } from '../stores/search.js';
import { usePaginationStore } from '../stores/pagination.js';
import { useRoute, useRouter } from 'vue-router';
import { onBeforeMount, onBeforeUnmount, onMounted, watch } from 'vue';
import BmcLogo from '../components/BmcLogo.vue';
import { replaceQuery } from '../helpers.js';
import { registerGlobalShortcuts, unregisterGlobalShortcuts } from '../keyboardNavigation';
import KeyboardShortcutsOverlay from '../components/KeyboardShortcutsOverlay.vue';
const hostStore = useHostStore();
const logViewerStore = useLogViewerStore();
const fileStore = useFileStore();
const searchStore = useSearchStore();
const paginationStore = usePaginationStore();
const route = useRoute();
const router = useRouter();
onBeforeMount(() => {
logViewerStore.syncTheme();
registerGlobalShortcuts();
});
onBeforeUnmount(() => {
unregisterGlobalShortcuts();
})
onMounted(() => {
// This makes sure we react to device's dark mode changes
setInterval(logViewerStore.syncTheme, 1000);
})
// watch for URL query changes and update the store values
watch(
() => route.query,
(query) => {
fileStore.selectFile(query.file || null);
paginationStore.setPage(query.page || 1);
searchStore.setQuery(query.query || '');
logViewerStore.loadLogs();
},
{ immediate: true },
)
watch(
() => route.query.host,
async (newHost) => {
hostStore.selectHost(newHost || null);
if (newHost && !hostStore.selectedHostIdentifier) {
// the host no longer exists, remove it from the URL
replaceQuery(router, 'host', null);
}
fileStore.reset();
await fileStore.loadFolders();
logViewerStore.loadLogs();
},
{ immediate: true },
)
onMounted(() => {
window.onresize = function () {
logViewerStore.setViewportDimensions(window.innerWidth, window.innerHeight);
};
})
</script>
@@ -0,0 +1,347 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import { useLocalStorage } from '@vueuse/core';
import { useHostStore } from './hosts.js';
import { useLogViewerStore } from './logViewer.js';
export const useFileStore = defineStore({
id: 'files',
state: () => ({
// data
folders: [],
direction: useLocalStorage('fileViewerDirection', 'desc'),
selectedFileIdentifier: null,
fileTypesAvailable: [],
selectedFileTypes: useLocalStorage('selectedFileTypes', []),
error: null,
clearingCache: {},
cacheRecentlyCleared: {},
deleting: {},
abortController: null,
// control variables
loading: false,
checkBoxesVisibility: false,
filesChecked: [],
openFolderIdentifiers: [],
foldersInView: [],
containerTop: 0,
sidebarOpen: false,
}),
getters: {
selectedHost() {
const hostStore = useHostStore();
return hostStore.selectedHost;
},
hostQueryParam() {
const hostStore = useHostStore();
return hostStore.hostQueryParam;
},
filteredFolders: (state) => {
// filter the folders based on the selected file types.
// If a particular folder is now empty, filter it out.
return state.folders.map(folder => ({
...folder,
files: folder.files.filter(file => state.selectedFileTypes.includes(file.type.value)),
})).filter(folder => folder.files.length > 0);
},
files: (state) => state.folders.flatMap((folder) => folder.files),
selectedFile: (state) => state.files.find((file) => file.identifier === state.selectedFileIdentifier),
foldersOpen(state) {
return state.openFolderIdentifiers.map((identifier) => state.folders.find((folder) => folder.identifier === identifier));
},
isOpen() {
return (folder) => this.foldersOpen.map(f => f.identifier).includes(folder.identifier);
},
isChecked: (state) => (file) => state.filesChecked.includes(
typeof file === 'string' ? file : file.identifier
),
shouldBeSticky(state) {
return (folder) => this.isOpen(folder) && state.foldersInView.map(f => f.identifier).includes(folder.identifier);
},
isInViewport() {
return (index) => this.pixelsAboveFold(index) > -36
},
pixelsAboveFold: (state) => (folder) => {
let folderContainer = document.getElementById('folder-' + folder);
if (!folderContainer) return false;
let row = folderContainer.getClientRects()[0];
return (row.top + row.height) - state.containerTop;
},
hasFilesChecked: (state) => state.filesChecked.length > 0,
fileTypesSelected: (state) => state.fileTypesAvailable.filter((fileType) => state.selectedFileTypes.includes(fileType.identifier)),
/** @returns {string[]} */
fileTypesExcluded: (state) => state.fileTypesAvailable
.filter((fileType) => !state.selectedFileTypes.includes(fileType.identifier))
.map((fileType) => fileType.identifier),
selectedFileTypesString() {
const fileTypesSelected = this.fileTypesSelected.map(fileType => fileType.name);
if (fileTypesSelected.length === 0) {
return 'Please select at least one file type';
} else if (fileTypesSelected.length === 1) {
return fileTypesSelected[0];
} else if (fileTypesSelected.length === 2) {
return fileTypesSelected.join(' and ');
} else if (fileTypesSelected.length === 3) {
return fileTypesSelected.slice(0, -1).join(', ') + ' and ' + fileTypesSelected.slice(-1);
} else {
return fileTypesSelected.slice(0, 3).join(', ') + ' and ' + (fileTypesSelected.length - 3) + ' more';
}
},
},
actions: {
setDirection(direction) {
this.direction = direction;
},
selectFile(logFileIdentifier) {
if (this.selectedFileIdentifier === logFileIdentifier) return;
this.selectedFileIdentifier = logFileIdentifier;
this.openFolderForActiveFile();
this.sidebarOpen = false;
},
openFolderForActiveFile() {
if (this.selectedFile) {
const folder = this.folders.find(folder => folder.files.some(file => file.identifier === this.selectedFile.identifier));
if (folder && !this.isOpen(folder)) {
this.toggle(folder);
}
}
},
openRootFolderIfNoneOpen() {
const rootFolder = this.folders.find(folder => folder.is_root);
if (rootFolder && this.openFolderIdentifiers.length === 0) {
this.openFolderIdentifiers.push(rootFolder.identifier);
}
},
loadFolders() {
// abort the previous request which might now be outdated
if (this.abortController) {
this.abortController.abort();
}
if (!this.selectedHost) {
this.folders = [];
this.error = null;
this.loading = false;
return;
}
this.abortController = new AbortController();
this.loading = true;
// load the folders from the server
return axios.get(`${LogViewer.basePath}/api/folders`, {
params: {
host: this.hostQueryParam,
direction: this.direction,
},
signal: this.abortController.signal
})
.then(({ data }) => {
this.folders = data;
this.error = data.error || null;
this.loading = false;
if (this.openFolderIdentifiers.length === 0) {
this.openFolderForActiveFile();
this.openRootFolderIfNoneOpen();
}
this.setAvailableFileTypes(data);
this.onScroll();
})
.catch((error) => {
// aborted, thus we don't need to display that as an error.
if (error.code === 'ERR_CANCELED') return;
this.loading = false;
this.error = error.message;
if (error.response?.data?.message) {
this.error += ': ' + error.response.data.message;
}
console.error(error);
})
},
setAvailableFileTypes(folders) {
const fileTypes = folders.flatMap(folder => folder.files.map(file => file.type));
const uniqueFileTypes = [...new Set(fileTypes.map(fileType => fileType.value))];
this.fileTypesAvailable = uniqueFileTypes.map(fileType => {
return {
identifier: fileType,
name: fileTypes.find(ft => ft.value === fileType).name,
count: fileTypes.filter(ft => ft.value === fileType).length,
}
});
if (!this.selectedFileTypes || this.selectedFileTypes.length === 0) {
this.selectedFileTypes = uniqueFileTypes;
}
},
toggle(folder) {
if (this.isOpen(folder)) {
this.openFolderIdentifiers = this.openFolderIdentifiers.filter(f => f !== folder.identifier);
} else {
this.openFolderIdentifiers.push(folder.identifier);
}
this.onScroll();
},
onScroll() {
let vm = this;
this.foldersOpen.forEach(function (folder) {
if (vm.isInViewport(folder)) {
if (!vm.foldersInView.includes(folder)) {
vm.foldersInView.push(folder);
}
} else {
vm.foldersInView = vm.foldersInView.filter(f => f !== folder);
}
})
},
reset() {
this.openFolderIdentifiers = [];
this.foldersInView = [];
const container = document.getElementById('file-list-container');
if (container) {
this.containerTop = container.getBoundingClientRect().top;
container.scrollTo(0, 0);
}
},
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
},
checkBoxToggle(file) {
if (this.isChecked(file)) {
this.filesChecked = this.filesChecked.filter(f => f !== file);
} else {
this.filesChecked.push(file);
}
},
toggleCheckboxVisibility() {
this.checkBoxesVisibility = !this.checkBoxesVisibility;
},
resetChecks() {
this.filesChecked = [];
this.checkBoxesVisibility = false;
},
clearCacheForFile(file) {
this.clearingCache[file.identifier] = true;
return axios.post(`${LogViewer.basePath}/api/files/${file.identifier}/clear-cache`, {}, {
params: { host: this.hostQueryParam }
})
.then(() => {
if (file.identifier === this.selectedFileIdentifier) {
useLogViewerStore().loadLogs();
}
this.cacheRecentlyCleared[file.identifier] = true;
setTimeout(() => this.cacheRecentlyCleared[file.identifier] = false, 2000);
})
.catch((error) => console.error(error))
.finally(() => this.clearingCache[file.identifier] = false);
},
deleteFile(file) {
return axios.delete(`${LogViewer.basePath}/api/files/${file.identifier}`, {
params: { host: this.hostQueryParam }
})
.then(() => this.loadFolders())
},
clearCacheForFolder(folder) {
this.clearingCache[folder.identifier] = true;
return axios.post(`${LogViewer.basePath}/api/folders/${folder.identifier}/clear-cache`, {}, {
params: { host: this.hostQueryParam }
})
.then(() => {
if (folder.files.some(file => file.identifier === this.selectedFileIdentifier)) {
useLogViewerStore().loadLogs();
}
this.cacheRecentlyCleared[folder.identifier] = true;
setTimeout(() => this.cacheRecentlyCleared[folder.identifier] = false, 2000);
})
.catch((error) => console.error(error))
.finally(() => {
this.clearingCache[folder.identifier] = false;
})
},
deleteFolder(folder) {
this.deleting[folder.identifier] = true;
return axios.delete(`${LogViewer.basePath}/api/folders/${folder.identifier}`, {
params: { host: this.hostQueryParam }
})
.then(() => this.loadFolders())
.catch((error) => console.error(error))
.finally(() => {
this.deleting[folder.identifier] = false;
})
},
deleteSelectedFiles() {
return axios.post(`${LogViewer.basePath}/api/delete-multiple-files`, {
files: this.filesChecked
}, {
params: { host: this.hostQueryParam }
});
},
clearCacheForAllFiles() {
this.clearingCache['*'] = true;
axios.post(`${LogViewer.basePath}/api/clear-cache-all`, {}, {
params: { host: this.hostQueryParam }
})
.then(() => {
this.cacheRecentlyCleared['*'] = true;
setTimeout(() => this.cacheRecentlyCleared['*'] = false, 2000);
useLogViewerStore().loadLogs();
})
.catch((error) => console.error(error))
.finally(() => this.clearingCache['*'] = false);
},
},
})
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia';
export const useHostStore = defineStore({
id: 'hosts',
state: () => ({
selectedHostIdentifier: null,
}),
getters: {
supportsHosts() {
return LogViewer.supports_hosts;
},
hosts() {
return LogViewer.hosts || [];
},
hasRemoteHosts() {
return this.hosts.some(host => host.is_remote);
},
selectedHost() {
return this.hosts.find(host => host.identifier === this.selectedHostIdentifier);
},
localHost() {
return this.hosts.find(host => !host.is_remote);
},
hostQueryParam() {
return this.selectedHost && this.selectedHost.is_remote ? this.selectedHost.identifier : undefined;
},
},
actions: {
selectHost(host) {
if (! this.supportsHosts) {
host = null;
}
if (typeof host === 'string') {
host = this.hosts.find(h => h.identifier === host);
}
if (!host) {
host = this.hosts.find(h => !h.is_remote);
}
this.selectedHostIdentifier = host?.identifier || null;
}
}
})
@@ -0,0 +1,291 @@
import { defineStore } from 'pinia';
import { useFileStore } from './files.js';
import axios from 'axios';
import { useSearchStore } from './search.js';
import { nextTick, toRaw } from 'vue';
import { usePaginationStore } from './pagination.js';
import { useSeverityStore } from './severity.js';
import { useLocalStorage } from '@vueuse/core';
import { debounce } from 'lodash';
import { useHostStore } from './hosts.js';
export const Theme = {
System: 'System',
Light: 'Light',
Dark: 'Dark',
}
const defaultColumns = [
{ label: 'Datetime', data_key: 'datetime' },
{ label: 'Severity', data_key: 'level' },
{ label: 'Message', data_key: 'message' },
]
const shouldUseLocalStorage = window.LogViewer?.defaults?.use_local_storage ?? true;
export const useLogViewerStore = defineStore({
id: 'logViewer',
state: () => ({
theme: shouldUseLocalStorage
? useLocalStorage('logViewerTheme', window.LogViewer?.defaults?.theme || Theme.System)
: (window.LogViewer?.defaults?.theme || Theme.System),
shorterStackTraces: shouldUseLocalStorage
? useLocalStorage('logViewerShorterStackTraces', window.LogViewer?.defaults?.shorter_stack_traces ?? false)
: (window.LogViewer?.defaults?.shorter_stack_traces ?? false),
resultsPerPage: shouldUseLocalStorage
? useLocalStorage('logViewerResultsPerPage', window.LogViewer?.defaults?.per_page ?? 25)
: (window.LogViewer?.defaults?.per_page ?? 25),
direction: shouldUseLocalStorage
? useLocalStorage('logViewerDirection', window.LogViewer?.defaults?.log_sorting_order || 'desc')
: (window.LogViewer?.defaults?.log_sorting_order || 'desc'),
helpSlideOverOpen: false,
// Log data
loading: false,
error: null,
logs: [],
columns: defaultColumns,
levelCounts: [],
performance: {},
hasMoreResults: false,
percentScanned: 100,
abortController: null,
// Log scrolling behaviour data
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
stacksOpen: [],
stacksInView: [],
stackTops: {},
containerTop: 0,
showLevelsDropdown: true,
}),
getters: {
selectedFile() {
const fileStore = useFileStore();
return fileStore.selectedFile;
},
isOpen: (state) => (index) => state.stacksOpen.includes(index),
isMobile: (state) => state.viewportWidth <= 1023,
tableRowHeight() {
return this.isMobile ? 29 : 36;
},
headerHeight() {
return this.isMobile ? 0 : 36;
},
shouldBeSticky(state) {
return (index) => this.isOpen(index) && state.stacksInView.includes(index);
},
stickTopPosition() {
return (index) => {
let aboveFold = this.pixelsAboveFold(index);
if (aboveFold < 0) {
return Math.max(
this.headerHeight - this.tableRowHeight,
this.headerHeight + aboveFold
) + 'px';
}
return this.headerHeight + 'px';
}
},
pixelsAboveFold(state) {
return (index) => {
let tbody = document.getElementById('tbody-' + index);
if (!tbody) return false;
let row = tbody.getClientRects()[0];
return (row.top + row.height - this.tableRowHeight - this.headerHeight) - state.containerTop;
}
},
isInViewport() {
return (index) => this.pixelsAboveFold(index) > -this.tableRowHeight;
},
perPageOptions() {
const baseOptions = window.LogViewer.per_page_options || [10, 25, 50, 100, 250, 500];
if (! baseOptions.includes(this.resultsPerPage)) {
baseOptions.push(this.resultsPerPage);
baseOptions.sort((a, b) => a - b);
}
return baseOptions;
},
},
actions: {
setViewportDimensions(width, height) {
this.viewportWidth = width;
this.viewportHeight = height;
const container = document.querySelector('.log-item-container');
if (container) {
this.containerTop = container.getBoundingClientRect().top;
}
},
toggleTheme() {
switch (this.theme) {
case Theme.System:
this.theme = Theme.Light;
break;
case Theme.Light:
this.theme = Theme.Dark;
break;
default:
this.theme = Theme.System;
break;
}
this.syncTheme();
},
syncTheme() {
const theme = this.theme;
if (theme === Theme.Dark || (theme === Theme.System && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
},
toggle(index) {
if (this.isOpen(index)) {
this.stacksOpen = this.stacksOpen.filter(idx => idx !== index)
} else {
this.stacksOpen.push(index)
}
this.onScroll();
},
onScroll() {
let vm = this;
this.stacksOpen.forEach(function (index) {
if (vm.isInViewport(index)) {
if (!vm.stacksInView.includes(index)) {
vm.stacksInView.push(index);
}
vm.stackTops[index] = vm.stickTopPosition(index);
} else {
vm.stacksInView = vm.stacksInView.filter(idx => idx !== index);
delete vm.stackTops[index];
}
})
},
reset() {
this.stacksOpen = [];
this.stacksInView = [];
this.stackTops = {};
const container = document.querySelector('.log-item-container');
if (!container) return;
this.containerTop = container.getBoundingClientRect().top;
container.scrollTo(0, 0);
},
loadLogs: debounce(function ({ silently = false } = {}) {
const hostStore = useHostStore();
const fileStore = useFileStore();
const searchStore = useSearchStore();
const paginationStore = usePaginationStore();
const severityStore = useSeverityStore();
// abort if the files are not ready yet
if (fileStore.folders.length === 0) return;
// abort the previous request which might now be outdated
if (this.abortController) {
this.abortController.abort();
}
// abort if there's no selected file and no query
if (!this.selectedFile && !searchStore.hasQuery) return;
this.abortController = new AbortController();
const params = {
host: hostStore.hostQueryParam,
file: this.selectedFile?.identifier,
direction: this.direction,
query: searchStore.query,
page: paginationStore.currentPage,
per_page: this.resultsPerPage,
exclude_levels: toRaw(severityStore.excludedLevels),
exclude_file_types: toRaw(fileStore.fileTypesExcluded),
shorter_stack_traces: this.shorterStackTraces,
};
if (!silently) {
this.loading = true;
}
axios.get(`${LogViewer.basePath}/api/logs`, { params, signal: this.abortController.signal })
.then(({ data }) => {
if (params.host) {
// because the host is different, we need to update the log links to be local instead of remote.
this.logs = data.logs.map(log => {
const queryParams = { host: params.host, file: log.file_identifier, query: `log-index:${log.index}` };
log.url = `${window.location.host}${LogViewer.basePath}?${new URLSearchParams(queryParams)}`;
return log;
})
} else {
this.logs = data.logs;
}
this.columns = data.columns || defaultColumns;
this.hasMoreResults = data.hasMoreResults;
this.percentScanned = data.percentScanned;
this.error = data.error || null;
this.performance = data.performance || {};
severityStore.setLevelCounts(data.levelCounts);
paginationStore.setPagination(data.pagination);
this.loading = false;
if (!silently) {
nextTick(() => {
document.dispatchEvent(new Event('logsPageLoaded'));
this.reset();
if (data.expandAutomatically) {
this.stacksOpen.push(0);
}
});
} else {
document.dispatchEvent(new Event('logsPageLoadedSilently'));
}
if (this.hasMoreResults) {
this.loadLogs({ silently: true });
}
})
.catch((error) => {
// aborted, thus we don't need to display that as an error.
if (error.code === 'ERR_CANCELED') {
this.hasMoreResults = false;
this.percentScanned = 100;
return;
}
this.loading = false;
this.error = error.message;
if (error.response?.data?.message) {
this.error += ': ' + error.response.data.message;
}
console.error(error);
});
}, 10),
},
})
@@ -0,0 +1,36 @@
import { defineStore } from 'pinia';
export const usePaginationStore = defineStore({
id: 'pagination',
state: () => ({
page: 1,
pagination: {},
}),
getters: {
currentPage: (state) => state.page !== 1 ? Number(state.page) : null,
links: (state) => (state.pagination?.links || []).slice(1, -1),
linksShort: (state) => (state.pagination?.links_short || []).slice(1, -1),
hasPages: (state) => state.pagination?.last_page > 1,
hasMorePages: (state) => state.pagination?.next_page_url !== null,
},
actions: {
setPagination(pagination) {
this.pagination = pagination;
if (this.pagination?.last_page < this.page) {
this.page = this.pagination?.last_page;
}
},
setPage(page) {
this.page = Number(page);
},
},
})
@@ -0,0 +1,60 @@
import { defineStore } from 'pinia';
import axios from 'axios';
export const useSearchStore = defineStore({
id: 'search',
state: () => ({
query: '',
searchMoreRoute: null,
searching: false,
percentScanned: 0,
error: null,
}),
getters: {
hasQuery: (state) => String(state.query).trim() !== '',
},
actions: {
init() {
this.checkSearchProgress();
},
setQuery(query) {
this.query = query;
},
update(query, error, searchMoreRoute, searching = false, percentScanned = 0) {
this.query = query;
this.error = (error && error !== '') ? error : null;
this.searchMoreRoute = searchMoreRoute;
this.searching = searching;
this.percentScanned = percentScanned;
if (this.searching) {
this.checkSearchProgress();
}
},
checkSearchProgress() {
const queryChecked = this.query;
if (queryChecked === '') return;
const queryParams = '?' + new URLSearchParams({ query: queryChecked });
axios.get(this.searchMoreRoute + queryParams)
.then((response) => {
const data = response.data;
if (this.query !== queryChecked) return;
const wasPreviouslySearching = this.searching;
this.searching = data.hasMoreResults;
this.percentScanned = data.percentScanned;
if (this.searching) {
this.checkSearchProgress();
} else if (wasPreviouslySearching && !this.searching) {
window.dispatchEvent(new CustomEvent('reload-results'));
}
});
},
}
})
@@ -0,0 +1,62 @@
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
export const useSeverityStore = defineStore({
id: 'severity',
state: () => ({
allLevels: [], // should be updated by the backend
excludedLevels: useLocalStorage('excludedLevels', []),
levelCounts: [],
}),
getters: {
levelsFound: (state) => (state.levelCounts || []).filter(level => level.count > 0),
totalResults() {
return this.levelsFound.reduce((total, level) => total + level.count, 0);
},
levelsSelected() {
return this.levelsFound.filter(levelCount => levelCount.selected);
},
totalResultsSelected() {
return this.levelsSelected.reduce((total, level) => total + level.count, 0);
},
},
actions: {
setLevelCounts(levelCounts) {
if (levelCounts.hasOwnProperty('length')) {
this.levelCounts = levelCounts;
} else {
this.levelCounts = Object.values(levelCounts);
}
this.allLevels = levelCounts.map(levelCount => levelCount.level);
},
selectAllLevels() {
this.excludedLevels = [];
this.levelCounts.forEach(levelCount => levelCount.selected = true);
},
deselectAllLevels() {
this.excludedLevels = this.allLevels;
this.levelCounts.forEach(levelCount => levelCount.selected = false);
},
toggleLevel(level) {
const levelCount = this.levelCounts.find(levelCount => levelCount.level === level) || {};
if (this.excludedLevels.includes(level)) {
this.excludedLevels = this.excludedLevels.filter(excludedLevel => excludedLevel !== level);
levelCount.selected = true;
} else {
this.excludedLevels.push(level);
levelCount.selected = false;
}
},
},
})
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="shortcut icon" href="{{ asset(mix('img/log-viewer-32.png', config('log-viewer.assets_path'))) }}">
<title>Log Viewer{{ config('app.name') ? ' - ' . config('app.name') : '' }}</title>
<!-- Style sheets-->
<link href="{{ asset(mix('app.css', config('log-viewer.assets_path'))) }}" rel="stylesheet" onerror="alert('app.css failed to load. Please refresh the page, re-publish Log Viewer assets, or fix routing for vendor assets.')">
</head>
<body class="h-full px-3 lg:px-5 bg-gray-100 dark:bg-gray-900">
<div id="log-viewer" class="flex h-full max-h-screen max-w-full">
<router-view></router-view>
</div>
<!-- Global LogViewer Object -->
<script>
window.LogViewer = @json($logViewerScriptVariables);
// Add additional headers for LogViewer requests like so:
// window.LogViewer.headers['Authorization'] = 'Bearer xxxxxxx';
</script>
<script src="{{ asset(mix('app.js', config('log-viewer.assets_path'))) }}" onerror="alert('app.js failed to load. Please refresh the page, re-publish Log Viewer assets, or fix routing for vendor assets.')"></script>
</body>
</html>
@@ -0,0 +1,36 @@
<?php
use Illuminate\Routing\Middleware\ValidateSignature;
use Illuminate\Support\Facades\Route;
use Opcodes\LogViewer\Http\Middleware\ForwardRequestToHostMiddleware;
use Opcodes\LogViewer\Http\Middleware\JsonResourceWithoutWrappingMiddleware;
Route::get('hosts', 'HostsController@index')->name('log-viewer.hosts');
Route::middleware([
ForwardRequestToHostMiddleware::class,
JsonResourceWithoutWrappingMiddleware::class,
])->group(function () {
Route::get('folders', 'FoldersController@index')->name('log-viewer.folders');
Route::get('folders/{folderIdentifier}/download/request', 'FoldersController@requestDownload')->name('log-viewer.folders.request-download');
Route::post('folders/{folderIdentifier}/clear-cache', 'FoldersController@clearCache')->name('log-viewer.folders.clear-cache');
Route::delete('folders/{folderIdentifier}', 'FoldersController@delete')->name('log-viewer.folders.delete');
Route::get('files', 'FilesController@index')->name('log-viewer.files');
Route::get('files/{fileIdentifier}/download/request', 'FilesController@requestDownload')->name('log-viewer.files.request-download');
Route::post('files/{fileIdentifier}/clear-cache', 'FilesController@clearCache')->name('log-viewer.files.clear-cache');
Route::delete('files/{fileIdentifier}', 'FilesController@delete')->name('log-viewer.files.delete');
Route::post('clear-cache-all', 'FilesController@clearCacheAll')->name('log-viewer.files.clear-cache-all');
Route::post('delete-multiple-files', 'FilesController@deleteMultipleFiles')->name('log-viewer.files.delete-multiple-files');
Route::get('logs', 'LogsController@index')->name('log-viewer.logs');
});
Route::get('folders/{folderIdentifier}/download', 'FoldersController@download')
->middleware(ValidateSignature::class)
->name('log-viewer.folders.download');
Route::get('files/{fileIdentifier}/download', 'FilesController@download')
->middleware(ValidateSignature::class)
->name('log-viewer.files.download');
@@ -0,0 +1,8 @@
<?php
use Illuminate\Support\Facades\Route;
// Catch all route
Route::get('/{view?}', 'IndexController')
->where('view', '(.*)')
->name('log-viewer.index');
@@ -0,0 +1,104 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogFile;
use Carbon\CarbonInterface;
use Opcodes\LogViewer\Facades\Cache;
use Opcodes\LogViewer\Utils\GenerateCacheKey;
use Opcodes\LogViewer\Utils\Utils;
trait CanCacheData
{
protected function indexCacheKeyForQuery(string $query = ''): string
{
return GenerateCacheKey::for($this, Utils::shortMd5($query).':index');
}
public function clearCache(): void
{
foreach ($this->getMetadata('related_indices', []) as $indexIdentifier => $indexMetadata) {
$this->index($indexMetadata['query'])->clearCache();
}
foreach ($this->getRelatedCacheKeys() as $relatedCacheKey) {
Cache::forget($relatedCacheKey);
}
Cache::forget($this->metadataCacheKey());
Cache::forget($this->relatedCacheKeysKey());
$this->index()->clearCache();
}
public function cacheSize(): int
{
$size = 0;
foreach ($this->getMetadata('related_indices', []) as $indexIdentifier => $indexMetadata) {
$size += $this->index($indexMetadata['query'])->cacheSize();
}
foreach ($this->getRelatedCacheKeys() as $relatedCacheKey) {
$size += strlen(serialize(Cache::get($relatedCacheKey)));
}
$size += strlen(serialize(Cache::get($this->metadataCacheKey())));
$size += strlen(serialize(Cache::get($this->relatedCacheKeysKey())));
$size += $this->index()->cacheSize();
return $size;
}
protected function cacheTtl(): CarbonInterface
{
return now()->addWeek();
}
protected function cacheKey(): string
{
return GenerateCacheKey::for($this);
}
protected function relatedCacheKeysKey(): string
{
return GenerateCacheKey::for($this, 'related-cache-keys');
}
public function addRelatedCacheKey(string $key): void
{
$keys = $this->getRelatedCacheKeys();
$keys[] = $key;
Cache::put(
$this->relatedCacheKeysKey(),
array_unique($keys),
$this->cacheTtl()
);
}
protected function getRelatedCacheKeys(): array
{
return array_merge(
Cache::get($this->relatedCacheKeysKey(), []),
[
$this->indexCacheKeyForQuery(),
$this->indexCacheKeyForQuery().':last-scan',
]
);
}
protected function metadataCacheKey(): string
{
return GenerateCacheKey::for($this, 'metadata');
}
protected function loadMetadataFromCache(): array
{
return Cache::get($this->metadataCacheKey(), []);
}
protected function saveMetadataToCache(array $data): void
{
Cache::put($this->metadataCacheKey(), $data, $this->cacheTtl());
}
}
@@ -0,0 +1,36 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogFile;
trait HasMetadata
{
protected array $metadata;
public function setMetadata(string $attribute, $value): void
{
$this->metadata[$attribute] = $value;
}
public function getMetadata(?string $attribute = null, $default = null): mixed
{
if (! isset($this->metadata)) {
$this->loadMetadata();
}
if (isset($attribute)) {
return $this->metadata[$attribute] ?? $default;
}
return $this->metadata;
}
public function saveMetadata(): void
{
$this->saveMetadataToCache($this->metadata);
}
protected function loadMetadata(): void
{
$this->metadata = $this->loadMetadataFromCache();
}
}
@@ -0,0 +1,120 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogIndex;
use Carbon\CarbonInterface;
use Opcodes\LogViewer\Facades\Cache;
use Opcodes\LogViewer\LogIndexChunk;
use Opcodes\LogViewer\Utils\GenerateCacheKey;
trait CanCacheIndex
{
public function clearCache(): void
{
foreach ($this->getAllCacheKeys() as $cacheKey) {
Cache::forget($cacheKey);
}
// this will reset all properties to default, because it won't find any cached settings for this index
$this->loadMetadata();
}
public function cacheSize(): int
{
return collect($this->getAllCacheKeys())
->sum(fn ($cacheKey) => strlen(serialize(Cache::get($cacheKey))));
}
protected function getAllCacheKeys(): array
{
$keys = [];
foreach ($this->getChunkDefinitions() as $chunkDefinition) {
$keys[] = $this->chunkCacheKey($chunkDefinition['index']);
}
$keys[] = $this->metaCacheKey();
$keys[] = $this->cacheKey();
return $keys;
}
protected function saveMetadataToCache(): void
{
Cache::put($this->metaCacheKey(), $this->getMetadata(), $this->cacheTtl());
}
protected function getMetadataFromCache(): array
{
return Cache::get($this->metaCacheKey(), []);
}
protected function canUseCompression(): bool
{
return extension_loaded('zlib')
&& in_array(config('cache.default'), ['file', 'redis', 'array']);
}
protected function saveChunkToCache(LogIndexChunk $chunk): void
{
$data = $chunk->data;
if ($this->canUseCompression()) {
$data = gzcompress(serialize($data), 1);
}
Cache::put(
$this->chunkCacheKey($chunk->index),
$data,
$this->cacheTtl()
);
}
protected function getChunkDataFromCache(int $index, $default = null): ?array
{
$data = Cache::get($this->chunkCacheKey($index), $default);
if (is_string($data) && $this->canUseCompression()) {
$data = unserialize(gzuncompress($data));
}
if ($data === false) {
throw new \Exception('Cannot retrieve the index chunk. Please clear the cache.');
}
return $data;
}
protected function clearChunksFromCache(): void
{
foreach ($this->getChunkDefinitions() as $chunkDefinition) {
Cache::forget($this->chunkCacheKey($chunkDefinition['index']));
}
}
protected function cacheKey(): string
{
return GenerateCacheKey::for($this);
}
protected function metaCacheKey(): string
{
return GenerateCacheKey::for($this, 'metadata');
}
protected function chunkCacheKey(int $index): string
{
return GenerateCacheKey::for($this, "chunk:$index");
}
protected function cacheTtl(): CarbonInterface
{
if (! empty($this->query)) {
// There will be a lot more search queries, and they're usually just one-off searches.
// We don't want these to take up too much of Redis/File-cache space for too long.
return now()->addDay();
}
return now()->addWeek();
}
}
@@ -0,0 +1,123 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogIndex;
use Carbon\CarbonInterface;
trait CanFilterIndex
{
protected ?int $filterFrom = null;
protected ?int $filterTo = null;
protected ?array $includeLevels = null;
protected ?array $excludeLevels = null;
protected ?int $limit = null;
protected ?int $skip = null;
public function setQuery(?string $query = null): self
{
if ($this->query !== $query) {
$this->query = $query;
$this->loadMetadata();
}
return $this;
}
public function getQuery(): ?string
{
return $this->query;
}
public function forDateRange(int|CarbonInterface|null $from = null, int|CarbonInterface|null $to = null): self
{
if ($from instanceof CarbonInterface) {
$from = $from->timestamp;
}
if ($to instanceof CarbonInterface) {
$to = $to->timestamp;
}
$this->filterFrom = $from;
$this->filterTo = $to;
return $this;
}
public function forLevels(string|array|null $levels = null): self
{
if (is_string($levels)) {
$levels = [$levels];
}
if (is_array($levels)) {
$this->includeLevels = array_filter($levels);
} else {
$this->includeLevels = null;
}
return $this;
}
public function exceptLevels(string|array|null $levels = null): self
{
if (is_null($levels)) {
$this->excludeLevels = null;
} elseif (is_array($levels)) {
$this->excludeLevels = $levels;
} else {
$this->excludeLevels = [$levels];
}
return $this;
}
public function forLevel(?string $level = null): self
{
return $this->forLevels($level);
}
public function isLevelSelected(string $level): bool
{
return (is_null($this->includeLevels) || in_array($level, $this->includeLevels))
&& (is_null($this->excludeLevels) || ! in_array($level, $this->excludeLevels));
}
public function skip(?int $skip = null): self
{
$this->skip = $skip;
return $this;
}
public function getSkip(): ?int
{
return $this->skip;
}
public function limit(?int $limit = null): self
{
$this->limit = $limit;
return $this;
}
public function getLimit(): ?int
{
return $this->limit;
}
protected function hasDateFilters(): bool
{
return isset($this->filterFrom)
|| isset($this->filterTo);
}
protected function hasFilters(): bool
{
return $this->hasDateFilters()
|| isset($this->includeLevels)
|| isset($this->excludeLevels);
}
}
@@ -0,0 +1,78 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogIndex;
use ArrayIterator;
use Opcodes\LogViewer\Direction;
trait CanIterateIndex
{
protected array $_cachedFlatIndex;
protected ArrayIterator $_cachedFlatIndexIterator;
protected string $direction = Direction::Forward;
public function setDirection(string $direction): self
{
$this->direction = $direction === Direction::Backward
? Direction::Backward
: Direction::Forward;
return $this->reset();
}
public function isForward(): bool
{
return $this->direction === Direction::Forward;
}
public function isBackward(): bool
{
return $this->direction === Direction::Backward;
}
/** @alias backward */
public function reverse(): self
{
return $this->backward();
}
public function backward(): self
{
return $this->setDirection(Direction::Backward);
}
public function forward(): self
{
return $this->setDirection(Direction::Forward);
}
public function next(): ?array
{
if (! isset($this->_cachedFlatIndex)) {
$this->_cachedFlatIndex = $this->getFlatIndex();
}
if (! isset($this->_cachedFlatIndexIterator)) {
$this->_cachedFlatIndexIterator = new ArrayIterator($this->_cachedFlatIndex);
} else {
$this->_cachedFlatIndexIterator->next();
}
if (! $this->_cachedFlatIndexIterator->valid()) {
return null;
}
return [
$this->_cachedFlatIndexIterator->key(),
$this->_cachedFlatIndexIterator->current(),
];
}
public function reset(): self
{
unset($this->_cachedFlatIndexIterator);
unset($this->_cachedFlatIndex);
return $this;
}
}
@@ -0,0 +1,99 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogIndex;
use Opcodes\LogViewer\Exceptions\InvalidChunkSizeException;
use Opcodes\LogViewer\LogIndexChunk;
trait CanSplitIndexIntoChunks
{
protected int $maxChunkSize;
protected array $currentChunkDefinition;
protected LogIndexChunk $currentChunk;
protected array $chunkDefinitions = [];
/**
* @throws InvalidChunkSizeException
*/
public function setMaxChunkSize(int $size): void
{
if ($size < 1) {
throw new InvalidChunkSizeException($size.' is not a valid chunk size. Must be higher than zero.');
}
$this->maxChunkSize = $size;
}
public function getMaxChunkSize(): int
{
return $this->maxChunkSize;
}
public function getCurrentChunk(): LogIndexChunk
{
if (! isset($this->currentChunk)) {
$this->currentChunk = LogIndexChunk::fromDefinitionArray($this->currentChunkDefinition);
if ($this->currentChunk->size > 0) {
$this->currentChunk->data = $this->getChunkDataFromCache($this->currentChunk->index, []);
}
}
return $this->currentChunk;
}
public function getChunkDefinitions(): array
{
return [
...$this->chunkDefinitions,
$this->getCurrentChunk()->toArray(),
];
}
public function getChunkDefinition(int $index): ?array
{
return $this->getChunkDefinitions()[$index] ?? null;
}
public function getChunkCount(): int
{
return count($this->getChunkDefinitions());
}
public function getChunkData(int $index): ?array
{
$currentChunk = $this->getCurrentChunk();
if ($index === $currentChunk?->index) {
$chunkData = $currentChunk->data ?? [];
} else {
$chunkData = $this->getChunkDataFromCache($index);
}
return $chunkData;
}
protected function rotateCurrentChunk(): void
{
$this->saveChunkToCache($this->currentChunk);
$this->chunkDefinitions[] = $this->currentChunk->toArray();
$this->currentChunk = new LogIndexChunk([], $this->currentChunk->index + 1, 0);
$this->saveMetadata();
}
protected function getRelevantItemsInChunk(array $chunkDefinition): int
{
$relevantItemsInChunk = 0;
foreach ($chunkDefinition['level_counts'] as $level => $count) {
if ($this->isLevelSelected($level)) {
$relevantItemsInChunk += $count;
}
}
return $relevantItemsInChunk;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogIndex;
trait HasMetadata
{
public function getMetadata(): array
{
return [
'query' => $this->getQuery(),
'identifier' => $this->identifier,
'last_scanned_file_position' => $this->lastScannedFilePosition,
'last_scanned_index' => $this->lastScannedIndex,
'next_log_index_to_create' => $this->nextLogIndexToCreate,
'max_chunk_size' => $this->maxChunkSize,
'current_chunk_index' => $this->getCurrentChunk()->index,
'chunk_definitions' => $this->chunkDefinitions,
'current_chunk_definition' => $this->getCurrentChunk()->toArray(),
];
}
protected function saveMetadata(): void
{
$this->saveMetadataToCache();
}
protected function loadMetadata(): void
{
$data = $this->getMetadataFromCache();
$this->lastScannedFilePosition = $data['last_scanned_file_position'] ?? 0;
$this->lastScannedIndex = $data['last_scanned_index'] ?? 0;
$this->nextLogIndexToCreate = $data['next_log_index_to_create'] ?? 0;
$this->maxChunkSize = $data['max_chunk_size'] ?? self::DEFAULT_CHUNK_SIZE;
$this->chunkDefinitions = $data['chunk_definitions'] ?? [];
$this->currentChunkDefinition = $data['current_chunk_definition'] ?? [];
}
}
@@ -0,0 +1,104 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogIndex;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
trait PreservesIndexingProgress
{
public function setLastScannedFilePosition(int $position): void
{
$this->lastScannedFilePosition = $position;
}
public function getLastScannedFilePosition(): int
{
if (! isset($this->lastScannedFilePosition)) {
$this->loadMetadata();
}
return $this->lastScannedFilePosition;
}
public function setLastScannedIndex(int $index): void
{
$this->lastScannedIndex = $index;
}
public function getLastScannedIndex(): int
{
if (! isset($this->lastScannedIndex)) {
$this->loadMetadata();
}
return $this->lastScannedIndex;
}
public function incomplete(): bool
{
return $this->file->size() !== $this->getLastScannedFilePosition();
}
public function getEarliestTimestamp(): ?int
{
$earliestTimestamp = null;
if ($this->hasFilters()) {
// because it has filters, we can no longer use our chunk definitions, which has
// values for the whole index and not just particular levels/dates.
foreach ($this->get() as $timestamp => $tsIndex) {
$earliestTimestamp = min($timestamp, $earliestTimestamp ?? $timestamp);
}
} else {
foreach ($this->getChunkDefinitions() as $chunkDefinition) {
if (! isset($chunkDefinition['earliest_timestamp'])) {
continue;
}
$earliestTimestamp = min(
$chunkDefinition['earliest_timestamp'],
$earliestTimestamp ?? $chunkDefinition['earliest_timestamp']
);
}
}
return $earliestTimestamp;
}
public function getEarliestDate(): CarbonInterface
{
return CarbonImmutable::createFromTimestamp($this->getEarliestTimestamp());
}
public function getLatestTimestamp(): ?int
{
$latestTimestamp = null;
if ($this->hasFilters()) {
// because it has filters, we can no longer use our chunk definitions, which has
// values for the whole index and not just particular levels/dates.
foreach ($this->get() as $timestamp => $tsIndex) {
$latestTimestamp = max($timestamp, $latestTimestamp ?? $timestamp);
}
} else {
foreach ($this->getChunkDefinitions() as $chunkDefinition) {
if (! isset($chunkDefinition['latest_timestamp'])) {
continue;
}
$latestTimestamp = max(
$chunkDefinition['latest_timestamp'],
$latestTimestamp ?? $chunkDefinition['latest_timestamp']
);
}
}
return $latestTimestamp;
}
public function getLatestDate(): CarbonInterface
{
return CarbonImmutable::createFromTimestamp($this->getLatestTimestamp());
}
}
@@ -0,0 +1,104 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogReader;
use Opcodes\LogViewer\Utils\Utils;
trait CanFilterUsingIndex
{
protected ?string $query = null;
protected ?int $onlyShowIndex = null;
/**
* Load only the provided log levels
*
* @alias setLevels
*
* @param string|array|null $levels
*/
public function only($levels = null): static
{
return $this->setLevels($levels);
}
/**
* Load only the provided log levels
*
* @param string|array|null $levels
*/
public function setLevels($levels = null): static
{
$this->index()->forLevels($levels);
return $this;
}
public function allLevels(): static
{
return $this->setLevels(null);
}
/**
* Load all log levels except the provided ones.
*
* @alias exceptLevels
*
* @param string|array|null $levels
*/
public function except($levels = null): static
{
return $this->exceptLevels($levels);
}
/**
* Load all log levels except the provided ones.
*
* @param string|array|null $levels
*/
public function exceptLevels($levels = null): static
{
$this->index()->exceptLevels($levels);
return $this;
}
public function skip(int $number): static
{
$this->index()->skip($number);
return $this;
}
public function limit(int $number): static
{
$this->index()->limit($number);
return $this;
}
public function search(?string $query = null): static
{
return $this->setQuery($query);
}
protected function setQuery(?string $query = null): static
{
$this->closeFile();
if (! empty($query) && str_starts_with($query, 'log-index:')) {
$this->query = null;
$this->only(null);
$this->onlyShowIndex = intval(explode(':', $query)[1]);
} elseif (! empty($query)) {
$query = '~'.$query.'~iu';
Utils::validateRegex($query);
$this->query = $query;
} else {
$this->query = null;
}
return $this;
}
}
@@ -0,0 +1,29 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogReader;
use Opcodes\LogViewer\Direction;
trait CanSetDirectionUsingIndex
{
public function reverse(): static
{
return $this->setDirection(Direction::Backward);
}
public function forward(): static
{
return $this->setDirection(Direction::Forward);
}
public function setDirection(?string $direction = null): static
{
$direction = $direction === Direction::Backward
? Direction::Backward
: Direction::Forward;
$this->index()->setDirection($direction);
return $this->reset();
}
}
@@ -0,0 +1,84 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogReader;
use Opcodes\LogViewer\Exceptions\CannotCloseFileException;
use Opcodes\LogViewer\Exceptions\CannotOpenFileException;
trait KeepsFileHandle
{
/** @var resource|null */
protected $fileHandle = null;
protected function isFileOpen(): bool
{
return is_resource($this->fileHandle);
}
protected function isFileClosed(): bool
{
return ! $this->isFileOpen();
}
/**
* @throws CannotOpenFileException
*/
protected function prepareFileForReading(): void
{
if ($this->isFileClosed()) {
$this->openFile();
}
}
/**
* Open the log file for reading. Most other methods will open the file automatically if needed.
*
* @throws CannotOpenFileException
*/
protected function openFile(): static
{
if ($this->isFileOpen()) {
return $this;
}
try {
$this->fileHandle = fopen($this->file->path, 'r');
} catch (\ErrorException $exception) {
throw new CannotOpenFileException('Could not open "'.$this->file->path.'" for reading.', 0, $exception);
}
if ($this->fileHandle === false) {
throw new CannotOpenFileException('Could not open "'.$this->file->path.'" for reading.');
}
if (method_exists($this, 'onFileOpened')) {
$this->onFileOpened();
}
return $this;
}
/**
* Close the file handle.
*
* @throws CannotCloseFileException
*/
protected function closeFile(): static
{
if ($this->isFileClosed()) {
return $this;
}
if (fclose($this->fileHandle)) {
$this->fileHandle = null;
} else {
throw new CannotCloseFileException('Could not close the file "'.$this->file->path.'".');
}
if (method_exists($this, 'onFileClosed')) {
$this->onFileClosed();
}
return $this;
}
}
@@ -0,0 +1,34 @@
<?php
namespace Opcodes\LogViewer\Concerns\LogReader;
use Opcodes\LogViewer\LogFile;
trait KeepsInstances
{
/**
* Cached LogReader instances.
*/
public static array $_instances = [];
public static function instance(LogFile $file): static
{
if (! isset(static::$_instances[$file->path])) {
static::$_instances[$file->path] = new static($file);
}
return static::$_instances[$file->path];
}
public static function clearInstance(LogFile $file): void
{
if (isset(static::$_instances[$file->path])) {
unset(static::$_instances[$file->path]);
}
}
public static function clearInstances(): void
{
static::$_instances = [];
}
}
@@ -0,0 +1,55 @@
<?php
namespace Opcodes\LogViewer\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class GenerateDummyLogsCommand extends Command
{
protected $signature = 'log-viewer:generate-dummy-logs {amount} {--channel=single}';
protected $description = 'Generate dummy log entries to preview in the Log Viewer';
protected array $severities = [
// 'notice',
'info',
// 'alert',
'debug',
'warning',
'error',
// 'critical',
// 'emergency',
];
public function handle()
{
if (app()->environment('production')) {
$this->error('You should not be generating dummy logs in production. Exiting...');
return;
}
$amount = $this->argument('amount');
$channel = $this->option('channel');
$this->info('Generating '.$amount.' logs on the "'.$channel.'" channel.');
while ($amount > 0) {
$level = Arr::random($this->severities);
if ($level === 'error') {
Log::channel($channel)->error(new \Exception('Example exception being logged'));
} else {
Log::channel($channel)->log($level, 'Example log entry for the level '.$level, [
'one' => 1,
'two' => 'two',
'three' => [1, 2, 3],
]);
}
$amount--;
}
$this->info('Done!');
}
}
@@ -0,0 +1,44 @@
<?php
namespace Opcodes\LogViewer\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Opcodes\LogViewer\LogViewerServiceProvider;
use Spatie\Watcher\Watch;
class PublishCommand extends Command
{
protected $signature = 'log-viewer:publish {--watch}';
protected $description = 'Publish Log Viewer assets';
public function handle()
{
$this->call('vendor:publish', [
'--tag' => 'log-viewer-assets',
'--force' => true,
]);
if ($this->option('watch')) {
if (! class_exists(Watch::class)) {
$this->error('Please install the spatie/file-system-watcher package to use the --watch option.');
$this->info('Learn more at https://github.com/spatie/file-system-watcher');
return;
}
$this->info('Watching for file changes... (Press CTRL+C to stop)');
Watch::path(LogViewerServiceProvider::basePath('/public'))
->onAnyChange(function (string $type, string $path) {
if (Str::endsWith($path, 'manifest.json')) {
$this->call('vendor:publish', [
'--tag' => 'log-viewer-assets',
'--force' => true,
]);
}
})
->start();
}
}
}
@@ -0,0 +1,9 @@
<?php
namespace Opcodes\LogViewer;
class Direction
{
const Forward = 'forward';
const Backward = 'backward';
}
@@ -0,0 +1,12 @@
<?php
namespace Opcodes\LogViewer\Enums;
/**
* @deprecated Use SortingMethod instead
*/
class FolderSortingMethod
{
public const Alphabetical = 'Alphabetical';
public const ModifiedTime = 'ModifiedTime';
}
@@ -0,0 +1,9 @@
<?php
namespace Opcodes\LogViewer\Enums;
class SortingMethod
{
public const Alphabetical = 'Alphabetical';
public const ModifiedTime = 'ModifiedTime';
}
@@ -0,0 +1,9 @@
<?php
namespace Opcodes\LogViewer\Enums;
class SortingOrder
{
public const Ascending = 'asc';
public const Descending = 'desc';
}
@@ -0,0 +1,10 @@
<?php
namespace Opcodes\LogViewer\Enums;
class Theme
{
public const System = 'System';
public const Light = 'Light';
public const Dark = 'Dark';
}
@@ -0,0 +1,15 @@
<?php
namespace Opcodes\LogViewer\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Opcodes\LogViewer\LogFile;
class LogFileDeleted
{
use Dispatchable;
public function __construct(
public LogFile $file
) {}
}
@@ -0,0 +1,7 @@
<?php
namespace Opcodes\LogViewer\Exceptions;
use Exception;
class CannotCloseFileException extends Exception {}
@@ -0,0 +1,7 @@
<?php
namespace Opcodes\LogViewer\Exceptions;
use Exception;
class CannotOpenFileException extends Exception {}
@@ -0,0 +1,7 @@
<?php
namespace Opcodes\LogViewer\Exceptions;
use Exception;
class InvalidChunkSizeException extends Exception {}
@@ -0,0 +1,7 @@
<?php
namespace Opcodes\LogViewer\Exceptions;
use Exception;
class InvalidRegularExpression extends Exception {}
@@ -0,0 +1,5 @@
<?php
namespace Opcodes\LogViewer\Exceptions;
class SkipLineException extends \Exception {}
@@ -0,0 +1,18 @@
<?php
namespace Opcodes\LogViewer\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @mixin \Illuminate\Contracts\Cache\Repository
*
* @see \Illuminate\Cache\Repository
*/
class Cache extends Facade
{
protected static function getFacadeAccessor()
{
return 'log-viewer-cache';
}
}
@@ -0,0 +1,49 @@
<?php
namespace Opcodes\LogViewer\Facades;
use Illuminate\Support\Facades\Facade;
use Opcodes\LogViewer\Host;
use Opcodes\LogViewer\HostCollection;
use Opcodes\LogViewer\LogFile;
use Opcodes\LogViewer\LogFileCollection;
use Opcodes\LogViewer\LogFolder;
use Opcodes\LogViewer\LogFolderCollection;
use Opcodes\LogViewer\Readers\LogReaderInterface;
/**
* @see \Opcodes\LogViewer\LogViewerService
*
* @method static string version()
* @method static string timezone()
* @method static bool assetsAreCurrent()
* @method static bool supportsHostsFeature()
* @method static void resolveHostsUsing(callable $callback)
* @method static Host[]|HostCollection getHosts()
* @method static Host|null getHost(?string $hostIdentifier)
* @method static LogFolder[]|LogFolderCollection getFilesGroupedByFolder()
* @method static LogFolder|null getFolder(?string $folderIdentifier)
* @method static LogFile[]|LogFileCollection getFiles()
* @method static LogFile|null getFile(string $fileIdentifier)
* @method static void clearFileCache()
* @method static string|null getRouteDomain()
* @method static array getRouteMiddleware()
* @method static string getRoutePrefix()
* @method static void auth($callback = null)
* @method static void setMaxLogSize(int $bytes)
* @method static int maxLogSize()
* @method static int lazyScanChunkSize()
* @method static float lazyScanTimeout()
* @method static string basePathForLogs()
* @method static void extend(string $type, string $class)
* @method static void useLogFileClass(string $class)
* @method static void useLogReaderClass(string $class)
* @method static string|LogReaderInterface logReaderClass()
*/
class LogViewer extends Facade
{
protected static function getFacadeAccessor()
{
return 'log-viewer';
}
}
@@ -0,0 +1,38 @@
<?php
namespace Opcodes\LogViewer;
use Opcodes\LogViewer\Utils\Utils;
class Host
{
public bool $is_remote;
public function __construct(
public ?string $identifier,
public string $name,
public ?string $host = null,
public ?array $headers = null,
public ?array $auth = null,
public ?bool $verifyServerCertificate = true,
) {
$this->is_remote = $this->isRemote();
}
public static function fromConfig(string|int $identifier, array $config = []): self
{
return new static(
is_string($identifier) ? $identifier : Utils::shortMd5($config['host']),
$config['name'] ?? (is_string($identifier) ? $identifier : $config['host']),
$config['host'] ?? null,
$config['headers'] ?? [],
$config['auth'] ?? [],
$config['verify_server_certificate'] ?? true,
);
}
public function isRemote(): bool
{
return ! is_null($this->host);
}
}
@@ -0,0 +1,27 @@
<?php
namespace Opcodes\LogViewer;
use Illuminate\Support\Collection;
class HostCollection extends Collection
{
public static function fromConfig(array $config = []): self
{
return new static(array_map(
fn (array $hostConfig, $identifier) => Host::fromConfig($identifier, $hostConfig),
$config,
array_keys($config)
));
}
public function remote(): self
{
return $this->filter(fn (Host $host) => $host->isRemote());
}
public function local(): self
{
return $this->filter(fn (Host $host) => ! $host->isRemote());
}
}
@@ -0,0 +1,116 @@
<?php
namespace Opcodes\LogViewer\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\URL;
use Opcodes\LogViewer\Enums\SortingMethod;
use Opcodes\LogViewer\Enums\SortingOrder;
use Opcodes\LogViewer\Facades\LogViewer;
use Opcodes\LogViewer\Http\Resources\LogFileResource;
class FilesController
{
public function index(Request $request)
{
$files = LogViewer::getFiles();
$sortingMethod = config('log-viewer.defaults.file_sorting_method', SortingMethod::ModifiedTime);
$direction = $this->validateDirection($request->query('direction'));
$files->sortUsing($sortingMethod, $direction);
return LogFileResource::collection($files);
}
private function validateDirection(?string $direction): string
{
if ($direction === SortingOrder::Ascending) {
return SortingOrder::Ascending;
}
return SortingOrder::Descending;
}
public function requestDownload(Request $request, string $fileIdentifier)
{
$file = LogViewer::getFile($fileIdentifier);
abort_if(is_null($file), 404);
Gate::authorize('downloadLogFile', $file);
return response()->json([
'url' => URL::temporarySignedRoute(
'log-viewer.files.download',
now()->addMinute(),
['fileIdentifier' => $fileIdentifier]
),
]);
}
public function download(string $fileIdentifier)
{
$file = LogViewer::getFile($fileIdentifier);
return $file->download();
}
public function clearCache(string $fileIdentifier)
{
$file = LogViewer::getFile($fileIdentifier);
abort_if(is_null($file), 404);
$file->clearCache();
return response()->json([
'success' => true,
]);
}
public function clearCacheAll()
{
LogViewer::getFiles()->each->clearCache();
return response()->json([
'success' => true,
]);
}
public function delete(string $fileIdentifier)
{
$file = LogViewer::getFile($fileIdentifier);
if (is_null($file)) {
return response()->json(['success' => true]);
}
Gate::authorize('deleteLogFile', $file);
$file->delete();
return response()->json([
'success' => true,
]);
}
public function deleteMultipleFiles(Request $request)
{
$selectedFilesArray = $request->input('files', []);
foreach ($selectedFilesArray as $fileIdentifier) {
$file = LogViewer::getFile($fileIdentifier);
if (! $file || ! Gate::check('deleteLogFile', $file)) {
continue;
}
$file->delete();
}
return response()->json([
'success' => true,
]);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Opcodes\LogViewer\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\URL;
use Opcodes\LogViewer\Enums\SortingMethod;
use Opcodes\LogViewer\Enums\SortingOrder;
use Opcodes\LogViewer\Facades\LogViewer;
use Opcodes\LogViewer\Http\Resources\LogFolderResource;
use Opcodes\LogViewer\LogFile;
class FoldersController
{
public function index(Request $request)
{
$folders = LogViewer::getFilesGroupedByFolder();
$sortingMethod = config('log-viewer.defaults.folder_sorting_method', SortingMethod::ModifiedTime);
$sortingOrder = config('log-viewer.defaults.folder_sorting_order', SortingOrder::Descending);
$fileSortingMethod = config('log-viewer.defaults.file_sorting_method', SortingMethod::ModifiedTime);
$fileSortingOrder = $this->validateDirection($request->query('direction'));
$folders->sortUsing($sortingMethod, $sortingOrder);
$folders->each(fn ($folder) => $folder->files()->sortUsing($fileSortingMethod, $fileSortingOrder));
return LogFolderResource::collection($folders->values());
}
private function validateDirection(?string $direction): string
{
if ($direction === SortingOrder::Ascending) {
return SortingOrder::Ascending;
}
return SortingOrder::Descending;
}
public function requestDownload(Request $request, string $folderIdentifier)
{
$folder = LogViewer::getFolder($folderIdentifier);
abort_if(is_null($folder), 404);
Gate::authorize('downloadLogFolder', $folder);
return response()->json([
'url' => URL::temporarySignedRoute(
'log-viewer.folders.download',
now()->addMinutes(30), // longer time to allow for processing of the ZIP file
['folderIdentifier' => $folderIdentifier]
),
]);
}
public function download(string $folderIdentifier)
{
$folder = LogViewer::getFolder($folderIdentifier);
return $folder->download();
}
public function clearCache(string $folderIdentifier)
{
$folder = LogViewer::getFolder($folderIdentifier);
abort_if(is_null($folder), 404);
$folder?->files()->each->clearCache();
return response()->json(['success' => true]);
}
public function delete(string $folderIdentifier)
{
$folder = LogViewer::getFolder($folderIdentifier);
if (is_null($folder)) {
return response()->json(['success' => true]);
}
Gate::authorize('deleteLogFolder', $folder);
$folder->files()->each(function (LogFile $file) {
if (Gate::check('deleteLogFile', $file)) {
$file->delete();
}
});
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,16 @@
<?php
namespace Opcodes\LogViewer\Http\Controllers;
use Opcodes\LogViewer\Facades\LogViewer;
use Opcodes\LogViewer\Http\Resources\LogViewerHostResource;
class HostsController
{
public function index()
{
return LogViewerHostResource::collection(
LogViewer::getHosts()
);
}
}
@@ -0,0 +1,47 @@
<?php
namespace Opcodes\LogViewer\Http\Controllers;
use Opcodes\LogViewer\Enums\SortingMethod;
use Opcodes\LogViewer\Facades\LogViewer;
use Opcodes\LogViewer\LogFolder;
use Opcodes\LogViewer\Utils\Utils;
class IndexController
{
public function __invoke()
{
if (config('log-viewer.api_only')) {
abort(404);
}
$files_sort_by_time = config('log-viewer.defaults.file_sorting_method') === SortingMethod::ModifiedTime;
return view(LogViewer::getViewLayout(), [
'logViewerScriptVariables' => [
'headers' => (object) [],
'assets_outdated' => ! LogViewer::assetsAreCurrent(),
'version' => LogViewer::version(),
'app_name' => config('app.name'),
'path' => config('log-viewer.route_path'),
'back_to_system_url' => config('log-viewer.back_to_system_url'),
'back_to_system_label' => config('log-viewer.back_to_system_label'),
'files_sort_by_time' => $files_sort_by_time,
'max_log_size_formatted' => Utils::bytesForHumans(LogViewer::maxLogSize()),
'show_support_link' => config('log-viewer.show_support_link', true),
'supports_hosts' => LogViewer::supportsHostsFeature(),
'hosts' => LogViewer::getHosts(),
'per_page_options' => config('log-viewer.per_page_options') ?? [10, 25, 50, 100, 250, 500],
'defaults' => [
'use_local_storage' => config('log-viewer.defaults.use_local_storage'),
'log_sorting_order' => config('log-viewer.defaults.log_sorting_order'),
'per_page' => config('log-viewer.defaults.per_page'),
'theme' => config('log-viewer.defaults.theme'),
'shorter_stack_traces' => config('log-viewer.defaults.shorter_stack_traces'),
],
'root_folder_prefix' => LogFolder::rootPrefix(),
],
]);
}
}
@@ -0,0 +1,123 @@
<?php
namespace Opcodes\LogViewer\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Opcodes\LogViewer\Exceptions\InvalidRegularExpression;
use Opcodes\LogViewer\Facades\LogViewer;
use Opcodes\LogViewer\Http\Resources\LevelCountResource;
use Opcodes\LogViewer\Http\Resources\LogFileResource;
use Opcodes\LogViewer\Http\Resources\LogResource;
use Opcodes\LogViewer\Logs\Log;
class LogsController
{
const OLDEST_FIRST = 'asc';
const NEWEST_FIRST = 'desc';
public function index(Request $request)
{
$fileIdentifier = $request->query('file', '');
$query = $request->query('query', '');
$direction = $request->query('direction', 'desc');
$log = $request->query('log', null);
$excludedLevels = $request->query('exclude_levels', []);
$excludedFileTypes = $request->query('exclude_file_types', []);
$perPage = $request->query('per_page', 25);
session()->put('log-viewer:shorter-stack-traces', $request->boolean('shorter_stack_traces', false));
$hasMoreResults = false;
$percentScanned = 0;
if ($request->query('page', 1) < 1) {
$request->replace(['page' => 1]);
}
if ($file = LogViewer::getFile($fileIdentifier)) {
$logQuery = $file->logs();
$logClass = $file->type()->logClass();
} elseif (! empty($query)) {
$fileCollection = LogViewer::getFiles();
if (! empty($excludedFileTypes)) {
$fileCollection = $fileCollection->filter(function ($file) use ($excludedFileTypes) {
return ! in_array($file->type()->value, $excludedFileTypes);
})->values();
}
$logQuery = $fileCollection->logs();
$logClass = Log::class;
}
if (isset($logQuery)) {
try {
$logQuery->search($query);
if (isset($file) && Str::startsWith($query, 'log-index:')) {
$logIndex = explode(':', $query)[1];
$expandAutomatically = intval($logIndex) || $logIndex === '0';
}
if ($direction === self::NEWEST_FIRST) {
$logQuery->reverse();
}
$logQuery->scan();
$logQuery->exceptLevels($excludedLevels);
$logs = $logQuery->paginate((int) $perPage);
$levels = array_values($logQuery->getLevelCounts());
if ($logs->lastPage() < $request->input('page', 1)) {
$request->replace(['page' => $logs->lastPage() ?? 1]);
// re-create the paginator instance to fix a bug
$logs = $logQuery->paginate($perPage);
}
$hasMoreResults = $logQuery->requiresScan();
$percentScanned = $logQuery->percentScanned();
} catch (InvalidRegularExpression $exception) {
$queryError = $exception->getMessage();
}
}
return response()->json([
'file' => isset($file) ? new LogFileResource($file) : null,
'levelCounts' => LevelCountResource::collection($levels ?? []),
'logs' => LogResource::collection($logs ?? []),
'columns' => isset($logClass) ? ($logClass::$columns ?? null) : null,
'pagination' => isset($logs) ? [
'current_page' => $logs->currentPage(),
'first_page_url' => $logs->url(1),
'from' => $logs->firstItem(),
'last_page' => $logs->lastPage(),
'last_page_url' => $logs->url($logs->lastPage()),
'links' => $logs->linkCollection()->toArray(),
'links_short' => $logs->onEachSide(0)->linkCollection()->toArray(),
'next_page_url' => $logs->nextPageUrl(),
'path' => $logs->path(),
'per_page' => $logs->perPage(),
'prev_page_url' => $logs->previousPageUrl(),
'to' => $logs->lastItem(),
'total' => $logs->total(),
] : null,
'expandAutomatically' => $expandAutomatically ?? false,
'cacheRecentlyCleared' => $this->cacheRecentlyCleared ?? false,
'hasMoreResults' => $hasMoreResults,
'percentScanned' => $percentScanned,
'performance' => $this->getRequestPerformanceInfo(),
]);
}
protected function getRequestPerformanceInfo(): array
{
$startTime = defined('LARAVEL_START') ? LARAVEL_START : request()->server('REQUEST_TIME_FLOAT');
$memoryUsage = number_format(memory_get_peak_usage(true) / 1024 / 1024, 2).' MB';
$requestTime = number_format((microtime(true) - $startTime) * 1000, 0).'ms';
return [
'memoryUsage' => $memoryUsage,
'requestTime' => $requestTime,
'version' => LogViewer::version(),
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace Opcodes\LogViewer\Http\Middleware;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Gate;
use Opcodes\LogViewer\Facades\LogViewer;
class AuthorizeLogViewer
{
public function handle($request, $next)
{
if (
config('log-viewer.require_auth_in_production', false)
&& App::isProduction()
&& ! Gate::has('viewLogViewer')
&& ! LogViewer::hasAuthCallback()
) {
abort(403);
}
LogViewer::auth();
return $next($request);
}
}

Some files were not shown because too many files have changed in this diff Show More