feat(ui): theme-aware logo, dashboard bar graph, server-side client sorting (v0.14.1-beta)
UI/UX refinements on top of v0.14.0-beta, all validated on the test server: - Logo: new theme-aware compass iconmark (light/dark SVGs swapped via the dark class), sized to 45px; favicon updated to match. Wordmark casing corrected to "MikroTik Manager" (font size unchanged). - Navigation icons enlarged 15 -> 17px. - Dashboard: "Connected clients" line chart converted to a bar chart with a category axis + light downsampling so bars stay readably thick across ranges. - Clients: sorting is now server-side across the whole dataset (was sorting only the current page, so Type/others appeared to do nothing on a uniform page); table uses a fixed column layout + keepPreviousData so columns no longer jump or blank out when sorting/paging. Backend clients route gains whitelisted sort/dir params (safe numeric IP sort, deterministic tiebreaker). - Connections tab: surfaces the live /ip/firewall/connection/tracking state (on/auto/off pill) and clearer empty-state copy explaining auto-mode. Lint/typecheck/build/jest green in both workspaces; npm audit --audit-level=high clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ee9c9ff9f7
commit
d79eb13119
@@ -13,7 +13,27 @@ export function setPollerService(p: PollerService): void { pollerService = p; }
|
||||
|
||||
// GET /api/clients
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const { deviceId, active, search, client_type, limit = '100', offset = '0' } = req.query;
|
||||
const { deviceId, active, search, client_type, limit = '100', offset = '0', sort, dir } = req.query;
|
||||
|
||||
// Whitelist sortable columns → safe SQL expressions on the outer (deduped)
|
||||
// result. Sorting must run server-side across the whole dataset, not just the
|
||||
// current page, or column sorts appear to do nothing when a page is uniform.
|
||||
const SORT_EXPR: Record<string, string> = {
|
||||
hostname: `LOWER(COALESCE(NULLIF(deduped.custom_name, ''), deduped.hostname, ''))`,
|
||||
vendor: `LOWER(COALESCE(deduped.vendor, ''))`,
|
||||
// Regex-guard the inet cast so non-IP/empty values can't error; sorts numerically.
|
||||
ip_address: `CASE WHEN deduped.ip_address ~ '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$' THEN deduped.ip_address::inet END`,
|
||||
client_type: `deduped.client_type`,
|
||||
interface_name: `LOWER(COALESCE(deduped.interface_name, ''))`,
|
||||
vlan_id: `deduped.vlan_id`,
|
||||
device_name: `LOWER(COALESCE(deduped.device_name, ''))`,
|
||||
traffic_today_bytes: `traffic_today_bytes`,
|
||||
last_seen: `deduped.last_seen`,
|
||||
};
|
||||
const sortExpr = SORT_EXPR[String(sort)] ?? SORT_EXPR.last_seen;
|
||||
const sortDir = String(dir).toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
// Stable, predictable: chosen column, then mac as a deterministic tiebreaker.
|
||||
const orderBy = `ORDER BY ${sortExpr} ${sortDir} NULLS LAST, deduped.mac_address ASC`;
|
||||
|
||||
// Build WHERE clause shared by both queries
|
||||
const filters: string[] = [];
|
||||
@@ -58,7 +78,7 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
) deduped
|
||||
LEFT JOIN client_traffic_daily ctd
|
||||
ON ctd.mac_address = LOWER(deduped.mac_address) AND ctd.day = CURRENT_DATE
|
||||
ORDER BY last_seen DESC NULLS LAST
|
||||
${orderBy}
|
||||
LIMIT $${idx++} OFFSET $${idx++}
|
||||
`;
|
||||
const clients = await query(sql, [...filterParams, parseInt(String(limit), 10), parseInt(String(offset), 10)]);
|
||||
|
||||
@@ -897,9 +897,9 @@ router.delete('/:id/address-lists/:entryId', requireWrite, async (req, res) => {
|
||||
router.get('/:id/connections', async (req, res) => {
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '500'), 10) || 500, 2000);
|
||||
await withCollector(req.params.id, res, async (c) => {
|
||||
const rows = await c.getConnections();
|
||||
const [rows, tracking] = await Promise.all([c.getConnections(), c.getConnectionTracking()]);
|
||||
// Connection tables can be huge; cap and surface the total so the UI can note truncation.
|
||||
return { total: rows.length, connections: rows.slice(0, limit) };
|
||||
return { total: rows.length, connections: rows.slice(0, limit), tracking };
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user