Parcourir la source

feat(nginx_log): implement log group index deletion and add TabFilter component for log type selection

0xJacky il y a 1 mois
Parent
commit
869012a340

+ 24 - 13
api/nginx_log/index_management.go

@@ -71,21 +71,32 @@ func performAsyncRebuild(modernIndexer interface{}, path string) {
 		}
 	}()
 
-	// First, destroy all existing indexes to ensure a clean slate
-	if err := nginx_log.DestroyAllIndexes(); err != nil {
-		logger.Errorf("Failed to destroy existing indexes before rebuild: %v", err)
-		return
-	}
+	logFileManager := nginx_log.GetLogFileManager()
 
-	// Re-initialize the indexer to create new, empty shards
-	if err := modernIndexer.(interface {
-		Start(context.Context) error
-	}).Start(context.Background()); err != nil {
-		logger.Errorf("Failed to re-initialize indexer after destruction: %v", err)
-		return
-	}
+	// Handle index cleanup based on rebuild scope
+	if path != "" {
+		// For single file rebuild, only delete indexes for that log group
+		if err := modernIndexer.(*indexer.ParallelIndexer).DeleteIndexByLogGroup(path, logFileManager); err != nil {
+			logger.Errorf("Failed to delete existing indexes for log group %s: %v", path, err)
+			return
+		}
+		logger.Infof("Deleted existing indexes for log group: %s", path)
+	} else {
+		// For full rebuild, destroy all existing indexes
+		if err := nginx_log.DestroyAllIndexes(); err != nil {
+			logger.Errorf("Failed to destroy existing indexes before rebuild: %v", err)
+			return
+		}
 
-	logFileManager := nginx_log.GetLogFileManager()
+		// Re-initialize the indexer to create new, empty shards
+		if err := modernIndexer.(interface {
+			Start(context.Context) error
+		}).Start(context.Background()); err != nil {
+			logger.Errorf("Failed to re-initialize indexer after destruction: %v", err)
+			return
+		}
+		logger.Info("Destroyed all indexes and re-initialized indexer")
+	}
 
 	// Create progress tracking callbacks
 	progressConfig := &indexer.ProgressConfig{

+ 1 - 0
app/components.d.ts

@@ -122,6 +122,7 @@ declare module 'vue' {
     SwitchAppearanceSwitchAppearance: typeof import('./src/components/SwitchAppearance/SwitchAppearance.vue')['default']
     SyncNodesPreviewSyncNodesPreview: typeof import('./src/components/SyncNodesPreview/SyncNodesPreview.vue')['default']
     SystemRestoreSystemRestoreContent: typeof import('./src/components/SystemRestore/SystemRestoreContent.vue')['default']
+    TabFilterTabFilter: typeof import('./src/components/TabFilter/TabFilter.vue')['default']
     TwoFAAuthorization: typeof import('./src/components/TwoFA/Authorization.vue')['default']
     UpstreamCardsUpstreamCards: typeof import('./src/components/UpstreamCards/UpstreamCards.vue')['default']
     UpstreamDetailModalUpstreamDetailModal: typeof import('./src/components/UpstreamDetailModal/UpstreamDetailModal.vue')['default']

+ 235 - 0
app/src/components/TabFilter/TabFilter.vue

@@ -0,0 +1,235 @@
+<script setup lang="ts">
+import type { Key } from 'ant-design-vue/es/_util/type'
+
+export interface TabOption {
+  key: string
+  label: string
+  icon?: VNode | Component
+  color?: string
+  disabled?: boolean
+}
+
+interface Props {
+  options: TabOption[]
+  counts?: Record<string, number>
+  activeKey?: string
+  size?: 'small' | 'middle' | 'large'
+}
+
+interface Emits {
+  (e: 'change', key: Key): void
+  (e: 'update:activeKey', key: string): void
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  counts: () => ({}),
+  activeKey: '',
+  size: 'middle',
+})
+
+const emit = defineEmits<Emits>()
+
+const currentActiveKey = computed({
+  get: () => props.activeKey,
+  set: value => emit('update:activeKey', value),
+})
+
+function handleTabChange(key: Key) {
+  const keyStr = key as string
+  currentActiveKey.value = keyStr
+  emit('change', key)
+}
+</script>
+
+<template>
+  <ATabs
+    :active-key="currentActiveKey"
+    class="tab-filter mb-4"
+    :size="size"
+    @change="handleTabChange"
+  >
+    <template #rightExtra>
+      <slot name="rightExtra" />
+    </template>
+
+    <ATabPane
+      v-for="option in options"
+      :key="option.key"
+      :disabled="option.disabled"
+    >
+      <template #tab>
+        <div
+          class="tab-content flex items-center gap-1.5"
+          :style="{ color: option.color || '#1890ff' }"
+        >
+          <span
+            v-if="option.icon"
+            class="tab-icon-wrapper flex items-center text-base"
+          >
+            <component :is="option.icon" />
+          </span>
+          <span class="tab-label font-medium">{{ option.label }}</span>
+          <ABadge
+            v-if="counts && counts[option.key] !== undefined && counts[option.key] > 0"
+            :count="counts[option.key]"
+            :number-style="{
+              backgroundColor: option.color || '#1890ff',
+              fontSize: '10px',
+              height: '16px',
+              lineHeight: '16px',
+              minWidth: '16px',
+              marginLeft: '6px',
+              color: '#ffffff',
+              border: 'none',
+            }"
+          />
+        </div>
+      </template>
+      <slot
+        :name="`content-${option.key}`"
+        :option="option"
+      />
+    </ATabPane>
+  </ATabs>
+</template>
+
+<style scoped>
+/* Main Tab Filter Styling */
+.tab-filter {
+  --border-color: #e8e8e8;
+  --primary-color: #1890ff;
+  --white: #ffffff;
+  --transparent: transparent;
+}
+
+/* Tab Navigation */
+.tab-filter :deep(.ant-tabs-nav) {
+  margin: 0;
+  padding: 0;
+  border-bottom: 1px solid var(--border-color);
+}
+
+.tab-filter :deep(.ant-tabs-nav::before) {
+  border: none;
+}
+
+/* Tab Items */
+.tab-filter :deep(.ant-tabs-tab) {
+  background: var(--transparent);
+  border: none;
+  margin: 0;
+  padding: 12px 16px;
+}
+
+/* Active Tab State */
+.tab-filter :deep(.ant-tabs-tab.ant-tabs-tab-active) {
+  background: var(--white);
+  border-bottom: 2px solid var(--primary-color);
+}
+
+.tab-filter :deep(.ant-tabs-tab.ant-tabs-tab-active) .tab-content {
+  padding-bottom: 0 !important;
+}
+
+.tab-filter :deep(.ant-tabs-tab.ant-tabs-tab-active) .ant-tabs-tab-btn {
+  text-shadow: unset !important;
+}
+
+/* Disabled Tab State */
+.tab-filter :deep(.ant-tabs-tab.ant-tabs-tab-disabled) .tab-content {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+/* Tab Content Layout */
+.tab-filter .tab-content {
+  font-size: 14px;
+  padding-bottom: 2px;
+}
+
+/* Size Variations */
+/* Small Size */
+.tab-filter:deep(.ant-tabs-small) .ant-tabs-tab {
+  padding: 8px 12px;
+}
+
+.tab-filter:deep(.ant-tabs-small) .tab-content {
+  font-size: 12px;
+  gap: 4px;
+}
+
+.tab-filter:deep(.ant-tabs-small) .tab-icon-wrapper {
+  font-size: 14px;
+}
+
+/* Large Size */
+.tab-filter:deep(.ant-tabs-large) .ant-tabs-tab {
+  padding: 16px 20px;
+}
+
+.tab-filter:deep(.ant-tabs-large) .tab-content {
+  font-size: 16px;
+  gap: 8px;
+}
+
+.tab-filter:deep(.ant-tabs-large) .tab-icon-wrapper {
+  font-size: 18px;
+}
+
+/* Dark Mode Support */
+@media (prefers-color-scheme: dark) {
+  .tab-filter {
+    --border-color: #303030;
+    --white: #1f1f1f;
+  }
+
+  .tab-filter :deep(.ant-tabs-nav) {
+    border-bottom-color: var(--border-color);
+  }
+
+  .tab-filter :deep(.ant-tabs-tab.ant-tabs-tab-active) {
+    background: var(--white);
+  }
+}
+
+/* Responsive Design */
+/* Tablet View (≤768px) */
+@media screen and (max-width: 768px) {
+  .tab-filter :deep(.ant-tabs-nav) {
+    padding: 0 8px;
+  }
+
+  .tab-filter :deep(.ant-tabs-tab) {
+    padding: 10px 8px;
+  }
+
+  .tab-filter .tab-content {
+    font-size: 13px;
+    gap: 4px;
+  }
+
+  .tab-filter .tab-icon-wrapper {
+    font-size: 18px;
+  }
+}
+
+/* Mobile View (≤480px) */
+@media screen and (max-width: 480px) {
+  .tab-filter :deep(.ant-tabs-nav) {
+    padding: 0 4px;
+  }
+
+  .tab-filter :deep(.ant-tabs-tab) {
+    padding: 8px 6px;
+    min-width: 44px;
+  }
+
+  .tab-filter .tab-content {
+    justify-content: center;
+  }
+
+  .tab-filter .tab-icon-wrapper {
+    font-size: 16px;
+  }
+}
+</style>

+ 5 - 0
app/src/components/TabFilter/index.ts

@@ -0,0 +1,5 @@
+import type { TabOption } from './TabFilter.vue'
+import TabFilter from './TabFilter.vue'
+
+export { TabFilter, type TabOption }
+export default TabFilter

+ 66 - 33
app/src/views/nginx_log/NginxLogList.vue

@@ -1,11 +1,14 @@
 <script setup lang="tsx">
 import type { CustomRenderArgs, StdTableColumn } from '@uozi-admin/curd'
 import type { NginxLogData } from '@/api/nginx_log'
-import { CheckCircleOutlined, SyncOutlined } from '@ant-design/icons-vue'
+import type { TabOption } from '@/components/TabFilter'
+import { CheckCircleOutlined, ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons-vue'
 import { StdCurd } from '@uozi-admin/curd'
+import { useRouteQuery } from '@vueuse/router'
 import { Badge, Tag, Tooltip } from 'ant-design-vue'
 import dayjs from 'dayjs'
 import nginxLog from '@/api/nginx_log'
+import { TabFilter } from '@/components/TabFilter'
 import { useWebSocketEventBus } from '@/composables/useWebSocketEventBus'
 import { useGlobalStore } from '@/pinia'
 import { useIndexProgress } from './composables/useIndexProgress'
@@ -22,7 +25,25 @@ const globalStore = useGlobalStore()
 const { nginxLogStatus, processingStatus } = storeToRefs(globalStore)
 
 // Index progress tracking
-const { getProgressForFile, isGlobalIndexing, globalProgress } = useIndexProgress()
+const { getProgressForFile, isGlobalIndexing } = useIndexProgress()
+
+// Tab filter for log types
+const activeLogType = useRouteQuery('type', 'access')
+
+const tabOptions: TabOption[] = [
+  {
+    key: 'access',
+    label: $gettext('Access Logs'),
+    icon: h(CheckCircleOutlined),
+    color: '#52c41a',
+  },
+  {
+    key: 'error',
+    label: $gettext('Error Logs'),
+    icon: h(ExclamationCircleOutlined),
+    color: '#ff4d4f',
+  },
+]
 
 // Subscribe to events
 onMounted(() => {
@@ -47,7 +68,8 @@ onMounted(() => {
   })
 })
 
-const columns: StdTableColumn[] = [
+// Base columns that are always visible
+const baseColumns: StdTableColumn[] = [
   {
     title: () => $gettext('Type'),
     dataIndex: 'type',
@@ -55,21 +77,6 @@ const columns: StdTableColumn[] = [
       return args.record?.type === 'access' ? <Tag color="green">{ $gettext('Access Log') }</Tag> : <Tag color="orange">{ $gettext('Error Log') }</Tag>
     },
     sorter: true,
-    search: {
-      type: 'select',
-      select: {
-        options: [
-          {
-            label: () => $gettext('Access Log'),
-            value: 'access',
-          },
-          {
-            label: () => $gettext('Error Log'),
-            value: 'error',
-          },
-        ],
-      },
-    },
     width: 120,
   },
   {
@@ -90,6 +97,10 @@ const columns: StdTableColumn[] = [
     },
     ellipsis: true,
   },
+]
+
+// Index-related columns only for Access logs
+const indexColumns: StdTableColumn[] = [
   {
     title: () => $gettext('Index Status'),
     dataIndex: 'index_status',
@@ -263,14 +274,29 @@ const columns: StdTableColumn[] = [
     },
     width: 380,
   },
-  {
-    title: () => $gettext('Actions'),
-    dataIndex: 'actions',
-    fixed: 'right',
-    width: 250,
-  },
 ]
 
+// Actions column
+const actionsColumn: StdTableColumn = {
+  title: () => $gettext('Actions'),
+  dataIndex: 'actions',
+  fixed: 'right',
+  width: 250,
+}
+
+// Computed columns based on active log type
+const columns = computed(() => {
+  const cols = [...baseColumns]
+
+  // Only show index-related columns for Access logs
+  if (activeLogType.value === 'access') {
+    cols.push(...indexColumns)
+  }
+
+  cols.push(actionsColumn)
+  return cols
+})
+
 function viewLog(record: NginxLogData) {
   router.push({
     path: `/nginx_log/${record.type}`,
@@ -303,25 +329,31 @@ async function refreshTable() {
     disable-trash
     disable-view
     disable-edit
+    :overwrite-params="{
+      type: activeLogType,
+    }"
   >
+    <template #beforeSearch>
+      <TabFilter
+        v-model:active-key="activeLogType"
+        :options="tabOptions"
+        size="middle"
+      />
+    </template>
+
     <template #beforeListActions>
       <div class="flex items-center gap-4">
         <!-- Global indexing progress -->
-        <div v-if="isGlobalIndexing" class="flex items-center space-x-4">
+        <div v-if="isGlobalIndexing" class="flex items-center">
           <div class="flex items-center text-blue-500">
             <SyncOutlined spin class="mr-2" />
             <span>{{ $gettext('Indexing logs...') }}</span>
           </div>
-          <div v-if="globalProgress.totalFiles > 0" class="text-sm text-gray-600 dark:text-gray-400">
-            <span>
-              {{ globalProgress.completedFiles }} / {{ globalProgress.totalFiles }}
-              {{ $gettext('files') }}
-            </span>
-          </div>
         </div>
 
-        <!-- Index Management -->
+        <!-- Index Management - only for Access logs -->
         <IndexManagement
+          v-if="activeLogType === 'access'"
           ref="indexManagementRef"
           :disabled="processingStatus.nginx_log_indexing"
           :indexing="isGlobalIndexing || processingStatus.nginx_log_indexing"
@@ -334,8 +366,9 @@ async function refreshTable() {
         {{ $gettext('View') }}
       </AButton>
 
-      <!-- Rebuild File Index Action -->
+      <!-- Rebuild File Index Action - only for Access logs -->
       <AButton
+        v-if="record.type === 'access'"
         type="link"
         size="small"
         :disabled="processingStatus.nginx_log_indexing"

+ 13 - 4
internal/nginx_log/indexer/log_file_manager.go

@@ -416,10 +416,19 @@ func (lm *LogFileManager) GetLogByPath(basePath string) (*NginxLogWithIndex, err
 
 // GetFilePathsForGroup returns all physical file paths for a given log group base path.
 func (lm *LogFileManager) GetFilePathsForGroup(basePath string) ([]string, error) {
-	// This function is not implemented in the provided file,
-	// but the user's edit hint implies its existence.
-	// For now, returning an empty slice as a placeholder.
-	return []string{}, nil
+	// Query the database for all log indexes with matching main_log_path
+	logIndexes, err := lm.persistence.GetLogIndexesByGroup(basePath)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get log indexes for group %s: %w", basePath, err)
+	}
+
+	// Extract file paths from the database records
+	filePaths := make([]string, 0, len(logIndexes))
+	for _, logIndex := range logIndexes {
+		filePaths = append(filePaths, logIndex.Path)
+	}
+
+	return filePaths, nil
 }
 
 // maxInt64 returns the maximum of two int64 values

+ 79 - 0
internal/nginx_log/indexer/parallel_indexer.go

@@ -457,6 +457,85 @@ func (pi *ParallelIndexer) GetAllShards() []bleve.Index {
 	return pi.shardManager.GetAllShards()
 }
 
+// DeleteIndexByLogGroup deletes all index entries for a specific log group (base path and its rotated files)
+func (pi *ParallelIndexer) DeleteIndexByLogGroup(basePath string, logFileManager interface{}) error {
+	if !pi.IsHealthy() {
+		return fmt.Errorf("indexer not healthy")
+	}
+
+	// Get all file paths for this log group from the database
+	if logFileManager == nil {
+		return fmt.Errorf("log file manager is required")
+	}
+
+	lfm, ok := logFileManager.(interface {
+		GetFilePathsForGroup(string) ([]string, error)
+	})
+	if !ok {
+		return fmt.Errorf("log file manager does not support GetFilePathsForGroup")
+	}
+
+	filesToDelete, err := lfm.GetFilePathsForGroup(basePath)
+	if err != nil {
+		return fmt.Errorf("failed to get file paths for log group %s: %w", basePath, err)
+	}
+
+	logger.Infof("Deleting index entries for log group %s, files: %v", basePath, filesToDelete)
+
+	// Delete documents from all shards for these files
+	shards := pi.shardManager.GetAllShards()
+	var deleteErrors []error
+	
+	for _, shard := range shards {
+		// Search for documents with matching file_path
+		for _, filePath := range filesToDelete {
+			query := bleve.NewTermQuery(filePath)
+			query.SetField("file_path")
+			
+			searchRequest := bleve.NewSearchRequest(query)
+			searchRequest.Size = 1000 // Process in batches
+			searchRequest.Fields = []string{"file_path"}
+			
+			for {
+				searchResult, err := shard.Search(searchRequest)
+				if err != nil {
+					deleteErrors = append(deleteErrors, fmt.Errorf("failed to search for documents in file %s: %w", filePath, err))
+					break
+				}
+				
+				if len(searchResult.Hits) == 0 {
+					break // No more documents to delete
+				}
+				
+				// Delete documents in batch
+				batch := shard.NewBatch()
+				for _, hit := range searchResult.Hits {
+					batch.Delete(hit.ID)
+				}
+				
+				if err := shard.Batch(batch); err != nil {
+					deleteErrors = append(deleteErrors, fmt.Errorf("failed to delete batch for file %s: %w", filePath, err))
+				}
+				
+				// If we got fewer results than requested, we're done
+				if len(searchResult.Hits) < searchRequest.Size {
+					break
+				}
+				
+				// Continue from where we left off
+				searchRequest.From += searchRequest.Size
+			}
+		}
+	}
+
+	if len(deleteErrors) > 0 {
+		return fmt.Errorf("encountered %d errors during deletion: %v", len(deleteErrors), deleteErrors[0])
+	}
+
+	logger.Infof("Successfully deleted index entries for log group: %s", basePath)
+	return nil
+}
+
 // DestroyAllIndexes closes and deletes all index data from disk.
 func (pi *ParallelIndexer) DestroyAllIndexes() error {
 	// Stop all background routines before deleting files

+ 12 - 0
internal/nginx_log/indexer/persistence.go

@@ -389,6 +389,18 @@ func (pm *PersistenceManager) DeleteAllLogIndexes() error {
 }
 
 // DeleteLogIndexesByGroup deletes all log index records for a specific log group.
+// GetLogIndexesByGroup retrieves all log index records for a given main log path
+func (pm *PersistenceManager) GetLogIndexesByGroup(mainLogPath string) ([]*model.NginxLogIndex, error) {
+	q := query.NginxLogIndex
+	
+	logIndexes, err := q.Where(q.MainLogPath.Eq(mainLogPath)).Find()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get log indexes for group %s: %w", mainLogPath, err)
+	}
+
+	return logIndexes, nil
+}
+
 func (pm *PersistenceManager) DeleteLogIndexesByGroup(mainLogPath string) error {
 	q := query.NginxLogIndex
 	result, err := q.Unscoped().Where(q.MainLogPath.Eq(mainLogPath)).Delete()