🆙 Add cms i using 🆙

This commit is contained in:
Remco committed 2025-11-25 22:42:56 +01:00
1 parent 94704e0925
commit d44196149e
35591 files changed
+3601123

No files matched your search

@@ -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": "[email protected]",
"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 <[email protected]>",
"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: {},
},
}
@@ -0,0 +1 @@
/*! tailwindcss v3.4.10 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e4e4e7;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{-webkit-text-size-adjust:100%;font-feature-settings:normal;-webkit-tap-highlight-color:transparent;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variation-settings:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-feature-settings:normal;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{font-feature-settings:inherit;color:inherit;font-family:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#a1a1aa;opacity:1}input::placeholder,textarea::placeholder{color:#a1a1aa;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]{display:none}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{Line truncated
@@ -0,0 +1,2 @@
/*! For license information please see app.js.LICENSE.txt */
(()=>{var e,t={267:(e,t,n)=>{"use strict";var r={};function o(e,t){const n=new Set(e.split(","));return t?e=>n.has(e.toLowerCase()):e=>n.has(e)}n.r(r),n.d(r,{hasBrowserEnv:()=>ps,hasStandardBrowserEnv:()=>vs,hasStandardBrowserWebWorkerEnv:()=>gs,navigator:()=>hs,origin:()=>ys});const i={},a=[],l=()=>{},s=()=>!1,u=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),c=e=>e.startsWith("onUpdate:"),f=Object.assign,d=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},p=Object.prototype.hasOwnProperty,h=(e,t)=>p.call(e,t),v=Array.isArray,g=e=>"[object Map]"===k(e),y=e=>"[object Set]"===k(e),m=e=>"[object Date]"===k(e),b=e=>"function"==typeof e,w=e=>"string"==typeof e,C=e=>"symbol"==typeof e,_=e=>null!==e&&"object"==typeof e,x=e=>(_(e)||b(e))&&b(e.then)&&b(e.catch),O=Object.prototype.toString,k=e=>O.call(e),S=e=>k(e).slice(8,-1),E=e=>"[object Object]"===k(e),L=e=>w(e)&&"NaN"!==e&&"-"!==e[0]&&""+parseInt(e,10)===e,P=o(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),A=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},j=/-(\w)/g,T=A((e=>e.replace(j,((e,t)=>t?t.toUpperCase():"")))),R=/\B([A-Z])/g,F=A((e=>e.replace(R,"-$1").toLowerCase())),I=A((e=>e.charAt(0).toUpperCase()+e.slice(1))),M=A((e=>e?`on${I(e)}`:"")),D=(e,t)=>!Object.is(e,t),B=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},N=(e,t,n,r=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},U=e=>{const t=parseFloat(e);return isNaN(t)?e:t},V=e=>{const t=w(e)?Number(e):NaN;return isNaN(t)?e:t};let H;const q=()=>H||(H="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:void 0!==n.g?n.g:{});function $(e){if(v(e)){const t={};for(let n=0;n<e.length;n++){const r=e[n],o=w(r)?K(r):$(r);if(o)for(const e in o)t[e]=o[e]}return t}if(w(e)||_(e))return e}const z=/;(?![^(]*\))/g,W=/:([^]+)/,Z=/\/\*[^]*?\*\//g;function K(e){const t={};return e.replace(Z,"").split(z).forEach((e=>{if(e){const n=e.split(W);n.length>1&&(t[n[0].trim()]=n[1].trim())}})),t}function Y(e){let t="";if(w(e))t=e;else if(v(e))for(let n=0;n<e.length;n++){const r=Y(e[n]);r&&(t+=r+" ")}else if(_(e))for(const n in e)e[n]&&(t+=n+" ");return t.trim()}const G="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",J=o(G);function Q(e){return!!e||""===e}function X(e,t){if(e===t)return!0;let n=m(e),r=m(t);if(n||r)return!(!n||!r)&&e.getTime()===t.getTime();if(n=C(e),r=C(t),n||r)return e===t;if(n=v(e),r=v(t),n||r)return!(!n||!r)&&function(e,t){if(e.length!==t.length)return!1;let n=!0;for(let r=0;n&&r<e.length;r++)n=X(e[r],t[r]);return n}(e,t);if(n=_(e),r=_(t),n||r){if(!n||!r)return!1;if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e){const r=e.hasOwnProperty(n),o=t.hasOwnProperty(n);if(r&&!o||!r&&o||!X(e[n],t[n]))return!1}}return String(e)===String(t)}function ee(e,t){return e.findIndex((e=>X(e,t)))}const te=e=>!(!e||!0!==e.__v_isRef),ne=e=>w(e)?e:null==e?"":v(e)||_(e)&&(e.toString===O||!b(e.toString))?te(e)?ne(e.value):JSON.stringify(e,re,2):String(e),re=(e,t)=>te(t)?re(e,t.value):g(t)?{[`Map(${t.size})`]:[...t.entries()].reduce(((e,[t,n],r)=>(e[oe(t,r)+" =>"]=n,e)),{})}:y(t)?{[`Set(${t.size})`]:[...t.values()].map((e=>oe(e)))}:C(t)?oe(t):!_(t)||v(t)||E(t)?t:String(t),oe=(e,t="")=>{var n;return C(e)?`Symbol(${null!=(n=e.description)?n:t})`:e};let ie,ae;class le{constructor(e=!1){this.detached=e,this._active=!0,this.effects=[],this.cleanups=[],this.parent=ie,!e&&ie&&(this.index=(ie.scopes||(ie.scopes=[])).push(this)-1)}get active(){return this._active}run(e){if(this._active){const t=ie;try{return ie=this,e()}finally{ie=t}}else 0}on(){ie=this}off(){ie=this.parent}stop(e){if(this._active){let t,n;for(t=0,n=this.effects.length;t<n;t++)this.effects[t].stop();for(t=0,n=this.cleanups.length;t<n;t++)this.cleanups[t]();if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].stop(!0);if(!this.detached&&this.parent&&!e){const e=this.parent.scopes.pop();e&&e!==this&&(this.parent.scopes[this.index]=e,e.index=this.index)}this.parent=void 0,this._active=!1}}}function se(e){return new le(e)}function ue(e,t=ie){t&&t.active&&t.effects.push(e)}function ce(){return ie}function fe(e){ie&&ie.cleanups.push(e)}class de{constructor(e,t,n,r){this.fn=e,this.trigger=t,this.scheduler=n,this.active=!0,this.deps=[],this._dirtyLevel=4,this._trackId=0,this._runnings=0,this._shouldSchedule=!1,this._depsLength=0,ue(this,r)}get dirty(){if(2===this._dirtyLevel||3===this._dirtyLevel){this._dirtyLevel=1,we();for(let e=0;e<this._depsLength;e++){const t=this.deps[e];if(t.computed&&(pe(t.computed),this._dirtyLevel>=4))break}1===this._dirtyLevel&&(this._dirtyLevel=0),Ce()}return this._dirtyLevel>=4}set dirty(e){this._dirtyLevel=e?4:0}run(){if(this._dirtyLevel=0,!this.active)return this.fn();let e=ye,t=ae;try{return ye=!0,ae=this,this._runnings++,he(this),this.fn()}finally{ve(this),Line truncated
@@ -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>
@@ -0,0 +1,39 @@
<template>
<svg width="884" height="1279" viewBox="0 0 884 1279" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M791.109 297.518L790.231 297.002L788.201 296.383C789.018 297.072 790.04 297.472 791.109 297.518V297.518Z"
fill="currentColor" />
<path d="M803.896 388.891L802.916 389.166L803.896 388.891Z" fill="currentColor" />
<path
d="M791.484 297.377C791.359 297.361 791.237 297.332 791.118 297.29C791.111 297.371 791.111 297.453 791.118 297.534C791.252 297.516 791.379 297.462 791.484 297.377V297.377Z"
fill="currentColor" />
<path d="M791.113 297.529H791.244V297.447L791.113 297.529Z" fill="currentColor" />
<path
d="M803.111 388.726L804.591 387.883L805.142 387.573L805.641 387.04C804.702 387.444 803.846 388.016 803.111 388.726V388.726Z"
fill="currentColor" />
<path d="M793.669 299.515L792.223 298.138L791.243 297.605C791.77 298.535 792.641 299.221 793.669 299.515V299.515Z"
fill="currentColor" />
<path
d="M430.019 1186.18C428.864 1186.68 427.852 1187.46 427.076 1188.45L427.988 1187.87C428.608 1187.3 429.485 1186.63 430.019 1186.18Z"
fill="currentColor" />
<path
d="M641.187 1144.63C641.187 1143.33 640.551 1143.57 640.705 1148.21C640.705 1147.84 640.86 1147.46 640.929 1147.1C641.015 1146.27 641.084 1145.46 641.187 1144.63Z"
fill="currentColor" />
<path
d="M619.284 1186.18C618.129 1186.68 617.118 1187.46 616.342 1188.45L617.254 1187.87C617.873 1187.3 618.751 1186.63 619.284 1186.18Z"
fill="currentColor" />
<path
d="M281.304 1196.06C280.427 1195.3 279.354 1194.8 278.207 1194.61C279.136 1195.06 280.065 1195.51 280.684 1195.85L281.304 1196.06Z"
fill="currentColor" />
<path
d="M247.841 1164.01C247.704 1162.66 247.288 1161.35 246.619 1160.16C247.093 1161.39 247.489 1162.66 247.806 1163.94L247.841 1164.01Z"
fill="currentColor" />
<path
d="M472.623 590.836C426.682 610.503 374.546 632.802 306.976 632.802C278.71 632.746 250.58 628.868 223.353 621.274L270.086 1101.08C271.74 1121.13 280.876 1139.83 295.679 1153.46C310.482 1167.09 329.87 1174.65 349.992 1174.65C349.992 1174.65 416.254 1178.09 438.365 1178.09C462.161 1178.09 533.516 1174.65 533.516 1174.65C553.636 1174.65 573.019 1167.08 587.819 1153.45C602.619 1139.82 611.752 1121.13 613.406 1101.08L663.459 570.876C641.091 563.237 618.516 558.161 593.068 558.161C549.054 558.144 513.591 573.303 472.623 590.836Z"
fill="#FFDD00" />
<path d="M78.6885 386.132L79.4799 386.872L79.9962 387.182C79.5987 386.787 79.1603 386.435 78.6885 386.132V386.132Z"
fill="currentColor" />
<path
d="M879.567 341.849L872.53 306.352C866.215 274.503 851.882 244.409 819.19 232.898C808.711 229.215 796.821 227.633 788.786 220.01C780.751 212.388 778.376 200.55 776.518 189.572C773.076 169.423 769.842 149.257 766.314 129.143C763.269 111.85 760.86 92.4243 752.928 76.56C742.604 55.2584 721.182 42.8009 699.88 34.559C688.965 30.4844 677.826 27.0375 666.517 24.2352C613.297 10.1947 557.342 5.03277 502.591 2.09047C436.875 -1.53577 370.983 -0.443234 305.422 5.35968C256.625 9.79894 205.229 15.1674 158.858 32.0469C141.91 38.224 124.445 45.6399 111.558 58.7341C95.7448 74.8221 90.5829 99.7026 102.128 119.765C110.336 134.012 124.239 144.078 138.985 150.737C158.192 159.317 178.251 165.846 198.829 170.215C256.126 182.879 315.471 187.851 374.007 189.968C438.887 192.586 503.87 190.464 568.44 183.618C584.408 181.863 600.347 179.758 616.257 177.304C634.995 174.43 647.022 149.928 641.499 132.859C634.891 112.453 617.134 104.538 597.055 107.618C594.095 108.082 591.153 108.512 588.193 108.942L586.06 109.252C579.257 110.113 572.455 110.915 565.653 111.661C551.601 113.175 537.515 114.414 523.394 115.378C491.768 117.58 460.057 118.595 428.363 118.647C397.219 118.647 366.058 117.769 334.983 115.722C320.805 114.793 306.661 113.611 292.552 112.177C286.134 111.506 279.733 110.801 273.333 110.009L267.241 109.235L265.917 109.046L259.602 108.134C246.697 106.189 233.792 103.953 221.025 101.251C219.737 100.965 218.584 100.249 217.758 99.2193C216.932 98.1901 216.482 96.9099 216.482 95.5903C216.482 94.2706 216.932 92.9904 217.758 91.9612C218.584 90.9319 219.737 90.2152 221.025 89.9293H221.266C232.33 87.5721 243.479 85.5589 254.663 83.8038C258.392 83.2188 262.131 82.6453 265.882 82.0832H265.985C272.988 81.6186 280.026 80.3625 286.994 79.5366C347.624 73.2302 408.614 71.0801 469.538 73.1014C499.115 73.9618 528.676 75.6996 558.116 78.6935C564.448 79.3474 570.746 80.0357 577.043 80.8099C579.452 81.1025 581.878 81.4465 584.305 81.7391L589.191 82.4445C603.438 84.5667 617.61 87.1419 631.708 90.1703C652.597 94.7128 679.422 96.1925 688.713 119.077C691.673 126.338 693.015 134.408 694.649 142.03L696.731 151.752C696.786 151.926 696.826 152.105 696.852 152.285C701.773 175.227 706.7 198.169 711.632 221.111C711.994 222.806 712.002 224.557 711.657 226.255C711.312 227.954 710.621 229.562 709.626 230.982C708.632 232.401 707.355 233.6 705.877 234.504C704.398 235.408 702.75 235.997 701.033 236.236H700.895L697.884 236.649L694.908 237.044C685.478 238.272 676.038 239.419 666.586 240.486C647.968 242.608 629.322 244.443 610.648 245.992C573.539 249.077 536.356 251.102 499.098 252.066C480.114 252.57 461.135 252.806 442.162 252.771C366.643 252.712 291.189 248.322 216.173 239.625C208.051 238.662 199.93 237.629 191.808 236.58C198.106 237.389 187.231 235.96 185.029 235.651C179.867 234.928 174.705 234.177 169.543 233.397C152.216 230.798 134.993 227.598 117.7 224.793C96.7944 221.352 76.8005 223.073 57.8906 233.397C42.3685 241.891 29.8055 254.916 21.8776 270.735C13.7217 287.597 11.2956 305.956 7.64786 324.075C4.00009 342.193 -1.67805 361.688 0.472751 380.288C5.10128 420.431 33.165 453.054 73.5313 460.35C111.506 467.232 149.687 472.807 187.971 477.556C338.361 495.975 490.294 498.178 641.155 484.129C653.44 482.982 665.708 481.732 677.959 480.378C681.786 479.958 685.658 480.398 689.292 481.668C692.926 482.938 696.23 485.005 698.962 487.717C701.694 490.429 703.784 493.718 705.08 497.342C706.377 500.967 706.846 504.836 706.453 508.665L702.633 545.797C694.936 620.828 687.239 695.854 679.542 770.874C671.513 849.657 663.431 928.434 655.298 1007.2C653.004 1029.39 650.71 1051.57 648.416 1073.74C646.213 1095.58 645.904 1118.1 641.757 1139.68C635.218 1173.61 612.248 1194.45 578.73 1202.07C548.022 1209.06 516.652 1212.73 485.161 1213.01C450.249 1213.2 415.355 1211.65 380.443 1211.84C343.173 1212.05 297.525 1208.61 268.756 1180.87C243.479 1156.51 239.986 1118.36 236.545 1085.37C231.957 1041.7 227.409 998.039 222.9 954.381L197.607 711.615L181.244 554.538C180.968 551.94 180.693 549.376 180.435 546.76C178.473 528.023 165.207 509.681 144.301 510.627C126.407 511.418 106.069 526.629 108.168 546.76L120.298 663.214L145.385 904.104C152.532 972.528 159.661 1040.96 166.773 1109.41C168.15 1122.52 169.44 1135.67 170.885 1148.78C178.749 1220.43 233.465 1259.04 301.224 1269.91C340.799 1276.28 381.337 1277.59 421.497 1278.24C472.979 1279.07 524.977 1281.05 575.615 1271.72C650.653 1257.95 706.952 1207.85 714.987 1130.13C717.282 1107.69 719.576 1085.25 721.87 1062.8C729.498 988.559 737.115 914.313 744.72 840.061L769.601 597.451L781.009 486.263C781.577 480.749 783.905 475.565 787.649 471.478C791.392 467.391 796.352 464.617 801.794 463.567C823.25 459.386 843.761 452.245 859.023 435.916C883.318 409.918 888.153 376.021 879.567 341.849ZM72.4301 365.835C72.757 365.68 72.1548 368.484 71.8967 369.792C71.8451 367.813 71.9483 366.058 72.4301 365.835ZM74.5121 381.94C74.6842 381.819 75.2003 382.508 75.7337 383.334C74.925 382.576 74.4089 382.009 74.4949 381.94H74.5121ZM76.5597 384.641C77.2996 385.897 77.6953 386.689Line truncated
fill="currentColor" />
</svg>
</template>
@@ -0,0 +1,54 @@
<template>
<svg width="4169" height="913" viewBox="0 0 4169 913" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M564.724 212.38L564.098 212.012L562.648 211.569C563.232 212.062 563.962 212.347 564.724 212.38V212.38Z"
fill="currentColor" />
<path d="M573.852 277.606L573.152 277.802L573.852 277.606Z" fill="currentColor" />
<path
d="M564.992 212.279C564.903 212.268 564.815 212.247 564.731 212.217C564.726 212.275 564.726 212.333 564.731 212.391C564.827 212.379 564.917 212.34 564.992 212.279V212.279Z"
fill="currentColor" />
<path d="M564.727 212.388H564.821V212.33L564.727 212.388Z" fill="currentColor" />
<path
d="M573.292 277.488L574.348 276.886L574.741 276.665L575.098 276.284C574.428 276.573 573.816 276.981 573.292 277.488V277.488Z"
fill="currentColor" />
<path d="M566.552 213.805L565.52 212.823L564.82 212.442C565.196 213.106 565.818 213.595 566.552 213.805V213.805Z"
fill="currentColor" />
<path
d="M306.964 846.743C306.14 847.099 305.418 847.657 304.864 848.364L305.515 847.946C305.957 847.541 306.583 847.062 306.964 846.743Z"
fill="currentColor" />
<path
d="M457.704 817.085C457.704 816.151 457.25 816.323 457.361 819.64C457.361 819.369 457.471 819.099 457.52 818.841C457.582 818.252 457.631 817.674 457.704 817.085Z"
fill="currentColor" />
<path
d="M442.069 846.743C441.245 847.099 440.523 847.657 439.969 848.364L440.62 847.946C441.062 847.541 441.688 847.062 442.069 846.743Z"
fill="currentColor" />
<path
d="M200.806 853.794C200.18 853.25 199.414 852.892 198.595 852.762C199.258 853.082 199.921 853.401 200.363 853.647L200.806 853.794Z"
fill="currentColor" />
<path
d="M176.918 830.918C176.821 829.95 176.524 829.014 176.046 828.167C176.385 829.049 176.668 829.951 176.894 830.869L176.918 830.918Z"
fill="currentColor" />
<path
d="M337.376 421.762C304.582 435.801 267.365 451.719 219.132 451.719C198.954 451.679 178.874 448.91 159.438 443.49L192.798 785.991C193.978 800.306 200.5 813.654 211.067 823.384C221.634 833.114 235.474 838.513 249.838 838.511C249.838 838.511 297.139 840.968 312.922 840.968C329.909 840.968 380.845 838.511 380.845 838.511C395.207 838.51 409.044 833.109 419.608 823.38C430.173 813.65 436.692 800.304 437.873 785.991L473.603 407.514C457.636 402.06 441.521 398.437 423.355 398.437C391.936 398.424 366.621 409.246 337.376 421.762Z"
fill="#FFDD00" />
<path d="M56.1709 275.636L56.7359 276.165L57.1044 276.386C56.8206 276.104 56.5077 275.852 56.1709 275.636V275.636Z"
fill="currentColor" />
<path
d="M627.869 244.025L622.846 218.686C618.338 195.951 608.107 174.469 584.77 166.251C577.289 163.623 568.802 162.493 563.066 157.052C557.33 151.611 555.635 143.16 554.309 135.324C551.852 120.941 549.543 106.546 547.025 92.1872C544.851 79.8431 543.131 65.9761 537.469 54.6515C530.1 39.4456 514.808 30.553 499.602 24.6696C491.81 21.7609 483.858 19.3004 475.786 17.3C437.796 7.27737 397.852 3.59259 358.769 1.49226C311.858 -1.09629 264.822 -0.316398 218.022 3.82595C183.189 6.99487 146.501 10.8271 113.399 22.8763C101.301 27.2858 88.8338 32.5796 79.6341 41.9267C68.3464 53.4109 64.6616 71.1716 72.9033 85.4931C78.7621 95.6632 88.6864 102.848 99.2126 107.602C112.923 113.727 127.242 118.387 141.932 121.506C182.833 130.546 225.196 134.096 266.981 135.606C313.294 137.475 359.682 135.961 405.775 131.074C417.173 129.821 428.551 128.319 439.908 126.566C453.284 124.515 461.87 107.025 457.927 94.8402C453.21 80.273 440.535 74.623 426.201 76.8216C424.088 77.1532 421.988 77.4603 419.875 77.7674L418.352 77.9885C413.496 78.6026 408.641 79.1758 403.785 79.708C393.754 80.7889 383.699 81.6733 373.619 82.3611C351.043 83.9333 328.406 84.6579 305.782 84.6948C283.55 84.6948 261.307 84.0683 239.124 82.6067C229.003 81.9435 218.907 81.1 208.835 80.0765C204.254 79.5975 199.685 79.0939 195.115 78.5289L190.767 77.9762L189.822 77.8411L185.314 77.1901C176.102 75.8022 166.89 74.2054 157.776 72.2771C156.857 72.073 156.034 71.5613 155.444 70.8266C154.855 70.0919 154.533 69.1781 154.533 68.2361C154.533 67.294 154.855 66.3802 155.444 65.6455C156.034 64.9107 156.857 64.3991 157.776 64.1951H157.948C165.846 62.5123 173.805 61.0753 181.789 59.8225C184.45 59.4048 187.119 58.9954 189.797 58.5942H189.871C194.87 58.2626 199.893 57.3659 204.868 56.7763C248.148 52.2745 291.685 50.7397 335.174 52.1827C356.288 52.7968 377.39 54.0373 398.405 56.1745C402.925 56.6413 407.421 57.1326 411.916 57.6853C413.636 57.8941 415.367 58.1397 417.099 58.3485L420.588 58.8521C430.758 60.367 440.874 62.2053 450.938 64.367C465.849 67.6097 484.998 68.6659 491.63 85.0018C493.743 90.1851 494.701 95.9457 495.868 101.387L497.354 108.327C497.393 108.451 497.422 108.578 497.44 108.707C500.953 125.084 504.47 141.461 507.991 157.838C508.249 159.048 508.255 160.298 508.009 161.51C507.762 162.722 507.269 163.871 506.559 164.884C505.849 165.897 504.938 166.753 503.882 167.398C502.827 168.043 501.65 168.464 500.425 168.634H500.326L498.177 168.929L496.052 169.212C489.321 170.088 482.582 170.907 475.835 171.668C462.545 173.183 449.235 174.493 435.904 175.599C409.415 177.801 382.872 179.246 356.276 179.934C342.724 180.295 329.176 180.462 315.633 180.438C261.724 180.395 207.862 177.262 154.312 171.054C148.515 170.366 142.718 169.629 136.92 168.88C141.416 169.457 133.653 168.438 132.081 168.217C128.396 167.701 124.711 167.164 121.027 166.608C108.658 164.753 96.3631 162.468 84.019 160.466C69.0956 158.01 54.8232 159.238 41.3246 166.608C30.2443 172.671 21.2763 181.969 15.6171 193.261C9.7951 205.298 8.06326 218.403 5.45934 231.337C2.85542 244.271 -1.19786 258.187 0.337468 271.464C3.6415 300.12 23.6745 323.408 52.4895 328.616C79.5973 333.529 106.852 337.508 134.181 340.898C241.535 354.046 349.991 355.619 457.681 345.59C466.451 344.771 475.208 343.879 483.954 342.913C486.685 342.612 489.449 342.927 492.043 343.834C494.637 344.74 496.996 346.215 498.946 348.151C500.896 350.087 502.389 352.435 503.314 355.022C504.239 357.61 504.574 360.372 504.294 363.105L501.567 389.611C496.073 443.172 490.578 496.728 485.084 550.28C479.352 606.518 473.583 662.752 467.777 718.982C466.14 734.818 464.502 750.651 462.864 766.479C461.292 782.066 461.071 798.144 458.111 813.546C453.444 837.767 437.046 852.642 413.12 858.083C391.2 863.071 368.807 865.69 346.327 865.895C321.405 866.03 296.496 864.924 271.575 865.059C244.971 865.207 212.385 862.75 191.848 842.951C173.805 825.558 171.312 798.328 168.855 774.782C165.58 743.609 162.333 712.439 159.115 681.274L141.06 507.979L129.379 395.851C129.182 393.996 128.986 392.166 128.802 390.299C127.401 376.923 117.931 363.83 103.008 364.505C90.2341 365.07 75.716 375.928 77.2145 390.299L85.8737 473.428L103.782 645.385C108.883 694.228 113.972 743.081 119.049 791.941C120.032 801.3 120.953 810.684 121.985 820.043C127.598 871.188 166.657 898.751 215.026 906.513C243.276 911.058 272.213 911.991 300.881 912.458C337.631 913.048 374.749 914.46 410.897 907.803C464.461 897.977 504.65 862.21 510.386 806.729C512.024 790.713 513.661 774.692 515.299 758.667C520.744 705.672 526.181 652.672 531.61 599.669L549.371 426.483L557.514 347.113C557.92 343.178 559.582 339.477 562.254 336.559C564.927 333.642 568.467 331.662 572.352 330.912C587.668 327.928 602.309 322.83 613.204 311.174C630.547 292.615 633.998 268.418 627.869 244.025ZM51.7034 261.147C51.9368 261.036 51.5069 263.039 51.3227 263.972C51.2858 262.56 51.3595 261.307 51.7034 261.147ZM53.1897 272.644C53.3125 272.558 53.6809 273.049 54.0617 273.638C53.4844 273.098 53.116 272.693 53.1774 272.644H53.1897ZM54.6513 274.572C55.1794 275.469 55.4619 276.034 54.651Line truncated
fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M1128.89 528.658C1123.53 538.898 1116.15 547.814 1106.77 555.381C1097.38 562.953 1086.43 569.118 1073.91 573.865C1061.4 578.623 1048.8 581.815 1036.14 583.443C1023.47 585.076 1011.17 584.935 999.256 582.996C987.335 581.069 977.194 577.136 968.858 571.192L978.249 473.648C986.887 470.681 997.758 467.635 1010.88 464.516C1023.99 461.403 1037.47 459.171 1051.33 457.837C1065.19 456.498 1078.3 456.656 1090.68 458.283C1103.03 459.916 1112.8 463.849 1119.95 470.088C1123.82 473.648 1127.11 477.507 1129.79 481.666C1132.47 485.825 1134.11 490.131 1134.71 494.584C1136.19 507.055 1134.26 518.413 1128.89 528.658ZM992.546 320.873C998.808 317.014 1006.33 313.595 1015.12 310.623C1023.91 307.662 1032.93 305.576 1042.17 304.39C1051.4 303.209 1060.42 303.051 1069.22 303.943C1078 304.836 1085.76 307.283 1092.46 311.289C1099.17 315.301 1104.16 321.094 1107.43 328.66C1110.71 336.238 1111.61 345.816 1110.12 357.394C1108.93 366.599 1105.27 374.397 1099.17 380.777C1093.06 387.168 1085.6 392.508 1076.82 396.814C1068.02 401.126 1058.71 404.539 1048.88 407.053C1039.04 409.585 1029.57 411.444 1020.49 412.625C1011.4 413.817 1003.5 414.563 996.8 414.851C990.091 415.151 985.69 415.298 983.609 415.298L992.546 320.873ZM1177.17 465.629C1172.4 455.243 1166 446.112 1157.95 438.234C1149.91 430.369 1140.36 424.656 1129.34 421.09C1134.11 417.23 1138.8 411.145 1143.42 402.827C1148.04 394.52 1151.99 385.456 1155.27 375.658C1158.54 365.853 1160.78 355.987 1161.97 346.036C1163.16 336.091 1162.71 327.552 1160.64 320.421C1155.56 302.61 1147.59 288.652 1136.71 278.554C1125.83 268.462 1113.17 261.483 1098.72 257.618C1084.26 253.77 1068.32 252.945 1050.89 255.171C1033.45 257.398 1015.64 261.777 997.469 268.31C997.469 266.829 997.617 265.269 997.917 263.636C998.206 262.009 998.359 260.297 998.359 258.511C998.359 254.058 996.125 250.204 991.656 246.933C987.187 243.666 982.043 241.74 976.236 241.141C970.423 240.553 964.757 241.807 959.245 244.927C953.727 248.046 949.927 253.77 947.846 262.071C945.458 288.799 943.076 316.567 940.694 345.364C938.307 374.171 935.777 403.273 933.095 432.674C930.412 462.069 927.73 491.244 925.047 520.193C922.365 549.148 919.682 576.984 917 603.706C917.896 611.725 920.131 617.963 923.709 622.416C927.282 626.875 931.456 629.548 936.225 630.435C940.989 631.328 945.986 630.502 951.198 627.982C956.409 625.468 960.958 621.077 964.837 614.844C976.752 621.376 990.165 625.609 1005.07 627.541C1019.97 629.468 1035.09 629.468 1050.44 627.541C1065.78 625.609 1080.91 621.975 1095.81 616.624C1110.71 611.284 1124.27 604.599 1136.49 596.586C1148.71 588.568 1158.99 579.431 1167.34 569.191C1175.68 558.941 1181.19 547.882 1183.88 536.01C1186.56 523.833 1187.3 511.661 1186.11 499.483C1184.92 487.312 1181.94 476.033 1177.17 465.629Z"
fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M1613.27 700.802C1611.63 710.601 1609.31 720.919 1606.34 731.763C1603.35 742.595 1599.85 752.473 1595.83 761.384C1591.81 770.29 1587.34 777.483 1582.43 782.982C1577.5 788.474 1572.21 790.78 1566.55 789.892C1562.08 789.288 1559.25 786.468 1558.06 781.428C1556.87 776.37 1556.87 770.137 1558.06 762.718C1559.25 755.293 1561.56 746.901 1564.99 737.549C1568.41 728.197 1572.59 718.913 1577.5 709.713C1582.43 700.503 1588.01 691.676 1594.27 683.211C1600.53 674.752 1607.08 667.694 1613.94 662.055C1615.43 663.835 1616.1 668.287 1615.95 675.419C1615.79 682.544 1614.9 691.009 1613.27 700.802ZM1764.81 507.494C1760.79 503.041 1755.87 500.521 1750.06 499.922C1744.25 499.329 1738.36 502.595 1732.4 509.721C1728.52 516.846 1723.61 523.678 1717.65 530.21C1711.68 536.748 1705.5 542.682 1699.09 548.027C1692.69 553.367 1686.58 557.978 1680.77 561.832C1674.96 565.697 1670.41 568.517 1667.13 570.297C1665.94 560.798 1665.27 550.553 1665.12 539.562C1664.96 528.577 1665.19 517.445 1665.79 506.161C1666.68 490.124 1668.54 473.946 1671.38 457.609C1674.21 441.278 1678.31 425.241 1683.67 409.498C1683.67 401.192 1681.73 394.433 1677.86 389.235C1673.98 384.041 1669.29 380.77 1663.78 379.436C1658.26 378.102 1652.61 378.843 1646.79 381.662C1640.98 384.488 1635.99 389.613 1631.82 397.027C1628.24 406.831 1624.14 417.816 1619.53 429.988C1614.9 442.165 1609.69 454.563 1603.88 467.182C1598.07 479.811 1591.58 492.056 1584.43 503.929C1577.28 515.812 1569.46 526.357 1560.96 535.556C1552.47 544.761 1543.23 551.966 1533.25 557.159C1523.26 562.358 1512.47 564.658 1500.84 564.064C1495.47 562.578 1491.6 558.572 1489.21 552.034C1486.83 545.507 1485.41 537.336 1484.97 527.538C1484.52 517.739 1484.97 506.974 1486.31 495.243C1487.65 483.518 1489.44 471.86 1491.67 460.282C1493.91 448.698 1496.37 437.713 1499.05 427.321C1501.73 416.929 1504.26 408.165 1506.65 401.039C1510.23 392.433 1510.23 385.222 1506.65 379.436C1503.07 373.644 1498.16 369.79 1491.9 367.852C1485.64 365.925 1479.08 366.004 1472.23 368.078C1465.37 370.157 1460.45 374.757 1457.48 381.883C1452.41 394.066 1447.79 407.718 1443.62 422.862C1439.44 438.007 1436.09 453.676 1433.56 469.854C1431.02 486.044 1429.6 502.081 1429.31 517.965C1429.29 518.999 1429.34 519.982 1429.34 521.011C1422.84 538.274 1416.64 551.322 1410.76 560.052C1403.16 571.343 1394.59 576.242 1385.06 574.75C1380.88 572.97 1378.13 568.817 1376.79 562.279C1375.44 555.752 1374.99 547.734 1375.44 538.223C1375.9 528.73 1377.01 517.965 1378.79 505.935C1380.59 493.91 1382.82 481.438 1385.5 468.521C1388.19 455.597 1391.02 442.618 1393.99 429.547C1396.97 416.483 1399.65 404.158 1402.05 392.574C1401.75 382.182 1398.69 374.243 1392.88 368.745C1387.07 363.258 1378.94 361.105 1368.52 362.286C1361.37 365.258 1356.07 369.123 1352.64 373.87C1349.21 378.617 1346.46 384.708 1344.38 392.128C1343.18 395.993 1341.39 403.712 1339.01 415.296C1336.62 426.869 1333.57 440.165 1329.85 455.157C1326.12 470.159 1321.73 485.818 1316.66 502.149C1311.59 518.479 1305.93 533.262 1299.68 546.468C1293.41 559.679 1286.56 570.297 1279.11 578.316C1271.66 586.328 1263.61 589.6 1254.97 588.114C1250.2 587.221 1247.15 582.322 1245.81 573.416C1244.47 564.505 1244.24 553.526 1245.14 540.455C1246.03 527.391 1247.82 513.06 1250.5 497.475C1253.18 481.885 1255.93 467.114 1258.77 453.151C1261.6 439.199 1264.21 426.869 1266.6 416.183C1268.98 405.492 1270.62 398.366 1271.51 394.806C1271.51 386.194 1269.57 379.295 1265.7 374.09C1261.82 368.903 1257.13 365.631 1251.62 364.292C1246.1 362.958 1240.44 363.699 1234.63 366.518C1228.82 369.344 1223.83 374.469 1219.65 381.883C1218.16 389.901 1216.22 399.186 1213.84 409.724C1211.45 420.263 1209.15 431.101 1206.92 442.239C1204.68 453.377 1202.59 464.288 1200.66 474.98C1198.72 485.671 1197.3 495.023 1196.41 503.041C1195.81 509.274 1195.14 516.925 1194.4 525.978C1193.65 535.042 1193.28 544.614 1193.28 554.707C1193.28 564.81 1194.02 574.829 1195.52 584.774C1197 594.725 1199.69 603.857 1203.56 612.164C1207.43 620.482 1212.87 627.308 1219.88 632.654C1226.88 637.999 1235.75 640.966 1246.48 641.565C1257.5 642.153 1267.11 641.344 1275.31 639.112C1283.51 636.886 1290.95 633.394 1297.66 628.642C1304.37 623.9 1310.47 618.255 1315.99 611.717C1321.5 605.191 1326.94 598.065 1332.31 590.34C1337.37 601.631 1343.93 610.384 1351.98 616.622C1360.02 622.855 1368.52 626.573 1377.46 627.754C1386.39 628.935 1395.49 627.687 1404.73 623.968C1413.96 620.261 1422.3 613.949 1429.76 605.038C1434.67 599.574 1439.3 593.364 1443.64 586.498C1445.48 589.713 1447.44 592.816 1449.65 595.68C1456.96 605.191 1466.87 611.717 1479.39 615.283C1492.79 618.849 1505.9 619.448 1518.72 617.069C1531.53 614.695 1543.75 610.384 1555.37 604.151C1567 597.913 1577.8 590.42 1587.79 581.655C1597.77 572.896 1606.48 563.77 1613.94 554.26C1613.63 561.092 1613.49 567.556 1613.49 573.637C1613.49 579.728 1613.34 586.328 1613.04 593.46C1598.14 603.857 1584.06 616.328 1570.8 630.874C1557.53 645.419 1545.91 660.936 1535.93 677.419C1525.95 693.897 1518.12 710.601 1512.47 727.525C1506.8 744.46 1504.Line truncated
fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M2328.72 478.992C2329.61 472.166 2330.95 464.961 2332.74 457.389C2334.52 449.817 2336.76 442.466 2339.45 435.34C2342.12 428.214 2345.25 422.055 2348.83 416.856C2352.41 411.663 2356.21 407.951 2360.23 405.719C2364.26 403.498 2368.35 403.718 2372.53 406.385C2377 409.064 2379.75 414.703 2380.8 423.309C2381.83 431.933 2380.8 441.132 2377.67 450.93C2374.53 460.735 2368.95 469.934 2360.9 478.546C2352.86 487.163 2341.98 492.797 2328.27 495.47C2327.67 491.322 2327.82 485.824 2328.72 478.992ZM2483.61 497.25C2478.69 495.775 2474 495.623 2469.53 496.809C2465.06 497.996 2462.22 501.115 2461.04 506.167C2458.64 515.666 2454.85 525.391 2449.63 535.336C2444.42 545.287 2438.01 554.713 2430.42 563.624C2422.82 572.53 2414.24 580.401 2404.71 587.227C2395.17 594.059 2385.19 598.959 2374.76 601.925C2364.32 605.197 2355.68 605.564 2348.83 603.038C2341.98 600.524 2336.54 596.212 2332.52 590.126C2328.5 584.04 2325.59 576.689 2323.8 568.077C2322.01 559.465 2320.96 550.56 2320.67 541.349C2337.65 542.541 2352.78 539.501 2366.05 532.217C2379.3 524.95 2390.55 515.293 2399.79 503.268C2409.03 491.243 2416.03 477.732 2420.8 462.735C2425.56 447.743 2428.25 432.82 2428.85 417.969C2429.14 404.012 2427.06 392.213 2422.59 382.562C2418.12 372.916 2412.08 365.406 2404.48 360.066C2396.89 354.72 2388.17 351.601 2378.33 350.714C2368.51 349.821 2358.51 351.16 2348.38 354.72C2336.16 358.879 2325.81 365.632 2317.32 374.99C2308.82 384.342 2301.74 395.185 2296.08 407.504C2290.42 419.829 2285.95 433.114 2282.67 447.365C2279.39 461.622 2277.01 475.653 2275.52 489.463C2274.18 501.855 2273.46 513.705 2273.21 525.142C2272.57 526.595 2271.95 527.99 2271.27 529.544C2266.65 540.094 2261.28 550.413 2255.18 560.505C2249.07 570.603 2242.29 579.068 2234.84 585.894C2227.39 592.726 2219.64 595.099 2211.59 593.02C2206.83 591.839 2204.22 586.335 2203.77 576.542C2203.33 566.738 2203.84 554.566 2205.33 540.015C2206.83 525.47 2208.54 509.721 2210.48 492.797C2212.41 475.873 2213.38 459.695 2213.38 444.251C2213.38 430.887 2210.85 418.049 2205.78 405.719C2200.71 393.405 2193.78 383.155 2185 374.99C2176.2 366.818 2166 361.399 2154.38 358.732C2142.75 356.054 2130.24 357.839 2116.83 364.072C2103.41 370.311 2092.76 379.069 2084.87 390.354C2076.96 401.644 2069.73 413.517 2063.18 425.988C2060.79 416.483 2057.3 407.657 2052.68 399.486C2048.05 391.32 2042.4 384.195 2035.69 378.103C2028.98 372.023 2021.45 367.271 2013.12 363.852C2004.77 360.444 1995.97 358.732 1986.74 358.732C1977.8 358.732 1969.53 360.444 1961.93 363.852C1954.33 367.271 1947.4 371.644 1941.14 376.99C1934.88 382.341 1929.22 388.348 1924.15 395.033C1919.09 401.712 1914.61 408.324 1910.75 414.85C1910.14 407.131 1909.47 400.379 1908.73 394.581C1907.99 388.794 1906.64 383.895 1904.71 379.889C1902.77 375.877 1900.02 372.837 1896.44 370.757C1892.86 368.683 1887.8 367.638 1881.25 367.638C1877.96 367.638 1874.68 368.305 1871.41 369.638C1868.12 370.977 1865.21 372.837 1862.69 375.21C1860.15 377.595 1858.22 380.482 1856.88 383.895C1855.53 387.308 1855.17 391.247 1855.76 395.7C1856.05 398.971 1856.88 402.899 1858.22 407.504C1859.56 412.11 1860.82 418.128 1862.02 425.541C1863.21 432.967 1864.18 441.951 1864.92 452.49C1865.67 463.034 1865.89 475.952 1865.59 491.243C1865.29 506.54 1864.18 524.425 1862.24 544.914C1860.3 565.404 1857.24 589.16 1853.08 616.177C1852.48 622.415 1854.86 627.467 1860.23 631.326C1865.59 635.18 1871.7 637.406 1878.56 638.005C1885.41 638.599 1891.9 637.406 1898 634.445C1904.11 631.468 1907.62 626.274 1908.51 618.855C1909.4 604.898 1911.12 590.053 1913.65 574.31C1916.18 558.578 1919.31 542.987 1923.04 527.544C1926.76 512.106 1931 497.408 1935.78 483.445C1940.54 469.488 1945.84 457.169 1951.65 446.478C1957.46 435.786 1963.5 427.254 1969.75 420.868C1976.01 414.483 1982.56 411.29 1989.42 411.29C1997.77 411.29 2004.24 415.071 2008.87 422.643C2013.48 430.22 2016.76 439.951 2018.7 451.818C2020.64 463.701 2021.53 476.698 2021.39 490.797C2021.23 504.901 2020.64 518.486 2019.6 531.55C2018.55 544.621 2017.36 556.272 2016.02 566.517C2014.68 576.762 2013.71 583.82 2013.12 587.674C2013.12 594.506 2015.72 599.919 2020.93 603.931C2026.15 607.938 2031.96 610.317 2038.37 611.057C2044.78 611.803 2050.81 610.61 2056.48 607.491C2062.14 604.372 2065.41 599.111 2066.31 591.68C2069.29 570.303 2073.39 548.853 2078.6 527.323C2083.81 505.794 2089.78 486.497 2096.49 469.42C2103.19 452.343 2110.64 438.386 2118.84 427.548C2127.03 416.715 2135.75 411.29 2144.99 411.29C2149.75 411.29 2153.41 414.562 2155.94 421.089C2158.47 427.621 2159.74 436.527 2159.74 447.811C2159.74 456.129 2159.07 464.668 2157.73 473.426C2156.39 482.185 2154.83 491.243 2153.03 500.595C2151.25 509.953 2149.68 519.525 2148.34 529.324C2147 539.128 2146.33 549.367 2146.33 560.058C2146.33 567.484 2147.07 576.095 2148.56 585.894C2150.05 595.687 2152.88 604.977 2157.05 613.73C2161.23 622.494 2166.96 629.914 2174.27 635.999C2181.57 642.085 2190.88 645.131 2202.2 645.131C2219.19 645.131 2234.24 641.492 2247.36 634.219C2260.47 626.947 2271.72 617.448 2281.11 605.7Line truncated
fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M2726.57 447.574C2725.07 456.338 2723.06 465.995 2720.53 476.528C2717.99 487.073 2714.79 497.984 2710.92 509.269C2707.04 520.554 2702.28 530.725 2696.62 539.778C2690.95 548.841 2684.47 556.035 2677.17 561.381C2669.86 566.726 2661.45 568.953 2651.91 568.06C2647.14 567.472 2643.49 564.206 2640.96 558.261C2638.42 552.328 2637.08 544.75 2636.93 535.545C2636.78 526.346 2637.6 516.248 2639.39 505.257C2641.18 494.277 2643.94 483.507 2647.67 472.963C2651.39 462.43 2655.94 452.699 2661.29 443.788C2666.66 434.882 2672.84 427.977 2679.85 423.078C2686.85 418.178 2694.53 415.884 2702.87 416.172C2711.21 416.472 2720.15 420.625 2729.7 428.644C2729.09 432.509 2728.06 438.821 2726.57 447.574ZM2885.48 481.648C2880.86 479.275 2876.09 478.76 2871.18 480.094C2866.26 481.428 2862.75 485.96 2860.67 493.678C2859.48 501.996 2856.8 511.789 2852.63 523.074C2848.45 534.359 2843.31 545.055 2837.21 555.142C2831.09 565.24 2824.09 573.631 2816.19 580.311C2808.29 586.996 2799.88 590.041 2790.94 589.437C2783.48 588.849 2778.26 585.063 2775.29 578.084C2772.3 571.106 2770.74 562.353 2770.6 551.802C2770.44 541.269 2771.49 529.391 2773.72 516.174C2775.96 502.963 2778.64 489.825 2781.77 476.749C2784.9 463.69 2788.1 451.139 2791.39 439.114C2794.66 427.09 2797.34 416.918 2799.43 408.606C2801.82 401.181 2801.07 394.874 2797.19 389.67C2793.32 384.483 2788.48 380.764 2782.67 378.538C2776.85 376.312 2770.97 375.718 2765 376.758C2759.04 377.798 2755.18 380.99 2753.38 386.33C2735.8 371.186 2718.89 363.021 2702.65 361.834C2686.4 360.648 2671.42 364.213 2657.72 372.526C2644.01 380.838 2631.87 392.863 2621.29 408.606C2610.71 424.349 2602.14 441.493 2595.58 460.051C2589.03 478.608 2584.93 497.544 2583.29 516.841C2581.65 536.144 2582.91 553.741 2587.09 569.625C2591.27 585.509 2598.63 598.506 2609.22 608.593C2619.79 618.691 2634.18 623.743 2652.36 623.743C2660.4 623.743 2668.15 622.11 2675.6 618.843C2683.05 615.572 2689.91 611.712 2696.17 607.259C2702.42 602.807 2707.94 598.128 2712.71 593.228C2717.47 588.329 2721.2 584.249 2723.88 580.978C2725.96 591.669 2729.4 600.733 2734.16 608.152C2738.93 615.572 2744.37 621.669 2750.48 626.41C2756.58 631.157 2763 634.649 2769.7 636.881C2776.41 639.107 2782.89 640.22 2789.15 640.22C2803.16 640.22 2816.26 635.468 2828.49 625.963C2840.7 616.47 2851.66 604.807 2861.35 591.002C2871.03 577.191 2879 562.646 2885.26 547.35C2891.52 532.059 2895.69 518.474 2897.77 506.591C2899.86 502.138 2899.49 497.465 2896.66 492.565C2893.82 487.666 2890.1 484.033 2885.48 481.648Z"
fill="currentColor" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M3690.14 727.546C3687.61 737.639 3683.66 746.183 3678.3 753.156C3672.94 760.135 3666.23 763.774 3658.18 764.073C3653.12 764.367 3649.24 761.694 3646.56 756.055C3643.88 750.41 3642.02 743.137 3640.97 734.231C3639.92 725.32 3639.48 715.369 3639.63 704.384C3639.78 693.399 3640.23 682.561 3640.97 671.869C3641.71 661.184 3642.61 651.227 3643.66 642.027C3644.69 632.817 3645.51 625.702 3646.11 620.651C3656.24 621.837 3664.67 626.211 3671.37 633.789C3678.08 641.361 3683.21 650.34 3686.79 660.737C3690.37 671.129 3692.45 682.34 3693.05 694.365C3693.64 706.39 3692.67 717.449 3690.14 727.546ZM3528.32 727.546C3525.79 737.639 3521.84 746.183 3516.47 753.156C3511.11 760.135 3504.41 763.774 3496.36 764.073C3491.29 764.367 3487.42 761.694 3484.74 756.055C3482.05 750.41 3480.19 743.137 3479.15 734.231C3478.1 725.32 3477.65 715.369 3477.81 704.384C3477.95 693.399 3478.4 682.561 3479.15 671.869C3479.89 661.184 3480.78 651.227 3481.83 642.027C3482.87 632.817 3483.69 625.702 3484.29 620.651C3494.42 621.837 3502.85 626.211 3509.54 633.789C3516.25 641.361 3521.39 650.34 3524.97 660.737C3528.55 671.129 3530.63 682.34 3531.23 694.365C3531.82 706.39 3530.85 717.449 3528.32 727.546ZM3362.26 474.555C3361.95 481.675 3361.28 487.987 3360.24 493.48C3359.19 498.983 3357.78 502.464 3355.99 503.95C3352.71 502.165 3348.68 497.571 3343.92 490.14C3339.15 482.72 3335.43 474.323 3332.74 464.971C3330.07 455.619 3329.24 446.42 3330.29 437.356C3331.32 428.303 3336.03 421.257 3344.37 416.199C3347.64 414.419 3350.47 415.086 3352.86 418.205C3355.25 421.325 3357.18 425.851 3358.68 431.79C3360.16 437.734 3361.2 444.561 3361.8 452.28C3362.4 460.004 3362.54 467.424 3362.26 474.555ZM3322.69 563.414C3318.07 568.307 3312.92 572.54 3307.27 576.105C3301.6 579.671 3295.79 582.344 3289.83 584.124C3283.87 585.909 3278.5 586.35 3273.74 585.457C3260.33 582.79 3250.04 576.993 3242.9 568.087C3235.74 559.181 3231.19 548.716 3229.26 536.691C3227.32 524.661 3227.54 511.816 3229.93 498.158C3232.31 484.5 3236.33 471.809 3242 460.078C3247.66 448.347 3254.52 438.249 3262.56 429.789C3270.61 421.325 3279.39 416.058 3288.94 413.973C3285.36 429.117 3284.17 444.787 3285.36 460.965C3286.55 477.149 3290.43 492.366 3296.99 506.618C3301.15 515.241 3306.15 523.101 3311.96 530.227C3317.77 537.358 3324.85 543.444 3333.2 548.49C3330.81 553.542 3327.3 558.514 3322.69 563.414ZM3817.33 479.008C3818.22 472.182 3819.56 464.971 3821.35 457.399C3823.13 449.833 3825.37 442.481 3828.05 435.355C3830.73 428.224 3833.86 422.065 3837.44 416.872C3841.02 411.679 3844.82 407.96 3848.84 405.734C3852.86 403.508 3856.96 403.728 3861.13 406.401C3865.6 409.079 3868.36 414.719 3869.4 423.325C3870.44 431.942 3869.4 441.142 3866.27 450.946C3863.14 460.744 3857.56 469.95 3849.51 478.556C3841.47 487.179 3830.59 492.813 3816.88 495.486C3816.28 491.332 3816.43 485.84 3817.33 479.008ZM3997.48 479.008C3998.37 472.182 3999.71 464.971 4001.5 457.399C4003.29 449.833 4005.53 442.481 4008.21 435.355C4010.89 428.224 4014.02 422.065 4017.59 416.872C4021.17 411.679 4024.97 407.96 4028.99 405.734C4033.02 403.508 4037.12 403.728 4041.29 406.401C4045.76 409.079 4048.51 414.719 4049.56 423.325C4050.6 431.942 4049.56 441.142 4046.43 450.946C4043.3 460.744 4037.71 469.95 4029.66 478.556C4021.62 487.179 4010.74 492.813 3997.03 495.486C3996.44 491.332 3996.58 485.84 3997.48 479.008ZM4164.89 505.064C4161.46 501.357 4157.29 498.757 4152.38 497.266C4147.46 495.785 4142.77 495.638 4138.29 496.825C4133.82 498.011 4130.99 501.131 4129.8 506.177C4127.41 515.681 4123.61 525.406 4118.4 535.346C4113.18 545.303 4106.78 554.728 4099.18 563.634C4091.58 572.54 4083.01 580.417 4073.48 587.243C4063.93 594.075 4053.95 598.974 4043.53 601.935C4033.09 605.213 4024.45 605.58 4017.59 603.054C4010.74 600.534 4005.3 596.228 4001.28 590.142C3997.26 584.05 3994.35 576.704 3992.56 568.087C3990.78 559.481 3989.73 550.575 3989.43 541.364C4006.42 542.557 4021.55 539.516 4034.81 532.233C4048.06 524.96 4059.32 515.303 4068.56 503.278C4077.79 491.259 4084.8 477.748 4089.57 462.751C4094.33 447.753 4097.01 432.835 4097.62 417.985C4097.9 404.028 4095.82 392.223 4091.35 382.571C4086.89 372.926 4080.85 365.421 4073.25 360.081C4065.65 354.73 4056.94 351.616 4047.1 350.724C4037.27 349.831 4027.28 351.176 4017.15 354.73C4004.93 358.895 3994.58 365.647 3986.09 374.999C3977.59 384.357 3970.51 395.201 3964.85 407.514C3959.18 419.844 3954.71 433.123 3951.43 447.38C3948.15 461.632 3945.77 475.668 3944.29 489.473C3942.84 502.871 3942.05 515.693 3941.87 527.966C3940.74 530.413 3939.54 532.871 3938.24 535.346C3933.03 545.303 3926.62 554.728 3919.02 563.634C3911.43 572.54 3902.85 580.417 3893.32 587.243C3883.78 594.075 3873.8 598.974 3863.37 601.935C3852.93 605.213 3844.29 605.58 3837.44 603.054C3830.59 600.534 3825.15 596.228 3821.13 590.142C3817.11 584.05 3814.2 576.704 3812.41 568.087C3810.62 559.481 3809.57 550.575 3809.28 541.364C3826.26 542.557 3841.39 539.516 3854.65 532.233C3867.91 524.96 3879.16 515.303 3888.4 503.278C3897.64 491.259 3904.64 477.748 3909.41 Line truncated
fill="currentColor" />
</svg>
</template>
@@ -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;
}
},
},
})
Whitespace-only changes.
@@ -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);
}
}
Loaded 100 of 35591 files, more files were not shown because too many files have changed in this diff. Show more