mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-10 18:05:48 +02:00
feat: config history
This commit is contained in:
parent
771859d3b8
commit
57b8dfd2f9
15 changed files with 823 additions and 75 deletions
13
api/config/history.go
Normal file
13
api/config/history.go
Normal 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()
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
2
app/components.d.ts
vendored
|
@ -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']
|
||||
|
|
|
@ -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
app/src/components/ConfigHistory/ConfigHistory.vue
Normal file
188
app/src/components/ConfigHistory/ConfigHistory.vue
Normal 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>
|
415
app/src/components/ConfigHistory/DiffViewer.vue
Normal file
415
app/src/components/ConfigHistory/DiffViewer.vue
Normal 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>
|
5
app/src/components/ConfigHistory/index.ts
Normal file
5
app/src/components/ConfigHistory/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import ConfigHistory from './ConfigHistory.vue'
|
||||
import DiffViewer from './DiffViewer.vue'
|
||||
|
||||
export { ConfigHistory, DiffViewer }
|
||||
export default ConfigHistory
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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,6 +200,17 @@ provide('data', data)
|
|||
</ATag>
|
||||
</template>
|
||||
<template #extra>
|
||||
<ASpace>
|
||||
<AButton
|
||||
v-if="filepath"
|
||||
type="link"
|
||||
@click="openHistory"
|
||||
>
|
||||
<template #icon>
|
||||
<HistoryOutlined />
|
||||
</template>
|
||||
{{ $gettext('History') }}
|
||||
</AButton>
|
||||
<div class="mode-switch">
|
||||
<div class="switch">
|
||||
<ASwitch
|
||||
|
@ -209,6 +228,7 @@ provide('data', data)
|
|||
<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>
|
||||
|
||||
|
|
|
@ -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,6 +187,17 @@ provide('data', data)
|
|||
</ATag>
|
||||
</template>
|
||||
<template #extra>
|
||||
<ASpace>
|
||||
<AButton
|
||||
v-if="filepath"
|
||||
type="link"
|
||||
@click="openHistory"
|
||||
>
|
||||
<template #icon>
|
||||
<HistoryOutlined />
|
||||
</template>
|
||||
{{ $gettext('History') }}
|
||||
</AButton>
|
||||
<div class="mode-switch">
|
||||
<div class="switch">
|
||||
<ASwitch
|
||||
|
@ -195,6 +214,7 @@ provide('data', data)
|
|||
<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
internal/config/history.go
Normal file
51
internal/config/history.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue