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) => (
-
selectReport(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-2 py-1.5 transition-colors hover:bg-muted/60',
selectedId === item.id && 'bg-muted'
)}
>
-
- {item.reason}
+ {
+ e.stopPropagation();
+ toggleChecked(item.id, e.target.checked);
+ }}
+ onClick={(e) => e.stopPropagation()}
+ aria-label={item.reason}
+ />
+ selectReport(item.id)}
+ className="flex min-w-0 flex-1 items-center gap-2 text-left"
+ >
+
+ {item.reason}
+
+
+ {item.target_type} · {formatRelativeAge(item.created_at)}
+
{formatStatusLabel(item.status)}
-
-
- {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 = () => {
<>
{
<>
{
<>
{
<>
{
<>