Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ read logs from any directory.

- 📂 **View all the Monolog logs** in your `%kernel.logs_dir%` directory,
- 🔍 **Search** the logs,
- 🎚 **Filter** by log level (error, info, debug, etc.), or by channel.
- 🎚 **Filter** by log level (error, info, debug, etc.), by channel, date range or log content,
- 🌑 **Dark mode**,
- 💾 **Download** or **delete** log files from the UI,
- ☎️ **API access** for folders, files & log entries,
Expand Down
11 changes: 7 additions & 4 deletions docs/advanced-search-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,24 @@ The search query allows for more fine-grained control over the search results. T
|--------------------------------------|-------|---------------------------------------------------------------------------------|
| `before:<date>`,`before:"<date>"` | `b` | Show all logs messages that occur before the specified date. |
| `after:<date>`,`after:"<date>"` | `a` | Show all logs messages that occur after the specified date. |
| `severity:<pipe-separated-string>` | `s` | Show all logs messages that match the given severity/severities. |
| `channel:<pipe-separated-string>` | `c` | Show all logs messages that match the given channel(s). |
| `after:<date>`,`after:"<date>"` | `a` | Show all logs messages that occur after the specified date. |
| `exclude:<word>`,`exclude:"<words>"` | `-` | Exclude the specific sentence from the results. Can be specified multiple times |

## Example

Search all log entries between `2020-01-01` and `2020-01-31`, excluding all entries that contain the word `"Controller"` and must
include `"Exception"`.
Search all log entries between `2020-01-01` and `2020-01-31`, for severity `warning` or `error`, in channel `app`
excluding all entries that contain the word `"Controller"` and must include `"Exception"`.

```text
before:2020-01-31 after:2020-01-01 exclude:Controller "Failed to read"
before:2020-01-31 after:2020-01-01 severity:warning|error channel:app exclude:Controller "Failed to read"
```

### In shorthand

```text
b:2020-01-31 a:2020-01-01 -:Controller "Failed to read"
b:2020-01-31 a:2020-01-01 s:warning|error c:app -:Controller "Failed to read"
```

### Multiple exclusions
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/LogFile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import ButtonGroup from '@/components/ButtonGroup.vue';
import type LogFile from '@/models/LogFile';
import bus from '@/services/EventBus';
import {useSearchStore} from '@/stores/search';
import axios from 'axios';
import {ref, watch} from 'vue';
import {useRoute, useRouter} from 'vue-router';
Expand All @@ -14,6 +15,7 @@ const toggleRef = ref();
const selectedFile = ref<string | null>(null);
const route = useRoute();
const router = useRouter();
const searchStore = useSearchStore();
const baseUri = axios.defaults.baseURL;
const deleteFile = (identifier: string) => {
axios.delete('/api/file/' + encodeURI(identifier))
Expand All @@ -32,7 +34,7 @@ watch(() => route.query.file, () => selectedFile.value = String(route.query.file
<!-- LogFile -->
<button-group ref="toggleRef" alignment="right" :split="file.can_download || file.can_delete" class="mb-1" :hide-on-selected="true">
<template v-slot:btn_left>
<router-link :to="'/log?file=' + encodeURI(file.identifier)"
<router-link :to="'/log?' + searchStore.toQueryString({file: file.identifier})"
class="btn btn-file text-start btn-outline-primary w-100"
v-bind:class="{'btn-outline-primary-active': selectedFile === file.identifier }"
:title="file.name">
Expand Down
8 changes: 0 additions & 8 deletions frontend/src/models/LogRecords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,6 @@ import type Paginator from '@/models/Paginator';
import type Performance from '@/models/Performance';

export default interface LogRecords {
levels: {
choices: {[key: string]: string};
selected: string[];
};
channels: {
choices: {[key: string]: string};
selected: string[];
};
logs: LogRecord[];
paginator: Paginator | null;
performance?: Performance;
Expand Down
13 changes: 2 additions & 11 deletions frontend/src/stores/log_records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,20 @@ import {ref} from 'vue'

export const useLogRecordStore = defineStore('log_records', () => {
const defaultData: LogRecords = {
levels: {choices: {}, selected: []},
channels: {choices: {}, selected: []},
logs: [],
paginator: null
};

const loading = ref(false);
const records = ref<LogRecords>(defaultData);

async function fetch(file: string, levels: string[], channels: string[], direction: string, perPage: string, query: string, offset: number) {
async function fetch(file: string, direction: string, perPage: string, query: string, offset: number) {
const params: { [key: string]: string } = {file, direction, per_page: perPage};

if (query !== '') {
params.query = query;
}
const levelChoices = Object.keys(records.value.levels.choices);
if (levels.length > 0 && levels.length !== levelChoices.length) {
params.levels = levels.join(',');
}
const channelChoices = Object.keys(records.value.channels.choices);
if (channels.length > 0 && channels.length !== channelChoices.length) {
params.channels = channels.join(',');
}

if (offset > 0) {
params.offset = offset.toString();
}
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/stores/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {defineStore} from 'pinia'
import {ref} from 'vue'

export const useSearchStore = defineStore('search', () => {
const query = ref('');
const perPage = ref('50');
const sort = ref('desc');

function toQueryString(params: { [key: string]: string } = {}): string {
if (query.value !== '') {
params.query = query.value;
}

if (perPage.value !== '50') {
params.perPage = perPage.value;
}

if (sort.value !== 'desc') {
params.sort = sort.value;
}

return new URLSearchParams(params).toString();
}

return {query, perPage, sort, toQueryString}
});
67 changes: 26 additions & 41 deletions frontend/src/views/LogView.vue
Original file line number Diff line number Diff line change
@@ -1,38 +1,31 @@
<script setup lang="ts">
import DropdownChecklist from '@/components/DropdownChecklist.vue';
import LogRecord from '@/components/LogRecord.vue';
import PerformanceDetails from '@/components/PerformanceDetails.vue';
import type Checklist from '@/models/Checklist';
import Arrays from '@/services/Arrays';
import {filter} from '@/services/Objects';
import {nullify} from '@/services/Optional';
import {useLogRecordStore} from '@/stores/log_records';
import {useSearchStore} from '@/stores/search';
import {onMounted, ref} from 'vue';
import {useRoute, useRouter} from 'vue-router';

const router = useRouter();
const route = useRoute();
const logRecordStore = useLogRecordStore();
const searchStore = useSearchStore();

const searchRef = ref<HTMLInputElement>();
const file = ref('');
const query = ref('');
const levels = ref<Checklist>({choices: {}, selected: []});
const channels = ref<Checklist>({choices: {}, selected: []});
const perPage = ref('50');
const sort = ref('desc');
const offset = ref(0);
const badRequest = ref(false);

const navigate = () => {
const fileOffset = offset.value > 0 && logRecordStore.records.paginator?.direction !== sort.value ? 0 : offset.value;
const fileOffset = offset.value > 0 && logRecordStore.records.paginator?.direction !== searchStore.sort ? 0 : offset.value;
router.push({
query: filter({
file: file.value,
query: nullify(query.value, ''),
perPage: nullify(perPage.value, '50'),
sort: nullify(sort.value, 'desc'),
levels: nullify(levels.value.selected.join(','), Object.keys(logRecordStore.records.levels.choices).join(',')),
channels: nullify(channels.value.selected.join(','), Object.keys(logRecordStore.records.channels.choices).join(',')),
query: nullify(searchStore.query, ''),
perPage: nullify(searchStore.perPage, '50'),
sort: nullify(searchStore.sort, 'desc'),
offset: nullify(fileOffset, 0)
})
});
Expand All @@ -41,53 +34,47 @@ const navigate = () => {
const load = () => {
badRequest.value = false;
logRecordStore
.fetch(file.value, levels.value.selected, channels.value.selected, sort.value, perPage.value, query.value, offset.value)
.then(() => {
levels.value = logRecordStore.records.levels;
channels.value = logRecordStore.records.channels;
})
.fetch(file.value, searchStore.sort, searchStore.perPage, searchStore.query, offset.value)
.catch((error: Error) => {
if (error.message === 'bad-request') {
badRequest.value = true;
return;
}

router.push({name: error.message});
})
.finally(() => {
searchRef.value?.focus();
});
}

onMounted(() => {
file.value = String(route.query.file);
query.value = String((route.query.query ?? ''));
perPage.value = String((route.query.perPage ?? '50'));
sort.value = String((route.query.sort ?? 'desc'));
levels.value.selected = Arrays.split(String(route.query.levels ?? ''), ',');
channels.value.selected = Arrays.split(String(route.query.channels ?? ''), ',');
offset.value = parseInt(String(route.query.offset ?? '0'));
file.value = String(route.query.file);
searchStore.query = String((route.query.query ?? ''));
searchStore.perPage = String((route.query.perPage ?? '50'));
searchStore.sort = String((route.query.sort ?? 'desc'));
offset.value = parseInt(String(route.query.offset ?? '0'));
load();
});
</script>

<template>
<div class="slv-content h-100 overflow-hidden slv-loadable" v-bind:class="{ 'slv-loading': logRecordStore.loading }">
<div class="slv-content h-100 overflow-hidden">
<div class="d-flex align-items-stretch pt-1">
<dropdown-checklist label="Levels" v-model="levels" class="pe-1"></dropdown-checklist>
<dropdown-checklist label="Channels" v-model="channels" class="pe-1"></dropdown-checklist>

<div class="flex-grow-1 input-group">
<input type="text"
class="form-control"
:class="{'is-invalid': badRequest}"
placeholder="Search log entries"
aria-label="Search log entries"
ref="searchRef"
placeholder="Search log entries, Use severity:, channel:, before:, after:, or exclude: to fine-tune the search."
aria-label="Search log entries, Use severity:, channel:, before:, after:, or exclude: to fine-tune the search."
aria-describedby="button-search"
@change="navigate"
v-model="query">
v-model="searchStore.query">

<select class="slv-menu-sort-direction form-control"
aria-label="Sort direction"
title="Sort direction"
v-model="sort"
v-model="searchStore.sort"
@change="navigate">
<option value="desc">Newest First</option>
<option value="asc">Oldest First</option>
Expand All @@ -96,7 +83,7 @@ onMounted(() => {
<select class="slv-menu-page-size form-control"
aria-label="Entries per page"
title="Entries per page"
v-model="perPage"
v-model="searchStore.perPage"
@change="navigate">
<option value="50">50</option>
<option value="100">100</option>
Expand All @@ -112,17 +99,15 @@ onMounted(() => {
<button class="btn btn-dark ms-1 me-1" type="button" aria-label="Refresh" title="Refresh" @click="load">
<i class="bi bi-arrow-clockwise"></i>
</button>

<div></div>
</div>

<main class="overflow-auto d-none d-md-block">
<main class="overflow-auto d-none d-md-block slv-loadable" v-bind:class="{ 'slv-loading': logRecordStore.loading }">
<div class="slv-entries list-group pt-1 pe-1 pb-3">
<log-record :logRecord="record" v-for="(record, index) in logRecordStore.records.logs ?? []" v-bind:key="index"></log-record>
</div>
</main>

<footer class="pt-1 pb-1 d-flex">
<footer class="pt-1 pb-1 d-flex" v-show="!logRecordStore.loading">
<button class="btn btn-sm btn-outline-secondary"
@click="offset = 0; navigate()"
v-bind:disabled="logRecordStore.records.paginator?.first !== false">
Expand All @@ -131,7 +116,7 @@ onMounted(() => {
<button class="ms-2 btn btn-sm btn-outline-secondary"
@click="offset = logRecordStore.records.paginator?.offset ?? 0; navigate()"
v-bind:disabled="logRecordStore.records.paginator?.more !== true">
Next {{ perPage }}
Next {{ searchStore.perPage }}
</button>

<div class="flex-grow-1"></div>
Expand Down
5 changes: 5 additions & 0 deletions phpmd.baseline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<phpmd-baseline>
<violation rule="PHPMD\Rule\CyclomaticComplexity" file="src/Service/Parser/TermParser.php" method="parse"/>
<violation rule="PHPMD\Rule\Design\NpathComplexity" file="src/Service/Parser/TermParser.php" method="parse"/>
</phpmd-baseline>
16 changes: 16 additions & 0 deletions src/Entity/Expression/ChannelTerm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);

namespace FD\LogViewer\Entity\Expression;

class ChannelTerm implements TermInterface
{
/**
* @codeCoverageIgnore Simple DTO
*
* @param string[] $channels
*/
public function __construct(public readonly array $channels)
{
}
}
16 changes: 16 additions & 0 deletions src/Entity/Expression/SeverityTerm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);

namespace FD\LogViewer\Entity\Expression;

class SeverityTerm implements TermInterface
{
/**
* @codeCoverageIgnore Simple DTO
*
* @param string[] $severities
*/
public function __construct(public readonly array $severities)
{
}
}
12 changes: 2 additions & 10 deletions src/Entity/Output/LogRecordsOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

use FD\LogViewer\Entity\Index\LogIndex;
use FD\LogViewer\Entity\Index\PerformanceStats;
use FD\LogViewer\Entity\Request\LogQueryDto;
use JsonSerializable;

class LogRecordsOutput implements JsonSerializable
Expand All @@ -17,7 +16,6 @@ class LogRecordsOutput implements JsonSerializable
public function __construct(
private readonly array $levels,
private readonly array $channels,
private readonly LogQueryDto $logQuery,
private readonly LogIndex $logIndex,
private readonly PerformanceStats $performance
) {
Expand All @@ -29,14 +27,8 @@ public function __construct(
public function jsonSerialize(): array
{
return [
'levels' => [
'choices' => $this->levels,
'selected' => $this->logQuery->levels ?? array_keys($this->levels)
],
'channels' => [
'choices' => $this->channels,
'selected' => $this->logQuery->channels ?? array_keys($this->channels)
],
'levels' => $this->levels,
'channels' => $this->channels,
'logs' => $this->logIndex->getLines(),
'paginator' => $this->logIndex->getPaginator(),
'performance' => $this->performance
Expand Down
4 changes: 4 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
use FD\LogViewer\Service\Folder\LogFolderOutputSorter;
use FD\LogViewer\Service\Folder\ZipArchiveFactory;
use FD\LogViewer\Service\JsonManifestAssetLoader;
use FD\LogViewer\Service\Matcher\ChannelTermMatcher;
use FD\LogViewer\Service\Matcher\DateAfterTermMatcher;
use FD\LogViewer\Service\Matcher\DateBeforeTermMatcher;
use FD\LogViewer\Service\Matcher\LogRecordMatcher;
use FD\LogViewer\Service\Matcher\SeverityTermMatcher;
use FD\LogViewer\Service\Matcher\WordTermMatcher;
use FD\LogViewer\Service\Parser\DateParser;
use FD\LogViewer\Service\Parser\ExpressionParser;
Expand Down Expand Up @@ -101,6 +103,8 @@

$services->set(DateBeforeTermMatcher::class)->tag('fd.symfony.log.viewer.term_matcher');
$services->set(DateAfterTermMatcher::class)->tag('fd.symfony.log.viewer.term_matcher');
$services->set(SeverityTermMatcher::class)->tag('fd.symfony.log.viewer.term_matcher');
$services->set(ChannelTermMatcher::class)->tag('fd.symfony.log.viewer.term_matcher');
$services->set(WordTermMatcher::class)->tag('fd.symfony.log.viewer.term_matcher');
$services->set(LogRecordMatcher::class)->arg('$termMatchers', tagged_iterator('fd.symfony.log.viewer.term_matcher'));
};
4 changes: 2 additions & 2 deletions src/Resources/public/.vite/manifest.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"src/main.ts": {
"file": "assets/main-p8XQmHAA.js",
"file": "assets/main-pRVgv0ys.js",
"isEntry": true,
"src": "src/main.ts"
},
"style.css": {
"file": "assets/style--kTQjvMD.css",
"file": "assets/style-skI77787.css",
"src": "style.css"
}
}
Loading