mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-07-09 16:00:05 +00:00
feat: add sortable columns in the Traffic page's Aggregated view and … (#259)
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled
Some checks failed
Deploy documentation / build-and-deploy (push) Has been cancelled
This commit is contained in:
parent
d5d53f9ad6
commit
b41171562a
34 changed files with 1244 additions and 876 deletions
10
changelog.md
10
changelog.md
|
|
@ -1,11 +1,15 @@
|
|||
# B4 - Bye Bye Big Bro
|
||||
|
||||
## [1.70.0] - 2026-06-xx
|
||||
## [1.70.0] - 2026-06-21
|
||||
|
||||
- ADDED: **New engine for devices without NFQUEUE** - some minimal devices lack the kernel modules b4 normally needs, so it could not run on them. A new mode under Settings → Feature Flags routes traffic through a virtual interface (TUN) instead. It inspects only the first packets of each TLS (port 443) and DNS connection, like the normal engine, rather than carrying every packet, and leaves the device's default route in place; on kernels that cannot count per-connection packets it routes the whole default route through the TUN instead.
|
||||
- CHANGED: **Web UI password is now stored securely** - it is kept only as a hash and is no longer shown on the settings page (leave the field blank to keep it, or type a new one to change it). Repeated failed logins are briefly blocked, and a session now expires after a day. Existing setups are migrated automatically.
|
||||
- ADDED: **New engine for devices without NFQUEUE** - some minimal devices lack the kernel modules b4 normally needs, so it could not run on them. A new mode under Settings → Feature Flags routes traffic through a virtual interface (TUN) instead. It inspects only the first packets of each TLS (port 443) and DNS connection, like the normal engine, rather than carrying every packet, and leaves the device's default route in place; on kernels that cannot count per-connection packets it routes the whole default route through the TUN instead. Strategy discovery (auto-tuning) still needs NFQUEUE, so it cannot run on these devices.
|
||||
- ADDED: **TUN uplink can follow the default route automatically** - leave the uplink on Auto (the default) and b4 takes the outbound interface, gateway and source address from the current default route, re-pointing them whenever it changes; pick a specific interface to pin a fixed path instead. Without it the uplink was fixed at startup, so a WAN failover or an L2TP/PPP/VPN tunnel reconnecting after b4 had started left processed traffic going out the old, dead path and bypassing b4 until a manual restart.
|
||||
- ADDED: **Sortable columns in the Traffic page's Aggregated view** - clicking a column header sorts the grouped list by that column, ascending or descending, like the Raw feed.
|
||||
- CHANGED: **Web UI password is now stored securely** - it is kept only as a hash and is no longer shown on the settings page (leave the field blank to keep it, or type a new one to change it).
|
||||
- CHANGED: **Dashboard rebuilt on one consistent design** - it now leads with a live signal panel that puts connection-rate and the key counters together, with every block sharing one panel, row, and header style.
|
||||
- FIXED: **A set's encrypted DNS (DoH) redirect failed in some setups** - lookups could time out or come back empty, so pages would not load. This affected gateway and container setups (for example b4 in a container on MikroTik) using NAT masquerade.
|
||||
- FIXED: **Routing set stopped updating its addresses on its own** - in some setups (for example b4 in a container on MikroTik) the set's addresses only filled in on restart or when the set was toggled, then expired and were never refreshed. The set now learns addresses live from DNS replies again.
|
||||
- FIXED: **Telegram bridge did not recover after an internet outage** - if the connection dropped and came back later, a set using the Telegram bridge kept failing until b4 was restarted by hand.
|
||||
|
||||
## [1.69.1] - 2026-06-14
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
# B4 - Bye Bye Big Bro
|
||||
|
||||
## [1.70.0] - 2026-06-xx
|
||||
## [1.70.0] - 2026-06-21
|
||||
|
||||
- ДОБАВЛЕНО: **Новый движок для устройств без NFQUEUE** - на некоторых минимальных устройствах нет модулей ядра, которые обычно нужны b4, поэтому он на них не запускался. Новый режим в «Настройки → Функции» пропускает трафик через виртуальный интерфейс (TUN). Он обрабатывает только первые пакеты каждого соединения TLS (порт 443) и DNS, как обычный движок, а не весь трафик, и не трогает маршрут по умолчанию устройства; на ядрах, которые не умеют считать пакеты по соединению, он вместо этого направляет в TUN весь маршрут по умолчанию.
|
||||
- ИЗМЕНЕНО: **Пароль веб-интерфейса теперь хранится безопасно** - он хранится только в виде хеша и больше не показывается на странице настроек (оставьте поле пустым, чтобы сохранить его, или введите новый для замены). Повторные неудачные попытки входа ненадолго блокируются, а вход теперь перестаёт действовать через сутки. Существующие конфигурации мигрируют автоматически.
|
||||
- ДОБАВЛЕНО: **Новый движок для устройств без NFQUEUE** - на некоторых минимальных устройствах нет модулей ядра, которые обычно нужны b4, поэтому он на них не запускался. Новый режим в «Настройки → Функции» пропускает трафик через виртуальный интерфейс (TUN). Он обрабатывает только первые пакеты каждого соединения TLS (порт 443) и DNS, как обычный движок, а не весь трафик, и не трогает маршрут по умолчанию устройства; на ядрах, которые не умеют считать пакеты по соединению, он вместо этого направляет в TUN весь маршрут по умолчанию. Подбор стратегий (Дискавери) по-прежнему требует NFQUEUE, поэтому на таких устройствах он не работает.
|
||||
- ДОБАВЛЕНО: **Аплинк в режиме TUN может автоматически следовать маршруту по умолчанию** - оставьте аплинк в режиме «Авто» (по умолчанию), и b4 берёт исходящий интерфейс, шлюз и адрес источника из текущего маршрута по умолчанию и переключает их при каждом изменении; выберите конкретный интерфейс, чтобы закрепить фиксированный путь. Без этого аплинк задавался один раз при запуске, поэтому переключение WAN или переподключение туннеля L2TP/PPP/VPN после старта b4 отправляло обработанный трафик по старому, уже нерабочему пути в обход b4 до ручного перезапуска.
|
||||
- ДОБАВЛЕНО: **Сортируемые столбцы в режиме «Группы» на странице «Трафик»** - клик по заголовку столбца сортирует сгруппированный список по этому столбцу, по возрастанию или убыванию, как уже умел «Сырой поток».
|
||||
- ИЗМЕНЕНО: **Пароль веб-интерфейса теперь хранится безопасно** - он хранится только в виде хеша и больше не показывается на странице настроек (оставьте поле пустым, чтобы сохранить его, или введите новый для замены).
|
||||
- ИЗМЕНЕНО: **Панель мониторинга переделана на единый стиль** - график скорости соединений собран вместе с ключевыми счётчиками, а все блоки используют одинаковый стиль панелей, строк и заголовков.
|
||||
- ИСПРАВЛЕНО: **Перенаправление сета на шифрованный DNS (DoH) не работало в некоторых конфигурациях** - запросы могли завершаться тайм-аутом или возвращаться пустыми, из-за чего страницы не открывались. Это затрагивало шлюзы и контейнеры (например, b4 в контейнере на MikroTik) с включённым NAT masquerade.
|
||||
- ИСПРАВЛЕНО: **Таблица маршрутизации сета переставала обновляться сама** - в некоторых конфигурациях (например, b4 в контейнере на MikroTik) адреса добавлялись только при перезапуске или включении/выключении сета, затем истекали по таймауту и больше не обновлялись. Теперь сет снова узнаёт адреса на лету из DNS-ответов.
|
||||
- ИСПРАВЛЕНО: **Мост Telegram не восстанавливался после обрыва интернета** - если соединение пропадало и возвращалось позже, сет с мостом Telegram не работал, пока b4 не перезапускали вручную.
|
||||
|
||||
## [1.69.1] - 2026-06-14
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ type TUNConfig struct {
|
|||
RouteTable int `json:"route_table"`
|
||||
}
|
||||
|
||||
func (t TUNConfig) FollowsDefaultRoute() bool {
|
||||
return t.OutInterface == "" || t.OutInterface == "auto"
|
||||
}
|
||||
|
||||
type DevicesConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
VendorLookup bool `json:"vendor_lookup"`
|
||||
|
|
|
|||
|
|
@ -257,10 +257,6 @@ func (c *Config) Validate() error {
|
|||
}
|
||||
|
||||
if c.Queue.Mode == "tun" {
|
||||
if c.Queue.TUN.OutInterface == "" {
|
||||
v.add("queue.tun.out_interface", "required", "tun out_interface is required in TUN mode (e.g. eth0, wan0, l2tp-vpn)", nil)
|
||||
return v.result()
|
||||
}
|
||||
const tunReservedMarkBits = uint(engine.TunSteerMark | engine.TunClientMark | engine.ReinjectMarkBit)
|
||||
if m := c.MainInjectedMark(); m&tunReservedMarkBits != 0 {
|
||||
v.addf("queue.mark", "mark_conflict", map[string]any{"mark": fmt.Sprintf("0x%x", m)},
|
||||
|
|
|
|||
|
|
@ -322,13 +322,27 @@ func TestValidate_QueueFields(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("tun mode requires out_interface", func(t *testing.T) {
|
||||
t.Run("tun mode follows default route when out_interface is empty", func(t *testing.T) {
|
||||
cfg := NewConfig()
|
||||
cfg.Queue.Mode = "tun"
|
||||
cfg.Queue.TUN.OutInterface = ""
|
||||
ve := mustValidationErr(t, cfg.Validate())
|
||||
if findField(ve, "queue.tun.out_interface", "required") == nil {
|
||||
t.Errorf("missing queue.tun.out_interface required; got %+v", ve.Fields)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("empty out_interface (follow-default) rejected: %v", err)
|
||||
}
|
||||
if !cfg.Queue.TUN.FollowsDefaultRoute() {
|
||||
t.Errorf("empty out_interface should follow the default route")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tun mode follows default route when out_interface is auto", func(t *testing.T) {
|
||||
cfg := NewConfig()
|
||||
cfg.Queue.Mode = "tun"
|
||||
cfg.Queue.TUN.OutInterface = "auto"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("out_interface=auto (follow-default) rejected: %v", err)
|
||||
}
|
||||
if !cfg.Queue.TUN.FollowsDefaultRoute() {
|
||||
t.Errorf("out_interface=auto should follow the default route")
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -441,4 +455,3 @@ func TestValidate_Idempotent(t *testing.T) {
|
|||
t.Errorf("Validate is not idempotent: marks/mode changed on second call")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Fab, Tooltip } from "@mui/material";
|
||||
import { StartIcon, StopIcon } from "@b4.icons";
|
||||
import { colors } from "@design";
|
||||
import { useConnectionGroups, type EnrichedGroup } from "@hooks/useConnectionGroups";
|
||||
import { matchesConnectionFilter, parseConnectionFilter } from "@utils";
|
||||
import {
|
||||
AGG_SORT_STORAGE_KEY,
|
||||
loadSortState,
|
||||
matchesConnectionFilter,
|
||||
parseConnectionFilter,
|
||||
saveSortState,
|
||||
} from "@utils";
|
||||
import { SortDirection } from "@common/SortableTableCell";
|
||||
import { AggregatedControlBar, TimeWindow } from "./AggregatedControlBar";
|
||||
import { DeviceSidebar } from "./DeviceSidebar";
|
||||
import { GroupList } from "./GroupList";
|
||||
import { AGG_SORT_COLUMNS, AggSortColumn, GroupList } from "./GroupList";
|
||||
import { DetailPane } from "./DetailPane";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
|
@ -65,6 +72,14 @@ const getGroupSearchableValues = (g: EnrichedGroup): (string | null)[] => [
|
|||
g.flags,
|
||||
];
|
||||
|
||||
const loadAggSort = (): { column: AggSortColumn | null; direction: SortDirection } => {
|
||||
const { column, direction } = loadSortState(AGG_SORT_STORAGE_KEY);
|
||||
if (column && direction && (AGG_SORT_COLUMNS as readonly string[]).includes(column)) {
|
||||
return { column: column as AggSortColumn, direction };
|
||||
}
|
||||
return { column: null, direction: null };
|
||||
};
|
||||
|
||||
export const AggregatedView = ({
|
||||
lines,
|
||||
deviceMap,
|
||||
|
|
@ -90,11 +105,39 @@ export const AggregatedView = ({
|
|||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
return localStorage.getItem("b4_connections_sidebar_collapsed") === "1";
|
||||
});
|
||||
const [sortColumn, setSortColumn] = useState<AggSortColumn | null>(
|
||||
() => loadAggSort().column,
|
||||
);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>(
|
||||
() => loadAggSort().direction,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("b4_connections_sidebar_collapsed", sidebarCollapsed ? "1" : "0");
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
saveSortState(sortColumn, sortDirection, AGG_SORT_STORAGE_KEY);
|
||||
}, [sortColumn, sortDirection]);
|
||||
|
||||
const handleSort = useCallback((column: AggSortColumn) => {
|
||||
setSortColumn((prevColumn) => {
|
||||
if (prevColumn === column) {
|
||||
setSortDirection((prevDir) => {
|
||||
if (prevDir === "asc") return "desc";
|
||||
if (prevDir === "desc") {
|
||||
setSortColumn(null);
|
||||
return null;
|
||||
}
|
||||
return "asc";
|
||||
});
|
||||
return prevColumn;
|
||||
}
|
||||
setSortDirection("asc");
|
||||
return column;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const state = useConnectionGroups(lines, deviceMap, paused, ipToMac);
|
||||
|
||||
const dataLatest = useMemo(() => {
|
||||
|
|
@ -141,10 +184,25 @@ export const AggregatedView = ({
|
|||
});
|
||||
}, [state.groups, window, unmatchedOnly, showAll, selectedMac, filter, now]);
|
||||
|
||||
const sortedGroups = useMemo(
|
||||
() => [...filteredGroups].sort((a, b) => b.lastSeen - a.lastSeen || b.packets - a.packets),
|
||||
[filteredGroups],
|
||||
);
|
||||
const sortedGroups = useMemo(() => {
|
||||
const arr = [...filteredGroups];
|
||||
if (!sortColumn || !sortDirection) {
|
||||
return arr.sort((a, b) => b.lastSeen - a.lastSeen || b.packets - a.packets);
|
||||
}
|
||||
const dir = sortDirection === "asc" ? 1 : -1;
|
||||
return arr.sort((a, b) => {
|
||||
let primary: number;
|
||||
if (sortColumn === "packets") {
|
||||
primary = a.packets - b.packets;
|
||||
} else if (sortColumn === "seen") {
|
||||
primary = a.lastSeen - b.lastSeen;
|
||||
} else {
|
||||
const field = sortColumn === "source" ? "device" : sortColumn;
|
||||
primary = getGroupFieldValue(a, field).localeCompare(getGroupFieldValue(b, field));
|
||||
}
|
||||
return primary * dir || b.lastSeen - a.lastSeen;
|
||||
});
|
||||
}, [filteredGroups, sortColumn, sortDirection]);
|
||||
|
||||
const selectedGroup = useMemo(
|
||||
() => (selectedKey ? state.groups.find((g) => g.key === selectedKey) ?? null : null),
|
||||
|
|
@ -187,6 +245,9 @@ export const AggregatedView = ({
|
|||
onAddIp={onAddIp}
|
||||
onEnrichAsn={onEnrichAsn}
|
||||
enrichingIps={enrichingIps}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
{selectedGroup && (
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { Box, Stack, TableSortLabel, Typography } from "@mui/material";
|
||||
import { colors } from "@design";
|
||||
import { SortDirection } from "@common/SortableTableCell";
|
||||
import { GroupRow, ROW_HEIGHT } from "./GroupRow";
|
||||
import type { EnrichedGroup } from "@hooks/useConnectionGroups";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const AGG_SORT_COLUMNS = [
|
||||
"protocol",
|
||||
"domain",
|
||||
"destination",
|
||||
"set",
|
||||
"source",
|
||||
"packets",
|
||||
"seen",
|
||||
] as const;
|
||||
|
||||
export type AggSortColumn = (typeof AGG_SORT_COLUMNS)[number];
|
||||
|
||||
interface Props {
|
||||
groups: EnrichedGroup[];
|
||||
now: number;
|
||||
|
|
@ -14,11 +27,60 @@ interface Props {
|
|||
onAddIp: (ip: string) => void;
|
||||
onEnrichAsn: (ip: string) => void;
|
||||
enrichingIps: Set<string>;
|
||||
sortColumn: AggSortColumn | null;
|
||||
sortDirection: SortDirection;
|
||||
onSort: (column: AggSortColumn) => void;
|
||||
}
|
||||
|
||||
const OVERSCAN = 6;
|
||||
const HEADER_HEIGHT = 32;
|
||||
|
||||
const SortHeader = ({
|
||||
column,
|
||||
label,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
align = "left",
|
||||
}: {
|
||||
column: AggSortColumn;
|
||||
label: string;
|
||||
sortColumn: AggSortColumn | null;
|
||||
sortDirection: SortDirection;
|
||||
onSort: (column: AggSortColumn) => void;
|
||||
align?: "left" | "right";
|
||||
}) => {
|
||||
const active = sortColumn === column;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: align === "right" ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={active}
|
||||
direction={active && sortDirection ? sortDirection : "asc"}
|
||||
onClick={() => onSort(column)}
|
||||
sx={{
|
||||
userSelect: "none",
|
||||
color: `${colors.secondary} !important`,
|
||||
"&:hover": { color: `${colors.primary} !important` },
|
||||
"&.Mui-active": {
|
||||
color: `${colors.primary} !important`,
|
||||
"& .MuiTableSortLabel-icon": { color: `${colors.primary} !important` },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: "inherit", fontWeight: 600, fontSize: 14 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</TableSortLabel>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const GroupList = ({
|
||||
groups,
|
||||
now,
|
||||
|
|
@ -28,6 +90,9 @@ export const GroupList = ({
|
|||
onAddIp,
|
||||
onEnrichAsn,
|
||||
enrichingIps,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -73,44 +138,74 @@ export const GroupList = ({
|
|||
}}
|
||||
>
|
||||
<Box sx={{ width: 170, flexShrink: 0 }}>
|
||||
<Typography sx={{ color: colors.secondary, fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.table.protocol")}
|
||||
</Typography>
|
||||
<SortHeader
|
||||
column="protocol"
|
||||
label={t("connections.table.protocol")}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 2, minWidth: 0 }}>
|
||||
<Typography sx={{ color: colors.secondary, fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.table.domain")}
|
||||
</Typography>
|
||||
<SortHeader
|
||||
column="domain"
|
||||
label={t("connections.table.domain")}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1.5, minWidth: 0 }}>
|
||||
<Typography sx={{ color: colors.secondary, fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.table.destination")}
|
||||
</Typography>
|
||||
<SortHeader
|
||||
column="destination"
|
||||
label={t("connections.table.destination")}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ width: 130, flexShrink: 0 }}>
|
||||
<Typography sx={{ color: colors.secondary, fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.table.set")}
|
||||
</Typography>
|
||||
<SortHeader
|
||||
column="set"
|
||||
label={t("connections.table.set")}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ width: 100, flexShrink: 0 }}>
|
||||
<Typography sx={{ color: colors.secondary, fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.table.source")}
|
||||
</Typography>
|
||||
<SortHeader
|
||||
column="source"
|
||||
label={t("connections.table.source")}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ width: 120, flexShrink: 0 }}>
|
||||
<Typography sx={{ color: colors.secondary, fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.aggregated.activity")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ width: 60, flexShrink: 0, textAlign: "right" }}>
|
||||
<Typography sx={{ color: colors.secondary, fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.aggregated.packets")}
|
||||
</Typography>
|
||||
<Box sx={{ width: 60, flexShrink: 0 }}>
|
||||
<SortHeader
|
||||
column="packets"
|
||||
label={t("connections.aggregated.packets")}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
align="right"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ width: 48, flexShrink: 0, textAlign: "right" }}>
|
||||
<Typography sx={{ color: colors.secondary, fontWeight: 600, fontSize: 14 }}>
|
||||
{t("connections.aggregated.seen")}
|
||||
</Typography>
|
||||
<Box sx={{ width: 48, flexShrink: 0 }}>
|
||||
<SortHeader
|
||||
column="seen"
|
||||
label={t("connections.aggregated.seen")}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
align="right"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { colors, radiusPx } from "@design";
|
||||
import { colors } from "@design";
|
||||
import { B4SetConfig } from "@models/config";
|
||||
import {
|
||||
Circle as CircleIcon,
|
||||
FolderOpen as FolderIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { Box, Stack } from "@mui/material";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DashboardPanel } from "./DashboardPanel";
|
||||
|
||||
interface ActiveSetsProps {
|
||||
sets: B4SetConfig[];
|
||||
|
|
@ -19,31 +20,8 @@ export const ActiveSets = ({ sets }: ActiveSetsProps) => {
|
|||
if (sets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
p: "14px",
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
bgcolor: colors.background.paper,
|
||||
border: `1px solid ${colors.border.default}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{ display: "block", color: colors.text.secondary, opacity: 0.8 }}
|
||||
>
|
||||
{t("dashboard.activeSets.title")}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
flexWrap="wrap"
|
||||
useFlexGap
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<DashboardPanel eyebrow={t("dashboard.activeSets.title")} padded>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{sets.map((set) => {
|
||||
const domainCount =
|
||||
(set.targets.sni_domains?.length || 0) +
|
||||
|
|
@ -114,6 +92,6 @@ export const ActiveSets = ({ sets }: ActiveSetsProps) => {
|
|||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</DashboardPanel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
77
src/http/ui/src/components/dashboard/AddToSetButton.tsx
Normal file
77
src/http/ui/src/components/dashboard/AddToSetButton.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useState } from "react";
|
||||
import { IconButton, Tooltip, Menu, MenuItem } from "@mui/material";
|
||||
import { AddCircleOutline as AddIcon } from "@mui/icons-material";
|
||||
import { colors } from "@design";
|
||||
import { B4SetConfig } from "@models/config";
|
||||
import { setsApi } from "@b4.sets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface AddToSetButtonProps {
|
||||
domain: string;
|
||||
sets: B4SetConfig[];
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
export const AddToSetButton = ({ domain, sets, onAdded }: AddToSetButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const enabledSets = sets.filter((s) => s.enabled);
|
||||
|
||||
if (enabledSets.length === 0) return null;
|
||||
|
||||
const handleAdd = async (setId: string) => {
|
||||
setAnchorEl(null);
|
||||
setAdding(true);
|
||||
try {
|
||||
await setsApi.addDomainToSet(setId, domain);
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
console.error("Failed to add domain:", e);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t("core.addToSet")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
disabled={adding}
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
p: 0.25,
|
||||
"&:hover": { color: colors.secondary },
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
bgcolor: colors.background.default,
|
||||
border: `1px solid ${colors.border.default}`,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{enabledSets.map((set) => (
|
||||
<MenuItem
|
||||
key={set.id}
|
||||
onClick={() => void handleAdd(set.id)}
|
||||
sx={{ color: colors.text.primary, fontSize: "0.8rem" }}
|
||||
>
|
||||
{set.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,18 +1,14 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Box, Paper, Typography, Grid } from "@mui/material";
|
||||
import { useMemo } from "react";
|
||||
import { Box, Typography, Grid } from "@mui/material";
|
||||
import { BlockOutlined as BlockIcon } from "@mui/icons-material";
|
||||
import { colors, fonts, radiusPx } from "@design";
|
||||
import { colors, fonts } from "@design";
|
||||
import { formatNumber } from "@utils";
|
||||
import { B4CountPill } from "@b4.elements";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DeviceInfo {
|
||||
mac: string;
|
||||
ip: string;
|
||||
hostname: string;
|
||||
vendor: string;
|
||||
alias?: string;
|
||||
}
|
||||
import { useDeviceNames } from "@hooks/useDeviceNames";
|
||||
import { DashboardPanel } from "./DashboardPanel";
|
||||
import { DataRow } from "./DataRow";
|
||||
import { DomainLabel } from "./DomainLabel";
|
||||
|
||||
interface BlackholeProps {
|
||||
total: number;
|
||||
|
|
@ -26,30 +22,7 @@ export const Blackhole = ({
|
|||
blockedDevices,
|
||||
}: BlackholeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/devices")
|
||||
.then((r) => r.json())
|
||||
.then((data: { devices?: DeviceInfo[] }) => {
|
||||
if (data?.devices) setDevices(data.devices);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const deviceMap = useMemo(() => {
|
||||
const map: Record<string, DeviceInfo> = {};
|
||||
for (const d of devices) map[d.mac] = d;
|
||||
return map;
|
||||
}, [devices]);
|
||||
|
||||
const getDeviceName = (mac: string): string => {
|
||||
const dev = deviceMap[mac];
|
||||
if (dev?.alias) return dev.alias;
|
||||
if (dev?.hostname) return dev.hostname;
|
||||
if (dev?.vendor && dev.vendor !== "Private") return `${dev.vendor} (${mac})`;
|
||||
return mac;
|
||||
};
|
||||
const { getDeviceName } = useDeviceNames();
|
||||
|
||||
const topDomains = useMemo(
|
||||
() =>
|
||||
|
|
@ -68,35 +41,11 @@ export const Blackhole = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: colors.background.paper,
|
||||
borderColor: colors.border.default,
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
p: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
p: "12px 14px",
|
||||
borderBottom: `1px solid ${colors.border.light}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<BlockIcon sx={{ fontSize: 18, color: colors.state.error }} />
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{ color: colors.text.secondary, opacity: 0.85 }}
|
||||
>
|
||||
{t("dashboard.blackhole.title")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<DashboardPanel
|
||||
icon={<BlockIcon sx={{ fontSize: 18, color: colors.state.error }} />}
|
||||
eyebrow={t("dashboard.blackhole.title")}
|
||||
divider
|
||||
right={
|
||||
<Box sx={{ display: "flex", alignItems: "baseline", gap: "8px" }}>
|
||||
<Box
|
||||
component="span"
|
||||
|
|
@ -121,8 +70,8 @@ export const Blackhole = ({
|
|||
{t("dashboard.blackhole.blocked")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
}
|
||||
>
|
||||
<Grid container>
|
||||
<Grid
|
||||
size={{ xs: 12, sm: 6 }}
|
||||
|
|
@ -150,7 +99,7 @@ export const Blackhole = ({
|
|||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</DashboardPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -174,7 +123,7 @@ const BlockSection = ({ label, rows, empty }: BlockSectionProps) => (
|
|||
display: "block",
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.7,
|
||||
p: "10px 14px 4px",
|
||||
p: "12px 14px 4px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
|
|
@ -192,36 +141,9 @@ const BlockSection = ({ label, rows, empty }: BlockSectionProps) => (
|
|||
</Box>
|
||||
) : (
|
||||
rows.map(({ id, name, count }) => (
|
||||
<Box
|
||||
key={id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
p: "8px 14px",
|
||||
borderTop: `1px solid ${colors.border.light}`,
|
||||
"&:hover": { bgcolor: "rgba(255, 255, 255, 0.025)" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.04em",
|
||||
color: colors.text.primary,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</Box>
|
||||
<B4CountPill value={formatNumber(count)} />
|
||||
</Box>
|
||||
<DataRow key={id} right={<B4CountPill value={formatNumber(count)} />}>
|
||||
<DomainLabel value={name} uppercase={false} />
|
||||
</DataRow>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
74
src/http/ui/src/components/dashboard/DashboardPanel.tsx
Normal file
74
src/http/ui/src/components/dashboard/DashboardPanel.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { Box, Paper, Typography } from "@mui/material";
|
||||
import { colors, radiusPx } from "@design";
|
||||
|
||||
interface DashboardPanelProps {
|
||||
eyebrow?: string;
|
||||
icon?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
padded?: boolean;
|
||||
divider?: boolean;
|
||||
fill?: boolean;
|
||||
children: React.ReactNode;
|
||||
sx?: object;
|
||||
}
|
||||
|
||||
export const DashboardPanel = ({
|
||||
eyebrow,
|
||||
icon,
|
||||
right,
|
||||
padded = false,
|
||||
divider = false,
|
||||
fill = false,
|
||||
children,
|
||||
sx,
|
||||
}: DashboardPanelProps) => (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: colors.background.paper,
|
||||
borderColor: colors.border.default,
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: fill ? "100%" : undefined,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{(eyebrow || right || icon) && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
p: "12px 14px 6px",
|
||||
borderBottom: divider ? `1px solid ${colors.border.light}` : undefined,
|
||||
...(divider ? { pb: "12px" } : null),
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: "8px", minWidth: 0 }}>
|
||||
{icon}
|
||||
{eyebrow && (
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{ color: colors.text.secondary, opacity: 0.8 }}
|
||||
>
|
||||
{eyebrow}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{right}
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
flex: fill ? 1 : undefined,
|
||||
minHeight: 0,
|
||||
...(padded ? { p: "0 14px 14px" } : null),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
51
src/http/ui/src/components/dashboard/DataRow.tsx
Normal file
51
src/http/ui/src/components/dashboard/DataRow.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { colors } from "@design";
|
||||
|
||||
interface DataRowProps {
|
||||
leading?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
indent?: boolean;
|
||||
hover?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DataRow = ({
|
||||
leading,
|
||||
right,
|
||||
onClick,
|
||||
indent = false,
|
||||
hover = true,
|
||||
children,
|
||||
}: DataRowProps) => (
|
||||
<Box
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
p: indent ? "8px 14px 8px 28px" : "8px 14px",
|
||||
borderBottom: `1px solid ${colors.border.light}`,
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
cursor: onClick ? "pointer" : undefined,
|
||||
transition: "background-color 120ms ease",
|
||||
...(hover ? { "&:hover": { bgcolor: colors.background.hover } } : null),
|
||||
}}
|
||||
>
|
||||
{leading}
|
||||
{children}
|
||||
{right}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -1,33 +1,20 @@
|
|||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
import { useState, useMemo } from "react";
|
||||
import { Box, Collapse, Tooltip } from "@mui/material";
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
AddCircleOutline as AddIcon,
|
||||
Check as CheckIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { colors, fonts, radiusPx } from "@design";
|
||||
import { colors, fonts } from "@design";
|
||||
import { formatNumber } from "@utils";
|
||||
import { B4SetConfig } from "@models/config";
|
||||
import { setsApi } from "@b4.sets";
|
||||
import { B4ConfidencePill, B4CountPill } from "@b4.elements";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DeviceInfo {
|
||||
mac: string;
|
||||
ip: string;
|
||||
hostname: string;
|
||||
vendor: string;
|
||||
alias?: string;
|
||||
}
|
||||
import { useDeviceNames } from "@hooks/useDeviceNames";
|
||||
import { useDomainTargeting } from "@hooks/useDomainTargeting";
|
||||
import { DashboardPanel } from "./DashboardPanel";
|
||||
import { DataRow } from "./DataRow";
|
||||
import { DomainLabel } from "./DomainLabel";
|
||||
import { AddToSetButton } from "./AddToSetButton";
|
||||
|
||||
interface DeviceActivityProps {
|
||||
deviceDomains: Record<string, Record<string, number>>;
|
||||
|
|
@ -47,35 +34,10 @@ export const DeviceActivity = ({
|
|||
onRefreshSets,
|
||||
}: DeviceActivityProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
const { getDeviceName, getDeviceMeta } = useDeviceNames();
|
||||
const isDomainTargeted = useDomainTargeting(targetedDomains);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/devices")
|
||||
.then((r) => r.json())
|
||||
.then((data: { devices?: DeviceInfo[] }) => {
|
||||
if (data?.devices) setDevices(data.devices);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const isDomainTargeted = (domain: string): boolean => {
|
||||
if (targetedDomains.has(domain)) return true;
|
||||
const parts = domain.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (targetedDomains.has(parts.slice(i).join("."))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const deviceMap = useMemo(() => {
|
||||
const map: Record<string, DeviceInfo> = {};
|
||||
for (const d of devices) {
|
||||
map[d.mac] = d;
|
||||
}
|
||||
return map;
|
||||
}, [devices]);
|
||||
|
||||
const sortedDevices = useMemo(() => {
|
||||
return Object.entries(deviceDomains)
|
||||
.map(([mac, domains]) => ({
|
||||
|
|
@ -96,49 +58,10 @@ export const DeviceActivity = ({
|
|||
});
|
||||
};
|
||||
|
||||
const getDeviceName = (mac: string): string => {
|
||||
const dev = deviceMap[mac];
|
||||
if (dev?.alias) return dev.alias;
|
||||
if (dev?.hostname) return dev.hostname;
|
||||
if (dev?.vendor && dev.vendor !== "Private")
|
||||
return `${dev.vendor} (${mac})`;
|
||||
return mac;
|
||||
};
|
||||
|
||||
const getDeviceMeta = (mac: string): string => {
|
||||
const dev = deviceMap[mac];
|
||||
if (!dev) return "";
|
||||
const parts: string[] = [];
|
||||
if (dev.ip) parts.push(dev.ip);
|
||||
if (dev.vendor && dev.vendor !== "Private") parts.push(dev.vendor);
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
if (sortedDevices.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: colors.background.paper,
|
||||
borderColor: colors.border.default,
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
p: 0,
|
||||
overflow: "hidden",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{
|
||||
display: "block",
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.8,
|
||||
p: "12px 14px 6px",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.deviceActivity.title")}
|
||||
</Typography>
|
||||
<DashboardPanel eyebrow={t("dashboard.deviceActivity.title")} fill>
|
||||
{sortedDevices.map(({ mac, domains, total, domainCount }) => {
|
||||
const isExpanded = expanded.has(mac);
|
||||
const sortedDomains = Object.entries(domains).sort(
|
||||
|
|
@ -166,7 +89,7 @@ export const DeviceActivity = ({
|
|||
borderBottom: `1px solid ${colors.border.light}`,
|
||||
cursor: "pointer",
|
||||
transition: "background-color 120ms ease",
|
||||
"&:hover": { bgcolor: "rgba(255, 255, 255, 0.025)" },
|
||||
"&:hover": { bgcolor: colors.background.hover },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
|
@ -239,140 +162,50 @@ export const DeviceActivity = ({
|
|||
</Box>
|
||||
<Collapse in={isExpanded} unmountOnExit>
|
||||
<Box sx={{ bgcolor: colors.background.default }}>
|
||||
{sortedDomains.map(([domain, count]) => (
|
||||
<DeviceDomainRow
|
||||
key={domain}
|
||||
domain={domain}
|
||||
count={count}
|
||||
tls={domainTLS[domain]}
|
||||
isTargeted={isDomainTargeted(domain)}
|
||||
sets={sets}
|
||||
onAdded={onRefreshSets}
|
||||
/>
|
||||
))}
|
||||
{sortedDomains.map(([domain, count]) => {
|
||||
const targeted = isDomainTargeted(domain);
|
||||
return (
|
||||
<DataRow
|
||||
key={domain}
|
||||
indent
|
||||
leading={
|
||||
domainTLS[domain] ? (
|
||||
<B4ConfidencePill score={domainTLS[domain]} />
|
||||
) : undefined
|
||||
}
|
||||
right={
|
||||
<>
|
||||
<B4CountPill value={formatNumber(count)} />
|
||||
{targeted ? (
|
||||
<Tooltip
|
||||
title={t("dashboard.deviceActivity.alreadyInSet")}
|
||||
>
|
||||
<CheckIcon
|
||||
sx={{
|
||||
color: colors.state.success,
|
||||
fontSize: 16,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<AddToSetButton
|
||||
domain={domain}
|
||||
sets={sets}
|
||||
onAdded={onRefreshSets}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DomainLabel value={domain} />
|
||||
</DataRow>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
interface DeviceDomainRowProps {
|
||||
domain: string;
|
||||
count: number;
|
||||
tls?: string;
|
||||
isTargeted: boolean;
|
||||
sets: B4SetConfig[];
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
const DeviceDomainRow = ({
|
||||
domain,
|
||||
count,
|
||||
tls,
|
||||
isTargeted,
|
||||
sets,
|
||||
onAdded,
|
||||
}: DeviceDomainRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const enabledSets = sets.filter((s) => s.enabled);
|
||||
|
||||
const handleAdd = async (setId: string) => {
|
||||
setAnchorEl(null);
|
||||
setAdding(true);
|
||||
try {
|
||||
await setsApi.addDomainToSet(setId, domain);
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
console.error("Failed to add domain:", e);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
p: "8px 14px 8px 28px",
|
||||
borderBottom: `1px solid ${colors.border.light}`,
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
transition: "background-color 120ms ease",
|
||||
"&:hover": { bgcolor: "rgba(255, 255, 255, 0.025)" },
|
||||
}}
|
||||
>
|
||||
{tls && <B4ConfidencePill score={tls} />}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.04em",
|
||||
color: colors.text.primary,
|
||||
textTransform: "uppercase",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
title={domain}
|
||||
>
|
||||
{domain}
|
||||
</Box>
|
||||
<B4CountPill value={formatNumber(count)} />
|
||||
{isTargeted && (
|
||||
<Tooltip title={t("dashboard.deviceActivity.alreadyInSet")}>
|
||||
<CheckIcon sx={{ color: colors.state.success, fontSize: 16 }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isTargeted && enabledSets.length > 0 && (
|
||||
<>
|
||||
<Tooltip title={t("core.addToSet")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
disabled={adding}
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
p: 0.25,
|
||||
"&:hover": { color: colors.secondary },
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
bgcolor: colors.background.default,
|
||||
border: `1px solid ${colors.border.default}`,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{enabledSets.map((set) => (
|
||||
<MenuItem
|
||||
key={set.id}
|
||||
onClick={() => void handleAdd(set.id)}
|
||||
sx={{ color: colors.text.primary, fontSize: "0.8rem" }}
|
||||
>
|
||||
{set.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DashboardPanel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
28
src/http/ui/src/components/dashboard/DomainLabel.tsx
Normal file
28
src/http/ui/src/components/dashboard/DomainLabel.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { colors, fonts } from "@design";
|
||||
|
||||
interface DomainLabelProps {
|
||||
value: string;
|
||||
uppercase?: boolean;
|
||||
}
|
||||
|
||||
export const DomainLabel = ({ value, uppercase = true }: DomainLabelProps) => (
|
||||
<Box
|
||||
component="span"
|
||||
title={value}
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.04em",
|
||||
color: colors.text.primary,
|
||||
textTransform: uppercase ? "uppercase" : "none",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { Box, Paper, Typography } from "@mui/material";
|
||||
import { colors, fonts, radiusPx } from "@design";
|
||||
import { Typography } from "@mui/material";
|
||||
import { colors, fonts } from "@design";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EscalationEntry } from "./Page";
|
||||
import { DashboardPanel } from "./DashboardPanel";
|
||||
import { DataRow } from "./DataRow";
|
||||
import { DomainLabel } from "./DomainLabel";
|
||||
|
||||
interface EscalationsProps {
|
||||
escalations: EscalationEntry[];
|
||||
|
|
@ -25,107 +28,67 @@ export const Escalations = ({ escalations, total }: EscalationsProps) => {
|
|||
if (escalations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
bgcolor: colors.background.paper,
|
||||
borderColor: colors.border.default,
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
p: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
p: "12px 14px 6px",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{ color: colors.text.secondary, opacity: 0.8 }}
|
||||
>
|
||||
{t("dashboard.escalations.title")}
|
||||
</Typography>
|
||||
<DashboardPanel
|
||||
eyebrow={t("dashboard.escalations.title")}
|
||||
right={
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: colors.text.secondary, opacity: 0.7 }}
|
||||
>
|
||||
{t("dashboard.escalations.totalCount", { count: total })}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{escalations.map((e) => (
|
||||
<Box
|
||||
<DataRow
|
||||
key={e.host}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
p: "8px 14px",
|
||||
borderBottom: `1px solid ${colors.border.light}`,
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
hover={false}
|
||||
right={
|
||||
<>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
fontFamily: fonts.mono,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={t("dashboard.escalations.viaSet")}
|
||||
>
|
||||
→ {e.to_set}
|
||||
</Typography>
|
||||
{e.hops > 1 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.6,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={t("dashboard.escalations.hops")}
|
||||
>
|
||||
×{e.hops}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.7,
|
||||
fontFamily: fonts.mono,
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 50,
|
||||
textAlign: "right",
|
||||
}}
|
||||
title={t("dashboard.escalations.expiresIn")}
|
||||
>
|
||||
{formatTimeLeft(e.expires_at)}
|
||||
</Typography>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.04em",
|
||||
color: colors.text.primary,
|
||||
textTransform: "uppercase",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
title={e.host}
|
||||
>
|
||||
{e.host}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
fontFamily: fonts.mono,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={t("dashboard.escalations.viaSet")}
|
||||
>
|
||||
→ {e.to_set}
|
||||
</Typography>
|
||||
{e.hops > 1 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.6,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={t("dashboard.escalations.hops")}
|
||||
>
|
||||
×{e.hops}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.7,
|
||||
fontFamily: fonts.mono,
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 50,
|
||||
textAlign: "right",
|
||||
}}
|
||||
title={t("dashboard.escalations.expiresIn")}
|
||||
>
|
||||
{formatTimeLeft(e.expires_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<DomainLabel value={e.host} />
|
||||
</DataRow>
|
||||
))}
|
||||
</Paper>
|
||||
</DashboardPanel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
142
src/http/ui/src/components/dashboard/LiveSignal.tsx
Normal file
142
src/http/ui/src/components/dashboard/LiveSignal.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { Box, Paper, Typography } from "@mui/material";
|
||||
import { colors, fonts, radiusPx } from "@design";
|
||||
import { SimpleLineChart } from "./SimpleLineChart";
|
||||
import { MetricsCards } from "./MetricsCards";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Metrics } from "./Page";
|
||||
|
||||
interface LiveSignalProps {
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
const CHART_HEIGHT = 140;
|
||||
|
||||
export const LiveSignal = ({ metrics }: LiveSignalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hasData = metrics.connection_rate.length > 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: colors.background.paper,
|
||||
borderColor: colors.border.default,
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: { xs: "column", lg: "row" },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
p: "12px 14px 14px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "12px",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{ color: colors.text.secondary, opacity: 0.8 }}
|
||||
>
|
||||
{t("dashboard.signal.title")}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
color: colors.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ width: 8, height: 2, bgcolor: colors.secondary }}
|
||||
/>
|
||||
{t("dashboard.connectionRateLegend")}
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ color: colors.text.primary, fontWeight: 700 }}
|
||||
>
|
||||
{metrics.current_cps.toFixed(1)} {t("dashboard.signal.cps")}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{hasData ? (
|
||||
<SimpleLineChart
|
||||
data={metrics.connection_rate}
|
||||
color={colors.secondary}
|
||||
height={CHART_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: CHART_HEIGHT,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "6px",
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: "33%",
|
||||
height: "1px",
|
||||
bgcolor: colors.border.light,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 12,
|
||||
color: colors.text.secondary,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.signal.idle")}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{ fontSize: 11, color: colors.text.disabled }}
|
||||
>
|
||||
{t("dashboard.signal.idleHint")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: "100%", lg: 300 },
|
||||
flexShrink: 0,
|
||||
borderTop: { xs: `1px solid ${colors.border.light}`, lg: "none" },
|
||||
borderLeft: { lg: `1px solid ${colors.border.light}` },
|
||||
}}
|
||||
>
|
||||
<MetricsCards metrics={metrics} />
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { Grid } from "@mui/material";
|
||||
import { Box } from "@mui/material";
|
||||
import { StatCard } from "./StatCard";
|
||||
import { colors } from "@design";
|
||||
import { formatNumber } from "@utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Metrics } from "./Page";
|
||||
|
|
@ -18,33 +19,35 @@ export const MetricsCards = ({ metrics }: MetricsCardsProps) => {
|
|||
const isIdle = metrics.rst_dropped === 0;
|
||||
|
||||
return (
|
||||
<Grid container spacing={1.5} sx={{ height: "100%" }} alignItems="stretch">
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ display: "flex" }}>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.targeted")}
|
||||
value={formatNumber(metrics.targeted_connections)}
|
||||
sub={`${targetRate}% ${t("dashboard.metrics.ofTotal")}`}
|
||||
tone="secondary"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ display: "flex" }}>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.rstDropped")}
|
||||
value={formatNumber(metrics.rst_dropped)}
|
||||
sub={isIdle ? t("dashboard.metrics.idle") : undefined}
|
||||
tone={isIdle ? "muted" : "primary"}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 4 }} sx={{ display: "flex" }}>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.packets")}
|
||||
value={formatNumber(metrics.packets_processed)}
|
||||
sub={`${metrics.current_pps.toFixed(1)} ${t("dashboard.metrics.pktPerSec")}`}
|
||||
tone="primary"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: { xs: "row", lg: "column" },
|
||||
height: "100%",
|
||||
"& > *:not(:last-of-type)": {
|
||||
borderBottom: { lg: `1px solid ${colors.border.light}` },
|
||||
borderRight: { xs: `1px solid ${colors.border.light}`, lg: "none" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.targeted")}
|
||||
value={formatNumber(metrics.targeted_connections)}
|
||||
sub={`${targetRate}% ${t("dashboard.metrics.ofTotal")}`}
|
||||
tone="secondary"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.rstDropped")}
|
||||
value={formatNumber(metrics.rst_dropped)}
|
||||
sub={isIdle ? t("dashboard.metrics.idle") : undefined}
|
||||
tone={isIdle ? "muted" : "primary"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("dashboard.metrics.packets")}
|
||||
value={formatNumber(metrics.packets_processed)}
|
||||
sub={`${metrics.current_pps.toFixed(1)} ${t("dashboard.metrics.pktPerSec")}`}
|
||||
tone="primary"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,17 +5,14 @@ import {
|
|||
Grid,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
} from "@mui/material";
|
||||
import { HealthBanner } from "./HealthBanner";
|
||||
import { MetricsCards } from "./MetricsCards";
|
||||
import { LiveSignal } from "./LiveSignal";
|
||||
import { ActiveSets } from "./ActiveSets";
|
||||
import { DeviceActivity } from "./DeviceActivity";
|
||||
import { Escalations } from "./Escalations";
|
||||
import { Blackhole } from "./Blackhole";
|
||||
import { UnmatchedDomains } from "./UnmatchedDomains";
|
||||
import { SimpleLineChart } from "./SimpleLineChart";
|
||||
import { colors, fonts } from "@design";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDashboardSets } from "@hooks/useDashboardSets";
|
||||
import { wsUrl } from "@utils";
|
||||
|
|
@ -366,63 +363,52 @@ export function DashboardPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const hasDevices = Object.keys(metrics.device_domains).length > 0;
|
||||
|
||||
return (
|
||||
<Container maxWidth={false} sx={{ p: 2 }}>
|
||||
<HealthBanner metrics={metrics} connected={connected} />
|
||||
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<LiveSignal metrics={metrics} />
|
||||
</Box>
|
||||
|
||||
{sets.length > 0 && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<ActiveSets sets={sets} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Grid container spacing={1.5} sx={{ mb: 1.5 }} alignItems="stretch">
|
||||
<Grid
|
||||
size={{ xs: 12, lg: sets.length > 0 ? 6 : 12 }}
|
||||
sx={{ display: "flex" }}
|
||||
>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<MetricsCards metrics={metrics} />
|
||||
</Box>
|
||||
</Grid>
|
||||
{sets.length > 0 && (
|
||||
<Grid size={{ xs: 12, lg: 6 }} sx={{ display: "flex" }}>
|
||||
{hasDevices && (
|
||||
<Grid size={{ xs: 12, xl: 6 }} sx={{ display: "flex" }}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<ActiveSets sets={sets} />
|
||||
<DeviceActivity
|
||||
deviceDomains={metrics.device_domains}
|
||||
domainTLS={metrics.domain_tls}
|
||||
sets={sets}
|
||||
targetedDomains={targetedDomains}
|
||||
onRefreshSets={refreshSets}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid
|
||||
size={{ xs: 12, xl: hasDevices ? 6 : 12 }}
|
||||
sx={{ display: "flex" }}
|
||||
>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<UnmatchedDomains
|
||||
topDomains={metrics.top_domains}
|
||||
domainTLS={metrics.domain_tls}
|
||||
sets={sets}
|
||||
targetedDomains={targetedDomains}
|
||||
onRefreshSets={refreshSets}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{(() => {
|
||||
const hasDevices = Object.keys(metrics.device_domains).length > 0;
|
||||
return (
|
||||
<Grid container spacing={1.5} sx={{ mb: 1.5 }} alignItems="stretch">
|
||||
{hasDevices && (
|
||||
<Grid size={{ xs: 12, xl: 6 }} sx={{ display: "flex" }}>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<DeviceActivity
|
||||
deviceDomains={metrics.device_domains}
|
||||
domainTLS={metrics.domain_tls}
|
||||
sets={sets}
|
||||
targetedDomains={targetedDomains}
|
||||
onRefreshSets={refreshSets}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid
|
||||
size={{ xs: 12, xl: hasDevices ? 6 : 12 }}
|
||||
sx={{ display: "flex" }}
|
||||
>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<UnmatchedDomains
|
||||
topDomains={metrics.top_domains}
|
||||
domainTLS={metrics.domain_tls}
|
||||
sets={sets}
|
||||
targetedDomains={targetedDomains}
|
||||
onRefreshSets={refreshSets}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
})()}
|
||||
|
||||
{metrics.escalations.length > 0 && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Escalations
|
||||
|
|
@ -441,59 +427,6 @@ export function DashboardPage() {
|
|||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metrics.connection_rate.length > 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: "14px",
|
||||
mt: 1.5,
|
||||
bgcolor: colors.background.paper,
|
||||
borderColor: colors.border.default,
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{
|
||||
display: "block",
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{t("dashboard.connectionRate")}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
color: colors.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ width: 8, height: 2, bgcolor: colors.secondary }}
|
||||
/>
|
||||
<Box component="span">{t("dashboard.connectionRateLegend")}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<SimpleLineChart
|
||||
data={metrics.connection_rate}
|
||||
color={colors.secondary}
|
||||
height={120}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, Typography } from "@mui/material";
|
||||
import { colors, fonts, radiusPx } from "@design";
|
||||
import { colors, fonts } from "@design";
|
||||
|
||||
type Tone = "primary" | "secondary" | "muted";
|
||||
|
||||
|
|
@ -38,37 +38,26 @@ export const StatCard = ({
|
|||
return (
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: colors.background.paper,
|
||||
border: `1px solid ${colors.border.default}`,
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
p: "16px 18px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
gap: "10px",
|
||||
justifyContent: "center",
|
||||
gap: "8px",
|
||||
p: "14px 18px",
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="div"
|
||||
sx={{
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.8,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
variant="metricLabel"
|
||||
sx={{ color: colors.text.secondary, opacity: 0.8 }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
component="div"
|
||||
sx={{
|
||||
fontSize: 30,
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 1,
|
||||
|
|
@ -81,7 +70,7 @@ export const StatCard = ({
|
|||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: colors.text.secondary,
|
||||
ml: "2px",
|
||||
|
|
@ -114,7 +103,7 @@ export const StatCard = ({
|
|||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{sub ?? " "}
|
||||
{sub ?? " "}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Menu,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
import { AddCircleOutline as AddIcon } from "@mui/icons-material";
|
||||
import { colors, fonts, radiusPx } from "@design";
|
||||
import { useMemo } from "react";
|
||||
import { formatNumber } from "@utils";
|
||||
import { B4SetConfig } from "@models/config";
|
||||
import { setsApi } from "@b4.sets";
|
||||
import { B4ConfidencePill, B4CountPill } from "@b4.elements";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDomainTargeting } from "@hooks/useDomainTargeting";
|
||||
import { DashboardPanel } from "./DashboardPanel";
|
||||
import { DataRow } from "./DataRow";
|
||||
import { DomainLabel } from "./DomainLabel";
|
||||
import { AddToSetButton } from "./AddToSetButton";
|
||||
|
||||
interface UnmatchedDomainsProps {
|
||||
topDomains: Record<string, number>;
|
||||
|
|
@ -32,168 +25,33 @@ export const UnmatchedDomains = ({
|
|||
onRefreshSets,
|
||||
}: UnmatchedDomainsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isDomainTargeted = (domain: string): boolean => {
|
||||
if (targetedDomains.has(domain)) return true;
|
||||
const parts = domain.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (targetedDomains.has(parts.slice(i).join("."))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const isDomainTargeted = useDomainTargeting(targetedDomains);
|
||||
|
||||
const unmatched = useMemo(() => {
|
||||
return Object.entries(topDomains)
|
||||
.filter(([domain]) => !isDomainTargeted(domain))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 15);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [topDomains, targetedDomains]);
|
||||
}, [topDomains, isDomainTargeted]);
|
||||
|
||||
if (unmatched.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
bgcolor: colors.background.paper,
|
||||
borderColor: colors.border.default,
|
||||
borderRadius: `${radiusPx.md}px`,
|
||||
p: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
<Typography
|
||||
variant="metricLabel"
|
||||
sx={{
|
||||
display: "block",
|
||||
color: colors.text.secondary,
|
||||
opacity: 0.8,
|
||||
p: "12px 14px 6px",
|
||||
}}
|
||||
>
|
||||
{t("dashboard.unmatchedDomains.title")}
|
||||
</Typography>
|
||||
<DashboardPanel eyebrow={t("dashboard.unmatchedDomains.title")} fill>
|
||||
{unmatched.map(([domain, count]) => (
|
||||
<UnmatchedRow
|
||||
<DataRow
|
||||
key={domain}
|
||||
domain={domain}
|
||||
count={count}
|
||||
tls={domainTLS[domain]}
|
||||
sets={sets}
|
||||
onAdded={onRefreshSets}
|
||||
/>
|
||||
leading={domainTLS[domain] ? <B4ConfidencePill score={domainTLS[domain]} /> : undefined}
|
||||
right={
|
||||
<>
|
||||
<B4CountPill value={formatNumber(count)} />
|
||||
<AddToSetButton domain={domain} sets={sets} onAdded={onRefreshSets} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DomainLabel value={domain} />
|
||||
</DataRow>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
interface UnmatchedRowProps {
|
||||
domain: string;
|
||||
count: number;
|
||||
tls?: string;
|
||||
sets: B4SetConfig[];
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
const UnmatchedRow = ({
|
||||
domain,
|
||||
count,
|
||||
tls,
|
||||
sets,
|
||||
onAdded,
|
||||
}: UnmatchedRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const enabledSets = sets.filter((s) => s.enabled);
|
||||
|
||||
const handleAdd = async (setId: string) => {
|
||||
setAnchorEl(null);
|
||||
setAdding(true);
|
||||
try {
|
||||
await setsApi.addDomainToSet(setId, domain);
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
console.error("Failed to add domain:", e);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
p: "8px 14px",
|
||||
borderBottom: `1px solid ${colors.border.light}`,
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
transition: "background-color 120ms ease",
|
||||
"&:hover": { bgcolor: "rgba(255, 255, 255, 0.025)" },
|
||||
}}
|
||||
>
|
||||
{tls && <B4ConfidencePill score={tls} />}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.04em",
|
||||
color: colors.text.primary,
|
||||
textTransform: "uppercase",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
title={domain}
|
||||
>
|
||||
{domain}
|
||||
</Box>
|
||||
<B4CountPill value={formatNumber(count)} />
|
||||
{enabledSets.length > 0 && (
|
||||
<>
|
||||
<Tooltip title={t("core.addToSet")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
disabled={adding}
|
||||
sx={{
|
||||
color: colors.text.secondary,
|
||||
p: 0.25,
|
||||
"&:hover": { color: colors.secondary },
|
||||
}}
|
||||
>
|
||||
<AddIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
bgcolor: colors.background.default,
|
||||
border: `1px solid ${colors.border.default}`,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{enabledSets.map((set) => (
|
||||
<MenuItem
|
||||
key={set.id}
|
||||
onClick={() => void handleAdd(set.id)}
|
||||
sx={{ color: colors.text.primary, fontSize: "0.8rem" }}
|
||||
>
|
||||
{set.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DashboardPanel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => {
|
|||
onChange("queue.interfaces", updated);
|
||||
};
|
||||
|
||||
const tunOutInterface = config.queue.tun?.out_interface;
|
||||
const tunFollowsDefault = !tunOutInterface || tunOutInterface === "auto";
|
||||
|
||||
const masqueradeSwitch = (
|
||||
<B4Switch
|
||||
label={t("settings.Feature.natMasquerade")}
|
||||
|
|
@ -85,16 +88,18 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => {
|
|||
<B4FormGroup label={t("settings.Feature.tunSettings")} columns={2}>
|
||||
<B4Select
|
||||
label={t("settings.Feature.tunOutInterface")}
|
||||
value={config.queue.tun?.out_interface || ""}
|
||||
value={tunFollowsDefault ? "" : tunOutInterface ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange("queue.tun.out_interface", e.target.value)
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: t("settings.Feature.tunOutInterfaceNone") },
|
||||
...(config.available_ifaces ?? []).map((i) => ({
|
||||
value: i,
|
||||
label: i,
|
||||
})),
|
||||
{ value: "", label: t("settings.Feature.tunOutInterfaceAuto") },
|
||||
...(config.available_ifaces ?? [])
|
||||
.filter((i) => i !== (config.queue.tun?.device_name || "b4tun0"))
|
||||
.map((i) => ({
|
||||
value: i,
|
||||
label: i,
|
||||
})),
|
||||
]}
|
||||
helperText={t("settings.Feature.tunOutInterfaceDesc")}
|
||||
/>
|
||||
|
|
@ -103,7 +108,12 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => {
|
|||
value={config.queue.tun?.out_gateway || ""}
|
||||
onChange={(e) => onChange("queue.tun.out_gateway", e.target.value)}
|
||||
placeholder={t("settings.Feature.tunOutGatewayPlaceholder")}
|
||||
helperText={t("settings.Feature.tunOutGatewayHelp")}
|
||||
disabled={tunFollowsDefault}
|
||||
helperText={t(
|
||||
tunFollowsDefault
|
||||
? "settings.Feature.tunOutGatewayAuto"
|
||||
: "settings.Feature.tunOutGatewayHelp"
|
||||
)}
|
||||
selectOnFocus
|
||||
/>
|
||||
<B4TextField
|
||||
|
|
@ -120,9 +130,9 @@ export const FeatureSettings = ({ config, onChange }: FeatureSettingsProps) => {
|
|||
helperText={t("settings.Feature.tunDeviceNameHelp")}
|
||||
selectOnFocus
|
||||
/>
|
||||
{!config.queue.tun?.out_interface && (
|
||||
<B4Alert severity="warning">
|
||||
{t("settings.Feature.tunOutInterfaceRequired")}
|
||||
{tunFollowsDefault && (
|
||||
<B4Alert severity="info">
|
||||
{t("settings.Feature.tunOutInterfaceAutoHint")}
|
||||
</B4Alert>
|
||||
)}
|
||||
</B4FormGroup>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const colors = {
|
|||
paper: "#1f1218",
|
||||
dark: "#0f0a0e",
|
||||
control: "rgba(31, 18, 24, 0.6)",
|
||||
hover: "rgba(255, 255, 255, 0.025)",
|
||||
},
|
||||
text: {
|
||||
primary: "#ffe8f4",
|
||||
|
|
|
|||
38
src/http/ui/src/hooks/useDeviceNames.ts
Normal file
38
src/http/ui/src/hooks/useDeviceNames.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DeviceInfo, devicesApi } from "@b4.devices";
|
||||
|
||||
export function useDeviceNames() {
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
devicesApi
|
||||
.list()
|
||||
.then((data) => setDevices(data.devices || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const deviceMap = useMemo(() => {
|
||||
const map: Record<string, DeviceInfo> = {};
|
||||
for (const d of devices) map[d.mac] = d;
|
||||
return map;
|
||||
}, [devices]);
|
||||
|
||||
const getDeviceName = (mac: string): string => {
|
||||
const dev = deviceMap[mac];
|
||||
if (dev?.alias) return dev.alias;
|
||||
if (dev?.hostname) return dev.hostname;
|
||||
if (dev?.vendor && dev.vendor !== "Private") return `${dev.vendor} (${mac})`;
|
||||
return mac;
|
||||
};
|
||||
|
||||
const getDeviceMeta = (mac: string): string => {
|
||||
const dev = deviceMap[mac];
|
||||
if (!dev) return "";
|
||||
const parts: string[] = [];
|
||||
if (dev.ip) parts.push(dev.ip);
|
||||
if (dev.vendor && dev.vendor !== "Private") parts.push(dev.vendor);
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
return { devices, deviceMap, getDeviceName, getDeviceMeta };
|
||||
}
|
||||
15
src/http/ui/src/hooks/useDomainTargeting.ts
Normal file
15
src/http/ui/src/hooks/useDomainTargeting.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useCallback } from "react";
|
||||
|
||||
export function useDomainTargeting(targetedDomains: Set<string>) {
|
||||
return useCallback(
|
||||
(domain: string): boolean => {
|
||||
if (targetedDomains.has(domain)) return true;
|
||||
const parts = domain.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (targetedDomains.has(parts.slice(i).join("."))) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[targetedDomains],
|
||||
);
|
||||
}
|
||||
|
|
@ -185,12 +185,13 @@
|
|||
"engineModeTun": "TUN interface",
|
||||
"tunSettings": "TUN settings",
|
||||
"tunOutInterface": "Uplink interface",
|
||||
"tunOutInterfaceNone": "Select interface…",
|
||||
"tunOutInterfaceDesc": "Where b4 sends processed packets (e.g. eth0, wan0, an L2TP/PPP tunnel).",
|
||||
"tunOutInterfaceRequired": "Select the uplink interface, required for TUN mode.",
|
||||
"tunOutInterfaceAuto": "Auto (follow default route)",
|
||||
"tunOutInterfaceDesc": "Where b4 sends processed packets. Auto follows the system default route and re-points automatically when the WAN/VPN changes; pick an interface to pin it.",
|
||||
"tunOutInterfaceAutoHint": "Auto mode tracks the system default route and re-points the uplink automatically when the WAN or VPN changes.",
|
||||
"tunOutGateway": "Uplink gateway",
|
||||
"tunOutGatewayPlaceholder": "auto-detect",
|
||||
"tunOutGatewayHelp": "Gateway IP for the uplink. Leave empty to auto-detect from the current default route.",
|
||||
"tunOutGatewayAuto": "Resolved automatically from the default route while the uplink is on Auto.",
|
||||
"tunAddress": "TUN address",
|
||||
"tunAddressHelp": "Address/prefix for the TUN device. Must not overlap your LAN or VPN subnets.",
|
||||
"tunDeviceName": "TUN device name",
|
||||
|
|
@ -619,6 +620,12 @@
|
|||
"loading": "Loading dashboard...",
|
||||
"connectionRate": "Connection Rate",
|
||||
"connectionRateLegend": "conn / s · last 60s",
|
||||
"signal": {
|
||||
"title": "Live Signal",
|
||||
"cps": "c/s",
|
||||
"idle": "Awaiting traffic",
|
||||
"idleHint": "Connection activity appears here once b4 sees traffic."
|
||||
},
|
||||
"metrics": {
|
||||
"targeted": "Targeted",
|
||||
"ofTotal": "of total",
|
||||
|
|
|
|||
|
|
@ -181,12 +181,13 @@
|
|||
"engineModeTun": "TUN-интерфейс",
|
||||
"tunSettings": "Настройки TUN",
|
||||
"tunOutInterface": "Аплинк-интерфейс",
|
||||
"tunOutInterfaceNone": "Выберите интерфейс…",
|
||||
"tunOutInterfaceDesc": "Куда b4 отправляет обработанные пакеты (например eth0, wan0, туннель L2TP/PPP).",
|
||||
"tunOutInterfaceRequired": "Выберите аплинк-интерфейс, обязательно для режима TUN.",
|
||||
"tunOutInterfaceAuto": "Авто (следовать маршруту по умолчанию)",
|
||||
"tunOutInterfaceDesc": "Куда b4 отправляет обработанные пакеты. «Авто» следует системному маршруту по умолчанию и автоматически переключается при смене WAN/VPN; выберите интерфейс, чтобы закрепить его.",
|
||||
"tunOutInterfaceAutoHint": "Режим «Авто» отслеживает системный маршрут по умолчанию и автоматически переключает аплинк при смене WAN или VPN.",
|
||||
"tunOutGateway": "Шлюз аплинка",
|
||||
"tunOutGatewayPlaceholder": "автоопределение",
|
||||
"tunOutGatewayHelp": "IP шлюза аплинка. Оставьте пустым для автоопределения из текущего маршрута по умолчанию.",
|
||||
"tunOutGatewayAuto": "Определяется автоматически из маршрута по умолчанию, пока аплинк в режиме «Авто».",
|
||||
"tunAddress": "Адрес TUN",
|
||||
"tunAddressHelp": "Адрес/префикс для устройства TUN. Не должен пересекаться с подсетями LAN или VPN.",
|
||||
"tunDeviceName": "Имя устройства TUN",
|
||||
|
|
@ -615,6 +616,12 @@
|
|||
"loading": "Загрузка...",
|
||||
"connectionRate": "Скорость соединений",
|
||||
"connectionRateLegend": "соед / с · за 60с",
|
||||
"signal": {
|
||||
"title": "Живой сигнал",
|
||||
"cps": "соед/с",
|
||||
"idle": "Ожидание трафика",
|
||||
"idleHint": "Активность соединений появится здесь, как только b4 увидит трафик."
|
||||
},
|
||||
"metrics": {
|
||||
"targeted": "Затронуто",
|
||||
"ofTotal": "от общего",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,24 @@
|
|||
import { ParsedLog } from "@b4.logs";
|
||||
|
||||
export const SORT_STORAGE_KEY = "b4_domains_sort";
|
||||
export const AGG_SORT_STORAGE_KEY = "b4_connections_agg_sort";
|
||||
|
||||
export interface DomainSortState {
|
||||
column: string | null;
|
||||
direction: "asc" | "desc" | null;
|
||||
}
|
||||
|
||||
export function loadSortState(): DomainSortState {
|
||||
export function loadSortState(key: string = SORT_STORAGE_KEY): DomainSortState {
|
||||
try {
|
||||
const stored = localStorage.getItem(SORT_STORAGE_KEY);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as DomainSortState;
|
||||
const parsed = JSON.parse(stored) as Partial<DomainSortState>;
|
||||
const column = typeof parsed.column === "string" ? parsed.column : null;
|
||||
const direction =
|
||||
parsed.direction === "asc" || parsed.direction === "desc"
|
||||
? parsed.direction
|
||||
: null;
|
||||
return { column, direction };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load sort state:", e);
|
||||
|
|
@ -22,12 +29,10 @@ export function loadSortState(): DomainSortState {
|
|||
export function saveSortState(
|
||||
column: string | null,
|
||||
direction: "asc" | "desc" | null,
|
||||
key: string = SORT_STORAGE_KEY,
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
SORT_STORAGE_KEY,
|
||||
JSON.stringify({ column, direction }),
|
||||
);
|
||||
localStorage.setItem(key, JSON.stringify({ column, direction }));
|
||||
} catch (e) {
|
||||
console.error("Failed to save sort state:", e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,7 +237,12 @@ func planTransports(cfg *config.MTProtoConfig, queueCfg config.QueueConfig, dc i
|
|||
appendTCP()
|
||||
}
|
||||
|
||||
if (mode == "ws" || mode == "auto") && !wsIsBlacklisted(dc) {
|
||||
wsMode := mode == "ws" || mode == "auto"
|
||||
wsBlacklisted := wsMode && wsIsBlacklisted(dc)
|
||||
if wsBlacklisted {
|
||||
log.Debugf("%s DC %d WS plans skipped (blacklisted)", tg(""), dc)
|
||||
}
|
||||
if wsMode && !wsBlacklisted {
|
||||
if wsEdgeServesDC(absDC) && !cfg.BridgeSkipNativeEdge {
|
||||
dh := wsNativeDialHost(cfg.WSEndpointHost)
|
||||
primary := transportPlan{kind: transportWS, dc: dc, sni: fmt.Sprintf("kws%d.web.telegram.org", absDC), dialHost: dh}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const (
|
|||
wsPoolMaxAge = 20 * time.Second
|
||||
wsPoolDefaultSize = 4
|
||||
wsDCFailCooldown = 30 * time.Second
|
||||
wsBlacklistTTL = 5 * time.Minute
|
||||
wsDialTimeoutCooldown = 2 * time.Second
|
||||
tcpFailCooldown = 30 * time.Second
|
||||
)
|
||||
|
|
@ -33,7 +34,7 @@ func (k wsKey) String() string {
|
|||
|
||||
var (
|
||||
wsStateMu sync.Mutex
|
||||
wsBlacklist = map[wsKey]bool{}
|
||||
wsBlacklist = map[wsKey]time.Time{}
|
||||
wsCooldownTo = map[wsKey]time.Time{}
|
||||
|
||||
tcpStateMu sync.Mutex
|
||||
|
|
@ -105,7 +106,16 @@ func wsIsBlacklisted(dc int) bool {
|
|||
k := wsKeyFromDC(dc)
|
||||
wsStateMu.Lock()
|
||||
defer wsStateMu.Unlock()
|
||||
return wsBlacklist[k]
|
||||
t, ok := wsBlacklist[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(t) {
|
||||
delete(wsBlacklist, k)
|
||||
log.Debugf("%s WS %s blacklist expired, WS re-enabled", tg(""), k)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func wsCooldownActive(dc int) bool {
|
||||
|
|
@ -128,25 +138,32 @@ func wsRecordFailure(dc int, allRedirect bool) {
|
|||
wsStateMu.Lock()
|
||||
defer wsStateMu.Unlock()
|
||||
if allRedirect {
|
||||
wsBlacklist[k] = true
|
||||
log.Warnf("MTProto WS %s blacklisted (all redirects)", k)
|
||||
wsBlacklist[k] = time.Now().Add(wsBlacklistTTL)
|
||||
log.Warnf("%s WS %s blacklisted (all redirects), retry in %v", tg(""), k, wsBlacklistTTL)
|
||||
}
|
||||
wsCooldownTo[k] = time.Now().Add(wsDCFailCooldown)
|
||||
log.Debugf("%s WS %s dial failure, cooldown %v (allRedirect=%v)", tg(""), k, wsDCFailCooldown, allRedirect)
|
||||
}
|
||||
|
||||
func wsRecordSuccess(dc int) {
|
||||
k := wsKeyFromDC(dc)
|
||||
wsStateMu.Lock()
|
||||
defer wsStateMu.Unlock()
|
||||
_, wasCooled := wsCooldownTo[k]
|
||||
_, wasBlacklisted := wsBlacklist[k]
|
||||
delete(wsCooldownTo, k)
|
||||
delete(wsBlacklist, k)
|
||||
if wasCooled || wasBlacklisted {
|
||||
log.Debugf("%s WS %s recovered (cleared cooldown/blacklist)", tg(""), k)
|
||||
}
|
||||
}
|
||||
|
||||
func wsResetState() {
|
||||
wsStateMu.Lock()
|
||||
defer wsStateMu.Unlock()
|
||||
wsBlacklist = map[wsKey]bool{}
|
||||
wsBlacklist = map[wsKey]time.Time{}
|
||||
wsCooldownTo = map[wsKey]time.Time{}
|
||||
log.Debugf("%s WS cooldown/blacklist state reset", tg(""))
|
||||
}
|
||||
|
||||
type wsPoolEntry struct {
|
||||
|
|
@ -301,7 +318,7 @@ func (p *wsPool) refill(dc int) {
|
|||
r := <-results
|
||||
if r.err != nil || r.conn == nil {
|
||||
if r.err != nil {
|
||||
log.Tracef("MTProto WS pool refill %s slot failed: %v", k, r.err)
|
||||
log.Tracef("%s WS pool refill %s slot failed: %v", tg(""), k, r.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -315,7 +332,7 @@ func (p *wsPool) refill(dc int) {
|
|||
added++
|
||||
}
|
||||
if added > 0 {
|
||||
log.Debugf("MTProto WS pool %s refilled +%d (target=%d)", k, added, p.target)
|
||||
log.Debugf("%s WS pool %s refilled +%d (target=%d)", tg(""), k, added, p.target)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,18 +39,17 @@ func (w *Worker) InitSender() error {
|
|||
return nil
|
||||
}
|
||||
cfg := w.getConfig()
|
||||
device := ""
|
||||
reinjectMark := int(cfg.Queue.Mark)
|
||||
if cfg.Queue.Mode == "tun" {
|
||||
device = cfg.Queue.TUN.OutInterface
|
||||
tun := cfg.Queue.Mode == "tun"
|
||||
if tun {
|
||||
reinjectMark |= engine.ReinjectMarkBit
|
||||
}
|
||||
s, err := sock.NewSenderWithMarkDevice(reinjectMark, device)
|
||||
s, err := sock.NewSenderWithMark(reinjectMark)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.sock = s
|
||||
if device != "" {
|
||||
if tun {
|
||||
cs, err := sock.NewSenderWithMark(engine.TunClientMark)
|
||||
if err != nil {
|
||||
w.sock.Close()
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ func ClearStaleArtifacts(cfg *config.Config) {
|
|||
if clearOwnedRoutingTable(steerMarkMatch(), captureTable) {
|
||||
cleared = true
|
||||
}
|
||||
if clearOwnedRoutingTable(reinjectMarkMatch(), routeTable) {
|
||||
cleared = true
|
||||
}
|
||||
if clearOwnedRoutingTable(fmt.Sprintf("0x%x", cfg.MainInjectedMark()), routeTable) {
|
||||
cleared = true
|
||||
}
|
||||
|
|
|
|||
103
src/tun/egress_watch.go
Normal file
103
src/tun/egress_watch.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package tun
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/daniellavrushin/b4/log"
|
||||
"github.com/mdlayher/netlink"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const egressWatchDebounce = 500 * time.Millisecond
|
||||
|
||||
type egressWatcher struct {
|
||||
conn *netlink.Conn
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
trigger func()
|
||||
started bool
|
||||
|
||||
dmu sync.Mutex
|
||||
dtimer *time.Timer
|
||||
}
|
||||
|
||||
func newEgressWatcher(trigger func()) *egressWatcher {
|
||||
return &egressWatcher{
|
||||
stop: make(chan struct{}),
|
||||
trigger: trigger,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *egressWatcher) Start() error {
|
||||
conn, err := netlink.Dial(unix.NETLINK_ROUTE, &netlink.Config{
|
||||
Groups: unix.RTMGRP_LINK | unix.RTMGRP_IPV4_ROUTE | unix.RTMGRP_IPV4_IFADDR,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.conn = conn
|
||||
w.started = true
|
||||
w.wg.Add(1)
|
||||
go w.loop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *egressWatcher) Stop() {
|
||||
if !w.started {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-w.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
close(w.stop)
|
||||
if w.conn != nil {
|
||||
_ = w.conn.Close()
|
||||
}
|
||||
w.wg.Wait()
|
||||
|
||||
w.dmu.Lock()
|
||||
if w.dtimer != nil {
|
||||
w.dtimer.Stop()
|
||||
w.dtimer = nil
|
||||
}
|
||||
w.dmu.Unlock()
|
||||
}
|
||||
|
||||
func (w *egressWatcher) loop() {
|
||||
defer w.wg.Done()
|
||||
for {
|
||||
if _, err := w.conn.Receive(); err != nil {
|
||||
select {
|
||||
case <-w.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
log.Tracef("TUN: egress watcher receive error: %v", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
w.schedule()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *egressWatcher) schedule() {
|
||||
w.dmu.Lock()
|
||||
defer w.dmu.Unlock()
|
||||
if w.dtimer != nil {
|
||||
return
|
||||
}
|
||||
w.dtimer = time.AfterFunc(egressWatchDebounce, func() {
|
||||
select {
|
||||
case <-w.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
w.dmu.Lock()
|
||||
w.dtimer = nil
|
||||
w.dmu.Unlock()
|
||||
w.trigger()
|
||||
})
|
||||
}
|
||||
146
src/tun/route.go
146
src/tun/route.go
|
|
@ -52,6 +52,8 @@ type routeManager struct {
|
|||
dupIPs []string
|
||||
replyCapture bool
|
||||
|
||||
followDefault bool
|
||||
|
||||
mu sync.Mutex
|
||||
srcIP string
|
||||
resolvedCapture string
|
||||
|
|
@ -60,6 +62,29 @@ type routeManager struct {
|
|||
captureExcl []string
|
||||
}
|
||||
|
||||
func resolveDefaultEgress(skipDev string) (iface, gw, src string, ok bool) {
|
||||
out, err := run("ip", "-4", "route", "show", "default")
|
||||
if err != nil {
|
||||
return "", "", "", false
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
dev := extractField(line, "dev")
|
||||
if dev == "" || dev == skipDev {
|
||||
continue
|
||||
}
|
||||
src = extractField(line, "src")
|
||||
if src == "" {
|
||||
src = interfacePrimaryIPv4(dev)
|
||||
}
|
||||
return dev, extractGateway(line), src, true
|
||||
}
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
func (r *routeManager) setupNAT() {
|
||||
markStr := reinjectMarkMatch()
|
||||
|
||||
|
|
@ -166,18 +191,29 @@ func (r *routeManager) setup() error {
|
|||
r.savedDefault = strings.TrimSpace(strings.SplitN(out, "\n", 2)[0])
|
||||
log.Infof("TUN: saved default route: %s", r.savedDefault)
|
||||
|
||||
if r.outGateway == "" {
|
||||
r.outGateway = extractGateway(r.savedDefault)
|
||||
if r.outGateway != "" {
|
||||
log.Infof("TUN: auto-detected gateway: %s", r.outGateway)
|
||||
} else {
|
||||
log.Infof("TUN: no gateway on default route, treating %s as point-to-point", r.outIface)
|
||||
var srcIP string
|
||||
if r.followDefault {
|
||||
iface, gw, src, found := resolveDefaultEgress(r.tunName)
|
||||
if !found {
|
||||
return fmt.Errorf("TUN follow-default mode: no usable IPv4 default route to derive the uplink; bring up the WAN/VPN first or pin queue.tun.out_interface")
|
||||
}
|
||||
r.outIface = iface
|
||||
r.outGateway = gw
|
||||
srcIP = src
|
||||
log.Infof("TUN: following default route (uplink=%s gw=%q src=%q)", iface, gw, src)
|
||||
} else {
|
||||
if r.outGateway == "" {
|
||||
r.outGateway = extractGateway(r.savedDefault)
|
||||
if r.outGateway != "" {
|
||||
log.Infof("TUN: auto-detected gateway: %s", r.outGateway)
|
||||
} else {
|
||||
log.Infof("TUN: no gateway on default route, treating %s as point-to-point", r.outIface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srcIP := extractField(r.savedDefault, "src")
|
||||
if srcIP == "" {
|
||||
srcIP = interfacePrimaryIPv4(r.outIface)
|
||||
if srcIP == "" {
|
||||
srcIP = extractField(r.savedDefault, "src")
|
||||
}
|
||||
}
|
||||
r.srcIP = srcIP
|
||||
|
||||
|
|
@ -265,7 +301,7 @@ func (r *routeManager) restoreRPFilter() {
|
|||
|
||||
func (r *routeManager) setupBypassTable() error {
|
||||
tableStr := fmt.Sprintf("%d", r.routeTable)
|
||||
markStr := fmt.Sprintf("0x%x", r.mark)
|
||||
markStr := reinjectMarkMatch()
|
||||
|
||||
if existing, err := run("ip", "route", "show", "table", tableStr); err == nil && strings.TrimSpace(existing) != "" {
|
||||
if !r.ownsBypassTable(markStr, tableStr) {
|
||||
|
|
@ -303,7 +339,10 @@ func (r *routeManager) ownsBypassTable(markStr, tableStr string) bool {
|
|||
continue
|
||||
}
|
||||
fw := ruleFieldValue(line, "fwmark")
|
||||
if fw == markStr || strings.HasPrefix(fw, markStr+"/") {
|
||||
bare := strings.SplitN(markStr, "/", 2)[0]
|
||||
legacy := fmt.Sprintf("0x%x", r.mark)
|
||||
if fw == markStr || fw == bare || strings.HasPrefix(fw, bare+"/") ||
|
||||
fw == legacy || strings.HasPrefix(fw, legacy+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -311,9 +350,13 @@ func (r *routeManager) ownsBypassTable(markStr, tableStr string) bool {
|
|||
}
|
||||
|
||||
func (r *routeManager) delFwmarkRule(markStr, tableStr string) {
|
||||
for {
|
||||
if _, err := run("ip", "rule", "del", "fwmark", markStr, "lookup", tableStr); err != nil {
|
||||
return
|
||||
bare := strings.SplitN(markStr, "/", 2)[0]
|
||||
legacy := fmt.Sprintf("0x%x", r.mark)
|
||||
for _, m := range []string{markStr, bare, legacy, legacy + "/" + legacy} {
|
||||
for {
|
||||
if _, err := run("ip", "rule", "del", "fwmark", m, "lookup", tableStr); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -351,14 +394,77 @@ func (r *routeManager) setupDefaultCapture(srcIP string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (r *routeManager) refreshEgress() bool {
|
||||
if !r.followDefault {
|
||||
return false
|
||||
}
|
||||
iface, gw, src, ok := resolveDefaultEgress(r.tunName)
|
||||
if !ok || (iface == r.outIface && gw == r.outGateway) {
|
||||
return false
|
||||
}
|
||||
log.Infof("TUN: default route changed; re-pointing egress %s(gw %q) -> %s(gw %q)", r.outIface, r.outGateway, iface, gw)
|
||||
|
||||
if !r.skipTables {
|
||||
r.restoreRPFilter()
|
||||
}
|
||||
r.removeSNAT()
|
||||
r.outIface = iface
|
||||
r.outGateway = gw
|
||||
if src != "" {
|
||||
r.srcIP = src
|
||||
}
|
||||
if r.resolvedCapture == "default" {
|
||||
if gw != "" {
|
||||
r.savedDefault = fmt.Sprintf("default via %s dev %s", gw, iface)
|
||||
} else {
|
||||
r.savedDefault = fmt.Sprintf("default dev %s", iface)
|
||||
}
|
||||
if src != "" {
|
||||
r.savedDefault += " src " + src
|
||||
}
|
||||
}
|
||||
if !r.skipTables {
|
||||
r.savedRPFilter = ""
|
||||
r.loosenRPFilter()
|
||||
}
|
||||
|
||||
tableStr := strconv.Itoa(r.routeTable)
|
||||
run("ip", "route", "flush", "table", tableStr)
|
||||
if err := r.addBypassDefault(tableStr); err != nil {
|
||||
log.Warnf("TUN: reconcile failed to rebuild bypass route for new uplink: %v", err)
|
||||
}
|
||||
|
||||
if mtu := interfaceMTU(iface); mtu > 0 {
|
||||
run("ip", "link", "set", r.tunName, "mtu", strconv.Itoa(mtu))
|
||||
}
|
||||
|
||||
if r.resolvedCapture == "ports" {
|
||||
if err := r.replaceCaptureDefault(strconv.Itoa(r.captureTable)); err != nil {
|
||||
log.Warnf("TUN: reconcile failed to refresh capture src for new uplink: %v", err)
|
||||
}
|
||||
} else if err := r.replaceDefaultIntoTun(); err != nil {
|
||||
log.Warnf("TUN: reconcile failed to refresh default-capture for new uplink: %v", err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *routeManager) reconcile() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if newIP := interfacePrimaryIPv4(r.outIface); newIP != "" && newIP != r.srcIP {
|
||||
log.Infof("TUN: uplink %s address changed %q -> %q; updating SNAT and capture source", r.outIface, r.srcIP, newIP)
|
||||
r.refreshEgress()
|
||||
|
||||
newSrc := interfacePrimaryIPv4(r.outIface)
|
||||
if r.followDefault {
|
||||
if _, _, s, ok := resolveDefaultEgress(r.tunName); ok && s != "" {
|
||||
newSrc = s
|
||||
}
|
||||
}
|
||||
if newSrc != "" && newSrc != r.srcIP {
|
||||
log.Infof("TUN: uplink %s source changed %q -> %q; updating SNAT and capture source", r.outIface, r.srcIP, newSrc)
|
||||
r.removeSNAT()
|
||||
r.srcIP = newIP
|
||||
r.srcIP = newSrc
|
||||
if r.resolvedCapture == "ports" {
|
||||
if err := r.replaceCaptureDefault(strconv.Itoa(r.captureTable)); err != nil {
|
||||
log.Warnf("TUN: reconcile failed to refresh capture-table src: %v", err)
|
||||
|
|
@ -408,7 +514,7 @@ func (r *routeManager) ensureNAT() {
|
|||
}
|
||||
|
||||
func (r *routeManager) ensureBypass() {
|
||||
markStr := fmt.Sprintf("0x%x", r.mark)
|
||||
markStr := reinjectMarkMatch()
|
||||
tableStr := fmt.Sprintf("%d", r.routeTable)
|
||||
if !r.ownsBypassTable(markStr, tableStr) {
|
||||
if _, err := run("ip", "rule", "add", "fwmark", markStr, "lookup", tableStr, "priority", "100"); err != nil {
|
||||
|
|
@ -464,7 +570,7 @@ func (r *routeManager) addBypassDefault(tableStr string) error {
|
|||
}
|
||||
|
||||
func (r *routeManager) teardown() {
|
||||
markStr := fmt.Sprintf("0x%x", r.mark)
|
||||
markStr := reinjectMarkMatch()
|
||||
tableStr := fmt.Sprintf("%d", r.routeTable)
|
||||
|
||||
if r.resolvedCapture == "ports" {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ type Engine struct {
|
|||
routes *routeManager
|
||||
sender *sock.Sender
|
||||
clientSender *sock.Sender
|
||||
trigger chan struct{}
|
||||
egressW *egressWatcher
|
||||
wg sync.WaitGroup
|
||||
quit chan struct{}
|
||||
stopOnce sync.Once
|
||||
|
|
@ -96,7 +98,7 @@ func (e *Engine) Start() error {
|
|||
e.tunName = name
|
||||
log.Infof("TUN: opened device %s", name)
|
||||
|
||||
sender, err := sock.NewSenderWithMarkDevice(int(cfg.Queue.Mark)|engine.ReinjectMarkBit, tunCfg.OutInterface)
|
||||
sender, err := sock.NewSenderWithMark(int(cfg.Queue.Mark) | engine.ReinjectMarkBit)
|
||||
if err != nil {
|
||||
e.tunFile.Close()
|
||||
run("ip", "link", "del", name)
|
||||
|
|
@ -108,7 +110,7 @@ func (e *Engine) Start() error {
|
|||
if replyCapture {
|
||||
clientSender, err := sock.NewSenderWithMark(defaultClientMark)
|
||||
if err != nil {
|
||||
e.sender.Close()
|
||||
sender.Close()
|
||||
e.tunFile.Close()
|
||||
run("ip", "link", "del", name)
|
||||
return err
|
||||
|
|
@ -134,25 +136,26 @@ func (e *Engine) Start() error {
|
|||
dupV4, _ := cfg.CollectDuplicateIPs()
|
||||
|
||||
e.routes = &routeManager{
|
||||
tunName: name,
|
||||
tunAddr: address,
|
||||
tunAddrV6: tunCfg.AddressV6,
|
||||
outIface: tunCfg.OutInterface,
|
||||
outGateway: tunCfg.OutGateway,
|
||||
mark: cfg.Queue.Mark,
|
||||
routeTable: routeTable,
|
||||
skipTables: cfg.System.Tables.SkipSetup,
|
||||
captureTable: captureTable,
|
||||
tcpPorts: normalizePorts(cfg.CollectTCPPorts()),
|
||||
udpPorts: normalizePorts(cfg.CollectUDPPorts()),
|
||||
tcpLimit: tcpLimit,
|
||||
udpLimit: udpLimit,
|
||||
dupIPs: dupV4,
|
||||
replyCapture: replyCapture,
|
||||
tunName: name,
|
||||
tunAddr: address,
|
||||
tunAddrV6: tunCfg.AddressV6,
|
||||
outIface: tunCfg.OutInterface,
|
||||
outGateway: tunCfg.OutGateway,
|
||||
mark: cfg.Queue.Mark,
|
||||
routeTable: routeTable,
|
||||
skipTables: cfg.System.Tables.SkipSetup,
|
||||
captureTable: captureTable,
|
||||
tcpPorts: normalizePorts(cfg.CollectTCPPorts()),
|
||||
udpPorts: normalizePorts(cfg.CollectUDPPorts()),
|
||||
tcpLimit: tcpLimit,
|
||||
udpLimit: udpLimit,
|
||||
dupIPs: dupV4,
|
||||
replyCapture: replyCapture,
|
||||
followDefault: tunCfg.FollowsDefaultRoute(),
|
||||
}
|
||||
if err := e.routes.setup(); err != nil {
|
||||
e.routes.teardown()
|
||||
e.sender.Close()
|
||||
sender.Close()
|
||||
e.tunFile.Close()
|
||||
return err
|
||||
}
|
||||
|
|
@ -168,9 +171,16 @@ func (e *Engine) Start() error {
|
|||
|
||||
log.Infof("TUN: started %d reader threads", threads)
|
||||
|
||||
e.trigger = make(chan struct{}, 1)
|
||||
e.wg.Add(1)
|
||||
go e.reconcileLoop()
|
||||
|
||||
e.egressW = newEgressWatcher(e.triggerReconcile)
|
||||
if err := e.egressW.Start(); err != nil {
|
||||
log.Warnf("TUN: egress netlink watcher disabled (%v); falling back to periodic reconcile poll", err)
|
||||
e.egressW = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -188,6 +198,10 @@ func (e *Engine) reconcileLoop() {
|
|||
select {
|
||||
case <-e.quit:
|
||||
return
|
||||
case <-e.trigger:
|
||||
if e.routes != nil {
|
||||
e.routes.reconcile()
|
||||
}
|
||||
case <-ticker.C:
|
||||
if e.routes != nil {
|
||||
e.routes.reconcile()
|
||||
|
|
@ -280,6 +294,13 @@ func replyCaptureNeeded(cfg *config.Config) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
func (e *Engine) triggerReconcile() {
|
||||
select {
|
||||
case e.trigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) logForwardError(err error, src, dst string) {
|
||||
n := atomic.AddUint64(&e.fwdErrCount, 1)
|
||||
now := time.Now().Unix()
|
||||
|
|
@ -292,6 +313,9 @@ func (e *Engine) logForwardError(err error, src, dst string) {
|
|||
|
||||
func (e *Engine) Stop() {
|
||||
e.stopOnce.Do(func() {
|
||||
if e.egressW != nil {
|
||||
e.egressW.Stop()
|
||||
}
|
||||
close(e.quit)
|
||||
|
||||
if e.tunFile != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue