feat: config history

This commit is contained in:
Jacky 2025-04-06 10:40:49 +08:00
parent 771859d3b8
commit 57b8dfd2f9
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
15 changed files with 823 additions and 75 deletions

13
api/config/history.go Normal file
View file

@ -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()
}

View file

@ -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 {

View file

@ -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
app/components.d.ts vendored
View file

@ -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']

View file

@ -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()

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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)
}
}