Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m8s
624 lines
24 KiB
React
624 lines
24 KiB
React
import { useState, useEffect, useMemo } from 'react';
|
||
import api from './lib/api.js';
|
||
import PageHeader from './components/PageHeader.jsx';
|
||
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
|
||
import EmptyState from './components/EmptyState.jsx';
|
||
import ErrorAlert from './components/ErrorAlert.jsx';
|
||
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
||
import Pagination from './components/Pagination.jsx';
|
||
import Tooltip from './components/Tooltip.jsx';
|
||
import Modal from './components/Modal.jsx';
|
||
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
||
import {
|
||
IconShield,
|
||
IconRefresh,
|
||
IconSearch,
|
||
IconCopy,
|
||
IconAlertCircle,
|
||
IconList,
|
||
IconChartBar,
|
||
} from '@tabler/icons-react';
|
||
|
||
const PAGE_SIZE = 15;
|
||
const DEFAULT_MIN_COUNT_N = 5;
|
||
|
||
/** Одиночный IP: без маски или /32 */
|
||
function isSingleIp(addr) {
|
||
const s = String(addr || '').trim();
|
||
if (!s) return false;
|
||
if (!s.includes('/')) return true;
|
||
const mask = s.split('/')[1];
|
||
return mask === '32' || mask === '128';
|
||
}
|
||
|
||
/** Базовая подсеть /24 для группировки */
|
||
function getBase24(addr) {
|
||
const s = String(addr || '').trim();
|
||
const ipPart = s.includes('/') ? s.split('/')[0] : s;
|
||
const parts = ipPart.split('.');
|
||
if (parts.length !== 4) return null;
|
||
const [a, b, c] = parts.map((x) => parseInt(x, 10));
|
||
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(c)) return null;
|
||
return `${a}.${b}.${c}.0/24`;
|
||
}
|
||
|
||
/**
|
||
* Анализ списка ban: группировка одиночных IP по /24, предложения суммаризации если в подсети > N.
|
||
*/
|
||
function analyzeBanList(banEntries, minCountN) {
|
||
const groupBy = new Map();
|
||
for (const entry of banEntries || []) {
|
||
if (!entry?.address || !isSingleIp(entry.address)) continue;
|
||
const base = getBase24(entry.address);
|
||
if (!base) continue;
|
||
if (!groupBy.has(base)) groupBy.set(base, { subnet: base, ids: [], entries: [] });
|
||
const g = groupBy.get(base);
|
||
g.ids.push(entry.id);
|
||
g.entries.push(entry);
|
||
}
|
||
const suggestions = [];
|
||
for (const [, g] of groupBy) {
|
||
if (g.entries.length > minCountN) {
|
||
suggestions.push({
|
||
subnet: g.subnet,
|
||
count: g.entries.length,
|
||
ids: g.ids,
|
||
addEntry: { address: g.subnet, list: 'ban', comment: `суммаризация ${g.entries.length} IP` },
|
||
});
|
||
}
|
||
}
|
||
return { suggestions };
|
||
}
|
||
|
||
/** Нормализация записи address-list из RouterOS (поля могут приходить в разном регистре) */
|
||
function normalizeEntry(entry) {
|
||
if (!entry || typeof entry !== 'object') return null;
|
||
const addr = entry.address ?? entry.Address ?? '';
|
||
const list = entry.list ?? entry.List ?? '';
|
||
const timeout = entry.timeout ?? entry.Timeout ?? '';
|
||
const comment = entry.comment ?? entry.Comment ?? '';
|
||
const id = entry['.id'] ?? entry.id ?? '';
|
||
const disabled = entry.disabled ?? entry.Disabled ?? '';
|
||
const dynamic = entry.dynamic ?? entry.Dynamic ?? '';
|
||
return {
|
||
id: id || addr || Math.random().toString(36).slice(2),
|
||
address: String(addr).trim(),
|
||
list: String(list).trim(),
|
||
timeout: String(timeout).trim(),
|
||
comment: String(comment).trim(),
|
||
disabled: String(disabled).trim(),
|
||
dynamic: String(dynamic).trim(),
|
||
};
|
||
}
|
||
|
||
function AddressListTable({ title, listKey, items, loading, emptyMessage }) {
|
||
const [search, setSearch] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
|
||
const filtered = useMemo(() => {
|
||
const term = String(search || '').toLowerCase().trim();
|
||
if (!term) return items;
|
||
return items.filter((e) => {
|
||
const a = (e.address || '').toLowerCase();
|
||
const c = (e.comment || '').toLowerCase();
|
||
return a.includes(term) || c.includes(term);
|
||
});
|
||
}, [items, search]);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||
const paginated = useMemo(
|
||
() => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||
[filtered, page]
|
||
);
|
||
|
||
useEffect(() => {
|
||
setPage(1);
|
||
}, [search]);
|
||
|
||
const copyToClipboard = (text) => {
|
||
navigator.clipboard.writeText(text).then(() => {
|
||
window.notify?.success?.('Скопировано в буфер обмена');
|
||
}).catch(() => {
|
||
window.notify?.error?.('Не удалось скопировать');
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="card card-flat mb-4">
|
||
<div className="card-header">
|
||
<div className="row align-items-center w-100 g-2">
|
||
<div className="col">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<IconList className="icon text-muted" size={18} />
|
||
<span className="text-muted text-uppercase small">{title}</span>
|
||
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
|
||
</div>
|
||
</div>
|
||
<div className="col-12 mt-2" />
|
||
<div className="col">
|
||
<div className="input-icon flex-grow-1" style={{ maxWidth: '320px' }}>
|
||
<span className="input-icon-addon">
|
||
<IconSearch size={18} />
|
||
</span>
|
||
<input
|
||
type="text"
|
||
className="form-control"
|
||
placeholder="Поиск по IP или комментарию..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
aria-label="Поиск"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card-body p-0">
|
||
{loading ? (
|
||
<TableSkeleton rows={8} cols={4} flat />
|
||
) : paginated.length === 0 ? (
|
||
<TableEmpty cols={4} flat>
|
||
<EmptyState
|
||
title={items.length === 0 ? emptyMessage : 'Нет совпадений'}
|
||
description={items.length === 0 ? `Список ${listKey} пуст на роутере.` : 'Измените поисковый запрос.'}
|
||
/>
|
||
</TableEmpty>
|
||
) : (
|
||
<div className="table-responsive">
|
||
<table className="table table-flat table-vcenter table-nowrap mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: '40px' }}>#</th>
|
||
<th>IP-адрес</th>
|
||
<th>Таймаут</th>
|
||
<th>Комментарий</th>
|
||
<th className="text-end" style={{ width: '50px' }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paginated.map((entry, idx) => (
|
||
<tr key={entry.id || entry.address || idx}>
|
||
<td className="text-muted small">{(page - 1) * PAGE_SIZE + idx + 1}</td>
|
||
<td className="font-monospace">
|
||
<div className="d-flex align-items-center">
|
||
<span>{entry.address || '—'}</span>
|
||
<Tooltip content="Копировать IP" position="top">
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
|
||
onClick={() => copyToClipboard(entry.address)}
|
||
aria-label="Копировать"
|
||
>
|
||
<IconCopy size={14} />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</td>
|
||
<td className="text-muted">{entry.timeout || '—'}</td>
|
||
<td className="text-muted small">{entry.comment || '—'}</td>
|
||
<td className="text-end"></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!loading && filtered.length > PAGE_SIZE && (
|
||
<Pagination
|
||
currentPage={page}
|
||
totalPages={totalPages}
|
||
totalItems={filtered.length}
|
||
pageSize={PAGE_SIZE}
|
||
onPageChange={setPage}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function FirewallPage() {
|
||
const [servers, setServers] = useState([]);
|
||
const [serverId, setServerId] = useState('');
|
||
const [serverMeta, setServerMeta] = useState(null);
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [loadingServers, setLoadingServers] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [analysisOpen, setAnalysisOpen] = useState(false);
|
||
const [minCountN, setMinCountN] = useState(DEFAULT_MIN_COUNT_N);
|
||
const [analysisResult, setAnalysisResult] = useState(null);
|
||
const [applyConfirmOpen, setApplyConfirmOpen] = useState(false);
|
||
const [applying, setApplying] = useState(false);
|
||
const [ipInfoMap, setIpInfoMap] = useState({});
|
||
const [ipInfoLoading, setIpInfoLoading] = useState(false);
|
||
const [applyConfirmRow, setApplyConfirmRow] = useState(null);
|
||
const [applyingRow, setApplyingRow] = useState(null);
|
||
|
||
const routerServers = useMemo(
|
||
() =>
|
||
(servers || []).filter(
|
||
(s) => String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home'
|
||
),
|
||
[servers]
|
||
);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
setLoadingServers(true);
|
||
try {
|
||
const res = await api.get('/servers');
|
||
const list = Array.isArray(res.data) ? res.data : [];
|
||
if (!cancelled) setServers(list);
|
||
} catch (e) {
|
||
if (!cancelled) setError('Не удалось загрузить список серверов.');
|
||
} finally {
|
||
if (!cancelled) setLoadingServers(false);
|
||
}
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
const fetchAddressLists = async () => {
|
||
if (!serverId) {
|
||
setData(null);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const res = await api.get('/mikrotik/address-lists', { params: { serverId } });
|
||
const payload = res?.data ?? {};
|
||
setData({
|
||
serverLabel: payload.serverLabel || serverId,
|
||
ban: (payload.ban || []).map(normalizeEntry).filter(Boolean),
|
||
ban_counter: (payload.ban_counter || []).map(normalizeEntry).filter(Boolean),
|
||
});
|
||
} catch (e) {
|
||
console.error('Firewall fetch:', e);
|
||
setData(null);
|
||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить address-list с роутера.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (serverId) fetchAddressLists();
|
||
else setData(null);
|
||
}, [serverId]);
|
||
|
||
const runAnalysis = () => {
|
||
if (!data?.ban?.length) return;
|
||
const n = Math.max(2, parseInt(minCountN, 10) || DEFAULT_MIN_COUNT_N);
|
||
setMinCountN(n);
|
||
const result = analyzeBanList(data.ban, n);
|
||
setAnalysisResult(result);
|
||
setIpInfoMap({});
|
||
};
|
||
|
||
// Подгрузка AS/орг с ipinfo.io для подсетей из анализа (лимит 30, с задержкой между запросами)
|
||
useEffect(() => {
|
||
const suggestions = analysisResult?.suggestions;
|
||
if (!suggestions?.length) return;
|
||
const maxFetch = 30;
|
||
const slice = suggestions.slice(0, maxFetch);
|
||
let cancelled = false;
|
||
setIpInfoLoading(true);
|
||
(async () => {
|
||
for (let i = 0; i < slice.length && !cancelled; i++) {
|
||
const s = slice[i];
|
||
const firstIp = s.subnet.split('/')[0];
|
||
try {
|
||
const res = await fetch(`https://ipinfo.io/${firstIp}/json`, { method: 'GET' });
|
||
if (!res.ok) continue;
|
||
const info = await res.json();
|
||
if (!cancelled) {
|
||
setIpInfoMap((prev) => ({
|
||
...prev,
|
||
[s.subnet]: { org: info.org, country: info.country, city: info.city },
|
||
}));
|
||
}
|
||
} catch (_) {
|
||
if (!cancelled) setIpInfoMap((prev) => ({ ...prev, [s.subnet]: null }));
|
||
}
|
||
if (i < slice.length - 1) await new Promise((r) => setTimeout(r, 180));
|
||
}
|
||
if (!cancelled) setIpInfoLoading(false);
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, [analysisResult?.suggestions]);
|
||
|
||
const applyPayload = applyConfirmRow
|
||
? { removeIds: applyConfirmRow.ids, addEntries: [applyConfirmRow.addEntry] }
|
||
: analysisResult?.suggestions?.length
|
||
? {
|
||
removeIds: analysisResult.suggestions.flatMap((s) => s.ids),
|
||
addEntries: analysisResult.suggestions.map((s) => s.addEntry),
|
||
}
|
||
: null;
|
||
|
||
const handleApplySummary = async () => {
|
||
if (!serverId || !applyPayload) return;
|
||
const isRow = !!applyConfirmRow;
|
||
if (isRow) setApplyingRow(applyConfirmRow.subnet);
|
||
else setApplying(true);
|
||
try {
|
||
await api.post('/mikrotik/address-lists/apply-summary', {
|
||
serverId,
|
||
removeIds: applyPayload.removeIds,
|
||
addEntries: applyPayload.addEntries,
|
||
});
|
||
const removed = applyPayload.removeIds.length;
|
||
const added = applyPayload.addEntries.length;
|
||
window.notify?.success?.(`Удалено ${removed} записей, добавлено ${added} подсетей.`);
|
||
setApplyConfirmOpen(false);
|
||
setApplyConfirmRow(null);
|
||
if (isRow && analysisResult?.suggestions) {
|
||
setAnalysisResult((prev) => ({
|
||
...prev,
|
||
suggestions: prev.suggestions.filter((s) => s.subnet !== applyConfirmRow.subnet),
|
||
}));
|
||
} else {
|
||
setAnalysisOpen(false);
|
||
setAnalysisResult(null);
|
||
}
|
||
await fetchAddressLists();
|
||
} catch (e) {
|
||
window.notify?.error?.(e?.response?.data?.message || e?.message || 'Ошибка применения');
|
||
} finally {
|
||
setApplying(false);
|
||
setApplyingRow(null);
|
||
}
|
||
};
|
||
|
||
const currentServerLabel = serverMeta?.dns || serverMeta?.ip || serverId || 'Не выбран';
|
||
|
||
return (
|
||
<div>
|
||
{error && (
|
||
<ErrorAlert message={error} onClose={() => setError('')} />
|
||
)}
|
||
|
||
<PageHeader
|
||
title="Firewall"
|
||
icon={<IconShield size={24} />}
|
||
meta={`Списки блокировки (address-list) с роутера: ${currentServerLabel}`}
|
||
actions={
|
||
<div className="btn-list">
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline-primary"
|
||
onClick={() => {
|
||
setAnalysisOpen(true);
|
||
setAnalysisResult(null);
|
||
}}
|
||
disabled={!serverId || !data?.ban?.length || loading}
|
||
title="Анализ списка ban и суммаризация по /24"
|
||
>
|
||
<IconChartBar className="me-1" size={18} />
|
||
Анализ
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-outline-primary"
|
||
onClick={fetchAddressLists}
|
||
disabled={!serverId || loading}
|
||
>
|
||
<IconRefresh className={loading ? 'icon spin me-1' : 'me-1'} size={18} />
|
||
Обновить
|
||
</button>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
<div className="card card-flat mb-4">
|
||
<div className="card-header py-3">
|
||
<h3 className="card-title mb-0">Сервер (MikroTik)</h3>
|
||
<p className="text-muted small mb-0 mt-1">
|
||
Выберите jumphost или home-роутер для просмотра списков <strong>ban</strong> и <strong>ban_counter</strong>.
|
||
</p>
|
||
</div>
|
||
<div className="card-body">
|
||
{loadingServers ? (
|
||
<div className="text-muted">Загрузка списка серверов...</div>
|
||
) : (
|
||
<div style={{ maxWidth: '420px' }}>
|
||
<ServerAutocompleteInput
|
||
value={serverId}
|
||
onChange={setServerId}
|
||
onSelectMeta={setServerMeta}
|
||
servers={routerServers}
|
||
placeholder="Выберите сервер..."
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{!serverId && (
|
||
<div className="card card-flat">
|
||
<div className="card-body text-center py-5">
|
||
<IconAlertCircle className="icon text-muted mb-2" size={48} />
|
||
<p className="text-muted mb-0">Выберите сервер выше, чтобы загрузить address-list (ban и ban_counter).</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{serverId && data && (
|
||
<>
|
||
<AddressListTable
|
||
title="Список ban"
|
||
listKey="ban"
|
||
items={data.ban}
|
||
loading={loading}
|
||
emptyMessage="Список ban пуст"
|
||
/>
|
||
<AddressListTable
|
||
title="Список ban_counter"
|
||
listKey="ban_counter"
|
||
items={data.ban_counter}
|
||
loading={loading}
|
||
emptyMessage="Список ban_counter пуст"
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{serverId && loading && !data && (
|
||
<div className="card card-flat">
|
||
<div className="card-body py-5 text-center text-muted">
|
||
<IconRefresh className="icon spin me-2" size={20} />
|
||
Загрузка списков с роутера...
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Modal
|
||
show={analysisOpen}
|
||
onClose={() => {
|
||
setAnalysisOpen(false);
|
||
setAnalysisResult(null);
|
||
setApplyConfirmOpen(false);
|
||
setApplyConfirmRow(null);
|
||
setIpInfoMap({});
|
||
}}
|
||
title="Анализ списка ban"
|
||
size="xl"
|
||
>
|
||
<div className="mb-3">
|
||
<label className="form-label">Суммаризировать подсети /24, в которых одиночных IP больше чем</label>
|
||
<div className="d-flex align-items-center gap-2">
|
||
<input
|
||
type="number"
|
||
className="form-control"
|
||
style={{ width: '80px' }}
|
||
min={2}
|
||
max={100}
|
||
value={minCountN}
|
||
onChange={(e) => setMinCountN(e.target.value)}
|
||
/>
|
||
<span className="text-muted small">(по умолчанию {DEFAULT_MIN_COUNT_N})</span>
|
||
</div>
|
||
</div>
|
||
<div className="mb-3">
|
||
<button type="button" className="btn btn-primary" onClick={runAnalysis}>
|
||
<IconChartBar className="me-1" size={18} />
|
||
Анализировать
|
||
</button>
|
||
</div>
|
||
{analysisResult && (
|
||
<>
|
||
{analysisResult.suggestions.length === 0 ? (
|
||
<p className="text-muted mb-0">Нет подсетей для суммаризации (в каждой /24 не больше {minCountN} одиночных IP).</p>
|
||
) : (
|
||
<>
|
||
<p className="mb-2">
|
||
Найдено <strong>{analysisResult.suggestions.length}</strong> подсетей /24 для замены: удалить одиночные IP и добавить одну запись на подсеть.
|
||
{ipInfoLoading && (
|
||
<span className="ms-2 text-muted small">(загрузка AS/хостер с ipinfo.io…)</span>
|
||
)}
|
||
</p>
|
||
<div className="table-responsive">
|
||
<table className="table table-sm table-vcenter mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Подсеть</th>
|
||
<th className="text-end">Кол-во IP</th>
|
||
<th>AS / Хостер</th>
|
||
<th>Ссылка</th>
|
||
<th>Действие</th>
|
||
<th className="text-end" style={{ width: '120px' }}>Применить</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{analysisResult.suggestions.map((s) => {
|
||
const firstIp = s.subnet.split('/')[0];
|
||
const info = ipInfoMap[s.subnet];
|
||
const rowApplying = applyingRow === s.subnet;
|
||
return (
|
||
<tr key={s.subnet}>
|
||
<td className="font-monospace">{s.subnet}</td>
|
||
<td className="text-end">{s.count}</td>
|
||
<td className="small">
|
||
{info === undefined
|
||
? (ipInfoLoading ? '…' : '—')
|
||
: info
|
||
? (info.org || info.country ? [info.org, info.country].filter(Boolean).join(' · ') : '—')
|
||
: '—'}
|
||
</td>
|
||
<td>
|
||
<a
|
||
href={`https://ipinfo.io/${firstIp}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-primary"
|
||
>
|
||
ipinfo.io
|
||
</a>
|
||
</td>
|
||
<td className="text-muted small">Удалить {s.count} записей → добавить 1</td>
|
||
<td className="text-end">
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-warning"
|
||
disabled={rowApplying || applying}
|
||
onClick={() => {
|
||
setApplyConfirmRow(s);
|
||
setApplyConfirmOpen(true);
|
||
}}
|
||
>
|
||
{rowApplying ? (
|
||
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
|
||
) : null}
|
||
Применить
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="mt-3">
|
||
<button
|
||
type="button"
|
||
className="btn btn-warning"
|
||
onClick={() => {
|
||
setApplyConfirmRow(null);
|
||
setApplyConfirmOpen(true);
|
||
}}
|
||
>
|
||
Применить всё на роутере
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</Modal>
|
||
|
||
<ConfirmDialog
|
||
open={applyConfirmOpen}
|
||
title={applyConfirmRow ? `Применить для ${applyConfirmRow.subnet}?` : 'Применить суммаризацию на роутере?'}
|
||
message={
|
||
applyConfirmRow
|
||
? `Удалить ${applyConfirmRow.count} записей и добавить 1 запись (подсеть ${applyConfirmRow.subnet}). Продолжить?`
|
||
: applyPayload
|
||
? `Будет удалено ${applyPayload.removeIds.length} записей и добавлено ${applyPayload.addEntries.length} записей (подсети /24). Продолжить?`
|
||
: ''
|
||
}
|
||
confirmText="Применить"
|
||
cancelText="Отмена"
|
||
destructive
|
||
size="md"
|
||
loading={applying || !!applyingRow}
|
||
onConfirm={handleApplySummary}
|
||
onCancel={() => {
|
||
setApplyConfirmOpen(false);
|
||
setApplyConfirmRow(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|