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

This commit is contained in:
Daniel Lavrushin 2026-06-21 02:02:44 +02:00 committed by GitHub
parent d5d53f9ad6
commit b41171562a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1244 additions and 876 deletions

View file

@ -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

View file

@ -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

View file

@ -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"`

View file

@ -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)},

View file

@ -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")
}
}

View file

@ -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

View file

@ -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>

View file

@ -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>
);
};

View 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>
</>
);
};

View file

@ -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>

View 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>
);

View 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>
);

View file

@ -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>
);
};

View 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>
);

View file

@ -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>
);
};

View 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>
);
};

View file

@ -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>
);
};

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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",

View 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 };
}

View 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],
);
}

View file

@ -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",

View file

@ -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": "от общего",

View file

@ -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);
}

View file

@ -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}

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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
View 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()
})
}

View file

@ -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" {

View file

@ -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 {