feat: Add language and theme context providers to App component, implement global search functionality in Dashboard, and enhance ASNs, Domains, and IPRanges managers with drag-and-drop import feature and confirmation modals for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m48s

This commit is contained in:
2025-08-10 23:44:17 +07:00
parent ca56b52672
commit 1fb53c3467
8 changed files with 460 additions and 25 deletions
@@ -0,0 +1,39 @@
function ConfirmDiffModal({ show, diff, onConfirm, onClose }) {
if (!show) return null;
const added = diff?.added?.length || 0;
const removed = diff?.removed?.length || 0;
const changed = diff?.changed?.length || 0;
return (
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Подтвердить сохранение</h5>
<button type="button" className="btn-close" onClick={onClose}></button>
</div>
<div className="modal-body">
<div className="row g-2 text-center">
<div className="col">
<div className="card"><div className="card-body p-2"><strong>Добавлено</strong><div className="text-muted">{added}</div></div></div>
</div>
<div className="col">
<div className="card"><div className="card-body p-2"><strong>Удалено</strong><div className="text-muted">{removed}</div></div></div>
</div>
<div className="col">
<div className="card"><div className="card-body p-2"><strong>Изменено</strong><div className="text-muted">{changed}</div></div></div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={onClose}>Отмена</button>
<button type="button" className="btn btn-primary" onClick={onConfirm}>Сохранить</button>
</div>
</div>
</div>
</div>
);
}
export default ConfirmDiffModal;
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
import axios from 'axios';
function LockBanner({ resource, className = '' }) {
const [status, setStatus] = useState({ locked: false });
const [loading, setLoading] = useState(false);
const owner = (() => {
const existing = localStorage.getItem('uiOwner');
if (existing) return existing;
const gen = `ui-${Math.random().toString(36).slice(2, 8)}`;
localStorage.setItem('uiOwner', gen);
return gen;
})();
const fetchStatus = async () => {
try {
const res = await axios.get(`/api/locks/${resource}`);
setStatus(res.data || { locked: false });
} catch {
// ignore
}
};
useEffect(() => {
fetchStatus();
const id = setInterval(fetchStatus, 30_000);
return () => clearInterval(id);
}, [resource]);
const acquireOrRefresh = async () => {
setLoading(true);
try {
const res = await axios.post(`/api/locks/${resource}`, { owner, ttlSeconds: 180 });
setStatus(res.data || { locked: true, owner, expiresAt: Date.now() + 180_000 });
} catch {
// ignore
} finally {
setLoading(false);
}
};
const release = async () => {
setLoading(true);
try {
await axios.delete(`/api/locks/${resource}`);
setStatus({ locked: false });
} catch {
// ignore
} finally {
setLoading(false);
}
};
const myLock = status.locked && status.owner === owner;
const until = status.expiresAt ? new Date(status.expiresAt).toLocaleTimeString() : null;
return (
<div className={`alert ${status.locked ? (myLock ? 'alert-success' : 'alert-warning') : 'alert-secondary'} d-flex align-items-center justify-content-between ${className}`} role="alert">
<div>
{status.locked ? (
myLock ? (
<>Вы удерживаете блокировку ресурса <code>{resource}</code>{until ? ` до ${until}` : ''}</>
) : (
<>Ресурс <code>{resource}</code> редактирует <strong>{status.owner || 'другой пользователь'}</strong>{until ? ` до ${until}` : ''}</>
)
) : (
<>Ресурс <code>{resource}</code> свободен</>
)}
</div>
<div className="btn-list m-0">
<button className="btn btn-outline-primary btn-sm" onClick={fetchStatus} disabled={loading}>Обновить</button>
<button className="btn btn-primary btn-sm" onClick={acquireOrRefresh} disabled={loading}>{myLock ? 'Продлить' : 'Захватить'}</button>
{myLock && (
<button className="btn btn-outline-danger btn-sm" onClick={release} disabled={loading}>Освободить</button>
)}
</div>
</div>
);
}
export default LockBanner;
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import axios from 'axios';
import { IconHash, IconClock, IconFileText, IconRefresh } from '@tabler/icons-react';
function S3MetaBar({ keys = [], className = '' }) {
const [meta, setMeta] = useState({});
const [loading, setLoading] = useState(false);
const fetchMeta = async () => {
setLoading(true);
try {
const res = await axios.get('/api/s3/last-modified');
setMeta(res.data || {});
} catch {
setMeta({});
} finally {
setLoading(false);
}
};
useEffect(() => { fetchMeta(); }, []);
const renderKey = (k) => {
const it = meta[k];
return (
<div key={k} className="d-flex align-items-center gap-2 flex-wrap">
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center"><IconHash size={14} className="me-1" />{it?.etag || '—'}</span>
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center"><IconClock size={14} className="me-1" />{it?.lastModified ? new Date(it.lastModified).toLocaleString() : '—'}</span>
<span className="badge bg-blue-lt text-blue d-inline-flex align-items-center"><IconFileText size={14} className="me-1" />{typeof it?.contentLength === 'number' ? `${it.contentLength} байт` : '—'}</span>
</div>
);
};
return (
<div className={`card-footer ${className}`}>
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex flex-column gap-1">
{keys.length === 0 ? renderKey('domainsNew') : keys.map(renderKey)}
</div>
<button className="btn btn-outline-secondary btn-sm" onClick={fetchMeta} disabled={loading}>
<IconRefresh className={loading ? 'spin' : ''} />
<span className="ms-1">Обновить</span>
</button>
</div>
</div>
);
}
export default S3MetaBar;