From e71293cd760c4457b387fb28c04615a314bf54e5 Mon Sep 17 00:00:00 2001 From: 0xJacky Date: Sat, 4 Oct 2025 04:51:23 +0000 Subject: [PATCH] feat: add deploy_mode field to namespace and implement sandbox testing for nginx config #1350 --- api/cluster/namespace.go | 2 + api/nginx/control.go | 63 +++ api/nginx/router.go | 1 + api/sites/list.go | 19 +- api/streams/streams.go | 19 +- app/components.d.ts | 26 +- app/src/App.vue | 2 +- app/src/api/namespace.ts | 7 + app/src/api/ngx.ts | 4 + .../{ => AppProvider}/AppProvider.vue | 0 app/src/components/AppProvider/index.ts | 3 + .../InspectConfig}/InspectConfig.vue | 27 +- app/src/components/InspectConfig/index.ts | 3 + .../NamespaceTabs/NamespaceTabs.vue | 16 +- .../components/NginxControl/NginxControl.vue | 2 +- .../constants.ts => constants/config.ts} | 0 app/src/constants/index.ts | 5 + app/src/language/ar/app.po | 64 ++-- app/src/language/de_DE/app.po | 66 ++-- app/src/language/en/app.po | 55 +-- app/src/language/es/app.po | 66 ++-- app/src/language/fr_FR/app.po | 66 ++-- app/src/language/ja_JP/app.po | 64 ++-- app/src/language/ko_KR/app.po | 64 ++-- app/src/language/messages.pot | 51 ++- app/src/language/pt_PT/app.po | 66 ++-- app/src/language/ru_RU/app.po | 64 ++-- app/src/language/tr_TR/app.po | 64 ++-- app/src/language/uk_UA/app.po | 64 ++-- app/src/language/vi_VN/app.po | 64 ++-- app/src/language/zh_CN/app.po | 64 ++-- app/src/language/zh_TW/app.po | 64 ++-- app/src/views/config/ConfigList.vue | 2 +- .../config/components/ConfigLeftPanel.vue | 2 +- app/src/views/namespace/columns.ts | 17 +- .../components/SiteEditor/SiteEditor.vue | 13 + app/src/views/site/site_list/SiteList.vue | 4 +- app/src/views/stream/StreamList.vue | 8 +- .../views/stream/components/StreamEditor.vue | 13 + app/src/views/upstream/SocketList.vue | 2 +- internal/nginx/sandbox.go | 360 ++++++++++++++++++ internal/nginx/sandbox_test.go | 254 ++++++++++++ model/namespace.go | 9 + query/namespaces.gen.go | 6 +- 44 files changed, 1448 insertions(+), 387 deletions(-) rename app/src/components/{ => AppProvider}/AppProvider.vue (100%) create mode 100644 app/src/components/AppProvider/index.ts rename app/src/{views/config => components/InspectConfig}/InspectConfig.vue (66%) create mode 100644 app/src/components/InspectConfig/index.ts rename app/src/{views/config/constants.ts => constants/config.ts} (100%) create mode 100644 internal/nginx/sandbox.go create mode 100644 internal/nginx/sandbox_test.go diff --git a/api/cluster/namespace.go b/api/cluster/namespace.go index 56e280c6..0151b225 100644 --- a/api/cluster/namespace.go +++ b/api/cluster/namespace.go @@ -82,6 +82,7 @@ func AddNamespace(c *gin.Context) { "sync_node_ids": "omitempty", "post_sync_action": "omitempty,oneof=" + model.PostSyncActionNone + " " + model.PostSyncActionReloadNginx, "upstream_test_type": "omitempty,oneof=" + model.UpstreamTestLocal + " " + model.UpstreamTestRemote + " " + model.UpstreamTestMirror, + "deploy_mode": "omitempty,oneof=" + model.DeployModeLocal + " " + model.DeployModeRemote, }). Create() } @@ -93,6 +94,7 @@ func ModifyNamespace(c *gin.Context) { "sync_node_ids": "omitempty", "post_sync_action": "omitempty,oneof=" + model.PostSyncActionNone + " " + model.PostSyncActionReloadNginx, "upstream_test_type": "omitempty,oneof=" + model.UpstreamTestLocal + " " + model.UpstreamTestRemote + " " + model.UpstreamTestMirror, + "deploy_mode": "omitempty,oneof=" + model.DeployModeLocal + " " + model.DeployModeRemote, }). Modify() } diff --git a/api/nginx/control.go b/api/nginx/control.go index 975fa4de..4aaee0b3 100644 --- a/api/nginx/control.go +++ b/api/nginx/control.go @@ -4,7 +4,9 @@ import ( "net/http" "github.com/0xJacky/Nginx-UI/internal/nginx" + "github.com/0xJacky/Nginx-UI/query" "github.com/gin-gonic/gin" + "github.com/uozi-tech/cosy" ) // Reload reloads the nginx @@ -21,6 +23,67 @@ func TestConfig(c *gin.Context) { }) } +// TestConfigWithNamespace tests nginx config in isolated sandbox for a specific namespace +func TestConfigWithNamespace(c *gin.Context) { + var req struct { + NamespaceID uint64 `json:"namespace_id" form:"namespace_id"` + } + + if !cosy.BindAndValid(c, &req) { + return + } + + // Get namespace and related configs + var namespaceInfo *nginx.NamespaceInfo + var sitePaths []string + var streamPaths []string + + if req.NamespaceID > 0 { + // Fetch namespace + ns := query.Namespace + namespace, err := ns.Where(ns.ID.Eq(req.NamespaceID)).First() + if err != nil { + cosy.ErrHandler(c, err) + return + } + + namespaceInfo = &nginx.NamespaceInfo{ + ID: namespace.ID, + Name: namespace.Name, + DeployMode: namespace.DeployMode, + } + + // Fetch sites belonging to this namespace + s := query.Site + sites, err := s.Where(s.NamespaceID.Eq(req.NamespaceID)).Find() + if err == nil { + for _, site := range sites { + sitePaths = append(sitePaths, site.Path) + } + } + + // Fetch streams belonging to this namespace + st := query.Stream + streams, err := st.Where(st.NamespaceID.Eq(req.NamespaceID)).Find() + if err == nil { + for _, stream := range streams { + streamPaths = append(streamPaths, stream.Path) + } + } + } + + // Use sandbox test with namespace-specific paths + result := nginx.Control(func() (string, error) { + return nginx.SandboxTestConfigWithPaths(namespaceInfo, sitePaths, streamPaths) + }) + + c.JSON(http.StatusOK, gin.H{ + "message": result.GetOutput(), + "level": result.GetLevel(), + "namespace_id": req.NamespaceID, + }) +} + // Restart restarts the nginx func Restart(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ diff --git a/api/nginx/router.go b/api/nginx/router.go index 7cb21fbc..f1e2deef 100644 --- a/api/nginx/router.go +++ b/api/nginx/router.go @@ -12,6 +12,7 @@ func InitRouter(r *gin.RouterGroup) { r.POST("nginx/reload", Reload) r.POST("nginx/restart", Restart) r.POST("nginx/test", TestConfig) + r.POST("nginx/test_namespace", TestConfigWithNamespace) r.GET("nginx/status", Status) // Get detailed Nginx status information, including connection count, process information, etc. (Issue #850) r.GET("nginx/detail_status", GetDetailStatus) diff --git a/api/sites/list.go b/api/sites/list.go index 94fc8c28..ce43f88d 100644 --- a/api/sites/list.go +++ b/api/sites/list.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/0xJacky/Nginx-UI/internal/site" + "github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/query" "github.com/gin-gonic/gin" "github.com/spf13/cast" @@ -23,12 +24,20 @@ func GetSiteList(c *gin.Context) { // Get sites from database s := query.Site - sTx := s.Preload(s.Namespace) - if options.NamespaceID != 0 { - sTx = sTx.Where(s.NamespaceID.Eq(options.NamespaceID)) - } + db := cosy.UseDB(c) - sites, err := sTx.Find() + var sites []*model.Site + var err error + + if options.NamespaceID == 0 { + // Local tab: no namespace OR deploy_mode='local' + err = db.Where("namespace_id IS NULL OR namespace_id IN (?)", + db.Model(&model.Namespace{}).Where("deploy_mode = ?", "local").Select("id"), + ).Preload("Namespace").Find(&sites).Error + } else { + // Remote tab: specific namespace + sites, err = s.Where(s.NamespaceID.Eq(options.NamespaceID)).Preload(s.Namespace).Find() + } if err != nil { cosy.ErrHandler(c, err) return diff --git a/api/streams/streams.go b/api/streams/streams.go index 347adada..60447223 100644 --- a/api/streams/streams.go +++ b/api/streams/streams.go @@ -93,10 +93,23 @@ func GetStreams(c *gin.Context) { // Get streams with optional filtering var streams []*model.Stream - if options.NamespaceID != 0 { - streams, err = s.Where(s.NamespaceID.Eq(options.NamespaceID)).Find() + if options.NamespaceID == 0 { + // Local tab: no namespace OR deploy_mode='local' + localNamespaceIDs := lo.Map(lo.Filter(namespaces, func(item *model.Namespace, _ int) bool { + return item.DeployMode == "local" + }), func(item *model.Namespace, _ int) uint64 { + return item.ID + }) + + db := cosy.UseDB(c) + if len(localNamespaceIDs) > 0 { + err = db.Where("namespace_id IS NULL OR namespace_id IN (?)", localNamespaceIDs).Find(&streams).Error + } else { + err = db.Where("namespace_id IS NULL").Find(&streams).Error + } } else { - streams, err = s.Find() + // Remote tab: specific namespace + streams, err = s.Where(s.NamespaceID.Eq(options.NamespaceID)).Find() } if err != nil { cosy.ErrHandler(c, err) diff --git a/app/components.d.ts b/app/components.d.ts index c906bc35..d8311c22 100644 --- a/app/components.d.ts +++ b/app/components.d.ts @@ -10,16 +10,28 @@ declare module 'vue' { export interface GlobalComponents { AAlert: typeof import('ant-design-vue/es')['Alert'] AApp: typeof import('ant-design-vue/es')['App'] + AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete'] ABadge: typeof import('ant-design-vue/es')['Badge'] ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb'] ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem'] AButton: typeof import('ant-design-vue/es')['Button'] ACard: typeof import('ant-design-vue/es')['Card'] + ACheckbox: typeof import('ant-design-vue/es')['Checkbox'] + ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup'] + ACol: typeof import('ant-design-vue/es')['Col'] + ACollapse: typeof import('ant-design-vue/es')['Collapse'] + ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel'] + AComment: typeof import('ant-design-vue/es')['Comment'] AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider'] ADivider: typeof import('ant-design-vue/es')['Divider'] ADrawer: typeof import('ant-design-vue/es')['Drawer'] + ADropdown: typeof import('ant-design-vue/es')['Dropdown'] + AEmpty: typeof import('ant-design-vue/es')['Empty'] + AForm: typeof import('ant-design-vue/es')['Form'] + AFormItem: typeof import('ant-design-vue/es')['FormItem'] AInput: typeof import('ant-design-vue/es')['Input'] AInputGroup: typeof import('ant-design-vue/es')['InputGroup'] + AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] ALayout: typeof import('ant-design-vue/es')['Layout'] ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent'] ALayoutFooter: typeof import('ant-design-vue/es')['LayoutFooter'] @@ -29,17 +41,28 @@ declare module 'vue' { AListItem: typeof import('ant-design-vue/es')['ListItem'] AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta'] AMenu: typeof import('ant-design-vue/es')['Menu'] + AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider'] AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] + AModal: typeof import('ant-design-vue/es')['Modal'] APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] APopover: typeof import('ant-design-vue/es')['Popover'] - AppProvider: typeof import('./src/components/AppProvider.vue')['default'] + AppProviderAppProvider: typeof import('./src/components/AppProvider/AppProvider.vue')['default'] + AProgress: typeof import('ant-design-vue/es')['Progress'] + AResult: typeof import('ant-design-vue/es')['Result'] + ARow: typeof import('ant-design-vue/es')['Row'] ASelect: typeof import('ant-design-vue/es')['Select'] ASelectOption: typeof import('ant-design-vue/es')['SelectOption'] ASpace: typeof import('ant-design-vue/es')['Space'] + ASpin: typeof import('ant-design-vue/es')['Spin'] + AStep: typeof import('ant-design-vue/es')['Step'] + ASteps: typeof import('ant-design-vue/es')['Steps'] ASubMenu: typeof import('ant-design-vue/es')['SubMenu'] ASwitch: typeof import('ant-design-vue/es')['Switch'] ATable: typeof import('ant-design-vue/es')['Table'] + ATabPane: typeof import('ant-design-vue/es')['TabPane'] + ATabs: typeof import('ant-design-vue/es')['Tabs'] ATag: typeof import('ant-design-vue/es')['Tag'] + ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATooltip: typeof import('ant-design-vue/es')['Tooltip'] AutoCertFormAutoCertForm: typeof import('./src/components/AutoCertForm/AutoCertForm.vue')['default'] AutoCertFormDNSChallenge: typeof import('./src/components/AutoCertForm/DNSChallenge.vue')['default'] @@ -55,6 +78,7 @@ declare module 'vue' { DevDebugPanelDevDebugPanel: typeof import('./src/components/DevDebugPanel/DevDebugPanel.vue')['default'] FooterToolbarFooterToolBar: typeof import('./src/components/FooterToolbar/FooterToolBar.vue')['default'] ICPICP: typeof import('./src/components/ICP/ICP.vue')['default'] + InspectConfigInspectConfig: typeof import('./src/components/InspectConfig/InspectConfig.vue')['default'] LLMChatMessage: typeof import('./src/components/LLM/ChatMessage.vue')['default'] LLMChatMessageInput: typeof import('./src/components/LLM/ChatMessageInput.vue')['default'] LLMChatMessageList: typeof import('./src/components/LLM/ChatMessageList.vue')['default'] diff --git a/app/src/App.vue b/app/src/App.vue index 21d45df3..c3191095 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -4,7 +4,7 @@ import en_US from 'ant-design-vue/es/locale/en_US' import zh_CN from 'ant-design-vue/es/locale/zh_CN' import zh_TW from 'ant-design-vue/es/locale/zh_TW' import loadTranslations from '@/api/translations' -import AppProvider from '@/components/AppProvider.vue' +import AppProvider from '@/components/AppProvider' import gettext from '@/gettext' import { useSettingsStore } from '@/pinia' diff --git a/app/src/api/namespace.ts b/app/src/api/namespace.ts index 0fd356fe..38e072ed 100644 --- a/app/src/api/namespace.ts +++ b/app/src/api/namespace.ts @@ -14,11 +14,18 @@ export const UpstreamTestType = { Mirror: 'mirror', } +// Deploy mode types +export const DeployMode = { + Local: 'local', + Remote: 'remote', +} as const + export interface Namespace extends ModelBase { name: string sync_node_ids: number[] post_sync_action?: string upstream_test_type?: string + deploy_mode?: string } const baseUrl = '/namespaces' diff --git a/app/src/api/ngx.ts b/app/src/api/ngx.ts index bde9458f..041a6092 100644 --- a/app/src/api/ngx.ts +++ b/app/src/api/ngx.ts @@ -148,6 +148,10 @@ const ngx = { return http.post('/nginx/test') }, + test_namespace(namespace_id?: number): Promise<{ message: string, level: number, namespace_id?: number }> { + return http.post('/nginx/test_namespace', { namespace_id }) + }, + get_directives(): Promise { return http.get('/nginx/directives') }, diff --git a/app/src/components/AppProvider.vue b/app/src/components/AppProvider/AppProvider.vue similarity index 100% rename from app/src/components/AppProvider.vue rename to app/src/components/AppProvider/AppProvider.vue diff --git a/app/src/components/AppProvider/index.ts b/app/src/components/AppProvider/index.ts new file mode 100644 index 00000000..0d4c3b8f --- /dev/null +++ b/app/src/components/AppProvider/index.ts @@ -0,0 +1,3 @@ +import AppProvider from './AppProvider.vue' + +export default AppProvider diff --git a/app/src/views/config/InspectConfig.vue b/app/src/components/InspectConfig/InspectConfig.vue similarity index 66% rename from app/src/views/config/InspectConfig.vue rename to app/src/components/InspectConfig/InspectConfig.vue index eb909219..b9f3f869 100644 --- a/app/src/views/config/InspectConfig.vue +++ b/app/src/components/InspectConfig/InspectConfig.vue @@ -1,25 +1,37 @@