123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854 |
- <script setup lang="ts">
- import type { SorterResult, TablePaginationConfig } from 'ant-design-vue/es/table/interface'
- import type { AccessLogEntry, AdvancedSearchRequest, PreflightResponse } from '@/api/nginx_log'
- import { DownOutlined, ExclamationCircleOutlined, LoadingOutlined, ReloadOutlined } from '@ant-design/icons-vue'
- import { message, Tag } from 'ant-design-vue'
- import dayjs from 'dayjs'
- import nginx_log from '@/api/nginx_log'
- import { useWebSocketEventBus } from '@/composables/useWebSocketEventBus'
- import { bytesToSize } from '@/lib/helper'
- import SearchFilters from './components/SearchFilters.vue'
- interface Props {
- logPath?: string
- }
- interface SearchSummary {
- pv?: number
- uv?: number
- total_traffic?: number
- unique_pages?: number
- avg_traffic_per_pv?: number
- }
- const props = defineProps<Props>()
- // Route and router
- const route = useRoute()
- // WebSocket event bus for index ready notifications
- const { subscribe: subscribeToEvent } = useWebSocketEventBus()
- // Use provided log path or let backend determine default
- const logPath = computed(() => props.logPath || undefined)
- // Check if this is an error log (error logs don't support structured processing)
- const isErrorLog = computed(() => {
- if (props.logPath) {
- return props.logPath.includes('error.log') || props.logPath.includes('error_log')
- }
- // Check route path
- return route.path.includes('error')
- })
- // Reactive data - Only advanced search mode now
- const timeRange = ref({
- start: null as dayjs.Dayjs | null, // Will be set from server time range
- end: null as dayjs.Dayjs | null, // Will be set from server time range
- })
- const preflightResponse = ref<PreflightResponse | null>(null)
- const searchFilters = ref({
- query: '',
- ip: '',
- method: '',
- status: [] as string[],
- path: '',
- user_agent: '',
- referer: '',
- browser: [] as string[],
- os: [] as string[],
- device: [] as string[],
- })
- const searchResults = ref<AccessLogEntry[]>([])
- const searchTotal = ref(0)
- const searchLoading = ref(false)
- const indexingStatus = ref<'idle' | 'indexing' | 'ready' | 'failed'>('idle')
- const currentPage = ref(1)
- const pageSize = ref(50)
- const sortBy = ref('timestamp')
- const sortOrder = ref<'asc' | 'desc'>('desc')
- // Cache for failed path validations to prevent repeated calls
- const pathValidationCache = ref<Map<string, boolean>>(new Map())
- // Removed showAdvancedFilters - filters are always shown now
- // Date range for ARangePicker
- const dateRange = computed({
- get: () => {
- const start = timeRange.value.start
- const end = timeRange.value.end
- // Return undefined if either value is null/undefined, otherwise return the tuple
- return (start && end) ? [start, end] as [typeof start, typeof end] : undefined
- },
- set: value => {
- if (value && Array.isArray(value) && value.length === 2) {
- timeRange.value.start = value[0]
- timeRange.value.end = value[1]
- }
- },
- })
- const filteredEntries = computed(() => {
- // Since we removed the simple filter, just return search results directly
- return searchResults.value
- })
- // Summary stats from search response
- const searchSummary = ref<SearchSummary | null>(null)
- // Computed properties for indexing status
- const isLoading = computed(() => searchLoading.value)
- const isIndexing = computed(() => indexingStatus.value === 'indexing')
- const isReady = computed(() => indexingStatus.value === 'ready')
- const isFailed = computed(() => indexingStatus.value === 'failed')
- // Combined status computed properties
- const shouldShowContent = computed(() => !isFailed.value)
- const shouldShowControls = computed(() => isReady.value)
- const shouldShowIndexingSpinner = computed(() => isIndexing.value)
- const shouldShowResults = computed(() => isReady.value && searchSummary.value !== null)
- // Status code color mapping
- function getStatusColor(status: number): string {
- if (status >= 200 && status < 300)
- return 'success'
- if (status >= 300 && status < 400)
- return 'processing'
- if (status >= 400 && status < 500)
- return 'warning'
- if (status >= 500)
- return 'error'
- return 'default'
- }
- // Device type color mapping
- function getDeviceColor(deviceType: string): string {
- const colors: Record<string, string> = {
- 'Desktop': 'blue',
- 'Mobile': 'green',
- 'Tablet': 'orange',
- 'Bot': 'red',
- 'TV': 'purple',
- 'Smart Speaker': 'cyan',
- 'Game Console': 'magenta',
- 'Wearable': 'gold',
- }
- return colors[deviceType] || 'default'
- }
- // Get sort order for column
- function getSortOrder(fieldName: string): 'ascend' | 'descend' | undefined {
- if (sortBy.value === fieldName) {
- return sortOrder.value === 'asc' ? 'ascend' : 'descend'
- }
- return undefined
- }
- // Table columns configuration
- const structuredLogColumns = computed(() => [
- {
- title: $gettext('Time'),
- dataIndex: 'timestamp',
- width: 140,
- fixed: 'left' as const,
- sorter: true,
- sortOrder: getSortOrder('timestamp'),
- customRender: ({ record }: { record: AccessLogEntry }) => h('span', dayjs.unix(record.timestamp).format('YYYY-MM-DD HH:mm:ss')),
- },
- {
- title: $gettext('IP'),
- dataIndex: 'ip',
- width: 350,
- sorter: true,
- sortOrder: getSortOrder('ip'),
- customRender: ({ record }: { record: AccessLogEntry }) => {
- const locationParts: string[] = []
- if (record.region_code) {
- locationParts.push(record.region_code)
- }
- if (record.province) {
- locationParts.push(record.province)
- }
- if (record.city) {
- locationParts.push(record.city)
- }
- return h('div', { class: 'flex items-center gap-2' }, [
- locationParts.length > 0 ? h(Tag, { color: 'blue', size: 'small' }, { default: () => locationParts.join(' · ') }) : null,
- h('span', record.ip),
- ])
- },
- },
- {
- title: $gettext('Request'),
- dataIndex: 'path',
- ellipsis: {
- showTitle: true,
- },
- width: 350,
- customRender: ({ record }: { record: AccessLogEntry }) => {
- let methodColor = 'default'
- if (record.method === 'GET')
- methodColor = 'green'
- else if (record.method === 'POST')
- methodColor = 'blue'
- return h('div', [
- h(Tag, {
- color: methodColor,
- size: 'small',
- }, { default: () => record.method }),
- h('span', { class: 'ml-1' }, record.path),
- ])
- },
- },
- {
- title: $gettext('Status'),
- dataIndex: 'status',
- width: 80,
- sorter: true,
- sortOrder: getSortOrder('status'),
- customRender: ({ record }: { record: AccessLogEntry }) => h(Tag, { color: getStatusColor(record.status) }, { default: () => record.status }),
- },
- {
- title: $gettext('Size'),
- dataIndex: 'bytes_sent',
- width: 80,
- sorter: true,
- sortOrder: getSortOrder('bytes_sent'),
- customRender: ({ record }: { record: AccessLogEntry }) => h('span', bytesToSize(record.bytes_sent)),
- },
- {
- title: $gettext('Browser'),
- dataIndex: 'browser',
- width: 120,
- sorter: true,
- sortOrder: getSortOrder('browser'),
- customRender: ({ record }: { record: AccessLogEntry }) => {
- if (record.browser && record.browser !== 'Unknown') {
- const browserText = record.browser_version
- ? `${record.browser} ${record.browser_version}`
- : record.browser
- return h('div', browserText)
- }
- return null
- },
- },
- {
- title: $gettext('OS'),
- dataIndex: 'os',
- width: 120,
- sorter: true,
- sortOrder: getSortOrder('os'),
- customRender: ({ record }: { record: AccessLogEntry }) => {
- if (record.os && record.os !== 'Unknown') {
- const osText = record.os_version
- ? `${record.os} ${record.os_version}`
- : record.os
- return h('div', osText)
- }
- return null
- },
- },
- {
- title: $gettext('Device'),
- dataIndex: 'device_type',
- width: 90,
- sorter: true,
- sortOrder: getSortOrder('device_type'),
- customRender: ({ record }: { record: AccessLogEntry }) => record.device_type
- ? h(Tag, { color: getDeviceColor(record.device_type), size: 'small' }, { default: () => record.device_type })
- : null,
- },
- {
- title: $gettext('Referer'),
- dataIndex: 'referer',
- ellipsis: true,
- width: 200,
- customRender: ({ record }: { record: AccessLogEntry }) => record.referer && record.referer !== '-'
- ? h('span', record.referer)
- : null,
- },
- ])
- // Time range presets (Grafana-style)
- const timePresets = [
- { label: () => $gettext('Last 15 minutes'), value: () => ({ start: dayjs().subtract(15, 'minute'), end: dayjs() }) },
- { label: () => $gettext('Last 30 minutes'), value: () => ({ start: dayjs().subtract(30, 'minute'), end: dayjs() }) },
- { label: () => $gettext('Last hour'), value: () => ({ start: dayjs().subtract(1, 'hour'), end: dayjs() }) },
- { label: () => $gettext('Last 4 hours'), value: () => ({ start: dayjs().subtract(4, 'hour'), end: dayjs() }) },
- { label: () => $gettext('Last 12 hours'), value: () => ({ start: dayjs().subtract(12, 'hour'), end: dayjs() }) },
- { label: () => $gettext('Last 24 hours'), value: () => ({ start: dayjs().subtract(24, 'hour'), end: dayjs() }) },
- { label: () => $gettext('Last 7 days'), value: () => ({ start: dayjs().subtract(7, 'day'), end: dayjs() }) },
- { label: () => $gettext('Last 30 days'), value: () => ({ start: dayjs().subtract(30, 'day'), end: dayjs() }) },
- ]
- // Load structured logs function - now only uses advanced search
- async function loadLogs() {
- await performAdvancedSearch()
- }
- // Advanced search function
- async function performAdvancedSearch() {
- // Don't search if time range is not set yet
- if (!timeRange.value.start || !timeRange.value.end) {
- return
- }
- searchLoading.value = true
- try {
- const searchRequest: AdvancedSearchRequest = {
- start_time: timeRange.value.start.unix(),
- end_time: timeRange.value.end.unix(),
- query: searchFilters.value.query || undefined,
- ip: searchFilters.value.ip || undefined,
- method: searchFilters.value.method || undefined,
- status: searchFilters.value.status.length > 0 ? searchFilters.value.status.map(s => Number.parseInt(s)).filter(n => !Number.isNaN(n)) : undefined,
- path: searchFilters.value.path || undefined,
- user_agent: searchFilters.value.user_agent || undefined,
- referer: searchFilters.value.referer || undefined,
- browser: searchFilters.value.browser.length > 0 ? searchFilters.value.browser.join(',') : undefined,
- os: searchFilters.value.os.length > 0 ? searchFilters.value.os.join(',') : undefined,
- device: searchFilters.value.device.length > 0 ? searchFilters.value.device.join(',') : undefined,
- limit: pageSize.value,
- offset: (currentPage.value - 1) * pageSize.value,
- sort_by: sortBy.value,
- sort_order: sortOrder.value,
- log_path: logPath.value,
- }
- const result = await nginx_log.search(searchRequest)
- searchResults.value = result.entries || []
- searchTotal.value = result.total || 0
- searchSummary.value = result.summary || null
- }
- catch (error: unknown) {
- // Check if this is a path validation error - don't show message for these
- if (isPathValidationError(error)) {
- // Silently reset results for path validation errors
- searchResults.value = []
- searchTotal.value = 0
- return
- }
- // Reset results on error
- searchResults.value = []
- searchTotal.value = 0
- searchSummary.value = null
- }
- finally {
- searchLoading.value = false
- }
- }
- // Load preflight information (single request, no retries)
- async function loadPreflight(): Promise<boolean> {
- // Check cache for known invalid paths
- const currentPath = logPath.value || ''
- if (pathValidationCache.value.has(currentPath) && !pathValidationCache.value.get(currentPath)) {
- throw new Error('Path validation failed (cached)')
- }
- try {
- preflightResponse.value = await nginx_log.getPreflight(logPath.value)
- if (preflightResponse.value.available) {
- // Cache this path as valid and set time range
- pathValidationCache.value.set(currentPath, true)
- timeRange.value.start = dayjs.unix(preflightResponse.value.start_time)
- timeRange.value.end = dayjs.unix(preflightResponse.value.end_time)
- return true // Index is ready
- }
- else {
- // Index is not ready, will wait for event notification
- // Don't show message here - let the UI status handle it
- // Use default range temporarily
- timeRange.value.start = dayjs().subtract(7, 'day')
- timeRange.value.end = dayjs()
- return false // Index not ready
- }
- }
- catch (error: unknown) {
- // Check if this is a path validation error by error code
- if (isPathValidationError(error)) {
- // Cache this path as invalid to prevent future calls
- pathValidationCache.value.set(currentPath, false)
- throw error // Immediately fail for path validation errors
- }
- // For other errors, set fallback range but don't show error message here
- // The error will be handled by the caller
- timeRange.value.start = dayjs().subtract(7, 'day')
- timeRange.value.end = dayjs()
- throw error // Let the caller handle the error message
- }
- }
- // Apply time preset
- function applyTimePreset(preset: { value: () => { start: dayjs.Dayjs, end: dayjs.Dayjs } }) {
- const range = preset.value()
- timeRange.value = range
- loadLogs()
- }
- // Reset search filters
- function resetSearchFilters() {
- searchFilters.value = {
- query: '',
- ip: '',
- method: '',
- status: [],
- path: '',
- user_agent: '',
- referer: '',
- browser: [],
- os: [],
- device: [],
- }
- currentPage.value = 1
- performAdvancedSearch()
- }
- // Note: handleSortingChange function removed - sorting is now handled directly in handleTableChange
- // Handle table sorting and pagination change
- function handleTableChange(
- pagination: TablePaginationConfig,
- filters: Record<string, unknown>,
- sorter: SorterResult<AccessLogEntry> | SorterResult<AccessLogEntry>[],
- ) {
- let shouldResetPage = false
- // Update page size first
- if (pagination.pageSize !== undefined && pagination.pageSize !== pageSize.value) {
- pageSize.value = pagination.pageSize
- shouldResetPage = true // Reset to first page when page size changes
- }
- // Handle sorting changes
- const singleSorter = Array.isArray(sorter) ? sorter[0] : sorter
- if (singleSorter?.field && singleSorter?.order) {
- const newSortBy = mapColumnToSortField(String(singleSorter.field))
- const newSortOrder = singleSorter.order === 'ascend' ? 'asc' : 'desc'
- // Check if sorting actually changed
- if (newSortBy !== sortBy.value || newSortOrder !== sortOrder.value) {
- sortBy.value = newSortBy
- sortOrder.value = newSortOrder
- shouldResetPage = true // Reset to first page when sorting changes
- }
- }
- // Update pagination (do this after handling sort/pageSize)
- if (shouldResetPage) {
- currentPage.value = 1
- }
- else if (pagination.current !== undefined) {
- currentPage.value = pagination.current
- }
- nextTick(() => {
- performAdvancedSearch()
- })
- }
- // Map table column names to backend sort fields
- function mapColumnToSortField(column: string): string {
- const mapping: Record<string, string> = {
- timestamp: 'timestamp',
- ip: 'ip',
- method: 'method',
- path: 'path',
- status: 'status',
- bytes_sent: 'bytes_sent',
- browser: 'browser',
- os: 'os',
- device_type: 'device_type',
- }
- return mapping[column] || 'timestamp'
- }
- // Get display name for sort field
- function getSortDisplayName(field: string): string {
- const displayNames: Record<string, string> = {
- timestamp: $gettext('Time'),
- ip: $gettext('IP Address'),
- method: $gettext('Method'),
- path: $gettext('Path'),
- status: $gettext('Status'),
- bytes_sent: $gettext('Size'),
- browser: $gettext('Browser'),
- os: $gettext('OS'),
- device_type: $gettext('Device'),
- }
- return displayNames[field] || field
- }
- // Reset sorting to default
- function resetSorting() {
- sortBy.value = 'timestamp'
- sortOrder.value = 'desc'
- currentPage.value = 1
- performAdvancedSearch()
- }
- // Refresh data - reload preflight and search results
- async function refreshData() {
- if (isErrorLog.value || isFailed.value) {
- return
- }
- try {
- indexingStatus.value = 'indexing'
- // Clear cache for current path to force fresh data
- const currentPath = logPath.value || ''
- pathValidationCache.value.delete(currentPath)
- // Reload preflight data
- const hasIndexedData = await loadPreflight()
- indexingStatus.value = 'ready'
- // Reload search results if we have valid data
- if (hasIndexedData && timeRange.value.start && timeRange.value.end) {
- await performAdvancedSearch()
- message.success($gettext('Data refreshed successfully'))
- }
- }
- catch {
- indexingStatus.value = 'failed'
- message.error($gettext('Failed to refresh data'))
- }
- }
- // Helper function to check if error is a path validation error
- function isPathValidationError(error: unknown): boolean {
- if (!(error instanceof Error)) {
- return false
- }
- try {
- // Check if error response contains path validation error codes
- const errorData = JSON.parse(error.message)
- if (errorData.scope === 'nginx_log' && errorData.code) {
- const code = Number.parseInt(errorData.code)
- // Path validation error codes: 50013, 50014, 50015
- return code === 50013 || code === 50014 || code === 50015
- }
- }
- catch {
- // Ignore parsing errors
- }
- return false
- }
- // Handle initialization with indexed data and search
- async function handleInitializedData(hasIndexedData: boolean) {
- if (timeRange.value.start && timeRange.value.end) {
- await performAdvancedSearch()
- // Only show messages for specific scenarios
- if (searchResults.value.length === 0 && hasIndexedData) {
- message.info($gettext('No logs found in the selected time range.'))
- }
- else if (searchResults.value.length > 0 && !hasIndexedData) {
- message.info($gettext('Background indexing in progress. Data will be updated automatically when ready.'))
- }
- }
- }
- // Handle index ready notification from WebSocket
- async function handleIndexReadyNotification(data: {
- log_path: string
- start_time: string
- end_time: string
- available: boolean
- index_status: string
- }) {
- const currentPath = logPath.value || ''
- // Check if the notification is for the current log path
- if (data.log_path === currentPath || (!currentPath && data.log_path)) {
- message.success($gettext('Log indexing completed! Loading updated data...'))
- try {
- // Re-request preflight to get the latest information
- const hasIndexedData = await loadPreflight()
- if (hasIndexedData) {
- indexingStatus.value = 'ready'
- // Load initial data with the updated time range
- await performAdvancedSearch()
- }
- }
- catch (error) {
- console.error('Failed to reload preflight after indexing completion:', error)
- indexingStatus.value = 'failed'
- }
- }
- }
- // Initialize on mount
- onMounted(async () => {
- // Skip initialization for error logs
- if (isErrorLog.value) {
- return
- }
- // Subscribe to index ready notifications
- subscribeToEvent('nginx_log_index_ready', handleIndexReadyNotification)
- indexingStatus.value = 'indexing'
- try {
- const hasIndexedData = await loadPreflight()
- if (hasIndexedData) {
- // Index is ready and data is available
- indexingStatus.value = 'ready'
- await handleInitializedData(hasIndexedData)
- }
- // Index is not ready yet, keep indexing status and wait for event notification
- // indexingStatus remains 'indexing'
- }
- catch {
- indexingStatus.value = 'failed'
- // Don't show any error messages - the empty page clearly indicates the issue
- }
- })
- // Watch for log path changes to clear cache and reload
- watch(logPath, (newPath, oldPath) => {
- // Clear cache when path changes
- if (newPath !== oldPath) {
- pathValidationCache.value.clear()
- }
- if (isReady.value) {
- loadLogs()
- }
- })
- // Watch for time range changes (only after initialization)
- watch(timeRange, () => {
- if (isReady.value) {
- loadLogs()
- }
- }, { deep: true })
- // Expose functions for parent component
- defineExpose({
- loadLogs,
- refreshData,
- })
- </script>
- <template>
- <div>
- <!-- Error Log Notice -->
- <div v-if="isErrorLog" class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded">
- <div class="flex items-start">
- <div class="flex-shrink-0">
- <ExclamationCircleOutlined class="h-5 w-5 text-yellow-400" />
- </div>
- <div class="ml-3">
- <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
- {{ $gettext('Error Log Detected') }}
- </h3>
- <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
- <p>{{ $gettext('Error logs do not support structured analysis as they contain free-form text messages.') }}</p>
- <p class="mt-1">
- {{ $gettext('For error logs, please use the Raw Log Viewer for better viewing experience.') }}
- </p>
- </div>
- <div class="mt-3">
- <AButton size="small" type="primary" @click="$router.push('/nginx_log')">
- {{ $gettext('Go to Raw Log Viewer') }}
- </AButton>
- </div>
- </div>
- </div>
- </div>
- <!-- Access Log Content (only show for non-error logs and non-failed preflight) -->
- <div v-else-if="shouldShowContent">
- <!-- Time Range and Search Controls (only show when ready) -->
- <div v-if="shouldShowControls" class="mb-4">
- <!-- Time Range Picker -->
- <div class="mb-4">
- <div class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
- {{ $gettext('Time Range') }}
- </div>
- <ASpace wrap>
- <ADropdown placement="bottomLeft">
- <template #overlay>
- <AMenu @click="({ key }) => applyTimePreset(timePresets[Number(key)])">
- <AMenuItem v-for="(preset, index) in timePresets" :key="index">
- {{ preset.label() }}
- </AMenuItem>
- </AMenu>
- </template>
- <AButton>
- {{ $gettext('Quick Select') }}
- <DownOutlined />
- </AButton>
- </ADropdown>
- <ARangePicker
- v-model:value="dateRange"
- show-time
- format="YYYY-MM-DD HH:mm:ss"
- @change="performAdvancedSearch"
- />
- <AButton
- type="default"
- :loading="isIndexing"
- @click="refreshData"
- >
- <template #icon>
- <ReloadOutlined />
- </template>
- </AButton>
- </ASpace>
- </div>
- <!-- Search Filters -->
- <SearchFilters
- v-model="searchFilters"
- class="mb-6"
- @search="performAdvancedSearch"
- @reset="resetSearchFilters"
- />
- <!-- Sort Info -->
- <div v-if="sortBy !== 'timestamp' || sortOrder !== 'desc'" class="mb-4 p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
- <span class="text-sm text-blue-600 dark:text-blue-300">
- {{ $gettext('Sorted by') }}: <strong>{{ getSortDisplayName(sortBy) }}</strong> ({{ sortOrder === 'asc' ? $gettext('Ascending') : $gettext('Descending') }})
- </span>
- <AButton size="small" type="text" class="ml-2" @click="resetSorting">
- {{ $gettext('Reset') }}
- </AButton>
- </div>
- </div>
- <!-- Loading State -->
- <div v-if="isLoading" class="text-center" style="padding: 40px;">
- <LoadingOutlined class="text-2xl text-blue-500" />
- <p style="margin-top: 16px;">
- {{ $gettext('Searching logs...') }}
- </p>
- </div>
- <!-- Indexing Status -->
- <div v-else-if="shouldShowIndexingSpinner" class="text-center" style="padding: 40px;">
- <LoadingOutlined class="text-2xl text-blue-500" />
- <p style="margin-top: 16px;">
- {{ $gettext('Indexing logs, please wait...') }}
- </p>
- </div>
- <!-- Search Results (show when indexing is ready and we have search results) -->
- <div v-else-if="shouldShowResults">
- <!-- Summary -->
- <div class="mb-4 p-4 bg-gray-50 dark:bg-trueGray-800 rounded">
- <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
- <div class="text-center">
- <AStatistic
- :title="$gettext('Total Entries')"
- :value="searchTotal"
- />
- </div>
- <div class="text-center">
- <AStatistic
- :title="$gettext('PV')"
- :value="searchSummary?.pv || 0"
- />
- </div>
- <div class="text-center">
- <AStatistic
- :title="$gettext('UV')"
- :value="searchSummary?.uv || 0"
- />
- </div>
- <div class="text-center">
- <AStatistic
- :title="$gettext('Traffic')"
- :value="bytesToSize(searchSummary?.total_traffic || 0)"
- />
- </div>
- <div class="text-center">
- <AStatistic
- :title="$gettext('Unique Pages')"
- :value="searchSummary?.unique_pages || 0"
- />
- </div>
- <div class="text-center">
- <AStatistic
- :title="$gettext('Avg/PV')"
- :value="bytesToSize(Math.round(searchSummary?.avg_traffic_per_pv || 0))"
- />
- </div>
- </div>
- </div>
- <!-- Log Table (show if we have entries) -->
- <div v-if="filteredEntries.length > 0">
- <ATable
- :data-source="filteredEntries"
- :pagination="{
- current: currentPage,
- pageSize,
- total: searchTotal,
- showSizeChanger: true,
- showQuickJumper: true,
- showTotal: (total, range) => $gettext('%{start}-%{end} of %{total} items', {
- start: range[0].toLocaleString(),
- end: range[1].toLocaleString(),
- total: total.toLocaleString(),
- }),
- }"
- size="small"
- :scroll="{ x: 2400 }"
- :columns="structuredLogColumns"
- :loading="isLoading"
- @change="handleTableChange"
- />
- </div>
- <!-- Empty State within search results -->
- <div v-else class="text-center" style="padding: 40px;">
- <AEmpty :description="$gettext('No entries in current page')" />
- <p class="text-gray-500 mt-2">
- {{ $gettext('Try adjusting your search criteria or navigate to different pages.') }}
- </p>
- </div>
- </div>
- <!-- Empty State -->
- <div v-else class="text-center" style="padding: 40px;">
- <AEmpty :description="$gettext('No structured log data available')" />
- <div v-if="isReady" class="mt-4">
- <p class="text-gray-500">
- {{ $gettext('Try adjusting your search criteria or time range.') }}
- </p>
- <p v-if="timeRange.start && timeRange.end" class="text-gray-400 text-sm mt-2">
- {{ $gettext('Search range') }}: {{ timeRange.start.format('YYYY-MM-DD HH:mm') }} - {{ timeRange.end.format('YYYY-MM-DD HH:mm') }}
- <Tag v-if="preflightResponse && preflightResponse.available" color="green" size="small" class="ml-2">
- {{ $gettext('From indexed logs') }}
- </Tag>
- <Tag v-else color="orange" size="small" class="ml-2">
- {{ $gettext('Default range') }}
- </Tag>
- </p>
- <AButton type="primary" class="mt-2" @click="resetSearchFilters">
- {{ $gettext('Reset Search') }}
- </AButton>
- </div>
- </div>
- </div> <!-- End of Access Log Content -->
- <!-- Failed State (show empty page when preflight fails) -->
- <div v-else class="text-center" style="padding: 80px 40px;">
- <AEmpty :description="$gettext('Log file not available')" />
- </div>
- </div>
- </template>
|