fix(evobgp): запросы к API на абсолютный URL EvoBGP, не на хост SPA
Publish Docker image / build-and-push (push) Successful in 1m39s
Publish Docker image / build-and-push (push) Successful in 1m39s
- base URL: VITE_EVOBGP_API_URL или localStorage evobgp_api_url, в dev — proxy /v1 - нормализация до …/v1; убран относительный /v1 в production (запросы больше не идут на порт UI) - форма URL и токена в Communities для сохранения в localStorage Made-with: Cursor
This commit is contained in:
@@ -16,7 +16,8 @@ import {
|
|||||||
IconSortDescending,
|
IconSortDescending,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconDeviceFloppy
|
IconDeviceFloppy,
|
||||||
|
IconPlugConnected
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import PageHeader from './components/PageHeader.jsx';
|
import PageHeader from './components/PageHeader.jsx';
|
||||||
import PortalDropdown from './components/PortalDropdown.jsx';
|
import PortalDropdown from './components/PortalDropdown.jsx';
|
||||||
@@ -30,7 +31,7 @@ import ErrorAlert from './components/ErrorAlert.jsx';
|
|||||||
import Pagination from './components/Pagination.jsx';
|
import Pagination from './components/Pagination.jsx';
|
||||||
import IconPicker from './components/IconPicker.jsx';
|
import IconPicker from './components/IconPicker.jsx';
|
||||||
import { getIconById } from './lib/brandIcons.js';
|
import { getIconById } from './lib/brandIcons.js';
|
||||||
import { listCommunities, saveCommunitiesData } from './lib/evobgpData.js';
|
import { listCommunities, saveCommunitiesData, normalizeEvobgpApiBaseUrl } from './lib/evobgpData.js';
|
||||||
|
|
||||||
const normalizeCommunityValue = (value) => {
|
const normalizeCommunityValue = (value) => {
|
||||||
const trimmed = String(value || '').trim();
|
const trimmed = String(value || '').trim();
|
||||||
@@ -60,7 +61,18 @@ function CommunitiesManager() {
|
|||||||
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
|
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
|
||||||
const [filterUsageError, setFilterUsageError] = useState('');
|
const [filterUsageError, setFilterUsageError] = useState('');
|
||||||
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
|
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
|
||||||
|
|
||||||
|
const [evobgpUrlDraft, setEvobgpUrlDraft] = useState('');
|
||||||
|
const [evobgpTokenDraft, setEvobgpTokenDraft] = useState('');
|
||||||
|
const [evobgpSettingsOpen, setEvobgpSettingsOpen] = useState(() => {
|
||||||
|
if (import.meta.env?.VITE_EVOBGP_API_URL) return false;
|
||||||
|
try {
|
||||||
|
return !window.localStorage?.getItem('evobgp_api_url');
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Модальные окна
|
// Модальные окна
|
||||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -72,6 +84,15 @@ function CommunitiesManager() {
|
|||||||
fetchFilterUsage();
|
fetchFilterUsage();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setEvobgpUrlDraft(String(window.localStorage?.getItem('evobgp_api_url') || ''));
|
||||||
|
setEvobgpTokenDraft(String(window.localStorage?.getItem('evobgp_api_token') || ''));
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!filterUsage) return;
|
if (!filterUsage) return;
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
@@ -271,6 +292,26 @@ function CommunitiesManager() {
|
|||||||
|
|
||||||
const handleSave = () => saveAll(items);
|
const handleSave = () => saveAll(items);
|
||||||
|
|
||||||
|
const saveEvobgpConnection = () => {
|
||||||
|
const norm = normalizeEvobgpApiBaseUrl(evobgpUrlDraft);
|
||||||
|
if (!norm) {
|
||||||
|
setError('Укажите корректный URL EvoBGP (например http://192.168.0.1:8080). Порт должен совпадать с API EvoBGP, не с этим интерфейсом.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('evobgp_api_url', norm);
|
||||||
|
window.localStorage.setItem('evobgp_api_token', String(evobgpTokenDraft || '').trim());
|
||||||
|
} catch (_e) {
|
||||||
|
setError('Не удалось сохранить настройки в localStorage.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEvobgpUrlDraft(norm);
|
||||||
|
setError('');
|
||||||
|
setSuccess('Подключение к EvoBGP сохранено. Загружаем данные…');
|
||||||
|
fetchItems();
|
||||||
|
setTimeout(() => setSuccess(''), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const arr = [...items];
|
const arr = [...items];
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
@@ -312,6 +353,60 @@ function CommunitiesManager() {
|
|||||||
icon={<IconFilter size={24} />}
|
icon={<IconFilter size={24} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-header py-2 d-flex align-items-center justify-content-between">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<IconPlugConnected size={18} />
|
||||||
|
<span className="fw-semibold">Подключение к EvoBGP API</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => setEvobgpSettingsOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{evobgpSettingsOpen ? 'Скрыть' : 'Настроить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{evobgpSettingsOpen && (
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary small mb-3">
|
||||||
|
Укажите <strong>прямой URL сервера EvoBGP</strong> (схема + хост + порт API), например{' '}
|
||||||
|
<code>http://192.168.100.67:8080</code> — не адрес этого веб-интерфейса. К пути будет добавлено{' '}
|
||||||
|
<code>/v1</code> автоматически. Токен — Bearer из EvoBGP.
|
||||||
|
</p>
|
||||||
|
<div className="row g-2 align-items-end">
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label">Base URL EvoBGP</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="http://192.168.100.67:8080"
|
||||||
|
autoComplete="off"
|
||||||
|
value={evobgpUrlDraft}
|
||||||
|
onChange={(e) => setEvobgpUrlDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-4">
|
||||||
|
<label className="form-label">API token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Bearer token"
|
||||||
|
autoComplete="off"
|
||||||
|
value={evobgpTokenDraft}
|
||||||
|
onChange={(e) => setEvobgpTokenDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-2">
|
||||||
|
<button type="button" className="btn btn-primary w-100" onClick={saveEvobgpConnection}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Уведомления */}
|
{/* Уведомления */}
|
||||||
{error && (
|
{error && (
|
||||||
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
|
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
|
||||||
|
|||||||
@@ -3,19 +3,74 @@ import axios from 'axios';
|
|||||||
const DEFAULT_TIMEOUT_MS = 30000;
|
const DEFAULT_TIMEOUT_MS = 30000;
|
||||||
const PAGE_LIMIT = 500;
|
const PAGE_LIMIT = 500;
|
||||||
|
|
||||||
|
const LS_KEY_URL = 'evobgp_api_url';
|
||||||
|
const LS_KEY_TOKEN = 'evobgp_api_token';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Приводит введённый URL к базе API EvoBGP (…/v1), без завершающего слэша.
|
||||||
|
* Примеры: http://host:8080 → http://host:8080/v1
|
||||||
|
*/
|
||||||
|
export function normalizeEvobgpApiBaseUrl(input) {
|
||||||
|
const raw = String(input || '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
let path = (u.pathname || '').replace(/\/+$/, '');
|
||||||
|
if (!path || path === '/') {
|
||||||
|
u.pathname = '/v1';
|
||||||
|
} else if (!/\/v1$/i.test(path)) {
|
||||||
|
u.pathname = `${path}/v1`;
|
||||||
|
} else {
|
||||||
|
u.pathname = path;
|
||||||
|
}
|
||||||
|
return `${u.origin}${u.pathname}`.replace(/\/+$/, '');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* База EvoBGP API: VITE_EVOBGP_API_URL → localStorage → в dev только proxy /v1.
|
||||||
|
* Не используем относительный /v1 в production без настройки — запросы уходили бы на хост SPA, а не на EvoBGP.
|
||||||
|
*/
|
||||||
|
export function getEvobgpApiBaseUrl() {
|
||||||
|
const envUrl = import.meta?.env?.VITE_EVOBGP_API_URL;
|
||||||
|
if (envUrl && String(envUrl).trim()) {
|
||||||
|
return normalizeEvobgpApiBaseUrl(String(envUrl).trim());
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = window.localStorage?.getItem(LS_KEY_URL);
|
||||||
|
if (stored && String(stored).trim()) {
|
||||||
|
return normalizeEvobgpApiBaseUrl(String(stored).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (import.meta?.env?.DEV) {
|
||||||
|
return '/v1';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function getAuthToken() {
|
function getAuthToken() {
|
||||||
const envToken = import.meta?.env?.VITE_EVOBGP_API_TOKEN;
|
const envToken = import.meta?.env?.VITE_EVOBGP_API_TOKEN;
|
||||||
if (envToken) return String(envToken);
|
if (envToken) return String(envToken);
|
||||||
if (typeof window === 'undefined') return '';
|
if (typeof window === 'undefined') return '';
|
||||||
return String(window.localStorage?.getItem('evobgp_api_token') || '');
|
return String(window.localStorage?.getItem(LS_KEY_TOKEN) || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const evobgpApi = axios.create({
|
export const evobgpApi = axios.create({
|
||||||
baseURL: import.meta?.env?.VITE_EVOBGP_API_URL || '/v1',
|
|
||||||
timeout: DEFAULT_TIMEOUT_MS,
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
evobgpApi.interceptors.request.use((config) => {
|
evobgpApi.interceptors.request.use((config) => {
|
||||||
|
const base = getEvobgpApiBaseUrl();
|
||||||
|
if (!base) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error(
|
||||||
|
'EvoBGP API URL не задан. Укажите полный URL API (например http://192.168.0.1:8080) в разделе Communities или задайте VITE_EVOBGP_API_URL при сборке.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
config.baseURL = base;
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers = config.headers || {};
|
config.headers = config.headers || {};
|
||||||
|
|||||||
Reference in New Issue
Block a user