浏览代码

feat: config history

Jacky 3 周之前
父节点
当前提交
57b8dfd2f9

+ 13 - 0
api/config/history.go

@@ -0,0 +1,13 @@
+package config
+
+import (
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/gin-gonic/gin"
+	"github.com/uozi-tech/cosy"
+)
+
+func GetConfigHistory(c *gin.Context) {
+	cosy.Core[model.ConfigBackup](c).
+		SetEqual("filepath").
+		PagingList()
+}

+ 6 - 0
api/config/modify.go

@@ -47,6 +47,12 @@ func EditConfig(c *gin.Context) {
 		return
 	}
 
+	err = config.CheckAndCreateHistory(absPath, content)
+	if err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
 	if content != "" && content != string(origContent) {
 		err = os.WriteFile(absPath, []byte(content), 0644)
 		if err != nil {

+ 2 - 0
api/config/router.go

@@ -18,4 +18,6 @@ func InitRouter(r *gin.RouterGroup) {
 		o.POST("config_mkdir", Mkdir)
 		o.POST("config_rename", Rename)
 	}
+
+	r.GET("config_histories", GetConfigHistory)
 }

+ 2 - 0
app/components.d.ts

@@ -77,6 +77,8 @@ declare module 'vue' {
     ChartUsageProgressLine: typeof import('./src/components/Chart/UsageProgressLine.vue')['default']
     ChatGPTChatGPT: typeof import('./src/components/ChatGPT/ChatGPT.vue')['default']
     CodeEditorCodeEditor: typeof import('./src/components/CodeEditor/CodeEditor.vue')['default']
+    ConfigHistoryConfigHistory: typeof import('./src/components/ConfigHistory/ConfigHistory.vue')['default']
+    ConfigHistoryDiffViewer: typeof import('./src/components/ConfigHistory/DiffViewer.vue')['default']
     EnvGroupTabsEnvGroupTabs: typeof import('./src/components/EnvGroupTabs/EnvGroupTabs.vue')['default']
     EnvIndicatorEnvIndicator: typeof import('./src/components/EnvIndicator/EnvIndicator.vue')['default']
     FooterToolbarFooterToolBar: typeof import('./src/components/FooterToolbar/FooterToolBar.vue')['default']

+ 17 - 0
app/src/api/config.ts

@@ -1,7 +1,14 @@
+import type { GetListResponse } from '@/api/curd'
 import type { ChatComplicationMessage } from '@/api/openai'
 import Curd from '@/api/curd'
 import http from '@/lib/http'
 
+export interface ModelBase {
+  id: number
+  created_at: string
+  updated_at: string
+}
+
 export interface Config {
   name: string
   content: string
@@ -13,6 +20,12 @@ export interface Config {
   dir: string
 }
 
+export interface ConfigBackup extends ModelBase {
+  name: string
+  filepath: string
+  content: string
+}
+
 class ConfigCurd extends Curd<Config> {
   constructor() {
     super('/configs')
@@ -34,6 +47,10 @@ class ConfigCurd extends Curd<Config> {
       sync_node_ids: syncNodeIds,
     })
   }
+
+  get_history(filepath: string) {
+    return http.get<GetListResponse<ConfigBackup>>('/config_histories', { params: { filepath } })
+  }
 }
 
 const config: ConfigCurd = new ConfigCurd()

+ 188 - 0
app/src/components/ConfigHistory/ConfigHistory.vue

@@ -0,0 +1,188 @@
+<script setup lang="ts">
+import type { ConfigBackup } from '@/api/config'
+import type { GetListResponse } from '@/api/curd'
+import type { Key } from 'ant-design-vue/es/_util/type'
+import config from '@/api/config'
+import StdPagination from '@/components/StdDesign/StdDataDisplay/StdPagination.vue'
+import { message } from 'ant-design-vue'
+import { datetime } from '../StdDesign/StdDataDisplay/StdTableTransformer'
+import DiffViewer from './DiffViewer.vue'
+
+// Define props for the component
+const props = defineProps<{
+  filepath: string
+  currentContent?: string
+}>()
+
+// Define modal props using defineModel with boolean type
+const visible = defineModel<boolean>('visible')
+
+const loading = ref(false)
+const records = ref<ConfigBackup[]>([])
+const showDiffViewer = ref(false)
+const pagination = ref({
+  total: 0,
+  per_page: 10,
+  current_page: 1,
+  total_pages: 0,
+})
+const selectedRowKeys = ref<Key[]>([])
+const selectedRecords = ref<ConfigBackup[]>([])
+
+// Watch for changes in modal visibility and filepath to fetch data
+watch(() => [visible.value, props.filepath], ([newVisible, newPath]) => {
+  if (newVisible && newPath) {
+    fetchHistoryList()
+  }
+}, { immediate: true })
+
+// Table column definitions
+const columns = [
+  {
+    title: () => $gettext('Created At'),
+    dataIndex: 'created_at',
+    key: 'created_at',
+    customRender: datetime,
+  },
+]
+
+// Fetch history records list
+async function fetchHistoryList() {
+  if (!props.filepath)
+    return
+
+  loading.value = true
+  try {
+    const response = await config.get_history(props.filepath)
+    const data = response as GetListResponse<ConfigBackup>
+    records.value = data.data || []
+
+    if (data.pagination) {
+      pagination.value = data.pagination
+    }
+  }
+  catch (error) {
+    message.error($gettext('Failed to load history records'))
+    console.error('Failed to fetch config backup list:', error)
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+// Handle pagination changes
+function changePage(page: number, pageSize: number) {
+  pagination.value.current_page = page
+  pagination.value.per_page = pageSize
+  fetchHistoryList()
+}
+
+// Row selection handler
+const rowSelection = computed(() => ({
+  selectedRowKeys: selectedRowKeys.value,
+  onChange: (keys: Key[], selectedRows: ConfigBackup[]) => {
+    // Limit to maximum of two records
+    if (keys.length > 2) {
+      return
+    }
+    selectedRowKeys.value = keys
+    selectedRecords.value = selectedRows
+  },
+  getCheckboxProps: (record: ConfigBackup) => ({
+    disabled: selectedRowKeys.value.length >= 2 && !selectedRowKeys.value.includes(record.id as Key),
+  }),
+}))
+
+// Compare selected records
+function compareSelected() {
+  if (selectedRecords.value.length > 0) {
+    showDiffViewer.value = true
+  }
+}
+
+// Close modal and reset selection
+function handleClose() {
+  showDiffViewer.value = false
+  selectedRowKeys.value = []
+  selectedRecords.value = []
+  visible.value = false
+}
+
+// Dynamic button text based on selection count
+const compareButtonText = computed(() => {
+  if (selectedRowKeys.value.length === 0)
+    return $gettext('Compare')
+  if (selectedRowKeys.value.length === 1)
+    return $gettext('Compare with Current')
+  return $gettext('Compare Selected')
+})
+</script>
+
+<template>
+  <div>
+    <AModal
+      v-model:open="visible"
+      :title="$gettext('Configuration History')"
+      :footer="null"
+      @cancel="handleClose"
+    >
+      <div class="history-container">
+        <ATable
+          :loading="loading"
+          :columns="columns"
+          :data-source="records"
+          :row-selection="rowSelection"
+          row-key="id"
+          size="small"
+          :pagination="false"
+        />
+
+        <div class="history-footer">
+          <StdPagination
+            :pagination="pagination"
+            :loading="loading"
+            @change="changePage"
+          />
+
+          <div class="actions">
+            <AButton
+              type="primary"
+              :disabled="selectedRowKeys.length === 0"
+              @click="compareSelected"
+            >
+              {{ compareButtonText }}
+            </AButton>
+            <AButton @click="handleClose">
+              {{ $gettext('Close') }}
+            </AButton>
+          </div>
+        </div>
+      </div>
+    </AModal>
+    <DiffViewer
+      v-model:visible="showDiffViewer"
+      :records="selectedRecords"
+      :current-content="currentContent"
+    />
+  </div>
+</template>
+
+<style lang="less" scoped>
+.history-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.history-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 16px;
+}
+
+.actions {
+  display: flex;
+  gap: 8px;
+}
+</style>

+ 415 - 0
app/src/components/ConfigHistory/DiffViewer.vue

@@ -0,0 +1,415 @@
+<script setup lang="ts">
+import type { ConfigBackup } from '@/api/config'
+import type { Ace } from 'ace-builds'
+import { formatDateTime } from '@/lib/helper'
+import ace from 'ace-builds'
+import 'ace-builds/src-noconflict/mode-nginx'
+import 'ace-builds/src-noconflict/theme-monokai'
+
+// Import required modules
+import 'ace-builds/src-min-noconflict/ext-language_tools'
+
+const props = defineProps<{
+  records: ConfigBackup[]
+  currentContent?: string
+}>()
+
+// Define modal visibility using defineModel with boolean type
+const visible = defineModel<boolean>('visible')
+
+const originalText = ref('')
+const modifiedText = ref('')
+const diffEditorRef = ref<HTMLElement | null>(null)
+const editors: { left?: Ace.Editor, right?: Ace.Editor } = {}
+const originalTitle = ref('')
+const modifiedTitle = ref('')
+const errorMessage = ref('')
+
+// Check if there is content to display
+function hasContent() {
+  return originalText.value && modifiedText.value
+}
+
+// Set editor content based on selected records
+function setContent() {
+  if (!props.records || props.records.length === 0) {
+    errorMessage.value = $gettext('No records selected')
+    return false
+  }
+
+  try {
+    // Set content based on number of selected records
+    if (props.records.length === 1) {
+      // Single record - compare with current content
+      originalText.value = props.records[0]?.content || ''
+      modifiedText.value = props.currentContent || ''
+
+      // Ensure both sides have content for comparison
+      if (!originalText.value || !modifiedText.value) {
+        errorMessage.value = $gettext('Cannot compare: Missing content')
+        return false
+      }
+
+      originalTitle.value = `${props.records[0]?.name || ''} (${formatDateTime(props.records[0]?.created_at || '')})`
+      modifiedTitle.value = $gettext('Current Content')
+    }
+    else if (props.records.length === 2) {
+      // Compare two records - sort by time
+      const sorted = [...props.records].sort((a, b) =>
+        new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
+      )
+      originalText.value = sorted[0]?.content || ''
+      modifiedText.value = sorted[1]?.content || ''
+
+      // Ensure both sides have content for comparison
+      if (!originalText.value || !modifiedText.value) {
+        errorMessage.value = $gettext('Cannot compare: Missing content')
+        return false
+      }
+
+      originalTitle.value = `${sorted[0]?.name || ''} (${formatDateTime(sorted[0]?.created_at || '')})`
+      modifiedTitle.value = `${sorted[1]?.name || ''} (${formatDateTime(sorted[1]?.created_at || '')})`
+    }
+
+    errorMessage.value = ''
+    return hasContent()
+  }
+  catch (error) {
+    console.error('Error setting content:', error)
+    errorMessage.value = $gettext('Error processing content')
+    return false
+  }
+}
+
+// Create editors
+function createEditors() {
+  if (!diffEditorRef.value)
+    return false
+
+  try {
+    // Clear editor area
+    diffEditorRef.value.innerHTML = ''
+
+    // Create left and right editor containers
+    const leftContainer = document.createElement('div')
+    leftContainer.style.width = '50%'
+    leftContainer.style.height = '100%'
+    leftContainer.style.float = 'left'
+    leftContainer.style.position = 'relative'
+
+    const rightContainer = document.createElement('div')
+    rightContainer.style.width = '50%'
+    rightContainer.style.height = '100%'
+    rightContainer.style.float = 'right'
+    rightContainer.style.position = 'relative'
+
+    // Add to DOM
+    diffEditorRef.value.appendChild(leftContainer)
+    diffEditorRef.value.appendChild(rightContainer)
+
+    // Create editors
+    editors.left = ace.edit(leftContainer)
+    editors.left.setTheme('ace/theme/monokai')
+    editors.left.getSession().setMode('ace/mode/nginx')
+    editors.left.setReadOnly(true)
+    editors.left.setOption('showPrintMargin', false)
+
+    editors.right = ace.edit(rightContainer)
+    editors.right.setTheme('ace/theme/monokai')
+    editors.right.getSession().setMode('ace/mode/nginx')
+    editors.right.setReadOnly(true)
+    editors.right.setOption('showPrintMargin', false)
+
+    return true
+  }
+  catch (error) {
+    console.error('Error creating editors:', error)
+    errorMessage.value = $gettext('Error initializing diff viewer')
+    return false
+  }
+}
+
+// Update editor content
+function updateEditors() {
+  if (!editors.left || !editors.right) {
+    console.error('Editors not available')
+    return false
+  }
+
+  try {
+    // Check if content is empty
+    if (!originalText.value || !modifiedText.value) {
+      console.error('Empty content detected', {
+        originalLength: originalText.value?.length,
+        modifiedLength: modifiedText.value?.length,
+      })
+      return false
+    }
+
+    // Set content
+    editors.left.setValue(originalText.value, -1)
+    editors.right.setValue(modifiedText.value, -1)
+
+    // Scroll to top
+    editors.left.scrollToLine(0, false, false)
+    editors.right.scrollToLine(0, false, false)
+
+    // Highlight differences
+    highlightDiffs()
+
+    // Setup sync scroll
+    setupSyncScroll()
+
+    return true
+  }
+  catch (error) {
+    console.error('Error updating editors:', error)
+    return false
+  }
+}
+
+// Highlight differences
+function highlightDiffs() {
+  if (!editors.left || !editors.right)
+    return
+
+  try {
+    const leftSession = editors.left.getSession()
+    const rightSession = editors.right.getSession()
+
+    // Clear previous all marks
+    leftSession.clearBreakpoints()
+    rightSession.clearBreakpoints()
+
+    // Add CSS styles
+    addHighlightStyles()
+
+    // Compare lines
+    const leftLines = originalText.value.split('\n')
+    const rightLines = modifiedText.value.split('\n')
+
+    // Use difference comparison algorithm
+    compareAndHighlightLines(leftSession, rightSession, leftLines, rightLines)
+  }
+  catch (error) {
+    console.error('Error highlighting diffs:', error)
+  }
+}
+
+// Add highlight styles
+function addHighlightStyles() {
+  const styleId = 'diff-highlight-style'
+  if (!document.getElementById(styleId)) {
+    const style = document.createElement('style')
+    style.id = styleId
+    style.textContent = `
+      .diff-line-deleted {
+        position: absolute;
+        background: rgba(255, 100, 100, 0.3);
+        z-index: 5;
+        width: 100% !important;
+      }
+      .diff-line-added {
+        position: absolute;
+        background: rgba(100, 255, 100, 0.3);
+        z-index: 5;
+        width: 100% !important;
+      }
+      .diff-line-changed {
+        position: absolute;
+        background: rgba(255, 255, 100, 0.3);
+        z-index: 5;
+        width: 100% !important;
+      }
+    `
+    document.head.appendChild(style)
+  }
+}
+
+// Compare and highlight lines
+function compareAndHighlightLines(leftSession: Ace.EditSession, rightSession: Ace.EditSession, leftLines: string[], rightLines: string[]) {
+  // Create a mapping table to track which lines have been matched
+  const matchedLeftLines = new Set<number>()
+  const matchedRightLines = new Set<number>()
+
+  // 1. First mark completely identical lines
+  for (let i = 0; i < leftLines.length; i++) {
+    for (let j = 0; j < rightLines.length; j++) {
+      if (leftLines[i] === rightLines[j] && !matchedLeftLines.has(i) && !matchedRightLines.has(j)) {
+        matchedLeftLines.add(i)
+        matchedRightLines.add(j)
+        break
+      }
+    }
+  }
+
+  // 2. Mark lines left deleted
+  for (let i = 0; i < leftLines.length; i++) {
+    if (!matchedLeftLines.has(i)) {
+      leftSession.addGutterDecoration(i, 'ace_gutter-active-line')
+      leftSession.addMarker(
+        new ace.Range(i, 0, i, leftLines[i].length || 1),
+        'diff-line-deleted',
+        'fullLine',
+      )
+    }
+  }
+
+  // 3. Mark lines right added
+  for (let j = 0; j < rightLines.length; j++) {
+    if (!matchedRightLines.has(j)) {
+      rightSession.addGutterDecoration(j, 'ace_gutter-active-line')
+      rightSession.addMarker(
+        new ace.Range(j, 0, j, rightLines[j].length || 1),
+        'diff-line-added',
+        'fullLine',
+      )
+    }
+  }
+}
+
+// Setup sync scroll
+function setupSyncScroll() {
+  if (!editors.left || !editors.right)
+    return
+
+  // Sync scroll
+  const leftSession = editors.left.getSession()
+  const rightSession = editors.right.getSession()
+
+  leftSession.on('changeScrollTop', (scrollTop: number) => {
+    rightSession.setScrollTop(scrollTop)
+  })
+
+  rightSession.on('changeScrollTop', (scrollTop: number) => {
+    leftSession.setScrollTop(scrollTop)
+  })
+}
+
+// Initialize difference comparator
+async function initDiffViewer() {
+  if (!diffEditorRef.value)
+    return
+
+  // Reset error message
+  errorMessage.value = ''
+
+  // Set content
+  const hasValidContent = setContent()
+  if (!hasValidContent) {
+    console.error('No valid content to compare')
+    return
+  }
+
+  // Create editors
+  const editorsCreated = createEditors()
+  if (!editorsCreated) {
+    console.error('Failed to create editors')
+    return
+  }
+
+  // Wait for DOM update
+  await nextTick()
+
+  // Update editor content
+  const editorsUpdated = updateEditors()
+  if (!editorsUpdated) {
+    console.error('Failed to update editors')
+    return
+  }
+
+  // Adjust size to ensure full display
+  window.setTimeout(() => {
+    if (editors.left && editors.right) {
+      editors.left.resize()
+      editors.right.resize()
+    }
+  }, 200)
+}
+
+// Listen for records change
+watch(() => [props.records, visible.value], async () => {
+  if (visible.value) {
+    // When selected records change, update content
+    await nextTick()
+    initDiffViewer()
+  }
+})
+
+// Close dialog handler
+function handleClose() {
+  visible.value = false
+  errorMessage.value = ''
+}
+</script>
+
+<template>
+  <AModal
+    v-model:open="visible"
+    :title="$gettext('Compare Configurations')"
+    width="100%"
+    :footer="null"
+    @cancel="handleClose"
+  >
+    <div v-if="errorMessage" class="diff-error">
+      <AAlert
+        :message="errorMessage"
+        type="warning"
+        show-icon
+      />
+    </div>
+
+    <div v-else class="diff-container">
+      <div class="diff-header">
+        <div class="diff-title">
+          {{ originalTitle }}
+        </div>
+        <div class="diff-title">
+          {{ modifiedTitle }}
+        </div>
+      </div>
+      <div
+        ref="diffEditorRef"
+        class="diff-editor"
+      />
+    </div>
+  </AModal>
+</template>
+
+<style lang="less" scoped>
+.diff-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.diff-error {
+  margin-bottom: 16px;
+}
+
+.diff-header {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 8px;
+}
+
+.diff-title {
+  font-weight: bold;
+  width: 50%;
+  padding: 0 8px;
+}
+
+.diff-editor {
+  height: 500px;
+  width: 100%;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.diff-footer {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+</style>

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

@@ -0,0 +1,5 @@
+import ConfigHistory from './ConfigHistory.vue'
+import DiffViewer from './DiffViewer.vue'
+
+export { ConfigHistory, DiffViewer }
+export default ConfigHistory

+ 26 - 1
app/src/views/config/ConfigEditor.vue

@@ -6,6 +6,7 @@ import config from '@/api/config'
 import ngx from '@/api/ngx'
 import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
+import { ConfigHistory } from '@/components/ConfigHistory'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
 import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
 import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
@@ -13,7 +14,7 @@ import { formatDateTime } from '@/lib/helper'
 import { useSettingsStore } from '@/pinia'
 import ConfigName from '@/views/config/components/ConfigName.vue'
 import InspectConfig from '@/views/config/InspectConfig.vue'
-import { InfoCircleOutlined } from '@ant-design/icons-vue'
+import { HistoryOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
 import { message } from 'ant-design-vue'
 import _ from 'lodash'
 
@@ -28,6 +29,7 @@ const origName = ref('')
 const addMode = computed(() => !route.params.name)
 const errors = ref({})
 
+const showHistory = ref(false)
 const basePath = computed(() => {
   if (route.query.basePath)
     return _.trim(route?.query?.basePath?.toString(), '/')
@@ -192,6 +194,10 @@ function goBack() {
     },
   })
 }
+
+function openHistory() {
+  showHistory.value = true
+}
 </script>
 
 <template>
@@ -202,6 +208,19 @@ function goBack() {
       :md="18"
     >
       <ACard :title="addMode ? $gettext('Add Configuration') : $gettext('Edit Configuration')">
+        <template #extra>
+          <AButton
+            v-if="!addMode && data.filepath"
+            type="link"
+            @click="openHistory"
+          >
+            <template #icon>
+              <HistoryOutlined />
+            </template>
+            {{ $gettext('History') }}
+          </AButton>
+        </template>
+
         <InspectConfig
           v-show="!addMode"
           ref="refInspectConfig"
@@ -315,6 +334,12 @@ function goBack() {
         </ACollapse>
       </ACard>
     </ACol>
+
+    <ConfigHistory
+      v-model:visible="showHistory"
+      :filepath="data.filepath"
+      :current-content="data.content"
+    />
   </ARow>
 </template>
 

+ 42 - 16
app/src/views/site/site_edit/SiteEdit.vue

@@ -9,9 +9,11 @@ import config from '@/api/config'
 import ngx from '@/api/ngx'
 import site from '@/api/site'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
+import { ConfigHistory } from '@/components/ConfigHistory'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
 import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
 import RightSettings from '@/views/site/site_edit/RightSettings.vue'
+import { HistoryOutlined } from '@ant-design/icons-vue'
 import { message } from 'ant-design-vue'
 
 const route = useRoute()
@@ -40,6 +42,8 @@ const data = ref({}) as Ref<Site>
 const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
 const loading = ref(true)
 
+const showHistory = ref(false)
+
 onMounted(init)
 
 const advanceMode = computed({
@@ -156,6 +160,10 @@ async function save() {
   })
 }
 
+function openHistory() {
+  showHistory.value = true
+}
+
 provide('save_config', save)
 provide('configText', configText)
 provide('ngx_config', ngx_config)
@@ -192,23 +200,35 @@ provide('data', data)
           </ATag>
         </template>
         <template #extra>
-          <div class="mode-switch">
-            <div class="switch">
-              <ASwitch
-                size="small"
-                :disabled="parseErrorStatus"
-                :checked="advanceMode"
-                :loading
-                @change="onModeChange"
-              />
+          <ASpace>
+            <AButton
+              v-if="filepath"
+              type="link"
+              @click="openHistory"
+            >
+              <template #icon>
+                <HistoryOutlined />
+              </template>
+              {{ $gettext('History') }}
+            </AButton>
+            <div class="mode-switch">
+              <div class="switch">
+                <ASwitch
+                  size="small"
+                  :disabled="parseErrorStatus"
+                  :checked="advanceMode"
+                  :loading
+                  @change="onModeChange"
+                />
+              </div>
+              <template v-if="advanceMode">
+                <div>{{ $gettext('Advance Mode') }}</div>
+              </template>
+              <template v-else>
+                <div>{{ $gettext('Basic Mode') }}</div>
+              </template>
             </div>
-            <template v-if="advanceMode">
-              <div>{{ $gettext('Advance Mode') }}</div>
-            </template>
-            <template v-else>
-              <div>{{ $gettext('Basic Mode') }}</div>
-            </template>
-          </div>
+          </ASpace>
         </template>
 
         <Transition name="slide-fade">
@@ -273,6 +293,12 @@ provide('data', data)
         </AButton>
       </ASpace>
     </FooterToolBar>
+
+    <ConfigHistory
+      v-model:visible="showHistory"
+      :filepath="filepath"
+      :current-content="configText"
+    />
   </ARow>
 </template>
 

+ 43 - 20
app/src/views/stream/StreamEdit.vue

@@ -9,9 +9,11 @@ import config from '@/api/config'
 import ngx from '@/api/ngx'
 import stream from '@/api/stream'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
+import { ConfigHistory } from '@/components/ConfigHistory'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
 import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
 import RightSettings from '@/views/stream/components/RightSettings.vue'
+import { HistoryOutlined } from '@ant-design/icons-vue'
 import { message } from 'ant-design-vue'
 
 const route = useRoute()
@@ -39,6 +41,8 @@ const parseErrorStatus = ref(false)
 const parseErrorMessage = ref('')
 const data = ref<Stream>({} as Stream)
 
+const showHistory = ref(false)
+
 init()
 
 const advanceMode = computed({
@@ -144,6 +148,10 @@ async function save() {
   })
 }
 
+function openHistory() {
+  showHistory.value = true
+}
+
 provide('save_config', save)
 provide('configText', configText)
 provide('ngx_config', ngxConfig)
@@ -179,22 +187,34 @@ provide('data', data)
           </ATag>
         </template>
         <template #extra>
-          <div class="mode-switch">
-            <div class="switch">
-              <ASwitch
-                size="small"
-                :disabled="parseErrorStatus"
-                :checked="advanceMode"
-                @change="onModeChange"
-              />
+          <ASpace>
+            <AButton
+              v-if="filepath"
+              type="link"
+              @click="openHistory"
+            >
+              <template #icon>
+                <HistoryOutlined />
+              </template>
+              {{ $gettext('History') }}
+            </AButton>
+            <div class="mode-switch">
+              <div class="switch">
+                <ASwitch
+                  size="small"
+                  :disabled="parseErrorStatus"
+                  :checked="advanceMode"
+                  @change="onModeChange"
+                />
+              </div>
+              <template v-if="advanceMode">
+                <div>{{ $gettext('Advance Mode') }}</div>
+              </template>
+              <template v-else>
+                <div>{{ $gettext('Basic Mode') }}</div>
+              </template>
             </div>
-            <template v-if="advanceMode">
-              <div>{{ $gettext('Advance Mode') }}</div>
-            </template>
-            <template v-else>
-              <div>{{ $gettext('Basic Mode') }}</div>
-            </template>
-          </div>
+          </ASpace>
         </template>
 
         <Transition name="slide-fade">
@@ -256,16 +276,19 @@ provide('data', data)
         </AButton>
       </ASpace>
     </FooterToolBar>
+
+    <ConfigHistory
+      v-model:visible="showHistory"
+      :filepath="filepath"
+      :current-content="configText"
+    />
   </ARow>
 </template>
 
-<style lang="less">
-
-</style>
-
 <style lang="less" scoped>
 .col-right {
-  position: relative;
+  position: sticky;
+  top: 78px;
 }
 
 .ant-card {

+ 51 - 0
internal/config/history.go

@@ -0,0 +1,51 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/0xJacky/Nginx-UI/query"
+	"github.com/uozi-tech/cosy/logger"
+)
+
+// CheckAndCreateHistory compares the provided content with the current content of the file
+// at the specified path and creates a history record if they are different.
+// The path must be under nginx.GetConfPath().
+func CheckAndCreateHistory(path string, content string) error {
+	// Check if path is under nginx.GetConfPath()
+	if !helper.IsUnderDirectory(path, nginx.GetConfPath()) {
+		return ErrPathIsNotUnderTheNginxConfDir
+	}
+
+	// Read the current content of the file
+	currentContent, err := os.ReadFile(path)
+	if err != nil {
+		return err
+	}
+
+	// Compare the contents
+	if string(currentContent) == content {
+		// Contents are identical, no need to create history
+		return nil
+	}
+
+	// Contents are different, create a history record (config backup)
+	backup := &model.ConfigBackup{
+		Name:     filepath.Base(path),
+		FilePath: path,
+		Content:  string(currentContent),
+	}
+
+	// Save the backup to the database
+	cb := query.ConfigBackup
+	err = cb.Create(backup)
+	if err != nil {
+		logger.Error("Failed to create config backup:", err)
+		return err
+	}
+
+	return nil
+}

+ 6 - 0
internal/site/save.go

@@ -7,6 +7,7 @@ import (
 	"runtime"
 	"sync"
 
+	"github.com/0xJacky/Nginx-UI/internal/config"
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/internal/notification"
@@ -23,6 +24,11 @@ func Save(name string, content string, overwrite bool, envGroupId uint64, syncNo
 		return ErrDstFileExists
 	}
 
+	err = config.CheckAndCreateHistory(path, content)
+	if err != nil {
+		return
+	}
+
 	err = os.WriteFile(path, []byte(content), 0644)
 	if err != nil {
 		return

+ 6 - 0
internal/stream/save.go

@@ -7,6 +7,7 @@ import (
 	"runtime"
 	"sync"
 
+	"github.com/0xJacky/Nginx-UI/internal/config"
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/internal/notification"
@@ -23,6 +24,11 @@ func Save(name string, content string, overwrite bool, syncNodeIds []uint64, pos
 		return ErrDstFileExists
 	}
 
+	err = config.CheckAndCreateHistory(path, content)
+	if err != nil {
+		return
+	}
+
 	err = os.WriteFile(path, []byte(content), 0644)
 	if err != nil {
 		return

+ 1 - 38
model/config_backup.go

@@ -1,45 +1,8 @@
 package model
 
-import (
-	"github.com/uozi-tech/cosy/logger"
-	"os"
-	"path/filepath"
-)
-
 type ConfigBackup struct {
 	Model
 	Name     string `json:"name"`
-	FilePath string `json:"filepath"`
+	FilePath string `json:"filepath" gorm:"column:filepath"`
 	Content  string `json:"content" gorm:"type:text"`
 }
-
-type ConfigBackupListItem struct {
-	Model
-	Name     string `json:"name"`
-	FilePath string `json:"filepath"`
-}
-
-func GetBackupList(path string) (configs []ConfigBackupListItem) {
-	db.Model(&ConfigBackup{}).
-		Where(&ConfigBackup{FilePath: path}).
-		Find(&configs)
-	return
-}
-
-func GetBackup(id int) (config ConfigBackup) {
-	db.First(&config, id)
-	return
-}
-
-func CreateBackup(path string) {
-	content, err := os.ReadFile(path)
-	if err != nil {
-		logger.Error(err)
-	}
-
-	config := ConfigBackup{Name: filepath.Base(path), FilePath: path, Content: string(content)}
-	result := db.Create(&config)
-	if result.Error != nil {
-		logger.Error(result.Error)
-	}
-}