4 Revize f2bc4938b7 ... 12c10dbd98

Autor SHA1 Zpráva Datum
  MHSanaei 12c10dbd98 feat(custom-geo): refresh index UI před 2 dny
  MHSanaei 2fd2cd0af1 fix(panel): silence update-check WARN spam when offline před 2 dny
  MHSanaei 37fb48ffff Axios v1.16.0 před 2 dny
  MHSanaei d8198f543b fix(warp): harden API client and frontend, bump to v0a4005 před 2 dny

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 1
web/assets/axios/axios.min.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
web/assets/axios/axios.min.js.map


+ 10 - 1
web/controller/server.go

@@ -7,6 +7,8 @@ import (
 	"strconv"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v2/web/entity"
 	"github.com/mhsanaei/3x-ui/v2/web/global"
 	"github.com/mhsanaei/3x-ui/v2/web/service"
 	"github.com/mhsanaei/3x-ui/v2/web/websocket"
@@ -135,10 +137,17 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
 }
 
 // getPanelUpdateInfo retrieves the current and latest panel version.
+// Network failures (e.g. no internet, GitHub blocked) are logged at debug
+// level only — the panel keeps working offline and we don't want to spam
+// WARN every time a user opens the page.
 func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
 	info, err := a.panelService.GetUpdateInfo()
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateCheckPopover"), err)
+		logger.Debug("panel update check failed:", err)
+		c.JSON(http.StatusOK, entity.Msg{
+			Success: false,
+			Msg:     I18nWeb(c, "pages.index.panelUpdateCheckPopover"),
+		})
 		return
 	}
 	jsonObj(c, info, nil)

+ 163 - 22
web/html/index.html

@@ -3,12 +3,98 @@
 
 {{ template "page/body_start" .}}
 <style>
+  .custom-geo-section code.custom-geo-ext-code {
+    padding: 2px 6px;
+    border-radius: 3px;
+    font-size: 12px;
+    background: rgba(0, 0, 0, 0.04);
+    border: 1px solid rgba(0, 0, 0, 0.08);
+  }
+
+  .custom-geo-copyable {
+    cursor: pointer;
+    transition: background 0.15s, border-color 0.15s;
+  }
+
+  .custom-geo-copyable:hover {
+    background: rgba(24, 144, 255, 0.12);
+    border-color: rgba(24, 144, 255, 0.45);
+  }
+
+  .custom-geo-alias-cell {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+  }
+
+  .custom-geo-alias {
+    font-weight: 500;
+  }
+
+  .custom-geo-type-tag {
+    margin-right: 0;
+    text-transform: uppercase;
+    font-size: 10px;
+    letter-spacing: 0.4px;
+  }
+
+  .custom-geo-url {
+    display: inline-block;
+    max-width: 220px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    vertical-align: bottom;
+  }
+
+  .custom-geo-muted {
+    color: rgba(0, 0, 0, 0.35);
+  }
+
+  .custom-geo-count {
+    background: rgba(0, 0, 0, 0.06);
+    color: rgba(0, 0, 0, 0.55);
+    border-radius: 10px;
+    padding: 1px 8px;
+    font-size: 12px;
+  }
+
+  .custom-geo-empty {
+    padding: 24px 0;
+    color: rgba(0, 0, 0, 0.45);
+    text-align: center;
+  }
+
+  .custom-geo-empty-icon {
+    font-size: 32px;
+    color: rgba(0, 0, 0, 0.25);
+    display: block;
+    margin: 0 auto 8px;
+  }
+
   body.dark .custom-geo-section code.custom-geo-ext-code {
     color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.85));
     background: var(--dark-color-surface-200, #222d42);
     border: 1px solid var(--dark-color-stroke, #2c3950);
-    padding: 2px 6px;
-    border-radius: 3px;
+  }
+
+  body.dark .custom-geo-copyable:hover {
+    background: rgba(24, 144, 255, 0.18);
+    border-color: rgba(64, 169, 255, 0.55);
+  }
+
+  body.dark .custom-geo-muted,
+  body.dark .custom-geo-empty {
+    color: rgba(255, 255, 255, 0.45);
+  }
+
+  body.dark .custom-geo-empty-icon {
+    color: rgba(255, 255, 255, 0.25);
+  }
+
+  body.dark .custom-geo-count {
+    background: rgba(255, 255, 255, 0.08);
+    color: rgba(255, 255, 255, 0.7);
   }
 
   html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
@@ -383,21 +469,43 @@
         <div class="custom-geo-section">
           <a-alert type="info" show-icon class="mb-10"
             message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
-          <div class="mb-10">
+          <div class="mb-10 d-flex align-center" style="flex-wrap: wrap; gap: 8px;">
             <a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
               {{ i18n "pages.index.customGeoAdd" }}
             </a-button>
-            <a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
-            "pages.index.geofilesUpdateAll" }}</a-button>
+            <a-button icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll"
+              :disabled="!customGeoList.length">{{ i18n "pages.index.geofilesUpdateAll" }}</a-button>
+            <span v-if="customGeoList.length" class="custom-geo-count">[[ customGeoList.length ]]</span>
           </div>
           <a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
