Files
router-lists-ui/frontend/src/CommunitiesManager.jsx
T

825 lines
33 KiB
React

import { useEffect, useMemo, useState } from 'react';
import api from './lib/api.js';
import {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconHash,
IconFilter,
IconChartBar,
IconList,
IconLink,
IconRefresh,
IconSortAscending,
IconSortDescending
} from '@tabler/icons-react';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import EmptyState from './components/EmptyState.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import CommunityStats from './components/CommunityStats.jsx';
import SavedFilters from './components/SavedFilters.jsx';
import FormModal from './components/FormModal.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import FormField from './components/FormField.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import Pagination from './components/Pagination.jsx';
const API_URL = '/api';
function CommunitiesManager() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState('value');
const [sortOrder, setSortOrder] = useState('asc');
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
// Состояние для переключения между списком и статистикой
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [editingValue, setEditingValue] = useState(null);
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '', category: '', priority: 0, enabled: true });
const [filterUsage, setFilterUsage] = useState({});
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
const [filterUsageError, setFilterUsageError] = useState('');
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
// Модальные окна
const [addModalOpen, setAddModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
useEffect(() => {
fetchItems();
fetchFilterUsage();
}, []);
useEffect(() => {
if (!filterUsage) return;
setItems((prev) => {
const map = new Map(prev.map((i) => [i.value, i]));
Object.keys(filterUsage).forEach((value) => {
if (!value) return;
if (!map.has(value)) {
map.set(value, { value, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
}
});
return Array.from(map.values());
});
}, [filterUsage]);
const fetchItems = async () => {
setLoading(true);
try {
const [dictRes, asnsRes, ipRes, domRes] = await Promise.all([
api.get(`/communities`),
api.get(`/asns`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
]);
const dict = Array.isArray(dictRes.data) ? dictRes.data : [];
const asnsPayload = Array.isArray(asnsRes.data?.items) ? asnsRes.data.items : (Array.isArray(asnsRes.data) ? asnsRes.data : []);
const ipPayload = Array.isArray(ipRes.data?.items) ? ipRes.data.items : (Array.isArray(ipRes.data) ? ipRes.data : []);
const domPayload = Array.isArray(domRes.data?.items) ? domRes.data.items : (Array.isArray(domRes.data) ? domRes.data : []);
const usedCommunities = new Set();
// ASNs: fields domain/type
for (const it of asnsPayload) {
const v = String(it.type ?? '').trim();
if (v) usedCommunities.add(v);
}
// IP ranges: fields ipRange/community
for (const it of ipPayload) {
const v = String(it.community ?? '').trim();
if (v) usedCommunities.add(v);
}
// Domains: fields domain/community
for (const it of domPayload) {
const v = String(it.community ?? '').trim();
if (v) usedCommunities.add(v);
}
const map = new Map();
// put dictionary items first
for (const d of dict) {
const value = String(d.value || '').trim();
if (!value) continue;
map.set(value, {
value,
name: String(d.name || ''),
description: String(d.description || ''),
tags: Array.isArray(d.tags) ? d.tags : [],
gatewayDefault: String(d.gatewayDefault || ''),
color: String(d.color || ''),
_external: false,
});
}
// add unknown ones from usage
for (const v of usedCommunities) {
if (!map.has(v)) {
map.set(v, { value: v, name: '', description: '', tags: [], gatewayDefault: '', color: '', _external: true });
}
}
setItems(Array.from(map.values()));
setError('');
} catch (e) {
console.error('Error fetching communities:', e);
setError('Не удалось загрузить справочник community.');
} finally {
setLoading(false);
}
};
const fetchFilterUsage = async () => {
setFilterUsageLoading(true);
setFilterUsageError('');
try {
const serversRes = await api.get(`/server-configs`);
const serversList = Array.isArray(serversRes.data) ? serversRes.data : [];
if (!serversList.length) {
setFilterUsage({});
return;
}
const responses = await Promise.all(
serversList.map((server) =>
api.get(`/server-filters/${server.id}`).catch(() => ({ data: [] }))
)
);
const usageMap = new Map();
responses.forEach((res) => {
const arr = Array.isArray(res.data) ? res.data : [];
arr.forEach((f) => {
const value = String(f?.community || '').trim();
if (!value) return;
usageMap.set(value, (usageMap.get(value) || 0) + 1);
});
});
setFilterUsage(Object.fromEntries(usageMap));
} catch (e) {
console.error('Error fetching filter usage:', e);
setFilterUsageError('Не удалось загрузить использование в фильтрах.');
} finally {
setFilterUsageLoading(false);
}
};
const saveAll = async (data) => {
setLoading(true);
try {
// не отправляем служебное поле _external
const payload = data.map(({ _external, ...rest }) => rest);
await api.post(`/communities`, { communities: payload });
setSuccess('Справочник сохранён!');
setTimeout(() => setSuccess(''), 3000);
} catch (e) {
console.error('Error saving communities:', e);
setError(e.response?.data || 'Не удалось сохранить справочник.');
} finally {
setLoading(false);
}
};
const toTagsArray = (str) => (str || '').split(',').map(t => t.trim()).filter(Boolean);
const fromTagsArray = (arr) => (arr || []).join(', ');
const addItem = () => {
const value = String(newItem.value || '').trim();
if (!value) { setError('Поле value обязательно.'); return; }
if (items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
if (!/^(\d+|\d+:\d+)$/.test(value)) { setError('Формат: число или N:N'); return; }
setError('');
setItems([...items, {
value,
name: String(newItem.name || ''),
description: String(newItem.description || ''),
tags: toTagsArray(newItem.tags),
gatewayDefault: String(newItem.gatewayDefault || ''),
color: String(newItem.color || ''),
}]);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
setAddModalOpen(false);
setSuccess('Community успешно добавлен!');
setTimeout(() => setSuccess(''), 3000);
};
const startEdit = (item) => {
setEditingValue(item.value);
setEditingDraft({
value: item.value,
name: item.name || '',
description: item.description || '',
tags: fromTagsArray(item.tags),
gatewayDefault: item.gatewayDefault || '',
color: item.color || '',
});
setEditModalOpen(true);
};
const saveEdit = () => {
const value = String(editingDraft.value || '').trim();
if (!value) { setError('Поле value обязательно.'); return; }
if (!/^(\d+|\d+:\d+)$/.test(value)) { setError('Формат: число или N:N'); return; }
if (value !== editingValue && items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
const next = items.map(i => i.value === editingValue ? {
value,
name: String(editingDraft.name || ''),
description: String(editingDraft.description || ''),
tags: toTagsArray(editingDraft.tags),
gatewayDefault: String(editingDraft.gatewayDefault || ''),
color: String(editingDraft.color || ''),
} : i);
setItems(next);
setEditingValue(null);
setEditModalOpen(false);
setSuccess('Community успешно обновлён!');
setTimeout(() => setSuccess(''), 3000);
};
const cancelEdit = () => {
setEditingValue(null);
setEditModalOpen(false);
};
const handleDeleteClick = (item) => {
setItemToDelete(item);
setDeleteModalOpen(true);
};
const confirmDelete = () => {
if (itemToDelete) {
setItems(items.filter(i => i.value !== itemToDelete.value));
setDeleteModalOpen(false);
setItemToDelete(null);
setSuccess('Community успешно удалён!');
setTimeout(() => setSuccess(''), 3000);
}
};
const handleImport = () => {
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)');
if (!text) return;
try {
const arr = JSON.parse(text);
if (!Array.isArray(arr)) throw new Error('Ожидается массив');
setItems(prev => [...prev, ...arr.filter(x => x && x.value)]);
} catch (e) {
setError('Неверный JSON.');
}
};
const handleExport = () => {
const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `communities_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleSave = () => saveAll(items);
const sorted = useMemo(() => {
const arr = [...items];
arr.sort((a, b) => {
let va = (a[sortField] ?? '').toString().toLowerCase();
let vb = (b[sortField] ?? '').toString().toLowerCase();
if (va < vb) return sortOrder === 'asc' ? -1 : 1;
if (va > vb) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
return arr;
}, [items, sortField, sortOrder]);
const filtered = useMemo(() => {
const q = searchTerm.trim().toLowerCase();
const searchFiltered = q
? sorted.filter(i =>
(i.value || '').toLowerCase().includes(q) ||
(i.name || '').toLowerCase().includes(q) ||
(i.description || '').toLowerCase().includes(q) ||
(i.tags || []).some(t => (t || '').toLowerCase().includes(q))
)
: sorted;
return searchFiltered.filter((i) => !showOnlyUsedInFilters || (filterUsage[i.value] || 0) > 0);
}, [sorted, searchTerm, showOnlyUsedInFilters, filterUsage]);
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
const page = Math.min(currentPage, totalPages);
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
const changeSort = (f) => {
if (sortField === f) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
else { setSortField(f); setSortOrder('asc'); }
};
return (
<div className="page-wrapper">
<div className="page-header d-print-none">
<div className="container-xl">
<div className="row g-2 align-items-center">
<div className="col">
<h2 className="page-title">
<IconFilter className="icon me-2" />
Справочник Community
</h2>
<div className="page-pretitle">
<Breadcrumbs items={[
{ label: 'Главная', to: '/dashboard' },
{ label: 'Данные' },
{ label: 'Community' },
]} />
</div>
</div>
<div className="col-auto ms-auto d-print-none">
<PageHeaderActions
loading={loading}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onSave={handleSave}
disableSave={loading}
/>
</div>
</div>
</div>
</div>
<div className="page-body">
<div className="container-xl">
{/* Уведомления */}
{error && (
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
{/* Вкладки: Список / Статистика */}
<div className="card mb-3">
<div className="card-body p-2">
<ul className="nav nav-tabs nav-tabs-bg" role="tablist">
<li className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === 'list' ? 'active' : ''}`}
onClick={() => setActiveTab('list')}
type="button"
role="tab"
aria-selected={activeTab === 'list'}
>
<IconList className="me-2" size={18} />
Список communities
</button>
</li>
<li className="nav-item" role="presentation">
<button
className={`nav-link ${activeTab === 'stats' ? 'active' : ''}`}
onClick={() => setActiveTab('stats')}
type="button"
role="tab"
aria-selected={activeTab === 'stats'}
>
<IconChartBar className="me-2" size={18} />
Статистика использования
</button>
</li>
</ul>
</div>
</div>
{/* Отображение списка communities */}
{activeTab === 'list' && (
<div className="row g-3">
<div className="col-12">
<div className="card">
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<h3 className="card-title mb-0">
<IconFilter className="icon me-2" size={20} />
Список community
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
</h3>
</div>
<div className="col-auto">
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
>
<IconPlus className="icon me-1" />
Добавить community
</button>
</div>
</div>
</div>
<div className="card-body">
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
<SavedFilters
pageKey="communities"
currentFilters={{ searchTerm, sortField, sortOrder }}
onApplyFilter={(filters) => {
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
if (filters.sortField !== undefined) setSortField(filters.sortField);
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
setCurrentPage(1);
}}
/>
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon"><IconSearch size={18} /></span>
<input
className="form-control"
placeholder="Поиск по value, названию, описанию и тегам"
value={searchTerm}
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/>
{!!searchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
aria-label="Очистить поиск"
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
>
<IconX size={16} />
</button>
)}
</div>
<div className="d-flex align-items-center gap-3 flex-wrap">
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
<input
className="form-check-input"
type="checkbox"
checked={showOnlyUsedInFilters}
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
/>
<span className="form-check-label">Только используемые в фильтрах</span>
{filterUsageLoading && (
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
)}
</label>
<button
className="btn btn-outline-secondary btn-sm"
type="button"
onClick={fetchFilterUsage}
disabled={filterUsageLoading}
title="Обновить использование из /filters"
>
<IconRefresh className="icon me-1" size={16} />
Обновить использование
</button>
{filterUsageError && (
<span className="text-danger small">{filterUsageError}</span>
)}
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Value
{sortField==='value' && (
<span className="ms-1">
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
</span>
)}
</div>
</th>
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
<div className="d-flex align-items-center">
<IconFilter size={16} className="me-1 text-muted" />
Название
{sortField==='name' && (
<span className="ms-1">
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
</span>
)}
</div>
</th>
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
) : paginated.length === 0 ? (
<tr><td colSpan={7} className="text-center text-muted py-4">
<EmptyState
title="Нет community"
description="Добавьте запись, чтобы начать."
action={
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
<IconPlus className="me-2" />
Добавить
</button>
}
/>
</td></tr>
) : paginated.map((item) => {
const usageCount = filterUsage[item.value] || 0;
return (
<tr key={item.value}>
<td>
<code className="text-blue">{item.value}</code>
{item._external && (
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
)}
</td>
<td>{item.name || <span className="text-muted"></span>}</td>
<td>
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
</td>
<td>
{(item.tags || []).map(t => (
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
))}
</td>
<td>
<span className="text-muted">{item.gatewayDefault || '—'}</span>
</td>
<td>
{usageCount > 0 ? (
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
) : (
<span className="text-muted">0</span>
)}
</td>
<td className="text-end">
<div className="btn-list">
<a
className="btn btn-outline-primary btn-icon btn-sm"
href={`/filters?community=${encodeURIComponent(item.value)}`}
title="Открыть в /filters с поиском по community"
>
<IconLink size={16} />
</a>
<button
className="btn btn-outline-primary btn-icon btn-sm"
onClick={()=>startEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
<button
className="btn btn-outline-danger btn-icon btn-sm"
onClick={()=>handleDeleteClick(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
)}
</div>
</div>
</div>
</div>
)}
{/* Отображение статистики communities */}
{activeTab === 'stats' && (
<CommunityStats communities={items} />
)}
</div>
</div>
{/* Модальное окно добавления */}
<FormModal
show={addModalOpen}
onClose={() => {
setAddModalOpen(false);
setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' });
setError('');
}}
onSubmit={addItem}
title="Добавить community"
submitLabel="Добавить"
submitIcon={IconPlus}
size="md"
>
{error && (
<div className="alert alert-danger mb-3" role="alert">
{error}
</div>
)}
<FormField
label="Value"
name="value"
value={newItem.value}
onChange={(val) => setNewItem({...newItem, value: val})}
placeholder="65001:200 или 100"
required
icon={IconHash}
helpText="Формат: число или N:N"
onValidate={(val) => {
const trimmed = String(val || '').trim();
if (!trimmed) return { valid: false, message: 'Поле обязательно' };
if (!/^(\d+|\d+:\d+)$/.test(trimmed)) return { valid: false, message: 'Формат: число или N:N' };
if (items.some(i => i.value === trimmed)) return { valid: false, message: 'Такое value уже существует' };
return { valid: true, message: 'Корректно' };
}}
/>
<FormField
label="Название"
name="name"
value={newItem.name}
onChange={(val) => setNewItem({...newItem, name: val})}
placeholder="Напр. Social"
icon={IconFilter}
/>
<FormField
label="Описание"
name="description"
type="textarea"
value={newItem.description}
onChange={(val) => setNewItem({...newItem, description: val})}
rows={2}
/>
<FormField
label="Теги (через запятую)"
name="tags"
value={newItem.tags}
onChange={(val) => setNewItem({...newItem, tags: val})}
placeholder="video,bank"
helpText="Разделяйте теги запятыми"
/>
<FormField
label="Gateway по умолчанию"
name="gatewayDefault"
value={newItem.gatewayDefault}
onChange={(val) => setNewItem({...newItem, gatewayDefault: val})}
placeholder="SWE-HIPHOST"
/>
<FormField
label="Цвет бейджа (опционально)"
name="color"
value={newItem.color}
onChange={(val) => setNewItem({...newItem, color: val})}
placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе"
/>
</FormModal>
{/* Модальное окно редактирования */}
<FormModal
show={editModalOpen}
onClose={cancelEdit}
onSubmit={saveEdit}
title="Редактировать community"
submitLabel="Сохранить"
submitIcon={IconCheck}
size="md"
>
{error && (
<div className="alert alert-danger mb-3" role="alert">
{error}
</div>
)}
<FormField
label="Value"
name="value"
value={editingDraft.value}
onChange={(val) => setEditingDraft({...editingDraft, value: val})}
placeholder="65001:200 или 100"
required
icon={IconHash}
helpText="Формат: число или N:N"
onValidate={(val) => {
const trimmed = String(val || '').trim();
if (!trimmed) return { valid: false, message: 'Поле обязательно' };
if (!/^(\d+|\d+:\d+)$/.test(trimmed)) return { valid: false, message: 'Формат: число или N:N' };
if (trimmed !== editingValue && items.some(i => i.value === trimmed)) {
return { valid: false, message: 'Такое value уже существует' };
}
return { valid: true, message: 'Корректно' };
}}
/>
<FormField
label="Название"
name="name"
value={editingDraft.name}
onChange={(val) => setEditingDraft({...editingDraft, name: val})}
placeholder="Напр. Social"
icon={IconFilter}
/>
<FormField
label="Описание"
name="description"
type="textarea"
value={editingDraft.description}
onChange={(val) => setEditingDraft({...editingDraft, description: val})}
rows={2}
/>
<FormField
label="Теги (через запятую)"
name="tags"
value={editingDraft.tags}
onChange={(val) => setEditingDraft({...editingDraft, tags: val})}
placeholder="video,bank"
helpText="Разделяйте теги запятыми"
/>
<FormField
label="Gateway по умолчанию"
name="gatewayDefault"
value={editingDraft.gatewayDefault}
onChange={(val) => setEditingDraft({...editingDraft, gatewayDefault: val})}
placeholder="SWE-HIPHOST"
/>
<FormField
label="Цвет бейджа (опционально)"
name="color"
value={editingDraft.color}
onChange={(val) => setEditingDraft({...editingDraft, color: val})}
placeholder="blue, green, red ..."
helpText="Цвет для отображения бейджа в интерфейсе"
/>
</FormModal>
{/* Модальное окно подтверждения удаления */}
<ConfirmModal
show={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setItemToDelete(null);
}}
onConfirm={confirmDelete}
title="Удалить community?"
message={
itemToDelete ? (
<>
<p>Вы уверены, что хотите удалить community <strong><code>{itemToDelete.value}</code></strong>?</p>
{itemToDelete.name && (
<p className="text-muted mb-0">Название: {itemToDelete.name}</p>
)}
<p className="text-muted mb-0 mt-2">Это действие необратимо.</p>
</>
) : null
}
confirmLabel="Удалить"
variant="danger"
icon={IconTrash}
/>
</div>
);
}
export default CommunitiesManager;