diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 1a13c12..a1a1d44 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Search } from 'lucide-react'; import { useAuthStore } from '@/store/authStore'; -import { NAV_GROUPS, filterNavGroupsByRole } from '@/config/adminAccess'; +import { NAV_GROUPS, filterNavGroupsByRole, canAccessRoute } from '@/config/adminAccess'; import { Dialog, DialogContent, @@ -24,6 +24,9 @@ interface CommandPaletteProps { onOpenChange: (open: boolean) => void; } +/** Looks like EventHub entity ids (base64url-ish). */ +const ENTITY_ID_RE = /^[A-Za-z0-9_-]{16,40}$/; + export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -54,15 +57,38 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { }, [role, t]); const filtered = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return items; - return items.filter( - (item) => - item.label.toLowerCase().includes(q) || - item.group.toLowerCase().includes(q) || - item.path.toLowerCase().includes(q) - ); - }, [items, query]); + const q = query.trim(); + const qLower = q.toLowerCase(); + const navHits = !qLower + ? items + : items.filter( + (item) => + item.label.toLowerCase().includes(qLower) || + item.group.toLowerCase().includes(qLower) || + item.path.toLowerCase().includes(qLower) + ); + + if (!ENTITY_ID_RE.test(q)) return navHits; + + const jumps: PaletteItem[] = []; + const pushIf = (path: string, labelKey: string, id: string) => { + if (canAccessRoute(path.split('?')[0], role)) { + jumps.push({ + id, + label: `${t(labelKey)} · ${q}`, + group: t('layout.paletteJumpGroup'), + path, + }); + } + }; + pushIf(`/inbox/reports?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenReportInbox', `jump-report-inbox-${q}`); + pushIf(`/reports/${encodeURIComponent(q)}`, 'layout.paletteOpenReport', `jump-report-${q}`); + pushIf(`/inbox/tickets?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenTicketInbox', `jump-ticket-inbox-${q}`); + pushIf(`/tickets/${encodeURIComponent(q)}`, 'layout.paletteOpenTicket', `jump-ticket-${q}`); + pushIf(`/events/${encodeURIComponent(q)}`, 'layout.paletteOpenEvent', `jump-event-${q}`); + pushIf(`/users/${encodeURIComponent(q)}`, 'layout.paletteOpenUser', `jump-user-${q}`); + return [...jumps, ...navHits]; + }, [items, query, role, t]); useEffect(() => { setActiveIndex(0); diff --git a/src/components/HeaderNodeMetrics.tsx b/src/components/HeaderNodeMetrics.tsx index e46ff64..9f9520a 100644 --- a/src/components/HeaderNodeMetrics.tsx +++ b/src/components/HeaderNodeMetrics.tsx @@ -80,7 +80,10 @@ export function HeaderNodeMetrics({ className }: { className?: string }) { const showArrows = canScroll.left || canScroll.right; return ( -
+
+ + {t('layout.nodesLabel')} + {showArrows && (
-
{node}
+
{t('layout.nodeTooltipTitle', { node })}
- CPU: {cpu.toFixed(1)}%{' '} - {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''} + {t('layout.nodeCpu', { value: cpu.toFixed(1) })} + {prevCpu !== undefined ? ` (${t('layout.nodeWas', { value: prevCpu.toFixed(1) })})` : ''}
- Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ( - {memoryPercent.toFixed(1)}%) - {prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''} + {t('layout.nodeMemory', { + used: (usedMemory / 1048576).toFixed(0), + total: (totalMemory / 1048576).toFixed(0), + percent: memoryPercent.toFixed(1), + })} + {prevMemoryPercent !== null + ? ` (${t('layout.nodeWas', { value: prevMemoryPercent.toFixed(1) })})` + : ''}
@@ -125,7 +132,11 @@ const MetricIndicator: React.FC = ({ className={shellClass} style={{ width: size, height: size }} onClick={onClick} - aria-label={`${node}: CPU ${cpu.toFixed(0)}%, память ${memoryPercent.toFixed(0)}%`} + aria-label={t('layout.nodeAria', { + node, + cpu: cpu.toFixed(0), + memory: memoryPercent.toFixed(0), + })} > {body} diff --git a/src/locales/en.json b/src/locales/en.json index 8339bee..6910b68 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -19,6 +19,14 @@ "yes": "Yes", "no": "No", "noData": "No data", + "ageJustNow": "just now", + "ageMinutes": "{{count}}m", + "ageHours": "{{count}}h", + "ageYesterday": "yesterday", + "ageDays": "{{count}}d", + "selectedCount": "Selected: {{count}}", + "bulkReviewed": "Reviewed ({{count}})", + "bulkDismissed": "Dismissed ({{count}})", "loading": "Loading…", "emDash": "—", "all": "All", @@ -140,18 +148,18 @@ "account": "Account" }, "nav": { - "inboxReports": "Inbox · reports", - "inboxTickets": "Inbox · tickets", + "inboxReports": "Queue · reports", + "inboxTickets": "Queue · tickets", "dashboard": "Dashboard", "monitoring": "Monitoring", "users": "Users", "calendars": "Calendars", "events": "Events", "subscriptions": "Subscriptions", - "reports": "Reports (list)", + "reports": "Reports · archive", "reviews": "Reviews", "bannedWords": "Banned words", - "tickets": "Tickets (list)", + "tickets": "Tickets · archive", "admins": "Admins", "audit": "Audit", "profile": "My profile" @@ -167,11 +175,24 @@ "densityToNormal": "Comfortable density", "densityToCompact": "Compact density", "paletteTitle": "Command palette", - "palettePlaceholder": "Go to section…", + "palettePlaceholder": "Go to a section or paste an ID…", "paletteHints": "↑↓ navigate · Enter open · Esc close", + "paletteOpenReportInbox": "Report in inbox", + "paletteOpenReport": "Report detail", + "paletteOpenTicketInbox": "Ticket in inbox", + "paletteOpenTicket": "Ticket detail", + "paletteOpenEvent": "Event detail", + "paletteOpenUser": "User detail", + "paletteJumpGroup": "By ID", "nodesNoData": "Nodes: no data", + "nodesLabel": "Nodes · CPU", "nodesPrev": "Previous nodes", - "nodesNext": "Next nodes" + "nodesNext": "Next nodes", + "nodeTooltipTitle": "Node {{node}} · CPU / RAM", + "nodeCpu": "CPU: {{value}}%", + "nodeMemory": "Memory: {{used}} / {{total}} MB ({{percent}}%)", + "nodeWas": "was {{value}}%", + "nodeAria": "{{node}}: CPU {{cpu}}%, memory {{memory}}%" }, "explore": { "viewAria": "List view", @@ -200,8 +221,8 @@ "ownerId": "owner {{id}}" }, "inbox": { - "reportsTitle": "Inbox · Reports", - "ticketsTitle": "Inbox · Tickets", + "reportsTitle": "Queue · Reports", + "ticketsTitle": "Queue · Tickets", "reportsHints": "j/k · Enter open · 1 reviewed · 2 dismissed · link → preview", "ticketsHints": "j/k · Enter open · 1 resolved · 2 in progress", "pending": "Pending", @@ -213,8 +234,8 @@ "selectTicket": "Select a ticket from the queue", "reportCard": "Report {{id}}", "fullCard": "Full card", - "bannerReports": "Day-to-day report work lives in the inbox", - "bannerTickets": "Day-to-day ticket work lives in the inbox" + "bannerReports": "Day-to-day work is in Queue · reports. This is archive and filters.", + "bannerTickets": "Day-to-day work is in Queue · tickets. This is the full list and filters." }, "dashboard": { "controlCenter": "Control Center", @@ -354,7 +375,12 @@ "onlineLink": "Online link", "tags": "Tags", "recurrenceInterval": "{{freq}} (interval: {{interval}})", - "ratingWithCount": "{{avg}} ({{count}} ratings)" + "ratingWithCount": "{{avg}} ({{count}} ratings)", + "periodChip": "start_time range: {{from}} — {{to}}", + "emptyFiltered": "No events match the current filters", + "emptyInRange": "No events in the selected period", + "widenPeriod": "Widen period", + "resetFilters": "Reset filters" }, "calendars": { "title": "Calendars", diff --git a/src/locales/ru.json b/src/locales/ru.json index 4e6b1c6..51bad78 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -19,6 +19,14 @@ "yes": "Да", "no": "Нет", "noData": "Нет данных", + "ageJustNow": "только что", + "ageMinutes": "{{count}} мин", + "ageHours": "{{count}} ч", + "ageYesterday": "вчера", + "ageDays": "{{count}} дн", + "selectedCount": "Выбрано: {{count}}", + "bulkReviewed": "Рассмотрено ({{count}})", + "bulkDismissed": "Отклонено ({{count}})", "loading": "Загрузка…", "emDash": "—", "all": "Все", @@ -140,18 +148,18 @@ "account": "Аккаунт" }, "nav": { - "inboxReports": "Входящие · жалобы", - "inboxTickets": "Входящие · тикеты", + "inboxReports": "Очередь · жалобы", + "inboxTickets": "Очередь · тикеты", "dashboard": "Дашборд", "monitoring": "Мониторинг", "users": "Пользователи", "calendars": "Календари", "events": "События", "subscriptions": "Подписки", - "reports": "Жалобы (список)", + "reports": "Жалобы · архив", "reviews": "Отзывы", "bannedWords": "Бан-слова", - "tickets": "Тикеты (список)", + "tickets": "Тикеты · архив", "admins": "Администраторы", "audit": "Аудит", "profile": "Мой профиль" @@ -167,11 +175,24 @@ "densityToNormal": "Обычная плотность", "densityToCompact": "Компактная плотность", "paletteTitle": "Командная палитра", - "palettePlaceholder": "Перейти к разделу…", + "palettePlaceholder": "Перейти к разделу или вставить ID…", "paletteHints": "↑↓ навигация · Enter открыть · Esc закрыть", + "paletteOpenReportInbox": "Жалоба во входящих", + "paletteOpenReport": "Карточка жалобы", + "paletteOpenTicketInbox": "Тикет во входящих", + "paletteOpenTicket": "Карточка тикета", + "paletteOpenEvent": "Карточка события", + "paletteOpenUser": "Карточка пользователя", + "paletteJumpGroup": "По ID", "nodesNoData": "Ноды: нет данных", + "nodesLabel": "Ноды · CPU", "nodesPrev": "Предыдущие ноды", - "nodesNext": "Следующие ноды" + "nodesNext": "Следующие ноды", + "nodeTooltipTitle": "Нода {{node}} · CPU / RAM", + "nodeCpu": "CPU: {{value}}%", + "nodeMemory": "Память: {{used}} / {{total}} МБ ({{percent}}%)", + "nodeWas": "было {{value}}%", + "nodeAria": "{{node}}: CPU {{cpu}}%, память {{memory}}%" }, "explore": { "viewAria": "Вид списка", @@ -200,8 +221,8 @@ "ownerId": "владелец {{id}}" }, "inbox": { - "reportsTitle": "Входящие · Жалобы", - "ticketsTitle": "Входящие · Тикеты", + "reportsTitle": "Очередь · Жалобы", + "ticketsTitle": "Очередь · Тикеты", "reportsHints": "j/k · Enter открыть · 1 рассмотрено · 2 отклонено · клик по ссылке → preview", "ticketsHints": "j/k · Enter открыть · 1 решено · 2 в работу", "pending": "Ожидают", @@ -213,8 +234,8 @@ "selectTicket": "Выберите тикет из очереди", "reportCard": "Жалоба {{id}}", "fullCard": "Полная карточка", - "bannerReports": "Оперативная работа с жалобами — во входящих", - "bannerTickets": "Оперативная работа с тикетами — во входящих" + "bannerReports": "Оперативка — в «Очередь · жалобы». Здесь архив и фильтры.", + "bannerTickets": "Оперативка — в «Очередь · тикеты». Здесь полный список и фильтры." }, "dashboard": { "controlCenter": "Control Center", @@ -354,7 +375,12 @@ "onlineLink": "Ссылка онлайн", "tags": "Теги", "recurrenceInterval": "{{freq}} (интервал: {{interval}})", - "ratingWithCount": "{{avg}} ({{count}} оценок)" + "ratingWithCount": "{{avg}} ({{count}} оценок)", + "periodChip": "Период start_time: {{from}} — {{to}}", + "emptyFiltered": "Нет событий по текущим фильтрам", + "emptyInRange": "Нет событий в выбранном периоде", + "widenPeriod": "Расширить период", + "resetFilters": "Сбросить фильтры" }, "calendars": { "title": "Календари", diff --git a/src/pages/admins/AdminListPage.tsx b/src/pages/admins/AdminListPage.tsx index 6b95a3c..0a68743 100644 --- a/src/pages/admins/AdminListPage.tsx +++ b/src/pages/admins/AdminListPage.tsx @@ -237,7 +237,6 @@ const AdminListPage: React.FC = () => { <> { <> { /> } /> } /> - } /> } /> - } /> {data.pending_users_total !== undefined && ( } /> )} - {data.reports_pending !== undefined && ( - - )} {data.subscriptions_total !== undefined && ( } /> )} diff --git a/src/pages/events/EventListPage.tsx b/src/pages/events/EventListPage.tsx index 7068a68..72cf0fa 100644 --- a/src/pages/events/EventListPage.tsx +++ b/src/pages/events/EventListPage.tsx @@ -244,6 +244,42 @@ const EventListPage: React.FC = () => { })); }; + const widenPeriod = () => { + const now = Date.now(); + const yearMs = 365 * 24 * 60 * 60 * 1000; + setParams((prev) => ({ + ...prev, + from: new Date(now - 15 * yearMs).toISOString(), + to: new Date(now + 5 * yearMs).toISOString(), + offset: 0, + })); + }; + + const resetFilters = () => { + const now = Date.now(); + const yearMs = 365 * 24 * 60 * 60 * 1000; + setSearchDraft(''); + setParams({ + limit: getSavedPageSize(TABLE_KEY), + offset: 0, + sort: 'created_at', + order: 'desc', + from: new Date(now - 5 * yearMs).toISOString(), + to: new Date(now + 2 * yearMs).toISOString(), + }); + }; + + const periodLabel = useMemo(() => { + if (!params.from || !params.to) return null; + return t('events.periodChip', { + from: formatDateTime(params.from).slice(0, 10), + to: formatDateTime(params.to).slice(0, 10), + }); + }, [params.from, params.to, t]); + + const emptyMessage = + params.q || params.status ? t('events.emptyFiltered') : t('events.emptyInRange'); + const columns = useMemo[]>( () => [ { @@ -341,17 +377,51 @@ const EventListPage: React.FC = () => { <> +
+
+ +
+ + {(params.q || params.status) && ( + + )} + {periodLabel && ( + {periodLabel} + )} +
} > {stats?.top_events_by_rating && stats.top_events_by_rating.length > 0 && ( @@ -378,6 +448,14 @@ const EventListPage: React.FC = () => { /> )} + {!isLoading && (data?.data?.length ?? 0) === 0 && !params.q && !params.status && ( +
+ +
+ )} + { tableKey={TABLE_KEY} params={params} onParamsChange={setParams} + emptyMessage={emptyMessage} />
diff --git a/src/pages/inbox/ReportInboxPage.tsx b/src/pages/inbox/ReportInboxPage.tsx index d5aafd4..15ae938 100644 --- a/src/pages/inbox/ReportInboxPage.tsx +++ b/src/pages/inbox/ReportInboxPage.tsx @@ -1,12 +1,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import dayjs from 'dayjs'; import { useReports, useReport, useUpdateReport } from '@/hooks/useReports'; import { useUser } from '@/hooks/useUsers'; import { useEvent } from '@/hooks/useEvents'; import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys'; import { formatStatusLabel } from '@/utils/statusLabels'; +import { formatDateTime, formatRelativeAge } from '@/utils/datetime'; import { Report } from '@/types/api'; import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; @@ -32,6 +32,8 @@ const ReportInboxPage: React.FC = () => { const [filter, setFilter] = useState('pending'); const selectedFromUrl = searchParams.get('selected') ?? ''; const [preview, setPreview] = useState(null); + const [checkedIds, setCheckedIds] = useState>(() => new Set()); + const [bulkPending, setBulkPending] = useState(false); const listParams = useMemo( () => ({ @@ -70,6 +72,19 @@ const ReportInboxPage: React.FC = () => { [setSearchParams] ); + const toggleChecked = (id: string, checked: boolean) => { + setCheckedIds((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + }; + + const toggleAllVisible = (checked: boolean) => { + setCheckedIds(checked ? new Set(ids) : new Set()); + }; + const { data: report, isLoading: detailLoading } = useReport(selectedId); const updateReport = useUpdateReport(); @@ -99,8 +114,22 @@ const ReportInboxPage: React.FC = () => { [selectedId, updateReport, advanceAfterAction] ); + const handleBulk = async (status: 'reviewed' | 'dismissed') => { + const targets = [...checkedIds]; + if (targets.length === 0) return; + setBulkPending(true); + try { + await Promise.all(targets.map((id) => updateReport.mutateAsync({ id, data: { status } }))); + setCheckedIds(new Set()); + const remaining = ids.filter((id) => !targets.includes(id)); + if (remaining[0]) selectReport(remaining[0]); + } finally { + setBulkPending(false); + } + }; + useInboxHotkeys({ - enabled: !preview, + enabled: !preview && checkedIds.size === 0, onNext: () => { const next = selectNeighborId(ids, selectedId, 1); if (next) selectReport(next); @@ -127,27 +156,74 @@ const ReportInboxPage: React.FC = () => {

{t('inbox.reportsTitle')}

{t('inbox.reportsHints')}

-
- + + + )} + -
-
+
- + {t('inbox.queue', { count: items.length })} + {items.length > 0 && ( + + )} {listLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - +
+ {Array.from({ length: 8 }).map((_, i) => ( + ))}
) : items.length === 0 ? ( @@ -155,25 +231,40 @@ const ReportInboxPage: React.FC = () => { ) : (
{items.map((item) => ( -
-

- {item.target_type} · {dayjs(item.created_at).format('DD.MM HH:mm')} -

- + +
))}
)} @@ -245,7 +336,7 @@ const ReportInboxPage: React.FC = () => {
{t('common.createdAt')}
-
{dayjs(report.created_at).format('DD.MM.YYYY HH:mm')}
+
{formatDateTime(report.created_at)}
diff --git a/src/pages/inbox/TicketInboxPage.tsx b/src/pages/inbox/TicketInboxPage.tsx index 56fffa1..0f4a391 100644 --- a/src/pages/inbox/TicketInboxPage.tsx +++ b/src/pages/inbox/TicketInboxPage.tsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import dayjs from 'dayjs'; import { useTickets, useTicket, useUpdateTicket } from '@/hooks/useTickets'; import { useAuthStore } from '@/store/authStore'; import { selectAfterRemove, selectNeighborId, useInboxHotkeys } from '@/hooks/useInboxHotkeys'; import { formatStatusLabel } from '@/utils/statusLabels'; +import { formatRelativeAge } from '@/utils/datetime'; import { Ticket } from '@/types/api'; import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; @@ -154,9 +154,9 @@ const TicketInboxPage: React.FC = () => { {listLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - +
+ {Array.from({ length: 8 }).map((_, i) => ( + ))}
) : items.length === 0 ? ( @@ -169,21 +169,19 @@ const TicketInboxPage: React.FC = () => { type="button" onClick={() => selectTicket(item.id)} className={cn( - 'w-full px-3 py-3 text-left transition-colors hover:bg-muted/60', + 'flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/60', selectedId === item.id && 'bg-muted' )} > -
- - {item.error_message || item.id} - - - {formatStatusLabel(item.status)} - -
-

- ×{item.count} · {dayjs(item.last_seen).format('DD.MM HH:mm')} -

+ + {item.error_message || item.id} + + + ×{item.count} · {formatRelativeAge(item.last_seen)} + + + {formatStatusLabel(item.status)} + ))}
@@ -234,7 +232,7 @@ const TicketInboxPage: React.FC = () => {
{t('common.lastSeen')}
-
{dayjs(ticket.last_seen).format('DD.MM.YYYY HH:mm')}
+
{formatRelativeAge(ticket.last_seen)}
{ticket.stacktrace && ( diff --git a/src/pages/reports/ReportListPage.tsx b/src/pages/reports/ReportListPage.tsx index d0732a9..1b3c0d5 100644 --- a/src/pages/reports/ReportListPage.tsx +++ b/src/pages/reports/ReportListPage.tsx @@ -251,7 +251,6 @@ const ReportListPage: React.FC = () => { <> { <> { <> { <> { <>