-            :loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
+            :loading="customGeoLoading" size="small" :scroll="{ x: 760 }">
+            <template slot="alias" slot-scope="text, record">
+              <div class="custom-geo-alias-cell">
+                <a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'"
+                  class="custom-geo-type-tag">[[ record.type ]]</a-tag>
+                <span class="custom-geo-alias">[[ record.alias ]]</span>
+              </div>
+            </template>
+            <template slot="url" slot-scope="text, record">
+              <a-tooltip :overlay-class-name="themeSwitcher.currentTheme" placement="topLeft">
+                <template slot="title">[[ record.url ]]</template>
+                <a :href="record.url" target="_blank" rel="noopener noreferrer"
+                  class="custom-geo-url">[[ record.url ]]</a>
+              </a-tooltip>
+            </template>
             <template slot="extDat" slot-scope="text, record">
-              <code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
+              <a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
+                <template slot="title">{{ i18n "copy" }}</template>
+                <code class="custom-geo-ext-code custom-geo-copyable"
+                  @click="copyCustomGeoExt(record)">[[ customGeoExtDisplay(record) ]]</code>
+              </a-tooltip>
             </template>
             <template slot="lastUpdatedAt" slot-scope="text, record">
-              <span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
-              <span v-else>—</span>
+              <a-tooltip v-if="record.lastUpdatedAt" :overlay-class-name="themeSwitcher.currentTheme">
+                <template slot="title">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</template>
+                <span>[[ customGeoRelativeTime(record.lastUpdatedAt) ]]</span>
+              </a-tooltip>
+              <span v-else class="custom-geo-muted">—</span>
             </template>
             <template slot="action" slot-scope="text, record">
               <a-space size="small">
@@ -416,6 +524,12 @@
                 </a-tooltip>
               </a-space>
             </template>
+            <template slot="emptyText">
+              <div class="custom-geo-empty">
+                <a-icon type="inbox" class="custom-geo-empty-icon"></a-icon>
+                <div>{{ i18n "pages.index.customGeoEmpty" }}</div>
+              </div>
+            </template>
           </a-table>
         </div>
       </a-collapse-panel>
@@ -1111,29 +1225,34 @@
   };
 
   const customGeoColumns = [{
+      title: '{{ i18n "pages.index.customGeoAlias" }}',
+      key: 'alias',
+      scopedSlots: { customRender: 'alias' },
+      width: 200
+    },
+    {
+      title: '{{ i18n "pages.index.customGeoUrl" }}',
+      key: 'url',
+      scopedSlots: { customRender: 'url' },
+      ellipsis: true
+    },
+    {
       title: '{{ i18n "pages.index.customGeoExtColumn" }}',
       key: 'extDat',
-      scopedSlots: {
-        customRender: 'extDat'
-      },
-      ellipsis: true
+      scopedSlots: { customRender: 'extDat' },
+      width: 220
     },
     {
       title: '{{ i18n "pages.index.customGeoLastUpdated" }}',
       key: 'lastUpdatedAt',
-      scopedSlots: {
-        customRender: 'lastUpdatedAt'
-      },
-      width: 160
+      scopedSlots: { customRender: 'lastUpdatedAt' },
+      width: 140
     },
     {
       title: '{{ i18n "pages.index.customGeoActions" }}',
       key: 'action',
-      scopedSlots: {
-        customRender: 'action'
-      },
+      scopedSlots: { customRender: 'action' },
       width: 120,
-      fixed: 'right'
     },
   ];
 
@@ -1266,12 +1385,29 @@
         if (!ts) return '';
         return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
       },
+      customGeoRelativeTime(ts) {
+        if (!ts) return '';
+        if (typeof moment === 'undefined') return String(ts);
+        return moment(ts * 1000).fromNow();
+      },
       customGeoExtDisplay(record) {
         const fn = record.type === 'geoip' ?
           `geoip_${record.alias}.dat` :
           `geosite_${record.alias}.dat`;
         return `ext:${fn}:tag`;
       },
+      copyCustomGeoExt(record) {
+        const text = this.customGeoExtDisplay(record);
+        if (typeof ClipboardManager !== 'undefined' && ClipboardManager.copyText) {
+          ClipboardManager.copyText(text).then(ok => {
+            if (ok) this.$message.success(`{{ i18n "copy" }}: ${text}`);
+          });
+        } else if (navigator.clipboard) {
+          navigator.clipboard.writeText(text).then(() => {
+            this.$message.success(`{{ i18n "copy" }}: ${text}`);
+          });
+        }
+      },
       async loadCustomGeo() {
         this.customGeoLoading = true;
         try {
@@ -1376,8 +1512,13 @@
         this.customGeoUpdatingAll = true;
         try {
           const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
-          if (msg.success || (msg.obj && Array.isArray(msg.obj.succeeded) && msg.obj.succeeded.length > 0)) {
+          const ok = (msg && msg.obj && Array.isArray(msg.obj.succeeded)) ? msg.obj.succeeded.length : 0;
+          const failed = (msg && msg.obj && Array.isArray(msg.obj.failed)) ? msg.obj.failed.length : 0;
+          if (msg.success || ok > 0) {
             await this.loadCustomGeo();
+            if (failed > 0) {
+              this.$message.warning(`Updated ${ok}, failed ${failed}`);
+            }
           }
         } finally {
           this.customGeoUpdatingAll = false;

+ 23 - 21
web/html/modals/warp_modal.html

@@ -88,7 +88,6 @@
                     <a-button @click="addOutbound" :loading="warpModal.confirmLoading"
                         type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
                 </template>
-                </a-form-item>
             </a-form>
         </template>
     </template>
@@ -131,26 +130,27 @@
         },
         methods: {
             collectConfig() {
-                config = warpModal.warpConfig.config;
-                peer = config.peers[0];
-                if (config) {
-                    warpModal.warpOutbound = Outbound.fromJson({
-                        tag: 'warp',
-                        protocol: Protocols.Wireguard,
-                        settings: {
-                            mtu: 1420,
-                            secretKey: warpModal.warpData.private_key,
-                            address: this.getAddresses(config.interface.addresses),
-                            reserved: this.getResolved(config.client_id),
-                            domainStrategy: 'ForceIP',
-                            peers: [{
-                                publicKey: peer.public_key,
-                                endpoint: peer.endpoint.host,
-                            }],
-                            noKernelTun: false,
-                        }
-                    });
+                const config = warpModal.warpConfig && warpModal.warpConfig.config;
+                if (!config || !config.peers || !config.peers.length) {
+                    return;
                 }
+                const peer = config.peers[0];
+                warpModal.warpOutbound = Outbound.fromJson({
+                    tag: 'warp',
+                    protocol: Protocols.Wireguard,
+                    settings: {
+                        mtu: 1420,
+                        secretKey: warpModal.warpData.private_key,
+                        address: this.getAddresses(config.interface.addresses),
+                        reserved: this.getReserved(config.client_id),
+                        domainStrategy: 'ForceIP',
+                        peers: [{
+                            publicKey: peer.public_key,
+                            endpoint: peer.endpoint.host,
+                        }],
+                        noKernelTun: false,
+                    }
+                });
             },
             getAddresses(addrs) {
                 let addresses = [];
@@ -158,7 +158,7 @@
                 if (addrs.v6) addresses.push(addrs.v6 + "/128");
                 return addresses;
             },
-            getResolved(client_id) {
+            getReserved(client_id) {
                 let reserved = [];
                 let decoded = atob(client_id);
                 let hexString = '';
@@ -218,11 +218,13 @@
                 }
             },
             addOutbound() {
+                if (!warpModal.warpOutbound) return;
                 app.templateSettings.outbounds.push(warpModal.warpOutbound.toJson());
                 app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
                 warpModal.close();
             },
             resetOutbound() {
+                if (!warpModal.warpOutbound) return;
                 app.templateSettings.outbounds[this.warpOutboundIndex] = warpModal.warpOutbound.toJson();
                 app.outboundSettings = JSON.stringify(app.templateSettings.outbounds);
                 warpModal.close();

+ 118 - 82
web/service/warp.go

@@ -4,11 +4,11 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
 	"os"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v2/logger"
 	"github.com/mhsanaei/3x-ui/v2/util/common"
 )
 
@@ -18,148 +18,147 @@ type WarpService struct {
 	SettingService
 }
 
+const (
+	warpAPIBase   = "https://api.cloudflareclient.com/v0a4005"
+	warpClientVer = "a-6.30-3596"
+)
+
+var warpHTTPClient = &http.Client{Timeout: 15 * time.Second}
+
 func (s *WarpService) GetWarpData() (string, error) {
-	warp, err := s.SettingService.GetWarp()
-	if err != nil {
-		return "", err
-	}
-	return warp, nil
+	return s.SettingService.GetWarp()
 }
 
 func (s *WarpService) DelWarpData() error {
-	err := s.SettingService.SetWarp("")
-	if err != nil {
-		return err
-	}
-	return nil
+	return s.SettingService.SetWarp("")
 }
 
 func (s *WarpService) GetWarpConfig() (string, error) {
-	var warpData map[string]string
-	warp, err := s.SettingService.GetWarp()
+	warpData, err := s.loadWarpCreds()
 	if err != nil {
 		return "", err
 	}
-	err = json.Unmarshal([]byte(warp), &warpData)
-	if err != nil {
-		return "", err
-	}
-
-	url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s", warpData["device_id"])
 
-	req, err := http.NewRequest("GET", url, nil)
+	url := fmt.Sprintf("%s/reg/%s", warpAPIBase, warpData["device_id"])
+	req, err := http.NewRequest(http.MethodGet, url, nil)
 	if err != nil {
 		return "", err
 	}
 	req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
 
-	client := &http.Client{}
-	resp, err := client.Do(req)
-	if err != nil {
-		return "", err
-	}
-	defer resp.Body.Close()
-	buffer := &bytes.Buffer{}
-	_, err = buffer.ReadFrom(resp.Body)
+	body, err := doWarpRequest(req)
 	if err != nil {
 		return "", err
 	}
-
-	return buffer.String(), nil
+	return string(body), nil
 }
 
 func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error) {
-	tos := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
 	hostName, _ := os.Hostname()
-	data := fmt.Sprintf(`{"key":"%s","tos":"%s","type": "PC","model": "x-ui", "name": "%s"}`, publicKey, tos, hostName)
-
-	url := "https://api.cloudflareclient.com/v0a2158/reg"
-
-	req, err := http.NewRequest("POST", url, bytes.NewBuffer([]byte(data)))
+	reqBody, err := json.Marshal(map[string]any{
+		"key":   publicKey,
+		"tos":   time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
+		"type":  "PC",
+		"model": "x-ui",
+		"name":  hostName,
+	})
 	if err != nil {
 		return "", err
 	}
 
-	req.Header.Add("CF-Client-Version", "a-7.21-0721")
-	req.Header.Add("Content-Type", "application/json")
-
-	client := &http.Client{}
-	resp, err := client.Do(req)
+	req, err := http.NewRequest(http.MethodPost, warpAPIBase+"/reg", bytes.NewReader(reqBody))
 	if err != nil {
 		return "", err
 	}
-	defer resp.Body.Close()
-	buffer := &bytes.Buffer{}
-	_, err = buffer.ReadFrom(resp.Body)
+	req.Header.Set("CF-Client-Version", warpClientVer)
+	req.Header.Set("Content-Type", "application/json")
+
+	body, err := doWarpRequest(req)
 	if err != nil {
 		return "", err
 	}
 
-	var rspData map[string]any
-	err = json.Unmarshal(buffer.Bytes(), &rspData)
-	if err != nil {
+	var rsp map[string]any
+	if err := json.Unmarshal(body, &rsp); err != nil {
 		return "", err
 	}
 
-	deviceId := rspData["id"].(string)
-	token := rspData["token"].(string)
-	license, ok := rspData["account"].(map[string]any)["license"].(string)
+	deviceID, ok := rsp["id"].(string)
 	if !ok {
-		logger.Debug("Error accessing license value.")
-		return "", err
+		return "", common.NewError("warp register: missing 'id' in response")
+	}
+	token, ok := rsp["token"].(string)
+	if !ok {
+		return "", common.NewError("warp register: missing 'token' in response")
+	}
+	account, ok := rsp["account"].(map[string]any)
+	if !ok {
+		return "", common.NewError("warp register: missing 'account' in response")
+	}
+	license, ok := account["license"].(string)
+	if !ok {
+		return "", common.NewError("warp register: missing 'account.license' in response")
 	}
 
-	warpData := fmt.Sprintf("{\n  \"access_token\": \"%s\",\n  \"device_id\": \"%s\",", token, deviceId)
-	warpData += fmt.Sprintf("\n  \"license_key\": \"%s\",\n  \"private_key\": \"%s\"\n}", license, secretKey)
-
-	s.SettingService.SetWarp(warpData)
-
-	result := fmt.Sprintf("{\n  \"data\": %s,\n  \"config\": %s\n}", warpData, buffer.String())
+	warpData := map[string]string{
+		"access_token": token,
+		"device_id":    deviceID,
+		"license_key":  license,
+		"private_key":  secretKey,
+	}
+	warpJSON, err := json.MarshalIndent(warpData, "", "  ")
+	if err != nil {
+		return "", err
+	}
+	if err := s.SettingService.SetWarp(string(warpJSON)); err != nil {
+		return "", err
+	}
 
-	return result, nil
+	result, err := json.MarshalIndent(map[string]any{
+		"data":   warpData,
+		"config": json.RawMessage(body),
+	}, "", "  ")
+	if err != nil {
+		return "", err
+	}
+	return string(result), nil
 }
 
 func (s *WarpService) SetWarpLicense(license string) (string, error) {
-	var warpData map[string]string
-	warp, err := s.SettingService.GetWarp()
+	warpData, err := s.loadWarpCreds()
 	if err != nil {
 		return "", err
 	}
-	err = json.Unmarshal([]byte(warp), &warpData)
+
+	url := fmt.Sprintf("%s/reg/%s/account", warpAPIBase, warpData["device_id"])
+	reqBody, err := json.Marshal(map[string]string{"license": license})
 	if err != nil {
 		return "", err
 	}
 
-	url := fmt.Sprintf("https://api.cloudflareclient.com/v0a2158/reg/%s/account", warpData["device_id"])
-	data := fmt.Sprintf(`{"license": "%s"}`, license)
-
-	req, err := http.NewRequest("PUT", url, bytes.NewBuffer([]byte(data)))
+	req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(reqBody))
 	if err != nil {
 		return "", err
 	}
 	req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
+	req.Header.Set("Content-Type", "application/json")
 
-	client := &http.Client{}
-	resp, err := client.Do(req)
-	if err != nil {
-		return "", err
-	}
-	defer resp.Body.Close()
-	buffer := &bytes.Buffer{}
-	_, err = buffer.ReadFrom(resp.Body)
+	body, err := doWarpRequest(req)
 	if err != nil {
 		return "", err
 	}
 
 	var response map[string]any
-	err = json.Unmarshal(buffer.Bytes(), &response)
-	if err != nil {
+	if err := json.Unmarshal(body, &response); err != nil {
 		return "", err
 	}
-	if response["success"] == false {
-		errorArr, _ := response["errors"].([]any)
-		errorObj := errorArr[0].(map[string]any)
-		return "", common.NewError(errorObj["code"], errorObj["message"])
+	if success, _ := response["success"].(bool); !success {
+		if errorArr, ok := response["errors"].([]any); ok && len(errorArr) > 0 {
+			if errorObj, ok := errorArr[0].(map[string]any); ok {
+				return "", common.NewError(errorObj["code"], errorObj["message"])
+			}
+		}
+		return "", common.NewError("warp set license failed: unknown error")
 	}
 
 	warpData["license_key"] = license
@@ -167,7 +166,44 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	s.SettingService.SetWarp(string(newWarpData))
-
+	if err := s.SettingService.SetWarp(string(newWarpData)); err != nil {
+		return "", err
+	}
 	return string(newWarpData), nil
 }
+
+// loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
+func (s *WarpService) loadWarpCreds() (map[string]string, error) {
+	warp, err := s.SettingService.GetWarp()
+	if err != nil {
+		return nil, err
+	}
+	var data map[string]string
+	if err := json.Unmarshal([]byte(warp), &data); err != nil {
+		return nil, err
+	}
+	if data["access_token"] == "" || data["device_id"] == "" {
+		return nil, common.NewError("warp not registered: missing access_token or device_id")
+	}
+	return data, nil
+}
+
+// doWarpRequest sends the request and returns the response body on 2xx.
+// Non-2xx responses are returned as errors including the status code and body.
+func doWarpRequest(req *http.Request) ([]byte, error) {
+	resp, err := warpHTTPClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, common.NewErrorf("warp api %s %s returned status %d: %s",
+			req.Method, req.URL.Path, resp.StatusCode, string(body))
+	}
+	return body, nil
+}

+ 1 - 0
web/translation/translate.ar_EG.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "مصدر geo المخصص غير موجود"
 "customGeoErrDownload" = "فشل التنزيل"
 "customGeoErrUpdateAllIncomplete" = "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة"
+"customGeoEmpty" = "لا توجد مصادر geo مخصصة بعد — انقر على «إضافة» لإنشاء واحد"
 
 [pages.inbounds]
 "allTimeTraffic" = "إجمالي حركة المرور"

+ 1 - 0
web/translation/translate.en_US.toml

@@ -204,6 +204,7 @@
 "customGeoErrNotFound" = "Custom geo source not found"
 "customGeoErrDownload" = "Download failed"
 "customGeoErrUpdateAllIncomplete" = "One or more custom geo sources failed to update"
+"customGeoEmpty" = "No custom geo sources yet — click Add to create one"
 "dontRefresh" = "Installation is in progress, please do not refresh this page"
 "logs" = "Logs"
 "config" = "Config"

+ 1 - 0
web/translation/translate.es_ES.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "Fuente geo personalizada no encontrada"
 "customGeoErrDownload" = "Error de descarga"
 "customGeoErrUpdateAllIncomplete" = "No se pudieron actualizar una o más fuentes geo personalizadas"
+"customGeoEmpty" = "Aún no hay fuentes geo personalizadas — haz clic en Añadir para crear una"
 
 [pages.inbounds]
 "allTimeTraffic" = "Tráfico Total"

+ 1 - 0
web/translation/translate.fa_IR.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "منبع geo سفارشی یافت نشد"
 "customGeoErrDownload" = "بارگیری ناموفق بود"
 "customGeoErrUpdateAllIncomplete" = "به‌روزرسانی یک یا چند منبع geo سفارشی ناموفق بود"
+"customGeoEmpty" = "هنوز منبع geo سفارشی‌ای ثبت نشده — برای ایجاد روی «افزودن» کلیک کنید"
 
 [pages.inbounds]
 "allTimeTraffic" = "کل ترافیک"

+ 1 - 0
web/translation/translate.id_ID.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "Sumber geo kustom tidak ditemukan"
 "customGeoErrDownload" = "Unduh gagal"
 "customGeoErrUpdateAllIncomplete" = "Satu atau lebih sumber geo kustom gagal diperbarui"
+"customGeoEmpty" = "Belum ada sumber geo kustom — klik Tambah untuk membuatnya"
 
 [pages.inbounds]
 "allTimeTraffic" = "Total Lalu Lintas"

+ 1 - 0
web/translation/translate.ja_JP.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "カスタム geo ソースが見つかりません"
 "customGeoErrDownload" = "ダウンロードに失敗しました"
 "customGeoErrUpdateAllIncomplete" = "カスタム geo ソースの 1 件以上を更新できませんでした"
+"customGeoEmpty" = "カスタム geo ソースはまだありません — 「追加」をクリックして作成してください"
 
 [pages.inbounds]
 "allTimeTraffic" = "総トラフィック"

+ 1 - 0
web/translation/translate.pt_BR.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "Fonte geo personalizada não encontrada"
 "customGeoErrDownload" = "Falha no download"
 "customGeoErrUpdateAllIncomplete" = "Falha ao atualizar uma ou mais fontes geo personalizadas"
+"customGeoEmpty" = "Ainda não há fontes geo personalizadas — clique em Adicionar para criar uma"
 
 [pages.inbounds]
 "allTimeTraffic" = "Tráfego Total"

+ 1 - 0
web/translation/translate.ru_RU.toml

@@ -204,6 +204,7 @@
 "customGeoErrNotFound" = "Источник не найден"
 "customGeoErrDownload" = "Ошибка загрузки"
 "customGeoErrUpdateAllIncomplete" = "Не удалось обновить один или несколько пользовательских источников"
+"customGeoEmpty" = "Пользовательских источников geo пока нет — нажмите «Добавить», чтобы создать"
 "dontRefresh" = "Установка в процессе. Не обновляйте страницу"
 "logs" = "Журнал"
 "config" = "Конфигурация"

+ 1 - 0
web/translation/translate.tr_TR.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "Özel geo kaynağı bulunamadı"
 "customGeoErrDownload" = "İndirme başarısız"
 "customGeoErrUpdateAllIncomplete" = "Bir veya daha fazla özel geo kaynağı güncellenemedi"
+"customGeoEmpty" = "Henüz özel geo kaynağı yok — oluşturmak için Ekle'ye tıklayın"
 
 [pages.inbounds]
 "allTimeTraffic" = "Toplam Trafik"

+ 1 - 0
web/translation/translate.uk_UA.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "Джерело geo не знайдено"
 "customGeoErrDownload" = "Помилка завантаження"
 "customGeoErrUpdateAllIncomplete" = "Не вдалося оновити один або кілька користувацьких джерел"
+"customGeoEmpty" = "Користувацьких джерел geo поки немає — натисніть «Додати», щоб створити"
 
 [pages.inbounds]
 "allTimeTraffic" = "Загальний трафік"

+ 1 - 0
web/translation/translate.vi_VN.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "Không tìm thấy nguồn geo tùy chỉnh"
 "customGeoErrDownload" = "Tải xuống thất bại"
 "customGeoErrUpdateAllIncomplete" = "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được"
+"customGeoEmpty" = "Chưa có nguồn geo tùy chỉnh nào — nhấp Thêm để tạo"
 
 [pages.inbounds]
 "allTimeTraffic" = "Tổng Lưu Lượng"

+ 1 - 0
web/translation/translate.zh_CN.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "未找到自定义 geo 源"
 "customGeoErrDownload" = "下载失败"
 "customGeoErrUpdateAllIncomplete" = "有一个或多个自定义 geo 源更新失败"
+"customGeoEmpty" = "暂无自定义 geo 源 — 点击「添加」以创建"
 
 [pages.inbounds]
 "allTimeTraffic" = "累计总流量"

+ 1 - 0
web/translation/translate.zh_TW.toml

@@ -218,6 +218,7 @@
 "customGeoErrNotFound" = "找不到自訂 geo 來源"
 "customGeoErrDownload" = "下載失敗"
 "customGeoErrUpdateAllIncomplete" = "有一個或多個自訂 geo 來源更新失敗"
+"customGeoEmpty" = "尚無自訂 geo 來源 — 點擊「新增」以建立"
 
 [pages.inbounds]
 "allTimeTraffic" = "累計總流量"

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů