Przeglądaj źródła

feat(balancers): tabbed Observatory/Burst Observatory form (#5627)

* feat(balancers): tabbed Observatory/Burst form replacing raw JSON

Replace the raw JSON editor for the Observatory / Burst Observatory sections
with a proper Ant Design form, and split the Balancers page into two sub-tabs:
"Balancer Settings" (the existing table) and "Observatory".

Observers stay fully auto-managed by balancer strategy through the existing
syncObservatories logic: users edit only the tunable probe fields, the
subjectSelector is shown read-only since it is derived from the balancers, and
deleting the last balancer that needs an observer now warns in the confirm
dialog that the observer will be removed too. Overlapping selectors keep an
observer alive while any balancer still references it.

Also add the previously missing pingConfig.httpMethod field (HEAD/GET) and
translations for the new strings across all 13 locales.

* refactor(balancers): tighten httpMethod typing and align connectivity default

Address automated review feedback on the Observatory form:
- Use the ObservatoryHttpMethodSchema enum for pingConfig.httpMethod instead of
  a free-form z.string(), and drive the HTTP method Select from its options.
  Removes the previously dead enum export and the duplicate local list, and
  types the field as 'HEAD' | 'GET'.
- Align the schema's connectivity default with DEFAULT_BURST_OBSERVATORY (the
  hicloud URL) so it matches what burst observers are actually created with.

No behavior change.
nima1024m 15 godzin temu
rodzic
commit
25a86b9ee2

+ 62 - 85
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -1,13 +1,14 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Divider, Dropdown, Empty, Modal, Radio, Select, Space, Table, Tag, Tooltip } from 'antd';
-import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined } from '@ant-design/icons';
+import { Button, Dropdown, Empty, Modal, Select, Space, Table, Tabs, Tag, Tooltip } from 'antd';
+import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined, DeploymentUnitOutlined, RadarChartOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
 import BalancerFormModal from './BalancerFormModal';
 import type { BalancerFormValue } from './BalancerFormModal';
-import { syncObservatories } from './balancer-helpers';
-import { JsonEditor } from '@/components/form';
+import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers';
+import ObservatorySettingsTab from './ObservatorySettingsTab';
+import { catTabLabel } from '@/pages/settings/catTabLabel';
 import { HttpUtil } from '@/utils';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
 import type {
@@ -184,8 +185,15 @@ export default function BalancersTab({
   }
 
   function confirmDelete(idx: number) {
+    const removed = templateSettings
+      ? observersRemovedByDeletingBalancer(templateSettings, idx)
+      : { observatory: false, burst: false };
+    const warnings: string[] = [];
+    if (removed.observatory) warnings.push(t('pages.xray.observatory.deleteAlsoObservatory'));
+    if (removed.burst) warnings.push(t('pages.xray.observatory.deleteAlsoBurst'));
     modal.confirm({
       title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
+      content: warnings.length ? warnings.join(' ') : undefined,
       okText: t('delete'),
       okType: 'danger',
       cancelText: t('cancel'),
@@ -316,92 +324,61 @@ export default function BalancersTab({
     },
   ];
 
-  const hasObservatory = !!templateSettings?.observatory;
-  const hasBurstObservatory = !!templateSettings?.burstObservatory;
-  const showObsEditor = hasObservatory || hasBurstObservatory;
-
-  const [obsView, setObsView] = useState<'observatory' | 'burstObservatory'>('observatory');
-
-  useEffect(() => {
-    if (obsView === 'observatory' && !hasObservatory && hasBurstObservatory) {
-      setObsView('burstObservatory');
-    } else if (obsView === 'burstObservatory' && !hasBurstObservatory && hasObservatory) {
-      setObsView('observatory');
-    }
-  }, [obsView, hasObservatory, hasBurstObservatory]);
-
-  const obsText = useMemo(() => {
-    const src = obsView === 'observatory' ? templateSettings?.observatory : templateSettings?.burstObservatory;
-    return src ? JSON.stringify(src, null, 2) : '';
-  }, [obsView, templateSettings?.observatory, templateSettings?.burstObservatory]);
-
-  function onObsTextChange(next: string) {
-    let parsed;
-    try {
-      parsed = JSON.parse(next);
-    } catch {
-      return;
-    }
-    mutate((tt) => {
-      if (obsView === 'observatory') tt.observatory = parsed;
-      else tt.burstObservatory = parsed;
-    });
-  }
-
-  return (
-    <>
-      {modalContextHolder}
-      <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
-        {rows.length === 0 ? (
-          <Empty description={t('emptyBalancersDesc')}>
+  const balancerSettingsTab = (
+    <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
+      {rows.length === 0 ? (
+        <Empty description={t('emptyBalancersDesc')}>
+          <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
+            {t('pages.xray.Balancers')}
+          </Button>
+        </Empty>
+      ) : (
+        <>
+          <Space>
             <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
               {t('pages.xray.Balancers')}
             </Button>
-          </Empty>
-        ) : (
-          <>
-            <Space>
-              <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
-                {t('pages.xray.Balancers')}
-              </Button>
-              <Tooltip title={t('pages.xray.balancerLiveRefresh')}>
-                <Button icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
-              </Tooltip>
-            </Space>
+            <Tooltip title={t('pages.xray.balancerLiveRefresh')}>
+              <Button icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
+            </Tooltip>
+          </Space>
 
-            <Table
-              columns={columns}
-              dataSource={rows}
-              rowKey={(r) => r.key}
-              pagination={false}
-              size="small"
-              scroll={{ x: 700 }}
-            />
+          <Table
+            columns={columns}
+            dataSource={rows}
+            rowKey={(r) => r.key}
+            pagination={false}
+            size="small"
+            scroll={{ x: 700 }}
+          />
+        </>
+      )}
+    </Space>
+  );
 
-            {showObsEditor && (
-              <>
-                <Divider style={{ margin: '8px 0' }} />
-                <Radio.Group
-                  value={obsView}
-                  onChange={(e) => setObsView(e.target.value)}
-                  optionType="button"
-                  buttonStyle="solid"
-                  size="small"
-                >
-                  {hasObservatory && <Radio.Button value="observatory">Observatory</Radio.Button>}
-                  {hasBurstObservatory && <Radio.Button value="burstObservatory">Burst Observatory</Radio.Button>}
-                </Radio.Group>
-                <JsonEditor
-                  value={obsText}
-                  onChange={onObsTextChange}
-                  minHeight="220px"
-                  maxHeight="480px"
-                />
-              </>
-            )}
-          </>
-        )}
-      </Space>
+  return (
+    <>
+      {modalContextHolder}
+      <Tabs
+        items={[
+          {
+            key: 'balancers',
+            label: catTabLabel(<DeploymentUnitOutlined />, t('pages.xray.tabBalancerSettings'), isMobile),
+            children: balancerSettingsTab,
+          },
+          {
+            key: 'observatory',
+            label: catTabLabel(<RadarChartOutlined />, t('pages.xray.tabObservatory'), isMobile),
+            children: (
+              <ObservatorySettingsTab
+                templateSettings={templateSettings}
+                mutate={mutate}
+                isMobile={isMobile}
+              />
+            ),
+          },
+        ]}
+      />
 
       <BalancerFormModal
         key={modalOpen ? `${editingIndex ?? 'new'}-${editingBalancer?.tag ?? ''}` : 'closed'}

+ 238 - 0
frontend/src/pages/xray/balancers/ObservatorySettingsTab.tsx

@@ -0,0 +1,238 @@
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Empty, Input, InputNumber, Segmented, Select, Space, Switch, Tag } from 'antd';
+
+import { SettingListItem } from '@/components/ui';
+import {
+  BurstObservatorySchema,
+  ObservatoryHttpMethodSchema,
+  ObservatorySchema,
+  type BurstObservatoryObject,
+  type ObservatoryHttpMethod,
+  type ObservatoryObject,
+  type PingConfigObject,
+} from '@/schemas/observatory';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+interface ObservatorySettingsTabProps {
+  templateSettings: XraySettingsValue | null;
+  mutate: (mutator: (next: XraySettingsValue) => void) => void;
+  isMobile: boolean;
+}
+
+const OBSERVATORY_DEFAULTS = ObservatorySchema.parse({});
+const BURST_DEFAULTS = BurstObservatorySchema.parse({});
+
+function asObject(value: unknown): Record<string, unknown> {
+  return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
+}
+
+function SelectorTags({ tags }: { tags: string[] }) {
+  if (!tags || tags.length === 0) return <Tag>—</Tag>;
+  return (
+    <>
+      {tags.map((sel) => (
+        <Tag key={sel} className="info-large-tag" style={{ margin: 0, marginRight: 4, marginBottom: 4 }}>
+          {sel}
+        </Tag>
+      ))}
+    </>
+  );
+}
+
+export default function ObservatorySettingsTab({
+  templateSettings,
+  mutate,
+  isMobile,
+}: ObservatorySettingsTabProps) {
+  const { t } = useTranslation();
+
+  const observatory = useMemo<ObservatoryObject | null>(() => {
+    const raw = templateSettings?.observatory;
+    if (raw == null) return null;
+    return { ...OBSERVATORY_DEFAULTS, ...asObject(raw) } as ObservatoryObject;
+  }, [templateSettings?.observatory]);
+
+  const burst = useMemo<BurstObservatoryObject | null>(() => {
+    const raw = templateSettings?.burstObservatory;
+    if (raw == null) return null;
+    const merged = { ...BURST_DEFAULTS, ...asObject(raw) } as BurstObservatoryObject;
+    merged.pingConfig = { ...BURST_DEFAULTS.pingConfig, ...asObject(merged.pingConfig) } as PingConfigObject;
+    return merged;
+  }, [templateSettings?.burstObservatory]);
+
+  const hasObservatory = observatory != null;
+  const hasBurst = burst != null;
+
+  const [view, setView] = useState<'observatory' | 'burstObservatory'>('observatory');
+  const effectiveView = !hasObservatory && hasBurst
+    ? 'burstObservatory'
+    : !hasBurst && hasObservatory
+      ? 'observatory'
+      : view;
+
+  function patchObservatory(patch: Partial<ObservatoryObject>) {
+    mutate((tt) => {
+      tt.observatory = { ...OBSERVATORY_DEFAULTS, ...asObject(tt.observatory), ...patch };
+    });
+  }
+
+  function patchPingConfig(patch: Partial<PingConfigObject>) {
+    mutate((tt) => {
+      const current = asObject(tt.burstObservatory);
+      const currentPing = asObject(current.pingConfig);
+      tt.burstObservatory = {
+        ...BURST_DEFAULTS,
+        ...current,
+        pingConfig: { ...BURST_DEFAULTS.pingConfig, ...currentPing, ...patch },
+      };
+    });
+  }
+
+  if (!hasObservatory && !hasBurst) {
+    return <Empty description={t('pages.xray.observatory.emptyHint')} />;
+  }
+
+  const observatorySection = observatory && (
+    <>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.subjectSelector')}
+        description={t('pages.xray.observatory.subjectSelectorDesc')}
+      >
+        <SelectorTags tags={observatory.subjectSelector} />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.probeURL')}
+        description={t('pages.xray.observatory.probeURLDesc')}
+      >
+        <Input
+          value={observatory.probeURL}
+          onChange={(e) => patchObservatory({ probeURL: e.target.value })}
+          placeholder="https://www.google.com/generate_204"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.probeInterval')}
+        description={t('pages.xray.observatory.probeIntervalDesc')}
+      >
+        <Input
+          value={observatory.probeInterval}
+          onChange={(e) => patchObservatory({ probeInterval: e.target.value })}
+          placeholder="1m"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.enableConcurrency')}
+        description={t('pages.xray.observatory.enableConcurrencyDesc')}
+      >
+        <Switch
+          checked={observatory.enableConcurrency}
+          onChange={(v) => patchObservatory({ enableConcurrency: v })}
+        />
+      </SettingListItem>
+    </>
+  );
+
+  const burstSection = burst && (
+    <>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.subjectSelector')}
+        description={t('pages.xray.observatory.subjectSelectorDesc')}
+      >
+        <SelectorTags tags={burst.subjectSelector} />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.destination')}
+        description={t('pages.xray.observatory.destinationDesc')}
+      >
+        <Input
+          value={burst.pingConfig.destination}
+          onChange={(e) => patchPingConfig({ destination: e.target.value })}
+          placeholder="https://www.google.com/generate_204"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.connectivity')}
+        description={t('pages.xray.observatory.connectivityDesc')}
+      >
+        <Input
+          value={burst.pingConfig.connectivity}
+          allowClear
+          onChange={(e) => patchPingConfig({ connectivity: e.target.value })}
+          placeholder="http://connectivitycheck.platform.hicloud.com/generate_204"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.interval')}
+        description={t('pages.xray.observatory.intervalDesc')}
+      >
+        <Input
+          value={burst.pingConfig.interval}
+          onChange={(e) => patchPingConfig({ interval: e.target.value })}
+          placeholder="1m"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.timeout')}
+        description={t('pages.xray.observatory.timeoutDesc')}
+      >
+        <Input
+          value={burst.pingConfig.timeout}
+          onChange={(e) => patchPingConfig({ timeout: e.target.value })}
+          placeholder="5s"
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.sampling')}
+        description={t('pages.xray.observatory.samplingDesc')}
+      >
+        <InputNumber
+          min={1}
+          value={burst.pingConfig.sampling}
+          onChange={(v) => patchPingConfig({ sampling: typeof v === 'number' ? v : burst.pingConfig.sampling })}
+          style={{ width: '100%' }}
+        />
+      </SettingListItem>
+      <SettingListItem
+        paddings="small"
+        title={t('pages.xray.observatory.httpMethod')}
+        description={t('pages.xray.observatory.httpMethodDesc')}
+      >
+        <Select<ObservatoryHttpMethod>
+          value={burst.pingConfig.httpMethod}
+          onChange={(v) => patchPingConfig({ httpMethod: v })}
+          options={ObservatoryHttpMethodSchema.options.map((m) => ({ value: m, label: m }))}
+          style={{ width: '100%' }}
+        />
+      </SettingListItem>
+    </>
+  );
+
+  return (
+    <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
+      <Alert type="info" showIcon message={t('pages.xray.observatory.autoManaged')} />
+      {hasObservatory && hasBurst && (
+        <Segmented
+          block={isMobile}
+          value={effectiveView}
+          onChange={(v) => setView(v as 'observatory' | 'burstObservatory')}
+          options={[
+            { label: t('pages.xray.observatory.title'), value: 'observatory' },
+            { label: t('pages.xray.observatory.burstTitle'), value: 'burstObservatory' },
+          ]}
+        />
+      )}
+      <div>{effectiveView === 'observatory' ? observatorySection : burstSection}</div>
+    </Space>
+  );
+}

+ 17 - 0
frontend/src/pages/xray/balancers/balancer-helpers.ts

@@ -16,6 +16,7 @@ export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
     connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
     timeout: '5s',
     sampling: 2,
+    httpMethod: 'HEAD',
   },
 });
 
@@ -71,3 +72,19 @@ export function syncObservatories(t: XraySettingsValue) {
     delete t.burstObservatory;
   }
 }
+
+export function observersRemovedByDeletingBalancer(
+  t: XraySettingsValue,
+  idx: number,
+): { observatory: boolean; burst: boolean } {
+  const hadObservatory = !!t.observatory;
+  const hadBurst = !!t.burstObservatory;
+  if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
+  const clone = JSON.parse(JSON.stringify(t)) as XraySettingsValue;
+  if (clone.routing?.balancers) clone.routing.balancers.splice(idx, 1);
+  syncObservatories(clone);
+  return {
+    observatory: hadObservatory && !clone.observatory,
+    burst: hadBurst && !clone.burstObservatory,
+  };
+}

+ 34 - 0
frontend/src/schemas/observatory.ts

@@ -0,0 +1,34 @@
+import { z } from 'zod';
+
+export const ObservatorySchema = z
+  .object({
+    subjectSelector: z.array(z.string()).default([]),
+    probeURL: z.string().default('https://www.google.com/generate_204'),
+    probeInterval: z.string().default('1m'),
+    enableConcurrency: z.boolean().default(true),
+  })
+  .loose();
+export type ObservatoryObject = z.infer<typeof ObservatorySchema>;
+
+export const ObservatoryHttpMethodSchema = z.enum(['HEAD', 'GET']);
+export type ObservatoryHttpMethod = z.infer<typeof ObservatoryHttpMethodSchema>;
+
+export const PingConfigSchema = z
+  .object({
+    destination: z.string().default('https://www.google.com/generate_204'),
+    connectivity: z.string().default('http://connectivitycheck.platform.hicloud.com/generate_204'),
+    interval: z.string().default('1m'),
+    timeout: z.string().default('5s'),
+    sampling: z.number().int().min(1).default(2),
+    httpMethod: ObservatoryHttpMethodSchema.default('HEAD'),
+  })
+  .loose();
+export type PingConfigObject = z.infer<typeof PingConfigSchema>;
+
+export const BurstObservatorySchema = z
+  .object({
+    subjectSelector: z.array(z.string()).default([]),
+    pingConfig: PingConfigSchema.default(PingConfigSchema.parse({})),
+  })
+  .loose();
+export type BurstObservatoryObject = z.infer<typeof BurstObservatorySchema>;

+ 66 - 1
frontend/src/test/balancer-observatory-sync.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest';
 
-import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
+import { observersRemovedByDeletingBalancer, syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
 import type { XraySettingsValue } from '@/hooks/useXraySetting';
 
 function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
@@ -111,4 +111,69 @@ describe('syncObservatories', () => {
     expect(t.observatory).toBeUndefined();
     expect(t.burstObservatory).toBeUndefined();
   });
+
+  it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
+    syncObservatories(t);
+    const burst = t.burstObservatory as { pingConfig: { httpMethod: string; sampling: number } };
+    expect(burst.pingConfig.httpMethod).toBe('HEAD');
+    expect(burst.pingConfig.sampling).toBe(2);
+  });
+
+  it('drops only the prefixes no remaining balancer uses (note #2)', () => {
+    const t = tpl({
+      balancers: [
+        { tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
+        { tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
+      ],
+    });
+    syncObservatories(t);
+    expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
+      new Set(['prefixA', 'prefixB', 'prefixC']),
+    );
+    (t.routing as { balancers: unknown[] }).balancers.splice(0, 1);
+    syncObservatories(t);
+    expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
+      new Set(['prefixC', 'prefixB']),
+    );
+  });
+});
+
+describe('observersRemovedByDeletingBalancer', () => {
+  it('reports the burst observer as removed when deleting the last leastLoad balancer', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
+    syncObservatories(t);
+    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: true });
+  });
+
+  it('keeps the burst observer when another balancer still needs it (overlap)', () => {
+    const t = tpl({
+      balancers: [
+        { tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
+        { tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
+      ],
+    });
+    syncObservatories(t);
+    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
+  });
+
+  it('reports the regular observer as removed when deleting the last leastPing balancer', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
+    syncObservatories(t);
+    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: true, burst: false });
+  });
+
+  it('reports nothing removed when the balancer never had an observer', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'] }] });
+    syncObservatories(t);
+    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
+  });
+
+  it('does not mutate the template it inspects', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
+    syncObservatories(t);
+    const before = JSON.stringify(t);
+    observersRemovedByDeletingBalancer(t, 0);
+    expect(JSON.stringify(t)).toBe(before);
+  });
 });

+ 30 - 0
internal/web/translation/ar-EG.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "تم الحذف",
         "toastDeleteFailed": "فشل الحذف"
       },
+      "tabBalancerSettings": "إعدادات الموازن",
+      "tabObservatory": "المرصد",
+      "observatory": {
+        "title": "المرصد",
+        "burstTitle": "مرصد Burst",
+        "autoManaged": "تتم إدارة المراصد تلقائيًا من الموازنات لديك. اضبط طريقة الفحص بالأسفل؛ تتبع الوجهات الصادرة المراقَبة محدِّدات الموازن.",
+        "emptyHint": "لا يوجد مرصد اتصال نشط. تتم إضافة واحد تلقائيًا عند إنشاء موازن Least Ping أو Least Load — أو موازن Random / Round-robin مع fallback — حتى يتمكن الموازن من قياس الوجهات الصادرة واختيار الأفضل.",
+        "subjectSelector": "الوجهات المراقَبة",
+        "subjectSelectorDesc": "وسوم الوجهات الصادرة التي يفحصها هذا المرصد. تتم إدارتها تلقائيًا من الموازنات لديك.",
+        "probeURL": "رابط الفحص (URL)",
+        "probeURLDesc": "الرابط الذي يُطلب لقياس كل وجهة صادرة. يجب أن يُعيد HTTP 204.",
+        "probeInterval": "فترة الفحص",
+        "probeIntervalDesc": "كم مرة يتم فحص كل وجهة صادرة، مثل 30s أو 1m أو 2h45m.",
+        "enableConcurrency": "فحص متزامن",
+        "enableConcurrencyDesc": "افحص كل الوجهات المراقَبة دفعة واحدة بدلًا من واحدة تلو الأخرى. أسرع، لكنه أكثر وضوحًا على الشبكة.",
+        "destination": "وجهة الفحص",
+        "destinationDesc": "الرابط الذي يُطلب لقياس كل وجهة صادرة. يجب أن يُعيد HTTP 204.",
+        "connectivity": "فحص الاتصال",
+        "connectivityDesc": "رابط اختياري لفحص الشبكة المحلية، يُجرَّب فقط بعد فشل الوجهة. اتركه فارغًا للتخطي.",
+        "interval": "فترة الفحص",
+        "intervalDesc": "متوسط الوقت بين عمليات الفحص لكل وجهة صادرة، مثل 1m. الحد الأدنى 10s.",
+        "timeout": "مهلة الفحص",
+        "timeoutDesc": "كم من الوقت يُنتظر استجابة الفحص قبل اعتباره فاشلًا، مثل 5s.",
+        "sampling": "عدد العينات",
+        "samplingDesc": "عدد نتائج الفحص الأخيرة المحفوظة لتقييم كل وجهة صادرة.",
+        "httpMethod": "طريقة HTTP",
+        "httpMethodDesc": "طريقة HTTP المستخدمة في عمليات الفحص.",
+        "deleteAlsoObservatory": "هذا آخر موازن يستخدم Observatory، لذلك ستتم إزالته أيضًا.",
+        "deleteAlsoBurst": "هذا آخر موازن يستخدم Burst Observatory، لذلك ستتم إزالته أيضًا."
+      },
       "balancer": {
         "addBalancer": "أضف موازن تحميل",
         "editBalancer": "عدل موازن التحميل",

+ 30 - 0
internal/web/translation/en-US.json

@@ -1782,6 +1782,36 @@
         "toastDeleted": "Deleted",
         "toastDeleteFailed": "Delete failed"
       },
+      "tabBalancerSettings": "Balancer Settings",
+      "tabObservatory": "Observatory",
+      "observatory": {
+        "title": "Observatory",
+        "burstTitle": "Burst Observatory",
+        "autoManaged": "Observers are managed automatically from your balancers. Tune how they probe below — the watched outbounds follow your balancer selectors.",
+        "emptyHint": "No connection observer is active. One is added automatically when you create a Least Ping or Least Load balancer — or a Random / Round-robin balancer with a fallback — so the balancer can measure your outbounds and pick the best one.",
+        "subjectSelector": "Watched Outbounds",
+        "subjectSelectorDesc": "Outbound tags this observer probes. Managed automatically from your balancers.",
+        "probeURL": "Probe URL",
+        "probeURLDesc": "URL fetched to measure each outbound. Should return HTTP 204.",
+        "probeInterval": "Probe Interval",
+        "probeIntervalDesc": "How often to probe each outbound, e.g. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Concurrent Probing",
+        "enableConcurrencyDesc": "Probe all watched outbounds at once instead of one-by-one. Faster, but more visible on the network.",
+        "destination": "Probe Destination",
+        "destinationDesc": "URL fetched to measure each outbound. Should return HTTP 204.",
+        "connectivity": "Connectivity Check",
+        "connectivityDesc": "Optional local-network check URL, tried only after the destination fails. Leave empty to skip.",
+        "interval": "Probe Interval",
+        "intervalDesc": "Average time between probes per outbound, e.g. 1m. Minimum 10s.",
+        "timeout": "Probe Timeout",
+        "timeoutDesc": "How long to wait for a probe before it counts as failed, e.g. 5s.",
+        "sampling": "Sampling Count",
+        "samplingDesc": "Number of recent probe results kept to score each outbound.",
+        "httpMethod": "HTTP Method",
+        "httpMethodDesc": "HTTP method used for probes.",
+        "deleteAlsoObservatory": "This is the last balancer using the Observatory, so it will be removed too.",
+        "deleteAlsoBurst": "This is the last balancer using the Burst Observatory, so it will be removed too."
+      },
       "balancer": {
         "addBalancer": "Add Balancer",
         "editBalancer": "Edit Balancer",

+ 30 - 0
internal/web/translation/es-ES.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "Eliminada",
         "toastDeleteFailed": "Error al eliminar"
       },
+      "tabBalancerSettings": "Ajustes del balanceador",
+      "tabObservatory": "Observatorio",
+      "observatory": {
+        "title": "Observatorio",
+        "burstTitle": "Observatorio Burst",
+        "autoManaged": "Los observadores se gestionan automáticamente a partir de tus balanceadores. Ajusta abajo cómo sondean; las salidas vigiladas siguen los selectores del balanceador.",
+        "emptyHint": "No hay ningún observador de conexión activo. Se añade uno automáticamente al crear un balanceador Least Ping o Least Load —o un balanceador Random / Round-robin con fallback— para que el balanceador pueda medir tus salidas y elegir la mejor.",
+        "subjectSelector": "Salidas vigiladas",
+        "subjectSelectorDesc": "Etiquetas de salida que sondea este observador. Se gestionan automáticamente a partir de tus balanceadores.",
+        "probeURL": "URL de sondeo",
+        "probeURLDesc": "URL solicitada para medir cada salida. Debe devolver HTTP 204.",
+        "probeInterval": "Intervalo de sondeo",
+        "probeIntervalDesc": "Con qué frecuencia se sondea cada salida, p. ej. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Sondeo concurrente",
+        "enableConcurrencyDesc": "Sondea todas las salidas vigiladas a la vez en lugar de una a una. Más rápido, pero más visible en la red.",
+        "destination": "Destino de sondeo",
+        "destinationDesc": "URL solicitada para medir cada salida. Debe devolver HTTP 204.",
+        "connectivity": "Comprobación de conectividad",
+        "connectivityDesc": "URL opcional de comprobación de red local, se prueba solo si falla el destino. Déjalo vacío para omitir.",
+        "interval": "Intervalo de sondeo",
+        "intervalDesc": "Tiempo medio entre sondeos por salida, p. ej. 1m. Mínimo 10s.",
+        "timeout": "Tiempo de espera del sondeo",
+        "timeoutDesc": "Cuánto esperar un sondeo antes de considerarlo fallido, p. ej. 5s.",
+        "sampling": "Número de muestras",
+        "samplingDesc": "Número de resultados de sondeo recientes que se conservan para puntuar cada salida.",
+        "httpMethod": "Método HTTP",
+        "httpMethodDesc": "Método HTTP usado para los sondeos.",
+        "deleteAlsoObservatory": "Este es el último balanceador que usa el Observatorio, por lo que también se eliminará.",
+        "deleteAlsoBurst": "Este es el último balanceador que usa el Observatorio Burst, por lo que también se eliminará."
+      },
       "balancer": {
         "addBalancer": "Agregar equilibrador",
         "editBalancer": "Editar balanceador",

+ 30 - 0
internal/web/translation/fa-IR.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "حذف شد",
         "toastDeleteFailed": "حذف ناموفق بود"
       },
+      "tabBalancerSettings": "تنظیمات بالانسر",
+      "tabObservatory": "رصدخانه",
+      "observatory": {
+        "title": "رصدخانه",
+        "burstTitle": "رصدخانه Burst",
+        "autoManaged": "رصدگرها به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شوند. در ادامه می‌توانید نحوهٔ پروب‌زدن را تنظیم کنید؛ خروجی‌های تحت نظر از سلکتورهای بالانسر پیروی می‌کنند.",
+        "emptyHint": "هیچ رصدگر اتصالی فعال نیست. وقتی یک بالانسر Least Ping یا Least Load بسازید — یا یک بالانسر Random / Round-robin همراه با fallback — به‌صورت خودکار یکی اضافه می‌شود تا بالانسر بتواند خروجی‌ها را اندازه‌گیری کند و بهترین را انتخاب کند.",
+        "subjectSelector": "خروجی‌های تحت نظر",
+        "subjectSelectorDesc": "تگ خروجی‌هایی که این رصدگر پروب می‌کند. به‌صورت خودکار از روی بالانسرهای شما مدیریت می‌شود.",
+        "probeURL": "آدرس پروب (URL)",
+        "probeURLDesc": "آدرسی که برای سنجش هر خروجی فراخوانی می‌شود. باید HTTP 204 برگرداند.",
+        "probeInterval": "بازهٔ پروب",
+        "probeIntervalDesc": "هر چند وقت یک‌بار هر خروجی پروب شود، مثلاً 30s یا 1m یا 2h45m.",
+        "enableConcurrency": "پروب هم‌زمان",
+        "enableConcurrencyDesc": "همهٔ خروجی‌های تحت نظر را به‌جای یکی‌یکی، هم‌زمان پروب کن. سریع‌تر است اما در شبکه نمایان‌تر.",
+        "destination": "مقصد پروب",
+        "destinationDesc": "آدرسی که برای سنجش هر خروجی فراخوانی می‌شود. باید HTTP 204 برگرداند.",
+        "connectivity": "بررسی اتصال",
+        "connectivityDesc": "آدرس اختیاری برای بررسی شبکهٔ محلی که فقط پس از شکست مقصد امتحان می‌شود. برای نادیده‌گرفتن خالی بگذارید.",
+        "interval": "بازهٔ پروب",
+        "intervalDesc": "میانگین فاصلهٔ زمانی بین پروب‌ها برای هر خروجی، مثلاً 1m. کمینه 10s.",
+        "timeout": "مهلت پروب",
+        "timeoutDesc": "چه مدت برای پاسخ یک پروب صبر شود تا ناموفق به‌حساب بیاید، مثلاً 5s.",
+        "sampling": "تعداد نمونه‌گیری",
+        "samplingDesc": "تعداد نتایج اخیر پروب که برای امتیازدهی به هر خروجی نگه داشته می‌شود.",
+        "httpMethod": "متد HTTP",
+        "httpMethodDesc": "متد HTTP که برای پروب‌ها استفاده می‌شود.",
+        "deleteAlsoObservatory": "این آخرین بالانسری است که از Observatory استفاده می‌کند، بنابراین آن هم حذف خواهد شد.",
+        "deleteAlsoBurst": "این آخرین بالانسری است که از Burst Observatory استفاده می‌کند، بنابراین آن هم حذف خواهد شد."
+      },
       "balancer": {
         "addBalancer": "افزودن بالانسر",
         "editBalancer": "ویرایش بالانسر",

+ 30 - 0
internal/web/translation/id-ID.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "Dihapus",
         "toastDeleteFailed": "Gagal menghapus"
       },
+      "tabBalancerSettings": "Pengaturan Balancer",
+      "tabObservatory": "Observatory",
+      "observatory": {
+        "title": "Observatory",
+        "burstTitle": "Burst Observatory",
+        "autoManaged": "Observer dikelola otomatis dari balancer Anda. Atur cara mereka melakukan probe di bawah; outbound yang dipantau mengikuti selector balancer.",
+        "emptyHint": "Tidak ada observer koneksi yang aktif. Satu akan ditambahkan otomatis saat Anda membuat balancer Least Ping atau Least Load — atau balancer Random / Round-robin dengan fallback — sehingga balancer dapat mengukur outbound Anda dan memilih yang terbaik.",
+        "subjectSelector": "Outbound yang Dipantau",
+        "subjectSelectorDesc": "Tag outbound yang di-probe observer ini. Dikelola otomatis dari balancer Anda.",
+        "probeURL": "URL Probe",
+        "probeURLDesc": "URL yang diminta untuk mengukur setiap outbound. Harus mengembalikan HTTP 204.",
+        "probeInterval": "Interval Probe",
+        "probeIntervalDesc": "Seberapa sering memprobe tiap outbound, mis. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Probe Bersamaan",
+        "enableConcurrencyDesc": "Probe semua outbound yang dipantau sekaligus, bukan satu per satu. Lebih cepat, tetapi lebih terlihat di jaringan.",
+        "destination": "Tujuan Probe",
+        "destinationDesc": "URL yang diminta untuk mengukur setiap outbound. Harus mengembalikan HTTP 204.",
+        "connectivity": "Pemeriksaan Konektivitas",
+        "connectivityDesc": "URL pemeriksaan jaringan lokal opsional, dicoba hanya setelah tujuan gagal. Kosongkan untuk melewati.",
+        "interval": "Interval Probe",
+        "intervalDesc": "Rata-rata waktu antar probe per outbound, mis. 1m. Minimal 10s.",
+        "timeout": "Batas Waktu Probe",
+        "timeoutDesc": "Berapa lama menunggu probe sebelum dianggap gagal, mis. 5s.",
+        "sampling": "Jumlah Sampling",
+        "samplingDesc": "Jumlah hasil probe terbaru yang disimpan untuk menilai tiap outbound.",
+        "httpMethod": "Metode HTTP",
+        "httpMethodDesc": "Metode HTTP yang digunakan untuk probe.",
+        "deleteAlsoObservatory": "Ini balancer terakhir yang memakai Observatory, jadi itu juga akan dihapus.",
+        "deleteAlsoBurst": "Ini balancer terakhir yang memakai Burst Observatory, jadi itu juga akan dihapus."
+      },
       "balancer": {
         "addBalancer": "Tambahkan Penyeimbang",
         "editBalancer": "Sunting Penyeimbang",

+ 30 - 0
internal/web/translation/ja-JP.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "削除しました",
         "toastDeleteFailed": "削除に失敗しました"
       },
+      "tabBalancerSettings": "バランサー設定",
+      "tabObservatory": "オブザーバトリ",
+      "observatory": {
+        "title": "オブザーバトリ",
+        "burstTitle": "バースト オブザーバトリ",
+        "autoManaged": "オブザーバはバランサーから自動的に管理されます。プローブの方法は下で調整できます。監視対象のアウトバウンドはバランサーのセレクターに従います。",
+        "emptyHint": "有効な接続オブザーバはありません。Least Ping または Least Load のバランサー、あるいは fallback 付きの Random / Round-robin バランサーを作成すると自動的に追加され、バランサーがアウトバウンドを測定して最適なものを選べるようになります。",
+        "subjectSelector": "監視対象のアウトバウンド",
+        "subjectSelectorDesc": "このオブザーバがプローブするアウトバウンドのタグ。バランサーから自動的に管理されます。",
+        "probeURL": "プローブ URL",
+        "probeURLDesc": "各アウトバウンドを測定するために取得する URL。HTTP 204 を返す必要があります。",
+        "probeInterval": "プローブ間隔",
+        "probeIntervalDesc": "各アウトバウンドをプローブする頻度。例: 30s、1m、2h45m。",
+        "enableConcurrency": "並行プローブ",
+        "enableConcurrencyDesc": "監視対象のアウトバウンドを1つずつではなく一度にプローブします。高速ですが、ネットワーク上で目立ちます。",
+        "destination": "プローブ先",
+        "destinationDesc": "各アウトバウンドを測定するために取得する URL。HTTP 204 を返す必要があります。",
+        "connectivity": "接続チェック",
+        "connectivityDesc": "任意のローカルネットワーク確認 URL。プローブ先が失敗した場合にのみ試行されます。空欄でスキップ。",
+        "interval": "プローブ間隔",
+        "intervalDesc": "アウトバウンドごとのプローブ間の平均時間。例: 1m。最小 10s。",
+        "timeout": "プローブ タイムアウト",
+        "timeoutDesc": "プローブを失敗とみなすまでの待機時間。例: 5s。",
+        "sampling": "サンプリング数",
+        "samplingDesc": "各アウトバウンドを評価するために保持する直近のプローブ結果の数。",
+        "httpMethod": "HTTP メソッド",
+        "httpMethodDesc": "プローブに使用する HTTP メソッド。",
+        "deleteAlsoObservatory": "これは Observatory を使用する最後のバランサーのため、こちらも削除されます。",
+        "deleteAlsoBurst": "これは Burst Observatory を使用する最後のバランサーのため、こちらも削除されます。"
+      },
       "balancer": {
         "addBalancer": "負荷分散追加",
         "editBalancer": "負荷分散編集",

+ 30 - 0
internal/web/translation/pt-BR.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "Excluído",
         "toastDeleteFailed": "Falha ao excluir"
       },
+      "tabBalancerSettings": "Configurações do balanceador",
+      "tabObservatory": "Observatório",
+      "observatory": {
+        "title": "Observatório",
+        "burstTitle": "Observatório Burst",
+        "autoManaged": "Os observadores são gerenciados automaticamente a partir dos seus balanceadores. Ajuste abaixo como eles sondam; as saídas monitoradas seguem os seletores do balanceador.",
+        "emptyHint": "Nenhum observador de conexão ativo. Um é adicionado automaticamente ao criar um balanceador Least Ping ou Least Load — ou um balanceador Random / Round-robin com fallback — para que o balanceador possa medir suas saídas e escolher a melhor.",
+        "subjectSelector": "Saídas monitoradas",
+        "subjectSelectorDesc": "Tags de saída que este observador sonda. Gerenciadas automaticamente a partir dos seus balanceadores.",
+        "probeURL": "URL de sondagem",
+        "probeURLDesc": "URL requisitada para medir cada saída. Deve retornar HTTP 204.",
+        "probeInterval": "Intervalo de sondagem",
+        "probeIntervalDesc": "Com que frequência sondar cada saída, ex.: 30s, 1m, 2h45m.",
+        "enableConcurrency": "Sondagem concorrente",
+        "enableConcurrencyDesc": "Sonda todas as saídas monitoradas de uma vez, em vez de uma a uma. Mais rápido, mas mais visível na rede.",
+        "destination": "Destino da sondagem",
+        "destinationDesc": "URL requisitada para medir cada saída. Deve retornar HTTP 204.",
+        "connectivity": "Verificação de conectividade",
+        "connectivityDesc": "URL opcional de verificação da rede local, testada apenas após o destino falhar. Deixe vazio para ignorar.",
+        "interval": "Intervalo de sondagem",
+        "intervalDesc": "Tempo médio entre sondagens por saída, ex.: 1m. Mínimo 10s.",
+        "timeout": "Tempo limite da sondagem",
+        "timeoutDesc": "Quanto esperar por uma sondagem antes de considerá-la falha, ex.: 5s.",
+        "sampling": "Número de amostras",
+        "samplingDesc": "Número de resultados de sondagem recentes mantidos para pontuar cada saída.",
+        "httpMethod": "Método HTTP",
+        "httpMethodDesc": "Método HTTP usado nas sondagens.",
+        "deleteAlsoObservatory": "Este é o último balanceador que usa o Observatório, então ele também será removido.",
+        "deleteAlsoBurst": "Este é o último balanceador que usa o Observatório Burst, então ele também será removido."
+      },
       "balancer": {
         "addBalancer": "Adicionar Balanceador",
         "editBalancer": "Editar Balanceador",

+ 30 - 0
internal/web/translation/ru-RU.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "Удалено",
         "toastDeleteFailed": "Не удалось удалить"
       },
+      "tabBalancerSettings": "Настройки балансировщика",
+      "tabObservatory": "Обсерватория",
+      "observatory": {
+        "title": "Обсерватория",
+        "burstTitle": "Burst-обсерватория",
+        "autoManaged": "Наблюдатели управляются автоматически на основе ваших балансировщиков. Ниже можно настроить, как они опрашивают; отслеживаемые исходящие следуют за селекторами балансировщика.",
+        "emptyHint": "Нет активного наблюдателя соединений. Он добавляется автоматически при создании балансировщика Least Ping или Least Load — либо Random / Round-robin с fallback — чтобы балансировщик мог измерять исходящие и выбирать лучший.",
+        "subjectSelector": "Отслеживаемые исходящие",
+        "subjectSelectorDesc": "Теги исходящих, которые опрашивает этот наблюдатель. Управляется автоматически на основе ваших балансировщиков.",
+        "probeURL": "URL пробы",
+        "probeURLDesc": "URL, запрашиваемый для измерения каждого исходящего. Должен возвращать HTTP 204.",
+        "probeInterval": "Интервал пробы",
+        "probeIntervalDesc": "Как часто опрашивать каждый исходящий, например 30s, 1m, 2h45m.",
+        "enableConcurrency": "Параллельные пробы",
+        "enableConcurrencyDesc": "Опрашивать все отслеживаемые исходящие сразу, а не по одному. Быстрее, но заметнее в сети.",
+        "destination": "Назначение пробы",
+        "destinationDesc": "URL, запрашиваемый для измерения каждого исходящего. Должен возвращать HTTP 204.",
+        "connectivity": "Проверка связи",
+        "connectivityDesc": "Необязательный URL проверки локальной сети, используется только после сбоя назначения. Оставьте пустым, чтобы пропустить.",
+        "interval": "Интервал пробы",
+        "intervalDesc": "Среднее время между пробами для каждого исходящего, например 1m. Минимум 10s.",
+        "timeout": "Тайм-аут пробы",
+        "timeoutDesc": "Сколько ждать ответа на пробу, прежде чем считать её неуспешной, например 5s.",
+        "sampling": "Размер выборки",
+        "samplingDesc": "Сколько последних результатов проб хранится для оценки каждого исходящего.",
+        "httpMethod": "Метод HTTP",
+        "httpMethodDesc": "Метод HTTP, используемый для проб.",
+        "deleteAlsoObservatory": "Это последний балансировщик, использующий Observatory, поэтому он тоже будет удалён.",
+        "deleteAlsoBurst": "Это последний балансировщик, использующий Burst Observatory, поэтому он тоже будет удалён."
+      },
       "balancer": {
         "addBalancer": "Создать балансировщик",
         "editBalancer": "Редактировать балансировщик",

+ 30 - 0
internal/web/translation/tr-TR.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "Silindi",
         "toastDeleteFailed": "Silme işlemi başarısız"
       },
+      "tabBalancerSettings": "Dengeleyici Ayarları",
+      "tabObservatory": "Gözlemci",
+      "observatory": {
+        "title": "Gözlemci",
+        "burstTitle": "Burst Gözlemci",
+        "autoManaged": "Gözlemciler dengeleyicilerinize göre otomatik yönetilir. Nasıl sınama yapacaklarını aşağıdan ayarlayın; izlenen çıkışlar dengeleyici seçicilerini izler.",
+        "emptyHint": "Etkin bir bağlantı gözlemcisi yok. Least Ping veya Least Load dengeleyici — ya da fallback içeren Random / Round-robin dengeleyici — oluşturduğunuzda otomatik olarak bir tane eklenir; böylece dengeleyici çıkışlarınızı ölçüp en iyisini seçebilir.",
+        "subjectSelector": "İzlenen Çıkışlar",
+        "subjectSelectorDesc": "Bu gözlemcinin sınadığı çıkış etiketleri. Dengeleyicilerinize göre otomatik yönetilir.",
+        "probeURL": "Sınama URL'si",
+        "probeURLDesc": "Her çıkışı ölçmek için istenen URL. HTTP 204 döndürmelidir.",
+        "probeInterval": "Sınama Aralığı",
+        "probeIntervalDesc": "Her çıkışın ne sıklıkta sınanacağı, örn. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Eşzamanlı Sınama",
+        "enableConcurrencyDesc": "İzlenen tüm çıkışları tek tek yerine aynı anda sına. Daha hızlı ama ağda daha görünür.",
+        "destination": "Sınama Hedefi",
+        "destinationDesc": "Her çıkışı ölçmek için istenen URL. HTTP 204 döndürmelidir.",
+        "connectivity": "Bağlantı Denetimi",
+        "connectivityDesc": "İsteğe bağlı yerel ağ denetim URL'si; yalnızca hedef başarısız olduktan sonra denenir. Atlamak için boş bırakın.",
+        "interval": "Sınama Aralığı",
+        "intervalDesc": "Çıkış başına sınamalar arasındaki ortalama süre, örn. 1m. En az 10s.",
+        "timeout": "Sınama Zaman Aşımı",
+        "timeoutDesc": "Bir sınamanın başarısız sayılmadan önce ne kadar bekleneceği, örn. 5s.",
+        "sampling": "Örnekleme Sayısı",
+        "samplingDesc": "Her çıkışı puanlamak için tutulan son sınama sonucu sayısı.",
+        "httpMethod": "HTTP Yöntemi",
+        "httpMethodDesc": "Sınamalar için kullanılan HTTP yöntemi.",
+        "deleteAlsoObservatory": "Bu, Observatory kullanan son dengeleyici, bu yüzden o da kaldırılacak.",
+        "deleteAlsoBurst": "Bu, Burst Observatory kullanan son dengeleyici, bu yüzden o da kaldırılacak."
+      },
       "balancer": {
         "addBalancer": "Dengeleyici Ekle",
         "editBalancer": "Dengeleyiciyi Düzenle",

+ 30 - 0
internal/web/translation/uk-UA.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "Видалено",
         "toastDeleteFailed": "Не вдалося видалити"
       },
+      "tabBalancerSettings": "Налаштування балансувальника",
+      "tabObservatory": "Обсерваторія",
+      "observatory": {
+        "title": "Обсерваторія",
+        "burstTitle": "Burst-обсерваторія",
+        "autoManaged": "Спостерігачі керуються автоматично на основі ваших балансувальників. Нижче можна налаштувати, як вони опитують; відстежувані вихідні слідують за селекторами балансувальника.",
+        "emptyHint": "Немає активного спостерігача з’єднань. Його буде додано автоматично під час створення балансувальника Least Ping або Least Load — чи Random / Round-robin із fallback — щоб балансувальник міг вимірювати вихідні й обирати найкращий.",
+        "subjectSelector": "Відстежувані вихідні",
+        "subjectSelectorDesc": "Теги вихідних, які опитує цей спостерігач. Керується автоматично на основі ваших балансувальників.",
+        "probeURL": "URL проби",
+        "probeURLDesc": "URL, що запитується для вимірювання кожного вихідного. Має повертати HTTP 204.",
+        "probeInterval": "Інтервал проби",
+        "probeIntervalDesc": "Як часто опитувати кожен вихідний, напр. 30s, 1m, 2h45m.",
+        "enableConcurrency": "Паралельні проби",
+        "enableConcurrencyDesc": "Опитувати всі відстежувані вихідні одночасно, а не по одному. Швидше, але помітніше в мережі.",
+        "destination": "Призначення проби",
+        "destinationDesc": "URL, що запитується для вимірювання кожного вихідного. Має повертати HTTP 204.",
+        "connectivity": "Перевірка з’єднання",
+        "connectivityDesc": "Необов’язковий URL перевірки локальної мережі, використовується лише після збою призначення. Залиште порожнім, щоб пропустити.",
+        "interval": "Інтервал проби",
+        "intervalDesc": "Середній час між пробами для кожного вихідного, напр. 1m. Мінімум 10s.",
+        "timeout": "Тайм-аут проби",
+        "timeoutDesc": "Скільки чекати на пробу, перш ніж вважати її невдалою, напр. 5s.",
+        "sampling": "Розмір вибірки",
+        "samplingDesc": "Скільки останніх результатів проб зберігається для оцінювання кожного вихідного.",
+        "httpMethod": "Метод HTTP",
+        "httpMethodDesc": "Метод HTTP, що використовується для проб.",
+        "deleteAlsoObservatory": "Це останній балансувальник, що використовує Observatory, тож його теж буде видалено.",
+        "deleteAlsoBurst": "Це останній балансувальник, що використовує Burst Observatory, тож його теж буде видалено."
+      },
       "balancer": {
         "addBalancer": "Додати балансир",
         "editBalancer": "Редагувати балансир",

+ 30 - 0
internal/web/translation/vi-VN.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "Đã xóa",
         "toastDeleteFailed": "Xóa thất bại"
       },
+      "tabBalancerSettings": "Cài đặt Balancer",
+      "tabObservatory": "Observatory",
+      "observatory": {
+        "title": "Observatory",
+        "burstTitle": "Burst Observatory",
+        "autoManaged": "Observer được quản lý tự động từ các balancer của bạn. Điều chỉnh cách chúng dò ở bên dưới; các outbound được theo dõi sẽ tuân theo selector của balancer.",
+        "emptyHint": "Không có observer kết nối nào đang hoạt động. Một observer sẽ được thêm tự động khi bạn tạo balancer Least Ping hoặc Least Load — hoặc balancer Random / Round-robin có fallback — để balancer có thể đo các outbound và chọn cái tốt nhất.",
+        "subjectSelector": "Outbound được theo dõi",
+        "subjectSelectorDesc": "Các thẻ outbound mà observer này dò. Được quản lý tự động từ các balancer của bạn.",
+        "probeURL": "URL dò",
+        "probeURLDesc": "URL được yêu cầu để đo mỗi outbound. Phải trả về HTTP 204.",
+        "probeInterval": "Khoảng thời gian dò",
+        "probeIntervalDesc": "Tần suất dò mỗi outbound, ví dụ 30s, 1m, 2h45m.",
+        "enableConcurrency": "Dò đồng thời",
+        "enableConcurrencyDesc": "Dò tất cả outbound được theo dõi cùng lúc thay vì lần lượt. Nhanh hơn nhưng dễ bị phát hiện trên mạng hơn.",
+        "destination": "Đích dò",
+        "destinationDesc": "URL được yêu cầu để đo mỗi outbound. Phải trả về HTTP 204.",
+        "connectivity": "Kiểm tra kết nối",
+        "connectivityDesc": "URL kiểm tra mạng cục bộ tùy chọn, chỉ thử sau khi đích thất bại. Để trống để bỏ qua.",
+        "interval": "Khoảng thời gian dò",
+        "intervalDesc": "Thời gian trung bình giữa các lần dò cho mỗi outbound, ví dụ 1m. Tối thiểu 10s.",
+        "timeout": "Thời gian chờ dò",
+        "timeoutDesc": "Thời gian chờ một lần dò trước khi coi là thất bại, ví dụ 5s.",
+        "sampling": "Số mẫu",
+        "samplingDesc": "Số kết quả dò gần đây được giữ để chấm điểm mỗi outbound.",
+        "httpMethod": "Phương thức HTTP",
+        "httpMethodDesc": "Phương thức HTTP dùng cho việc dò.",
+        "deleteAlsoObservatory": "Đây là balancer cuối cùng dùng Observatory, nên nó cũng sẽ bị xóa.",
+        "deleteAlsoBurst": "Đây là balancer cuối cùng dùng Burst Observatory, nên nó cũng sẽ bị xóa."
+      },
       "balancer": {
         "addBalancer": "Thêm cân bằng",
         "editBalancer": "Chỉnh sửa cân bằng",

+ 30 - 0
internal/web/translation/zh-CN.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "已删除",
         "toastDeleteFailed": "删除失败"
       },
+      "tabBalancerSettings": "负载均衡设置",
+      "tabObservatory": "观测器",
+      "observatory": {
+        "title": "观测器",
+        "burstTitle": "突发观测器",
+        "autoManaged": "观测器会根据你的负载均衡器自动管理。可在下方调整探测方式;被观测的出站会跟随负载均衡器的选择器。",
+        "emptyHint": "当前没有活动的连接观测器。当你创建 Least Ping 或 Least Load 负载均衡器,或带有 fallback 的 Random / Round-robin 负载均衡器时,会自动添加一个,以便负载均衡器测量各出站并选择最优。",
+        "subjectSelector": "被观测的出站",
+        "subjectSelectorDesc": "该观测器探测的出站标签。根据你的负载均衡器自动管理。",
+        "probeURL": "探测 URL",
+        "probeURLDesc": "用于测量每个出站而请求的 URL,应返回 HTTP 204。",
+        "probeInterval": "探测间隔",
+        "probeIntervalDesc": "每个出站的探测频率,例如 30s、1m、2h45m。",
+        "enableConcurrency": "并发探测",
+        "enableConcurrencyDesc": "一次性探测所有被观测的出站,而不是逐个探测。更快,但在网络上更明显。",
+        "destination": "探测目标",
+        "destinationDesc": "用于测量每个出站而请求的 URL,应返回 HTTP 204。",
+        "connectivity": "连通性检查",
+        "connectivityDesc": "可选的本地网络检查 URL,仅在目标失败后才尝试。留空则跳过。",
+        "interval": "探测间隔",
+        "intervalDesc": "每个出站两次探测之间的平均时间,例如 1m。最小 10s。",
+        "timeout": "探测超时",
+        "timeoutDesc": "判定探测失败前的等待时长,例如 5s。",
+        "sampling": "采样数量",
+        "samplingDesc": "为每个出站评分而保留的最近探测结果数量。",
+        "httpMethod": "HTTP 方法",
+        "httpMethodDesc": "探测所用的 HTTP 方法。",
+        "deleteAlsoObservatory": "这是最后一个使用 Observatory 的负载均衡器,因此它也会被一并移除。",
+        "deleteAlsoBurst": "这是最后一个使用 Burst Observatory 的负载均衡器,因此它也会被一并移除。"
+      },
       "balancer": {
         "addBalancer": "添加负载均衡",
         "editBalancer": "编辑负载均衡",

+ 30 - 0
internal/web/translation/zh-TW.json

@@ -1663,6 +1663,36 @@
         "toastDeleted": "已刪除",
         "toastDeleteFailed": "刪除失敗"
       },
+      "tabBalancerSettings": "負載平衡設定",
+      "tabObservatory": "觀測器",
+      "observatory": {
+        "title": "觀測器",
+        "burstTitle": "突發觀測器",
+        "autoManaged": "觀測器會根據你的負載平衡器自動管理。可在下方調整探測方式;被觀測的出站會跟隨負載平衡器的選擇器。",
+        "emptyHint": "目前沒有作用中的連線觀測器。當你建立 Least Ping 或 Least Load 負載平衡器,或帶有 fallback 的 Random / Round-robin 負載平衡器時,會自動新增一個,讓負載平衡器能量測各出站並選出最佳者。",
+        "subjectSelector": "被觀測的出站",
+        "subjectSelectorDesc": "此觀測器探測的出站標籤。會根據你的負載平衡器自動管理。",
+        "probeURL": "探測 URL",
+        "probeURLDesc": "用於量測每個出站而請求的 URL,應回傳 HTTP 204。",
+        "probeInterval": "探測間隔",
+        "probeIntervalDesc": "每個出站的探測頻率,例如 30s、1m、2h45m。",
+        "enableConcurrency": "並行探測",
+        "enableConcurrencyDesc": "一次探測所有被觀測的出站,而非逐一探測。較快,但在網路上更明顯。",
+        "destination": "探測目標",
+        "destinationDesc": "用於量測每個出站而請求的 URL,應回傳 HTTP 204。",
+        "connectivity": "連線檢查",
+        "connectivityDesc": "選用的本機網路檢查 URL,僅在目標失敗後才嘗試。留空則略過。",
+        "interval": "探測間隔",
+        "intervalDesc": "每個出站兩次探測之間的平均時間,例如 1m。最小 10s。",
+        "timeout": "探測逾時",
+        "timeoutDesc": "判定探測失敗前的等待時間,例如 5s。",
+        "sampling": "取樣數量",
+        "samplingDesc": "為每個出站評分而保留的最近探測結果數量。",
+        "httpMethod": "HTTP 方法",
+        "httpMethodDesc": "探測所用的 HTTP 方法。",
+        "deleteAlsoObservatory": "這是最後一個使用 Observatory 的負載平衡器,因此它也會一併被移除。",
+        "deleteAlsoBurst": "這是最後一個使用 Burst Observatory 的負載平衡器,因此它也會一併被移除。"
+      },
       "balancer": {
         "addBalancer": "新增負載均衡",
         "editBalancer": "編輯負載均衡",