mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2025-05-10 18:05:48 +02:00
feat: maintenance mode #739
This commit is contained in:
parent
191ddea309
commit
5d3f478086
33 changed files with 3698 additions and 2222 deletions
78
api/pages/maintenance.go
Normal file
78
api/pages/maintenance.go
Normal file
|
@ -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
api/pages/maintenance.tmpl
Normal file
89
api/pages/maintenance.tmpl
Normal file
|
@ -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
api/pages/router.go
Normal file
11
api/pages/router.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'))
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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
internal/site/maintenance.go
Normal file
371
internal/site/maintenance.go
Normal file
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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("")))
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue