1
0
Jacky 3 долоо хоног өмнө
parent
commit
5d3f478086

+ 78 - 0
api/pages/maintenance.go

@@ -0,0 +1,78 @@
+package pages
+
+import (
+	"embed"
+	"html/template"
+	"net/http"
+	"strings"
+
+	"github.com/0xJacky/Nginx-UI/settings"
+	"github.com/gin-gonic/gin"
+)
+
+//go:embed *.tmpl
+var tmplFS embed.FS
+
+// MaintenancePageData maintenance page data structure
+type MaintenancePageData struct {
+	Title                string `json:"title"`
+	Message              string `json:"message"`
+	Description          string `json:"description"`
+	ICPNumber            string `json:"icp_number"`
+	PublicSecurityNumber string `json:"public_security_number"`
+}
+
+const (
+	Title       = "System Maintenance"
+	Message     = "We are currently performing system maintenance to improve your experience."
+	Description = "Please check back later. Thank you for your understanding and patience."
+)
+
+// MaintenancePage returns a maintenance page
+func MaintenancePage(c *gin.Context) {
+	// Prepare template data
+	data := MaintenancePageData{
+		Title:                Title,
+		Message:              Message,
+		Description:          Description,
+		ICPNumber:            settings.NodeSettings.ICPNumber,
+		PublicSecurityNumber: settings.NodeSettings.PublicSecurityNumber,
+	}
+
+	// Check User-Agent
+	userAgent := c.GetHeader("User-Agent")
+	isBrowser := len(userAgent) > 0 && (contains(userAgent, "Mozilla") ||
+		contains(userAgent, "Chrome") ||
+		contains(userAgent, "Safari") ||
+		contains(userAgent, "Edge") ||
+		contains(userAgent, "Firefox") ||
+		contains(userAgent, "Opera"))
+
+	if !isBrowser {
+		c.JSON(http.StatusServiceUnavailable, data)
+		return
+	}
+
+	// Parse template
+	tmpl, err := template.ParseFS(tmplFS, "maintenance.tmpl")
+	if err != nil {
+		c.String(http.StatusInternalServerError, "503 Service Unavailable")
+		return
+	}
+
+	// Set content type
+	c.Header("Content-Type", "text/html; charset=utf-8")
+	c.Status(http.StatusServiceUnavailable)
+
+	// Render template
+	err = tmpl.Execute(c.Writer, data)
+	if err != nil {
+		c.String(http.StatusInternalServerError, "503 Service Unavailable")
+		return
+	}
+}
+
+// Helper function to check if a string contains a substring
+func contains(s, substr string) bool {
+	return strings.Contains(s, substr)
+}

+ 89 - 0
api/pages/maintenance.tmpl

@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=0">
+    <title>{{.Title}} | Nginx UI</title>
+    <style>
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+            background-color: #f4f5f7;
+            color: #333;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            height: 100vh;
+            margin: 0;
+            text-align: center;
+            flex-direction: column;
+        }
+        .maintenance-container {
+            max-width: 600px;
+            padding: 40px;
+            background: white;
+            border-radius: 8px;
+            @media (max-width: 768px) {
+                border-radius: 0;
+            }
+            box-shadow: 0 0 30px #c8c8c840;
+            margin-bottom: 20px;
+        }
+        h1 {
+            color:rgb(0, 128, 247);
+            margin-bottom: 20px;
+            font-size: 28px;
+        }
+        a {
+            color: #1890ff;
+            text-decoration: none;
+        }
+        p {
+            font-size: 14px;
+            line-height: 1.6;
+            margin-bottom: 20px;
+        }
+        .icon {
+            font-size: 64px;
+            margin-bottom: 20px;
+        }
+        .footer {
+            margin-top: 30px;
+            font-size: 12px;
+            color: #888;
+        }
+        .beian-info {
+            font-size: 12px;
+            color: #888;
+            margin-top: 10px;
+        }
+        .beian-info p {
+            margin: 5px 0;
+        }
+        .beian-info img {
+            vertical-align: middle;
+            margin-right: 5px;
+        }
+    </style>
+</head>
+<body>
+    <div>
+        <div class="maintenance-container">
+          <div class="icon">🛠️</div>
+          <h1>{{.Title}}</h1>
+          <p>{{.Message}}</p>
+          <p>{{.Description}}</p>
+          <div class="footer">
+              <p>Powered by <a href="https://nginxui.com" target="_blank">Nginx UI</a></p>
+          </div>
+      </div>
+      <div class="beian-info">
+          {{if .ICPNumber}}
+          <p><a href="https://beian.miit.gov.cn/" target="_blank">{{.ICPNumber}}</a></p>
+          {{end}}
+          {{if .PublicSecurityNumber}}
+          <p><img src="//www.beian.gov.cn/img/new/gongan.png" alt="公安备案"><a href="http://www.beian.gov.cn/portal/index" target="_blank">{{.PublicSecurityNumber}}</a></p>
+          {{end}}
+      </div>
+    </div>
+</body>
+</html>

+ 11 - 0
api/pages/router.go

