Skip to content

Commit 74c30b5

Browse files
authored
Add support for search multiple files at once (#78)
1 parent ffd9f80 commit 74c30b5

File tree

60 files changed

+870
-570
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+870
-570
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ read logs from any directory.
2323
- 📂 **View other types of logs** - Apache, Nginx, or custom logs,
2424
- 🔍 **Search** the logs,
2525
- 🔍 **Filter** by log level (error, info, debug, etc.), by channel, date range or log content inclusion or exclusion,
26+
- 🔍 **Search** multiple log files at once,
2627
- 🌑 **Dark mode**,
2728
- 🖥️ **Multiple host** support,
2829
- 💾 **Download** or **delete** log files from the UI,

frontend/src/components/LogFile.vue

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,66 +6,81 @@ import bus from '@/services/EventBus';
66
import {useHostsStore} from '@/stores/hosts';
77
import {useSearchStore} from '@/stores/search';
88
import axios from 'axios';
9-
import {ref, watch} from 'vue';
10-
import {useRoute, useRouter} from 'vue-router';
9+
import {ref} from 'vue';
10+
import {useRouter} from 'vue-router';
1111
1212
defineProps<{
1313
file: LogFile
1414
}>();
1515
16-
const toggleRef = ref();
17-
const selectedFile = ref<string | null>(null);
18-
const route = useRoute();
19-
const router = useRouter();
20-
const searchStore = useSearchStore();
21-
const hostsStore = useHostsStore();
16+
const toggleRef = ref();
17+
const router = useRouter();
18+
const searchStore = useSearchStore();
19+
const hostsStore = useHostsStore();
2220
2321
const baseUri = axios.defaults.baseURL;
2422
const deleteFile = (identifier: string) => {
2523
const params = new ParameterBag().set('host', hostsStore.selected, 'localhost').all();
2624
axios.delete('/api/file/' + encodeURI(identifier), {params: params})
2725
.then(() => {
28-
if (selectedFile.value === identifier) {
26+
searchStore.removeFile(identifier);
27+
if (searchStore.files.length === 0) {
2928
router.push({name: 'home'});
3029
}
3130
bus.emit('file-deleted', identifier);
3231
});
3332
}
3433
35-
watch(() => route.query.file, () => selectedFile.value = String(route.query.file));
34+
const navigate = (identifier: string, multiSelect: boolean) => {
35+
if (multiSelect) {
36+
searchStore.toggleFile(identifier);
37+
} else {
38+
searchStore.setFile(identifier);
39+
}
40+
if (searchStore.files.length === 0) {
41+
router.push({name: 'home'});
42+
return;
43+
}
44+
router.push('/log?' + searchStore.toQueryString());
45+
}
3646
</script>
3747

3848
<template>
3949
<!-- LogFile -->
4050
<button-group ref="toggleRef" alignment="right" :split="file.can_download || file.can_delete" class="mb-1" :hide-on-selected="true">
4151
<template v-slot:btn_left>
42-
<router-link :to="'/log?' + searchStore.toQueryString({file: file.identifier})"
43-
class="btn btn-file text-start btn-outline-primary w-100"
44-
v-bind:class="{'btn-outline-primary-active': selectedFile === file.identifier }"
45-
:title="file.name">
52+
<a @click="(event) => {event.preventDefault(); navigate(file.identifier, event.ctrlKey || event.metaKey)}"
53+
href="javascript:"
54+
class="btn btn-file text-start btn-outline-primary w-100"
55+
v-bind:class="{'btn-outline-primary-active': searchStore.files.includes(file.identifier) }"
56+
:title="file.name">
4657
<span class="d-block text-nowrap overflow-hidden">{{ file.name }}</span>
4758
<span class="d-block file-size text-secondary text-nowrap overflow-hidden">{{ file.size_formatted }}</span>
48-
</router-link>
59+
</a>
4960
</template>
5061
<template v-slot:btn_right>
5162
<button type="button"
5263
class="slv-toggle-btn btn btn-outline-primary dropdown-toggle dropdown-toggle-split"
53-
v-bind:class="{'btn-outline-primary-active': selectedFile === file.identifier }"
54-
@click="toggleRef.toggle"
55-
v-if="file.can_download || file.can_delete">
64+
v-bind:class="{'btn-outline-primary-active': searchStore.files.includes(file.identifier) }"
65+
@click="toggleRef.toggle">
5666
<i class="bi bi-three-dots-vertical"></i>
5767
</button>
5868
</template>
5969
<template v-slot:dropdown>
6070
<li>
71+
<a class="dropdown-item" href="javascript:" @click="navigate(file.identifier, true)">
72+
<i class="bi bi-check2-circle me-3"></i>{{ searchStore.files.includes(file.identifier) ? 'Deselect' : 'Select' }}
73+
<code>(ctrl+click)</code>
74+
</a>
75+
</li>
76+
<li v-if="file.can_download">
6177
<a class="dropdown-item"
62-
:href="baseUri + 'api/file/' + encodeURI(file.identifier) + '?' + new ParameterBag().set('host', hostsStore.selected, 'localhost').toString()"
63-
v-if="file.can_download">
78+
:href="baseUri + 'api/file/' + encodeURI(file.identifier) + '?' + new ParameterBag().set('host', hostsStore.selected, 'localhost').toString()">
6479
<i class="bi bi-cloud-download me-3"></i>Download
6580
</a>
6681
</li>
67-
<li>
68-
<a class="dropdown-item" href="javascript:" @click="deleteFile(file.identifier)" v-if="file.can_delete">
82+
<li v-if="file.can_delete">
83+
<a class="dropdown-item" href="javascript:" @click="deleteFile(file.identifier)">
6984
<i class="bi bi-trash3 me-3"></i>Delete
7085
</a>
7186
</li>

frontend/src/components/LogFolder.vue

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
<script setup lang="ts">
22
import ButtonGroup from '@/components/ButtonGroup.vue';
33
import LogFile from '@/components/LogFile.vue';
4+
import type LogFileModel from '@/models/LogFile';
45
import type LogFolder from '@/models/LogFolder';
56
import ParameterBag from '@/models/ParameterBag';
67
import bus from '@/services/EventBus';
78
import {useHostsStore} from '@/stores/hosts';
9+
import {useSearchStore} from '@/stores/search';
810
import axios from 'axios';
911
import {onMounted, ref} from 'vue';
1012
import {useRouter} from 'vue-router';
1113
12-
const toggleRef = ref();
13-
const baseUri = axios.defaults.baseURL;
14-
const router = useRouter();
15-
const expanded = ref(false);
16-
const hostsStore = useHostsStore();
14+
const toggleRef = ref();
15+
const baseUri = axios.defaults.baseURL;
16+
const router = useRouter();
17+
const expanded = ref(false);
18+
const hostsStore = useHostsStore();
19+
const searchStore = useSearchStore();
1720
1821
const props = defineProps<{
1922
expand: boolean,
@@ -29,8 +32,12 @@ const deleteFile = (identifier: string) => {
2932
});
3033
}
3134
32-
onMounted(() => expanded.value = props.expand);
35+
const selectAll = (files: LogFileModel[]) => {
36+
files.forEach(file => searchStore.addFile(file.identifier));
37+
router.push('/log?' + searchStore.toQueryString());
38+
}
3339
40+
onMounted(() => expanded.value = props.expand);
3441
</script>
3542

3643
<template>
@@ -44,23 +51,24 @@ onMounted(() => expanded.value = props.expand);
4451
</button>
4552
</template>
4653
<template v-slot:btn_right>
47-
<button type="button"
48-
class="slv-toggle-btn btn btn-outline-primary dropdown-toggle dropdown-toggle-split"
49-
@click="toggleRef.toggle"
50-
v-if="folder.can_download || folder.can_delete">
54+
<button type="button" class="slv-toggle-btn btn btn-outline-primary dropdown-toggle dropdown-toggle-split" @click="toggleRef.toggle">
5155
<i class="bi bi-three-dots-vertical"></i>
5256
</button>
5357
</template>
5458
<template v-slot:dropdown>
5559
<li>
60+
<a class="dropdown-item" href="javascript:" @click="selectAll(folder.files)">
61+
<i class="bi bi-check2-circle me-3"></i>Select all
62+
</a>
63+
</li>
64+
<li v-if="folder.can_download">
5665
<a class="dropdown-item"
57-
:href="baseUri + 'api/folder/' + encodeURI(folder.identifier) + '?' + new ParameterBag().set('host', hostsStore.selected, 'localhost').toString()"
58-
v-if="folder.can_download">
66+
:href="baseUri + 'api/folder/' + encodeURI(folder.identifier) + '?' + new ParameterBag().set('host', hostsStore.selected, 'localhost').toString()">
5967
<i class="bi bi-cloud-download me-3"></i>Download
6068
</a>
6169
</li>
62-
<li>
63-
<a class="dropdown-item" href="javascript:" @click="deleteFile(folder.identifier)" v-if="folder.can_delete">
70+
<li v-if="folder.can_delete">
71+
<a class="dropdown-item" href="javascript:" @click="deleteFile(folder.identifier)">
6472
<i class="bi bi-trash3 me-3"></i>Delete
6573
</a>
6674
</li>

frontend/src/stores/search.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,40 @@ export const useSearchStore = defineStore('search', () => {
77
const query = ref('');
88
const perPage = ref('100');
99
const sort = ref('desc');
10+
const files = ref([] as string[]);
1011
const hostsStore = useHostsStore();
1112

12-
function toQueryString(params: { [key: string]: string } = {}): string {
13-
const bag = new ParameterBag(params);
13+
function addFile(identifier: string) {
14+
if (files.value.includes(identifier) === false) {
15+
files.value.push(identifier);
16+
}
17+
}
18+
19+
function toggleFile(identifier: string) {
20+
if (files.value.includes(identifier)) {
21+
files.value = files.value.filter(file => file !== identifier);
22+
return;
23+
}
24+
files.value.push(identifier);
25+
}
26+
27+
function setFile(identifier: string) {
28+
files.value.splice(0, files.value.length, identifier);
29+
}
30+
31+
function removeFile(identifier: string) {
32+
files.value = files.value.filter(file => file !== identifier);
33+
}
34+
35+
function toQueryString(): string {
36+
const bag = new ParameterBag();
37+
bag.set('file', files.value.join(','), '');
1438
bag.set('query', query.value, '');
1539
bag.set('per_page', perPage.value, '100');
1640
bag.set('sort', sort.value, 'desc');
1741
bag.set('host', hostsStore.selected, 'localhost');
1842
return bag.toString();
1943
}
2044

21-
return {query, perPage, sort, toQueryString}
45+
return {files, query, perPage, sort, addFile, toggleFile, setFile, removeFile, toQueryString}
2246
});

frontend/src/views/LogView.vue

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,14 @@ const hostsStore = useHostsStore();
1616
const searchStore = useSearchStore();
1717
1818
const searchRef = ref<InstanceType<typeof SearchForm>>()
19-
const file = ref('');
2019
const offset = ref(0);
2120
const badRequest = ref(false);
2221
2322
const navigate = () => {
2423
const fileOffset = offset.value > 0 && logRecordStore.records.paginator?.direction !== searchStore.sort ? 0 : offset.value;
2524
const params = new ParameterBag()
2625
.set('host', hostsStore.selected, 'localhost')
27-
.set('file', file.value)
26+
.set('file', searchStore.files.join(','))
2827
.set('query', searchStore.query, '')
2928
.set('per_page', searchStore.perPage, '100')
3029
.set('sort', searchStore.sort, 'desc')
@@ -37,7 +36,7 @@ const load = () => {
3736
logRecordStore
3837
.fetch(new ParameterBag()
3938
.set('host', hostsStore.selected, 'localhost')
40-
.set('file', file.value)
39+
.set('file', searchStore.files.join(','))
4140
.set('query', searchStore.query, '')
4241
.set('per_page', searchStore.perPage, '100')
4342
.set('sort', searchStore.sort, 'desc')
@@ -55,8 +54,8 @@ const load = () => {
5554
}
5655
5756
onMounted(() => {
58-
file.value = String(route.query.file);
5957
hostsStore.selected = String(route.query.host ?? 'localhost');
58+
searchStore.files = String(route.query.file).split(',');
6059
searchStore.query = String(route.query.query ?? '');
6160
searchStore.perPage = String(route.query.per_page ?? '100');
6261
searchStore.sort = String(route.query.sort ?? 'desc');

src/Controller/LogRecordsController.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@ public function __invoke(Request $request): JsonResponse
3434
throw new BadRequestHttpException('Invalid date.', $exception);
3535
}
3636

37-
$file = $this->fileService->findFileByIdentifier($logQuery->fileIdentifier);
38-
if ($file === null) {
39-
throw new NotFoundHttpException(sprintf('Log file with id `%s` not found.', $logQuery->fileIdentifier));
37+
$files = $this->fileService->findFileByIdentifiers($logQuery->fileIdentifiers);
38+
if (count($files) === 0) {
39+
throw new NotFoundHttpException(sprintf('No log files found with id(s) `%s`.', implode(',', $logQuery->fileIdentifiers)));
4040
}
4141

42-
$output = $this->outputProvider->provide($file->folder->collection->config, $file, $logQuery);
42+
if (count($files) === 1) {
43+
$output = $this->outputProvider->provide(reset($files), $logQuery);
44+
} else {
45+
$output = $this->outputProvider->provideForFiles($files, $logQuery);
46+
}
4347

4448
return new JsonResponse($output);
4549
}

src/DependencyInjection/Compiler/MonologCompilerPass.php

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace FD\LogViewer\Entity;
6+
7+
interface IdentifierAwareInterface
8+
{
9+
/**
10+
* @return string uniquely identifies this object
11+
*/
12+
public function getIdentifier(): string;
13+
}

src/Entity/Index/LogIndex.php

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)