Browse Source

refactor: refresh 25.04

Jacky 2 months ago
parent
commit
b63dbe1e50
98 changed files with 4828 additions and 4346 deletions
  1. 127 162
      api/certificate/certificate.go
  2. 6 0
      api/config/rename.go
  3. 19 0
      app/components.d.ts
  4. 1 0
      app/package.json
  5. 125 0
      app/pnpm-lock.yaml
  6. 6 1
      app/src/api/auto_cert.ts
  7. 2 1
      app/src/api/cert.ts
  8. 4 2
      app/src/api/template.ts
  9. 7 8
      app/src/components/AutoCertForm/AutoCertForm.vue
  10. 0 0
      app/src/components/AutoCertForm/DNSChallenge.vue
  11. 3 0
      app/src/components/AutoCertForm/index.ts
  12. 0 0
      app/src/components/CertInfo/CertInfo.vue
  13. 3 0
      app/src/components/CertInfo/index.ts
  14. 1 1
      app/src/components/ChatGPT/ChatGPT.vue
  15. 3 0
      app/src/components/ChatGPT/index.ts
  16. 3 2
      app/src/components/CodeEditor/CodeEditor.vue
  17. 180 0
      app/src/components/NgxConfigEditor/LocationEditor.vue
  18. 7 8
      app/src/components/NgxConfigEditor/LogEntry.vue
  19. 0 0
      app/src/components/NgxConfigEditor/NginxStatusAlert.vue
  20. 73 0
      app/src/components/NgxConfigEditor/NgxConfigEditor.vue
  21. 121 0
      app/src/components/NgxConfigEditor/NgxServer.vue
  22. 36 35
      app/src/components/NgxConfigEditor/NgxUpstream.vue
  23. 3 0
      app/src/components/NgxConfigEditor/README.md
  24. 16 33
      app/src/components/NgxConfigEditor/directive/DirectiveAdd.vue
  25. 29 0
      app/src/components/NgxConfigEditor/directive/DirectiveDocuments.vue
  26. 80 0
      app/src/components/NgxConfigEditor/directive/DirectiveEditor.vue
  27. 25 24
      app/src/components/NgxConfigEditor/directive/DirectiveEditorItem.vue
  28. 2 0
      app/src/components/NgxConfigEditor/directive/index.ts
  29. 21 0
      app/src/components/NgxConfigEditor/directive/store.ts
  30. 28 0
      app/src/components/NgxConfigEditor/index.ts
  31. 69 0
      app/src/components/NgxConfigEditor/store.ts
  32. 8 0
      app/src/constants/index.ts
  33. 168 163
      app/src/language/ar/app.po
  34. 164 159
      app/src/language/de_DE/app.po
  35. 164 158
      app/src/language/en/app.po
  36. 168 164
      app/src/language/es/app.po
  37. 165 160
      app/src/language/fr_FR/app.po
  38. 168 163
      app/src/language/ko_KR/app.po
  39. 163 161
      app/src/language/messages.pot
  40. 168 163
      app/src/language/ru_RU/app.po
  41. 165 158
      app/src/language/tr_TR/app.po
  42. 167 163
      app/src/language/uk_UA/app.po
  43. 164 158
      app/src/language/vi_VN/app.po
  44. 166 160
      app/src/language/zh_CN/app.po
  45. 168 163
      app/src/language/zh_TW/app.po
  46. 1 1
      app/src/version.json
  47. 5 3
      app/src/views/certificate/components/CertificateEditor.vue
  48. 1 5
      app/src/views/certificate/components/RenewCert.vue
  49. 3 5
      app/src/views/certificate/components/WildcardCertificate.vue
  50. 1 1
      app/src/views/preference/ServerSettings.vue
  51. 0 78
      app/src/views/site/cert/IssueCert.vue
  52. 0 178
      app/src/views/site/ngx_conf/LocationEditor.vue
  53. 0 219
      app/src/views/site/ngx_conf/NgxConfigEditor.vue
  54. 0 175
      app/src/views/site/ngx_conf/NgxServer.vue
  55. 0 163
      app/src/views/site/ngx_conf/config_template/ConfigTemplate.vue
  56. 0 28
      app/src/views/site/ngx_conf/directive/DirectiveDocuments.vue
  57. 0 68
      app/src/views/site/ngx_conf/directive/DirectiveEditor.vue
  58. 0 1
      app/src/views/site/ngx_conf/index.ts
  59. 24 42
      app/src/views/site/site_add/SiteAdd.vue
  60. 0 133
      app/src/views/site/site_edit/RightSettings.vue
  61. 32 350
      app/src/views/site/site_edit/SiteEdit.vue
  62. 10 15
      app/src/views/site/site_edit/components/Cert/Cert.vue
  63. 0 0
      app/src/views/site/site_edit/components/Cert/ChangeCert.vue
  64. 103 0
      app/src/views/site/site_edit/components/Cert/IssueCert.vue
  65. 47 44
      app/src/views/site/site_edit/components/Cert/ObtainCert.vue
  66. 4 2
      app/src/views/site/site_edit/components/Cert/ObtainCertLive.vue
  67. 3 0
      app/src/views/site/site_edit/components/Cert/index.ts
  68. 0 0
      app/src/views/site/site_edit/components/ConfigName/ConfigName.vue
  69. 3 0
      app/src/views/site/site_edit/components/ConfigName/index.ts
  70. 125 0
      app/src/views/site/site_edit/components/ConfigTemplate/ConfigTemplate.vue
  71. 1 1
      app/src/views/site/site_edit/components/ConfigTemplate/TemplateForm.vue
  72. 7 7
      app/src/views/site/site_edit/components/ConfigTemplate/TemplateFormItem.vue
  73. 3 0
      app/src/views/site/site_edit/components/ConfigTemplate/index.ts
  74. 26 0
      app/src/views/site/site_edit/components/ConfigTemplate/store.ts
  75. 120 0
      app/src/views/site/site_edit/components/EnableTLS/EnableTLS.vue
  76. 3 0
      app/src/views/site/site_edit/components/EnableTLS/index.ts
  77. 92 0
      app/src/views/site/site_edit/components/RightPanel/Basic.vue
  78. 31 0
      app/src/views/site/site_edit/components/RightPanel/Chat.vue
  79. 9 0
      app/src/views/site/site_edit/components/RightPanel/ConfigTemplate.vue
  80. 72 0
      app/src/views/site/site_edit/components/RightPanel/RightPanel.vue
  81. 3 0
      app/src/views/site/site_edit/components/RightPanel/index.ts
  82. 214 0
      app/src/views/site/site_edit/components/SiteEditor/SiteEditor.vue
  83. 4 0
      app/src/views/site/site_edit/components/SiteEditor/index.ts
  84. 174 0
      app/src/views/site/site_edit/components/SiteEditor/store.ts
  85. 17 277
      app/src/views/stream/StreamEdit.vue
  86. 1 1
      app/src/views/stream/components/ConfigName.vue
  87. 116 0
      app/src/views/stream/components/RightPanel/Basic.vue
  88. 17 0
      app/src/views/stream/components/RightPanel/Chat.vue
  89. 54 0
      app/src/views/stream/components/RightPanel/RightPanel.vue
  90. 3 0
      app/src/views/stream/components/RightPanel/index.ts
  91. 0 166
      app/src/views/stream/components/RightSettings.vue
  92. 135 0
      app/src/views/stream/components/StreamEditor.vue
  93. 160 0
      app/src/views/stream/store.ts
  94. 2 0
      app/vite.config.ts
  95. 11 0
      internal/site/rename.go
  96. 16 4
      internal/stream/rename.go
  97. 1 1
      template/block/letsencrypt.conf
  98. 8 8
      template/block/nginx-ui.conf

+ 127 - 162
api/certificate/certificate.go

@@ -57,21 +57,25 @@ func Transformer(certModel *model.Cert) (certificate *APICertificate) {
 }
 
 func GetCertList(c *gin.Context) {
-	cosy.Core[model.Cert](c).SetFussy("name", "domain").SetTransformer(func(m *model.Cert) any {
-
-		info, _ := cert.GetCertInfo(m.SSLCertificatePath)
-
-		return APICertificate{
-			Cert:            m,
-			CertificateInfo: info,
-		}
-	}).PagingList()
+	cosy.Core[model.Cert](c).SetFussy("name", "domain").
+		SetTransformer(func(m *model.Cert) any {
+			info, _ := cert.GetCertInfo(m.SSLCertificatePath)
+			return APICertificate{
+				Cert:            m,
+				CertificateInfo: info,
+			}
+		}).PagingList()
 }
 
 func GetCert(c *gin.Context) {
 	q := query.Cert
 
-	certModel, err := q.FirstByID(cast.ToUint64(c.Param("id")))
+	id := cast.ToUint64(c.Param("id"))
+	if contextId, ok := c.Get("id"); ok {
+		id = cast.ToUint64(contextId)
+	}
+
+	certModel, err := q.FirstByID(id)
 
 	if err != nil {
 		cosy.ErrHandler(c, err)
@@ -81,167 +85,128 @@ func GetCert(c *gin.Context) {
 	c.JSON(http.StatusOK, Transformer(certModel))
 }
 
-type certJson struct {
-	Name                  string             `json:"name" binding:"required"`
-	SSLCertificatePath    string             `json:"ssl_certificate_path" binding:"required,certificate_path"`
-	SSLCertificateKeyPath string             `json:"ssl_certificate_key_path" binding:"required,privatekey_path"`
-	SSLCertificate        string             `json:"ssl_certificate" binding:"omitempty,certificate"`
-	SSLCertificateKey     string             `json:"ssl_certificate_key" binding:"omitempty,privatekey"`
-	KeyType               certcrypto.KeyType `json:"key_type" binding:"omitempty,auto_cert_key_type"`
-	ChallengeMethod       string             `json:"challenge_method"`
-	DnsCredentialID       uint64             `json:"dns_credential_id"`
-	ACMEUserID            uint64             `json:"acme_user_id"`
-	SyncNodeIds           []uint64           `json:"sync_node_ids"`
-	RevokeOld             bool               `json:"revoke_old"`
-}
-
 func AddCert(c *gin.Context) {
-	var json certJson
-
-	if !cosy.BindAndValid(c, &json) {
-		return
-	}
-
-	certModel := &model.Cert{
-		Name:                  json.Name,
-		SSLCertificatePath:    json.SSLCertificatePath,
-		SSLCertificateKeyPath: json.SSLCertificateKeyPath,
-		KeyType:               json.KeyType,
-		ChallengeMethod:       json.ChallengeMethod,
-		DnsCredentialID:       json.DnsCredentialID,
-		ACMEUserID:            json.ACMEUserID,
-		SyncNodeIds:           json.SyncNodeIds,
-	}
-
-	err := certModel.Insert()
-	if err != nil {
-		cosy.ErrHandler(c, err)
-		return
-	}
-
-	content := &cert.Content{
-		SSLCertificatePath:    json.SSLCertificatePath,
-		SSLCertificateKeyPath: json.SSLCertificateKeyPath,
-		SSLCertificate:        json.SSLCertificate,
-		SSLCertificateKey:     json.SSLCertificateKey,
-	}
-
-	err = content.WriteFile()
-	if err != nil {
-		cosy.ErrHandler(c, err)
-		return
-	}
-
-	// Detect and set certificate type
-	if len(json.SSLCertificate) > 0 {
-		keyType, err := cert.GetKeyType(json.SSLCertificate)
-		if err == nil && keyType != "" {
-			// Set KeyType based on certificate type
-			switch keyType {
-			case "2048":
-				certModel.KeyType = certcrypto.RSA2048
-			case "3072":
-				certModel.KeyType = certcrypto.RSA3072
-			case "4096":
-				certModel.KeyType = certcrypto.RSA4096
-			case "P256":
-				certModel.KeyType = certcrypto.EC256
-			case "P384":
-				certModel.KeyType = certcrypto.EC384
+	cosy.Core[model.Cert](c).
+		SetValidRules(gin.H{
+			"name":                       "omitempty",
+			"ssl_certificate_path":       "required,certificate_path",
+			"ssl_certificate_key_path":   "required,privatekey_path",
+			"ssl_certificate":            "omitempty,certificate",
+			"ssl_certificate_key":        "omitempty,privatekey",
+			"key_type":                   "omitempty,auto_cert_key_type",
+			"challenge_method":           "omitempty,oneof=http01 dns01",
+			"dns_credential_id":          "omitempty",
+			"acme_user_id":               "omitempty",
+			"sync_node_ids":              "omitempty",
+			"must_staple":                "omitempty",
+			"lego_disable_cname_support": "omitempty",
+			"revoke_old":                 "omitempty",
+		}).
+		BeforeExecuteHook(func(ctx *cosy.Ctx[model.Cert]) {
+			sslCertificate := ctx.Payload["ssl_certificate"].(string)
+			// Detect and set certificate type
+			if sslCertificate != "" {
+				keyType, err := cert.GetKeyType(sslCertificate)
+				if err == nil && keyType != "" {
+					// Set KeyType based on certificate type
+					switch keyType {
+					case "2048":
+						ctx.Model.KeyType = certcrypto.RSA2048
+					case "3072":
+						ctx.Model.KeyType = certcrypto.RSA3072
+					case "4096":
+						ctx.Model.KeyType = certcrypto.RSA4096
+					case "P256":
+						ctx.Model.KeyType = certcrypto.EC256
+					case "P384":
+						ctx.Model.KeyType = certcrypto.EC384
+					}
+				}
+			}
+		}).
+		ExecutedHook(func(ctx *cosy.Ctx[model.Cert]) {
+			content := &cert.Content{
+				SSLCertificatePath:    ctx.Model.SSLCertificatePath,
+				SSLCertificateKeyPath: ctx.Model.SSLCertificateKeyPath,
+				SSLCertificate:        ctx.Payload["ssl_certificate"].(string),
+				SSLCertificateKey:     ctx.Payload["ssl_certificate_key"].(string),
 			}
-			// Update certificate model
-			err = certModel.Updates(&model.Cert{KeyType: certModel.KeyType})
+			err := content.WriteFile()
 			if err != nil {
-				notification.Error("Update Certificate Type Error", err.Error(), nil)
+				ctx.AbortWithError(err)
+				return
 			}
-		}
-	}
-
-	err = cert.SyncToRemoteServer(certModel)
-	if err != nil {
-		notification.Error("Sync Certificate Error", err.Error(), nil)
-		return
-	}
-
-	c.JSON(http.StatusOK, Transformer(certModel))
+			err = cert.SyncToRemoteServer(&ctx.Model)
+			if err != nil {
+				notification.Error("Sync Certificate Error", err.Error(), nil)
+				return
+			}
+			ctx.Context.Set("id", ctx.Model.ID)
+		}).
+		SetNextHandler(GetCert).
+		Create()
 }
 
 func ModifyCert(c *gin.Context) {
-	id := cast.ToUint64(c.Param("id"))
-
-	var json certJson
-
-	if !cosy.BindAndValid(c, &json) {
-		return
-	}
-
-	q := query.Cert
-
-	certModel, err := q.FirstByID(id)
-	if err != nil {
-		cosy.ErrHandler(c, err)
-		return
-	}
-
-	// Create update data object
-	updateData := &model.Cert{
-		Name:                  json.Name,
-		SSLCertificatePath:    json.SSLCertificatePath,
-		SSLCertificateKeyPath: json.SSLCertificateKeyPath,
-		ChallengeMethod:       json.ChallengeMethod,
-		KeyType:               json.KeyType,
-		DnsCredentialID:       json.DnsCredentialID,
-		ACMEUserID:            json.ACMEUserID,
-		SyncNodeIds:           json.SyncNodeIds,
-		RevokeOld:             json.RevokeOld,
-	}
-
-	content := &cert.Content{
-		SSLCertificatePath:    json.SSLCertificatePath,
-		SSLCertificateKeyPath: json.SSLCertificateKeyPath,
-		SSLCertificate:        json.SSLCertificate,
-		SSLCertificateKey:     json.SSLCertificateKey,
-	}
-
-	err = content.WriteFile()
-	if err != nil {
-		cosy.ErrHandler(c, err)
-		return
-	}
-
-	// Detect and set certificate type
-	if len(json.SSLCertificate) > 0 {
-		keyType, err := cert.GetKeyType(json.SSLCertificate)
-		if err == nil && keyType != "" {
-			// Set KeyType based on certificate type
-			switch keyType {
-			case "2048":
-				updateData.KeyType = certcrypto.RSA2048
-			case "3072":
-				updateData.KeyType = certcrypto.RSA3072
-			case "4096":
-				updateData.KeyType = certcrypto.RSA4096
-			case "P256":
-				updateData.KeyType = certcrypto.EC256
-			case "P384":
-				updateData.KeyType = certcrypto.EC384
+	cosy.Core[model.Cert](c).
+		SetValidRules(gin.H{
+			"name":                       "omitempty",
+			"ssl_certificate_path":       "required,certificate_path",
+			"ssl_certificate_key_path":   "required,privatekey_path",
+			"ssl_certificate":            "omitempty,certificate",
+			"ssl_certificate_key":        "omitempty,privatekey",
+			"key_type":                   "omitempty,auto_cert_key_type",
+			"challenge_method":           "omitempty,oneof=http01 dns01",
+			"dns_credential_id":          "omitempty",
+			"acme_user_id":               "omitempty",
+			"sync_node_ids":              "omitempty",
+			"must_staple":                "omitempty",
+			"lego_disable_cname_support": "omitempty",
+			"revoke_old":                 "omitempty",
+		}).
+		BeforeExecuteHook(func(ctx *cosy.Ctx[model.Cert]) {
+			sslCertificate := ctx.Payload["ssl_certificate"].(string)
+			// Detect and set certificate type
+			if sslCertificate != "" {
+				keyType, err := cert.GetKeyType(sslCertificate)
+				if err == nil && keyType != "" {
+					// Set KeyType based on certificate type
+					switch keyType {
+					case "2048":
+						ctx.Model.KeyType = certcrypto.RSA2048
+					case "3072":
+						ctx.Model.KeyType = certcrypto.RSA3072
+					case "4096":
+						ctx.Model.KeyType = certcrypto.RSA4096
+					case "P256":
+						ctx.Model.KeyType = certcrypto.EC256
+					case "P384":
+						ctx.Model.KeyType = certcrypto.EC384
+					}
+				}
+			}
+		}).
+		ExecutedHook(func(ctx *cosy.Ctx[model.Cert]) {
+			content := &cert.Content{
+				SSLCertificatePath:    ctx.Model.SSLCertificatePath,
+				SSLCertificateKeyPath: ctx.Model.SSLCertificateKeyPath,
+				SSLCertificate:        ctx.Payload["ssl_certificate"].(string),
+				SSLCertificateKey:     ctx.Payload["ssl_certificate_key"].(string),
+			}
+			err := content.WriteFile()
+			if err != nil {
+				ctx.AbortWithError(err)
+				return
+			}
+			err = cert.SyncToRemoteServer(&ctx.Model)
+			if err != nil {
+				notification.Error("Sync Certificate Error", err.Error(), nil)
+				return
 			}
-		}
-	}
-
-	err = certModel.Updates(updateData)
-	if err != nil {
-		cosy.ErrHandler(c, err)
-		return
-	}
-
-	err = cert.SyncToRemoteServer(certModel)
-	if err != nil {
-		notification.Error("Sync Certificate Error", err.Error(), nil)
-		return
-	}
 
-	GetCert(c)
+		}).
+		SetNextHandler(GetCert).
+		Modify()
 }
 
 func RemoveCert(c *gin.Context) {

+ 6 - 0
api/config/rename.go

@@ -110,6 +110,12 @@ func Rename(c *gin.Context) {
 		return
 	}
 
+	b := query.ConfigBackup
+	_, _ = b.Where(b.FilePath.Eq(origFullPath)).Updates(map[string]interface{}{
+		"filepath": newFullPath,
+		"name":     json.NewName,
+	})
+
 	if len(json.SyncNodeIds) > 0 {
 		err = config.SyncRenameOnRemoteServer(origFullPath, newFullPath, json.SyncNodeIds)
 		if err != nil {

+ 19 - 0
app/components.d.ts

@@ -72,7 +72,11 @@ declare module 'vue' {
     ATextarea: typeof import('ant-design-vue/es')['Textarea']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
     AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
+    AutoCertFormAutoCertForm: typeof import('./src/components/AutoCertForm/AutoCertForm.vue')['default']
+    AutoCertFormDNSChallenge: typeof import('./src/components/AutoCertForm/DNSChallenge.vue')['default']
     BreadcrumbBreadcrumb: typeof import('./src/components/Breadcrumb/Breadcrumb.vue')['default']
+    CertInfoCertInfo: typeof import('./src/components/CertInfo/CertInfo.vue')['default']
+    ChangeCertChangeCert: typeof import('./src/views/site/NgxConfigEditor/cert/ChangeCert.vue')['default']
     ChartAreaChart: typeof import('./src/components/Chart/AreaChart.vue')['default']
     ChartRadialBarChart: typeof import('./src/components/Chart/RadialBarChart.vue')['default']
     ChartUsageProgressLine: typeof import('./src/components/Chart/UsageProgressLine.vue')['default']
@@ -86,6 +90,21 @@ declare module 'vue' {
     ICPICP: typeof import('./src/components/ICP/ICP.vue')['default']
     LogoLogo: typeof import('./src/components/Logo/Logo.vue')['default']
     NginxControlNginxControl: typeof import('./src/components/NginxControl/NginxControl.vue')['default']
+    NgxConfigEditorCertCert: typeof import('./src/components/NgxConfigEditor/cert/Cert.vue')['default']
+    NgxConfigEditorCertChangeCert: typeof import('./src/components/NgxConfigEditor/cert/ChangeCert.vue')['default']
+    NgxConfigEditorCertIssueCert: typeof import('./src/components/NgxConfigEditor/cert/IssueCert.vue')['default']
+    NgxConfigEditorCertObtainCert: typeof import('./src/components/NgxConfigEditor/cert/ObtainCert.vue')['default']
+    NgxConfigEditorCertObtainCertLive: typeof import('./src/components/NgxConfigEditor/cert/ObtainCertLive.vue')['default']
+    NgxConfigEditorDirectiveDirectiveAdd: typeof import('./src/components/NgxConfigEditor/directive/DirectiveAdd.vue')['default']
+    NgxConfigEditorDirectiveDirectiveDocuments: typeof import('./src/components/NgxConfigEditor/directive/DirectiveDocuments.vue')['default']
+    NgxConfigEditorDirectiveDirectiveEditor: typeof import('./src/components/NgxConfigEditor/directive/DirectiveEditor.vue')['default']
+    NgxConfigEditorDirectiveDirectiveEditorItem: typeof import('./src/components/NgxConfigEditor/directive/DirectiveEditorItem.vue')['default']
+    NgxConfigEditorLocationEditor: typeof import('./src/components/NgxConfigEditor/LocationEditor.vue')['default']
+    NgxConfigEditorLogEntry: typeof import('./src/components/NgxConfigEditor/LogEntry.vue')['default']
+    NgxConfigEditorNginxStatusAlert: typeof import('./src/components/NgxConfigEditor/NginxStatusAlert.vue')['default']
+    NgxConfigEditorNgxConfigEditor: typeof import('./src/components/NgxConfigEditor/NgxConfigEditor.vue')['default']
+    NgxConfigEditorNgxServer: typeof import('./src/components/NgxConfigEditor/NgxServer.vue')['default']
+    NgxConfigEditorNgxUpstream: typeof import('./src/components/NgxConfigEditor/NgxUpstream.vue')['default']
     NodeSelectorNodeSelector: typeof import('./src/components/NodeSelector/NodeSelector.vue')['default']
     NotificationNotification: typeof import('./src/components/Notification/Notification.vue')['default']
     OTPInputOTPInput: typeof import('./src/components/OTPInput/OTPInput.vue')['default']

+ 1 - 0
app/package.json

@@ -80,6 +80,7 @@
     "unplugin-vue-components": "^28.5.0",
     "unplugin-vue-define-options": "^1.5.5",
     "vite": "^6.3.2",
+    "vite-plugin-inspect": "^11.0.1",
     "vite-svg-loader": "^5.1.0",
     "vue-tsc": "^2.2.8"
   }

+ 125 - 0
app/pnpm-lock.yaml

@@ -204,6 +204,9 @@ importers:
       vite:
         specifier: ^6.3.2
         version: 6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1)
+      vite-plugin-inspect:
+        specifier: ^11.0.1
+        version: 11.0.1(@nuxt/kit@3.16.2)(vite@6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1))
       vite-svg-loader:
         specifier: ^5.1.0
         version: 5.1.0(vue@3.5.13(typescript@5.8.2))
@@ -1668,6 +1671,10 @@ packages:
     resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==}
     engines: {node: '>=18.20'}
 
+  bundle-name@4.1.0:
+    resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
+    engines: {node: '>=18'}
+
   bytes@3.1.2:
     resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
     engines: {node: '>= 0.8'}
@@ -1908,10 +1915,22 @@ packages:
   deep-pick-omit@1.2.1:
     resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==}
 
+  default-browser-id@5.0.0:
+    resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==}
+    engines: {node: '>=18'}
+
+  default-browser@5.2.1:
+    resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==}
+    engines: {node: '>=18'}
+
   define-data-property@1.1.4:
     resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
     engines: {node: '>= 0.4'}
 
+  define-lazy-prop@3.0.0:
+    resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
+    engines: {node: '>=12'}
+
   define-properties@1.2.1:
     resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
     engines: {node: '>= 0.4'}
@@ -2010,6 +2029,9 @@ packages:
   error-ex@1.3.2:
     resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
 
+  error-stack-parser-es@1.0.5:
+    resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
+
   errx@0.1.0:
     resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==}
 
@@ -2605,6 +2627,11 @@ packages:
     resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
     engines: {node: '>= 0.4'}
 
+  is-docker@3.0.0:
+    resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+    hasBin: true
+
   is-extglob@2.1.1:
     resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
     engines: {node: '>=0.10.0'}
@@ -2625,6 +2652,11 @@ packages:
     resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
     engines: {node: '>=0.10.0'}
 
+  is-inside-container@1.0.0:
+    resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
+    engines: {node: '>=14.16'}
+    hasBin: true
+
   is-map@2.0.3:
     resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
     engines: {node: '>= 0.4'}
@@ -2684,6 +2716,10 @@ packages:
     resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
     engines: {node: '>=12.13'}
 
+  is-wsl@3.1.0:
+    resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
+    engines: {node: '>=16'}
+
   isarray@2.0.5:
     resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
 
@@ -3141,6 +3177,10 @@ packages:
   once@1.4.0:
     resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
 
+  open@10.1.1:
+    resolution: {integrity: sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==}
+    engines: {node: '>=18'}
+
   optionator@0.9.4:
     resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
     engines: {node: '>= 0.8.0'}
@@ -3415,6 +3455,10 @@ packages:
     engines: {node: '>=18.0.0', npm: '>=8.0.0'}
     hasBin: true
 
+  run-applescript@7.0.0:
+    resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==}
+    engines: {node: '>=18'}
+
   run-parallel@1.2.0:
     resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
 
@@ -3850,9 +3894,29 @@ packages:
   validate-npm-package-license@3.0.4:
     resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
 
+  vite-dev-rpc@1.0.7:
+    resolution: {integrity: sha512-FxSTEofDbUi2XXujCA+hdzCDkXFG1PXktMjSk1efq9Qb5lOYaaM9zNSvKvPPF7645Bak79kSp1PTooMW2wktcA==}
+    peerDependencies:
+      vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.1
+
+  vite-hot-client@2.0.4:
+    resolution: {integrity: sha512-W9LOGAyGMrbGArYJN4LBCdOC5+Zwh7dHvOHC0KmGKkJhsOzaKbpo/jEjpPKVHIW0/jBWj8RZG0NUxfgA8BxgAg==}
+    peerDependencies:
+      vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0
+
   vite-plugin-build-id@0.5.0:
     resolution: {integrity: sha512-dvf3PSSjzSZSCoWodOjDSDei7wRgQKTYHBKfAZAEoIDTuQtxIVFNzKPHuWETFDOE3pnOa76BUjbTOKxRjMKD9Q==}
 
+  vite-plugin-inspect@11.0.1:
+    resolution: {integrity: sha512-aABw7eGTr9Cmbn9RAs76e0BztVUFDl6a2R+/IJXpoUZxjx5YHB0P+Em3ZTWzpIPZzuRj28tAMblvcUyhgJc4aQ==}
+    engines: {node: '>=14'}
+    peerDependencies:
+      '@nuxt/kit': '*'
+      vite: ^6.0.0
+    peerDependenciesMeta:
+      '@nuxt/kit':
+        optional: true
+
   vite-svg-loader@5.1.0:
     resolution: {integrity: sha512-M/wqwtOEjgb956/+m5ZrYT/Iq6Hax0OakWbokj8+9PXOnB7b/4AxESHieEtnNEy7ZpjsjYW1/5nK8fATQMmRxw==}
     peerDependencies:
@@ -5541,6 +5605,10 @@ snapshots:
 
   builtin-modules@5.0.0: {}
 
+  bundle-name@4.1.0:
+    dependencies:
+      run-applescript: 7.0.0
+
   bytes@3.1.2: {}
 
   c12@3.0.3:
@@ -5788,12 +5856,21 @@ snapshots:
 
   deep-pick-omit@1.2.1: {}
 
+  default-browser-id@5.0.0: {}
+
+  default-browser@5.2.1:
+    dependencies:
+      bundle-name: 4.1.0
+      default-browser-id: 5.0.0
+
   define-data-property@1.1.4:
     dependencies:
       es-define-property: 1.0.1
       es-errors: 1.3.0
       gopd: 1.2.0
 
+  define-lazy-prop@3.0.0: {}
+
   define-properties@1.2.1:
     dependencies:
       define-data-property: 1.1.4
@@ -5889,6 +5966,8 @@ snapshots:
     dependencies:
       is-arrayish: 0.2.1
 
+  error-stack-parser-es@1.0.5: {}
+
   errx@0.1.0: {}
 
   es-abstract@1.23.9:
@@ -6694,6 +6773,8 @@ snapshots:
       call-bound: 1.0.4
       has-tostringtag: 1.0.2
 
+  is-docker@3.0.0: {}
+
   is-extglob@2.1.1: {}
 
   is-finalizationregistry@1.1.1:
@@ -6713,6 +6794,10 @@ snapshots:
     dependencies:
       is-extglob: 2.1.1
 
+  is-inside-container@1.0.0:
+    dependencies:
+      is-docker: 3.0.0
+
   is-map@2.0.3: {}
 
   is-number-object@1.1.1:
@@ -6767,6 +6852,10 @@ snapshots:
 
   is-what@4.1.16: {}
 
+  is-wsl@3.1.0:
+    dependencies:
+      is-inside-container: 1.0.0
+
   isarray@2.0.5: {}
 
   isexe@2.0.0: {}
@@ -7387,6 +7476,13 @@ snapshots:
     dependencies:
       wrappy: 1.0.2
 
+  open@10.1.1:
+    dependencies:
+      default-browser: 5.2.1
+      define-lazy-prop: 3.0.0
+      is-inside-container: 1.0.0
+      is-wsl: 3.1.0
+
   optionator@0.9.4:
     dependencies:
       deep-is: 0.1.4
@@ -7681,6 +7777,8 @@ snapshots:
       '@rollup/rollup-win32-x64-msvc': 4.40.0
       fsevents: 2.3.3
 
+  run-applescript@7.0.0: {}
+
   run-parallel@1.2.0:
     dependencies:
       queue-microtask: 1.2.3
@@ -8227,6 +8325,16 @@ snapshots:
       spdx-correct: 3.2.0
       spdx-expression-parse: 3.0.1
 
+  vite-dev-rpc@1.0.7(vite@6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1)):
+    dependencies:
+      birpc: 2.3.0
+      vite: 6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1)
+      vite-hot-client: 2.0.4(vite@6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1))
+
+  vite-hot-client@2.0.4(vite@6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1)):
+    dependencies:
+      vite: 6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1)
+
   vite-plugin-build-id@0.5.0:
     dependencies:
       isomorphic-git: 1.30.1
@@ -8234,6 +8342,23 @@ snapshots:
       picocolors: 1.1.1
       typescript: 5.8.2
 
+  vite-plugin-inspect@11.0.1(@nuxt/kit@3.16.2)(vite@6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1)):
+    dependencies:
+      ansis: 3.17.0
+      debug: 4.4.0
+      error-stack-parser-es: 1.0.5
+      ohash: 2.0.11
+      open: 10.1.1
+      perfect-debounce: 1.0.0
+      sirv: 3.0.1
+      unplugin-utils: 0.2.4
+      vite: 6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1)
+      vite-dev-rpc: 1.0.7(vite@6.3.2(@types/node@22.14.1)(jiti@2.4.2)(less@4.3.0)(tsx@4.19.2)(yaml@2.7.1))
+    optionalDependencies:
+      '@nuxt/kit': 3.16.2
+    transitivePeerDependencies:
+      - supports-color
+
   vite-svg-loader@5.1.0(vue@3.5.13(typescript@5.8.2)):
     dependencies:
       svgo: 3.3.2

+ 6 - 1
app/src/api/auto_cert.ts

@@ -1,5 +1,10 @@
 import http from '@/lib/http'
 
+export const AutoCertChallengeMethod = {
+  http01: 'http01',
+  dns01: 'dns01',
+} as const
+
 export interface DNSProvider {
   name?: string
   code?: string
@@ -19,7 +24,7 @@ export interface AutoCertOptions {
   domains: string[]
   code?: string
   dns_credential_id?: number | null
-  challenge_method?: string
+  challenge_method: keyof typeof AutoCertChallengeMethod
   configuration?: DNSProvider['configuration']
   key_type: string
   acme_user_id?: number

+ 2 - 1
app/src/api/cert.ts

@@ -2,6 +2,7 @@ import type { AcmeUser } from '@/api/acme_user'
 import type { ModelBase } from '@/api/curd'
 import type { DnsCredential } from '@/api/dns_credential'
 import type { PrivateKeyType } from '@/constants'
+import type { AutoCertChallengeMethod } from './auto_cert'
 import Curd from '@/api/curd'
 
 export interface Cert extends ModelBase {
@@ -13,7 +14,7 @@ export interface Cert extends ModelBase {
   ssl_certificate_key_path: string
   ssl_certificate_key: string
   auto_cert: number
-  challenge_method: string
+  challenge_method: keyof typeof AutoCertChallengeMethod
   dns_credential_id: number
   dns_credential?: DnsCredential
   acme_user_id: number

+ 4 - 2
app/src/api/template.ts

@@ -27,7 +27,9 @@ class TemplateApi extends Curd<Template> {
   }
 
   get_block_list() {
-    return http.get('templates/blocks')
+    return http.get<{
+      data: Template[]
+    }>('templates/blocks')
   }
 
   get_config(name: string) {
@@ -35,7 +37,7 @@ class TemplateApi extends Curd<Template> {
   }
 
   get_block(name: string) {
-    return http.get(`templates/block/${name}`)
+    return http.get<Template>(`templates/block/${name}`)
   }
 
   build_block(name: string, data: Variable) {

+ 7 - 8
app/src/views/site/cert/components/AutoCertStepOne.vue → app/src/components/AutoCertForm/AutoCertForm.vue

@@ -1,8 +1,9 @@
 <script setup lang="ts">
 import type { AutoCertOptions } from '@/api/auto_cert'
-import { PrivateKeyTypeList } from '@/constants'
+import { AutoCertChallengeMethod } from '@/api/auto_cert'
+import { PrivateKeyTypeEnum, PrivateKeyTypeList } from '@/constants'
 import ACMEUserSelector from '@/views/certificate/components/ACMEUserSelector.vue'
-import DNSChallenge from '@/views/site/cert/components/DNSChallenge.vue'
+import DNSChallenge from './DNSChallenge.vue'
 
 const props = defineProps<{
   hideNote?: boolean
@@ -10,23 +11,21 @@ const props = defineProps<{
 }>()
 
 const data = defineModel<AutoCertOptions>('options', {
-  default: () => {
-    return {}
-  },
+  default: reactive({}),
   required: true,
 })
 
 onMounted(() => {
   if (!data.value.key_type)
-    data.value.key_type = '2048'
+    data.value.key_type = PrivateKeyTypeEnum.P256
 
   if (props.forceDnsChallenge)
-    data.value.challenge_method = 'dns01'
+    data.value.challenge_method = AutoCertChallengeMethod.dns01
 })
 
 watch(() => props.forceDnsChallenge, v => {
   if (v)
-    data.value.challenge_method = 'dns01'
+    data.value.challenge_method = AutoCertChallengeMethod.dns01
 })
 </script>
 

+ 0 - 0
app/src/views/site/cert/components/DNSChallenge.vue → app/src/components/AutoCertForm/DNSChallenge.vue


+ 3 - 0
app/src/components/AutoCertForm/index.ts

@@ -0,0 +1,3 @@
+import AutoCertForm from './AutoCertForm.vue'
+
+export default AutoCertForm

+ 0 - 0
app/src/views/site/cert/CertInfo.vue → app/src/components/CertInfo/CertInfo.vue


+ 3 - 0
app/src/components/CertInfo/index.ts

@@ -0,0 +1,3 @@
+import CertInfo from './CertInfo.vue'
+
+export default CertInfo

+ 1 - 1
app/src/components/ChatGPT/ChatGPT.vue

@@ -305,7 +305,7 @@ const show = computed(() => !messages.value || messages.value.length === 0)
 <template>
   <div
     v-if="show"
-    class="chat-start"
+    class="chat-start mt-4"
   >
     <AButton
       :loading="loading"

+ 3 - 0
app/src/components/ChatGPT/index.ts

@@ -0,0 +1,3 @@
+import ChatGPT from './ChatGPT.vue'
+
+export default ChatGPT

+ 3 - 2
app/src/components/CodeEditor/CodeEditor.vue

@@ -11,6 +11,7 @@ const props = defineProps<{
   defaultHeight?: string
   readonly?: boolean
   placeholder?: string
+  disableCodeCompletion?: boolean
 }>()
 
 const content = defineModel<string>('content', { default: '' })
@@ -26,8 +27,8 @@ onMounted(() => {
 
 const codeCompletion = useCodeCompletion()
 
-function init(editor: Editor) {
-  if (props.readonly) {
+async function init(editor: Editor) {
+  if (props.readonly || props.disableCodeCompletion) {
     return
   }
   codeCompletion.init(editor)

+ 180 - 0
app/src/components/NgxConfigEditor/LocationEditor.vue

@@ -0,0 +1,180 @@
+<script setup lang="ts">
+import type { NgxLocation } from '@/api/ngx'
+import CodeEditor from '@/components/CodeEditor'
+import { CopyOutlined, DeleteOutlined, HolderOutlined } from '@ant-design/icons-vue'
+import { cloneDeep } from 'lodash'
+import Draggable from 'vuedraggable'
+
+defineProps<{
+  readonly?: boolean
+}>()
+
+const locations = defineModel<NgxLocation[]>('locations', {
+  default: reactive([]),
+})
+
+const location = reactive({
+  comments: '',
+  path: '',
+  content: '',
+})
+
+const adding = ref(false)
+
+function add() {
+  adding.value = true
+  location.comments = ''
+  location.path = ''
+  location.content = ''
+}
+
+function save() {
+  adding.value = false
+  locations.value.push({
+    ...location,
+  })
+}
+
+function remove(index: number) {
+  locations.value.splice(index, 1)
+}
+
+function duplicate(index: number) {
+  const loc = locations.value[index]
+
+  locations.value.splice(index, 0, cloneDeep(loc))
+}
+</script>
+
+<template>
+  <div>
+    <h3>{{ $gettext('Locations') }}</h3>
+    <AEmpty v-if="locations && locations?.length === 0" />
+    <Draggable
+      v-else
+      :list="locations"
+      item-key="name"
+      class="list-group"
+      ghost-class="ghost"
+      handle=".ant-collapse-header"
+    >
+      <template #item="{ element: v, index }">
+        <ACollapse
+          :bordered="false"
+          collapsible="header"
+        >
+          <ACollapsePanel>
+            <template #header>
+              <HolderOutlined />
+              {{ $gettext('Location') }}
+              {{ v.path }}
+            </template>
+            <template
+              v-if="!readonly"
+              #extra
+            >
+              <ASpace>
+                <AButton
+                  type="text"
+                  size="small"
+                  @click="() => duplicate(index)"
+                >
+                  <template #icon>
+                    <CopyOutlined style="font-size: 14px;" />
+                  </template>
+                </AButton>
+                <APopconfirm
+                  :title="$gettext('Are you sure you want to remove this location?')"
+                  :ok-text="$gettext('Yes')"
+                  :cancel-text="$gettext('No')"
+                  @confirm="remove(index)"
+                >
+                  <AButton
+                    type="text"
+                    size="small"
+                  >
+                    <template #icon>
+                      <DeleteOutlined style="font-size: 14px;" />
+                    </template>
+                  </AButton>
+                </APopconfirm>
+              </ASpace>
+            </template>
+            <AForm layout="vertical">
+              <AFormItem :label="$gettext('Comments')">
+                <ATextarea
+                  v-model:value="v.comments"
+                  :bordered="false"
+                />
+              </AFormItem>
+              <AFormItem :label="$gettext('Path')">
+                <AInput
+                  v-model:value="v.path"
+                  addon-before="location"
+                />
+              </AFormItem>
+              <AFormItem :label="$gettext('Content')">
+                <CodeEditor
+                  v-model:content="v.content"
+                  default-height="200px"
+                  style="width: 100%;"
+                />
+              </AFormItem>
+            </AForm>
+          </ACollapsePanel>
+        </ACollapse>
+      </template>
+    </Draggable>
+
+    <AModal
+      v-model:open="adding"
+      :title="$gettext('Add Location')"
+      @ok="save"
+    >
+      <AForm layout="vertical">
+        <AFormItem :label="$gettext('Comments')">
+          <ATextarea v-model:value="location.comments" />
+        </AFormItem>
+        <AFormItem :label="$gettext('Path')">
+          <AInput
+            v-model:value="location.path"
+            addon-before="location"
+          />
+        </AFormItem>
+        <AFormItem :label="$gettext('Content')">
+          <CodeEditor
+            v-model:content="location.content"
+            default-height="200px"
+          />
+        </AFormItem>
+      </AForm>
+    </AModal>
+
+    <div v-if="!readonly">
+      <AButton
+        block
+        @click="add"
+      >
+        {{ $gettext('Add Location') }}
+      </AButton>
+    </div>
+  </div>
+</template>
+
+<style lang="less" scoped>
+.ant-collapse {
+  margin: 10px 0;
+}
+
+.ant-collapse-item {
+  border: 0 !important;
+}
+
+.ant-collapse-header {
+  align-items: center;
+}
+
+:deep(.ant-collapse-header-text) {
+  max-width: calc(90% - 56px);
+}
+</style>

+ 7 - 8
app/src/views/site/ngx_conf/LogEntry.vue → app/src/components/NgxConfigEditor/LogEntry.vue

@@ -1,11 +1,10 @@
 <script setup lang="ts">
 import type { NgxConfig } from '@/api/ngx'
 import { FileExclamationOutlined, FileTextOutlined } from '@ant-design/icons-vue'
-import { useRouter } from 'vue-router'
 
 const props = defineProps<{
   ngxConfig: NgxConfig
-  currentServerIdx: number
+  curServerIdx: number
   name?: string
 }>()
 
@@ -16,7 +15,7 @@ const errorLogPath = ref<string>()
 
 const hasAccessLog = computed(() => {
   let flag = false
-  props.ngxConfig?.servers[props.currentServerIdx].directives?.forEach((v, k) => {
+  props.ngxConfig?.servers[props.curServerIdx].directives?.forEach((v, k) => {
     if (v.directive === 'access_log') {
       flag = true
       accessIdx.value = k
@@ -36,7 +35,7 @@ const hasAccessLog = computed(() => {
 
 const hasErrorLog = computed(() => {
   let flag = false
-  props.ngxConfig?.servers[props.currentServerIdx].directives?.forEach((v, k) => {
+  props.ngxConfig?.servers[props.curServerIdx].directives?.forEach((v, k) => {
     if (v.directive === 'error_log') {
       flag = true
       errorIdx.value = k
@@ -56,7 +55,7 @@ const hasErrorLog = computed(() => {
 
 const router = useRouter()
 
-function on_click_access_log() {
+function onClickAccessLog() {
   router.push({
     path: '/nginx_log/site',
     query: {
@@ -66,7 +65,7 @@ function on_click_access_log() {
   })
 }
 
-function on_click_error_log() {
+function onClickErrorLog() {
   router.push({
     path: '/nginx_log/site',
     query: {
@@ -85,7 +84,7 @@ function on_click_error_log() {
     <AButton
       v-if="hasAccessLog"
       type="link"
-      @click="on_click_access_log"
+      @click="onClickAccessLog"
     >
       <FileTextOutlined />
       {{ $gettext('Access Logs') }}
@@ -93,7 +92,7 @@ function on_click_error_log() {
     <AButton
       v-if="hasErrorLog"
       type="link"
-      @click="on_click_error_log"
+      @click="onClickErrorLog"
     >
       <FileExclamationOutlined />
       {{ $gettext('Error Logs') }}

+ 0 - 0
app/src/views/site/ngx_conf/NginxStatusAlert.vue → app/src/components/NgxConfigEditor/NginxStatusAlert.vue


+ 73 - 0
app/src/components/NgxConfigEditor/NgxConfigEditor.vue

@@ -0,0 +1,73 @@
+<script setup lang="ts">
+import CodeEditor from '@/components/CodeEditor'
+import { NginxStatusAlert, NgxServer, NgxUpstream, useNgxConfigStore } from '.'
+
+withDefaults(defineProps<{
+  context?: 'http' | 'stream'
+}>(), {
+  context: 'http',
+})
+
+const ngxConfigStore = useNgxConfigStore()
+const { ngxConfig, curServerIdx } = storeToRefs(ngxConfigStore)
+
+const route = useRoute()
+
+onMounted(() => {
+  curServerIdx.value = Number.parseInt((route.query?.server_idx ?? 0) as string)
+})
+
+const activeKey = ref(['3'])
+</script>
+
+<template>
+  <div>
+    <NginxStatusAlert />
+
+    <ACollapse
+      v-model:active-key="activeKey"
+      ghost
+    >
+      <ACollapsePanel
+        key="1"
+        :header="$gettext('Custom')"
+      >
+        <div class="mb-4">
+          <CodeEditor
+            v-model:content="ngxConfig.custom"
+            default-height="150px"
+          />
+        </div>
+      </ACollapsePanel>
+      <ACollapsePanel
+        key="2"
+        header="Upstream"
+      >
+        <NgxUpstream />
+      </ACollapsePanel>
+      <ACollapsePanel
+        key="3"
+        header="Server"
+      >
+        <NgxServer :context>
+          <template
+            v-for="(_, key) in $slots"
+            :key="key"
+            #[key]="slotProps"
+          >
+            <slot
+              :name="key"
+              v-bind="slotProps"
+            />
+          </template>
+        </NgxServer>
+      </ACollapsePanel>
+    </ACollapse>
+  </div>
+</template>
+
+<style lang="less" scoped>
+:deep(.ant-tabs-tab-btn) {
+  margin-left: 16px;
+}
+</style>

+ 121 - 0
app/src/components/NgxConfigEditor/NgxServer.vue

@@ -0,0 +1,121 @@
+<script setup lang="ts">
+import { MoreOutlined, PlusOutlined } from '@ant-design/icons-vue'
+import { Modal } from 'ant-design-vue'
+import { DirectiveEditor, Http, LocationEditor, LogEntry, useNgxConfigStore } from '.'
+
+withDefaults(defineProps<{
+  context?: 'http' | 'stream'
+}>(), {
+  context: 'http',
+})
+
+const [modal, ContextHolder] = Modal.useModal()
+const ngxConfigStore = useNgxConfigStore()
+const { ngxConfig, curServerIdx } = storeToRefs(ngxConfigStore)
+
+const route = useRoute()
+const name = computed(() => route.params.name) as ComputedRef<string>
+
+const router = useRouter()
+
+const serversLength = computed(() => {
+  return ngxConfig.value.servers.length
+})
+
+watch(serversLength, () => {
+  if (curServerIdx.value >= serversLength.value)
+    curServerIdx.value = serversLength.value - 1
+  else if (curServerIdx.value < 0)
+    curServerIdx.value = 0
+})
+
+watch(curServerIdx, () => {
+  router.push({
+    query: {
+      server_idx: curServerIdx.value.toString(),
+    },
+  })
+})
+
+function addServer() {
+  ngxConfig.value.servers.push({
+    comments: '',
+    locations: [],
+    directives: [],
+  })
+}
+
+function removeServer(index: number) {
+  modal.confirm({
+    title: $gettext('Do you want to remove this server?'),
+    mask: false,
+    centered: true,
+    okText: $gettext('OK'),
+    cancelText: $gettext('Cancel'),
+    onOk() {
+      ngxConfig.value.servers?.splice(index, 1)
+      curServerIdx.value = (index > 1 ? index - 1 : 0)
+    },
+  })
+}
+</script>
+
+<template>
+  <div>
+    <ContextHolder />
+    <ATabs v-model:active-key="curServerIdx">
+      <ATabPane
+        v-for="(v, k) in ngxConfig.servers"
+        :key="k"
+      >
+        <template #tab>
+          Server {{ k + 1 }}
+          <ADropdown>
+            <MoreOutlined />
+            <template #overlay>
+              <AMenu>
+                <AMenuItem>
+                  <a @click="removeServer(k)">{{ $gettext('Delete') }}</a>
+                </AMenuItem>
+              </AMenu>
+            </template>
+          </ADropdown>
+        </template>
+
+        <LogEntry class="mb-4" :ngx-config :cur-server-idx :name />
+
+        <div class="tab-content">
+          <slot name="tab-content" :tab-idx="k" />
+
+          <template v-if="v.comments">
+            <h3>{{ $gettext('Comments') }}</h3>
+            <ATextarea
+              v-model:value="v.comments"
+              :bordered="false"
+            />
+          </template>
+          <DirectiveEditor v-model:directives="v.directives" class="mb-4" />
+          <LocationEditor
+            v-if="context === Http"
+            v-model:locations="v.locations"
+          />
+        </div>
+      </ATabPane>
+
+      <template #rightExtra>
+        <AButton
+          type="link"
+          size="small"
+          @click="addServer"
+        >
+          <PlusOutlined />
+          {{ $gettext('Add') }}
+        </AButton>
+      </template>
+    </ATabs>
+  </div>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 36 - 35
app/src/views/site/ngx_conf/NgxUpstream.vue → app/src/components/NgxConfigEditor/NgxUpstream.vue

@@ -1,31 +1,34 @@
 <script setup lang="ts">
-import type { NgxConfig, NgxDirective } from '@/api/ngx'
+import type { NgxDirective } from '@/api/ngx'
 import type { UpstreamStatus } from '@/api/upstream'
 import type ReconnectingWebSocket from 'reconnecting-websocket'
 import upstream from '@/api/upstream'
-import DirectiveEditor from '@/views/site/ngx_conf/directive/DirectiveEditor.vue'
 import { MoreOutlined, PlusOutlined } from '@ant-design/icons-vue'
 import { Modal } from 'ant-design-vue'
-import _ from 'lodash'
+import { throttle } from 'lodash'
+import { DirectiveEditor, Server, useNgxConfigStore } from '.'
 
 const [modal, ContextHolder] = Modal.useModal()
 
-const ngx_config = inject('ngx_config') as NgxConfig
-const current_upstream_index = ref(0)
-async function add_upstream() {
-  if (!ngx_config.upstreams)
-    ngx_config.upstreams = []
+const ngxConfigStore = useNgxConfigStore()
+const { ngxConfig } = storeToRefs(ngxConfigStore)
 
-  ngx_config.upstreams?.push({
+const currentUpstreamIdx = ref(0)
+
+async function addUpstream() {
+  if (!ngxConfig.value.upstreams)
+    ngxConfig.value.upstreams = []
+
+  ngxConfig.value.upstreams?.push({
     name: '',
     comments: '',
     directives: [],
   })
 
-  rename(ngx_config.upstreams.length - 1)
+  rename(ngxConfig.value.upstreams.length - 1)
 }
 
-function remove_upstream(index: number) {
+function removeUpstream(index: number) {
   modal.confirm({
     title: $gettext('Do you want to remove this upstream?'),
     mask: false,
@@ -33,18 +36,16 @@ function remove_upstream(index: number) {
     okText: $gettext('OK'),
     cancelText: $gettext('Cancel'),
     onOk() {
-      ngx_config?.upstreams?.splice(index, 1)
-      current_upstream_index.value = (index > 1 ? index - 1 : 0)
+      ngxConfig.value.upstreams?.splice(index, 1)
+      currentUpstreamIdx.value = (index > 1 ? index - 1 : 0)
     },
   })
 }
 
-const ngx_directives = computed(() => {
-  return ngx_config?.upstreams?.[current_upstream_index.value]?.directives
+const curUptreamDirectives = computed(() => {
+  return ngxConfig.value.upstreams?.[currentUpstreamIdx.value]?.directives
 })
 
-provide('ngx_directives', ngx_directives)
-
 const open = ref(false)
 const renameIdx = ref(-1)
 const buffer = ref('')
@@ -52,23 +53,23 @@ const buffer = ref('')
 function rename(idx: number) {
   open.value = true
   renameIdx.value = idx
-  buffer.value = ngx_config?.upstreams?.[renameIdx.value].name ?? ''
+  buffer.value = ngxConfig.value.upstreams?.[renameIdx.value].name ?? ''
 }
 
-function ok() {
-  if (ngx_config?.upstreams?.[renameIdx.value])
-    ngx_config.upstreams[renameIdx.value].name = buffer.value
+function renameOK() {
+  if (ngxConfig.value.upstreams?.[renameIdx.value])
+    ngxConfig.value.upstreams[renameIdx.value].name = buffer.value
   open.value = false
 }
 
 const availabilityResult = ref({}) as Ref<Record<string, UpstreamStatus>>
 const websocket = shallowRef<ReconnectingWebSocket | WebSocket>()
 
-function availability_test() {
+function availabilityTest() {
   const sockets: string[] = []
-  for (const u of ngx_config.upstreams ?? []) {
+  for (const u of ngxConfig.value.upstreams ?? []) {
     for (const d of u.directives ?? []) {
-      if (d.directive === 'server')
+      if (d.directive === Server)
         sockets.push(d.params.split(' ')[0])
     }
   }
@@ -85,7 +86,7 @@ function availability_test() {
 }
 
 onMounted(() => {
-  availability_test()
+  availabilityTest()
 })
 
 onBeforeUnmount(() => {
@@ -94,12 +95,12 @@ onBeforeUnmount(() => {
 
 async function _restartTest() {
   websocket.value?.close()
-  availability_test()
+  availabilityTest()
 }
 
-const restartTest = _.throttle(_restartTest, 5000)
+const restartTest = throttle(_restartTest, 5000)
 
-watch(ngx_directives, () => {
+watch(curUptreamDirectives, () => {
   restartTest()
 }, { deep: true })
 </script>
@@ -108,11 +109,11 @@ watch(ngx_directives, () => {
   <div>
     <ContextHolder />
     <ATabs
-      v-if="ngx_config.upstreams && ngx_config.upstreams.length > 0"
-      v-model:active-key="current_upstream_index"
+      v-if="ngxConfig.upstreams && ngxConfig.upstreams.length > 0"
+      v-model:active-key="currentUpstreamIdx"
     >
       <ATabPane
-        v-for="(v, k) in ngx_config.upstreams"
+        v-for="(v, k) in ngxConfig.upstreams"
         :key="k"
       >
         <template #tab>
@@ -125,7 +126,7 @@ watch(ngx_directives, () => {
                   <a @click="rename(k)">{{ $gettext('Rename') }}</a>
                 </AMenuItem>
                 <AMenuItem>
-                  <a @click="remove_upstream(k)">{{ $gettext('Delete') }}</a>
+                  <a @click="removeUpstream(k)">{{ $gettext('Delete') }}</a>
                 </AMenuItem>
               </AMenu>
             </template>
@@ -148,7 +149,7 @@ watch(ngx_directives, () => {
         <AButton
           type="link"
           size="small"
-          @click="add_upstream"
+          @click="addUpstream"
         >
           <PlusOutlined />
           {{ $gettext('Add') }}
@@ -160,7 +161,7 @@ watch(ngx_directives, () => {
       <div class="flex justify-center">
         <AButton
           type="primary"
-          @click="add_upstream"
+          @click="addUpstream"
         >
           {{ $gettext('Create') }}
         </AButton>
@@ -171,7 +172,7 @@ watch(ngx_directives, () => {
       v-model:open="open"
       :title="$gettext('Upstream Name')"
       centered
-      @ok="ok"
+      @ok="renameOK"
     >
       <AForm layout="vertical">
         <AFormItem :label="$gettext('Name')">

+ 3 - 0
app/src/components/NgxConfigEditor/README.md

@@ -0,0 +1,3 @@
+# NgxConfigEditor
+
+Designed by [@0xJacky](https://github.com/0xJacky)

+ 16 - 33
app/src/views/site/ngx_conf/directive/DirectiveAdd.vue → app/src/components/NgxConfigEditor/directive/DirectiveAdd.vue

@@ -1,29 +1,17 @@
 <script setup lang="ts">
-import type { DirectiveMap, NgxDirective } from '@/api/ngx'
 import CodeEditor from '@/components/CodeEditor'
 import { DeleteOutlined } from '@ant-design/icons-vue'
-
-const props = defineProps<{
-  idx?: number
-  nginxDirectivesMap?: DirectiveMap
-}>()
+import { MultiLineDirective, SingleLineDirective } from '.'
+import { useDirectiveStore } from './store'
 
 const emit = defineEmits(['save'])
 
-const ngx_directives = inject('ngx_directives') as ComputedRef<NgxDirective[]>
+const directiveStore = useDirectiveStore()
+const { nginxDirectivesDocsMap, nginxDirectivesOptions } = storeToRefs(directiveStore)
+
 const directive = reactive({ directive: '', params: '' })
 const adding = ref(false)
-const mode = ref('default')
-
-const nginxDirectives = computed(() => {
-  const res: { label: string, value: string }[] = []
-  if (props.nginxDirectivesMap) {
-    Object.keys(props.nginxDirectivesMap).forEach(k => {
-      res.push({ label: k, value: k })
-    })
-  }
-  return res
-})
+const mode = ref(SingleLineDirective)
 
 function add() {
   adding.value = true
@@ -33,15 +21,10 @@ function add() {
 
 function save() {
   adding.value = false
-  if (mode.value === 'multi-line')
+  if (mode.value === MultiLineDirective)
     directive.directive = ''
 
-  if (props.idx)
-    ngx_directives.value.splice(props.idx + 1, 0, { directive: directive.directive, params: directive.params })
-  else
-    ngx_directives.value.push({ directive: directive.directive, params: directive.params })
-
-  emit('save', props.idx)
+  emit('save', directive)
 }
 
 function filterOption(inputValue: string, option: { label: string }) {
@@ -58,13 +41,13 @@ function filterOption(inputValue: string, option: { label: string }) {
       <AFormItem>
         <ASelect
           v-model:value="mode"
-          default-value="default"
+          :default-value="SingleLineDirective"
           style="width: 180px"
         >
-          <ASelectOption value="default">
+          <ASelectOption :value="SingleLineDirective">
             {{ $gettext('Single Directive') }}
           </ASelectOption>
-          <ASelectOption value="multi-line">
+          <ASelectOption :value="MultiLineDirective">
             {{ $gettext('Multi-line Directive') }}
           </ASelectOption>
         </ASelect>
@@ -72,7 +55,7 @@ function filterOption(inputValue: string, option: { label: string }) {
       <AFormItem>
         <div class="input-wrapper">
           <CodeEditor
-            v-if="mode === 'multi-line'"
+            v-if="mode === MultiLineDirective"
             v-model:content="directive.params"
             default-height="100px"
             style="width: 100%;"
@@ -83,7 +66,7 @@ function filterOption(inputValue: string, option: { label: string }) {
           >
             <AAutoComplete
               v-model:value="directive.directive"
-              :options="nginxDirectives"
+              :options="nginxDirectivesOptions"
               style="width: 30%"
               :filter-option="filterOption"
               :placeholder="$gettext('Directive')"
@@ -101,9 +84,9 @@ function filterOption(inputValue: string, option: { label: string }) {
             </template>
           </AButton>
         </div>
-        <div v-if="nginxDirectivesMap?.[directive.directive]" class="mt-2">
-          <div>{{ $ngettext('Document', 'Documents', nginxDirectivesMap[directive.directive].links.length) }}</div>
-          <div v-for="(link, index) in nginxDirectivesMap?.[directive.directive].links" :key="index" class="overflow-auto">
+        <div v-if="nginxDirectivesDocsMap?.[directive.directive]" class="mt-2">
+          <div>{{ $ngettext('Document', 'Documents', nginxDirectivesDocsMap[directive.directive].links.length) }}</div>
+          <div v-for="(link, index) in nginxDirectivesDocsMap?.[directive.directive].links" :key="index" class="overflow-auto">
             <a :href="link">
               {{ link }}
             </a>

+ 29 - 0
app/src/components/NgxConfigEditor/directive/DirectiveDocuments.vue

@@ -0,0 +1,29 @@
+<script setup lang="ts">
+import { useDirectiveStore } from './store'
+
+const props = defineProps<{
+  directive: string
+}>()
+
+const { nginxDirectivesDocsMap } = storeToRefs(useDirectiveStore())
+</script>
+
+<template>
+  <AFormItem
+    v-if="nginxDirectivesDocsMap?.[props.directive]"
+    class="mb-0"
+    :label="
+      $ngettext('Document', 'Documents',
+                nginxDirectivesDocsMap[props.directive].links.length)"
+  >
+    <div v-for="(link, idx) in nginxDirectivesDocsMap[props.directive]?.links" :key="idx" class="mb-2">
+      <a :href="link">
+        {{ link }}
+      </a>
+    </div>
+  </AFormItem>
+</template>
+
+<style scoped lang="less">
+
+</style>

+ 80 - 0
app/src/components/NgxConfigEditor/directive/DirectiveEditor.vue

@@ -0,0 +1,80 @@
+<script setup lang="ts">
+import type { NgxDirective } from '@/api/ngx'
+import Draggable from 'vuedraggable'
+import DirectiveAdd from './DirectiveAdd.vue'
+import DirectiveEditorItem from './DirectiveEditorItem.vue'
+import { useDirectiveStore } from './store'
+
+defineProps<{
+  readonly?: boolean
+  context?: string
+}>()
+
+const directiveStore = useDirectiveStore()
+const { curIdx } = storeToRefs(directiveStore)
+
+const ngxDirectives = defineModel<NgxDirective[]>('directives', {
+  default: reactive([]),
+})
+
+onMounted(() => {
+  directiveStore.getNginxDirectivesDocsMap()
+})
+
+function addDirective(directive: NgxDirective) {
+  if (curIdx.value >= 0)
+    ngxDirectives.value.splice(curIdx.value + 1, 0, directive)
+  else
+    ngxDirectives.value.push(directive)
+}
+
+function removeDirective(index: number) {
+  ngxDirectives.value.splice(index, 1)
+}
+</script>
+
+<template>
+  <div>
+    <h3>{{ $gettext('Directives') }}</h3>
+
+    <Draggable
+      v-model:list="ngxDirectives"
+      item-key="name"
+      class="list-group"
+      ghost-class="ghost"
+      handle=".anticon-holder"
+    >
+      <template #item="{ index }">
+        <DirectiveEditorItem
+          v-model:directive="ngxDirectives[index]"
+          v-auto-animate
+          :index="index"
+          :readonly="readonly"
+          :context="context"
+          @click="curIdx = index"
+          @remove="removeDirective(index)"
+        >
+          <template
+            v-if="$slots.directiveSuffix"
+            #suffix="{ directive }"
+          >
+            <slot
+              name="directiveSuffix"
+              :directive="directive"
+            />
+          </template>
+        </DirectiveEditorItem>
+      </template>
+    </Draggable>
+
+    <DirectiveAdd
+      v-if="!readonly"
+      v-auto-animate
+      @save="addDirective"
+    />
+  </div>
+</template>
+
+<style lang="less" scoped>
+
+</style>

+ 25 - 24
app/src/views/site/ngx_conf/directive/DirectiveEditorItem.vue → app/src/components/NgxConfigEditor/directive/DirectiveEditorItem.vue

@@ -1,29 +1,33 @@
 <script setup lang="ts">
-import type { DirectiveMap, NgxDirective } from '@/api/ngx'
+import type { NgxDirective } from '@/api/ngx'
 import config from '@/api/config'
 import CodeEditor from '@/components/CodeEditor'
-import DirectiveDocuments from '@/views/site/ngx_conf/directive/DirectiveDocuments.vue'
 import { DeleteOutlined, HolderOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
 import { message } from 'ant-design-vue'
+import { Include } from '..'
+import DirectiveDocuments from './DirectiveDocuments.vue'
+import { useDirectiveStore } from './store'
 
 const props = defineProps<{
   index: number
   readonly?: boolean
   context?: string
-  nginxDirectivesMap?: DirectiveMap
 }>()
 
-const ngxDirectives = inject('ngx_directives') as ComputedRef<NgxDirective[]>
+const emit = defineEmits(['remove'])
 
-function remove(index: number) {
-  ngxDirectives.value.splice(index, 1)
-}
+const directiveStore = useDirectiveStore()
+const { curIdx } = storeToRefs(directiveStore)
+
+const directive = defineModel<NgxDirective>('directive', {
+  default: reactive({}),
+})
 
 const content = ref('')
 
 function init() {
-  if (ngxDirectives.value[props.index].directive === 'include') {
-    config.get(ngxDirectives.value[props.index].params).then(r => {
+  if (directive.value.directive === Include) {
+    config.get(directive.value.params).then(r => {
       content.value = r.content
     })
   }
@@ -34,7 +38,7 @@ init()
 watch(props, init)
 
 function save() {
-  config.save(ngxDirectives.value[props.index].params, { content: content.value }).then(r => {
+  config.save(directive.value.params, { content: content.value }).then(r => {
     content.value = r.content
     message.success($gettext('Saved successfully'))
   }).catch(r => {
@@ -42,25 +46,23 @@ function save() {
   })
 }
 
-const currentIdx = inject<Ref<number>>('current_idx')!
-
 const onHover = ref(false)
 const showComment = ref(false)
 </script>
 
 <template>
   <div
-    v-if="ngxDirectives[props.index]"
+    v-if="directive"
     class="dir-editor-item"
   >
     <div class="input-wrapper" @mouseenter="onHover = true" @mouseleave="onHover = false">
       <div
-        v-if="ngxDirectives[props.index].directive === ''"
+        v-if="directive.directive === ''"
         class="code-editor-wrapper"
       >
         <HolderOutlined class="pa-2" />
         <CodeEditor
-          v-model:content="ngxDirectives[props.index].params"
+          v-model:content="directive.params"
           default-height="100px"
           class="w-full"
         />
@@ -68,17 +70,17 @@ const showComment = ref(false)
 
       <AInput
         v-else
-        v-model:value="ngxDirectives[props.index].params"
-        @click="currentIdx = index"
+        v-model:value="directive.params"
+        @click="curIdx = index"
       >
         <template #addonBefore>
           <HolderOutlined />
-          {{ ngxDirectives[props.index].directive }}
+          {{ directive.directive }}
         </template>
         <template #suffix>
           <slot
             name="suffix"
-            :directive="ngxDirectives[props.index]"
+            :directive="directive"
           />
 
           <!-- Comments Entry -->
@@ -95,7 +97,7 @@ const showComment = ref(false)
         :title="$gettext('Are you sure you want to remove this directive?')"
         :ok-text="$gettext('Yes')"
         :cancel-text="$gettext('No')"
-        @confirm="remove(index)"
+        @confirm="emit('remove')"
       >
         <AButton>
           <template #icon>
@@ -111,10 +113,10 @@ const showComment = ref(false)
       <div class="extra-content">
         <AForm layout="vertical">
           <AFormItem :label="$gettext('Comments')">
-            <ATextarea v-model:value="ngxDirectives[props.index].comments" />
+            <ATextarea v-model:value="directive.comments" />
           </AFormItem>
           <AFormItem
-            v-if="ngxDirectives[props.index].directive === 'include'"
+            v-if="directive.directive === Include"
             :label="$gettext('Content')"
           >
             <CodeEditor
@@ -129,8 +131,7 @@ const showComment = ref(false)
             </div>
           </AFormItem>
           <DirectiveDocuments
-            :directive="ngxDirectives[props.index].directive"
-            :nginx-directives-map
+            :directive="directive.directive"
           />
         </AForm>
       </div>

+ 2 - 0
app/src/components/NgxConfigEditor/directive/index.ts

@@ -0,0 +1,2 @@
+export const MultiLineDirective = 'multi-line'
+export const SingleLineDirective = 'single-line'

+ 21 - 0
app/src/components/NgxConfigEditor/directive/store.ts

@@ -0,0 +1,21 @@
+import type { DirectiveMap } from '@/api/ngx'
+import ngx from '@/api/ngx'
+
+export const useDirectiveStore = defineStore('directive', () => {
+  const curIdx = ref(-1)
+  const nginxDirectivesDocsMap = ref<DirectiveMap>()
+  const nginxDirectivesOptions = ref<{ label: string, value: string }[]>([])
+
+  async function getNginxDirectivesDocsMap() {
+    nginxDirectivesDocsMap.value = await ngx.get_directives()
+    await nextTick()
+    nginxDirectivesOptions.value = Object.keys(nginxDirectivesDocsMap.value).map(k => ({ label: k, value: k }))
+  }
+
+  return {
+    curIdx,
+    nginxDirectivesDocsMap,
+    getNginxDirectivesDocsMap,
+    nginxDirectivesOptions,
+  }
+})

+ 28 - 0
app/src/components/NgxConfigEditor/index.ts

@@ -0,0 +1,28 @@
+import DirectiveEditor from './directive/DirectiveEditor.vue'
+import LocationEditor from './LocationEditor.vue'
+import LogEntry from './LogEntry.vue'
+import NginxStatusAlert from './NginxStatusAlert.vue'
+import NgxConfigEditor from './NgxConfigEditor.vue'
+import NgxServer from './NgxServer.vue'
+import NgxUpstream from './NgxUpstream.vue'
+import { useNgxConfigStore } from './store'
+
+export const If = 'if'
+export const Server = 'server'
+export const Location = 'location'
+export const Upstream = 'upstream'
+export const Http = 'http'
+export const Stream = 'stream'
+export const Include = 'include'
+
+export {
+  DirectiveEditor,
+  LocationEditor,
+  LogEntry,
+  NginxStatusAlert,
+  NgxServer,
+  NgxUpstream,
+  useNgxConfigStore,
+}
+
+export default NgxConfigEditor

+ 69 - 0
app/src/components/NgxConfigEditor/store.ts

@@ -0,0 +1,69 @@
+import type { NgxConfig, NgxDirective } from '@/api/ngx'
+import { defineStore } from 'pinia'
+
+export const useNgxConfigStore = defineStore('ngxConfig', () => {
+  const ngxConfig = ref<NgxConfig>({
+    name: '',
+    servers: [],
+  })
+
+  const configText = ref('')
+
+  const curServerIdx = ref(0)
+
+  function setNgxConfig(config: NgxConfig) {
+    ngxConfig.value = config
+  }
+
+  const curServer = computed({
+    get() {
+      return ngxConfig.value.servers[curServerIdx.value]
+    },
+    set(v) {
+      ngxConfig.value.servers[curServerIdx.value] = v
+    },
+  })
+
+  const curServerDirectives = computed({
+    get() {
+      return ngxConfig.value.servers[curServerIdx.value]?.directives
+    },
+    set(v) {
+      ngxConfig.value.servers[curServerIdx.value].directives = v
+    },
+  })
+
+  const curServerLocations = computed({
+    get() {
+      return ngxConfig.value.servers[curServerIdx.value]?.locations
+    },
+    set(v) {
+      ngxConfig.value.servers[curServerIdx.value].locations = v
+    },
+  })
+
+  const curDirectivesMap = computed(() => {
+    const record: Record<string, NgxDirective[]> = {}
+
+    curServerDirectives.value?.forEach((v, k) => {
+      v.idx = k
+      if (record[v.directive])
+        record[v.directive].push(v)
+      else
+        record[v.directive] = [v]
+    })
+
+    return record
+  })
+
+  return {
+    ngxConfig,
+    configText,
+    curServerIdx,
+    setNgxConfig,
+    curServer,
+    curServerDirectives,
+    curServerLocations,
+    curDirectivesMap,
+  }
+})

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

@@ -46,3 +46,11 @@ export const PrivateKeyTypeList
       ({ key, name }))
 
 export type PrivateKeyType = keyof typeof PrivateKeyTypeMask
+export const PrivateKeyTypeEnum = {
+  2048: '2048',
+  3072: '3072',
+  4096: '4096',
+  8192: '8192',
+  P256: 'P256',
+  P384: 'P384',
+} as const

File diff suppressed because it is too large
+ 168 - 163
app/src/language/ar/app.po


File diff suppressed because it is too large
+ 164 - 159
app/src/language/de_DE/app.po


File diff suppressed because it is too large
+ 164 - 158
app/src/language/en/app.po


File diff suppressed because it is too large
+ 168 - 164
app/src/language/es/app.po


File diff suppressed because it is too large
+ 165 - 160
app/src/language/fr_FR/app.po


File diff suppressed because it is too large
+ 168 - 163
app/src/language/ko_KR/app.po


File diff suppressed because it is too large
+ 163 - 161
app/src/language/messages.pot


File diff suppressed because it is too large
+ 168 - 163
app/src/language/ru_RU/app.po


File diff suppressed because it is too large
+ 165 - 158
app/src/language/tr_TR/app.po


File diff suppressed because it is too large
+ 167 - 163
app/src/language/uk_UA/app.po


File diff suppressed because it is too large
+ 164 - 158
app/src/language/vi_VN/app.po


File diff suppressed because it is too large
+ 166 - 160
app/src/language/zh_CN/app.po


File diff suppressed because it is too large
+ 168 - 163
app/src/language/zh_TW/app.po


+ 1 - 1
app/src/version.json

@@ -1 +1 @@
-{"version":"2.0.0-rc.5","build_id":19,"total_build":413}
+{"version":"2.0.0-rc.5","build_id":20,"total_build":414}

+ 5 - 3
app/src/views/certificate/components/CertificateEditor.vue

@@ -2,12 +2,12 @@
 import type { Cert } from '@/api/cert'
 import type { Ref } from 'vue'
 import cert from '@/api/cert'
+import AutoCertForm from '@/components/AutoCertForm/AutoCertForm.vue'
+import CertInfo from '@/components/CertInfo/CertInfo.vue'
 import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
 import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
 import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
 import { AutoCertState } from '@/constants'
-import CertInfo from '@/views/site/cert/CertInfo.vue'
-import AutoCertStepOne from '@/views/site/cert/components/AutoCertStepOne.vue'
 import { message } from 'ant-design-vue'
 import RenewCert from './RenewCert.vue'
 
@@ -147,7 +147,7 @@ const isManaged = computed(() => {
             @renewed="init"
           />
 
-          <AutoCertStepOne
+          <AutoCertForm
             v-model:options="data"
             style="max-width: 600px"
             hide-note
@@ -219,6 +219,7 @@ const isManaged = computed(() => {
               v-model:content="data.ssl_certificate"
               default-height="300px"
               :readonly="!notShowInAutoCert"
+              disable-code-completion
               :placeholder="$gettext('Leave blank will not change anything')"
             />
           </AFormItem>
@@ -232,6 +233,7 @@ const isManaged = computed(() => {
               v-model:content="data.ssl_certificate_key"
               default-height="300px"
               :readonly="!notShowInAutoCert"
+              disable-code-completion
               :placeholder="$gettext('Leave blank will not change anything')"
             />
           </AFormItem>

+ 1 - 5
app/src/views/certificate/components/RenewCert.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { AutoCertOptions } from '@/api/auto_cert'
-import ObtainCertLive from '@/views/site/cert/components/ObtainCertLive.vue'
+import ObtainCertLive from '@/views/site/site_edit/components/Cert/ObtainCertLive.vue'
 import { message } from 'ant-design-vue'
 
 const props = defineProps<{
@@ -28,10 +28,6 @@ async function issueCert() {
     emit('renewed')
   })
 }
-
-const issuing_cert = ref(false)
-
-provide('issuing_cert', issuing_cert)
 </script>
 
 <template>

+ 3 - 5
app/src/views/certificate/components/WildcardCertificate.vue

@@ -1,8 +1,8 @@
 <script setup lang="ts">
 import type { AutoCertOptions } from '@/api/auto_cert'
 import type { Ref } from 'vue'
-import AutoCertStepOne from '@/views/site/cert/components/AutoCertStepOne.vue'
-import ObtainCertLive from '@/views/site/cert/components/ObtainCertLive.vue'
+import AutoCertForm from '@/components/AutoCertForm/AutoCertForm.vue'
+import ObtainCertLive from '@/views/site/site_edit/components/Cert/ObtainCertLive.vue'
 import { message } from 'ant-design-vue'
 
 const emit = defineEmits<{
@@ -12,10 +12,8 @@ const emit = defineEmits<{
 const step = ref(0)
 const visible = ref(false)
 const data = ref({}) as Ref<AutoCertOptions>
-const issuing_cert = ref(false)
 const domain = ref('')
 
-provide('issuing_cert', issuing_cert)
 function open() {
   visible.value = true
   step.value = 0
@@ -73,7 +71,7 @@ function issueCert() {
           </AFormItem>
         </AForm>
 
-        <AutoCertStepOne
+        <AutoCertForm
           v-model:options="data"
           style="max-width: 600px"
           hide-note

+ 1 - 1
app/src/views/preference/ServerSettings.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import type { Cert } from '@/api/cert'
 import type { Settings } from '@/api/settings'
-import ChangeCert from '@/views/site/cert/components/ChangeCert/ChangeCert.vue'
+import ChangeCert from '@/views/site/site_edit/components/Cert/ChangeCert.vue'
 
 const data: Ref<Settings> = inject('data') as Ref<Settings>
 

+ 0 - 78
app/src/views/site/cert/IssueCert.vue

@@ -1,78 +0,0 @@
-<script setup lang="ts">
-import type { NgxDirective } from '@/api/ngx'
-import ObtainCert from '@/views/site/cert/components/ObtainCert.vue'
-
-defineProps<{
-  configName: string
-}>()
-
-const issuing_cert = ref(false)
-const obtain_cert = useTemplateRef('obtain_cert')
-const directivesMap = inject('directivesMap') as Ref<Record<string, NgxDirective[]>>
-
-const enabled = defineModel<boolean>('enabled', {
-  default: () => false,
-})
-
-const noServerName = computed(() => {
-  if (!directivesMap.value.server_name)
-    return true
-
-  return directivesMap.value.server_name.length === 0
-})
-
-provide('issuing_cert', issuing_cert)
-
-watch(noServerName, () => {
-  enabled.value = false
-})
-
-const update = ref(0)
-
-async function onchange() {
-  update.value++
-  await nextTick()
-  obtain_cert.value!.toggle(enabled.value)
-}
-</script>
-
-<template>
-  <ObtainCert
-    ref="obtain_cert"
-    :key="update"
-    :no-server-name="noServerName"
-    :config-name="configName"
-    @update:auto_cert="r => enabled = r"
-  />
-  <div class="issue-cert">
-    <AFormItem :label="$gettext('Encrypt website with Let\'s Encrypt')">
-      <ASwitch
-        :loading="issuing_cert"
-        :checked="enabled"
-        :disabled="noServerName"
-        @change="onchange"
-      />
-    </AFormItem>
-  </div>
-</template>
-
-<style lang="less" scoped>
-.ant-tag {
-  margin: 0;
-}
-
-.issue-cert {
-  margin: 15px 0;
-}
-
-.switch-wrapper {
-  position: relative;
-
-  .text {
-    position: absolute;
-    top: 50%;
-    transform: translateY(-50%);
-    margin-left: 10px;
-  }
-}
-</style>

+ 0 - 178
app/src/views/site/ngx_conf/LocationEditor.vue

@@ -1,178 +0,0 @@
-<script setup lang="ts">
-import type { NgxConfig, NgxLocation } from '@/api/ngx'
-import CodeEditor from '@/components/CodeEditor'
-import { CopyOutlined, DeleteOutlined, HolderOutlined } from '@ant-design/icons-vue'
-import _ from 'lodash'
-import Draggable from 'vuedraggable'
-
-const props = defineProps<{
-  locations?: NgxLocation[]
-  readonly?: boolean
-  currentServerIndex?: number
-}>()
-
-const ngx_config = inject('ngx_config') as NgxConfig
-
-const location = reactive({
-  comments: '',
-  path: '',
-  content: '',
-})
-
-const adding = ref(false)
-
-function add() {
-  adding.value = true
-  location.comments = ''
-  location.path = ''
-  location.content = ''
-}
-
-function save() {
-  adding.value = false
-  ngx_config.servers[props.currentServerIndex!].locations?.push({
-    ...location,
-  })
-}
-
-function remove(index: number) {
-  ngx_config.servers[props.currentServerIndex!].locations?.splice(index, 1)
-}
-
-function duplicate(index: number) {
-  const loc = ngx_config.servers[props.currentServerIndex!].locations![index]
-
-  ngx_config.servers[props.currentServerIndex!].locations?.splice(index, 0, _.cloneDeep(loc))
-}
-</script>
-
-<template>
-  <h3>{{ $gettext('Locations') }}</h3>
-  <AEmpty v-if="locations && locations?.length === 0" />
-  <Draggable
-    v-else
-    :list="locations"
-    item-key="name"
-    class="list-group"
-    ghost-class="ghost"
-    handle=".ant-collapse-header"
-  >
-    <template #item="{ element: v, index }">
-      <ACollapse
-        :bordered="false"
-        collapsible="header"
-      >
-        <ACollapsePanel>
-          <template #header>
-            <HolderOutlined />
-            {{ $gettext('Location') }}
-            {{ v.path }}
-          </template>
-          <template
-            v-if="!readonly"
-            #extra
-          >
-            <ASpace>
-              <AButton
-                type="text"
-                size="small"
-                @click="() => duplicate(index)"
-              >
-                <template #icon>
-                  <CopyOutlined style="font-size: 14px;" />
-                </template>
-              </AButton>
-              <APopconfirm
-                :title="$gettext('Are you sure you want to remove this location?')"
-                :ok-text="$gettext('Yes')"
-                :cancel-text="$gettext('No')"
-                @confirm="remove(index)"
-              >
-                <AButton
-                  type="text"
-                  size="small"
-                >
-                  <template #icon>
-                    <DeleteOutlined style="font-size: 14px;" />
-                  </template>
-                </AButton>
-              </APopconfirm>
-            </ASpace>
-          </template>
-          <AForm layout="vertical">
-            <AFormItem :label="$gettext('Comments')">
-              <ATextarea
-                v-model:value="v.comments"
-                :bordered="false"
-              />
-            </AFormItem>
-            <AFormItem :label="$gettext('Path')">
-              <AInput
-                v-model:value="v.path"
-                addon-before="location"
-              />
-            </AFormItem>
-            <AFormItem :label="$gettext('Content')">
-              <CodeEditor
-                v-model:content="v.content"
-                default-height="200px"
-                style="width: 100%;"
-              />
-            </AFormItem>
-          </AForm>
-        </ACollapsePanel>
-      </ACollapse>
-    </template>
-  </Draggable>
-
-  <AModal
-    v-model:open="adding"
-    :title="$gettext('Add Location')"
-    @ok="save"
-  >
-    <AForm layout="vertical">
-      <AFormItem :label="$gettext('Comments')">
-        <ATextarea v-model:value="location.comments" />
-      </AFormItem>
-      <AFormItem :label="$gettext('Path')">
-        <AInput
-          v-model:value="location.path"
-          addon-before="location"
-        />
-      </AFormItem>
-      <AFormItem :label="$gettext('Content')">
-        <CodeEditor
-          v-model:content="location.content"
-          default-height="200px"
-        />
-      </AFormItem>
-    </AForm>
-  </AModal>
-
-  <div v-if="!readonly">
-    <AButton
-      block
-      @click="add"
-    >
-      {{ $gettext('Add Location') }}
-    </AButton>
-  </div>
-</template>
-
-<style lang="less" scoped>
-.ant-collapse {
-  margin: 10px 0;
-}
-
-.ant-collapse-item {
-  border: 0 !important;
-}
-
-.ant-collapse-header {
-  align-items: center;
-}
-
-:deep(.ant-collapse-header-text) {
-  max-width: calc(90% - 56px);
-}
-</style>

+ 0 - 219
app/src/views/site/ngx_conf/NgxConfigEditor.vue

@@ -1,219 +0,0 @@
-<script setup lang="ts">
-import type { CertificateInfo } from '@/api/cert'
-import type { NgxConfig, NgxDirective } from '@/api/ngx'
-import type { SiteStatus } from '@/api/site'
-import type { CheckedType } from '@/types'
-import type { ComputedRef } from 'vue'
-import template from '@/api/template'
-import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
-import { ConfigStatus } from '@/constants'
-import NginxStatusAlert from '@/views/site/ngx_conf/NginxStatusAlert.vue'
-import NgxServer from '@/views/site/ngx_conf/NgxServer.vue'
-import NgxUpstream from '@/views/site/ngx_conf/NgxUpstream.vue'
-import { Modal } from 'ant-design-vue'
-
-withDefaults(defineProps<{
-  status?: SiteStatus
-  certInfo?: Record<number, CertificateInfo[]>
-  context?: 'http' | 'stream'
-}>(), {
-  status: ConfigStatus.Enabled,
-  context: 'http',
-})
-
-const autoCert = defineModel<boolean>('autoCert')
-
-const save_config = inject('save_config') as () => Promise<void>
-
-const [modal, ContextHolder] = Modal.useModal()
-
-const current_server_index = ref(0)
-
-provide('current_server_index', current_server_index)
-
-const route = useRoute()
-
-onMounted(() => {
-  current_server_index.value = Number.parseInt((route.query?.server_idx ?? 0) as string)
-})
-
-const ngx_config = inject('ngx_config') as NgxConfig
-
-function confirm_change_tls(status: CheckedType) {
-  modal.confirm({
-    title: $gettext('Do you want to enable TLS?'),
-    content: $gettext('To make sure the certification auto-renewal can work normally, '
-      + 'we need to add a location which can proxy the request from authority to backend, '
-      + 'and we need to save this file and reload the Nginx. Are you sure you want to continue?'),
-    mask: false,
-    centered: true,
-    okText: $gettext('OK'),
-    cancelText: $gettext('Cancel'),
-    async onOk() {
-      await template.get_block('letsencrypt.conf').then(async r => {
-        const first = ngx_config.servers[0]
-        if (!first.locations)
-          first.locations = []
-        else
-          first.locations = first.locations.filter(l => l.path !== '/.well-known/acme-challenge')
-
-        first.locations.push(...r.locations)
-      })
-      await save_config()
-
-      change_tls(status)
-    },
-  })
-}
-
-const current_server_directives = computed({
-  get() {
-    return ngx_config.servers?.[current_server_index.value]?.directives
-  },
-  set(v) {
-    ngx_config.servers[current_server_index.value].directives = v
-  },
-})
-
-provide('current_server_directives', current_server_directives)
-
-const directivesMap: ComputedRef<Record<string, NgxDirective[]>> = computed(() => {
-  const record: Record<string, NgxDirective[]> = {}
-
-  current_server_directives.value?.forEach((v, k) => {
-    v.idx = k
-    if (record[v.directive])
-      record[v.directive].push(v)
-    else
-      record[v.directive] = [v]
-  })
-
-  return record
-})
-
-function change_tls(status: CheckedType) {
-  if (status) {
-    // deep copy servers[0] to servers[1]
-    const server = JSON.parse(JSON.stringify(ngx_config.servers[0]))
-
-    ngx_config.servers.push(server)
-
-    current_server_index.value = 1
-
-    const servers = ngx_config.servers
-
-    let i = 0
-    while (i < (servers?.[1].directives?.length ?? 0)) {
-      const v = servers?.[1]?.directives?.[i]
-      if (v?.directive === 'listen')
-        servers[1]?.directives?.splice(i, 1)
-      else
-        i++
-    }
-
-    servers?.[1]?.directives?.splice(0, 0, {
-      directive: 'listen',
-      params: '443 ssl',
-    }, {
-      directive: 'listen',
-      params: '[::]:443 ssl',
-    })
-
-    const server_name_idx = directivesMap.value?.server_name?.[0].idx ?? 0
-
-    if (!directivesMap.value.ssl_certificate) {
-      servers?.[1]?.directives?.splice(server_name_idx + 1, 0, {
-        directive: 'ssl_certificate',
-        params: '',
-      })
-    }
-
-    setTimeout(() => {
-      if (!directivesMap.value.ssl_certificate_key) {
-        servers?.[1]?.directives?.splice(server_name_idx + 2, 0, {
-          directive: 'ssl_certificate_key',
-          params: '',
-        })
-      }
-    }, 100)
-  }
-  else {
-    // remove servers[1]
-    current_server_index.value = 0
-    if (ngx_config.servers.length === 2)
-      ngx_config.servers.splice(1, 1)
-  }
-}
-
-const support_ssl = computed(() => {
-  const servers = ngx_config.servers
-  for (const server_key in servers) {
-    for (const k in servers[server_key].directives) {
-      const v = servers?.[server_key]?.directives?.[Number.parseInt(k)]
-      if (v?.directive === 'listen' && v?.params?.indexOf('ssl') > 0)
-        return true
-    }
-  }
-
-  return false
-})
-
-provide('directivesMap', directivesMap)
-
-const activeKey = ref(['3'])
-</script>
-
-<template>
-  <div>
-    <ContextHolder />
-
-    <NginxStatusAlert />
-
-    <AFormItem
-      v-if="!support_ssl && context === 'http'"
-      :label="$gettext('Enable TLS')"
-    >
-      <ASwitch @change="confirm_change_tls" />
-    </AFormItem>
-
-    <ACollapse
-      v-model:active-key="activeKey"
-      ghost
-    >
-      <ACollapsePanel
-        key="1"
-        :header="$gettext('Custom')"
-      >
-        <div class="mb-4">
-          <CodeEditor
-            v-model:content="ngx_config.custom"
-            default-height="150px"
-          />
-        </div>
-      </ACollapsePanel>
-      <ACollapsePanel
-        key="2"
-        header="Upstream"
-      >
-        <NgxUpstream />
-      </ACollapsePanel>
-      <ACollapsePanel
-        key="3"
-        header="Server"
-      >
-        <NgxServer
-          v-model:auto-cert="autoCert"
-          :status
-          :cert-info
-          :context
-        />
-      </ACollapsePanel>
-    </ACollapse>
-  </div>
-</template>
-
-<style lang="less" scoped>
-:deep(.ant-tabs-tab-btn) {
-  margin-left: 16px;
-}
-</style>

+ 0 - 175
app/src/views/site/ngx_conf/NgxServer.vue

@@ -1,175 +0,0 @@
-<script setup lang="ts">
-import type { CertificateInfo } from '@/api/cert'
-import type { NgxConfig, NgxDirective } from '@/api/ngx'
-import type { SiteStatus } from '@/api/site'
-import { ConfigStatus } from '@/constants'
-import Cert from '@/views/site/cert/Cert.vue'
-import ConfigTemplate from '@/views/site/ngx_conf/config_template/ConfigTemplate.vue'
-import DirectiveEditor from '@/views/site/ngx_conf/directive/DirectiveEditor.vue'
-import LocationEditor from '@/views/site/ngx_conf/LocationEditor.vue'
-import LogEntry from '@/views/site/ngx_conf/LogEntry.vue'
-import { MoreOutlined, PlusOutlined } from '@ant-design/icons-vue'
-import { Modal } from 'ant-design-vue'
-
-withDefaults(defineProps<{
-  certInfo?: {
-    [key: number]: CertificateInfo[]
-  }
-  context?: 'http' | 'stream'
-  status?: SiteStatus
-}>(), {
-  context: 'http',
-  status: ConfigStatus.Enabled,
-})
-
-const [modal, ContextHolder] = Modal.useModal()
-
-const current_server_index = inject('current_server_index') as Ref<number>
-const route = useRoute()
-const name = computed(() => route.params.name) as ComputedRef<string>
-
-const ngx_config = inject('ngx_config') as NgxConfig
-
-const directivesMap = inject('directivesMap') as ComputedRef<Record<string, NgxDirective[]>>
-
-const current_support_ssl = computed(() => {
-  if (directivesMap.value.listen) {
-    for (const v of directivesMap.value.listen) {
-      if (v?.params.indexOf('ssl') > 0)
-        return true
-    }
-  }
-
-  return false
-})
-
-const autoCert = defineModel<boolean>('autoCert', { default: false })
-
-const router = useRouter()
-
-const servers_length = computed(() => {
-  return ngx_config.servers.length
-})
-
-watch(servers_length, () => {
-  if (current_server_index.value >= servers_length.value)
-    current_server_index.value = servers_length.value - 1
-  else if (current_server_index.value < 0)
-    current_server_index.value = 0
-})
-
-watch(current_server_index, () => {
-  router.push({
-    query: {
-      server_idx: current_server_index.value.toString(),
-    },
-  })
-})
-
-function add_server() {
-  ngx_config.servers.push({
-    comments: '',
-    locations: [],
-    directives: [],
-  })
-}
-
-function remove_server(index: number) {
-  modal.confirm({
-    title: $gettext('Do you want to remove this server?'),
-    mask: false,
-    centered: true,
-    okText: $gettext('OK'),
-    cancelText: $gettext('Cancel'),
-    onOk() {
-      ngx_config?.servers?.splice(index, 1)
-      current_server_index.value = (index > 1 ? index - 1 : 0)
-    },
-  })
-}
-
-const ngx_directives = computed(() => {
-  return ngx_config?.servers?.[current_server_index.value]?.directives
-})
-
-provide('ngx_directives', ngx_directives)
-</script>
-
-<template>
-  <div>
-    <ContextHolder />
-    <ATabs v-model:active-key="current_server_index">
-      <ATabPane
-        v-for="(v, k) in ngx_config.servers"
-        :key="k"
-      >
-        <template #tab>
-          Server {{ k + 1 }}
-          <ADropdown>
-            <MoreOutlined />
-            <template #overlay>
-              <AMenu>
-                <AMenuItem>
-                  <a @click="remove_server(k)">{{ $gettext('Delete') }}</a>
-                </AMenuItem>
-              </AMenu>
-            </template>
-          </ADropdown>
-        </template>
-        <LogEntry
-          :ngx-config="ngx_config"
-          :current-server-idx="current_server_index"
-          :name="name"
-        />
-
-        <div class="tab-content">
-          <Cert
-            v-if="current_support_ssl"
-            v-model:enabled="autoCert"
-            v-model:current_server_directives="ngx_config.servers[current_server_index].directives"
-            class="mb-4"
-            :site-status="status"
-            :config-name="ngx_config.name"
-            :cert-info="certInfo?.[k]"
-            :current-server-index="current_server_index"
-          />
-
-          <template v-if="v.comments">
-            <h3>{{ $gettext('Comments') }}</h3>
-            <ATextarea
-              v-model:value="v.comments"
-              :bordered="false"
-            />
-          </template>
-          <DirectiveEditor />
-          <br>
-          <ConfigTemplate
-            v-if="context === 'http'"
-            :current-server-index="current_server_index"
-          />
-          <br>
-          <LocationEditor
-            v-if="context === 'http'"
-            :current-server-index="current_server_index"
-            :locations="v.locations"
-          />
-        </div>
-      </ATabPane>
-
-      <template #rightExtra>
-        <AButton
-          type="link"
-          size="small"
-          @click="add_server"
-        >
-          <PlusOutlined />
-          {{ $gettext('Add') }}
-        </AButton>
-      </template>
-    </ATabs>
-  </div>
-</template>
-
-<style scoped lang="less">
-
-</style>

+ 0 - 163
app/src/views/site/ngx_conf/config_template/ConfigTemplate.vue

@@ -1,163 +0,0 @@
-<script setup lang="ts">
-import type { NgxConfig } from '@/api/ngx'
-import type { Template } from '@/api/template'
-import type { Ref } from 'vue'
-import template from '@/api/template'
-import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
-
-import { useSettingsStore } from '@/pinia'
-import TemplateForm from '@/views/site/ngx_conf/config_template/TemplateForm.vue'
-import DirectiveEditor from '@/views/site/ngx_conf/directive/DirectiveEditor.vue'
-import LocationEditor from '@/views/site/ngx_conf/LocationEditor.vue'
-import { storeToRefs } from 'pinia'
-
-const props = defineProps<{
-  currentServerIndex: number
-}>()
-
-const { language } = storeToRefs(useSettingsStore())
-const ngx_config = inject('ngx_config') as NgxConfig
-const blocks = ref([])
-const data = ref({}) as Ref<Template>
-const visible = ref(false)
-const name = ref('')
-
-function get_block_list() {
-  template.get_block_list().then(r => {
-    blocks.value = r.data
-  })
-}
-
-get_block_list()
-
-function view(n: string) {
-  visible.value = true
-  name.value = n
-  template.get_block(n).then(r => {
-    data.value = r
-  })
-}
-
-const trans_description = computed(() => {
-  return (item: { description: { [key: string]: string } }) =>
-    item.description?.[language.value] ?? item.description?.en ?? ''
-})
-
-async function add() {
-  if (data.value.custom)
-    ngx_config.custom += `\n${data.value.custom}`
-
-  ngx_config.custom = ngx_config.custom?.trim()
-
-  if (data.value.locations)
-    ngx_config?.servers?.[props.currentServerIndex]?.locations?.push(...data.value.locations)
-
-  if (data.value.directives)
-    ngx_config?.servers?.[props.currentServerIndex]?.directives?.push(...data.value.directives)
-
-  visible.value = false
-}
-
-const variables = computed(() => {
-  return data.value.variables
-})
-
-function build_template() {
-  template.build_block(name.value, variables.value).then(r => {
-    data.value.directives = r.directives
-    data.value.locations = r.locations
-    data.value.custom = r.custom
-  })
-}
-
-const ngx_directives = computed(() => {
-  return data.value?.directives
-})
-
-provide('build_template', build_template)
-provide('ngx_directives', ngx_directives)
-</script>
-
-<template>
-  <div>
-    <h3>
-      {{ $gettext('Config Templates') }}
-    </h3>
-    <div class="config-list-wrapper">
-      <AList
-        :grid="{ gutter: 16, xs: 1, sm: 2, md: 2, lg: 2, xl: 2, xxl: 2, xxxl: 2 }"
-        :data-source="blocks"
-      >
-        <template #renderItem="{ item }">
-          <AListItem>
-            <ACard
-              size="small"
-              :title="item.name"
-            >
-              <template #extra>
-                <AButton
-                  type="link"
-                  size="small"
-                  @click="view(item.filename)"
-                >
-                  {{ $gettext('View') }}
-                </AButton>
-              </template>
-              <p>{{ $gettext('Author') }}: {{ item.author }}</p>
-              <p>{{ $gettext('Description') }}: {{ trans_description(item) }}</p>
-            </ACard>
-          </AListItem>
-        </template>
-      </AList>
-    </div>
-    <AModal
-      v-model:open="visible"
-      :title="data.name"
-      :mask="false"
-      :ok-text="$gettext('Add')"
-      @ok="add"
-    >
-      <p>{{ $gettext('Author') }}: {{ data.author }}</p>
-      <p>{{ $gettext('Description') }}: {{ trans_description(data) }}</p>
-      <TemplateForm v-model="data.variables" />
-      <div
-        v-if="data.custom"
-        class="mb-4"
-      >
-        <h3>{{ $gettext('Custom') }}</h3>
-        <CodeEditor
-          v-model:content="data.custom"
-          default-height="150px"
-        />
-      </div>
-      <DirectiveEditor
-        v-if="data.directives"
-        readonly
-      />
-      <LocationEditor
-        v-if="data.locations"
-        :locations="data.locations"
-        readonly
-      />
-    </AModal>
-  </div>
-</template>
-
-<style lang="less" scoped>
-.config-list-wrapper {
-  max-height: 200px;
-  overflow-y: scroll;
-  overflow-x: hidden;
-}
-
-:deep(.ant-col) {
-  height: calc(100% - 16px);
-  .ant-list-item {
-    height: 100%;
-
-    .ant-card {
-      height: 100%;
-    }
-  }
-}
-</style>

+ 0 - 28
app/src/views/site/ngx_conf/directive/DirectiveDocuments.vue

@@ -1,28 +0,0 @@
-<script setup lang="ts">
-import type { DirectiveMap } from '@/api/ngx'
-
-const props = defineProps<{
-  directive: string
-  nginxDirectivesMap?: DirectiveMap
-}>()
-</script>
-
-<template>
-  <AFormItem
-    v-if="nginxDirectivesMap?.[props.directive]"
-    class="mb-0"
-    :label="
-      $ngettext('Document', 'Documents',
-                nginxDirectivesMap[props.directive].links.length)"
-  >
-    <div v-for="(link, idx) in nginxDirectivesMap?.[props.directive]?.links" :key="idx" class="mb-2">
-      <a :href="link">
-        {{ link }}
-      </a>
-    </div>
-  </AFormItem>
-</template>
-
-<style scoped lang="less">
-
-</style>

+ 0 - 68
app/src/views/site/ngx_conf/directive/DirectiveEditor.vue

@@ -1,68 +0,0 @@
-<script setup lang="ts">
-import type { DirectiveMap, NgxDirective } from '@/api/ngx'
-import type { ComputedRef } from 'vue'
-import ngx from '@/api/ngx'
-import DirectiveEditorItem from '@/views/site/ngx_conf/directive/DirectiveEditorItem.vue'
-import Draggable from 'vuedraggable'
-import DirectiveAdd from './DirectiveAdd.vue'
-
-defineProps<{
-  readonly?: boolean
-  context?: string
-}>()
-
-const current_idx = ref(-1)
-
-const ngx_directives = inject('ngx_directives') as ComputedRef<NgxDirective[]>
-
-provide('current_idx', current_idx)
-
-const nginxDirectivesMap = shallowRef<DirectiveMap>()
-
-onMounted(async () => {
-  nginxDirectivesMap.value = await ngx.get_directives()
-})
-</script>
-
-<template>
-  <h3>{{ $gettext('Directives') }}</h3>
-
-  <Draggable
-    :list="ngx_directives"
-    item-key="name"
-    class="list-group"
-    ghost-class="ghost"
-    handle=".anticon-holder"
-  >
-    <template #item="{ index }">
-      <DirectiveEditorItem
-        v-auto-animate
-        :index="index"
-        :readonly="readonly"
-        :context="context"
-        :nginx-directives-map
-        @click="current_idx = index"
-      >
-        <template
-          v-if="$slots.directiveSuffix"
-          #suffix="{ directive }"
-        >
-          <slot
-            name="directiveSuffix"
-            :directive="directive"
-          />
-        </template>
-      </DirectiveEditorItem>
-    </template>
-  </Draggable>
-
-  <DirectiveAdd
-    v-if="!readonly"
-    v-auto-animate
-    :nginx-directives-map
-  />
-</template>
-
-<style lang="less" scoped>
-
-</style>

+ 0 - 1
app/src/views/site/ngx_conf/index.ts

@@ -1 +0,0 @@
-export const If = 'if'

+ 24 - 42
app/src/views/site/site_add/SiteAdd.vue

@@ -1,20 +1,9 @@
 <script setup lang="ts">
-import type { NgxConfig } from '@/api/ngx'
 import ngx from '@/api/ngx'
 import site from '@/api/site'
-import DirectiveEditor from '@/views/site/ngx_conf/directive/DirectiveEditor.vue'
-import LocationEditor from '@/views/site/ngx_conf/LocationEditor.vue'
-import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
+import NgxConfigEditor, { DirectiveEditor, LocationEditor, useNgxConfigStore } from '@/components/NgxConfigEditor'
 import { message } from 'ant-design-vue'
 
-const ngxConfig: NgxConfig = reactive({
-  name: '',
-  servers: [{
-    directives: [],
-    locations: [],
-  }],
-})
-
 const currentStep = ref(0)
 
 const enabled = ref(true)
@@ -25,18 +14,21 @@ onMounted(() => {
   init()
 })
 
+const ngxConfigStore = useNgxConfigStore()
+const { ngxConfig, curServerDirectives, curServerLocations } = storeToRefs(ngxConfigStore)
+
 function init() {
   site.get_default_template().then(r => {
-    Object.assign(ngxConfig, r.tokenized)
+    ngxConfig.value = r.tokenized
   })
 }
 
 async function save() {
-  return ngx.build_config(ngxConfig).then(r => {
-    site.save(ngxConfig.name, { name: ngxConfig.name, content: r.content, overwrite: true }).then(() => {
+  return ngx.build_config(ngxConfig.value).then(r => {
+    site.save(ngxConfig.value.name, { name: ngxConfig.value.name, content: r.content, overwrite: true }).then(() => {
       message.success($gettext('Saved successfully'))
 
-      site.enable(ngxConfig.name).then(() => {
+      site.enable(ngxConfig.value.name).then(() => {
         message.success($gettext('Enabled successfully'))
         window.scroll({ top: 0, left: 0, behavior: 'smooth' })
       }).catch(e => {
@@ -51,7 +43,7 @@ async function save() {
 const router = useRouter()
 
 function gotoModify() {
-  router.push(`/sites/${ngxConfig.name}`)
+  router.push(`/sites/${ngxConfig.value.name}`)
 }
 
 function createAnother() {
@@ -59,7 +51,7 @@ function createAnother() {
 }
 
 const hasServerName = computed(() => {
-  const servers = ngxConfig.servers
+  const servers = ngxConfig.value.servers
 
   for (const server of Object.values(servers)) {
     for (const directive of Object.values(server.directives!)) {
@@ -75,14 +67,6 @@ async function next() {
   await save()
   currentStep.value++
 }
-
-const ngxDirectives = computed(() => {
-  return ngxConfig.servers[0].directives
-})
-
-provide('save_config', save)
-provide('ngx_directives', ngxDirectives)
-provide('ngx_config', ngxConfig)
 </script>
 
 <template>
@@ -96,32 +80,30 @@ provide('ngx_config', ngxConfig)
         <AStep :title="$gettext('Configure SSL')" />
         <AStep :title="$gettext('Finished')" />
       </ASteps>
-      <template v-if="currentStep === 0">
+      <div v-if="currentStep === 0" class="mb-6">
         <AForm layout="vertical">
           <AFormItem :label="$gettext('Configuration Name')">
             <AInput v-model:value="ngxConfig.name" />
           </AFormItem>
         </AForm>
 
-        <DirectiveEditor />
-        <br>
-        <LocationEditor
-          :locations="ngxConfig.servers[0].locations"
-          :current-server-index="0"
-        />
-        <br>
         <AAlert
           v-if="!hasServerName"
-          :message="$gettext('Warning')"
           type="warning"
+          class="mb-4"
           show-icon
-        >
-          <template #description>
-            <span>{{ $gettext('The parameter of server_name is required') }}</span>
-          </template>
-        </AAlert>
-        <br>
-      </template>
+          :message="$gettext('The parameter of server_name is required')"
+        />
+
+        <DirectiveEditor
+          v-model:directives="curServerDirectives"
+          class="mb-4"
+        />
+        <LocationEditor
+          v-model:locations="curServerLocations"
+          :current-server-index="0"
+        />
+      </div>
 
       <template v-else-if="currentStep === 1">
         <NgxConfigEditor

+ 0 - 133
app/src/views/site/site_edit/RightSettings.vue

@@ -1,133 +0,0 @@
-<script setup lang="ts">
-import type { ChatComplicationMessage } from '@/api/openai'
-import type { Site, SiteStatus } from '@/api/site'
-import type { Ref } from 'vue'
-import envGroup from '@/api/env_group'
-import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
-import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
-import StdSelector from '@/components/StdDesign/StdDataEntry/components/StdSelector.vue'
-import { formatDateTime } from '@/lib/helper'
-import { useSettingsStore } from '@/pinia'
-import envGroupColumns from '@/views/environments/group/columns'
-import SiteStatusSegmented from '@/views/site/components/SiteStatusSegmented.vue'
-import ConfigName from '@/views/site/site_edit/components/ConfigName.vue'
-import { InfoCircleOutlined } from '@ant-design/icons-vue'
-
-const settings = useSettingsStore()
-
-const configText = inject('configText') as Ref<string>
-const name = inject('name') as ComputedRef<string>
-const filepath = inject('filepath') as Ref<string>
-const historyChatgptRecord = inject('history_chatgpt_record') as Ref<ChatComplicationMessage[]>
-const data = inject('data') as Ref<Site>
-
-const activeKey = ref(['1', '2', '3'])
-
-function handleStatusChanged(event: { status: SiteStatus }) {
-  data.value.status = event.status
-}
-</script>
-
-<template>
-  <ACard
-    class="right-settings"
-    :bordered="false"
-  >
-    <ACollapse
-      v-model:active-key="activeKey"
-      ghost
-      collapsible="header"
-    >
-      <ACollapsePanel
-        key="1"
-        :header="$gettext('Basic')"
-      >
-        <AForm layout="vertical">
-          <AFormItem :label="$gettext('Status')">
-            <SiteStatusSegmented
-              v-model="data.status"
-              :site-name="name"
-              @status-changed="handleStatusChanged"
-            />
-          </AFormItem>
-          <AFormItem :label="$gettext('Name')">
-            <ConfigName v-if="name" :name />
-          </AFormItem>
-          <AFormItem :label="$gettext('Node Group')">
-            <StdSelector
-              v-model:selected-key="data.env_group_id"
-              :api="envGroup"
-              :columns="envGroupColumns"
-              record-value-index="name"
-              selection-type="radio"
-            />
-          </AFormItem>
-          <AFormItem :label="$gettext('Updated at')">
-            {{ formatDateTime(data.modified_at) }}
-          </AFormItem>
-        </AForm>
-      </ACollapsePanel>
-      <ACollapsePanel
-        v-if="!settings.is_remote"
-        key="2"
-      >
-        <template #header>
-          {{ $gettext('Synchronization') }}
-        </template>
-        <template #extra>
-          <APopover placement="bottomRight" :title="$gettext('Sync strategy')">
-            <template #content>
-              <div class="max-w-200px mb-2">
-                {{ $gettext('When you enable/disable, delete, or save this site, '
-                  + 'the nodes set in the Node Group and the nodes selected below will be synchronized.') }}
-              </div>
-              <div class="max-w-200px">
-                {{ $gettext('Note, if the configuration file include other configurations or certificates, '
-                  + 'please synchronize them to the remote nodes in advance.') }}
-              </div>
-            </template>
-            <div class="text-trueGray-600">
-              <InfoCircleOutlined class="mr-1" />
-              {{ $gettext('Sync strategy') }}
-            </div>
-          </APopover>
-        </template>
-        <NodeSelector
-          v-model:target="data.sync_node_ids"
-          class="mb-4"
-          hidden-local
-        />
-      </ACollapsePanel>
-      <ACollapsePanel
-        key="3"
-        header="ChatGPT"
-      >
-        <ChatGPT
-          v-model:history-messages="historyChatgptRecord"
-          :content="configText"
-          :path="filepath"
-        />
-      </ACollapsePanel>
-    </ACollapse>
-  </ACard>
-</template>
-
-<style scoped lang="less">
-.right-settings {
-  position: sticky;
-  top: 78px;
-
-  :deep(.ant-card-body) {
-    max-height: 100vh;
-    overflow-y: scroll;
-  }
-}
-
-:deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
-  padding: 0;
-}
-
-:deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
-  padding: 0 0 10px 0;
-}
-</style>

+ 32 - 350
app/src/views/site/site_edit/SiteEdit.vue

@@ -1,362 +1,44 @@
 <script setup lang="ts">
-import type { CertificateInfo } from '@/api/cert'
-import type { NgxConfig } from '@/api/ngx'
-import type { ChatComplicationMessage } from '@/api/openai'
-import type { Site } from '@/api/site'
-import type { CheckedType } from '@/types'
-import config from '@/api/config'
-import ngx from '@/api/ngx'
-import site from '@/api/site'
-import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
-import { ConfigHistory } from '@/components/ConfigHistory'
-import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
-import { ConfigStatus } from '@/constants'
-import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
-import RightSettings from '@/views/site/site_edit/RightSettings.vue'
-import { HistoryOutlined } from '@ant-design/icons-vue'
-import { message } from 'ant-design-vue'
-
-const route = useRoute()
-const router = useRouter()
-
-const name = computed(() => decodeURIComponent(route.params?.name?.toString() ?? ''))
-
-const ngx_config: NgxConfig = reactive({
-  name: '',
-  upstreams: [],
-  servers: [],
-})
-
-const certInfoMap: Ref<Record<number, CertificateInfo[]>> = ref({})
-
-const autoCert = ref(false)
-const filepath = ref('')
-const configText = ref('')
-const advanceModeRef = ref(false)
-const saving = ref(false)
-const filename = ref('')
-const parseErrorStatus = ref(false)
-const parseErrorMessage = ref('')
-const data = ref({}) as Ref<Site>
-const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
-const loading = ref(true)
-
-const showHistory = ref(false)
-
-onMounted(init)
-
-const advanceMode = computed({
-  get() {
-    return advanceModeRef.value || parseErrorStatus.value
-  },
-  set(v: boolean) {
-    advanceModeRef.value = v
-  },
-})
-
-async function handleResponse(r: Site) {
-  if (r.advanced)
-    advanceMode.value = true
-
-  parseErrorStatus.value = false
-  parseErrorMessage.value = ''
-  filename.value = r.name
-  filepath.value = r.filepath
-  configText.value = r.config
-  autoCert.value = r.auto_cert
-  historyChatgptRecord.value = r.chatgpt_messages
-  data.value = r
-  certInfoMap.value = r.cert_info || {}
-  Object.assign(ngx_config, r.tokenized)
-}
-
-async function init() {
-  loading.value = true
-  if (name.value) {
-    await site.get(name.value).then(r => {
-      handleResponse(r)
-    }).catch(handleParseError)
-  }
-  else {
-    historyChatgptRecord.value = []
-  }
-  loading.value = false
-}
-
-function handleParseError(e: { error?: string, message: string }) {
-  console.error(e)
-  parseErrorStatus.value = true
-  parseErrorMessage.value = e.message
-  config.get(`sites-available/${name.value}`).then(r => {
-    configText.value = r.content
-  })
-}
-
-async function onModeChange(advanced: CheckedType) {
-  loading.value = true
-
-  try {
-    await site.advance_mode(name.value, { advanced: advanced as boolean })
-    advanceMode.value = advanced as boolean
-    if (advanced) {
-      await buildConfig()
-    }
-    else {
-      let r = await site.get(name.value)
-      await handleResponse(r)
-      r = await ngx.tokenize_config(configText.value)
-      Object.assign(ngx_config, {
-        ...r,
-        name: name.value,
-      })
-    }
-  }
-  // eslint-disable-next-line ts/no-explicit-any
-  catch (e: any) {
-    handleParseError(e)
-  }
-
-  loading.value = false
-}
-
-async function buildConfig() {
-  return ngx.build_config(ngx_config).then(r => {
-    configText.value = r.content
-  })
-}
-
-async function save() {
-  saving.value = true
-
-  if (!advanceMode.value) {
-    try {
-      await buildConfig()
-    }
-    catch {
-      saving.value = false
-      message.error($gettext('Failed to save, syntax error(s) was detected in the configuration.'))
-
-      return
-    }
-  }
-
-  return site.save(name.value, {
-    content: configText.value,
-    overwrite: true,
-    env_group_id: data.value.env_group_id,
-    sync_node_ids: data.value.sync_node_ids,
-    post_action: 'reload_nginx',
-  }).then(r => {
-    handleResponse(r)
-    router.push({
-      path: `/sites/${encodeURIComponent(filename.value)}`,
-      query: route.query,
-    })
-    message.success($gettext('Saved successfully'))
-  }).catch(handleParseError).finally(() => {
-    saving.value = false
-  })
-}
-
-function openHistory() {
-  showHistory.value = true
-}
-
-provide('save_config', save)
-provide('configText', configText)
-provide('ngx_config', ngx_config)
-provide('history_chatgpt_record', historyChatgptRecord)
-provide('name', name)
-provide('filepath', filepath)
-provide('data', data)
+import RightSettings from '@/views/site/site_edit/components/RightPanel/RightPanel.vue'
+import SiteEditor from '@/views/site/site_edit/components/SiteEditor'
 </script>
 
 <template>
-  <ARow :gutter="[{ xs: 0, sm: 16 }, 16]">
-    <ACol
-      :xs="24"
-      :sm="24"
-      :md="24"
-      :lg="16"
-      :xl="17"
-    >
-      <ACard :bordered="false">
-        <template #title>
-          <span style="margin-right: 10px">{{ $gettext('Edit %{n}', { n: name }) }}</span>
-          <ATag
-            v-if="data.status === ConfigStatus.Enabled"
-            color="blue"
-          >
-            {{ $gettext('Enabled') }}
-          </ATag>
-          <ATag
-            v-else-if="data.status === ConfigStatus.Disabled"
-            color="red"
-          >
-            {{ $gettext('Disabled') }}
-          </ATag>
-          <ATag
-            v-else-if="data.status === ConfigStatus.Maintenance"
-            color="orange"
-          >
-            {{ $gettext('Maintenance') }}
-          </ATag>
-        </template>
-        <template #extra>
-          <ASpace>
-            <AButton
-              v-if="filepath"
-              type="link"
-              @click="openHistory"
-            >
-              <template #icon>
-                <HistoryOutlined />
-              </template>
-              {{ $gettext('History') }}
-            </AButton>
-            <div class="mode-switch">
-              <div class="switch">
-                <ASwitch
-                  size="small"
-                  :disabled="parseErrorStatus"
-                  :checked="advanceMode"
-                  :loading
-                  @change="onModeChange"
-                />
-              </div>
-              <template v-if="advanceMode">
-                <div>{{ $gettext('Advance Mode') }}</div>
-              </template>
-              <template v-else>
-                <div>{{ $gettext('Basic Mode') }}</div>
-              </template>
-            </div>
-          </ASpace>
-        </template>
-
-        <Transition name="slide-fade">
-          <div
-            v-if="advanceMode"
-            key="advance"
-          >
-            <div
-              v-if="parseErrorStatus"
-              class="parse-error-alert-wrapper"
-            >
-              <AAlert
-                :message="$gettext('Nginx Configuration Parse Error')"
-                :description="parseErrorMessage"
-                type="error"
-                show-icon
-              />
-            </div>
-            <div>
-              <CodeEditor v-model:content="configText" />
-            </div>
-          </div>
-
-          <div
-            v-else
-            key="basic"
-            class="domain-edit-container"
-          >
-            <NgxConfigEditor
-              v-model:auto-cert="autoCert"
-              :cert-info="certInfoMap"
-              :status="data.status"
-              @callback="save"
-            />
-          </div>
-        </Transition>
-      </ACard>
-    </ACol>
-
-    <ACol
-      class="col-right"
-      :xs="24"
-      :sm="24"
-      :md="24"
-      :lg="8"
-      :xl="7"
-    >
-      <RightSettings />
-    </ACol>
-
-    <FooterToolBar>
-      <ASpace>
-        <AButton @click="$router.push('/sites/list')">
-          {{ $gettext('Back') }}
-        </AButton>
-        <AButton
-          type="primary"
-          :loading="saving"
-          @click="save"
-        >
-          {{ $gettext('Save') }}
-        </AButton>
-      </ASpace>
-    </FooterToolBar>
-
-    <ConfigHistory
-      v-model:visible="showHistory"
-      v-model:current-content="configText"
-      :filepath="filepath"
-    />
-  </ARow>
+  <div class="site-container">
+    <ARow :gutter="{ xs: 0, sm: 16 }">
+      <ACol
+        :xs="24"
+        :sm="24"
+        :md="24"
+        :lg="16"
+        :xl="17"
+      >
+        <div>
+          <SiteEditor />
+        </div>
+      </ACol>
+
+      <ACol
+        class="col-right"
+        :xs="24"
+        :sm="24"
+        :md="24"
+        :lg="8"
+        :xl="7"
+      >
+        <RightSettings />
+      </ACol>
+    </ARow>
+  </div>
 </template>
 
-<style lang="less">
-
-</style>
-
 <style lang="less" scoped>
-.col-right {
-  position: relative;
+.site-container {
+  max-height: calc(100vh - 267px);
+  overflow-y: auto;
 }
 
-.ant-card {
-  margin: 10px 0;
+:deep(.ant-card) {
   box-shadow: unset;
 }
-
-.mode-switch {
-  display: flex;
-
-  .switch {
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-  }
-}
-
-.parse-error-alert-wrapper {
-  margin-bottom: 20px;
-}
-
-.domain-edit-container {
-  max-width: 800px;
-  margin: 0 auto;
-}
-
-.slide-fade-enter-active {
-  transition: all .3s ease-in-out;
-}
-
-.slide-fade-leave-active {
-  transition: all .3s cubic-bezier(1.0, 0.5, 0.8, 1.0);
-}
-
-.slide-fade-enter-from, .slide-fade-enter-to, .slide-fade-leave-to
-  /* .slide-fade-leave-active for below version 2.1.8 */ {
-  transform: translateX(10px);
-  opacity: 0;
-}
-
-.directive-params-wrapper {
-  margin: 10px 0;
-}
-
-.tab-content {
-  padding: 10px;
-}
 </style>

+ 10 - 15
app/src/views/site/cert/Cert.vue → app/src/views/site/site_edit/components/Cert/Cert.vue

@@ -1,24 +1,20 @@
 <script setup lang="ts">
 import type { Cert, CertificateInfo } from '@/api/cert'
-import type { NgxDirective } from '@/api/ngx'
 import type { SiteStatus } from '@/api/site'
+import CertInfo from '@/components/CertInfo/CertInfo.vue'
 import { ConfigStatus } from '@/constants'
-import CertInfo from '@/views/site/cert/CertInfo.vue'
-import ChangeCert from '@/views/site/cert/components/ChangeCert/ChangeCert.vue'
-import IssueCert from '@/views/site/cert/IssueCert.vue'
+import { useSiteEditorStore } from '../SiteEditor/store'
+import ChangeCert from './ChangeCert.vue'
+import IssueCert from './IssueCert.vue'
 
 const props = defineProps<{
   configName: string
-  currentServerIndex: number
   certInfo?: CertificateInfo[]
   siteStatus: SiteStatus
 }>()
 
-const current_server_directives = defineModel<NgxDirective[]>('current_server_directives')
-
-const enabled = defineModel<boolean>('enabled', {
-  default: () => false,
-})
+const editorStore = useSiteEditorStore()
+const { curServerDirectives } = storeToRefs(editorStore)
 
 const changedCerts: Ref<Cert[]> = ref([])
 
@@ -31,9 +27,9 @@ function handleCertChange(certs: Cert[]) {
   changedCerts.value = certs
 
   // Update NgxDirective
-  if (current_server_directives.value) {
+  if (curServerDirectives.value) {
     // Filter out existing certificate configurations
-    const filteredDirectives = current_server_directives.value
+    const filteredDirectives = curServerDirectives.value
       .filter(v => v.directive !== 'ssl_certificate' && v.directive !== 'ssl_certificate_key')
 
     // Add new certificate configuration
@@ -51,7 +47,7 @@ function handleCertChange(certs: Cert[]) {
     })
 
     // Update directives
-    current_server_directives.value = newDirectives
+    curServerDirectives.value = newDirectives
   }
 }
 </script>
@@ -99,8 +95,7 @@ function handleCertChange(certs: Cert[]) {
 
     <IssueCert
       v-if="siteStatus === ConfigStatus.Enabled || siteStatus === ConfigStatus.Maintenance"
-      v-model:enabled="enabled"
-      :config-name="configName"
+      :config-name
     />
   </div>
 </template>

+ 0 - 0
app/src/views/site/cert/components/ChangeCert/ChangeCert.vue → app/src/views/site/site_edit/components/Cert/ChangeCert.vue


+ 103 - 0
app/src/views/site/site_edit/components/Cert/IssueCert.vue

@@ -0,0 +1,103 @@
+<script setup lang="ts">
+import template from '@/api/template'
+import { useSiteEditorStore } from '@/views/site/site_edit/components/SiteEditor/store'
+import { Modal } from 'ant-design-vue'
+import ObtainCert from './ObtainCert.vue'
+
+const editorStore = useSiteEditorStore()
+const { ngxConfig, issuingCert, curServer, curDirectivesMap, autoCert } = storeToRefs(editorStore)
+
+const [modal, ContextHolder] = Modal.useModal()
+
+const obtainCert = useTemplateRef('obtainCert')
+
+const noServerName = computed(() => {
+  if (!curDirectivesMap.value.server_name)
+    return true
+
+  return curDirectivesMap.value.server_name.length === 0
+})
+
+watch(noServerName, () => {
+  autoCert.value = false
+})
+
+const update = ref(0)
+
+async function onchange() {
+  update.value++
+  await nextTick()
+
+  modal.confirm({
+    title: $gettext('Do you want to enable TLS?'),
+    content: $gettext('To make sure the certification auto-renewal can work normally, '
+      + 'we need to add a location which can proxy the request from authority to backend, '
+      + 'and we need to save this file and reload the Nginx. Are you sure you want to continue?'),
+    mask: false,
+    centered: true,
+    okText: $gettext('OK'),
+    cancelText: $gettext('Cancel'),
+    async onOk() {
+      await template.get_block('letsencrypt.conf').then(async r => {
+        if (!curServer.value.locations)
+          curServer.value.locations = []
+        else
+          curServer.value.locations = curServer.value.locations.filter(l => !l.path.includes('/.well-known/acme-challenge'))
+
+        await nextTick()
+
+        curServer.value.locations.push(...r.locations!)
+      })
+      await editorStore.save()
+
+      await nextTick()
+
+      obtainCert.value!.toggle(autoCert.value)
+    },
+  })
+}
+</script>
+
+<template>
+  <div>
+    <ContextHolder />
+    <ObtainCert
+      ref="obtainCert"
+      :key="update"
+      v-model:auto-cert="autoCert"
+      :no-server-name="noServerName"
+      :config-name="ngxConfig.name"
+    />
+    <div class="issue-cert">
+      <AFormItem :label="$gettext('Encrypt website with Let\'s Encrypt')">
+        <ASwitch
+          :loading="issuingCert"
+          :checked="autoCert"
+          :disabled="noServerName"
+          @change="onchange"
+        />
+      </AFormItem>
+    </div>
+  </div>
+</template>
+
+<style lang="less" scoped>
+.ant-tag {
+  margin: 0;
+}
+
+.issue-cert {
+  margin: 15px 0;
+}
+
+.switch-wrapper {
+  position: relative;
+
+  .text {
+    position: absolute;
+    top: 50%;
+    transform: translateY(-50%);
+    margin-left: 10px;
+  }
+}
+</style>

+ 47 - 44
app/src/views/site/cert/components/ObtainCert.vue → app/src/views/site/site_edit/components/Cert/ObtainCert.vue

@@ -1,52 +1,50 @@
 <script setup lang="ts">
 import type { AutoCertOptions } from '@/api/auto_cert'
 import type { CertificateResult } from '@/api/cert'
-import type { NgxConfig, NgxDirective } from '@/api/ngx'
 import type { PrivateKeyType } from '@/constants'
-import type { ComputedRef, Ref } from 'vue'
+import { AutoCertChallengeMethod } from '@/api/auto_cert'
 import site from '@/api/site'
-import AutoCertStepOne from '@/views/site/cert/components/AutoCertStepOne.vue'
-import ObtainCertLive from '@/views/site/cert/components/ObtainCertLive.vue'
+import AutoCertStepOne from '@/components/AutoCertForm/AutoCertForm.vue'
+import { PrivateKeyTypeEnum } from '@/constants'
 import { message, Modal } from 'ant-design-vue'
+import { useSiteEditorStore } from '../SiteEditor/store'
+import ObtainCertLive from './ObtainCertLive.vue'
 
 const props = defineProps<{
   configName: string
   noServerName?: boolean
 }>()
 
-const emit = defineEmits(['update:auto_cert'])
+const editorStore = useSiteEditorStore()
+const { ngxConfig, issuingCert, curServerDirectives, curDirectivesMap } = storeToRefs(editorStore)
+
+const autoCert = defineModel<boolean>('autoCert')
 
 const modalVisible = ref(false)
 const step = ref(1)
-const directivesMap = inject('directivesMap') as Ref<Record<string, NgxDirective[]>>
 
 const [modal, ContextHolder] = Modal.useModal()
 
 const data = ref({
   dns_credential_id: null,
-  challenge_method: 'http01',
+  challenge_method: AutoCertChallengeMethod.http01,
   code: '',
   configuration: {
     credentials: {},
     additional: {},
   },
-  key_type: 'P256',
+  key_type: PrivateKeyTypeEnum.P256,
 }) as Ref<AutoCertOptions>
 
 const modalClosable = ref(true)
 
-const save_config = inject('save_config') as () => Promise<void>
-const issuing_cert = inject('issuing_cert') as Ref<boolean>
-const ngx_config = inject('ngx_config') as NgxConfig
-const current_server_directives = inject('current_server_directives') as ComputedRef<NgxDirective[]>
-
 const name = computed(() => {
-  return directivesMap.value.server_name[0].params.trim()
+  return curDirectivesMap.value.server_name[0].params.trim()
 })
 
 const refObtainCertLive = useTemplateRef('refObtainCertLive')
 
-function issue_cert() {
+function issueCert() {
   refObtainCertLive.value?.issue_cert(
     props.configName,
     name.value.trim().split(' '),
@@ -55,14 +53,14 @@ function issue_cert() {
 }
 
 async function resolveCert({ ssl_certificate, ssl_certificate_key, key_type }: CertificateResult) {
-  directivesMap.value.ssl_certificate[0].params = ssl_certificate
-  directivesMap.value.ssl_certificate_key[0].params = ssl_certificate_key
-  await save_config()
-  change_auto_cert(true, key_type)
-  emit('update:auto_cert', true)
+  curDirectivesMap.value.ssl_certificate[0].params = ssl_certificate
+  curDirectivesMap.value.ssl_certificate_key[0].params = ssl_certificate_key
+  await editorStore.save()
+  changeAutoCert(true, key_type)
+  autoCert.value = true
 }
 
-function change_auto_cert(status: boolean, key_type?: PrivateKeyType) {
+function changeAutoCert(status: boolean, key_type?: PrivateKeyType) {
   if (status) {
     site.add_auto_cert(props.configName, {
       domains: name.value.trim().split(' '),
@@ -89,46 +87,51 @@ async function onchange(status: boolean) {
     job()
   }
   else {
-    ngx_config.servers.forEach(v => {
+    ngxConfig.value.servers.forEach(v => {
       v.locations = v?.locations?.filter(l => l.path !== '/.well-known/acme-challenge')
     })
-    await save_config()
-    change_auto_cert(status)
+    await editorStore.save()
+    changeAutoCert(status)
   }
 
-  emit('update:auto_cert', status)
+  autoCert.value = status
 }
 
-function job() {
+async function job() {
   modalClosable.value = false
-  issuing_cert.value = true
+  issuingCert.value = true
 
   if (props.noServerName) {
     message.error($gettext('server_name not found in directives'))
-    issuing_cert.value = false
+    issuingCert.value = false
 
     return
   }
 
-  const server_name_idx = directivesMap.value.server_name[0]?.idx ?? 0
+  const serverNameIdx = curDirectivesMap.value.server_name[0]?.idx ?? 0
 
-  if (!directivesMap.value.ssl_certificate) {
-    current_server_directives.value.splice(server_name_idx + 1, 0, {
+  if (!curServerDirectives.value)
+    curServerDirectives.value = []
+
+  if (!curDirectivesMap.value.ssl_certificate) {
+    curServerDirectives.value.splice(serverNameIdx + 1, 0, {
       directive: 'ssl_certificate',
       params: '',
     })
   }
 
-  nextTick(() => {
-    if (!directivesMap.value.ssl_certificate_key) {
-      const ssl_certificate_idx = directivesMap.value.ssl_certificate[0]?.idx ?? 0
+  await nextTick()
+
+  if (!curDirectivesMap.value.ssl_certificate_key) {
+    const sslCertificateIdx = curDirectivesMap.value.ssl_certificate[0]?.idx ?? 0
+
+    curServerDirectives.value.splice(sslCertificateIdx + 1, 0, {
+      directive: 'ssl_certificate_key',
+      params: '',
+    })
+  }
 
-      current_server_directives.value.splice(ssl_certificate_idx + 1, 0, {
-        directive: 'ssl_certificate_key',
-        params: '',
-      })
-    }
-  }).then(issue_cert)
+  issueCert()
 }
 function toggle(status: boolean) {
   if (status) {
@@ -155,14 +158,14 @@ defineExpose({
   toggle,
 })
 
-const can_next = computed(() => {
+const canNext = computed(() => {
   if (step.value === 2) {
     return false
   }
-  else if (data.value.challenge_method === 'http01') {
+  else if (data.value.challenge_method === AutoCertChallengeMethod.http01) {
     return true
   }
-  else if (data.value.challenge_method === 'dns01') {
+  else if (data.value.challenge_method === AutoCertChallengeMethod.dns01) {
     return data.value?.code ?? false
   }
   return false
@@ -201,7 +204,7 @@ function next() {
         />
       </template>
       <div
-        v-if="can_next"
+        v-if="canNext"
         class="control-btn"
       >
         <AButton

+ 4 - 2
app/src/views/site/cert/components/ObtainCertLive.vue → app/src/views/site/site_edit/components/Cert/ObtainCertLive.vue

@@ -3,6 +3,7 @@ import type { AutoCertOptions } from '@/api/auto_cert'
 import type { CertificateResult } from '@/api/cert'
 import type { Ref } from 'vue'
 import websocket from '@/lib/websocket'
+import { useSiteEditorStore } from '../SiteEditor/store'
 
 const props = defineProps<{
   options: AutoCertOptions
@@ -11,7 +12,8 @@ const props = defineProps<{
 const modalVisible = defineModel<boolean>('modalVisible')
 const modalClosable = defineModel<boolean>('modalClosable')
 
-const issuing_cert = inject('issuing_cert') as Ref<boolean>
+const editorStore = useSiteEditorStore()
+const { issuingCert } = storeToRefs(editorStore)
 
 const progressStrokeColor = {
   from: '#108ee9',
@@ -78,7 +80,7 @@ async function issue_cert(config_name: string, server_name: string[], key_type:
           break
         default:
           modalClosable.value = true
-          issuing_cert.value = false
+          issuingCert.value = false
 
           if (r.status === 'success' && r.ssl_certificate !== undefined && r.ssl_certificate_key !== undefined) {
             progressStatus.value = 'success'

+ 3 - 0
app/src/views/site/site_edit/components/Cert/index.ts

@@ -0,0 +1,3 @@
+import Cert from './Cert.vue'
+
+export default Cert

+ 0 - 0
app/src/views/site/site_edit/components/ConfigName.vue → app/src/views/site/site_edit/components/ConfigName/ConfigName.vue


+ 3 - 0
app/src/views/site/site_edit/components/ConfigName/index.ts

@@ -0,0 +1,3 @@
+import ConfigName from './ConfigName.vue'
+
+export default ConfigName

+ 125 - 0
app/src/views/site/site_edit/components/ConfigTemplate/ConfigTemplate.vue

@@ -0,0 +1,125 @@
+<script setup lang="ts">
+import type { Template } from '@/api/template'
+import template from '@/api/template'
+import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
+import { DirectiveEditor, LocationEditor, useNgxConfigStore } from '@/components/NgxConfigEditor'
+import { useSettingsStore } from '@/pinia'
+import { storeToRefs } from 'pinia'
+import { useConfigTemplateStore } from './store'
+import TemplateForm from './TemplateForm.vue'
+
+const { language } = storeToRefs(useSettingsStore())
+
+const ngxConfigStore = useNgxConfigStore()
+const { ngxConfig, curServer } = storeToRefs(ngxConfigStore)
+
+const configTemplateStore = useConfigTemplateStore()
+const { data } = storeToRefs(configTemplateStore)
+
+const blocks = ref<Template[]>([])
+const visible = ref(false)
+const name = ref('')
+
+function getBlockList() {
+  template.get_block_list().then(r => {
+    blocks.value = r.data
+  })
+}
+
+getBlockList()
+
+function view(n: string) {
+  visible.value = true
+  name.value = n
+  template.get_block(n).then(r => {
+    data.value = r
+  })
+}
+
+const transDescription = computed(() => {
+  return (item: { description: { [key: string]: string } }) =>
+    item.description?.[language.value] ?? item.description?.en ?? ''
+})
+
+async function add() {
+  if (data.value?.custom)
+    ngxConfig.value.custom += `\n${data.value.custom}`
+
+  ngxConfig.value.custom = ngxConfig.value.custom?.trim()
+
+  if (data.value?.locations)
+    curServer.value?.locations?.push(...data.value.locations)
+
+  if (data.value?.directives)
+    curServer.value?.directives?.push(...data.value.directives)
+
+  visible.value = false
+}
+</script>
+
+<template>
+  <div>
+    <div class="config-list-wrapper">
+      <AList :data-source="blocks">
+        <template #renderItem="{ item }">
+          <AListItem>
+            <AListItemMeta
+              :title="item.name"
+            >
+              <template #description>
+                <p class="mt-4">
+                  {{ $gettext('Author') }}: {{ item.author }}
+                </p>
+                <p class="mb-0">
+                  {{ $gettext('Description') }}: {{ transDescription(item) }}
+                </p>
+              </template>
+            </AListItemMeta>
+            <template #extra>
+              <AButton
+                type="link"
+                @click="view(item.filename)"
+              >
+                {{ $gettext('View') }}
+              </AButton>
+            </template>
+          </AListItem>
+        </template>
+      </AList>
+    </div>
+    <AModal
+      v-model:open="visible"
+      :title="data.name"
+      :mask="false"
+      :ok-text="$gettext('Add')"
+      @ok="add"
+    >
+      <p>{{ $gettext('Author') }}: {{ data.author }}</p>
+      <p>{{ $gettext('Description') }}: {{ transDescription(data) }}</p>
+      <TemplateForm v-model="data.variables" />
+      <div
+        v-if="data.custom"
+        class="mb-4"
+      >
+        <h3>{{ $gettext('Custom') }}</h3>
+        <CodeEditor
+          v-model:content="data.custom"
+          default-height="150px"
+        />
+      </div>
+      <DirectiveEditor
+        v-if="data.directives"
+        readonly
+      />
+      <LocationEditor
+        v-if="data.locations"
+        :locations="data.locations"
+        readonly
+      />
+    </AModal>
+  </div>
+</template>
+
+<style lang="less" scoped>
+
+</style>

+ 1 - 1
app/src/views/site/ngx_conf/config_template/TemplateForm.vue → app/src/views/site/site_edit/components/ConfigTemplate/TemplateForm.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { Variable } from '@/api/template'
-import TemplateFormItem from '@/views/site/ngx_conf/config_template/TemplateFormItem.vue'
+import TemplateFormItem from './TemplateFormItem.vue'
 
 const data = defineModel<Record<string, Variable>>({
   default: () => {},

+ 7 - 7
app/src/views/site/ngx_conf/config_template/TemplateFormItem.vue → app/src/views/site/site_edit/components/ConfigTemplate/TemplateFormItem.vue

@@ -1,24 +1,24 @@
 <script setup lang="ts">
 import type { Variable } from '@/api/template'
 import { useSettingsStore } from '@/pinia'
-import _ from 'lodash'
 import { storeToRefs } from 'pinia'
+import { useConfigTemplateStore } from './store'
 
 const data = defineModel<Variable>({
-  default: () => {},
+  default: reactive({}),
 })
 
 const { language } = storeToRefs(useSettingsStore())
 
-const trans_name = computed(() => {
+const configTemplateStore = useConfigTemplateStore()
+
+const transName = computed(() => {
   return data.value?.name?.[language.value] ?? data.value?.name?.en ?? ''
 })
 
-const build_template = inject('build_template') as () => void
-
 const value = computed(() => data.value.value)
 
-watch(value, _.throttle(build_template, 500))
+watch(value, configTemplateStore.buildTemplate)
 
 const selectOptions = computed(() => {
   return Object.keys(data.value?.mask || {}).map(k => {
@@ -33,7 +33,7 @@ const selectOptions = computed(() => {
 </script>
 
 <template>
-  <AFormItem :label="trans_name">
+  <AFormItem :label="transName">
     <AInput
       v-if="data.type === 'string'"
       v-model:value="data.value"

+ 3 - 0
app/src/views/site/site_edit/components/ConfigTemplate/index.ts

@@ -0,0 +1,3 @@
+import ConfigTemplate from './ConfigTemplate.vue'
+
+export default ConfigTemplate

+ 26 - 0
app/src/views/site/site_edit/components/ConfigTemplate/store.ts

@@ -0,0 +1,26 @@
+import type { Template } from '@/api/template'
+import template from '@/api/template'
+import { debounce } from 'lodash'
+import { defineStore } from 'pinia'
+
+export const useConfigTemplateStore = defineStore('configTemplate', () => {
+  const data = ref<Template>({} as Template)
+
+  const variables = computed(() => data.value?.variables ?? {})
+
+  function __buildTemplate(name: string) {
+    template.build_block(name, variables.value).then(r => {
+      data.value.directives = r.directives
+      data.value.locations = r.locations
+      data.value.custom = r.custom
+    })
+  }
+
+  const buildTemplate = debounce(__buildTemplate, 500)
+
+  return {
+    data,
+    variables,
+    buildTemplate,
+  }
+})

+ 120 - 0
app/src/views/site/site_edit/components/EnableTLS/EnableTLS.vue

@@ -0,0 +1,120 @@
+<script setup lang="ts">
+import type { CheckedType } from '@/types'
+import template from '@/api/template'
+import { useSiteEditorStore } from '@/views/site/site_edit/components/SiteEditor/store'
+import { Modal } from 'ant-design-vue'
+
+const [modal, ContextHolder] = Modal.useModal()
+
+const editorStore = useSiteEditorStore()
+const { ngxConfig, curServerIdx, curDirectivesMap } = storeToRefs(editorStore)
+
+function confirmChangeTLS(status: CheckedType) {
+  modal.confirm({
+    title: $gettext('Do you want to enable TLS?'),
+    content: $gettext('To make sure the certification auto-renewal can work normally, '
+      + 'we need to add a location which can proxy the request from authority to backend, '
+      + 'and we need to save this file and reload the Nginx. Are you sure you want to continue?'),
+    mask: false,
+    centered: true,
+    okText: $gettext('OK'),
+    cancelText: $gettext('Cancel'),
+    async onOk() {
+      await template.get_block('letsencrypt.conf').then(async r => {
+        const first = ngxConfig.value.servers[0]
+        if (!first.locations)
+          first.locations = []
+        else
+          first.locations = first.locations.filter(l => !l.path.includes('/.well-known/acme-challenge'))
+
+        await nextTick()
+
+        first.locations?.push(...r.locations!)
+      })
+      await editorStore.save()
+
+      changeTLS(status)
+    },
+  })
+}
+
+function changeTLS(status: CheckedType) {
+  if (status) {
+    // deep copy servers[0] to servers[1]
+    const server = JSON.parse(JSON.stringify(ngxConfig.value.servers[0]))
+
+    ngxConfig.value.servers.push(server)
+
+    curServerIdx.value = 1
+
+    const servers = ngxConfig.value.servers
+
+    let i = 0
+    while (i < (servers?.[1].directives?.length ?? 0)) {
+      const v = servers?.[1]?.directives?.[i]
+      if (v?.directive === 'listen')
+        servers[1]?.directives?.splice(i, 1)
+      else
+        i++
+    }
+
+    servers?.[1]?.directives?.splice(0, 0, {
+      directive: 'listen',
+      params: '443 ssl',
+    }, {
+      directive: 'listen',
+      params: '[::]:443 ssl',
+    })
+
+    const serverNameIdx = curDirectivesMap.value?.server_name?.[0].idx ?? 0
+
+    if (!curDirectivesMap.value.ssl_certificate) {
+      servers?.[1]?.directives?.splice(serverNameIdx + 1, 0, {
+        directive: 'ssl_certificate',
+        params: '',
+      })
+    }
+
+    setTimeout(() => {
+      if (!curDirectivesMap.value.ssl_certificate_key) {
+        servers?.[1]?.directives?.splice(serverNameIdx + 2, 0, {
+          directive: 'ssl_certificate_key',
+          params: '',
+        })
+      }
+    }, 100)
+  }
+  else {
+    // remove servers[1]
+    curServerIdx.value = 0
+    if (ngxConfig.value.servers.length === 2)
+      ngxConfig.value.servers.splice(1, 1)
+  }
+}
+
+const supportSSL = computed(() => {
+  const servers = ngxConfig.value.servers
+  for (const server_key in servers) {
+    for (const k in servers[server_key].directives) {
+      const v = servers?.[server_key]?.directives?.[Number.parseInt(k)]
+      if (v?.directive === 'listen' && v?.params?.indexOf('ssl') > 0)
+        return true
+    }
+  }
+
+  return false
+})
+</script>
+
+<template>
+  <div>
+    <ContextHolder />
+
+    <AFormItem
+      v-if="!supportSSL"
+      :label="$gettext('Enable TLS')"
+    >
+      <ASwitch @change="confirmChangeTLS" />
+    </AFormItem>
+  </div>
+</template>

+ 3 - 0
app/src/views/site/site_edit/components/EnableTLS/index.ts

@@ -0,0 +1,3 @@
+import EnableTLS from './EnableTLS.vue'
+
+export default EnableTLS

+ 92 - 0
app/src/views/site/site_edit/components/RightPanel/Basic.vue

@@ -0,0 +1,92 @@
+<script setup lang="ts">
+import type { SiteStatus } from '@/api/site'
+import envGroup from '@/api/env_group'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
+import StdSelector from '@/components/StdDesign/StdDataEntry/components/StdSelector.vue'
+import { formatDateTime } from '@/lib/helper'
+import { useSettingsStore } from '@/pinia'
+import envGroupColumns from '@/views/environments/group/columns'
+import SiteStatusSegmented from '@/views/site/components/SiteStatusSegmented.vue'
+import ConfigName from '@/views/site/site_edit/components/ConfigName/ConfigName.vue'
+import { InfoCircleOutlined } from '@ant-design/icons-vue'
+import { useSiteEditorStore } from '../SiteEditor/store'
+
+const settings = useSettingsStore()
+
+const editorStore = useSiteEditorStore()
+const { name, data } = storeToRefs(editorStore)
+
+function handleStatusChanged(event: { status: SiteStatus }) {
+  data.value.status = event.status
+}
+</script>
+
+<template>
+  <div>
+    <div class="mb-6">
+      <AForm layout="vertical">
+        <AFormItem :label="$gettext('Status')">
+          <SiteStatusSegmented
+            v-model="data.status"
+            :site-name="name"
+            @status-changed="handleStatusChanged"
+          />
+        </AFormItem>
+        <AFormItem :label="$gettext('Name')">
+          <ConfigName v-if="name" :name />
+        </AFormItem>
+        <AFormItem :label="$gettext('Updated at')">
+          {{ formatDateTime(data.modified_at) }}
+        </AFormItem>
+        <AFormItem :label="$gettext('Node Group')">
+          <StdSelector
+            v-model:selected-key="data.env_group_id"
+            :api="envGroup"
+            :columns="envGroupColumns"
+            record-value-index="name"
+            selection-type="radio"
+          />
+        </AFormItem>
+      </AForm>
+    </div>
+
+    <div v-if="!settings.is_remote">
+      <div class="flex items-center justify-between mb-4">
+        <div>
+          {{ $gettext('Synchronization') }}
+        </div>
+        <APopover placement="bottomRight" :title="$gettext('Sync strategy')">
+          <template #content>
+            <div class="max-w-200px mb-2">
+              {{ $gettext('When you enable/disable, delete, or save this site, '
+                + 'the nodes set in the Node Group and the nodes selected below will be synchronized.') }}
+            </div>
+            <div class="max-w-200px">
+              {{ $gettext('Note, if the configuration file include other configurations or certificates, '
+                + 'please synchronize them to the remote nodes in advance.') }}
+            </div>
+          </template>
+          <div class="text-trueGray-600">
+            <InfoCircleOutlined class="mr-1" />
+            {{ $gettext('Sync strategy') }}
+          </div>
+        </APopover>
+      </div>
+      <NodeSelector
+        v-model:target="data.sync_node_ids"
+        class="mb-4"
+        hidden-local
+      />
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less">
+:deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
+  padding: 0;
+}
+
+:deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
+  padding: 0 0 10px 0;
+}
+</style>

+ 31 - 0
app/src/views/site/site_edit/components/RightPanel/Chat.vue

@@ -0,0 +1,31 @@
+<script setup lang="ts">
+import ChatGPT from '@/components/ChatGPT'
+import { useSiteEditorStore } from '../SiteEditor/store'
+
+const editorStore = useSiteEditorStore()
+const {
+  configText,
+  filepath,
+  historyChatgptRecord,
+} = storeToRefs(editorStore)
+</script>
+
+<template>
+  <div class="mt--4">
+    <ChatGPT
+      v-model:history-messages="historyChatgptRecord"
+      :content="configText"
+      :path="filepath"
+    />
+  </div>
+</template>
+
+<style scoped lang="less">
+:deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
+  padding: 0;
+}
+
+:deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
+  padding: 0 0 10px 0;
+}
+</style>

+ 9 - 0
app/src/views/site/site_edit/components/RightPanel/ConfigTemplate.vue

@@ -0,0 +1,9 @@
+<script lang="ts" setup>
+import ConfigTemplate from '../ConfigTemplate'
+</script>
+
+<template>
+  <div>
+    <ConfigTemplate />
+  </div>
+</template>

+ 72 - 0
app/src/views/site/site_edit/components/RightPanel/RightPanel.vue

@@ -0,0 +1,72 @@
+<script setup lang="ts">
+import { useSiteEditorStore } from '../SiteEditor/store'
+import Basic from './Basic.vue'
+import Chat from './Chat.vue'
+import ConfigTemplate from './ConfigTemplate.vue'
+
+const activeKey = ref('basic')
+
+const editorStore = useSiteEditorStore()
+const { advanceMode } = storeToRefs(editorStore)
+
+watch(advanceMode, val => {
+  if (val) {
+    activeKey.value = 'basic'
+  }
+})
+</script>
+
+<template>
+  <div class="right-settings-container">
+    <ACard
+      class="right-settings"
+      :bordered="false"
+    >
+      <ATabs
+        v-model:active-key="activeKey"
+        class="mb-24px"
+        size="small"
+      >
+        <ATabPane key="basic" :tab="$gettext('Basic')">
+          <Basic />
+        </ATabPane>
+        <ATabPane
+          v-if="!advanceMode"
+          key="config-template"
+          :tab="$gettext('Config Template')"
+        >
+          <ConfigTemplate />
+        </ATabPane>
+        <ATabPane key="chat" :tab="$gettext('Chat')">
+          <Chat />
+        </ATabPane>
+      </ATabs>
+    </ACard>
+  </div>
+</template>
+
+<style scoped lang="less">
+.right-settings-container {
+  position: relative;
+
+  .right-settings {
+    max-height: calc(100vh - 323px);
+    overflow-y: scroll;
+    position: relative;
+  }
+
+  :deep(.ant-card-body) {
+    padding: 19.5px 24px;
+  }
+
+  :deep(.ant-tabs-nav) {
+    margin: 0;
+  }
+}
+
+:deep(.ant-tabs-content) {
+  padding-top: 24px;
+  max-height: calc(100vh - 425px);
+  overflow-y: scroll;
+}
+</style>

+ 3 - 0
app/src/views/site/site_edit/components/RightPanel/index.ts

@@ -0,0 +1,3 @@
+import RightPanel from './RightPanel.vue'
+
+export default RightPanel

+ 214 - 0
app/src/views/site/site_edit/components/SiteEditor/SiteEditor.vue

@@ -0,0 +1,214 @@
+<script setup lang="ts">
+import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
+import ConfigHistory from '@/components/ConfigHistory'
+import FooterToolBar from '@/components/FooterToolbar'
+import NgxConfigEditor from '@/components/NgxConfigEditor'
+import { ConfigStatus } from '@/constants'
+import Cert from '@/views/site/site_edit/components/Cert'
+import EnableTLS from '@/views/site/site_edit/components/EnableTLS'
+import { HistoryOutlined } from '@ant-design/icons-vue'
+import { message } from 'ant-design-vue'
+import { useSiteEditorStore } from './store'
+
+const route = useRoute()
+
+const name = computed(() => decodeURIComponent(route.params?.name?.toString() ?? ''))
+
+const editorStore = useSiteEditorStore()
+const {
+  data,
+  parseErrorStatus,
+  parseErrorMessage,
+  filepath,
+  configText,
+  loading,
+  saving,
+  certInfoMap,
+  advanceMode,
+  curSupportSSL,
+} = storeToRefs(editorStore)
+
+const showHistory = ref(false)
+
+onMounted(() => {
+  editorStore.init(name.value)
+})
+
+async function save() {
+  try {
+    await editorStore.save()
+    message.success($gettext('Saved successfully'))
+  }
+  catch {
+    // do nothing
+  }
+}
+</script>
+
+<template>
+  <ACard class="mb-4 site-edit-container" :bordered="false">
+    <template #title>
+      <span style="margin-right: 10px">{{ $gettext('Edit %{n}', { n: name }) }}</span>
+      <ATag
+        v-if="data.status === ConfigStatus.Enabled"
+        color="blue"
+      >
+        {{ $gettext('Enabled') }}
+      </ATag>
+      <ATag
+        v-else-if="data.status === ConfigStatus.Disabled"
+        color="red"
+      >
+        {{ $gettext('Disabled') }}
+      </ATag>
+      <ATag
+        v-else-if="data.status === ConfigStatus.Maintenance"
+        color="orange"
+      >
+        {{ $gettext('Maintenance') }}
+      </ATag>
+    </template>
+    <template #extra>
+      <ASpace>
+        <AButton
+          v-if="filepath"
+          type="link"
+          @click="showHistory = true"
+        >
+          <template #icon>
+            <HistoryOutlined />
+          </template>
+          {{ $gettext('History') }}
+        </AButton>
+        <div class="mode-switch">
+          <div class="switch">
+            <ASwitch
+              size="small"
+              :disabled="parseErrorStatus"
+              :checked="advanceMode"
+              :loading="loading"
+              @change="editorStore.handleModeChange"
+            />
+          </div>
+          <template v-if="advanceMode">
+            <div>{{ $gettext('Advance Mode') }}</div>
+          </template>
+          <template v-else>
+            <div>{{ $gettext('Basic Mode') }}</div>
+          </template>
+        </div>
+      </ASpace>
+    </template>
+
+    <Transition name="slide-fade">
+      <div
+        v-if="advanceMode"
+        key="advance"
+      >
+        <div
+          v-if="parseErrorStatus"
+          class="parse-error-alert-wrapper"
+        >
+          <AAlert
+            :message="$gettext('Nginx Configuration Parse Error')"
+            :description="parseErrorMessage"
+            type="error"
+            show-icon
+          />
+        </div>
+        <div>
+          <CodeEditor v-model:content="configText" />
+        </div>
+      </div>
+
+      <div
+        v-else
+        key="basic"
+        class="domain-edit-container"
+      >
+        <EnableTLS />
+        <NgxConfigEditor
+          :cert-info="certInfoMap"
+          :status="data.status"
+        >
+          <template #tab-content="{ tabIdx }">
+            <Cert
+              v-if="curSupportSSL"
+              class="mb-4"
+              :site-status="data.status"
+              :config-name="name"
+              :cert-info="certInfoMap?.[tabIdx]"
+            />
+          </template>
+        </NgxConfigEditor>
+      </div>
+    </Transition>
+
+    <FooterToolBar>
+      <ASpace>
+        <AButton @click="$router.push('/sites/list')">
+          {{ $gettext('Back') }}
+        </AButton>
+        <AButton
+          type="primary"
+          :loading="saving"
+          @click="save"
+        >
+          {{ $gettext('Save') }}
+        </AButton>
+      </ASpace>
+    </FooterToolBar>
+
+    <ConfigHistory
+      v-model:visible="showHistory"
+      v-model:current-content="configText"
+      :filepath="filepath"
+    />
+  </ACard>
+</template>
+
+<style lang="less" scoped>
+.mode-switch {
+  display: flex;
+
+  .switch {
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+  }
+}
+
+.domain-edit-container {
+    max-width: 800px;
+    margin: 0 auto;
+}
+
+.parse-error-alert-wrapper {
+  margin-bottom: 20px;
+}
+
+.site-edit-container {
+  :deep(.ant-card-body) {
+    max-height: calc(100vh - 375px);
+    overflow-y: scroll;
+  }
+}
+
+.domain-edit-container {
+  max-width: 800px;
+  margin: 0 auto;
+}
+
+.slide-fade-enter-active {
+  transition: all .3s ease-in-out;
+}
+
+.slide-fade-leave-active {
+  transition: all .3s cubic-bezier(1.0, 0.5, 0.8, 1.0);
+}
+
+.slide-fade-enter-from, .slide-fade-enter-to, .slide-fade-leave-to {
+  transform: translateX(10px);
+  opacity: 0;
+}
+</style>

+ 4 - 0
app/src/views/site/site_edit/components/SiteEditor/index.ts

@@ -0,0 +1,4 @@
+import SiteEditor from './SiteEditor.vue'
+
+export { SiteEditor }
+export default SiteEditor

+ 174 - 0
app/src/views/site/site_edit/components/SiteEditor/store.ts

@@ -0,0 +1,174 @@
+import type { CertificateInfo } from '@/api/cert'
+import type { ChatComplicationMessage } from '@/api/openai'
+import type { Site } from '@/api/site'
+import type { CheckedType } from '@/types'
+import config from '@/api/config'
+import ngx from '@/api/ngx'
+import site from '@/api/site'
+import { useNgxConfigStore } from '@/components/NgxConfigEditor'
+
+export const useSiteEditorStore = defineStore('siteEditor', () => {
+  const name = ref('')
+  const advanceMode = ref(false)
+  const parseErrorStatus = ref(false)
+  const parseErrorMessage = ref('')
+  const data = ref({}) as Ref<Site>
+  const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
+  const loading = ref(true)
+  const saving = ref(false)
+  const autoCert = ref(false)
+  const certInfoMap = ref({}) as Ref<Record<number, CertificateInfo[]>>
+  const filename = ref('')
+  const filepath = ref('')
+  const issuingCert = ref(false)
+
+  const ngxConfigStore = useNgxConfigStore()
+  const { ngxConfig, configText, curServerIdx, curServer, curServerDirectives, curDirectivesMap } = storeToRefs(ngxConfigStore)
+
+  async function init(_name: string) {
+    loading.value = true
+    name.value = _name
+    await nextTick()
+
+    if (name.value) {
+      try {
+        const r = await site.get(name.value)
+        handleResponse(r)
+      }
+      catch (error) {
+        handleParseError(error as { error?: string, message: string })
+      }
+    }
+    else {
+      historyChatgptRecord.value = []
+    }
+    loading.value = false
+  }
+
+  async function buildConfig() {
+    return ngx.build_config(ngxConfig.value).then(r => {
+      configText.value = r.content
+    })
+  }
+
+  async function save() {
+    saving.value = true
+
+    try {
+      if (!advanceMode.value) {
+        await buildConfig()
+      }
+
+      const response = await site.save(name.value, {
+        content: configText.value,
+        overwrite: true,
+        env_group_id: data.value.env_group_id,
+        sync_node_ids: data.value.sync_node_ids,
+        post_action: 'reload_nginx',
+      })
+
+      handleResponse(response)
+    }
+    catch (error) {
+      handleParseError(error as { error?: string, message: string })
+    }
+    finally {
+      saving.value = false
+    }
+  }
+
+  function handleParseError(e: { error?: string, message: string }) {
+    console.error(e)
+    parseErrorStatus.value = true
+    parseErrorMessage.value = e.message
+    config.get(`sites-available/${name.value}`).then(r => {
+      configText.value = r.content
+    })
+  }
+
+  async function handleResponse(r: Site) {
+    if (r.advanced)
+      advanceMode.value = true
+
+    parseErrorStatus.value = false
+    parseErrorMessage.value = ''
+    filename.value = r.name
+    filepath.value = r.filepath
+    configText.value = r.config
+    autoCert.value = r.auto_cert
+    historyChatgptRecord.value = r.chatgpt_messages
+    data.value = r
+    autoCert.value = r.auto_cert
+    certInfoMap.value = r.cert_info || {}
+    Object.assign(ngxConfig, r.tokenized)
+
+    const ngxConfigStore = useNgxConfigStore()
+
+    if (r.tokenized)
+      ngxConfigStore.setNgxConfig(r.tokenized)
+  }
+
+  async function handleModeChange(advanced: CheckedType) {
+    loading.value = true
+
+    try {
+      await site.advance_mode(name.value, { advanced: advanced as boolean })
+      advanceMode.value = advanced as boolean
+      if (advanced) {
+        await buildConfig()
+      }
+      else {
+        let r = await site.get(name.value)
+        await handleResponse(r)
+        r = await ngx.tokenize_config(configText.value)
+        Object.assign(ngxConfig, {
+          ...r,
+          name: name.value,
+        })
+      }
+    }
+    // eslint-disable-next-line ts/no-explicit-any
+    catch (e: any) {
+      handleParseError(e)
+    }
+
+    loading.value = false
+  }
+
+  const curSupportSSL = computed(() => {
+    if (curDirectivesMap.value.listen) {
+      for (const v of curDirectivesMap.value.listen) {
+        if (v?.params.indexOf('ssl') > 0)
+          return true
+      }
+    }
+
+    return false
+  })
+
+  return {
+    name,
+    advanceMode,
+    parseErrorStatus,
+    parseErrorMessage,
+    data,
+    historyChatgptRecord,
+    loading,
+    saving,
+    autoCert,
+    certInfoMap,
+    ngxConfig,
+    curServerIdx,
+    curServer,
+    curServerDirectives,
+    curDirectivesMap,
+    filename,
+    filepath,
+    configText,
+    issuingCert,
+    curSupportSSL,
+    init,
+    save,
+    handleModeChange,
+  }
+})

+ 17 - 277
app/src/views/stream/StreamEdit.vue

@@ -1,283 +1,43 @@
 <script setup lang="ts">
-import type { NgxConfig } from '@/api/ngx'
-import type { ChatComplicationMessage } from '@/api/openai'
-import type { Stream } from '@/api/stream'
-import type { CheckedType } from '@/types'
-
-import type { Ref } from 'vue'
-import config from '@/api/config'
-import ngx from '@/api/ngx'
-import stream from '@/api/stream'
-import CodeEditor from '@/components/CodeEditor/CodeEditor.vue'
-import { ConfigHistory } from '@/components/ConfigHistory'
-import FooterToolBar from '@/components/FooterToolbar/FooterToolBar.vue'
-import NgxConfigEditor from '@/views/site/ngx_conf/NgxConfigEditor.vue'
-import RightSettings from '@/views/stream/components/RightSettings.vue'
-import { HistoryOutlined } from '@ant-design/icons-vue'
-import { message } from 'ant-design-vue'
+import RightSettings from '@/views/stream/components/RightPanel'
+import StreamEditor from '@/views/stream/components/StreamEditor.vue'
+import { useStreamEditorStore } from '@/views/stream/store'
 
 const route = useRoute()
-const router = useRouter()
 
 const name = computed(() => decodeURIComponent(route.params?.name?.toString() ?? ''))
 
-const ngxConfig: NgxConfig = reactive({
-  name: '',
-  upstreams: [],
-  servers: [],
-})
-
-const enabled = ref(false)
-const configText = ref('')
-const advanceModeRef = ref(false)
-const saving = ref(false)
-const filename = ref('')
-const filepath = ref('')
-const parseErrorStatus = ref(false)
-const parseErrorMessage = ref('')
-const data = ref<Stream>({} as Stream)
-
-const showHistory = ref(false)
-
-init()
+const store = useStreamEditorStore()
 
-const advanceMode = computed({
-  get() {
-    return advanceModeRef.value || parseErrorStatus.value
-  },
-  set(v: boolean) {
-    advanceModeRef.value = v
-  },
+onMounted(() => {
+  store.init(name.value)
 })
-
-const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
-
-function handleResponse(r: Stream) {
-  if (r.advanced)
-    advanceMode.value = true
-
-  if (r.advanced)
-    advanceMode.value = true
-
-  parseErrorStatus.value = false
-  parseErrorMessage.value = ''
-  filename.value = r.name
-  filepath.value = r.filepath
-  configText.value = r.config
-  enabled.value = r.enabled
-  historyChatgptRecord.value = r.chatgpt_messages
-  data.value = r
-  Object.assign(ngxConfig, r.tokenized)
-}
-
-function init() {
-  if (name.value) {
-    stream.get(name.value).then(r => {
-      handleResponse(r)
-    }).catch(handleParseError)
-  }
-  else {
-    historyChatgptRecord.value = []
-  }
-}
-
-function handleParseError(e: { error?: string, message: string }) {
-  console.error(e)
-  parseErrorStatus.value = true
-  parseErrorMessage.value = e.message
-  config.get(`streams-available/${name.value}`).then(r => {
-    configText.value = r.content
-  })
-}
-
-function onModeChange(advanced: CheckedType) {
-  stream.advance_mode(name.value, { advanced: advanced as boolean }).then(() => {
-    advanceMode.value = advanced as boolean
-    if (advanced) {
-      buildConfig()
-    }
-    else {
-      return ngx.tokenize_config(configText.value).then(r => {
-        Object.assign(ngxConfig, r)
-      }).catch(handleParseError)
-    }
-  })
-}
-
-async function buildConfig() {
-  return ngx.build_config(ngxConfig).then(r => {
-    configText.value = r.content
-  })
-}
-
-async function save() {
-  saving.value = true
-
-  if (!advanceMode.value) {
-    try {
-      await buildConfig()
-    }
-    catch {
-      saving.value = false
-      message.error($gettext('Failed to save, syntax error(s) was detected in the configuration.'))
-
-      return
-    }
-  }
-
-  return stream.save(name.value, {
-    name: filename.value || name.value,
-    content: configText.value,
-    overwrite: true,
-    env_group_id: data.value?.env_group_id,
-    sync_node_ids: data.value?.sync_node_ids,
-    post_action: 'reload_nginx',
-  }).then(r => {
-    handleResponse(r)
-    router.push({
-      path: `/streams/${encodeURIComponent(filename.value)}`,
-      query: route.query,
-    })
-    message.success($gettext('Saved successfully'))
-  }).catch(handleParseError).finally(() => {
-    saving.value = false
-  })
-}
-
-function openHistory() {
-  showHistory.value = true
-}
-
-provide('save_config', save)
-provide('configText', configText)
-provide('ngx_config', ngxConfig)
-provide('history_chatgpt_record', historyChatgptRecord)
-provide('enabled', enabled)
-provide('name', name)
-provide('filename', filename)
-provide('filepath', filepath)
-provide('data', data)
 </script>
 
 <template>
-  <ARow :gutter="16">
+  <ARow :gutter="{ xs: 0, sm: 16 }">
     <ACol
       :xs="24"
       :sm="24"
-      :md="18"
+      :md="24"
+      :lg="16"
+      :xl="17"
     >
-      <ACard :bordered="false">
-        <template #title>
-          <span style="margin-right: 10px">{{ $gettext('Edit %{n}', { n: name }) }}</span>
-          <ATag
-            v-if="enabled"
-            color="blue"
-          >
-            {{ $gettext('Enabled') }}
-          </ATag>
-          <ATag
-            v-else
-            color="orange"
-          >
-            {{ $gettext('Disabled') }}
-          </ATag>
-        </template>
-        <template #extra>
-          <ASpace>
-            <AButton
-              v-if="filepath"
-              type="link"
-              @click="openHistory"
-            >
-              <template #icon>
-                <HistoryOutlined />
-              </template>
-              {{ $gettext('History') }}
-            </AButton>
-            <div class="mode-switch">
-              <div class="switch">
-                <ASwitch
-                  size="small"
-                  :disabled="parseErrorStatus"
-                  :checked="advanceMode"
-                  @change="onModeChange"
-                />
-              </div>
-              <template v-if="advanceMode">
-                <div>{{ $gettext('Advance Mode') }}</div>
-              </template>
-              <template v-else>
-                <div>{{ $gettext('Basic Mode') }}</div>
-              </template>
-            </div>
-          </ASpace>
-        </template>
-
-        <Transition name="slide-fade">
-          <div
-            v-if="advanceMode"
-            key="advance"
-          >
-            <div
-              v-if="parseErrorStatus"
-              class="parse-error-alert-wrapper"
-            >
-              <AAlert
-                :message="$gettext('Nginx Configuration Parse Error')"
-                :description="parseErrorMessage"
-                type="error"
-                show-icon
-              />
-            </div>
-            <div>
-              <CodeEditor v-model:content="configText" />
-            </div>
-          </div>
-
-          <div
-            v-else
-            key="basic"
-            class="domain-edit-container"
-          >
-            <NgxConfigEditor
-              :enabled="enabled"
-              context="stream"
-              @callback="save"
-            />
-          </div>
-        </Transition>
-      </ACard>
+      <div>
+        <StreamEditor />
+      </div>
     </ACol>
 
     <ACol
       class="col-right"
       :xs="24"
       :sm="24"
-      :md="6"
+      :md="24"
+      :lg="8"
+      :xl="7"
     >
       <RightSettings />
     </ACol>
-
-    <FooterToolBar>
-      <ASpace>
-        <AButton @click="$router.push('/streams')">
-          {{ $gettext('Back') }}
-        </AButton>
-        <AButton
-          type="primary"
-          :loading="saving"
-          @click="save"
-        >
-          {{ $gettext('Save') }}
-        </AButton>
-      </ASpace>
-    </FooterToolBar>
-
-    <ConfigHistory
-      v-model:visible="showHistory"
-      v-model:current-content="configText"
-      :filepath="filepath"
-    />
   </ARow>
 </template>
 
@@ -287,30 +47,10 @@ provide('data', data)
   top: 78px;
 }
 
-.ant-card {
-  margin: 10px 0;
+:deep(.ant-card) {
   box-shadow: unset;
 }
 
-.mode-switch {
-  display: flex;
-
-  .switch {
-    display: flex;
-    align-items: center;
-    margin-right: 5px;
-  }
-}
-
-.parse-error-alert-wrapper {
-  margin-bottom: 20px;
-}
-
-.domain-edit-container {
-  max-width: 800px;
-  margin: 0 auto;
-}
-
 .slide-fade-enter-active {
   transition: all .3s ease-in-out;
 }

+ 1 - 1
app/src/views/stream/components/ConfigName.vue

@@ -12,7 +12,7 @@ const modify = ref(false)
 const buffer = ref('')
 const loading = ref(false)
 
-onMounted(() => {
+watchEffect(() => {
   buffer.value = props.name
 })
 

+ 116 - 0
app/src/views/stream/components/RightPanel/Basic.vue

@@ -0,0 +1,116 @@
+<script setup lang="ts">
+import type { CheckedType } from '@/types'
+import envGroup from '@/api/env_group'
+import stream from '@/api/stream'
+import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
+import StdSelector from '@/components/StdDesign/StdDataEntry/components/StdSelector.vue'
+import { formatDateTime } from '@/lib/helper'
+import { useSettingsStore } from '@/pinia'
+import envGroupColumns from '@/views/environments/group/columns'
+import { InfoCircleOutlined } from '@ant-design/icons-vue'
+import { message, Modal } from 'ant-design-vue'
+import { storeToRefs } from 'pinia'
+import { useStreamEditorStore } from '../../store'
+import ConfigName from '../ConfigName.vue'
+
+const settings = useSettingsStore()
+const store = useStreamEditorStore()
+const { name, enabled, data } = storeToRefs(store)
+
+const [modal, ContextHolder] = Modal.useModal()
+const showSync = computed(() => !settings.is_remote)
+
+function enable() {
+  stream.enable(name.value).then(() => {
+    message.success($gettext('Enabled successfully'))
+    enabled.value = true
+  }).catch(r => {
+    message.error($gettext('Failed to enable %{msg}', { msg: r.message ?? '' }), 10)
+  })
+}
+
+function disable() {
+  stream.disable(name.value).then(() => {
+    message.success($gettext('Disabled successfully'))
+    enabled.value = false
+  }).catch(r => {
+    message.error($gettext('Failed to disable %{msg}', { msg: r.message ?? '' }))
+  })
+}
+
+function onChangeEnabled(checked: CheckedType) {
+  modal.confirm({
+    title: checked ? $gettext('Do you want to enable this stream?') : $gettext('Do you want to disable this stream?'),
+    mask: false,
+    centered: true,
+    okText: $gettext('OK'),
+    cancelText: $gettext('Cancel'),
+    async onOk() {
+      if (checked)
+        enable()
+      else
+        disable()
+    },
+  })
+}
+</script>
+
+<template>
+  <div>
+    <ContextHolder />
+
+    <AFormItem :label="$gettext('Enabled')">
+      <ASwitch
+        :checked="enabled"
+        @change="onChangeEnabled"
+      />
+    </AFormItem>
+
+    <AFormItem :label="$gettext('Name')">
+      <ConfigName :name />
+    </AFormItem>
+
+    <AFormItem :label="$gettext('Updated at')">
+      {{ formatDateTime(data.modified_at) }}
+    </AFormItem>
+
+    <AFormItem :label="$gettext('Node Group')">
+      <StdSelector
+        v-model:selected-key="data.env_group_id"
+        :api="envGroup"
+        :columns="envGroupColumns"
+        record-value-index="name"
+        selection-type="radio"
+      />
+    </AFormItem>
+    <!-- Synchronization Section -->
+    <div v-if="showSync" class="mt-4">
+      <div class="flex items-center justify-between mb-4">
+        <div>
+          {{ $gettext('Synchronization') }}
+        </div>
+        <APopover placement="bottomRight" :title="$gettext('Sync strategy')">
+          <template #content>
+            <div class="max-w-200px mb-2">
+              {{ $gettext('When you enable/disable, delete, or save this site, '
+                + 'the nodes set in the Node Group and the nodes selected below will be synchronized.') }}
+            </div>
+            <div class="max-w-200px">
+              {{ $gettext('Note, if the configuration file include other configurations or certificates, '
+                + 'please synchronize them to the remote nodes in advance.') }}
+            </div>
+          </template>
+          <div class="text-trueGray-600">
+            <InfoCircleOutlined class="mr-1" />
+            {{ $gettext('Sync strategy') }}
+          </div>
+        </APopover>
+      </div>
+      <NodeSelector
+        v-model:target="data.sync_node_ids"
+        class="mb-4"
+        hidden-local
+      />
+    </div>
+  </div>
+</template>

+ 17 - 0
app/src/views/stream/components/RightPanel/Chat.vue

@@ -0,0 +1,17 @@
+<script setup lang="ts">
+import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
+import { useStreamEditorStore } from '../../store'
+
+const store = useStreamEditorStore()
+const { configText, filepath, historyChatgptRecord } = storeToRefs(store)
+</script>
+
+<template>
+  <div>
+    <ChatGPT
+      v-model:history-messages="historyChatgptRecord"
+      :content="configText"
+      :path="filepath"
+    />
+  </div>
+</template>

+ 54 - 0
app/src/views/stream/components/RightPanel/RightPanel.vue

@@ -0,0 +1,54 @@
+<script setup lang="ts">
+import Basic from './Basic.vue'
+import Chat from './Chat.vue'
+
+const activeKey = ref('basic')
+</script>
+
+<template>
+  <div class="right-settings-container">
+    <ACard
+      class="right-settings"
+      :bordered="false"
+    >
+      <ATabs
+        v-model:active-key="activeKey"
+        class="mb-24px"
+        size="small"
+      >
+        <ATabPane key="basic" :tab="$gettext('Basic')">
+          <Basic />
+        </ATabPane>
+        <ATabPane key="chat" :tab="$gettext('Chat')">
+          <Chat />
+        </ATabPane>
+      </ATabs>
+    </ACard>
+  </div>
+</template>
+
+<style scoped lang="less">
+.right-settings-container {
+  position: relative;
+
+  .right-settings {
+    max-height: calc(100vh - 323px);
+    overflow-y: scroll;
+    position: relative;
+  }
+
+  :deep(.ant-card-body) {
+    padding: 19.5px 24px;
+  }
+
+  :deep(.ant-tabs-nav) {
+    margin: 0;
+  }
+}
+
+:deep(.ant-tabs-content) {
+  padding-top: 24px;
+  max-height: calc(100vh - 425px);
+  overflow-y: scroll;
+}
+</style>

+ 3 - 0
app/src/views/stream/components/RightPanel/index.ts

@@ -0,0 +1,3 @@
+import RightPanel from './RightPanel.vue'
+
+export default RightPanel

+ 0 - 166
app/src/views/stream/components/RightSettings.vue

@@ -1,166 +0,0 @@
-<script setup lang="ts">
-import type { ChatComplicationMessage } from '@/api/openai'
-import type { Stream } from '@/api/stream'
-import type { CheckedType } from '@/types'
-import type { Ref } from 'vue'
-import envGroup from '@/api/env_group'
-import stream from '@/api/stream'
-import ChatGPT from '@/components/ChatGPT/ChatGPT.vue'
-import NodeSelector from '@/components/NodeSelector/NodeSelector.vue'
-import StdSelector from '@/components/StdDesign/StdDataEntry/components/StdSelector.vue'
-import { formatDateTime } from '@/lib/helper'
-import { useSettingsStore } from '@/pinia'
-import envGroupColumns from '@/views/environments/group/columns'
-import { InfoCircleOutlined } from '@ant-design/icons-vue'
-import { message, Modal } from 'ant-design-vue'
-import ConfigName from './ConfigName.vue'
-
-const settings = useSettingsStore()
-
-const configText = inject('configText') as Ref<string>
-const enabled = inject('enabled') as Ref<boolean>
-const name = inject('name') as Ref<string>
-const historyChatgptRecord = inject('history_chatgpt_record') as Ref<ChatComplicationMessage[]>
-const filepath = inject('filepath') as Ref<string>
-const data = inject('data') as Ref<Stream>
-
-const [modal, ContextHolder] = Modal.useModal()
-
-const active_key = ref(['1', '2', '3'])
-
-function enable() {
-  stream.enable(name.value).then(() => {
-    message.success($gettext('Enabled successfully'))
-    enabled.value = true
-  }).catch(r => {
-    message.error($gettext('Failed to enable %{msg}', { msg: r.message ?? '' }), 10)
-  })
-}
-
-function disable() {
-  stream.disable(name.value).then(() => {
-    message.success($gettext('Disabled successfully'))
-    enabled.value = false
-  }).catch(r => {
-    message.error($gettext('Failed to disable %{msg}', { msg: r.message ?? '' }))
-  })
-}
-
-function onChangeEnabled(checked: CheckedType) {
-  modal.confirm({
-    title: checked ? $gettext('Do you want to enable this stream?') : $gettext('Do you want to disable this stream?'),
-    mask: false,
-    centered: true,
-    okText: $gettext('OK'),
-    cancelText: $gettext('Cancel'),
-    async onOk() {
-      if (checked)
-        enable()
-      else
-        disable()
-    },
-  })
-}
-</script>
-
-<template>
-  <ACard
-    class="right-settings"
-    :bordered="false"
-  >
-    <ContextHolder />
-    <ACollapse
-      v-model:active-key="active_key"
-      ghost
-      collapsible="header"
-    >
-      <ACollapsePanel
-        key="1"
-        :header="$gettext('Basic')"
-      >
-        <AFormItem :label="$gettext('Enabled')">
-          <ASwitch
-            :checked="enabled"
-            @change="onChangeEnabled"
-          />
-        </AFormItem>
-        <AFormItem :label="$gettext('Name')">
-          <ConfigName :name="name" />
-        </AFormItem>
-        <AFormItem :label="$gettext('Node Group')">
-          <StdSelector
-            v-model:selected-key="data.env_group_id"
-            :api="envGroup"
-            :columns="envGroupColumns"
-            record-value-index="name"
-            selection-type="radio"
-          />
-        </AFormItem>
-        <AFormItem :label="$gettext('Updated at')">
-          {{ formatDateTime(data.modified_at) }}
-        </AFormItem>
-      </ACollapsePanel>
-      <ACollapsePanel
-        v-if="!settings.is_remote"
-        key="2"
-      >
-        <template #header>
-          {{ $gettext('Synchronization') }}
-        </template>
-        <template #extra>
-          <APopover placement="bottomRight" :title="$gettext('Sync strategy')">
-            <template #content>
-              <div class="max-w-200px mb-2">
-                {{ $gettext('When you enable/disable, delete, or save this stream, '
-                  + 'the nodes set in the Node Group and the nodes selected below will be synchronized.') }}
-              </div>
-              <div class="max-w-200px">
-                {{ $gettext('Note, if the configuration file include other configurations or certificates, '
-                  + 'please synchronize them to the remote nodes in advance.') }}
-              </div>
-            </template>
-            <div class="text-trueGray-600">
-              <InfoCircleOutlined class="mr-1" />
-              {{ $gettext('Sync strategy') }}
-            </div>
-          </APopover>
-        </template>
-        <NodeSelector
-          v-model:target="data.sync_node_ids"
-          class="mb-4"
-          hidden-local
-        />
-      </ACollapsePanel>
-      <ACollapsePanel
-        key="3"
-        header="ChatGPT"
-      >
-        <ChatGPT
-          v-model:history-messages="historyChatgptRecord"
-          :content="configText"
-          :path="filepath"
-        />
-      </ACollapsePanel>
-    </ACollapse>
-  </ACard>
-</template>
-
-<style scoped lang="less">
-.right-settings {
-  position: sticky;
-  top: 78px;
-
-  :deep(.ant-card-body) {
-    max-height: 100vh;
-    overflow-y: scroll;
-  }
-}
-
-:deep(.ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box) {
-  padding: 0;
-}
-
-:deep(.ant-collapse > .ant-collapse-item > .ant-collapse-header) {
-  padding: 0 0 10px 0;
-}
-</style>

+ 135 - 0
app/src/views/stream/components/StreamEditor.vue

@@ -0,0 +1,135 @@
+<script lang="ts" setup>
+import CodeEditor from '@/components/CodeEditor'
+import ConfigHistory from '@/components/ConfigHistory'
+import FooterToolBar from '@/components/FooterToolbar'
+import NgxConfigEditor from '@/components/NgxConfigEditor'
+import { HistoryOutlined } from '@ant-design/icons-vue'
+import { useStreamEditorStore } from '../store'
+
+const router = useRouter()
+
+const store = useStreamEditorStore()
+const { name, enabled, configText, filepath, saving, parseErrorStatus, parseErrorMessage, advanceMode } = storeToRefs(store)
+const showHistory = ref(false)
+</script>
+
+<template>
+  <ACard class="mb-4" :bordered="false">
+    <template #title>
+      <span style="margin-right: 10px">{{ $gettext('Edit %{n}', { n: name }) }}</span>
+      <ATag
+        v-if="enabled"
+        color="blue"
+      >
+        {{ $gettext('Enabled') }}
+      </ATag>
+      <ATag
+        v-else
+        color="orange"
+      >
+        {{ $gettext('Disabled') }}
+      </ATag>
+    </template>
+    <template #extra>
+      <ASpace>
+        <AButton
+          v-if="filepath"
+          type="link"
+          @click="showHistory = true"
+        >
+          <template #icon>
+            <HistoryOutlined />
+          </template>
+          {{ $gettext('History') }}
+        </AButton>
+        <div class="mode-switch">
+          <div class="switch">
+            <ASwitch
+              size="small"
+              :disabled="parseErrorStatus"
+              :checked="advanceMode"
+              @change="store.handleModeChange"
+            />
+          </div>
+          <template v-if="advanceMode">
+            <div>{{ $gettext('Advance Mode') }}</div>
+          </template>
+          <template v-else>
+            <div>{{ $gettext('Basic Mode') }}</div>
+          </template>
+        </div>
+      </ASpace>
+    </template>
+
+    <Transition name="slide-fade">
+      <div
+        v-if="advanceMode"
+        key="advance"
+      >
+        <div
+          v-if="parseErrorStatus"
+          class="mb-4"
+        >
+          <AAlert
+            :message="$gettext('Nginx Configuration Parse Error')"
+            :description="parseErrorMessage"
+            type="error"
+            show-icon
+          />
+        </div>
+        <div>
+          <CodeEditor v-model:content="configText" />
+        </div>
+      </div>
+
+      <div
+        v-else
+        key="basic"
+        class="domain-edit-container"
+      >
+        <NgxConfigEditor
+          :enabled="enabled"
+          context="stream"
+        />
+      </div>
+    </Transition>
+
+    <ConfigHistory
+      v-model:visible="showHistory"
+      v-model:current-content="configText"
+      :filepath="filepath"
+    />
+
+    <FooterToolBar>
+      <ASpace>
+        <AButton @click="router.push('/streams')">
+          {{ $gettext('Back') }}
+        </AButton>
+        <AButton
+          type="primary"
+          :loading="saving"
+          @click="store.save"
+        >
+          {{ $gettext('Save') }}
+        </AButton>
+      </ASpace>
+    </FooterToolBar>
+  </ACard>
+</template>
+
+<style scoped lang="less">
+.mode-switch {
+  display: flex;
+
+  .switch {
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+  }
+}
+
+.domain-edit-container {
+  max-width: 800px;
+  margin: 0 auto;
+}
+</style>

+ 160 - 0
app/src/views/stream/store.ts

@@ -0,0 +1,160 @@
+import type { CertificateInfo } from '@/api/cert'
+import type { ChatComplicationMessage } from '@/api/openai'
+import type { Stream } from '@/api/stream'
+import type { CheckedType } from '@/types'
+import config from '@/api/config'
+import ngx from '@/api/ngx'
+import stream from '@/api/stream'
+import { useNgxConfigStore } from '@/components/NgxConfigEditor'
+
+export const useStreamEditorStore = defineStore('streamEditor', () => {
+  const name = ref('')
+  const advanceMode = ref(false)
+  const parseErrorStatus = ref(false)
+  const parseErrorMessage = ref('')
+  const data = ref({}) as Ref<Stream>
+  const historyChatgptRecord = ref([]) as Ref<ChatComplicationMessage[]>
+  const loading = ref(true)
+  const saving = ref(false)
+  const autoCert = ref(false)
+  const certInfoMap = ref({}) as Ref<Record<number, CertificateInfo[]>>
+  const filename = ref('')
+  const filepath = ref('')
+  const enabled = ref(false)
+
+  const ngxConfigStore = useNgxConfigStore()
+  const { ngxConfig, configText, curServerIdx, curServer, curServerDirectives, curDirectivesMap } = storeToRefs(ngxConfigStore)
+
+  async function init(_name: string) {
+    loading.value = true
+    name.value = _name
+    await nextTick()
+
+    if (name.value) {
+      try {
+        const r = await stream.get(name.value)
+        handleResponse(r)
+      }
+      catch (error) {
+        handleParseError(error as { error?: string, message: string })
+      }
+    }
+    else {
+      historyChatgptRecord.value = []
+    }
+    loading.value = false
+  }
+
+  async function buildConfig() {
+    return ngx.build_config(ngxConfig.value).then(r => {
+      configText.value = r.content
+    })
+  }
+
+  async function save() {
+    saving.value = true
+
+    try {
+      if (!advanceMode.value) {
+        await buildConfig()
+      }
+
+      const response = await stream.save(name.value, {
+        content: configText.value,
+        overwrite: true,
+        env_group_id: data.value.env_group_id,
+        sync_node_ids: data.value.sync_node_ids,
+        post_action: 'reload_nginx',
+      })
+
+      handleResponse(response)
+    }
+    catch (error) {
+      handleParseError(error as { error?: string, message: string })
+    }
+    finally {
+      saving.value = false
+    }
+  }
+
+  function handleParseError(e: { error?: string, message: string }) {
+    console.error(e)
+    parseErrorStatus.value = true
+    parseErrorMessage.value = e.message
+    config.get(`streams-available/${name.value}`).then(r => {
+      configText.value = r.content
+    })
+  }
+
+  async function handleResponse(r: Stream) {
+    if (r.advanced)
+      advanceMode.value = true
+
+    enabled.value = r.enabled
+    parseErrorStatus.value = false
+    parseErrorMessage.value = ''
+    filename.value = r.name
+    filepath.value = r.filepath
+    configText.value = r.config
+    historyChatgptRecord.value = r.chatgpt_messages
+    data.value = r
+    Object.assign(ngxConfig, r.tokenized)
+
+    const ngxConfigStore = useNgxConfigStore()
+
+    if (r.tokenized)
+      ngxConfigStore.setNgxConfig(r.tokenized)
+  }
+
+  async function handleModeChange(advanced: CheckedType) {
+    loading.value = true
+
+    try {
+      await stream.advance_mode(name.value, { advanced: advanced as boolean })
+      advanceMode.value = advanced as boolean
+      if (advanced) {
+        await buildConfig()
+      }
+      else {
+        let r = await stream.get(name.value)
+        await handleResponse(r)
+        r = await ngx.tokenize_config(configText.value)
+        Object.assign(ngxConfig, {
+          ...r,
+          name: name.value,
+        })
+      }
+    }
+    // eslint-disable-next-line ts/no-explicit-any
+    catch (e: any) {
+      handleParseError(e)
+    }
+
+    loading.value = false
+  }
+
+  return {
+    name,
+    advanceMode,
+    parseErrorStatus,
+    parseErrorMessage,
+    data,
+    historyChatgptRecord,
+    loading,
+    saving,
+    autoCert,
+    certInfoMap,
+    ngxConfig,
+    curServerIdx,
+    curServer,
+    curServerDirectives,
+    curDirectivesMap,
+    filename,
+    filepath,
+    configText,
+    enabled,
+    init,
+    save,
+    handleModeChange,
+  }
+})

+ 2 - 0
app/vite.config.ts

@@ -8,6 +8,7 @@ import Components from 'unplugin-vue-components/vite'
 import DefineOptions from 'unplugin-vue-define-options/vite'
 import { defineConfig, loadEnv } from 'vite'
 import vitePluginBuildId from 'vite-plugin-build-id'
+import Inspect from 'vite-plugin-inspect'
 import svgLoader from 'vite-svg-loader'
 
 // https://vitejs.dev/config/
@@ -62,6 +63,7 @@ export default defineConfig(({ mode }) => {
         },
       }),
       DefineOptions(),
+      Inspect(),
     ],
     css: {
       preprocessorOptions: {

+ 11 - 0
internal/site/rename.go

@@ -58,6 +58,17 @@ func Rename(oldName string, newName string) (err error) {
 		return fmt.Errorf("%s", output)
 	}
 
+	// update ChatGPT history
+	g := query.ChatGPTLog
+	_, _ = g.Where(g.Name.Eq(oldName)).Update(g.Name, newName)
+
+	// update config history
+	b := query.ConfigBackup
+	_, _ = b.Where(b.FilePath.Eq(oldPath)).Updates(map[string]interface{}{
+		"filepath": newPath,
+		"name":     newName,
+	})
+
 	go syncRename(oldName, newName)
 
 	return

+ 16 - 4
internal/stream/rename.go

@@ -2,6 +2,11 @@ package stream
 
 import (
 	"fmt"
+	"net/http"
+	"os"
+	"runtime"
+	"sync"
+
 	"github.com/0xJacky/Nginx-UI/internal/helper"
 	"github.com/0xJacky/Nginx-UI/internal/nginx"
 	"github.com/0xJacky/Nginx-UI/internal/notification"
@@ -9,10 +14,6 @@ import (
 	"github.com/go-resty/resty/v2"
 	"github.com/uozi-tech/cosy"
 	"github.com/uozi-tech/cosy/logger"
-	"net/http"
-	"os"
-	"runtime"
-	"sync"
 )
 
 func Rename(oldName string, newName string) (err error) {
@@ -59,6 +60,17 @@ func Rename(oldName string, newName string) (err error) {
 		return cosy.WrapErrorWithParams(ErrNginxReloadFailed, output)
 	}
 
+	// update ChatGPT history
+	g := query.ChatGPTLog
+	_, _ = g.Where(g.Name.Eq(oldName)).Update(g.Name, newName)
+
+	// update config history
+	b := query.ConfigBackup
+	_, _ = b.Where(b.FilePath.Eq(oldPath)).Updates(map[string]interface{}{
+		"filepath": newPath,
+		"name":     newName,
+	})
+
 	go syncRename(oldName, newName)
 
 	return

+ 1 - 1
template/block/letsencrypt.conf

@@ -4,7 +4,7 @@ author = "@0xJacky"
 description = { en = "Let's Encrypt HTTPChallange", zh_CN = "Let's Encrypt HTTP 鉴权"}
 # Nginx UI Template End
 
-location /.well-known/acme-challenge {
+location ~ /.well-known/acme-challenge {
     proxy_set_header Host $host;
     proxy_set_header X-Real_IP $remote_addr;
     proxy_set_header X-Forwarded-For $remote_addr:$remote_port;

+ 8 - 8
template/block/nginx-ui.conf

@@ -11,12 +11,12 @@ map $http_upgrade $connection_upgrade {
 }
 # Nginx UI Custom End
 location / {
-        proxy_set_header Host $host;
-        proxy_set_header X-Real-IP $remote_addr;
-        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-        proxy_set_header X-Forwarded-Proto $scheme;
-        proxy_http_version 1.1;
-        proxy_set_header Upgrade $http_upgrade;
-        proxy_set_header Connection $connection_upgrade;
-        proxy_pass http://127.0.0.1:{{.HTTPPORT}}/;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection $connection_upgrade;
+    proxy_pass http://127.0.0.1:{{.HTTPPORT}}/;
 }

Some files were not shown because too many files changed in this diff