@@ -0,0 +1,11 @@
+package pages
+
+import (
+	"github.com/gin-gonic/gin"
+)
+
+// InitRouter initializes the pages routes
+func InitRouter(r *gin.Engine) {
+	// Register maintenance page route
+	r.GET("/pages/maintenance", MaintenancePage)
+}

+ 22 - 12
api/sites/list.go

@@ -19,7 +19,7 @@ import (
 
 func GetSiteList(c *gin.Context) {
 	name := c.Query("name")
-	enabled := c.Query("enabled")
+	status := c.Query("status")
 	orderBy := c.Query("sort_by")
 	sort := c.DefaultQuery("order", "desc")
 	queryEnvGroupId := cast.ToUint64(c.Query("env_group_id"))
@@ -50,9 +50,23 @@ func GetSiteList(c *gin.Context) {
 		return filepath.Base(item.Path), item
 	})
 
-	enabledConfigMap := make(map[string]bool)
-	for i := range enabledConfig {
-		enabledConfigMap[enabledConfig[i].Name()] = true
+	configStatusMap := make(map[string]config.ConfigStatus)
+	for _, site := range configFiles {
+		configStatusMap[site.Name()] = config.StatusDisabled
+	}
+
+	// Check for enabled sites and maintenance mode sites
+	for _, enabledSite := range enabledConfig {
+		name := enabledSite.Name()
+
+		// Check if this is a maintenance mode configuration
+		if strings.HasSuffix(name, site.MaintenanceSuffix) {
+			// Extract the original site name by removing maintenance suffix
+			originalName := strings.TrimSuffix(name, site.MaintenanceSuffix)
+			configStatusMap[originalName] = config.StatusMaintenance
+		} else {
+			configStatusMap[name] = config.StatusEnabled
+		}
 	}
 
 	var configs []config.Config
@@ -68,14 +82,10 @@ func GetSiteList(c *gin.Context) {
 			continue
 		}
 		// status filter
-		if enabled != "" {
-			if enabled == "true" && !enabledConfigMap[file.Name()] {
-				continue
-			}
-			if enabled == "false" && enabledConfigMap[file.Name()] {
-				continue
-			}
+		if status != "" && configStatusMap[file.Name()] != config.ConfigStatus(status) {
+			continue
 		}
+
 		var (
 			envGroupId uint64
 			envGroup   *model.EnvGroup
@@ -98,7 +108,7 @@ func GetSiteList(c *gin.Context) {
 			ModifiedAt: fileInfo.ModTime(),
 			Size:       fileInfo.Size(),
 			IsDir:      fileInfo.IsDir(),
-			Enabled:    enabledConfigMap[file.Name()],
+			Status:     configStatusMap[file.Name()],
 			EnvGroupID: envGroupId,
 			EnvGroup:   envGroup,
 			Urls:       indexedSite.Urls,

+ 4 - 0
api/sites/router.go

@@ -22,4 +22,8 @@ func InitRouter(r *gin.RouterGroup) {
 	r.DELETE("sites/:name", DeleteSite)
 	// duplicate site
 	r.POST("sites/:name/duplicate", DuplicateSite)
+	// enable maintenance mode for site
+	r.POST("sites/:name/maintenance/enable", EnableMaintenanceSite)
+	// disable maintenance mode for site
+	r.POST("sites/:name/maintenance/disable", DisableMaintenanceSite)
 }

+ 24 - 0
api/sites/site.go

@@ -215,3 +215,27 @@ func BatchUpdateSites(c *gin.Context) {
 			ctx.BatchEffectedIDs = effectedPath
 		}).BatchModify()
 }
+
+func EnableMaintenanceSite(c *gin.Context) {
+	err := site.EnableMaintenance(c.Param("name"))
+	if err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}
+
+func DisableMaintenanceSite(c *gin.Context) {
+	err := site.DisableMaintenance(c.Param("name"))
+	if err != nil {
+		cosy.ErrHandler(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "ok",
+	})
+}

+ 9 - 11
api/streams/streams.go

@@ -36,7 +36,7 @@ type Stream struct {
 
 func GetStreams(c *gin.Context) {
 	name := c.Query("name")
-	enabled := c.Query("enabled")
+	status := c.Query("status")
 	orderBy := c.Query("order_by")
 	sort := c.DefaultQuery("sort", "desc")
 	queryEnvGroupId := cast.ToUint64(c.Query("env_group_id"))
@@ -53,9 +53,12 @@ func GetStreams(c *gin.Context) {
 		return
 	}
 
-	enabledConfigMap := make(map[string]bool)
+	enabledConfigMap := make(map[string]config.ConfigStatus)
+	for _, file := range configFiles {
+		enabledConfigMap[file.Name()] = config.StatusDisabled
+	}
 	for i := range enabledConfig {
-		enabledConfigMap[enabledConfig[i].Name()] = true
+		enabledConfigMap[enabledConfig[i].Name()] = config.StatusEnabled
 	}
 
 	var configs []config.Config
@@ -107,13 +110,8 @@ func GetStreams(c *gin.Context) {
 		}
 
 		// Apply enabled status filter if specified
-		if enabled != "" {
-			if enabled == "true" && !enabledConfigMap[file.Name()] {
-				continue
-			}
-			if enabled == "false" && enabledConfigMap[file.Name()] {
-				continue
-			}
+		if status != "" && enabledConfigMap[file.Name()] != config.ConfigStatus(status) {
+			continue
 		}
 
 		var (
@@ -138,7 +136,7 @@ func GetStreams(c *gin.Context) {
 			ModifiedAt: fileInfo.ModTime(),
 			Size:       fileInfo.Size(),
 			IsDir:      fileInfo.IsDir(),
-			Enabled:    enabledConfigMap[file.Name()],
+			Status:     enabledConfigMap[file.Name()],
 			EnvGroupID: envGroupId,
 			EnvGroup:   envGroup,
 		})

+ 9 - 0
app/src/api/site.ts

@@ -23,6 +23,7 @@ export interface Site extends ModelBase {
   env_group?: EnvGroup
   sync_node_ids: number[]
   urls?: string[]
+  status: string
 }
 
 export interface AutoCertRequest {
@@ -65,6 +66,14 @@ class SiteCurd extends Curd<Site> {
   advance_mode(name: string, data: { advanced: boolean }) {
     return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/advance`, data)
   }
+
+  enableMaintenance(name: string) {
+    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/maintenance/enable`)
+  }
+
+  disableMaintenance(name: string) {
+    return http.post(`${this.baseUrl}/${encodeURIComponent(name)}/maintenance/disable`)
+  }
 }
 
 const site = new SiteCurd('/sites')

+ 22 - 6
app/src/components/Notification/notifications.ts

@@ -4,12 +4,6 @@
 
 const notifications: Record<string, { title: () => string, content: (args: any) => string }> = {
 
-  // user module notifications
-  'All Recovery Codes Have Been Used': {
-    title: () => $gettext('All Recovery Codes Have Been Used'),
-    content: (args: any) => $gettext('Please generate new recovery codes in the preferences immediately to prevent lockout.', args),
-  },
-
   // cluster module notifications
   'Reload Remote Nginx Error': {
     title: () => $gettext('Reload Remote Nginx Error'),
@@ -81,6 +75,22 @@ const notifications: Record<string, { title: () => string, content: (args: any)
     title: () => $gettext('Enable Remote Site Success'),
     content: (args: any) => $gettext('Enable site %{name} on %{node} successfully', args),
   },
+  'Enable Remote Site Maintenance Error': {
+    title: () => $gettext('Enable Remote Site Maintenance Error'),
+    content: (args: any) => $gettext('Enable site %{name} maintenance on %{node} failed', args),
+  },
+  'Enable Remote Site Maintenance Success': {
+    title: () => $gettext('Enable Remote Site Maintenance Success'),
+    content: (args: any) => $gettext('Enable site %{name} maintenance on %{node} successfully', args),
+  },
+  'Disable Remote Site Maintenance Error': {
+    title: () => $gettext('Disable Remote Site Maintenance Error'),
+    content: (args: any) => $gettext('Disable site %{name} maintenance on %{node} failed', args),
+  },
+  'Disable Remote Site Maintenance Success': {
+    title: () => $gettext('Disable Remote Site Maintenance Success'),
+    content: (args: any) => $gettext('Disable site %{name} maintenance on %{node} successfully', args),
+  },
   'Rename Remote Site Error': {
     title: () => $gettext('Rename Remote Site Error'),
     content: (args: any) => $gettext('Rename site %{name} to %{new_name} on %{node} failed', args),
@@ -139,6 +149,12 @@ const notifications: Record<string, { title: () => string, content: (args: any)
     title: () => $gettext('Save Remote Stream Success'),
     content: (args: any) => $gettext('Save stream %{name} to %{node} successfully', args),
   },
+
+  // user module notifications
+  'All Recovery Codes Have Been Used': {
+    title: () => $gettext('All Recovery Codes Have Been Used'),
+    content: (args: any) => $gettext('Please generate new recovery codes in the preferences immediately to prevent lockout.', args),
+  },
 }
 
 export default notifications

+ 1 - 0
app/src/constants/errors/cert.ts

@@ -5,4 +5,5 @@ export default {
   50004: () => $gettext('Certificate parse error'),
   50005: () => $gettext('Payload resource is nil'),
   50006: () => $gettext('Path: {0} is not under the nginx conf dir: {1}'),
+  50007: () => $gettext('Certificate path is empty'),
 }

+ 6 - 0
app/src/constants/index.ts

@@ -1,5 +1,11 @@
 export const DATE_FORMAT = 'YYYY-MM-DD'
 
+export enum ConfigStatus {
+  Enabled = 'enabled',
+  Disabled = 'disabled',
+  Maintenance = 'maintenance',
+}
+
 export enum AutoCertState {
   Disable = 0,
   Enable = 1,

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 211 - 143
app/src/language/ar/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 211 - 143
app/src/language/de_DE/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 211 - 143
app/src/language/en/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 211 - 143
app/src/language/es/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 211 - 143
app/src/language/fr_FR/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 211 - 143
app/src/language/ko_KR/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 209 - 154
app/src/language/messages.pot


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 211 - 143
app/src/language/ru_RU/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 219 - 143
app/src/language/tr_TR/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 211 - 143
app/src/language/vi_VN/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 207 - 150
app/src/language/zh_CN/app.po


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 215 - 147
app/src/language/zh_TW/app.po


+ 35 - 6
app/src/views/site/site_list/SiteList.vue

@@ -9,6 +9,7 @@ import site from '@/api/site'
 import EnvGroupTabs from '@/components/EnvGroupTabs/EnvGroupTabs.vue'
 import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue'
 import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
+import { ConfigStatus } from '@/constants'
 import InspectConfig from '@/views/config/InspectConfig.vue'
 import columns from '@/views/site/site_list/columns'
 import SiteDuplicate from '@/views/site/site_list/SiteDuplicate.vue'
@@ -94,8 +95,6 @@ function enable(name: string) {
     message.success($gettext('Enabled successfully'))
     table.value?.get_list()
     inspect_config.value?.test()
-  }).catch(r => {
-    message.error($gettext('Failed to enable %{msg}', { msg: r.message ?? '' }), 10)
   })
 }
 
@@ -104,8 +103,22 @@ function disable(name: string) {
     message.success($gettext('Disabled successfully'))
     table.value?.get_list()
     inspect_config.value?.test()
-  }).catch(r => {
-    message.error($gettext('Failed to disable %{msg}', { msg: r.message ?? '' }))
+  })
+}
+
+function enableMaintenance(name: string) {
+  site.enableMaintenance(name).then(() => {
+    message.success($gettext('Maintenance mode enabled successfully'))
+    table.value?.get_list()
+    inspect_config.value?.test()
+  })
+}
+
+function disableMaintenance(name: string) {
+  site.disableMaintenance(name).then(() => {
+    message.success($gettext('Maintenance mode disabled successfully'))
+    table.value?.get_list()
+    inspect_config.value?.test()
   })
 }
 
@@ -172,7 +185,7 @@ function handleBatchUpdated() {
     >
       <template #actions="{ record }">
         <AButton
-          v-if="record.enabled"
+          v-if="record.status !== ConfigStatus.Disabled"
           type="link"
           size="small"
           @click="disable(record.name)"
@@ -180,13 +193,29 @@ function handleBatchUpdated() {
           {{ $gettext('Disable') }}
         </AButton>
         <AButton
-          v-else
+          v-else-if="record.status !== ConfigStatus.Enabled"
           type="link"
           size="small"
           @click="enable(record.name)"
         >
           {{ $gettext('Enable') }}
         </AButton>
+        <AButton
+          v-if="record.status === ConfigStatus.Maintenance"
+          type="link"
+          size="small"
+          @click="disableMaintenance(record.name)"
+        >
+          {{ $gettext('Exit Maintenance') }}
+        </AButton>
+        <AButton
+          v-else-if="record.status !== ConfigStatus.Maintenance"
+          type="link"
+          size="small"
+          @click="enableMaintenance(record.name)"
+        >
+          {{ $gettext('Enter Maintenance') }}
+        </AButton>
         <AButton
           type="link"
           size="small"

+ 11 - 5
app/src/views/site/site_list/columns.tsx

@@ -8,6 +8,7 @@ import {
   datetime,
 } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { input, select, selector } from '@/components/StdDesign/StdDataEntry'
+import { ConfigStatus } from '@/constants'
 import envGroupColumns from '@/views/environments/group/columns'
 import { Badge, Tag } from 'ant-design-vue'
 
@@ -64,26 +65,31 @@ const columns: Column[] = [{
   width: 100,
 }, {
   title: () => $gettext('Status'),
-  dataIndex: 'enabled',
+  dataIndex: 'status',
   customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
-    if (text === true || text > 0) {
+    if (text === ConfigStatus.Enabled) {
       template.push(<Badge status="success" />)
       template.push($gettext('Enabled'))
     }
-    else {
+    else if (text === ConfigStatus.Disabled) {
       template.push(<Badge status="warning" />)
       template.push($gettext('Disabled'))
     }
+    else if (text === ConfigStatus.Maintenance) {
+      template.push(<Badge color="volcano" />)
+      template.push($gettext('Maintenance'))
+    }
 
     return h('div', template)
   },
   search: {
     type: select,
     mask: {
-      true: $gettext('Enabled'),
-      false: $gettext('Disabled'),
+      [ConfigStatus.Enabled]: $gettext('Enabled'),
+      [ConfigStatus.Disabled]: $gettext('Disabled'),
+      [ConfigStatus.Maintenance]: $gettext('Maintenance'),
     },
   },
   sorter: true,

+ 4 - 3
app/src/views/stream/StreamList.vue

@@ -10,6 +10,7 @@ import StdBatchEdit from '@/components/StdDesign/StdDataDisplay/StdBatchEdit.vue
 import StdTable from '@/components/StdDesign/StdDataDisplay/StdTable.vue'
 import { actualValueRender, datetime } from '@/components/StdDesign/StdDataDisplay/StdTableTransformer'
 import { input, selector } from '@/components/StdDesign/StdDataEntry'
+import { ConfigStatus } from '@/constants'
 import InspectConfig from '@/views/config/InspectConfig.vue'
 import envGroupColumns from '@/views/environments/group/columns'
 import StreamDuplicate from '@/views/stream/components/StreamDuplicate.vue'
@@ -44,15 +45,15 @@ const columns: Column[] = [{
   width: 150,
 }, {
   title: () => $gettext('Status'),
-  dataIndex: 'enabled',
+  dataIndex: 'status',
   customRender: (args: CustomRender) => {
     const template: JSXElements = []
     const { text } = args
-    if (text === true || text > 0) {
+    if (text === ConfigStatus.Enabled) {
       template.push(<Badge status="success" />)
       template.push($gettext('Enabled'))
     }
-    else {
+    else if (text === ConfigStatus.Disabled) {
       template.push(<Badge status="warning" />)
       template.push($gettext('Disabled'))
     }

+ 9 - 1
internal/config/config.go

@@ -7,6 +7,14 @@ import (
 	"github.com/sashabaranov/go-openai"
 )
 
+type ConfigStatus string
+
+const (
+	StatusEnabled     ConfigStatus = "enabled"
+	StatusDisabled    ConfigStatus = "disabled"
+	StatusMaintenance ConfigStatus = "maintenance"
+)
+
 type Config struct {
 	Name            string                         `json:"name"`
 	Content         string                         `json:"content"`
@@ -17,7 +25,7 @@ type Config struct {
 	IsDir           bool                           `json:"is_dir"`
 	EnvGroupID      uint64                         `json:"env_group_id"`
 	EnvGroup        *model.EnvGroup                `json:"env_group,omitempty"`
-	Enabled         bool                           `json:"enabled"`
+	Status          ConfigStatus                   `json:"status"`
 	Dir             string                         `json:"dir"`
 	Urls            []string                       `json:"urls,omitempty"`
 }

+ 2 - 2
internal/config/config_list.go

@@ -31,8 +31,8 @@ func (c ConfigsSort) Less(i, j int) bool {
 		flag = c.ConfigList[i].ModifiedAt.After(c.ConfigList[j].ModifiedAt)
 	case "is_dir":
 		flag = boolToInt(c.ConfigList[i].IsDir) > boolToInt(c.ConfigList[j].IsDir)
-	case "enabled":
-		flag = boolToInt(c.ConfigList[i].Enabled) > boolToInt(c.ConfigList[j].Enabled)
+	case "status":
+		flag = c.ConfigList[i].Status > c.ConfigList[j].Status
 	case "env_group_id":
 		flag = c.ConfigList[i].EnvGroupID > c.ConfigList[j].EnvGroupID
 	}

+ 371 - 0
internal/site/maintenance.go

@@ -0,0 +1,371 @@
+package site
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"runtime"
+	"strings"
+	"sync"
+
+	"github.com/0xJacky/Nginx-UI/internal/helper"
+	"github.com/0xJacky/Nginx-UI/internal/nginx"
+	"github.com/0xJacky/Nginx-UI/internal/notification"
+	"github.com/0xJacky/Nginx-UI/model"
+	"github.com/go-resty/resty/v2"
+	"github.com/tufanbarisyildirim/gonginx/config"
+	"github.com/tufanbarisyildirim/gonginx/parser"
+	"github.com/uozi-tech/cosy/logger"
+	"github.com/uozi-tech/cosy/settings"
+)
+
+const MaintenanceSuffix = "_nginx_ui_maintenance"
+
+// EnableMaintenance enables maintenance mode for a site
+func EnableMaintenance(name string) (err error) {
+	// Check if the site exists in sites-available
+	configFilePath := nginx.GetConfPath("sites-available", name)
+	_, err = os.Stat(configFilePath)
+	if err != nil {
+		return
+	}
+
+	// Path for the maintenance configuration file
+	maintenanceConfigPath := nginx.GetConfPath("sites-enabled", name+MaintenanceSuffix)
+
+	// Path for original configuration in sites-enabled
+	originalEnabledPath := nginx.GetConfPath("sites-enabled", name)
+
+	// Check if the site is already in maintenance mode
+	if helper.FileExists(maintenanceConfigPath) {
+		return
+	}
+
+	// Read the original configuration file
+	content, err := os.ReadFile(configFilePath)
+	if err != nil {
+		return
+	}
+
+	// Parse the nginx configuration
+	p := parser.NewStringParser(string(content), parser.WithSkipValidDirectivesErr())
+	conf, err := p.Parse()
+	if err != nil {
+		return fmt.Errorf("failed to parse nginx configuration: %s", err)
+	}
+
+	// Create new maintenance configuration
+	maintenanceConfig := createMaintenanceConfig(conf)
+
+	// Write maintenance configuration to file
+	err = os.WriteFile(maintenanceConfigPath, []byte(maintenanceConfig), 0644)
+	if err != nil {
+		return
+	}
+
+	// Remove the original symlink from sites-enabled if it exists
+	if helper.FileExists(originalEnabledPath) {
+		err = os.Remove(originalEnabledPath)
+		if err != nil {
+			// If we couldn't remove the original, remove the maintenance file and return the error
+			_ = os.Remove(maintenanceConfigPath)
+			return
+		}
+	}
+
+	// Test nginx config, if not pass, then restore original configuration
+	output := nginx.TestConf()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		// Configuration error, cleanup and revert
+		_ = os.Remove(maintenanceConfigPath)
+		if helper.FileExists(originalEnabledPath + "_backup") {
+			_ = os.Rename(originalEnabledPath+"_backup", originalEnabledPath)
+		}
+		return fmt.Errorf("%s", output)
+	}
+
+	// Reload nginx
+	output = nginx.Reload()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		return fmt.Errorf("%s", output)
+	}
+
+	// Synchronize with other nodes
+	go syncEnableMaintenance(name)
+
+	return nil
+}
+
+// DisableMaintenance disables maintenance mode for a site
+func DisableMaintenance(name string) (err error) {
+	// Check if the site is in maintenance mode
+	maintenanceConfigPath := nginx.GetConfPath("sites-enabled", name+MaintenanceSuffix)
+	_, err = os.Stat(maintenanceConfigPath)
+	if err != nil {
+		return
+	}
+
+	// Original configuration paths
+	configFilePath := nginx.GetConfPath("sites-available", name)
+	enabledConfigFilePath := nginx.GetConfPath("sites-enabled", name)
+
+	// Check if the original configuration exists
+	_, err = os.Stat(configFilePath)
+	if err != nil {
+		return
+	}
+
+	// Create symlink to original configuration
+	err = os.Symlink(configFilePath, enabledConfigFilePath)
+	if err != nil {
+		return
+	}
+
+	// Remove maintenance configuration
+	err = os.Remove(maintenanceConfigPath)
+	if err != nil {
+		// If we couldn't remove the maintenance file, remove the new symlink and return the error
+		_ = os.Remove(enabledConfigFilePath)
+		return
+	}
+
+	// Test nginx config, if not pass, then revert
+	output := nginx.TestConf()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		// Configuration error, cleanup and revert
+		_ = os.Remove(enabledConfigFilePath)
+		_ = os.Symlink(configFilePath, maintenanceConfigPath)
+		return fmt.Errorf("%s", output)
+	}
+
+	// Reload nginx
+	output = nginx.Reload()
+	if nginx.GetLogLevel(output) > nginx.Warn {
+		return fmt.Errorf("%s", output)
+	}
+
+	// Synchronize with other nodes
+	go syncDisableMaintenance(name)
+
+	return nil
+}
+
+// createMaintenanceConfig creates a maintenance configuration based on the original config
+func createMaintenanceConfig(conf *config.Config) string {
+	nginxUIPort := settings.ServerSettings.Port
+	schema := "http"
+	if settings.ServerSettings.EnableHTTPS {
+		schema = "https"
+	}
+
+	// Create new configuration
+	ngxConfig := nginx.NewNgxConfig("")
+
+	// Find all server blocks in the original configuration
+	serverBlocks := findServerBlocks(conf.Block)
+
+	// Create maintenance mode configuration for each server block
+	for _, server := range serverBlocks {
+		ngxServer := nginx.NewNgxServer()
+
+		// Copy listen directives
+		listenDirectives := extractDirectives(server, "listen")
+		for _, directive := range listenDirectives {
+			ngxDirective := &nginx.NgxDirective{
+				Directive: directive.GetName(),
+				Params:    strings.Join(extractParams(directive), " "),
+			}
+			ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
+		}
+
+		// Copy server_name directives
+		serverNameDirectives := extractDirectives(server, "server_name")
+		for _, directive := range serverNameDirectives {
+			ngxDirective := &nginx.NgxDirective{
+				Directive: directive.GetName(),
+				Params:    strings.Join(extractParams(directive), " "),
+			}
+			ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
+		}
+
+		// Copy SSL certificate directives
+		sslCertDirectives := extractDirectives(server, "ssl_certificate")
+		for _, directive := range sslCertDirectives {
+			ngxDirective := &nginx.NgxDirective{
+				Directive: directive.GetName(),
+				Params:    strings.Join(extractParams(directive), " "),
+			}
+			ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
+		}
+
+		// Copy SSL certificate key directives
+		sslKeyDirectives := extractDirectives(server, "ssl_certificate_key")
+		for _, directive := range sslKeyDirectives {
+			ngxDirective := &nginx.NgxDirective{
+				Directive: directive.GetName(),
+				Params:    strings.Join(extractParams(directive), " "),
+			}
+			ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
+		}
+
+		// Copy http2 directives
+		http2Directives := extractDirectives(server, "http2")
+		for _, directive := range http2Directives {
+			ngxDirective := &nginx.NgxDirective{
+				Directive: directive.GetName(),
+				Params:    strings.Join(extractParams(directive), " "),
+			}
+			ngxServer.Directives = append(ngxServer.Directives, ngxDirective)
+		}
+
+		// Add maintenance mode location
+		location := &nginx.NgxLocation{
+			Path: "~ .*",
+		}
+
+		// Build location content using string builder
+		var locationContent strings.Builder
+		locationContent.WriteString("proxy_set_header Host $host;\n")
+		locationContent.WriteString("proxy_set_header X-Real-IP $remote_addr;\n")
+		locationContent.WriteString("proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
+		locationContent.WriteString("proxy_set_header X-Forwarded-Proto $scheme;\n")
+		locationContent.WriteString(fmt.Sprintf("rewrite ^ /pages/maintenance break;\n"))
+		locationContent.WriteString(fmt.Sprintf("proxy_pass %s://127.0.0.1:%d;\n", schema, nginxUIPort))
+
+		location.Content = locationContent.String()
+		ngxServer.Locations = append(ngxServer.Locations, location)
+
+		// Add to configuration
+		ngxConfig.Servers = append(ngxConfig.Servers, ngxServer)
+	}
+
+	// Generate configuration file content
+	content, err := ngxConfig.BuildConfig()
+	if err != nil {
+		logger.Error("Failed to build maintenance config", err)
+		return ""
+	}
+
+	return content
+}
+
+// findServerBlocks finds all server blocks in a configuration
+func findServerBlocks(block config.IBlock) []config.IDirective {
+	var servers []config.IDirective
+
+	if block == nil {
+		return servers
+	}
+
+	for _, directive := range block.GetDirectives() {
+		if directive.GetName() == "server" {
+			servers = append(servers, directive)
+		}
+	}
+
+	return servers
+}
+
+// extractDirectives extracts all directives with a specific name from a server block
+func extractDirectives(server config.IDirective, name string) []config.IDirective {
+	var directives []config.IDirective
+
+	if server.GetBlock() == nil {
+		return directives
+	}
+
+	for _, directive := range server.GetBlock().GetDirectives() {
+		if directive.GetName() == name {
+			directives = append(directives, directive)
+		}
+	}
+
+	return directives
+}
+
+// extractParams extracts all parameters from a directive
+func extractParams(directive config.IDirective) []string {
+	var params []string
+
+	for _, param := range directive.GetParameters() {
+		params = append(params, param.Value)
+	}
+
+	return params
+}
+
+// syncEnableMaintenance synchronizes enabling maintenance mode with other nodes
+func syncEnableMaintenance(name string) {
+	nodes := getSyncNodes(name)
+
+	wg := &sync.WaitGroup{}
+	wg.Add(len(nodes))
+
+	for _, node := range nodes {
+		go func(node *model.Environment) {
+			defer func() {
+				if err := recover(); err != nil {
+					buf := make([]byte, 1024)
+					runtime.Stack(buf, false)
+					logger.Error(err)
+				}
+			}()
+			defer wg.Done()
+
+			client := resty.New()
+			client.SetBaseURL(node.URL)
+			resp, err := client.R().
+				SetHeader("X-Node-Secret", node.Token).
+				Post(fmt.Sprintf("/api/sites/%s/maintenance/enable", name))
+			if err != nil {
+				notification.Error("Enable Remote Site Maintenance Error", err.Error(), nil)
+				return
+			}
+			if resp.StatusCode() != http.StatusOK {
+				notification.Error("Enable Remote Site Maintenance Error", "Enable site %{name} maintenance on %{node} failed", NewSyncResult(node.Name, name, resp))
+				return
+			}
+			notification.Success("Enable Remote Site Maintenance Success", "Enable site %{name} maintenance on %{node} successfully", NewSyncResult(node.Name, name, resp))
+		}(node)
+	}
+
+	wg.Wait()
+}
+
+// syncDisableMaintenance synchronizes disabling maintenance mode with other nodes
+func syncDisableMaintenance(name string) {
+	nodes := getSyncNodes(name)
+
+	wg := &sync.WaitGroup{}
+	wg.Add(len(nodes))
+
+	for _, node := range nodes {
+		go func(node *model.Environment) {
+			defer func() {
+				if err := recover(); err != nil {
+					buf := make([]byte, 1024)
+					runtime.Stack(buf, false)
+					logger.Error(err)
+				}
+			}()
+			defer wg.Done()
+
+			client := resty.New()
+			client.SetBaseURL(node.URL)
+			resp, err := client.R().
+				SetHeader("X-Node-Secret", node.Token).
+				Post(fmt.Sprintf("/api/sites/%s/maintenance/disable", name))
+			if err != nil {
+				notification.Error("Disable Remote Site Maintenance Error", err.Error(), nil)
+				return
+			}
+			if resp.StatusCode() != http.StatusOK {
+				notification.Error("Disable Remote Site Maintenance Error", "Disable site %{name} maintenance on %{node} failed", NewSyncResult(node.Name, name, resp))
+				return
+			}
+			notification.Success("Disable Remote Site Maintenance Success", "Disable site %{name} maintenance on %{node} successfully", NewSyncResult(node.Name, name, resp))
+		}(node)
+	}
+
+	wg.Wait()
+}

+ 3 - 3
query/config_backups.gen.go

@@ -33,7 +33,7 @@ func newConfigBackup(db *gorm.DB, opts ...gen.DOOption) configBackup {
 	_configBackup.UpdatedAt = field.NewTime(tableName, "updated_at")
 	_configBackup.DeletedAt = field.NewField(tableName, "deleted_at")
 	_configBackup.Name = field.NewString(tableName, "name")
-	_configBackup.FilePath = field.NewString(tableName, "file_path")
+	_configBackup.FilePath = field.NewString(tableName, "filepath")
 	_configBackup.Content = field.NewString(tableName, "content")
 
 	_configBackup.fillFieldMap()
@@ -73,7 +73,7 @@ func (c *configBackup) updateTableName(table string) *configBackup {
 	c.UpdatedAt = field.NewTime(table, "updated_at")
 	c.DeletedAt = field.NewField(table, "deleted_at")
 	c.Name = field.NewString(table, "name")
-	c.FilePath = field.NewString(table, "file_path")
+	c.FilePath = field.NewString(table, "filepath")
 	c.Content = field.NewString(table, "content")
 
 	c.fillFieldMap()
@@ -97,7 +97,7 @@ func (c *configBackup) fillFieldMap() {
 	c.fieldMap["updated_at"] = c.UpdatedAt
 	c.fieldMap["deleted_at"] = c.DeletedAt
 	c.fieldMap["name"] = c.Name
-	c.fieldMap["file_path"] = c.FilePath
+	c.fieldMap["filepath"] = c.FilePath
 	c.fieldMap["content"] = c.Content
 }
 

+ 13 - 9
query/env_groups.gen.go

@@ -35,6 +35,7 @@ func newEnvGroup(db *gorm.DB, opts ...gen.DOOption) envGroup {
 	_envGroup.Name = field.NewString(tableName, "name")
 	_envGroup.SyncNodeIds = field.NewField(tableName, "sync_node_ids")
 	_envGroup.OrderID = field.NewInt(tableName, "order_id")
+	_envGroup.PostSyncAction = field.NewString(tableName, "post_sync_action")
 
 	_envGroup.fillFieldMap()
 
@@ -44,14 +45,15 @@ func newEnvGroup(db *gorm.DB, opts ...gen.DOOption) envGroup {
 type envGroup struct {
 	envGroupDo
 
-	ALL         field.Asterisk
-	ID          field.Uint64
-	CreatedAt   field.Time
-	UpdatedAt   field.Time
-	DeletedAt   field.Field
-	Name        field.String
-	SyncNodeIds field.Field
-	OrderID     field.Int
+	ALL            field.Asterisk
+	ID             field.Uint64
+	CreatedAt      field.Time
+	UpdatedAt      field.Time
+	DeletedAt      field.Field
+	Name           field.String
+	SyncNodeIds    field.Field
+	OrderID        field.Int
+	PostSyncAction field.String
 
 	fieldMap map[string]field.Expr
 }
@@ -75,6 +77,7 @@ func (e *envGroup) updateTableName(table string) *envGroup {
 	e.Name = field.NewString(table, "name")
 	e.SyncNodeIds = field.NewField(table, "sync_node_ids")
 	e.OrderID = field.NewInt(table, "order_id")
+	e.PostSyncAction = field.NewString(table, "post_sync_action")
 
 	e.fillFieldMap()
 
@@ -91,7 +94,7 @@ func (e *envGroup) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
 }
 
 func (e *envGroup) fillFieldMap() {
-	e.fieldMap = make(map[string]field.Expr, 7)
+	e.fieldMap = make(map[string]field.Expr, 8)
 	e.fieldMap["id"] = e.ID
 	e.fieldMap["created_at"] = e.CreatedAt
 	e.fieldMap["updated_at"] = e.UpdatedAt
@@ -99,6 +102,7 @@ func (e *envGroup) fillFieldMap() {
 	e.fieldMap["name"] = e.Name
 	e.fieldMap["sync_node_ids"] = e.SyncNodeIds
 	e.fieldMap["order_id"] = e.OrderID
+	e.fieldMap["post_sync_action"] = e.PostSyncAction
 }
 
 func (e envGroup) clone(db *gorm.DB) envGroup {

+ 4 - 1
router/routers.go

@@ -15,6 +15,7 @@ import (
 	nginxLog "github.com/0xJacky/Nginx-UI/api/nginx_log"
 	"github.com/0xJacky/Nginx-UI/api/notification"
 	"github.com/0xJacky/Nginx-UI/api/openai"
+	"github.com/0xJacky/Nginx-UI/api/pages"
 	"github.com/0xJacky/Nginx-UI/api/public"
 	"github.com/0xJacky/Nginx-UI/api/settings"
 	"github.com/0xJacky/Nginx-UI/api/sites"
@@ -35,13 +36,15 @@ func InitRouter() {
 
 	initEmbedRoute(r)
 
+	pages.InitRouter(r)
+
 	r.NoRoute(func(c *gin.Context) {
 		c.JSON(http.StatusNotFound, gin.H{
 			"message": "not found",
 		})
 	})
 
-	root := r.Group("/api")
+	root := r.Group("/api", middleware.IPWhiteList())
 	{
 		public.InitRouter(root)
 		crypto.InitPublicRouter(root)

+ 3 - 5
router/routers_embed.go

@@ -9,9 +9,7 @@ import (
 )
 
 func initEmbedRoute(r *gin.Engine) {
-	r.Use(
-		middleware.CacheJs(),
-		middleware.IPWhiteList(),
-		static.Serve("/", middleware.MustFs("")),
-	)
+	r.Use(middleware.CacheJs())
+
+	r.GET("/", middleware.IPWhiteList(), static.Serve("/", middleware.MustFs("")))
 }

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно