mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-25 13:56:10 +00:00
refactor: replace custom geo manager with Xray-core native geodata auto-update
Remove the panel-side custom geo download feature (service, controller, /panel/api/custom-geo/* endpoints, CustomGeoResource model, UI tab) in favor of Xray-core's native geodata section (https://xtls.github.io/config/geodata.html). - pass the top-level "geodata" key through xray.Config so it survives the template round-trip into the generated config - add a Geodata Auto-Update section to the Xray Updates modal that edits geodata (cron schedule, download outbound, asset list) in the config template and restarts Xray on save - previously downloaded geo files in the bin folder keep working in ext: routing rules; the orphaned custom_geo_resources table is left in place so existing source URLs stay recoverable
This commit is contained in:
@@ -250,17 +250,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"up": 1048576,
|
||||
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
|
||||
},
|
||||
"CustomGeoResource": {
|
||||
"alias": "",
|
||||
"createdAt": 0,
|
||||
"id": 0,
|
||||
"lastModified": "",
|
||||
"lastUpdatedAt": 0,
|
||||
"localPath": "",
|
||||
"type": "",
|
||||
"updatedAt": 0,
|
||||
"url": ""
|
||||
},
|
||||
"FallbackParentInfo": {
|
||||
"masterId": 0,
|
||||
"path": ""
|
||||
|
||||
@@ -1193,49 +1193,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CustomGeoResource": {
|
||||
"properties": {
|
||||
"alias": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lastModified": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastUpdatedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"localPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alias",
|
||||
"createdAt",
|
||||
"id",
|
||||
"lastModified",
|
||||
"lastUpdatedAt",
|
||||
"localPath",
|
||||
"type",
|
||||
"updatedAt",
|
||||
"url"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FallbackParentInfo": {
|
||||
"description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
|
||||
"properties": {
|
||||
|
||||
@@ -263,18 +263,6 @@ export interface ClientTraffic {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface CustomGeoResource {
|
||||
alias: string;
|
||||
createdAt: number;
|
||||
id: number;
|
||||
lastModified: string;
|
||||
lastUpdatedAt: number;
|
||||
localPath: string;
|
||||
type: string;
|
||||
updatedAt: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FallbackParentInfo {
|
||||
masterId: number;
|
||||
path?: string;
|
||||
|
||||
@@ -280,19 +280,6 @@ export const ClientTrafficSchema = z.object({
|
||||
});
|
||||
export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
|
||||
|
||||
export const CustomGeoResourceSchema = z.object({
|
||||
alias: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
id: z.number().int(),
|
||||
lastModified: z.string(),
|
||||
lastUpdatedAt: z.number().int(),
|
||||
localPath: z.string(),
|
||||
type: z.string(),
|
||||
updatedAt: z.number().int(),
|
||||
url: z.string(),
|
||||
});
|
||||
export type CustomGeoResource = z.infer<typeof CustomGeoResourceSchema>;
|
||||
|
||||
export const FallbackParentInfoSchema = z.object({
|
||||
masterId: z.number().int(),
|
||||
path: z.string().optional(),
|
||||
|
||||
@@ -860,61 +860,6 @@ export const sections: readonly Section[] = [
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'custom-geo',
|
||||
title: 'Custom Geo',
|
||||
description:
|
||||
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/custom-geo/list',
|
||||
summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/custom-geo/aliases',
|
||||
summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/add',
|
||||
summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
|
||||
body:
|
||||
'{\n "type": "geoip",\n "alias": "myips",\n "url": "https://example.com/geo/my.dat"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/update/:id',
|
||||
summary: 'Replace a custom geo source. Same body shape as /add.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/delete/:id',
|
||||
summary: 'Remove a custom geo source and its cached file.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/download/:id',
|
||||
summary: 'Re-download one custom geo source on demand.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/update-all',
|
||||
summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'backup',
|
||||
title: 'Backup',
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function FilterDrawer({
|
||||
value={filters.buckets}
|
||||
onChange={(v) => patch('buckets', v as string[])}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<Space orientation="vertical">
|
||||
{BUCKET_KEYS.map((k) => (
|
||||
<Checkbox key={k} value={k}>
|
||||
{bucketLabel(k, t)}
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function GroupAddClientsModal({
|
||||
{t('pages.groups.addToGroupDesc')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function GroupRemoveClientsModal({
|
||||
{t('pages.groups.removeFromGroupDesc')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function AttachClientsModal({
|
||||
{t('pages.inbounds.attachClientsDesc', { count: clientRows.length })}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space direction="vertical" size="small" style={{ width: '100%', marginBottom: 12 }}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%', marginBottom: 12 }}>
|
||||
<Typography.Text strong>{t('pages.inbounds.attachClientsSelectLabel')}</Typography.Text>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Input.Search
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function AttachExistingClientsModal({
|
||||
<Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function DetachClientsModal({
|
||||
{t('pages.inbounds.detachClientsDesc', { count: clientRows.length })}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Typography.Text strong>{t('pages.inbounds.detachClientsSelectLabel')}</Typography.Text>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Input.Search
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, message, Modal, Select } from 'antd';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { CustomGeoFormSchema } from '@/schemas/xray';
|
||||
|
||||
export interface CustomGeoRecord {
|
||||
id: number;
|
||||
type: 'geosite' | 'geoip';
|
||||
alias: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface CustomGeoFormModalProps {
|
||||
open: boolean;
|
||||
record: CustomGeoRecord | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function CustomGeoFormModal({
|
||||
open,
|
||||
record,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: CustomGeoFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [type, setType] = useState<'geosite' | 'geoip'>('geosite');
|
||||
const [alias, setAlias] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const editing = record != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (record) {
|
||||
setType(record.type);
|
||||
setAlias(record.alias);
|
||||
setUrl(record.url);
|
||||
} else {
|
||||
setType('geosite');
|
||||
setAlias('');
|
||||
setUrl('');
|
||||
}
|
||||
}, [open, record]);
|
||||
|
||||
async function submit() {
|
||||
const validated = CustomGeoFormSchema.safeParse({ type, alias, url });
|
||||
if (!validated.success) {
|
||||
messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const apiUrl = editing
|
||||
? `/panel/api/custom-geo/update/${record!.id}`
|
||||
: '/panel/api/custom-geo/add';
|
||||
const msg = await HttpUtil.post(apiUrl, validated.data);
|
||||
if (msg?.success) {
|
||||
onSaved();
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')}
|
||||
confirmLoading={saving}
|
||||
okText={t('pages.index.customGeoModalSave')}
|
||||
cancelText={t('close')}
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('pages.index.customGeoType')}>
|
||||
<Select
|
||||
value={type}
|
||||
disabled={editing}
|
||||
onChange={(v) => setType(v)}
|
||||
options={[
|
||||
{ value: 'geosite', label: 'geosite' },
|
||||
{ value: 'geoip', label: 'geoip' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.index.customGeoAlias')}>
|
||||
<Input
|
||||
value={alias}
|
||||
disabled={editing}
|
||||
placeholder={t('pages.index.customGeoAliasPlaceholder')}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.index.customGeoUrl')}>
|
||||
<Input
|
||||
value={url}
|
||||
placeholder="https://"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.custom-geo-count {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.custom-geo-alias-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.custom-geo-alias {
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-type-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-geo-url {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-ext-code {
|
||||
cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.custom-geo-copyable:hover {
|
||||
background: var(--ant-color-fill-secondary);
|
||||
}
|
||||
|
||||
.custom-geo-muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.custom-geo-empty {
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.custom-geo-empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, message, Modal, Space, Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil, ClipboardManager } from '@/utils';
|
||||
import CustomGeoFormModal from './CustomGeoFormModal';
|
||||
import type { CustomGeoRecord } from './CustomGeoFormModal';
|
||||
import './CustomGeoSection.css';
|
||||
|
||||
interface CustomGeoSectionProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface CustomGeoListRecord extends CustomGeoRecord {
|
||||
lastUpdatedAt?: number;
|
||||
}
|
||||
|
||||
function formatTime(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function relativeTime(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
||||
return formatTime(ts);
|
||||
}
|
||||
|
||||
function extDisplay(record: CustomGeoListRecord): string {
|
||||
const fn = record.type === 'geoip'
|
||||
? `geoip_${record.alias}.dat`
|
||||
: `geosite_${record.alias}.dat`;
|
||||
return `ext:${fn}:tag`;
|
||||
}
|
||||
|
||||
export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [list, setList] = useState<CustomGeoListRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingAll, setUpdatingAll] = useState(false);
|
||||
const [actionId, setActionId] = useState<number | null>(null);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<CustomGeoListRecord | null>(null);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||
if (msg?.success && Array.isArray(msg.obj)) setList(msg.obj);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) loadList();
|
||||
}, [active, loadList]);
|
||||
|
||||
function openAdd() {
|
||||
setEditingRecord(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(record: CustomGeoListRecord) {
|
||||
setEditingRecord(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function copyExt(record: CustomGeoListRecord) {
|
||||
const text = extDisplay(record);
|
||||
const ok = await ClipboardManager.copyText(text);
|
||||
if (ok) messageApi.success(`${t('copied')}: ${text}`);
|
||||
}
|
||||
|
||||
function confirmDelete(record: CustomGeoListRecord) {
|
||||
modal.confirm({
|
||||
title: t('pages.index.customGeoDelete'),
|
||||
content: t('pages.index.customGeoDeleteConfirm'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||
if (msg?.success) await loadList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadOne(id: number) {
|
||||
setActionId(id);
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||
if (msg?.success) await loadList();
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
setUpdatingAll(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post<{ succeeded?: unknown[]; failed?: unknown[] }>('/panel/api/custom-geo/update-all');
|
||||
const ok = msg?.obj?.succeeded?.length || 0;
|
||||
const failed = msg?.obj?.failed?.length || 0;
|
||||
if (msg?.success || ok > 0) {
|
||||
await loadList();
|
||||
if (failed > 0) messageApi.warning(`Updated ${ok}, failed ${failed}`);
|
||||
}
|
||||
} finally {
|
||||
setUpdatingAll(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<ColumnsType<CustomGeoListRecord>>(
|
||||
() => [
|
||||
{
|
||||
title: t('pages.index.customGeoAlias'),
|
||||
key: 'alias',
|
||||
width: 200,
|
||||
render: (_v, record) => (
|
||||
<div className="custom-geo-alias-cell">
|
||||
<Tag color={record.type === 'geoip' ? 'cyan' : 'purple'} className="custom-geo-type-tag">
|
||||
{record.type}
|
||||
</Tag>
|
||||
<span className="custom-geo-alias">{record.alias}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.index.customGeoUrl'),
|
||||
key: 'url',
|
||||
ellipsis: true,
|
||||
render: (_v, record) => (
|
||||
<Tooltip placement="topLeft" title={record.url}>
|
||||
<a
|
||||
href={record.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="custom-geo-url"
|
||||
>
|
||||
{record.url}
|
||||
</a>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.index.customGeoExtColumn'),
|
||||
key: 'extDat',
|
||||
width: 220,
|
||||
render: (_v, record) => (
|
||||
<Tooltip title={t('copy')}>
|
||||
<code
|
||||
className="custom-geo-ext-code custom-geo-copyable"
|
||||
onClick={() => copyExt(record)}
|
||||
>
|
||||
{extDisplay(record)}
|
||||
</code>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.index.customGeoLastUpdated'),
|
||||
key: 'lastUpdatedAt',
|
||||
width: 140,
|
||||
render: (_v, record) =>
|
||||
record.lastUpdatedAt ? (
|
||||
<Tooltip title={formatTime(record.lastUpdatedAt)}>
|
||||
<span>{relativeTime(record.lastUpdatedAt)}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="custom-geo-muted">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('pages.index.customGeoActions'),
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_v, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title={t('pages.index.customGeoEdit')}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.index.customGeoDownload')}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
loading={actionId === record.id}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => downloadOne(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('pages.index.customGeoDelete')}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => confirmDelete(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, actionId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="custom-geo-section">
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-10"
|
||||
title={t('pages.index.customGeoRoutingHint')}
|
||||
/>
|
||||
|
||||
<div className="toolbar">
|
||||
<Button type="primary" loading={loading} onClick={openAdd} icon={<PlusOutlined />}>
|
||||
{t('pages.index.customGeoAdd')}
|
||||
</Button>
|
||||
<Button
|
||||
loading={updatingAll}
|
||||
disabled={list.length === 0}
|
||||
onClick={updateAll}
|
||||
icon={<ReloadOutlined />}
|
||||
>
|
||||
{t('pages.index.geofilesUpdateAll')}
|
||||
</Button>
|
||||
{list.length > 0 && <span className="custom-geo-count">{list.length}</span>}
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
pagination={false}
|
||||
rowKey={(r) => r.id}
|
||||
loading={loading}
|
||||
size="small"
|
||||
scroll={{ x: 760 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="custom-geo-empty">
|
||||
<InboxOutlined className="custom-geo-empty-icon" />
|
||||
<div>{t('pages.index.customGeoEmpty')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<CustomGeoFormModal
|
||||
open={formOpen}
|
||||
record={editingRecord}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSaved={loadList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
interface GeodataAssetRow {
|
||||
url: string;
|
||||
file: string;
|
||||
}
|
||||
|
||||
interface GeodataSectionProps {
|
||||
active: boolean;
|
||||
onBusy: (e: { busy: boolean; tip?: string }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CRON = '0 4 * * *';
|
||||
// Xray resolves `file` inside its asset directory; plain file names only.
|
||||
const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function fileNameFromUrl(url: string): string {
|
||||
try {
|
||||
const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
|
||||
return FILE_NAME_PATTERN.test(seg) ? seg : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cron, setCron] = useState(DEFAULT_CRON);
|
||||
const [outbound, setOutbound] = useState<string | undefined>(undefined);
|
||||
const [rows, setRows] = useState<GeodataAssetRow[]>([]);
|
||||
const [outboundTags, setOutboundTags] = useState<string[]>([]);
|
||||
const templateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const outboundTestUrlRef = useRef('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
||||
if (!msg?.success || typeof msg.obj !== 'string') return;
|
||||
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
|
||||
const template = (payload.xraySetting || {}) as Record<string, unknown>;
|
||||
templateRef.current = template;
|
||||
outboundTestUrlRef.current =
|
||||
typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
|
||||
|
||||
const geodata = (template.geodata || {}) as Record<string, unknown>;
|
||||
const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
|
||||
setRows(
|
||||
assets
|
||||
.filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
|
||||
.map((a) => ({ url: String(a.url ?? ''), file: String(a.file ?? '') })),
|
||||
);
|
||||
setCron(typeof geodata.cron === 'string' && geodata.cron ? geodata.cron : DEFAULT_CRON);
|
||||
setOutbound(
|
||||
typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
|
||||
);
|
||||
|
||||
// Download outbound candidates: template outbounds + subscription outbounds.
|
||||
const tags = new Set<string>();
|
||||
const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
|
||||
for (const o of outbounds) {
|
||||
const tag = o && typeof o === 'object' ? (o as Record<string, unknown>).tag : undefined;
|
||||
if (typeof tag === 'string' && tag) tags.add(tag);
|
||||
}
|
||||
const subTags = Array.isArray(payload.subscriptionOutboundTags)
|
||||
? payload.subscriptionOutboundTags
|
||||
: [];
|
||||
for (const tag of subTags) {
|
||||
if (typeof tag === 'string' && tag) tags.add(tag);
|
||||
}
|
||||
setOutboundTags([...tags]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) load();
|
||||
}, [active, load]);
|
||||
|
||||
function setRow(index: number, patch: Partial<GeodataAssetRow>) {
|
||||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function onUrlBlur(index: number) {
|
||||
setRows((prev) =>
|
||||
prev.map((r, i) => (i === index && !r.file ? { ...r, file: fileNameFromUrl(r.url) } : r)),
|
||||
);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const template = templateRef.current;
|
||||
if (!template) return;
|
||||
const assets = rows
|
||||
.map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
|
||||
.filter((r) => r.url || r.file);
|
||||
for (const a of assets) {
|
||||
// Xray's geodata downloader accepts HTTPS URLs only.
|
||||
if (!/^https:\/\/\S+$/i.test(a.url)) {
|
||||
messageApi.error(t('pages.index.geodataInvalidUrl'));
|
||||
return;
|
||||
}
|
||||
if (!FILE_NAME_PATTERN.test(a.file)) {
|
||||
messageApi.error(t('pages.index.geodataInvalidFile'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const cronValue = cron.trim();
|
||||
if (assets.length > 0 && cronValue && cronValue.split(/\s+/).length !== 5) {
|
||||
messageApi.error(t('pages.index.geodataInvalidCron'));
|
||||
return;
|
||||
}
|
||||
|
||||
modal.confirm({
|
||||
title: t('pages.index.geodataConfirmTitle'),
|
||||
content: t('pages.index.geodataConfirmContent'),
|
||||
okText: t('confirm'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const next: Record<string, unknown> = { ...template };
|
||||
if (assets.length === 0) {
|
||||
delete next.geodata;
|
||||
} else {
|
||||
const geodata: Record<string, unknown> = { assets };
|
||||
if (cronValue) geodata.cron = cronValue;
|
||||
if (outbound) geodata.outbound = outbound;
|
||||
next.geodata = geodata;
|
||||
}
|
||||
onClose();
|
||||
onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||
xraySetting: JSON.stringify(next, null, 2),
|
||||
outboundTestUrl: outboundTestUrlRef.current,
|
||||
});
|
||||
if (msg?.success) {
|
||||
await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||
}
|
||||
} finally {
|
||||
onBusy({ busy: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{modalContextHolder}
|
||||
{messageContextHolder}
|
||||
<Spin spinning={loading}>
|
||||
<Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}>
|
||||
<Input
|
||||
value={cron}
|
||||
placeholder={DEFAULT_CRON}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
allowClear
|
||||
value={outbound}
|
||||
onChange={(v) => setOutbound(v)}
|
||||
options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
|
||||
{rows.length === 0 && (
|
||||
<Typography.Text type="secondary">{t('pages.index.geodataEmpty')}</Typography.Text>
|
||||
)}
|
||||
{rows.map((row, i) => (
|
||||
<Space.Compact key={i} style={{ width: '100%' }}>
|
||||
<Input
|
||||
style={{ width: '60%' }}
|
||||
placeholder="https://example.com/geosite_custom.dat"
|
||||
value={row.url}
|
||||
onChange={(e) => setRow(i, { url: e.target.value })}
|
||||
onBlur={() => onUrlBlur(i)}
|
||||
/>
|
||||
<Input
|
||||
style={{ width: '40%' }}
|
||||
placeholder={t('pages.index.geodataFile')}
|
||||
value={row.file}
|
||||
onChange={(e) => setRow(i, { file: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
|
||||
/>
|
||||
</Space.Compact>
|
||||
))}
|
||||
<div className="actions-row">
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
|
||||
>
|
||||
{t('pages.index.geodataAddFile')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={save} disabled={loading || !templateRef.current}>
|
||||
{t('pages.index.geodataSaveRestart')}
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { Status } from '@/models/status';
|
||||
import CustomGeoSection from './CustomGeoSection';
|
||||
import GeodataSection from './GeodataSection';
|
||||
import './VersionModal.css';
|
||||
|
||||
interface BusyEvent {
|
||||
@@ -161,8 +161,14 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('pages.index.customGeoTitle'),
|
||||
children: <CustomGeoSection active={activeKeyStr === '3'} />,
|
||||
label: t('pages.index.geodataTitle'),
|
||||
children: (
|
||||
<GeodataSection
|
||||
active={activeKeyStr === '3'}
|
||||
onBusy={onBusy}
|
||||
onClose={onClose}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -72,26 +72,6 @@ export const OutboundTestResultSchema = z.object({
|
||||
.optional(),
|
||||
}).loose();
|
||||
|
||||
export const CustomGeoFormSchema = z.object({
|
||||
type: z.enum(['geosite', 'geoip']),
|
||||
alias: z.string().regex(/^[a-z0-9_-]+$/, 'pages.index.customGeoValidationAlias'),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(u) => {
|
||||
if (!/^https?:\/\//i.test(u)) return false;
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'pages.index.customGeoValidationUrl' },
|
||||
),
|
||||
});
|
||||
|
||||
export const RuleFormSchema = z.object({
|
||||
domain: z.string(),
|
||||
ip: z.string(),
|
||||
@@ -123,7 +103,6 @@ export const OutboundTagSchema = z
|
||||
|
||||
export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
|
||||
export type RuleFormValues = z.infer<typeof RuleFormSchema>;
|
||||
export type CustomGeoFormValues = z.infer<typeof CustomGeoFormSchema>;
|
||||
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
||||
export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||
export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;
|
||||
|
||||
Reference in New Issue
Block a user