🆙 Add cms i using 🆙

This commit is contained in:
Remco
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'App',
};
</script>
@@ -0,0 +1,53 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import axios from 'axios';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import Home from './pages/Home.vue';
let token = document.head.querySelector('meta[name="csrf-token"]');
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
if (token) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
}
for (const [key, value] of Object.entries(window.LogViewer.headers || {})) {
axios.defaults.headers.common[key] = value;
}
window.LogViewer.basePath = '/' + window.LogViewer.path;
if (! window.location.pathname.startsWith(window.LogViewer.basePath)) {
window.LogViewer.basePath = window.location.pathname;
}
let routerBasePath = window.LogViewer.basePath + '/';
if (window.LogViewer.path === '' || window.LogViewer.path === '/') {
routerBasePath = '/';
window.LogViewer.basePath = '';
}
const router = createRouter({
routes: [{
path: window.LogViewer.basePath,
name: 'home',
component: Home,
}],
history: createWebHistory(),
base: routerBasePath,
});
const pinia = createPinia();
const app = createApp(App);
app.use(router);
app.use(pinia);
app.mixin({
computed: {
LogViewer: () => window.LogViewer,
},
});
app.mount('#log-viewer');
@@ -0,0 +1,235 @@
<template>
<table class="table-fixed min-w-full max-w-full border-separate" style="border-spacing: 0">
<thead class="bg-gray-50">
<tr>
<th class="hidden lg:table-cell"><span class="sr-only">Expand/Collapse</span></th>
<th v-for="(column) in logViewerStore.columns" scope="col">
<div>{{ column.label }}</div>
</th>
<th scope="col" class="hidden lg:table-cell"><span class="sr-only">Log index</span></th>
</tr>
</thead>
<template v-if="logViewerStore.logs && logViewerStore.logs.length > 0">
<tbody v-for="(log, index) in logViewerStore.logs" :key="index"
:class="[index === 0 ? 'first' : '', 'log-group']"
:id="`tbody-${index}`" :data-index="index"
>
<tr @click="logViewerStore.toggle(index)"
:class="['log-item group', log.level_class, logViewerStore.isOpen(index) ? 'active' : '', logViewerStore.shouldBeSticky(index) ? 'sticky z-2' : '']"
:style="{ top: logViewerStore.stackTops[index] || 0 }"
>
<td class="log-level hidden lg:table-cell">
<div class="flex items-center lg:pl-2">
<button :aria-expanded="logViewerStore.isOpen(index)"
@keydown="handleLogToggleKeyboardNavigation"
class="log-level-icon opacity-75 w-5 h-5 hidden lg:block group focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-brand-500 rounded-md"
>
<span class="sr-only" v-if="!logViewerStore.isOpen(index)">Expand log entry</span>
<span class="sr-only" v-if="logViewerStore.isOpen(index)">Collapse log entry</span>
<span class="w-full h-full group-hover:hidden group-focus:hidden">
<ExclamationCircleIcon v-if="log.level_class === 'danger'" />
<ExclamationTriangleIcon v-else-if="log.level_class === 'warning'" />
<CheckCircleIcon v-else-if="log.level_class === 'success'" />
<InformationCircleIcon v-else />
</span>
<span class="w-full h-full hidden group-hover:inline-block group-focus:inline-block">
<ChevronRightIcon :class="[logViewerStore.isOpen(index) ? 'rotate-90' : '', 'transition duration-100']" />
</span>
</button>
</div>
</td>
<template v-for="(column, colIndex) in logViewerStore.columns">
<!-- Severity -->
<td :key="`${log.index}-column-${colIndex}`" v-if="column.data_path === 'level'" class="log-level truncate">
<span>{{ log.level_name }}</span>
</td>
<!-- /Severity -->
<!-- Datetime -->
<td :key="`${log.index}-column-${colIndex}`" v-else-if="column.data_path === 'datetime'" class="whitespace-nowrap text-gray-900 dark:text-gray-200">
<span class="hidden lg:inline" v-html="highlightSearchResult(log.datetime, searchStore.query)"></span>
<span class="lg:hidden">{{ log.time }}</span>
</td>
<!-- /Datetime -->
<!-- Message -->
<td :key="`${log.index}-column-${colIndex}`" v-else-if="column.data_path === 'message'" class="max-w-[1px] w-full truncate text-gray-500 dark:text-gray-300 dark:opacity-90">
<span v-html="highlightSearchResult(`${log.message}`, searchStore.query)"></span>
</td>
<!-- /Message -->
<td :key="`${log.index}-column-${colIndex}`" v-else class="text-gray-500 dark:text-gray-300 dark:opacity-90" :class="column.class || ''">
<span v-html="highlightSearchResult(getDataAtPath(log, column.data_path), searchStore.query)"></span>
</td>
</template>
<td class="whitespace-nowrap text-gray-500 dark:text-gray-300 dark:opacity-90 text-xs hidden lg:table-cell">
<LogCopyButton :log="log" class="pr-2 large-screen" />
</td>
</tr>
<tr v-show="logViewerStore.isOpen(index)">
<td :colspan="tableColumns">
<div class="lg:hidden flex justify-between px-2 pt-2 pb-1 text-xs">
<div class="flex-1"><span class="font-semibold">Datetime:</span> {{ log.datetime }}</div>
<div>
<LogCopyButton :log="log" />
</div>
</div>
<tab-container v-if="logViewerStore.isOpen(index)" :tabs="getTabsForLog(log)">
<tab-content v-if="log.extra && log.extra.mail_preview && log.extra.mail_preview.html" tab-value="mail_html_preview">
<mail-html-preview :mail="log.extra.mail_preview" />
</tab-content>
<tab-content v-if="log.extra && log.extra.mail_preview && log.extra.mail_preview.text" tab-value="mail_text_preview">
<mail-text-preview :mail="log.extra.mail_preview" />
</tab-content>
<tab-content v-if="hasLaravelStackTrace(log)" tab-value="laravel_stack_trace">
<LaravelStackTraceDisplay :log="log" />
</tab-content>
<tab-content tab-value="raw">
<pre class="log-stack" v-html="highlightSearchResult(log.full_text, searchStore.query)"></pre>
<template v-if="hasContext(log)">
<p class="mx-2 lg:mx-8 pt-2 border-t font-semibold text-gray-700 dark:text-gray-400 text-xs lg:text-sm">Context:</p>
<pre class="log-stack" v-html="highlightSearchResult(prepareContextForOutput(log.context), searchStore.query)"></pre>
</template>
<div v-if="log.extra && log.extra.log_text_incomplete" class="py-4 px-8 text-gray-500 italic">
The contents of this log have been cut short to the first {{ LogViewer.max_log_size_formatted }}.
The full size of this log entry is <strong>{{ log.extra.log_size_formatted }}</strong>
</div>
</tab-content>
</tab-container>
</td>
</tr>
</tbody>
</template>
<tbody v-else class="log-group">
<tr>
<td colspan="6">
<div class="bg-white text-gray-600 dark:bg-gray-800 dark:text-gray-200 p-12">
<div class="text-center font-semibold">No results</div>
<div class="text-center mt-6">
<button v-if="searchStore.query?.length > 0"
class="px-3 py-2 border dark:border-gray-700 text-gray-800 dark:text-gray-200 hover:border-brand-600 dark:hover:border-brand-700 rounded-md"
@click="clearQuery">Clear search query
</button>
<button v-if="searchStore.query?.length > 0 && fileStore.selectedFile"
class="px-3 ml-3 py-2 border dark:border-gray-700 text-gray-800 dark:text-gray-200 hover:border-brand-600 dark:hover:border-brand-700 rounded-md"
@click.prevent="clearSelectedFile">Search all files
</button>
<button
v-if="severityStore.levelsFound.length > 0 && severityStore.levelsSelected.length === 0"
class="px-3 ml-3 py-2 border dark:border-gray-700 text-gray-800 dark:text-gray-200 hover:border-brand-600 dark:hover:border-brand-700 rounded-md"
@click="severityStore.selectAllLevels">Select all severities
</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
import {
ChevronRightIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
InformationCircleIcon,
} from '@heroicons/vue/24/solid';
import { highlightSearchResult } from '../helpers.js';
import { useLogViewerStore } from '../stores/logViewer.js';
import { useSearchStore } from '../stores/search.js';
import { useFileStore } from '../stores/files.js';
import LogCopyButton from './LogCopyButton.vue';
import { handleLogToggleKeyboardNavigation } from '../keyboardNavigation';
import { useSeverityStore } from '../stores/severity.js';
import TabContainer from "./TabContainer.vue";
import TabContent from "./TabContent.vue";
import MailHtmlPreview from "./MailHtmlPreview.vue";
import MailTextPreview from "./MailTextPreview.vue";
import LaravelStackTraceDisplay from "./LaravelStackTraceDisplay.vue";
import {computed} from "vue";
const fileStore = useFileStore();
const logViewerStore = useLogViewerStore();
const searchStore = useSearchStore();
const severityStore = useSeverityStore();
const emit = defineEmits(['clearSelectedFile', 'clearQuery']);
const clearSelectedFile = () => {
emit('clearSelectedFile');
}
const clearQuery = () => {
emit('clearQuery');
}
const getDataAtPath = (obj, path) => {
const value = path.split('.').reduce((acc, part) => acc && acc[part], obj);
return typeof value === 'undefined' ? '' : String(value);
}
const hasContext = (log) => {
return log.context && Object.keys(log.context).length > 0;
}
const getExtraTabsForLog = (log) => {
let tabs = [];
if (hasLaravelStackTrace(log)) {
tabs.push({ name: 'Stack Trace', value: 'laravel_stack_trace' });
}
if (! log.extra || ! log.extra.mail_preview) {
return tabs;
}
if (log.extra.mail_preview.html) {
tabs.push({ name: 'HTML preview', value: 'mail_html_preview' });
}
if (log.extra.mail_preview.text) {
tabs.push({ name: 'Text preview', value: 'mail_text_preview' });
}
return tabs;
}
const getTabsForLog = (log) => {
const tabs = [...getExtraTabsForLog(log)];
tabs.push({ name: 'Raw', value: 'raw' });
return tabs.filter(Boolean);
}
const prepareContextForOutput = (context) => {
return JSON.stringify(context, function (key, value) {
if (typeof value === 'string') {
return value.replaceAll('\n', '<br/>');
}
return value;
}, 2);
}
const hasLaravelStackTrace = (log) => {
const exception = Array.isArray(log.context)
? log.context.find(item => item.exception)?.exception
: log.context.exception;
return exception && typeof exception === 'string' && exception.includes('[stacktrace]');
}
const tableColumns = computed(() => {
// the extra two columns are for the expand/collapse and log index columns
return logViewerStore.columns.length + 2;
});
</script>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
<template>
<div class="checkmark w-[18px] h-[18px] bg-gray-50 dark:bg-gray-800 rounded border dark:border-gray-600 inline-flex items-center justify-center">
<CheckIcon v-if="checked" width="18" height="18" class="w-full h-full" />
</div>
</template>
<script setup>
import { CheckIcon } from '@heroicons/vue/20/solid'
defineProps({
checked: {
type: Boolean,
required: true,
},
})
</script>
@@ -0,0 +1,37 @@
<script setup>
import { CloudArrowDownIcon } from '@heroicons/vue/24/outline';
import axios from 'axios';
const props = defineProps(['url']);
const requestFileDownload = () => {
axios.get(`${props.url}/request`)
.then((response) => {
downloadFromUrl(response.data.url);
}).catch((error) => {
console.log(error);
if (error.response && error.response.data) {
alert(`${error.message}: ${error.response.data.message}. Check developer console for more info.`);
}
});
};
const downloadFromUrl = (url) => {
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', '');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script>
<template>
<button @click="requestFileDownload">
<slot>
<CloudArrowDownIcon class="w-4 h-4 mr-2" />
Download
</slot>
</button>
</template>
@@ -0,0 +1,270 @@
<template>
<nav class="flex flex-col h-full py-5">
<div class="mx-3 md:mx-0 mb-1">
<div class="sm:flex sm:flex-col-reverse">
<h1 class="font-semibold text-brand-700 dark:text-brand-600 text-2xl flex items-center">
Log Viewer
<a href="https://www.github.com/opcodesio/log-viewer" target="_blank"
class="rounded ml-3 text-gray-400 hover:text-brand-800 dark:hover:text-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:focus:ring-brand-700 p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" title="">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
</svg>
</a>
<span class="md:hidden flex-1 flex justify-end">
<SiteSettingsDropdown class="ml-2" />
<button type="button" class="menu-button">
<XMarkIcon class="w-5 h-5 ml-2" @click="fileStore.toggleSidebar" />
</button>
</span>
</h1>
<div v-if="LogViewer.back_to_system_url">
<a :href="LogViewer.back_to_system_url"
class="rounded shrink inline-flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-brand-800 dark:hover:text-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:focus:ring-brand-700 mt-0">
<ArrowLeftIcon class="h-3 w-3 mr-1.5" />
{{ LogViewer.back_to_system_label || `Back to ${LogViewer.app_name}` }}
</a>
</div>
</div>
<div v-if="LogViewer.assets_outdated" class="bg-yellow-100 dark:bg-yellow-900 bg-opacity-75 dark:bg-opacity-40 border border-yellow-300 dark:border-yellow-800 rounded-md px-2 py-1 mt-2 text-xs leading-5 text-yellow-700 dark:text-yellow-400">
<ExclamationTriangleIcon class="h-4 w-4 mr-1 inline" />
Front-end assets are outdated. To update, please run <code class="font-mono px-2 py-1 bg-gray-100 dark:bg-gray-900 rounded">php artisan log-viewer:publish</code>
</div>
<template v-if="hostStore.supportsHosts && hostStore.hasRemoteHosts">
<host-selector class="mb-8 mt-6" />
</template>
<template v-if="fileStore.fileTypesAvailable && fileStore.fileTypesAvailable.length > 1">
<file-type-selector class="mb-8 mt-6" />
</template>
<div class="flex justify-between items-baseline mt-6" v-if="fileStore.filteredFolders?.length > 0">
<div class="ml-1 block text-sm text-gray-500 dark:text-gray-400 truncate">Log files on {{ fileStore.selectedHost?.name }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
<label for="file-sort-direction" class="sr-only">Sort direction</label>
<select id="file-sort-direction" class="select" v-model="fileStore.direction">
<option v-if="!LogViewer.files_sort_by_time" value="asc">From A to Z</option>
<option v-if="!LogViewer.files_sort_by_time" value="desc">From Z to A</option>
<option v-if="LogViewer.files_sort_by_time" value="desc">Newest first</option>
<option v-if="LogViewer.files_sort_by_time" value="asc">Oldest first</option>
</select>
</div>
</div>
<p v-if="fileStore.error" class="mx-1 mt-1 text-red-600 text-xs">
{{ fileStore.error }}
</p>
</div>
<div v-show="fileStore.checkBoxesVisibility">
<p class="text-sm text-gray-600 dark:text-gray-400">Please select files to delete and confirm or cancel deletion.</p>
<div class="grid grid-flow-col pr-4 mt-2"
:class="[fileStore.hasFilesChecked ? 'justify-between' : 'justify-end']"
>
<button v-show="fileStore.hasFilesChecked"
@click.stop="confirmDeleteSelectedFiles"
class="button inline-flex">
<TrashIcon class="w-5 mr-1" />
Delete selected files
</button>
<button class="button inline-flex" @click.stop="fileStore.resetChecks()">
Cancel
<XMarkIcon class="w-5 ml-1" />
</button>
</div>
</div>
<div id="file-list-container" class="relative h-full overflow-hidden">
<div class="file-list" @scroll="(event) => fileStore.onScroll(event)">
<div v-for="folder in fileStore.filteredFolders"
:key="folder.identifier"
:id="`folder-${folder.identifier}`"
class="relative folder-container"
>
<Menu v-slot="{ open }">
<div class="folder-item-container"
@click="fileStore.toggle(folder)"
:class="[fileStore.isOpen(folder) ? 'active-folder' : '', fileStore.shouldBeSticky(folder) ? 'sticky ' + (open ? 'z-20' : 'z-10') : '' ]"
>
<div class="file-item group">
<button class="file-item-info group" @keydown="handleKeyboardFileNavigation">
<span class="sr-only" v-if="!fileStore.isOpen(folder)">Open folder</span>
<span class="sr-only" v-if="fileStore.isOpen(folder)">Close folder</span>
<span class="file-icon group-hover:hidden group-focus:hidden">
<FolderIcon v-show="!fileStore.isOpen(folder)" class="w-5 h-5" />
<FolderOpenIcon v-show="fileStore.isOpen(folder)" class="w-5 h-5" />
</span>
<span class="file-icon hidden group-hover:inline-block group-focus:inline-block">
<ChevronRightIcon :class="[fileStore.isOpen(folder) ? 'rotate-90' : '', 'transition duration-100']" />
</span>
<span class="file-name">
<span v-if="String(folder.clean_path || '').startsWith(rootFolderPrefix)">
<span class="text-gray-500 dark:text-gray-400">{{ rootFolderPrefix }}</span>{{ String(folder.clean_path).substring(rootFolderPrefix.length) }}
</span>
<span v-else>{{ folder.clean_path }}</span>
</span>
</button>
<MenuButton as="button" class="file-dropdown-toggle group-hover:border-brand-600 group-hover:dark:border-brand-800"
:data-toggle-id="folder.identifier"
@keydown="handleKeyboardFileSettingsNavigation"
@click.stop="calculateDropdownDirection($event.target)">
<span class="sr-only">Open folder options</span>
<EllipsisVerticalIcon class="w-4 h-4 pointer-events-none" />
</MenuButton>
</div>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
>
<MenuItems static v-show="open" as="div" class="dropdown w-48" :class="[dropdownDirections[folder.identifier]]">
<div class="py-2">
<MenuItem @click.stop.prevent="fileStore.clearCacheForFolder(folder)" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<CircleStackIcon v-show="!fileStore.clearingCache[folder.identifier]" class="w-4 h-4 mr-2"/>
<SpinnerIcon v-show="fileStore.clearingCache[folder.identifier]" class="w-4 h-4 mr-2" />
<span v-show="!fileStore.cacheRecentlyCleared[folder.identifier] && !fileStore.clearingCache[folder.identifier]">Clear indices</span>
<span v-show="!fileStore.cacheRecentlyCleared[folder.identifier] && fileStore.clearingCache[folder.identifier]">Clearing...</span>
<span v-show="fileStore.cacheRecentlyCleared[folder.identifier]" class="text-brand-500">Indices cleared</span>
</button>
</MenuItem>
<MenuItem v-if="folder.can_download" v-slot="{ active }">
<DownloadLink :url="folder.download_url" @click.stop :class="[active ? 'active' : '']" />
</MenuItem>
<template v-if="folder.can_delete">
<div class="divider"></div>
<MenuItem v-slot="{ active }">
<button @click.stop="confirmDeleteFolder(folder)" :disabled="fileStore.deleting[folder.identifier]" :class="[active ? 'active' : '']">
<TrashIcon v-show="!fileStore.deleting[folder.identifier]" class="w-4 h-4 mr-2" />
<SpinnerIcon v-show="fileStore.deleting[folder.identifier]" />
Delete
</button>
</MenuItem>
</template>
</div>
</MenuItems>
</transition>
</div>
</Menu>
<div class="folder-files pl-3 ml-1 border-l border-gray-200 dark:border-gray-800"
v-show="fileStore.isOpen(folder)">
<file-list-item
v-for="logFile in (folder.files || [])"
:key="logFile.identifier"
:log-file="logFile"
@click="selectFile(logFile.identifier)"
/>
</div>
</div>
<div v-if="fileStore.folders.length === 0" class="text-center text-sm text-gray-600 dark:text-gray-400">
<p class="mb-5">No log files were found.</p>
<div class="flex items-center justify-center px-1">
<button @click.prevent="fileStore.loadFolders()"
class="inline-flex items-center px-4 py-2 text-left text-sm bg-white hover:bg-gray-50 outline-brand-500 dark:outline-brand-800 text-gray-900 dark:text-gray-200 rounded-md dark:bg-gray-700 dark:hover:bg-gray-600"
>
<ArrowPathIcon class="w-4 h-4 mr-1.5" />
Refresh file list
</button>
</div>
</div>
</div>
<!-- gradient to hide the bottom of the file list -->
<div class="pointer-events-none absolute z-10 bottom-0 h-4 w-full bg-gradient-to-t from-gray-100 dark:from-gray-900 to-transparent"></div>
<!-- loading state overlay -->
<div class="absolute inset-y-0 left-3 right-7 lg:left-0 lg:right-0 z-10" v-show="fileStore.loading">
<div class="rounded-md bg-white text-gray-800 dark:bg-gray-700 dark:text-gray-200 opacity-90 w-full h-full flex items-center justify-center">
<SpinnerIcon class="w-14 h-14" />
</div>
</div>
</div>
</nav>
</template>
<script setup>
import { onMounted, watch } from 'vue';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import {
ArrowLeftIcon,
ArrowPathIcon,
CircleStackIcon,
EllipsisVerticalIcon,
ExclamationTriangleIcon,
FolderIcon,
FolderOpenIcon,
TrashIcon,
XMarkIcon,
ChevronRightIcon,
} from '@heroicons/vue/24/outline';
import { useHostStore } from '../stores/hosts.js';
import { useFileStore } from '../stores/files.js';
import { useRoute, useRouter } from 'vue-router';
import { replaceQuery, useDropdownDirection } from '../helpers.js';
import FileListItem from './FileListItem.vue';
import SpinnerIcon from './SpinnerIcon.vue';
import SiteSettingsDropdown from './SiteSettingsDropdown.vue';
import HostSelector from './HostSelector.vue';
import { handleKeyboardFileNavigation, handleKeyboardFileSettingsNavigation } from '../keyboardNavigation';
import FileTypeSelector from './FileTypeSelector.vue';
import DownloadLink from "./DownloadLink.vue";
const router = useRouter();
const route = useRoute();
const hostStore = useHostStore();
const fileStore = useFileStore();
const { dropdownDirections, calculateDropdownDirection } = useDropdownDirection();
const confirmDeleteFolder = async (folder) => {
if (confirm(`Are you sure you want to delete the log folder '${folder.path}'? THIS ACTION CANNOT BE UNDONE.`)) {
await fileStore.deleteFolder(folder);
if (folder.files.some(file => file.identifier === fileStore.selectedFileIdentifier)) {
replaceQuery(router, 'file', null);
}
}
}
const confirmDeleteSelectedFiles = async () => {
if (confirm('Are you sure you want to delete selected log files? THIS ACTION CANNOT BE UNDONE.')) {
await fileStore.deleteSelectedFiles();
if (fileStore.filesChecked.includes(fileStore.selectedFileIdentifier)) {
replaceQuery(router, 'file', null);
}
fileStore.resetChecks();
await fileStore.loadFolders();
}
}
const selectFile = (fileIdentifier) => {
if (route.query.file && route.query.file === fileIdentifier) {
replaceQuery(router, 'file', null);
} else {
replaceQuery(router, 'file', fileIdentifier);
}
};
const rootFolderPrefix = window.LogViewer?.root_folder_prefix || 'root';
onMounted(async () => {
hostStore.selectHost(route.query.host || null);
});
watch(
() => fileStore.direction,
() => fileStore.loadFolders()
);
</script>
@@ -0,0 +1,126 @@
<template>
<div class="file-item-container" :class="[isSelected ? 'active' : '']">
<Menu>
<div class="file-item group">
<button class="file-item-info" @keydown="handleKeyboardFileNavigation">
<span class="sr-only" v-if="!isSelected">Select log file</span>
<span class="sr-only" v-if="isSelected">Deselect log file</span>
<span v-if="logFile.can_delete" class="my-auto mr-2" v-show="fileStore.checkBoxesVisibility">
<input type="checkbox"
@click.stop="toggleCheckbox"
:checked="fileStore.isChecked(logFile)"
:value="fileStore.isChecked(logFile)"
/>
</span>
<span class="file-name"><span class="sr-only">Name:</span>{{ logFile.name }}</span>
<span class="file-size"><span class="sr-only">Size:</span>{{ logFile.size_formatted }}</span>
</button>
<MenuButton as="button" class="file-dropdown-toggle group-hover:border-brand-600 group-hover:dark:border-brand-800"
:data-toggle-id="logFile.identifier"
@keydown="handleKeyboardFileSettingsNavigation"
@click.stop="calculateDropdownDirection($event.target)">
<EllipsisVerticalIcon class="w-4 h-4 pointer-events-none" />
</MenuButton>
</div>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
>
<MenuItems as="div" class="dropdown w-48" :class="[dropdownDirections[logFile.identifier]]">
<div class="py-2">
<MenuItem @click.stop.prevent="fileStore.clearCacheForFile(logFile)" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<CircleStackIcon v-show="!fileStore.clearingCache[logFile.identifier]" class="h-4 w-4 mr-2" />
<SpinnerIcon v-show="fileStore.clearingCache[logFile.identifier]" />
<span v-show="!fileStore.cacheRecentlyCleared[logFile.identifier] && !fileStore.clearingCache[logFile.identifier]">Clear index</span>
<span v-show="!fileStore.cacheRecentlyCleared[logFile.identifier] && fileStore.clearingCache[logFile.identifier]">Clearing...</span>
<span v-show="fileStore.cacheRecentlyCleared[logFile.identifier]" class="text-brand-500">Index cleared</span>
</button>
</MenuItem>
<MenuItem v-if="logFile.can_download" @click.stop v-slot="{ active }">
<DownloadLink :url="logFile.download_url" :class="[active ? 'active' : '']" />
</MenuItem>
<template v-if="logFile.can_delete">
<div class="divider"></div>
<MenuItem @click.stop.prevent="confirmDeletion" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<TrashIcon class="w-4 h-4 mr-2" />
Delete
</button>
</MenuItem>
<MenuItem @click.stop="deleteMultiple" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<TrashIcon class="w-4 h-4 mr-2" />
Delete Multiple
</button>
</MenuItem>
</template>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import { CircleStackIcon, EllipsisVerticalIcon, TrashIcon } from '@heroicons/vue/24/outline';
import { useFileStore } from '../stores/files.js';
import SpinnerIcon from './SpinnerIcon.vue';
import { replaceQuery, useDropdownDirection } from '../helpers.js';
import { useRouter } from 'vue-router';
import { handleKeyboardFileNavigation, handleKeyboardFileSettingsNavigation } from '../keyboardNavigation';
import DownloadLink from "./DownloadLink.vue";
const props = defineProps({
logFile: {
type: Object,
required: true,
},
showSelectToggle: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['selectForDeletion']);
const fileStore = useFileStore();
const router = useRouter();
const { dropdownDirections, calculateDropdownDirection } = useDropdownDirection();
// data
const isSelected = computed(() => {
return fileStore.selectedFile && fileStore.selectedFile.identifier === props.logFile.identifier;
})
const confirmDeletion = async () => {
if (confirm(`Are you sure you want to delete the log file '${props.logFile.name}'? THIS ACTION CANNOT BE UNDONE.`)) {
await fileStore.deleteFile(props.logFile);
if (props.logFile.identifier === fileStore.selectedFileIdentifier) {
replaceQuery(router, 'file', null);
}
await fileStore.loadFolders();
}
}
const toggleCheckbox = () => {
fileStore.checkBoxToggle(props.logFile.identifier);
}
const deleteMultiple = () => {
fileStore.toggleCheckboxVisibility();
toggleCheckbox();
}
</script>
@@ -0,0 +1,38 @@
<template>
<Listbox as="div" v-model="fileStore.selectedFileTypes" multiple>
<ListboxLabel class="ml-1 block text-sm text-gray-500 dark:text-gray-400">Selected file types</ListboxLabel>
<div class="relative mt-1">
<ListboxButton id="hosts-toggle-button" class="cursor-pointer relative text-gray-800 dark:text-gray-200 w-full cursor-default rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 py-2 pl-4 pr-10 text-left hover:border-brand-600 hover:dark:border-brand-800 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 text-sm">
<span class="block truncate">{{ fileStore.selectedFileTypesString }}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<ListboxOptions class="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md shadow-md bg-white dark:bg-gray-800 py-1 border border-gray-200 dark:border-gray-700 ring-1 ring-brand ring-opacity-5 focus:outline-none text-sm">
<ListboxOption as="template" v-for="fileType in fileStore.fileTypesAvailable" :key="fileType.identifier" :value="fileType.identifier" v-slot="{ active, selected }">
<li :class="[active ? 'text-white bg-brand-600' : 'text-gray-900 dark:text-gray-300', 'relative cursor-default select-none py-2 pl-3 pr-9']">
<span :class="[selected ? 'font-semibold' : 'font-normal', 'block truncate']">{{ fileType.name }}</span>
<span v-if="selected" :class="[active ? 'text-white' : 'text-brand-600', 'absolute inset-y-0 right-0 flex items-center pr-4']">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup>
import { Listbox, ListboxButton, ListboxLabel, ListboxOption, ListboxOptions } from '@headlessui/vue'
import { CheckIcon, ChevronDownIcon } from '@heroicons/vue/20/solid'
import { useRouter } from 'vue-router';
import { useFileStore } from '../stores/files.js';
const router = useRouter();
const fileStore = useFileStore();
</script>
@@ -0,0 +1,47 @@
<template>
<Listbox as="div" v-model="hostStore.selectedHostIdentifier">
<ListboxLabel class="ml-1 block text-sm text-gray-500 dark:text-gray-400">Select host</ListboxLabel>
<div class="relative mt-1">
<ListboxButton id="hosts-toggle-button" class="cursor-pointer relative text-gray-800 dark:text-gray-200 w-full cursor-default rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 py-2 pl-4 pr-10 text-left hover:border-brand-600 hover:dark:border-brand-800 focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 text-sm">
<span class="block truncate">{{ hostStore.selectedHost?.name || 'Please select a server' }}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</span>
</ListboxButton>
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
<ListboxOptions class="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md shadow-md bg-white dark:bg-gray-800 py-1 border border-gray-200 dark:border-gray-700 ring-1 ring-brand ring-opacity-5 focus:outline-none text-sm">
<ListboxOption as="template" v-for="host in hostStore.hosts" :key="host.identifier" :value="host.identifier" v-slot="{ active, selected }">
<li :class="[active ? 'text-white bg-brand-600' : 'text-gray-900 dark:text-gray-300', 'relative cursor-default select-none py-2 pl-3 pr-9']">
<span :class="[selected ? 'font-semibold' : 'font-normal', 'block truncate']">{{ host.name }}</span>
<span v-if="selected" :class="[active ? 'text-white' : 'text-brand-600', 'absolute inset-y-0 right-0 flex items-center pr-4']">
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</template>
<script setup>
import { watch } from 'vue'
import { Listbox, ListboxButton, ListboxLabel, ListboxOption, ListboxOptions } from '@headlessui/vue'
import { CheckIcon, ChevronDownIcon } from '@heroicons/vue/20/solid'
import { useHostStore } from '../stores/hosts.js';
import { useRouter } from 'vue-router';
import { replaceQuery } from '../helpers.js';
const router = useRouter();
const hostStore = useHostStore();
watch(
() => hostStore.selectedHost,
(value) => {
replaceQuery(router, 'host', value?.is_remote ? value.identifier : null);
}
);
</script>
@@ -0,0 +1,98 @@
<template>
<TransitionRoot as="template" :show="logViewerStore.helpSlideOverOpen">
<Dialog as="div" class="relative z-20" @close="logViewerStore.helpSlideOverOpen = false">
<div class="fixed inset-0" />
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<TransitionChild
as="template"
enter="transform transition ease-in-out duration-200 sm:duration-300"
enter-from="translate-x-full"
enter-to="translate-x-0"
leave="transform transition ease-in-out duration-200 sm:duration-300"
leave-from="translate-x-0"
leave-to="translate-x-full"
>
<DialogPanel class="pointer-events-auto w-screen max-w-md">
<div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl dark:bg-gray-700">
<div class="px-4 sm:px-6">
<div class="flex items-start justify-between">
<DialogTitle class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">Keyboard Shortcuts</DialogTitle>
<div class="ml-3 flex h-7 items-center">
<button type="button" class="rounded-md bg-white dark:bg-gray-700 text-gray-400 hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:focus:ring-brand-300 focus:ring-offset-2" @click="logViewerStore.helpSlideOverOpen = false">
<span class="sr-only">Close panel</span>
<XMarkIcon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div class="relative mt-6 flex-1 px-4 sm:px-6">
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Hosts }}</span>
<span class="description">Select a host</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Files }}</span>
<span class="description">Jump to file selection</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Logs }}</span>
<span class="description">Jump to logs</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.NextLog }}</span>
<span class="description">Open next log</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.PreviousLog }}</span>
<span class="description">Open previous log</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Next }}</span>
<span class="description">Next (file or log)</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Previous }}</span>
<span class="description">Previous (file or log)</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Severity }}</span>
<span class="description">Severity selection</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Settings }}</span>
<span class="description">Settings</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Search }}</span>
<span class="description">Search</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.Refresh }}</span>
<span class="description">Refresh logs</span>
</div>
<div class="keyboard-shortcut">
<span class="shortcut">{{ KeyShortcuts.ShortcutHelp }}</span>
<span class="description">Keyboard shortcuts help</span>
</div>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
import { XMarkIcon } from '@heroicons/vue/24/outline'
import { useLogViewerStore } from '../stores/logViewer.js';
import { KeyShortcuts } from '../keyboardNavigation/shared.js';
const logViewerStore = useLogViewerStore();
</script>
@@ -0,0 +1,113 @@
<template>
<div class="p-4 lg:p-8">
<!-- Exception Header -->
<div v-if="stackTrace.header" class="mb-6 pb-4 border-b border-gray-200 dark:border-gray-600">
<div class="text-red-600 dark:text-red-400 font-semibold text-lg mb-2">
{{ stackTrace.header.type }}
</div>
<div class="text-gray-800 dark:text-gray-200 text-base mb-2">
{{ stackTrace.header.message }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
in {{ stackTrace.header.file }}:{{ stackTrace.header.line }}
</div>
</div>
<!-- Stack Trace Frames -->
<div class="space-y-2">
<div v-for="(frame, frameIndex) in stackTrace.frames" :key="frameIndex"
class="mb-2 border-b border-gray-100 dark:border-gray-700 pb-2 last:border-b-0">
<div class="flex items-start gap-2">
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono w-8 flex-shrink-0 pt-1">
#{{ frame.number }}
</div>
<div class="flex-1 min-w-0">
<div v-if="frame.file" class="text-xs mb-1">
<span class="font-mono text-blue-600 dark:text-blue-400 break-all">{{ frame.file }}</span>
<span class="text-gray-500 dark:text-gray-400 mx-0.5">:</span>
<span class="font-mono text-orange-600 dark:text-orange-400">{{ frame.line }}</span>
</div>
<div class="text-xs text-gray-800 dark:text-gray-200 font-mono break-all">
{{ frame.call }}
</div>
</div>
</div>
</div>
</div>
<!-- Error Fallback -->
<div v-if="!stackTrace.header && stackTrace.frames.length === 0" class="text-gray-500 dark:text-gray-400 text-sm italic">
Unable to parse stack trace. View the Raw tab for full details.
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
log: {
type: Object,
required: true
}
});
/**
* Parses the Laravel exception stack trace from the log context.
* This computed property ensures the expensive parsing operation only happens once per log.
*/
const stackTrace = computed(() => {
try {
const exception = Array.isArray(props.log.context)
? props.log.context.find(item => item.exception)?.exception
: props.log.context?.exception;
if (!exception || typeof exception !== 'string') {
return { header: null, frames: [] };
}
// Parse exception header
// Format: [object] (ExceptionType(code: 0): Message at /path/file.php:123)
const headerMatch = exception.match(/^\[object\]\s*\(([^(]+)\(code:\s*\d+\):\s*(.+?)\s+at\s+(.+?):(\d+)\)/);
const header = headerMatch ? {
type: headerMatch[1].trim(),
message: headerMatch[2].trim(),
file: headerMatch[3].trim(),
line: parseInt(headerMatch[4])
} : null;
// Parse stack trace frames
// Format: #0 /path/file.php(123): Class::method()
const stacktraceMatch = exception.match(/\[stacktrace\]([\s\S]*?)(?:\n\n|\n$|$)/);
const frames = [];
if (stacktraceMatch) {
const frameRegex = /#(\d+)\s+(.+?)(?:\n|$)/g;
let match;
while ((match = frameRegex.exec(stacktraceMatch[1])) !== null) {
const frameLine = match[2].trim();
const fileMatch = frameLine.match(/^(.+?)\((\d+)\):\s*(.+)$/);
frames.push(fileMatch ? {
number: parseInt(match[1]),
file: fileMatch[1],
line: parseInt(fileMatch[2]),
call: fileMatch[3]
} : {
number: parseInt(match[1]),
file: '',
line: 0,
call: frameLine
});
}
}
return { header, frames };
} catch (error) {
// Gracefully handle parsing errors
console.error('Error parsing stack trace:', error);
return { header: null, frames: [] };
}
});
</script>
@@ -0,0 +1,86 @@
<template>
<div class="flex items-center">
<Menu as="div" class="mr-5 relative log-levels-selector">
<MenuButton as="button" id="severity-dropdown-toggle" class="dropdown-toggle badge none" :class="severityStore.levelsSelected.length > 0 ? 'active' : ''">
<template v-if="severityStore.levelsSelected.length > 2">
<span class="opacity-90 mr-1">{{ severityStore.totalResultsSelected.toLocaleString() + (logViewerStore.hasMoreResults ? '+' : '') }} entries in</span>
<strong class="font-semibold">{{ severityStore.levelsSelected[0].level_name }} + {{ severityStore.levelsSelected.length - 1 }} more</strong>
</template>
<template v-else-if="severityStore.levelsSelected.length > 0">
<span class="opacity-90 mr-1">{{ severityStore.totalResultsSelected.toLocaleString() + (logViewerStore.hasMoreResults ? '+' : '') }} entries in</span>
<strong class="font-semibold">{{ severityStore.levelsSelected.map(levelCount => levelCount.level_name).join(', ') }}</strong>
</template>
<span v-else-if="severityStore.levelsFound.length > 0" class="opacity-90">{{ severityStore.totalResults.toLocaleString() + (logViewerStore.hasMoreResults ? '+' : '') }} entries found. None selected</span>
<span v-else class="opacity-90">No entries found</span>
<ChevronDownIcon class="w-4 h-4" />
</MenuButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
>
<MenuItems as="div" class="dropdown down left min-w-[240px]">
<div class="py-2">
<div class="label flex justify-between">
Severity
<template v-if="severityStore.levelsFound.length > 0">
<MenuItem v-if="severityStore.levelsSelected.length === severityStore.levelsFound.length" @click.stop="severityStore.deselectAllLevels" v-slot="{ active }">
<a class="inline-link px-2 -mr-2 py-1 -my-1 rounded-md cursor-pointer text-brand-700 dark:text-brand-500 font-normal" :class="[active ? 'active' : '']">
Deselect all
</a>
</MenuItem>
<MenuItem v-else @click.stop="severityStore.selectAllLevels" v-slot="{ active }">
<a class="inline-link px-2 -mr-2 py-1 -my-1 rounded-md cursor-pointer text-brand-700 dark:text-brand-500 font-normal" :class="[active ? 'active' : '']">
Select all
</a>
</MenuItem>
</template>
</div>
<template v-if="severityStore.levelsFound.length === 0">
<div class="no-results">There are no severity filters to display because no entries have been found.</div>
</template>
<template v-else>
<MenuItem v-for="levelCount in severityStore.levelsFound"
@click.stop.prevent="severityStore.toggleLevel(levelCount.level)"
v-slot="{ active }"
>
<button :class="[active ? 'active' : '']">
<Checkmark class="checkmark mr-2.5" :checked="levelCount.selected" />
<span class="flex-1 inline-flex justify-between">
<span :class="['log-level', levelCount.level_class]">{{ levelCount.level_name }}</span>
<span class="log-count">{{ Number(levelCount.count).toLocaleString() }}</span>
</span>
</button>
</MenuItem>
</template>
</div>
</MenuItems>
</transition>
</Menu>
</div>
</template>
<script setup>
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import { ChevronDownIcon } from '@heroicons/vue/24/outline';
import Checkmark from './Checkmark.vue';
import { useLogViewerStore } from '../stores/logViewer.js';
import { useSeverityStore } from '../stores/severity.js';
import { watch } from 'vue';
const logViewerStore = useLogViewerStore();
const severityStore = useSeverityStore();
watch(
() => severityStore.excludedLevels,
() => logViewerStore.loadLogs()
);
</script>
@@ -0,0 +1,36 @@
<template>
<button class="log-link group"
@click.stop="copy"
@keydown="handleLogLinkKeyboardNavigation"
title="Copy link to this log entry"
>
<span class="sr-only">Log index {{ log.index }}. Click the button to copy link to this log entry.</span>
<span v-show="!copied" class="hidden md:inline group-hover:underline">{{ Number(log.index).toLocaleString() }}</span>
<LinkIcon v-show="!copied" class="md:opacity-75 group-hover:opacity-100" />
<HandThumbUpIcon v-show="copied" class="text-green-600 dark:text-green-500 md:hidden" />
<span v-show="copied" class="text-green-600 dark:text-green-500 hidden md:inline">Copied!</span>
</button>
</template>
<script setup>
import { ref } from 'vue';
import { copyToClipboard } from '../helpers.js';
import { LinkIcon } from '@heroicons/vue/24/outline';
import { HandThumbUpIcon } from '@heroicons/vue/24/solid';
import { handleLogLinkKeyboardNavigation } from '../keyboardNavigation';
const props = defineProps({
log: {
type: Object,
required: true,
},
})
const copied = ref(false);
const copy = () => {
copyToClipboard(props.log.url);
copied.value = true;
setTimeout(() => copied.value = false, 1000);
}
</script>
@@ -0,0 +1,115 @@
<template>
<div class="h-full w-full py-5 log-list">
<div class="flex flex-col h-full w-full md:mx-3 mb-4">
<div class="md:px-4 mb-4 flex flex-col-reverse lg:flex-row items-start">
<div class="flex items-center mr-5 mt-3 md:mt-0" v-if="showLevelsDropdown">
<LevelButtons />
</div>
<div class="w-full lg:w-auto flex-1 flex justify-end min-h-[38px]">
<SearchInput />
<div class="hidden md:block ml-5">
<button @click="logViewerStore.loadLogs()" id="reload-logs-button" title="Reload current results" class="menu-button">
<ArrowPathIcon class="w-5 h-5" />
</button>
</div>
<div class="hidden md:block">
<SiteSettingsDropdown class="ml-2" id="desktop-site-settings" />
</div>
<div class="md:hidden">
<button type="button" class="menu-button">
<Bars3Icon class="w-5 h-5 ml-2" @click="fileStore.toggleSidebar" />
</button>
</div>
</div>
</div>
<div v-if="!inlinePaginationSettingsIntoTableHeader" class="flex justify-end md:px-4 my-1 mx-2">
<pagination-options />
</div>
<div v-if="displayLogs" class="relative overflow-hidden h-full text-sm">
<!-- pagination settings -->
<pagination-options
v-if="inlinePaginationSettingsIntoTableHeader"
class="mx-2 mt-1 mb-2 text-right lg:mx-0 lg:mt-0 lg:mb-0 lg:absolute lg:top-2 lg:right-6 z-20"
/>
<div class="log-item-container h-full overflow-y-auto md:px-4" @scroll="(event) => logViewerStore.onScroll(event)">
<div class="inline-block min-w-full max-w-full align-middle">
<base-log-table />
</div>
</div>
<!-- loading state for logs -->
<div class="absolute inset-0 top-9 md:px-4 z-20" v-show="logViewerStore.loading && (!logViewerStore.isMobile || !fileStore.sidebarOpen)">
<div
class="rounded-md bg-white text-gray-800 dark:bg-gray-700 dark:text-gray-200 opacity-90 w-full h-full flex items-center justify-center">
<SpinnerIcon class="w-14 h-14" />
</div>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-gray-600 dark:text-gray-400">
<span v-if="logViewerStore.hasMoreResults">Searching...</span>
<span v-else>Select a file or start searching...</span>
</div>
<div v-if="displayLogs && paginationStore.hasPages" class="md:px-4">
<div class="hidden lg:block">
<Pagination :loading="logViewerStore.loading" />
</div>
<div class="lg:hidden">
<Pagination :loading="logViewerStore.loading" :short="true" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import {computed, ref, watch} from 'vue';
import { useRouter } from 'vue-router';
import { ArrowPathIcon, Bars3Icon } from '@heroicons/vue/24/solid';
import { useLogViewerStore } from '../stores/logViewer.js';
import { useSearchStore } from '../stores/search.js';
import { useFileStore } from '../stores/files.js';
import { usePaginationStore } from '../stores/pagination.js';
import Pagination from './Pagination.vue';
import LevelButtons from './LevelButtons.vue';
import SearchInput from './SearchInput.vue';
import SiteSettingsDropdown from './SiteSettingsDropdown.vue';
import SpinnerIcon from './SpinnerIcon.vue';
import BaseLogTable from './BaseLogTable.vue';
import PaginationOptions from './PaginationOptions.vue';
const router = useRouter();
const fileStore = useFileStore();
const logViewerStore = useLogViewerStore();
const searchStore = useSearchStore();
const paginationStore = usePaginationStore();
const showLevelsDropdown = computed(() => {
return fileStore.selectedFile || String(searchStore.query || '').trim().length > 0;
});
const displayLogs = computed(() => {
return logViewerStore.logs && (logViewerStore.logs.length > 0 || !logViewerStore.hasMoreResults) && (logViewerStore.selectedFile || searchStore.hasQuery);
});
watch(
[
() => logViewerStore.direction,
() => logViewerStore.resultsPerPage,
],
() => logViewerStore.loadLogs()
)
const inlinePaginationSettingsIntoTableHeader = ref(true);
watch(() => logViewerStore.columns, () => {
// only if the last column is the message column, which is usually a wide column
// and leaves space for the pagination settings to be displayed in the table's header.
inlinePaginationSettingsIntoTableHeader.value =
logViewerStore.columns[logViewerStore.columns.length - 1].data_path === 'message';
});
</script>
@@ -0,0 +1,34 @@
<template>
<div class="mail-preview">
<!-- headers -->
<mail-preview-attributes :mail="mail"/>
<!-- HTML preview -->
<iframe
v-if="mail.html"
class="mail-preview-html"
:style="{height: `${iframeHeight}px`}"
:srcdoc="mail.html"
@load="setIframeHeight"
ref="iframe"
></iframe>
</div>
</template>
<script setup>
import {ref} from "vue";
import MailPreviewAttributes from "./MailPreviewAttributes.vue";
defineProps({
mail: {
type: Object,
},
})
const iframe = ref(null);
const iframeHeight = ref(600);
const setIframeHeight = () => {
iframeHeight.value = (iframe.value?.contentWindow?.document?.body?.clientHeight || 580) + 20;
}
</script>
@@ -0,0 +1,69 @@
<template>
<div class="mail-preview-attributes">
<table>
<tr v-if="mail.from">
<td class="font-semibold">From</td>
<td>{{ mail.from }}</td>
</tr>
<tr v-if="mail.to">
<td class="font-semibold">To</td>
<td>{{ mail.to }}</td>
</tr>
<tr v-if="mail.id">
<td class="font-semibold">Message ID</td>
<td>{{ mail.id }}</td>
</tr>
<tr v-if="mail.subject">
<td class="font-semibold">Subject</td>
<td>{{ mail.subject }}</td>
</tr>
<tr v-if="mail.attachments && mail.attachments.length > 0">
<td class="font-semibold">Attachments</td>
<td>
<div v-for="(attachment, index) in mail.attachments" :key="`mail-${mail.id}-attachment-${index}`"
class="mail-attachment-button"
>
<div class="flex items-center">
<PaperClipIcon class="h-4 w-4 text-gray-500 dark:text-gray-400 mr-1"/>
<span>{{ attachment.filename }} <span class="opacity-60">({{ attachment.size_formatted }})</span></span>
</div>
<div>
<a href="#" @click.prevent="downloadAttachment(attachment)"
class="text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>Download</a>
</div>
</div>
</td>
</tr>
</table>
</div>
</template>
<script setup>
import { PaperClipIcon } from '@heroicons/vue/24/outline';
defineProps(['mail']);
const downloadAttachment = (attachment) => {
// Decode the base64 encoded string
const decodedContent = atob(attachment.content);
// Convert decoded base64 string to a Uint8Array
const byteNumbers = new Array(decodedContent.length);
for (let i = 0; i < decodedContent.length; i++) {
byteNumbers[i] = decodedContent.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: attachment.content_type || 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
downloadLink.download = attachment.filename;
downloadLink.click();
// Clean up the temporary URL after the download
URL.revokeObjectURL(blobUrl);
}
</script>
@@ -0,0 +1,31 @@
<template>
<div class="mail-preview">
<!-- headers -->
<mail-preview-attributes :mail="mail"/>
<!-- Text preview -->
<pre
v-if="mail.text"
class="mail-preview-text"
v-text="mail.text"
></pre>
</div>
</template>
<script setup>
import {ref} from "vue";
import MailPreviewAttributes from "./MailPreviewAttributes.vue";
defineProps({
mail: {
type: Object,
},
})
const iframe = ref(null);
const iframeHeight = ref(600);
const setIframeHeight = () => {
iframeHeight.value = (iframe.value?.contentWindow?.document?.body?.clientHeight || 580) + 20;
}
</script>
@@ -0,0 +1,82 @@
<template>
<nav class="pagination">
<div class="previous">
<button v-if="paginationStore.page !== 1" @click="previousPage" :disabled="loading" rel="prev">
<ArrowLeftIcon class="h-5 w-5" />
<span class="sm:hidden">Previous page</span>
</button>
</div>
<div class="sm:hidden border-transparent text-gray-500 dark:text-gray-400 border-t-2 pt-3 px-4 inline-flex items-center text-sm font-medium">
<span>{{ paginationStore.page }}</span>
</div>
<div class="pages">
<template v-for="link in (short ? paginationStore.linksShort : paginationStore.links)">
<button v-if="link.active" class="border-brand-500 text-brand-600 dark:border-brand-600 dark:text-brand-500"
aria-current="page">
{{ Number(link.label).toLocaleString() }}
</button>
<span v-else-if="link.label === '...'">{{ link.label }}</span>
<button v-else @click="gotoPage(Number(link.label))"
class="border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 hover:border-gray-300 dark:hover:text-gray-300 dark:hover:border-gray-400">
{{ Number(link.label).toLocaleString() }}
</button>
</template>
</div>
<div class="next">
<button v-if="paginationStore.hasMorePages" @click="nextPage" :disabled="loading" rel="next">
<span class="sm:hidden">Next page</span>
<ArrowRightIcon class="h-5 w-5" />
</button>
</div>
</nav>
</template>
<script setup>
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/vue/24/outline';
import { usePaginationStore } from '../stores/pagination.js';
import { useRoute, useRouter } from 'vue-router';
import {computed, onBeforeUnmount, onMounted} from 'vue';
import { replaceQuery } from '../helpers.js';
const props = defineProps({
loading: {
type: Boolean,
required: true,
},
short: {
type: Boolean,
default: false,
}
})
const paginationStore = usePaginationStore();
const router = useRouter();
const route = useRoute();
const currentPage = computed(() => Number(route.query.page) || 1);
const gotoPage = (page) => {
if (page < 1) {
page = 1;
}
if (paginationStore.pagination && page > paginationStore.pagination.last_page) {
page = paginationStore.pagination.last_page;
}
replaceQuery(router, 'page', page > 1 ? Number(page) : null);
}
const nextPage = () => gotoPage(paginationStore.page + 1);
const previousPage = () => gotoPage(paginationStore.page - 1);
onMounted(() => {
document.addEventListener('goToNextPage', nextPage);
document.addEventListener('goToPreviousPage', previousPage);
})
onBeforeUnmount(() => {
document.removeEventListener('goToNextPage', nextPage);
document.removeEventListener('goToPreviousPage', previousPage);
})
</script>
@@ -0,0 +1,19 @@
<script setup>
import {useLogViewerStore} from "../stores/logViewer";
const logViewerStore = useLogViewerStore();
</script>
<template>
<div class="text-sm text-gray-500 dark:text-gray-400">
<label for="log-sort-direction" class="sr-only">Sort direction</label>
<select id="log-sort-direction" v-model="logViewerStore.direction" class="select mr-4">
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
<label for="items-per-page" class="sr-only">Items per page</label>
<select id="items-per-page" v-model="logViewerStore.resultsPerPage" class="select">
<option v-for="option in logViewerStore.perPageOptions" :key="option" :value="option">{{ option }} items per page</option>
</select>
</div>
</template>
@@ -0,0 +1,68 @@
<template>
<div class="flex-1">
<div class="search" :class="{'has-error': logViewerStore.error}">
<div class="prefix-icon">
<label for="query" class="sr-only">Search</label>
<MagnifyingGlassIcon v-show="!logViewerStore.hasMoreResults" class="h-4 w-4" />
<SpinnerIcon v-show="logViewerStore.hasMoreResults" class="w-4 h-4" />
</div>
<div class="relative flex-1 m-1">
<input v-model="tempQuery" name="query" id="query" type="text"
@keydown.enter="submitQuery"
@keydown.esc="(event) => event.target.blur()"
/>
<div v-show="searchStore.hasQuery" class="clear-search">
<button @click="clearQuery">
<XMarkIcon class="h-4 w-4" />
</button>
</div>
</div>
<div class="submit-search">
<button v-if="logViewerStore.hasMoreResults" disabled="disabled">
<span>Searching<span class="hidden xl:inline ml-1"> {{ selectedFile ? selectedFile.name : 'all files' }}</span>...</span>
</button>
<button v-else @click="submitQuery" id="query-submit">
<span>Search<span class="hidden xl:inline ml-1"> {{ selectedFile ? 'in "' + selectedFile.name + '"' : 'all files' }}</span></span>
<ArrowRightIcon class="h-4 w-4" />
</button>
</div>
</div>
<div class="relative h-0 w-full overflow-visible">
<div class="search-progress-bar" v-show="logViewerStore.hasMoreResults"
:style="{ width: logViewerStore.percentScanned + '%' }"></div>
</div>
<p class="mt-1 text-red-600 text-xs" v-show="logViewerStore.error" v-html="logViewerStore.error"></p>
</div>
</template>
<script setup>
import { useSearchStore } from '../stores/search.js';
import { ArrowRightIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { useLogViewerStore } from '../stores/logViewer.js';
import { computed, ref, watch } from 'vue';
import SpinnerIcon from './SpinnerIcon.vue';
import { replaceQuery } from '../helpers.js';
import { useRoute, useRouter } from 'vue-router';
const searchStore = useSearchStore();
const logViewerStore = useLogViewerStore();
const router = useRouter();
const route = useRoute();
const selectedFile = computed(() => logViewerStore.selectedFile);
const tempQuery = ref(route.query.query || '');
const submitQuery = () => {
replaceQuery(router, 'query', tempQuery.value === '' ? null : tempQuery.value);
document.getElementById('query-submit')?.focus();
}
const clearQuery = () => {
tempQuery.value = '';
submitQuery();
}
watch(
() => route.query.query,
(query) => tempQuery.value = query || '',
)
</script>
@@ -0,0 +1,132 @@
<template>
<Menu as="div" class="relative">
<MenuButton as="button" class="menu-button">
<span class="sr-only">Settings dropdown</span>
<Cog8ToothIcon class="w-5 h-5" />
</MenuButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
enter-active-class="transition ease-out duration-100"
enter-from-class="opacity-0 scale-90"
enter-to-class="opacity-100 scale-100"
>
<MenuItems as="div" style="min-width: 250px;" class="dropdown">
<div class="py-2">
<div class="label">Settings</div>
<MenuItem v-slot="{ active }">
<button :class="[active ? 'active' : '']" @click.stop.prevent="logViewerStore.shorterStackTraces = !logViewerStore.shorterStackTraces">
<Checkmark :checked="logViewerStore.shorterStackTraces" />
<span class="ml-3">Shorter stack traces</span>
</button>
</MenuItem>
<div class="divider"></div>
<div class="label">Actions</div>
<MenuItem @click.stop.prevent="fileStore.clearCacheForAllFiles" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<CircleStackIcon v-show="!fileStore.clearingCache['*']" class="w-4 h-4 mr-1.5" />
<SpinnerIcon v-show="fileStore.clearingCache['*']" class="w-4 h-4 mr-1.5" />
<span v-show="!fileStore.cacheRecentlyCleared['*'] && !fileStore.clearingCache['*']">Clear indices for all files</span>
<span v-show="!fileStore.cacheRecentlyCleared['*'] && fileStore.clearingCache['*']">Please wait...</span>
<span v-show="fileStore.cacheRecentlyCleared['*']" class="text-brand-500">File indices cleared</span>
</button>
</MenuItem>
<MenuItem @click.stop.prevent="copyUrlToClipboard" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<ShareIcon class="w-4 h-4" />
<span v-show="!copied">Share this page</span>
<span v-show="copied" class="text-brand-500">Link copied!</span>
</button>
</MenuItem>
<div class="divider"></div>
<MenuItem @click.stop.prevent="logViewerStore.toggleTheme()" v-slot="{ active }">
<button :class="[active ? 'active' : '']">
<ComputerDesktopIcon v-show="logViewerStore.theme === Theme.System" class="w-4 h-4" />
<SunIcon v-show="logViewerStore.theme === Theme.Light" class="w-4 h-4" />
<MoonIcon v-show="logViewerStore.theme === Theme.Dark" class="w-4 h-4" />
<span>Theme: <span v-html="logViewerStore.theme" class="font-semibold"></span></span>
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<button @click="logViewerStore.helpSlideOverOpen = true" :class="[active ? 'active' : '']">
<QuestionMarkCircleIcon class="w-4 h-4" />
Keyboard Shortcuts
</button>
</MenuItem>
<MenuItem v-slot="{ active }">
<a href="https://log-viewer.opcodes.io/docs" target="_blank" :class="[active ? 'active' : '']">
<QuestionMarkCircleIcon class="w-4 h-4" />
Documentation
</a>
</MenuItem>
<MenuItem v-slot="{ active }">
<a href="https://www.github.com/opcodesio/log-viewer" target="_blank" :class="[active ? 'active' : '']">
<QuestionMarkCircleIcon class="w-4 h-4" />
Help
</a>
</MenuItem>
<div class="divider"></div>
<MenuItem v-slot="{ active }">
<a href="https://www.buymeacoffee.com/arunas" target="_blank" :class="[active ? 'active' : '']">
<div class="w-4 h-4 mr-3 flex flex-col items-center">
<bmc-icon class="h-4 w-auto" />
</div>
<strong :class="[active ? 'text-white' : 'text-brand-500']">Show your support</strong>
<ArrowTopRightOnSquareIcon class="ml-2 w-4 h-4 opacity-75" />
</a>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup>
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import {
ArrowTopRightOnSquareIcon,
CircleStackIcon,
Cog8ToothIcon,
ComputerDesktopIcon,
MoonIcon,
QuestionMarkCircleIcon,
ShareIcon,
SunIcon,
} from '@heroicons/vue/24/outline';
import { Theme, useLogViewerStore } from '../stores/logViewer.js';
import { ref, watch } from 'vue';
import Checkmark from './Checkmark.vue';
import SpinnerIcon from './SpinnerIcon.vue';
import { copyToClipboard } from '../helpers.js';
import BmcIcon from './BmcIcon.vue';
import { useFileStore } from '../stores/files.js';
const logViewerStore = useLogViewerStore();
const fileStore = useFileStore();
const copied = ref(false);
const copyUrlToClipboard = () => {
copyToClipboard(window.location.href);
copied.value = true;
setTimeout(() => copied.value = false, 2000);
};
watch(
() => logViewerStore.shorterStackTraces,
() => logViewerStore.loadLogs()
);
</script>
@@ -0,0 +1,8 @@
<template>
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</template>
@@ -0,0 +1,33 @@
<template>
<div>
<div class="tabs-container" v-if="tabs && tabs.length > 1">
<div class="border-b border-gray-200 dark:border-gray-800">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<a v-for="tab in tabs" :key="tab.name" href="#" @click.prevent="currentTab = tab"
:class="[isCurrent(tab) ? 'border-brand-500 dark:border-brand-400 text-brand-600 dark:text-brand-500' : 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-200', 'whitespace-nowrap border-b-2 py-2 px-1 text-sm font-medium focus:outline-brand-500']"
:aria-current="isCurrent(tab) ? 'page' : undefined">{{ tab.name }}</a>
</nav>
</div>
</div>
<slot></slot>
</div>
</template>
<script setup>
import {provide, ref} from "vue";
const props = defineProps({
tabs: {
type: Array,
required: true,
},
})
const currentTab = ref(props.tabs[0]);
provide('currentTab', currentTab);
const isCurrent = (tab) => {
return currentTab.value && currentTab.value.value === tab.value;
}
</script>
@@ -0,0 +1,22 @@
<template>
<div v-if="isSelected">
<slot></slot>
</div>
</template>
<script setup>
import {computed, inject} from "vue";
const props = defineProps({
tabValue: {
type: String,
required: true,
},
})
const currentTab = inject('currentTab');
const isSelected = computed(() => {
return currentTab.value && currentTab.value.value === props.tabValue;
})
</script>
@@ -0,0 +1,97 @@
import { ref } from 'vue';
export const highlightSearchResult = (text, query = null) => {
text = text || '';
if (query) {
try {
text = text.replace(new RegExp(query, 'gi'), '<mark>$&</mark>');
} catch (e) {
// in case the regex is invalid, we want to just continue without marking any text.
}
}
// Let's return the <mark> tags which we use for highlighting the search results
// while escaping the rest of the HTML entities
return escapeHtml(text)
.replace(/&lt;mark&gt;/g, '<mark>')
.replace(/&lt;\/mark&gt;/g, '</mark>')
.replace(/&lt;br\/&gt;/g, '<br/>');
};
export const escapeHtml = (text) => {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
export const copyToClipboard = (str) => {
const el = document.createElement('textarea');
el.value = str;
el.setAttribute('readonly', '');
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
const selected =
document.getSelection().rangeCount > 0
? document.getSelection().getRangeAt(0)
: false;
el.select();
document.execCommand('copy');
document.body.removeChild(el);
if (selected) {
document.getSelection().removeAllRanges();
document.getSelection().addRange(selected);
}
};
export const replaceQuery = (router, key, value) => {
const route = router.currentRoute.value;
const query = {
host: route.query.host || undefined,
file: route.query.file || undefined,
query: route.query.query || undefined,
page: route.query.page || undefined,
};
// maybe this logic shouldn't be here, but that's what works for now.
// calling `replaceQuery` twice in a single "tick" can cause previous change to be reverted.
if (key === 'host') {
query.file = undefined;
query.page = undefined;
} else if (key === 'file' && query.page !== undefined) {
query.page = undefined;
}
query[key] = value ? String(value) : undefined;
router.push({ name: 'home', query });
};
export const useDropdownDirection = () => {
const dropdownDirections = ref({});
const getDropdownDirection = (buttonElement) => {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const boundingRect = buttonElement.getBoundingClientRect();
return (boundingRect.bottom + 190) < viewportHeight ? 'down' : 'up';
}
const calculateDropdownDirection = (toggleButton) => {
dropdownDirections.value[toggleButton.dataset.toggleId] = getDropdownDirection(toggleButton);
}
return { dropdownDirections, calculateDropdownDirection };
}
export const isMobile = () => {
return window.matchMedia('(max-width: 768px)').matches;
}
@@ -0,0 +1,27 @@
import {focusNextFile, focusPreviousFile, logToggleButtonClass} from './shared.js';
export const handleKeyboardFileNavigation = (event) => {
if (event.key === 'ArrowUp') {
event.preventDefault();
focusPreviousFile();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
focusNextFile();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
document.activeElement.nextElementSibling.focus();
}
};
export const handleKeyboardFileSettingsNavigation = (event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
document.activeElement.previousElementSibling.focus();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
const logToggleButtons = Array.from(document.querySelectorAll(`.${logToggleButtonClass}`));
if (logToggleButtons.length > 0) {
logToggleButtons[0].focus();
}
}
}
@@ -0,0 +1,92 @@
import {
ensureIsExpanded,
fileItemClass,
focusActiveOrFirstFile,
focusFirstLogEntry,
focusLastLogEntry, focusNextFile, focusPreviousFile,
KeyShortcuts,
logToggleButtonClass,
openNextLogEntry,
openPreviousLogEntry
} from './shared.js';
import {useLogViewerStore} from '../stores/logViewer.js';
const globalKeyboardEventHandler = (event) => {
// if event.target is an <input> element, we don't want to handle the keyboard shortcuts
if (event.target.tagName === 'INPUT') return;
if (event.metaKey || event.ctrlKey) return;
if (event.key === KeyShortcuts.ShortcutHelp) {
event.preventDefault();
const logViewerStore = useLogViewerStore();
logViewerStore.helpSlideOverOpen = !logViewerStore.helpSlideOverOpen;
} else if (event.key === KeyShortcuts.Files) {
event.preventDefault();
focusActiveOrFirstFile();
} else if (event.key === KeyShortcuts.Logs) {
event.preventDefault();
focusFirstLogEntry();
} else if (event.key === KeyShortcuts.Hosts) {
event.preventDefault();
const hostsButton = document.getElementById('hosts-toggle-button');
hostsButton?.click();
} else if (event.key === KeyShortcuts.Severity) {
event.preventDefault();
const severityButton = document.getElementById('severity-dropdown-toggle');
severityButton?.click();
} else if (event.key === KeyShortcuts.Settings) {
event.preventDefault();
const settingsButton = document.querySelector('#desktop-site-settings .menu-button');
settingsButton?.click();
} else if (event.key === KeyShortcuts.Search) {
event.preventDefault();
const searchInput = document.getElementById('query');
searchInput?.focus();
} else if (event.key === KeyShortcuts.Refresh) {
event.preventDefault();
const refreshButton = document.getElementById('reload-logs-button');
refreshButton?.click();
} else if (event.key === KeyShortcuts.NextLog) {
event.preventDefault();
if (!document.activeElement.classList.contains(logToggleButtonClass)) {
focusFirstLogEntry();
ensureIsExpanded(document.activeElement);
return;
}
openNextLogEntry();
} else if (event.key === KeyShortcuts.PreviousLog) {
event.preventDefault();
if (!document.activeElement.classList.contains(logToggleButtonClass)) {
focusLastLogEntry();
ensureIsExpanded(document.activeElement);
return;
}
openPreviousLogEntry();
} else if (event.key === KeyShortcuts.Next) {
event.preventDefault();
const isLogEntry = document.activeElement.classList.contains(logToggleButtonClass);
const isFile = document.activeElement.classList.contains(fileItemClass);
if (isLogEntry) {
openNextLogEntry();
} else if (isFile) {
focusNextFile();
}
} else if (event.key === KeyShortcuts.Previous) {
event.preventDefault();
const isLogEntry = document.activeElement.classList.contains(logToggleButtonClass);
const isFile = document.activeElement.classList.contains(fileItemClass);
if (isLogEntry) {
openPreviousLogEntry();
} else if (isFile) {
focusPreviousFile();
}
}
}
export const registerGlobalShortcuts = () => {
document.addEventListener('keydown', globalKeyboardEventHandler);
}
export const unregisterGlobalShortcuts = () => {
document.removeEventListener('keydown', globalKeyboardEventHandler);
}
@@ -0,0 +1,3 @@
export * from './global.js';
export * from './logs.js';
export * from './files.js';
@@ -0,0 +1,62 @@
import {
focusActiveOrFirstFileSettings,
getPreviousElementWithClass,
getIndexOfElementWithClass,
getNextElementWithClass,
logToggleButtonClass,
logLinkClass,
} from './shared.js';
export const handleLogToggleKeyboardNavigation = (event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
focusActiveOrFirstFileSettings();
} else if (event.key === 'ArrowRight') {
const logIndex = getIndexOfElementWithClass(document.activeElement, logToggleButtonClass);
const logLinks = Array.from(document.querySelectorAll(`.${logLinkClass}`));
if (logLinks.length > logIndex) {
event.preventDefault();
logLinks[logIndex].focus();
}
} else if (event.key === 'ArrowUp') {
const previousElement = getPreviousElementWithClass(document.activeElement, logToggleButtonClass);
if (previousElement) {
event.preventDefault();
previousElement.focus();
}
} else if (event.key === 'ArrowDown') {
const nextElement = getNextElementWithClass(document.activeElement, logToggleButtonClass);
if (nextElement) {
event.preventDefault();
nextElement.focus();
}
}
}
export const handleLogLinkKeyboardNavigation = (event) => {
if (event.key === 'ArrowLeft') {
const logIndex = getIndexOfElementWithClass(document.activeElement, logLinkClass);
const logToggleButtons = Array.from(document.querySelectorAll(`.${logToggleButtonClass}`));
if (logToggleButtons.length > logIndex) {
event.preventDefault();
logToggleButtons[logIndex].focus();
}
} else if (event.key === 'ArrowUp') {
const previousElement = getPreviousElementWithClass(document.activeElement, logLinkClass);
if (previousElement) {
event.preventDefault();
previousElement.focus();
}
} else if (event.key === 'ArrowDown') {
const nextElement = getNextElementWithClass(document.activeElement, logLinkClass);
if (nextElement) {
event.preventDefault();
nextElement.focus();
}
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const el = document.activeElement;
el.click();
el.focus();
}
}
@@ -0,0 +1,152 @@
export const fileItemClass = 'file-item-info';
export const fileSettingsButtonClass = 'file-dropdown-toggle';
export const logToggleButtonClass = 'log-level-icon';
export const logLinkClass = 'log-link.large-screen';
export const KeyShortcuts = {
Files: 'f',
Logs: 'l',
Next: 'j',
Previous: 'k',
NextLog: 'n',
PreviousLog: 'p',
Hosts: 'h',
Severity: 's',
Settings: 'g',
Search: '/',
Refresh: 'r',
ShortcutHelp: '?',
}
export const focusFirstLogEntry = () => {
const logToggleButtons = Array.from(document.querySelectorAll(`.${logToggleButtonClass}`));
if (logToggleButtons.length > 0) {
logToggleButtons[0].focus();
}
}
export const focusLastLogEntry = () => {
const logToggleButtons = Array.from(document.querySelectorAll(`.${logToggleButtonClass}`));
if (logToggleButtons.length > 0) {
logToggleButtons[logToggleButtons.length - 1].focus();
}
}
export const ensureIsExpanded = (element) => {
const isExpanded = element.getAttribute('aria-expanded') === 'true';
if (!isExpanded) {
element.click();
}
}
export const ensureIsCollapsed = (element) => {
const isExpanded = element.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
element.click();
}
}
export const openNextLogEntry = () => {
const el = document.activeElement;
const nextElement = getNextElementWithClass(el, logToggleButtonClass);
if (!nextElement) {
const onNextPageLoad = () => {
setTimeout(() => {
focusFirstLogEntry();
ensureIsExpanded(document.activeElement);
}, 50)
document.removeEventListener('logsPageLoaded', onNextPageLoad);
};
document.addEventListener('logsPageLoaded', onNextPageLoad);
document.dispatchEvent(new Event('goToNextPage'));
return;
}
ensureIsCollapsed(el);
nextElement.focus();
ensureIsExpanded(nextElement);
}
export const openPreviousLogEntry = () => {
const el = document.activeElement;
const previousElement = getPreviousElementWithClass(el, logToggleButtonClass);
if (!previousElement) {
const onPreviousPageLoad = () => {
setTimeout(() => {
focusLastLogEntry();
ensureIsExpanded(document.activeElement);
}, 50)
document.removeEventListener('logsPageLoaded', onPreviousPageLoad);
};
document.addEventListener('logsPageLoaded', onPreviousPageLoad);
document.dispatchEvent(new Event('goToPreviousPage'));
return;
}
ensureIsCollapsed(el);
previousElement.focus();
ensureIsExpanded(previousElement);
}
export const focusActiveOrFirstFile = () => {
const activeFile = document.querySelector('.file-item-container.active .file-item-info');
if (activeFile) {
activeFile.focus();
} else {
const firstFile = document.querySelector('.file-item-container .file-item-info');
firstFile?.focus();
}
};
export const focusActiveOrFirstFileSettings = () => {
const activeFile = document.querySelector('.file-item-container.active .file-item-info');
if (activeFile) {
activeFile.nextElementSibling.focus();
} else {
const firstFile = document.querySelector('.file-item-container .file-item-info');
firstFile?.nextElementSibling?.focus();
}
};
export const focusNextFile = () => {
const nextElement = getNextElementWithClass(document.activeElement, fileItemClass);
if (nextElement) {
nextElement.focus();
}
}
export const focusPreviousFile = () => {
const previousElement = getPreviousElementWithClass(document.activeElement, fileItemClass);
if (previousElement) {
previousElement.focus();
}
}
export const getPreviousElementWithClass = (element, className) => {
const elements = Array.from(document.querySelectorAll(`.${className}`));
const currentIndex = elements.findIndex(el => el === element);
// Let's find the first previous element that's not hidden
let previousIndex = currentIndex - 1;
while (previousIndex >= 0 && elements[previousIndex].offsetParent === null) {
previousIndex--;
}
return elements[previousIndex] ? elements[previousIndex] : null;
};
export const getNextElementWithClass = (element, className) => {
const elements = Array.from(document.querySelectorAll(`.${className}`));
const currentIndex = elements.findIndex(el => el === element);
// Let's find the first next element that's not hidden
let nextIndex = currentIndex + 1;
while (nextIndex < elements.length && elements[nextIndex].offsetParent === null) {
nextIndex++;
}
return elements[nextIndex] ? elements[nextIndex] : null;
};
export const getIndexOfElementWithClass = (element, className) => {
const elements = Array.from(document.querySelectorAll(`.${className}`));
return elements.findIndex(el => el === element);
}
@@ -0,0 +1,102 @@
<template>
<div class="absolute z-20 top-0 bottom-10 bg-gray-100 dark:bg-gray-900 md:left-0 md:flex md:w-88 md:flex-col md:fixed md:inset-y-0"
:class="[fileStore.sidebarOpen ? 'left-0 right-0 md:left-auto md:right-auto' : '-left-[200%] right-[200%] md:left-auto md:right-auto']"
>
<file-list></file-list>
</div>
<div class="md:pl-88 flex flex-col flex-1 min-h-screen max-h-screen max-w-full">
<log-list class="pb-16 md:pb-12"></log-list>
</div>
<div class="absolute bottom-4 right-4 flex items-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mr-5 -mb-0.5">
<template v-if="logViewerStore.performance?.requestTime">
<span><span class="hidden md:inline">Memory: </span><span class="font-semibold">{{ logViewerStore.performance.memoryUsage }}</span></span>
<span class="mx-1.5">&middot;</span>
<span><span class="hidden md:inline">Duration: </span><span class="font-semibold">{{ logViewerStore.performance.requestTime }}</span></span>
<span class="mx-1.5">&middot;</span>
</template>
<span><span class="hidden md:inline">Version: </span><span class="font-semibold">{{ LogViewer.version }}</span></span>
</p>
<a href="https://www.buymeacoffee.com/arunas" target="_blank" v-if="LogViewer.show_support_link">
<bmc-logo class="h-6 w-auto" title="Support me by buying me a cup of coffee ❤️" />
</a>
</div>
<keyboard-shortcuts-overlay />
</template>
<script setup>
import FileList from '../components/FileList.vue';
import LogList from '../components/LogList.vue';
import { useHostStore } from '../stores/hosts.js';
import { useLogViewerStore } from '../stores/logViewer.js';
import { useFileStore } from '../stores/files.js';
import { useSearchStore } from '../stores/search.js';
import { usePaginationStore } from '../stores/pagination.js';
import { useRoute, useRouter } from 'vue-router';
import { onBeforeMount, onBeforeUnmount, onMounted, watch } from 'vue';
import BmcLogo from '../components/BmcLogo.vue';
import { replaceQuery } from '../helpers.js';
import { registerGlobalShortcuts, unregisterGlobalShortcuts } from '../keyboardNavigation';
import KeyboardShortcutsOverlay from '../components/KeyboardShortcutsOverlay.vue';
const hostStore = useHostStore();
const logViewerStore = useLogViewerStore();
const fileStore = useFileStore();
const searchStore = useSearchStore();
const paginationStore = usePaginationStore();
const route = useRoute();
const router = useRouter();
onBeforeMount(() => {
logViewerStore.syncTheme();
registerGlobalShortcuts();
});
onBeforeUnmount(() => {
unregisterGlobalShortcuts();
})
onMounted(() => {
// This makes sure we react to device's dark mode changes
setInterval(logViewerStore.syncTheme, 1000);
})
// watch for URL query changes and update the store values
watch(
() => route.query,
(query) => {
fileStore.selectFile(query.file || null);
paginationStore.setPage(query.page || 1);
searchStore.setQuery(query.query || '');
logViewerStore.loadLogs();
},
{ immediate: true },
)
watch(
() => route.query.host,
async (newHost) => {
hostStore.selectHost(newHost || null);
if (newHost && !hostStore.selectedHostIdentifier) {
// the host no longer exists, remove it from the URL
replaceQuery(router, 'host', null);
}
fileStore.reset();
await fileStore.loadFolders();
logViewerStore.loadLogs();
},
{ immediate: true },
)
onMounted(() => {
window.onresize = function () {
logViewerStore.setViewportDimensions(window.innerWidth, window.innerHeight);
};
})
</script>
@@ -0,0 +1,347 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import { useLocalStorage } from '@vueuse/core';
import { useHostStore } from './hosts.js';
import { useLogViewerStore } from './logViewer.js';
export const useFileStore = defineStore({
id: 'files',
state: () => ({
// data
folders: [],
direction: useLocalStorage('fileViewerDirection', 'desc'),
selectedFileIdentifier: null,
fileTypesAvailable: [],
selectedFileTypes: useLocalStorage('selectedFileTypes', []),
error: null,
clearingCache: {},
cacheRecentlyCleared: {},
deleting: {},
abortController: null,
// control variables
loading: false,
checkBoxesVisibility: false,
filesChecked: [],
openFolderIdentifiers: [],
foldersInView: [],
containerTop: 0,
sidebarOpen: false,
}),
getters: {
selectedHost() {
const hostStore = useHostStore();
return hostStore.selectedHost;
},
hostQueryParam() {
const hostStore = useHostStore();
return hostStore.hostQueryParam;
},
filteredFolders: (state) => {
// filter the folders based on the selected file types.
// If a particular folder is now empty, filter it out.
return state.folders.map(folder => ({
...folder,
files: folder.files.filter(file => state.selectedFileTypes.includes(file.type.value)),
})).filter(folder => folder.files.length > 0);
},
files: (state) => state.folders.flatMap((folder) => folder.files),
selectedFile: (state) => state.files.find((file) => file.identifier === state.selectedFileIdentifier),
foldersOpen(state) {
return state.openFolderIdentifiers.map((identifier) => state.folders.find((folder) => folder.identifier === identifier));
},
isOpen() {
return (folder) => this.foldersOpen.map(f => f.identifier).includes(folder.identifier);
},
isChecked: (state) => (file) => state.filesChecked.includes(
typeof file === 'string' ? file : file.identifier
),
shouldBeSticky(state) {
return (folder) => this.isOpen(folder) && state.foldersInView.map(f => f.identifier).includes(folder.identifier);
},
isInViewport() {
return (index) => this.pixelsAboveFold(index) > -36
},
pixelsAboveFold: (state) => (folder) => {
let folderContainer = document.getElementById('folder-' + folder);
if (!folderContainer) return false;
let row = folderContainer.getClientRects()[0];
return (row.top + row.height) - state.containerTop;
},
hasFilesChecked: (state) => state.filesChecked.length > 0,
fileTypesSelected: (state) => state.fileTypesAvailable.filter((fileType) => state.selectedFileTypes.includes(fileType.identifier)),
/** @returns {string[]} */
fileTypesExcluded: (state) => state.fileTypesAvailable
.filter((fileType) => !state.selectedFileTypes.includes(fileType.identifier))
.map((fileType) => fileType.identifier),
selectedFileTypesString() {
const fileTypesSelected = this.fileTypesSelected.map(fileType => fileType.name);
if (fileTypesSelected.length === 0) {
return 'Please select at least one file type';
} else if (fileTypesSelected.length === 1) {
return fileTypesSelected[0];
} else if (fileTypesSelected.length === 2) {
return fileTypesSelected.join(' and ');
} else if (fileTypesSelected.length === 3) {
return fileTypesSelected.slice(0, -1).join(', ') + ' and ' + fileTypesSelected.slice(-1);
} else {
return fileTypesSelected.slice(0, 3).join(', ') + ' and ' + (fileTypesSelected.length - 3) + ' more';
}
},
},
actions: {
setDirection(direction) {
this.direction = direction;
},
selectFile(logFileIdentifier) {
if (this.selectedFileIdentifier === logFileIdentifier) return;
this.selectedFileIdentifier = logFileIdentifier;
this.openFolderForActiveFile();
this.sidebarOpen = false;
},
openFolderForActiveFile() {
if (this.selectedFile) {
const folder = this.folders.find(folder => folder.files.some(file => file.identifier === this.selectedFile.identifier));
if (folder && !this.isOpen(folder)) {
this.toggle(folder);
}
}
},
openRootFolderIfNoneOpen() {
const rootFolder = this.folders.find(folder => folder.is_root);
if (rootFolder && this.openFolderIdentifiers.length === 0) {
this.openFolderIdentifiers.push(rootFolder.identifier);
}
},
loadFolders() {
// abort the previous request which might now be outdated
if (this.abortController) {
this.abortController.abort();
}
if (!this.selectedHost) {
this.folders = [];
this.error = null;
this.loading = false;
return;
}
this.abortController = new AbortController();
this.loading = true;
// load the folders from the server
return axios.get(`${LogViewer.basePath}/api/folders`, {
params: {
host: this.hostQueryParam,
direction: this.direction,
},
signal: this.abortController.signal
})
.then(({ data }) => {
this.folders = data;
this.error = data.error || null;
this.loading = false;
if (this.openFolderIdentifiers.length === 0) {
this.openFolderForActiveFile();
this.openRootFolderIfNoneOpen();
}
this.setAvailableFileTypes(data);
this.onScroll();
})
.catch((error) => {
// aborted, thus we don't need to display that as an error.
if (error.code === 'ERR_CANCELED') return;
this.loading = false;
this.error = error.message;
if (error.response?.data?.message) {
this.error += ': ' + error.response.data.message;
}
console.error(error);
})
},
setAvailableFileTypes(folders) {
const fileTypes = folders.flatMap(folder => folder.files.map(file => file.type));
const uniqueFileTypes = [...new Set(fileTypes.map(fileType => fileType.value))];
this.fileTypesAvailable = uniqueFileTypes.map(fileType => {
return {
identifier: fileType,
name: fileTypes.find(ft => ft.value === fileType).name,
count: fileTypes.filter(ft => ft.value === fileType).length,
}
});
if (!this.selectedFileTypes || this.selectedFileTypes.length === 0) {
this.selectedFileTypes = uniqueFileTypes;
}
},
toggle(folder) {
if (this.isOpen(folder)) {
this.openFolderIdentifiers = this.openFolderIdentifiers.filter(f => f !== folder.identifier);
} else {
this.openFolderIdentifiers.push(folder.identifier);
}
this.onScroll();
},
onScroll() {
let vm = this;
this.foldersOpen.forEach(function (folder) {
if (vm.isInViewport(folder)) {
if (!vm.foldersInView.includes(folder)) {
vm.foldersInView.push(folder);
}
} else {
vm.foldersInView = vm.foldersInView.filter(f => f !== folder);
}
})
},
reset() {
this.openFolderIdentifiers = [];
this.foldersInView = [];
const container = document.getElementById('file-list-container');
if (container) {
this.containerTop = container.getBoundingClientRect().top;
container.scrollTo(0, 0);
}
},
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
},
checkBoxToggle(file) {
if (this.isChecked(file)) {
this.filesChecked = this.filesChecked.filter(f => f !== file);
} else {
this.filesChecked.push(file);
}
},
toggleCheckboxVisibility() {
this.checkBoxesVisibility = !this.checkBoxesVisibility;
},
resetChecks() {
this.filesChecked = [];
this.checkBoxesVisibility = false;
},
clearCacheForFile(file) {
this.clearingCache[file.identifier] = true;
return axios.post(`${LogViewer.basePath}/api/files/${file.identifier}/clear-cache`, {}, {
params: { host: this.hostQueryParam }
})
.then(() => {
if (file.identifier === this.selectedFileIdentifier) {
useLogViewerStore().loadLogs();
}
this.cacheRecentlyCleared[file.identifier] = true;
setTimeout(() => this.cacheRecentlyCleared[file.identifier] = false, 2000);
})
.catch((error) => console.error(error))
.finally(() => this.clearingCache[file.identifier] = false);
},
deleteFile(file) {
return axios.delete(`${LogViewer.basePath}/api/files/${file.identifier}`, {
params: { host: this.hostQueryParam }
})
.then(() => this.loadFolders())
},
clearCacheForFolder(folder) {
this.clearingCache[folder.identifier] = true;
return axios.post(`${LogViewer.basePath}/api/folders/${folder.identifier}/clear-cache`, {}, {
params: { host: this.hostQueryParam }
})
.then(() => {
if (folder.files.some(file => file.identifier === this.selectedFileIdentifier)) {
useLogViewerStore().loadLogs();
}
this.cacheRecentlyCleared[folder.identifier] = true;
setTimeout(() => this.cacheRecentlyCleared[folder.identifier] = false, 2000);
})
.catch((error) => console.error(error))
.finally(() => {
this.clearingCache[folder.identifier] = false;
})
},
deleteFolder(folder) {
this.deleting[folder.identifier] = true;
return axios.delete(`${LogViewer.basePath}/api/folders/${folder.identifier}`, {
params: { host: this.hostQueryParam }
})
.then(() => this.loadFolders())
.catch((error) => console.error(error))
.finally(() => {
this.deleting[folder.identifier] = false;
})
},
deleteSelectedFiles() {
return axios.post(`${LogViewer.basePath}/api/delete-multiple-files`, {
files: this.filesChecked
}, {
params: { host: this.hostQueryParam }
});
},
clearCacheForAllFiles() {
this.clearingCache['*'] = true;
axios.post(`${LogViewer.basePath}/api/clear-cache-all`, {}, {
params: { host: this.hostQueryParam }
})
.then(() => {
this.cacheRecentlyCleared['*'] = true;
setTimeout(() => this.cacheRecentlyCleared['*'] = false, 2000);
useLogViewerStore().loadLogs();
})
.catch((error) => console.error(error))
.finally(() => this.clearingCache['*'] = false);
},
},
})
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia';
export const useHostStore = defineStore({
id: 'hosts',
state: () => ({
selectedHostIdentifier: null,
}),
getters: {
supportsHosts() {
return LogViewer.supports_hosts;
},
hosts() {
return LogViewer.hosts || [];
},
hasRemoteHosts() {
return this.hosts.some(host => host.is_remote);
},
selectedHost() {
return this.hosts.find(host => host.identifier === this.selectedHostIdentifier);
},
localHost() {
return this.hosts.find(host => !host.is_remote);
},
hostQueryParam() {
return this.selectedHost && this.selectedHost.is_remote ? this.selectedHost.identifier : undefined;
},
},
actions: {
selectHost(host) {
if (! this.supportsHosts) {
host = null;
}
if (typeof host === 'string') {
host = this.hosts.find(h => h.identifier === host);
}
if (!host) {
host = this.hosts.find(h => !h.is_remote);
}
this.selectedHostIdentifier = host?.identifier || null;
}
}
})
@@ -0,0 +1,291 @@
import { defineStore } from 'pinia';
import { useFileStore } from './files.js';
import axios from 'axios';
import { useSearchStore } from './search.js';
import { nextTick, toRaw } from 'vue';
import { usePaginationStore } from './pagination.js';
import { useSeverityStore } from './severity.js';
import { useLocalStorage } from '@vueuse/core';
import { debounce } from 'lodash';
import { useHostStore } from './hosts.js';
export const Theme = {
System: 'System',
Light: 'Light',
Dark: 'Dark',
}
const defaultColumns = [
{ label: 'Datetime', data_key: 'datetime' },
{ label: 'Severity', data_key: 'level' },
{ label: 'Message', data_key: 'message' },
]
const shouldUseLocalStorage = window.LogViewer?.defaults?.use_local_storage ?? true;
export const useLogViewerStore = defineStore({
id: 'logViewer',
state: () => ({
theme: shouldUseLocalStorage
? useLocalStorage('logViewerTheme', window.LogViewer?.defaults?.theme || Theme.System)
: (window.LogViewer?.defaults?.theme || Theme.System),
shorterStackTraces: shouldUseLocalStorage
? useLocalStorage('logViewerShorterStackTraces', window.LogViewer?.defaults?.shorter_stack_traces ?? false)
: (window.LogViewer?.defaults?.shorter_stack_traces ?? false),
resultsPerPage: shouldUseLocalStorage
? useLocalStorage('logViewerResultsPerPage', window.LogViewer?.defaults?.per_page ?? 25)
: (window.LogViewer?.defaults?.per_page ?? 25),
direction: shouldUseLocalStorage
? useLocalStorage('logViewerDirection', window.LogViewer?.defaults?.log_sorting_order || 'desc')
: (window.LogViewer?.defaults?.log_sorting_order || 'desc'),
helpSlideOverOpen: false,
// Log data
loading: false,
error: null,
logs: [],
columns: defaultColumns,
levelCounts: [],
performance: {},
hasMoreResults: false,
percentScanned: 100,
abortController: null,
// Log scrolling behaviour data
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
stacksOpen: [],
stacksInView: [],
stackTops: {},
containerTop: 0,
showLevelsDropdown: true,
}),
getters: {
selectedFile() {
const fileStore = useFileStore();
return fileStore.selectedFile;
},
isOpen: (state) => (index) => state.stacksOpen.includes(index),
isMobile: (state) => state.viewportWidth <= 1023,
tableRowHeight() {
return this.isMobile ? 29 : 36;
},
headerHeight() {
return this.isMobile ? 0 : 36;
},
shouldBeSticky(state) {
return (index) => this.isOpen(index) && state.stacksInView.includes(index);
},
stickTopPosition() {
return (index) => {
let aboveFold = this.pixelsAboveFold(index);
if (aboveFold < 0) {
return Math.max(
this.headerHeight - this.tableRowHeight,
this.headerHeight + aboveFold
) + 'px';
}
return this.headerHeight + 'px';
}
},
pixelsAboveFold(state) {
return (index) => {
let tbody = document.getElementById('tbody-' + index);
if (!tbody) return false;
let row = tbody.getClientRects()[0];
return (row.top + row.height - this.tableRowHeight - this.headerHeight) - state.containerTop;
}
},
isInViewport() {
return (index) => this.pixelsAboveFold(index) > -this.tableRowHeight;
},
perPageOptions() {
const baseOptions = window.LogViewer.per_page_options || [10, 25, 50, 100, 250, 500];
if (! baseOptions.includes(this.resultsPerPage)) {
baseOptions.push(this.resultsPerPage);
baseOptions.sort((a, b) => a - b);
}
return baseOptions;
},
},
actions: {
setViewportDimensions(width, height) {
this.viewportWidth = width;
this.viewportHeight = height;
const container = document.querySelector('.log-item-container');
if (container) {
this.containerTop = container.getBoundingClientRect().top;
}
},
toggleTheme() {
switch (this.theme) {
case Theme.System:
this.theme = Theme.Light;
break;
case Theme.Light:
this.theme = Theme.Dark;
break;
default:
this.theme = Theme.System;
break;
}
this.syncTheme();
},
syncTheme() {
const theme = this.theme;
if (theme === Theme.Dark || (theme === Theme.System && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
},
toggle(index) {
if (this.isOpen(index)) {
this.stacksOpen = this.stacksOpen.filter(idx => idx !== index)
} else {
this.stacksOpen.push(index)
}
this.onScroll();
},
onScroll() {
let vm = this;
this.stacksOpen.forEach(function (index) {
if (vm.isInViewport(index)) {
if (!vm.stacksInView.includes(index)) {
vm.stacksInView.push(index);
}
vm.stackTops[index] = vm.stickTopPosition(index);
} else {
vm.stacksInView = vm.stacksInView.filter(idx => idx !== index);
delete vm.stackTops[index];
}
})
},
reset() {
this.stacksOpen = [];
this.stacksInView = [];
this.stackTops = {};
const container = document.querySelector('.log-item-container');
if (!container) return;
this.containerTop = container.getBoundingClientRect().top;
container.scrollTo(0, 0);
},
loadLogs: debounce(function ({ silently = false } = {}) {
const hostStore = useHostStore();
const fileStore = useFileStore();
const searchStore = useSearchStore();
const paginationStore = usePaginationStore();
const severityStore = useSeverityStore();
// abort if the files are not ready yet
if (fileStore.folders.length === 0) return;
// abort the previous request which might now be outdated
if (this.abortController) {
this.abortController.abort();
}
// abort if there's no selected file and no query
if (!this.selectedFile && !searchStore.hasQuery) return;
this.abortController = new AbortController();
const params = {
host: hostStore.hostQueryParam,
file: this.selectedFile?.identifier,
direction: this.direction,
query: searchStore.query,
page: paginationStore.currentPage,
per_page: this.resultsPerPage,
exclude_levels: toRaw(severityStore.excludedLevels),
exclude_file_types: toRaw(fileStore.fileTypesExcluded),
shorter_stack_traces: this.shorterStackTraces,
};
if (!silently) {
this.loading = true;
}
axios.get(`${LogViewer.basePath}/api/logs`, { params, signal: this.abortController.signal })
.then(({ data }) => {
if (params.host) {
// because the host is different, we need to update the log links to be local instead of remote.
this.logs = data.logs.map(log => {
const queryParams = { host: params.host, file: log.file_identifier, query: `log-index:${log.index}` };
log.url = `${window.location.host}${LogViewer.basePath}?${new URLSearchParams(queryParams)}`;
return log;
})
} else {
this.logs = data.logs;
}
this.columns = data.columns || defaultColumns;
this.hasMoreResults = data.hasMoreResults;
this.percentScanned = data.percentScanned;
this.error = data.error || null;
this.performance = data.performance || {};
severityStore.setLevelCounts(data.levelCounts);
paginationStore.setPagination(data.pagination);
this.loading = false;
if (!silently) {
nextTick(() => {
document.dispatchEvent(new Event('logsPageLoaded'));
this.reset();
if (data.expandAutomatically) {
this.stacksOpen.push(0);
}
});
} else {
document.dispatchEvent(new Event('logsPageLoadedSilently'));
}
if (this.hasMoreResults) {
this.loadLogs({ silently: true });
}
})
.catch((error) => {
// aborted, thus we don't need to display that as an error.
if (error.code === 'ERR_CANCELED') {
this.hasMoreResults = false;
this.percentScanned = 100;
return;
}
this.loading = false;
this.error = error.message;
if (error.response?.data?.message) {
this.error += ': ' + error.response.data.message;
}
console.error(error);
});
}, 10),
},
})
@@ -0,0 +1,36 @@
import { defineStore } from 'pinia';
export const usePaginationStore = defineStore({
id: 'pagination',
state: () => ({
page: 1,
pagination: {},
}),
getters: {
currentPage: (state) => state.page !== 1 ? Number(state.page) : null,
links: (state) => (state.pagination?.links || []).slice(1, -1),
linksShort: (state) => (state.pagination?.links_short || []).slice(1, -1),
hasPages: (state) => state.pagination?.last_page > 1,
hasMorePages: (state) => state.pagination?.next_page_url !== null,
},
actions: {
setPagination(pagination) {
this.pagination = pagination;
if (this.pagination?.last_page < this.page) {
this.page = this.pagination?.last_page;
}
},
setPage(page) {
this.page = Number(page);
},
},
})
@@ -0,0 +1,60 @@
import { defineStore } from 'pinia';
import axios from 'axios';
export const useSearchStore = defineStore({
id: 'search',
state: () => ({
query: '',
searchMoreRoute: null,
searching: false,
percentScanned: 0,
error: null,
}),
getters: {
hasQuery: (state) => String(state.query).trim() !== '',
},
actions: {
init() {
this.checkSearchProgress();
},
setQuery(query) {
this.query = query;
},
update(query, error, searchMoreRoute, searching = false, percentScanned = 0) {
this.query = query;
this.error = (error && error !== '') ? error : null;
this.searchMoreRoute = searchMoreRoute;
this.searching = searching;
this.percentScanned = percentScanned;
if (this.searching) {
this.checkSearchProgress();
}
},
checkSearchProgress() {
const queryChecked = this.query;
if (queryChecked === '') return;
const queryParams = '?' + new URLSearchParams({ query: queryChecked });
axios.get(this.searchMoreRoute + queryParams)
.then((response) => {
const data = response.data;
if (this.query !== queryChecked) return;
const wasPreviouslySearching = this.searching;
this.searching = data.hasMoreResults;
this.percentScanned = data.percentScanned;
if (this.searching) {
this.checkSearchProgress();
} else if (wasPreviouslySearching && !this.searching) {
window.dispatchEvent(new CustomEvent('reload-results'));
}
});
},
}
})
@@ -0,0 +1,62 @@
import { defineStore } from 'pinia';
import { useLocalStorage } from '@vueuse/core';
export const useSeverityStore = defineStore({
id: 'severity',
state: () => ({
allLevels: [], // should be updated by the backend
excludedLevels: useLocalStorage('excludedLevels', []),
levelCounts: [],
}),
getters: {
levelsFound: (state) => (state.levelCounts || []).filter(level => level.count > 0),
totalResults() {
return this.levelsFound.reduce((total, level) => total + level.count, 0);
},
levelsSelected() {
return this.levelsFound.filter(levelCount => levelCount.selected);
},
totalResultsSelected() {
return this.levelsSelected.reduce((total, level) => total + level.count, 0);
},
},
actions: {
setLevelCounts(levelCounts) {
if (levelCounts.hasOwnProperty('length')) {
this.levelCounts = levelCounts;
} else {
this.levelCounts = Object.values(levelCounts);
}
this.allLevels = levelCounts.map(levelCount => levelCount.level);
},
selectAllLevels() {
this.excludedLevels = [];
this.levelCounts.forEach(levelCount => levelCount.selected = true);
},
deselectAllLevels() {
this.excludedLevels = this.allLevels;
this.levelCounts.forEach(levelCount => levelCount.selected = false);
},
toggleLevel(level) {
const levelCount = this.levelCounts.find(levelCount => levelCount.level === level) || {};
if (this.excludedLevels.includes(level)) {
this.excludedLevels = this.excludedLevels.filter(excludedLevel => excludedLevel !== level);
levelCount.selected = true;
} else {
this.excludedLevels.push(level);
levelCount.selected = false;
}
},
},
})