Add live hotspot network diagnostics
This commit is contained in:
@@ -13,6 +13,7 @@ NETWORK_MANAGER_ENABLED=false
|
||||
NETWORK_STATUS_PATH=/var/lib/pi-car-companion/network-status.json
|
||||
NETWORK_COMMAND_PATH=/var/lib/pi-car-companion/network-command.json
|
||||
NETWORK_CONFIGURATION_REQUEST_PATH=/var/lib/pi-car-companion/network-configuration-request.json
|
||||
NETWORK_ACTIVITY_PATH=/var/lib/pi-car-companion/network-activity.json
|
||||
NETWORK_HOTSPOT_INTERFACE=wlan0
|
||||
NETWORK_UPSTREAM_INTERFACE=wlan1
|
||||
NETWORK_HOTSPOT_CONNECTION=pi-car-hotspot
|
||||
|
||||
@@ -54,6 +54,7 @@ Core operation must work without cloud access. Optional remote access is a later
|
||||
- Companion service restart job
|
||||
- Reboot and shutdown actions with strong confirmation
|
||||
- Audit filtering and diagnostic export improvements
|
||||
- Live hotspot client, DNS-query metadata, and active-flow diagnostics
|
||||
- Optional PWA installation support
|
||||
|
||||
### Later, only after real-world validation
|
||||
|
||||
@@ -17,6 +17,7 @@ The current MVP slice includes:
|
||||
- an idempotent Raspberry Pi installer with systemd start-on-boot support;
|
||||
- atomic, dashboard-triggered Git updates with automatic verification and rollback safety;
|
||||
- optional dual-radio Wi-Fi failover with an always-recoverable offline car hotspot;
|
||||
- authenticated live network diagnostics for hotspot clients, DNS query metadata, and active destination flows;
|
||||
- automated API security tests and reproducible quality commands.
|
||||
|
||||
The ADB transport, job UI, artifacts, Android companion, and remaining operations work are tracked in [PLAN.md](./PLAN.md).
|
||||
@@ -157,6 +158,7 @@ Important values:
|
||||
- `NETWORK_MANAGER_ENABLED`: enables the separately supervised NetworkManager controller after explicit setup
|
||||
- `NETWORK_STATUS_PATH` and `NETWORK_COMMAND_PATH`: validated state and fixed-action request files
|
||||
- `NETWORK_CONFIGURATION_REQUEST_PATH`: mode-0600 one-time hotspot configuration request consumed by the root service
|
||||
- `NETWORK_ACTIVITY_PATH`: bounded observer snapshot consumed by the authenticated dashboard
|
||||
- `NETWORK_HOTSPOT_INTERFACE` / `NETWORK_UPSTREAM_INTERFACE`: default to built-in `wlan0` and USB `wlan1`
|
||||
- `NETWORK_PROBE_TIMEOUT_SECONDS`: maximum built-in upstream attempt before restoring the offline hotspot
|
||||
|
||||
@@ -187,7 +189,17 @@ Behavior by mode:
|
||||
- no upstream after 60 seconds: `wlan0` returns to the offline hotspot automatically.
|
||||
- `wlan1` returns: it must connect and remain stable before `wlan0` returns to hotspot mode.
|
||||
|
||||
Add phone hotspots as additional saved `wlan1` profiles. Dashboard actions only select existing NetworkManager profiles; Wi-Fi credentials are not accepted through the API.
|
||||
Add phone hotspots as additional saved `wlan1` profiles. Upstream actions only select existing NetworkManager profiles; the dashboard credential form configures the isolated car hotspot, not arbitrary upstream networks.
|
||||
|
||||
### Network activity diagnostics
|
||||
|
||||
The authenticated **Network** page refreshes every two seconds and reports:
|
||||
|
||||
- current and recently leased hotspot clients from NetworkManager DHCP leases and the neighbor table;
|
||||
- up to 250 DNS query names/types from the previous 15 minutes;
|
||||
- up to 150 active TCP/UDP destination IP, port, state, packet, and byte counters from conntrack.
|
||||
|
||||
The observer runs read-only as a separate root service and publishes a validated snapshot to the unprivileged application. It does not capture packet payloads, passwords, cookies, HTTP bodies, or HTTPS paths. DNS metadata comes from the system journal and follows its configured retention; the dashboard snapshot is overwritten rather than appended.
|
||||
|
||||
### Read-only ADB setup
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Query metadata only. Replies, packet bodies, and client payloads are not logged.
|
||||
log-queries=extra
|
||||
+8
-1
@@ -38,7 +38,7 @@ fi
|
||||
echo "[1/7] Installing operating-system packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 android-tools-adb build-essential network-manager wpasupplicant
|
||||
apt-get install -y ca-certificates curl gnupg git rsync sudo util-linux avahi-daemon sqlite3 android-tools-adb build-essential network-manager wpasupplicant conntrack
|
||||
|
||||
node_major=""
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
@@ -116,10 +116,13 @@ install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion.service" /etc/systemd/sy
|
||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
|
||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
|
||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
|
||||
install -m 0644 "${SCRIPT_DIR}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
|
||||
install -m 0440 "${SCRIPT_DIR}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
|
||||
visudo -cf /etc/sudoers.d/pi-car-companion >/dev/null
|
||||
install -d -m 0755 /etc/avahi/services
|
||||
install -m 0644 "${SCRIPT_DIR}/config/pi-car-companion.service.xml" /etc/avahi/services/pi-car-companion.service
|
||||
install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d
|
||||
install -m 0644 "${SCRIPT_DIR}/config/dnsmasq-shared-pi-car-companion.conf" /etc/NetworkManager/dnsmasq-shared.d/pi-car-companion.conf
|
||||
|
||||
if [[ ! -f "${CONFIG_DIR}/companion.env" ]]; then
|
||||
cat > "${CONFIG_DIR}/companion.env" <<EOF
|
||||
@@ -140,6 +143,7 @@ NETWORK_MANAGER_ENABLED=false
|
||||
NETWORK_STATUS_PATH=${DATA_DIR}/network-status.json
|
||||
NETWORK_COMMAND_PATH=${DATA_DIR}/network-command.json
|
||||
NETWORK_CONFIGURATION_REQUEST_PATH=${DATA_DIR}/network-configuration-request.json
|
||||
NETWORK_ACTIVITY_PATH=${DATA_DIR}/network-activity.json
|
||||
NETWORK_HOTSPOT_INTERFACE=wlan0
|
||||
NETWORK_UPSTREAM_INTERFACE=wlan1
|
||||
NETWORK_HOTSPOT_CONNECTION=pi-car-hotspot
|
||||
@@ -160,6 +164,7 @@ ensure_env_default NETWORK_MANAGER_ENABLED false
|
||||
ensure_env_default NETWORK_STATUS_PATH "${DATA_DIR}/network-status.json"
|
||||
ensure_env_default NETWORK_COMMAND_PATH "${DATA_DIR}/network-command.json"
|
||||
ensure_env_default NETWORK_CONFIGURATION_REQUEST_PATH "${DATA_DIR}/network-configuration-request.json"
|
||||
ensure_env_default NETWORK_ACTIVITY_PATH "${DATA_DIR}/network-activity.json"
|
||||
ensure_env_default NETWORK_HOTSPOT_INTERFACE wlan0
|
||||
ensure_env_default NETWORK_UPSTREAM_INTERFACE wlan1
|
||||
ensure_env_default NETWORK_HOTSPOT_CONNECTION pi-car-hotspot
|
||||
@@ -179,10 +184,12 @@ echo "[6/7] Enabling start-on-boot services"
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now avahi-daemon
|
||||
systemctl enable pi-car-companion.service
|
||||
systemctl enable pi-car-companion-network-observer.service
|
||||
if grep -qx 'NETWORK_MANAGER_ENABLED=true' "${CONFIG_DIR}/companion.env"; then
|
||||
systemctl enable --now pi-car-companion-network.service
|
||||
fi
|
||||
systemctl restart pi-car-companion.service
|
||||
systemctl restart pi-car-companion-network-observer.service
|
||||
|
||||
echo "[7/7] Verifying the service"
|
||||
for _attempt in {1..20}; do
|
||||
|
||||
@@ -61,7 +61,10 @@ install_integration_files() {
|
||||
install -m 0644 "${source_release}/systemd/pi-car-companion-update.service" /etc/systemd/system/pi-car-companion-update.service
|
||||
install -m 0644 "${source_release}/systemd/pi-car-companion-network.service" /etc/systemd/system/pi-car-companion-network.service
|
||||
install -m 0644 "${source_release}/systemd/pi-car-companion-network-configure.service" /etc/systemd/system/pi-car-companion-network-configure.service
|
||||
install -m 0644 "${source_release}/systemd/pi-car-companion-network-observer.service" /etc/systemd/system/pi-car-companion-network-observer.service
|
||||
install -m 0440 "${source_release}/systemd/pi-car-companion.sudoers" /etc/sudoers.d/pi-car-companion
|
||||
install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d
|
||||
install -m 0644 "${source_release}/config/dnsmasq-shared-pi-car-companion.conf" /etc/NetworkManager/dnsmasq-shared.d/pi-car-companion.conf
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@@ -182,3 +185,4 @@ fi
|
||||
|
||||
write_status "success" "Update installed and the companion service is healthy."
|
||||
systemctl try-restart pi-car-companion-network.service || true
|
||||
systemctl enable --now pi-car-companion-network-observer.service || true
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SESSION_COOKIE
|
||||
} from "./security.js";
|
||||
import { collectSystemStatus } from "./status.js";
|
||||
import { readNetworkActivity } from "./network-activity.js";
|
||||
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
|
||||
import {
|
||||
hotspotConfigurationSchema,
|
||||
@@ -325,6 +326,11 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
return readNetworkStatus(config);
|
||||
});
|
||||
|
||||
app.get("/api/network/activity", async (request, reply) => {
|
||||
if (!requireUser(request, reply)) return;
|
||||
return readNetworkActivity(config.network.activityPath);
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/network/configuration",
|
||||
{ config: { rateLimit: { max: 3, timeWindow: "10 minutes" } } },
|
||||
|
||||
@@ -21,6 +21,7 @@ const schema = z.object({
|
||||
NETWORK_STATUS_PATH: z.string().default("/var/lib/pi-car-companion/network-status.json"),
|
||||
NETWORK_COMMAND_PATH: z.string().default("/var/lib/pi-car-companion/network-command.json"),
|
||||
NETWORK_CONFIGURATION_REQUEST_PATH: z.string().default("/var/lib/pi-car-companion/network-configuration-request.json"),
|
||||
NETWORK_ACTIVITY_PATH: z.string().default("/var/lib/pi-car-companion/network-activity.json"),
|
||||
ADB_ENABLED: booleanFromString,
|
||||
ADB_PATH: z.string().min(1).default("adb"),
|
||||
ADB_SERIAL: z.string().max(128).regex(/^[a-zA-Z0-9._:-]*$/).default(""),
|
||||
@@ -42,6 +43,7 @@ export type AppConfig = {
|
||||
statusPath: string;
|
||||
commandPath: string;
|
||||
configurationRequestPath: string;
|
||||
activityPath: string;
|
||||
};
|
||||
adb: AdbConfig;
|
||||
};
|
||||
@@ -74,7 +76,8 @@ export function loadConfig(environment: NodeJS.ProcessEnv = process.env): AppCon
|
||||
enabled: parsed.NETWORK_MANAGER_ENABLED,
|
||||
statusPath: parsed.NETWORK_STATUS_PATH,
|
||||
commandPath: parsed.NETWORK_COMMAND_PATH,
|
||||
configurationRequestPath: parsed.NETWORK_CONFIGURATION_REQUEST_PATH
|
||||
configurationRequestPath: parsed.NETWORK_CONFIGURATION_REQUEST_PATH,
|
||||
activityPath: parsed.NETWORK_ACTIVITY_PATH
|
||||
},
|
||||
adb: {
|
||||
enabled: parsed.ADB_ENABLED,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
const clientSchema = z.object({
|
||||
ipAddress: z.string().max(64),
|
||||
macAddress: z.string().max(32),
|
||||
hostname: z.string().max(128).nullable(),
|
||||
leaseExpiresAt: z.string().nullable(),
|
||||
state: z.string().max(32),
|
||||
connected: z.boolean()
|
||||
});
|
||||
|
||||
const dnsQuerySchema = z.object({
|
||||
timestamp: z.string(),
|
||||
clientAddress: z.string().max(64),
|
||||
type: z.string().max(16),
|
||||
name: z.string().max(253)
|
||||
});
|
||||
|
||||
const flowSchema = z.object({
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
state: z.string().max(32),
|
||||
sourceAddress: z.string().max(64),
|
||||
sourcePort: z.number().int().min(0).max(65_535),
|
||||
destinationAddress: z.string().max(64),
|
||||
destinationPort: z.number().int().min(0).max(65_535),
|
||||
packets: z.number().int().nonnegative().nullable(),
|
||||
bytes: z.number().int().nonnegative().nullable()
|
||||
});
|
||||
|
||||
export const networkActivitySchema = z.object({
|
||||
collectedAt: z.string(),
|
||||
clients: z.array(clientSchema).max(256),
|
||||
dnsQueries: z.array(dnsQuerySchema).max(250),
|
||||
flows: z.array(flowSchema).max(150),
|
||||
dnsAvailable: z.boolean(),
|
||||
flowsAvailable: z.boolean(),
|
||||
messages: z.array(z.string().max(300)).max(8)
|
||||
});
|
||||
|
||||
export type NetworkActivity = z.infer<typeof networkActivitySchema> & { supported: boolean };
|
||||
|
||||
export async function readNetworkActivity(path: string): Promise<NetworkActivity> {
|
||||
try {
|
||||
return { ...networkActivitySchema.parse(JSON.parse(await readFile(path, "utf8"))), supported: true };
|
||||
} catch {
|
||||
return {
|
||||
supported: false,
|
||||
collectedAt: new Date(0).toISOString(),
|
||||
clients: [],
|
||||
dnsQueries: [],
|
||||
flows: [],
|
||||
dnsAvailable: false,
|
||||
flowsAvailable: false,
|
||||
messages: ["Network activity is available after the observer service is installed and started."]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { chmod, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
import { networkActivitySchema } from "./network-activity.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
const config = z
|
||||
.object({
|
||||
NETWORK_ACTIVITY_PATH: z.string().default("/var/lib/pi-car-companion/network-activity.json"),
|
||||
NETWORK_HOTSPOT_INTERFACE: z.string().regex(/^[a-zA-Z0-9_.-]+$/).default("wlan0"),
|
||||
NETWORK_OBSERVER_INTERVAL_MS: z.coerce.number().int().min(1_000).max(30_000).default(2_000)
|
||||
})
|
||||
.parse(process.env);
|
||||
|
||||
type Activity = z.infer<typeof networkActivitySchema>;
|
||||
type Client = Activity["clients"][number];
|
||||
type DnsQuery = Activity["dnsQueries"][number];
|
||||
type Flow = Activity["flows"][number];
|
||||
let stopped = false;
|
||||
|
||||
async function execute(file: string, args: string[], maxBuffer = 256 * 1024): Promise<string> {
|
||||
const { stdout } = await execFileAsync(file, args, { timeout: 10_000, maxBuffer });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function clients(): Promise<Client[]> {
|
||||
const leasePath = `/var/lib/NetworkManager/dnsmasq-${config.NETWORK_HOTSPOT_INTERFACE}.leases`;
|
||||
const [leases, neighbors] = await Promise.all([
|
||||
readFile(leasePath, "utf8").catch(() => ""),
|
||||
execute("/usr/sbin/ip", ["neigh", "show", "dev", config.NETWORK_HOTSPOT_INTERFACE]).catch(() => "")
|
||||
]);
|
||||
const states = new Map<string, { mac: string | null; state: string }>();
|
||||
for (const line of neighbors.trim().split("\n")) {
|
||||
const match = line.match(/^(\S+)(?:\s+lladdr\s+(\S+))?\s+(\S+)$/);
|
||||
if (match?.[1] && match[3]) states.set(match[1], { mac: match[2] ?? null, state: match[3] });
|
||||
}
|
||||
const result = new Map<string, Client>();
|
||||
for (const line of leases.trim().split("\n")) {
|
||||
const [expiry, mac, ip, hostname] = line.trim().split(/\s+/);
|
||||
if (!expiry || !mac || !ip) continue;
|
||||
const neighbor = states.get(ip);
|
||||
result.set(ip, {
|
||||
ipAddress: ip,
|
||||
macAddress: mac,
|
||||
hostname: hostname && hostname !== "*" ? hostname.slice(0, 128) : null,
|
||||
leaseExpiresAt: Number.isFinite(Number(expiry)) ? new Date(Number(expiry) * 1000).toISOString() : null,
|
||||
state: neighbor?.state ?? "LEASED",
|
||||
connected: Boolean(neighbor && !["FAILED", "INCOMPLETE"].includes(neighbor.state))
|
||||
});
|
||||
}
|
||||
for (const [ip, neighbor] of states) {
|
||||
if (!result.has(ip)) {
|
||||
result.set(ip, {
|
||||
ipAddress: ip,
|
||||
macAddress: neighbor.mac ?? "unknown",
|
||||
hostname: null,
|
||||
leaseExpiresAt: null,
|
||||
state: neighbor.state,
|
||||
connected: !["FAILED", "INCOMPLETE"].includes(neighbor.state)
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...result.values()].sort((left, right) => Number(right.connected) - Number(left.connected) || left.ipAddress.localeCompare(right.ipAddress));
|
||||
}
|
||||
|
||||
async function dnsQueries(): Promise<{ available: boolean; queries: DnsQuery[] }> {
|
||||
try {
|
||||
const output = await execute(
|
||||
"/usr/bin/journalctl",
|
||||
["--unit", "NetworkManager.service", "--since", "-15 minutes", "--output", "json", "--no-pager"],
|
||||
1024 * 1024
|
||||
);
|
||||
const queries: DnsQuery[] = [];
|
||||
for (const line of output.trim().split("\n")) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as { MESSAGE?: unknown; __REALTIME_TIMESTAMP?: unknown };
|
||||
if (typeof entry.MESSAGE !== "string") continue;
|
||||
const match = entry.MESSAGE.match(/query\[([^\]]+)]\s+(\S+)\s+from\s+(10\.42\.0\.\d+)/);
|
||||
if (!match?.[1] || !match[2] || !match[3]) continue;
|
||||
const microseconds = Number(entry.__REALTIME_TIMESTAMP);
|
||||
queries.push({
|
||||
timestamp: Number.isFinite(microseconds) ? new Date(Math.floor(microseconds / 1000)).toISOString() : new Date().toISOString(),
|
||||
clientAddress: match[3],
|
||||
type: match[1].slice(0, 16),
|
||||
name: match[2].slice(0, 253)
|
||||
});
|
||||
} catch {
|
||||
// Ignore unrelated or malformed journal records.
|
||||
}
|
||||
}
|
||||
return { available: true, queries: queries.slice(-250).reverse() };
|
||||
} catch {
|
||||
return { available: false, queries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function value(line: string, name: string): string | null {
|
||||
return line.match(new RegExp(`(?:^|\\s)${name}=([^\\s]+)`))?.[1] ?? null;
|
||||
}
|
||||
|
||||
function numericValue(line: string, name: string): number | null {
|
||||
const raw = value(line, name);
|
||||
if (raw === null) return null;
|
||||
const parsed = Number(raw);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
async function flows(): Promise<{ available: boolean; flows: Flow[] }> {
|
||||
try {
|
||||
const output = await execute("/usr/sbin/conntrack", ["--list"], 1024 * 1024);
|
||||
const parsed: Flow[] = [];
|
||||
for (const line of output.trim().split("\n")) {
|
||||
const protocol = line.match(/^(tcp|udp)\s/)?.[1] as "tcp" | "udp" | undefined;
|
||||
const sourceAddress = value(line, "src");
|
||||
const destinationAddress = value(line, "dst");
|
||||
const sourcePort = numericValue(line, "sport");
|
||||
const destinationPort = numericValue(line, "dport");
|
||||
if (!protocol || !sourceAddress?.startsWith("10.42.0.") || !destinationAddress || sourcePort === null || destinationPort === null) continue;
|
||||
const state = protocol === "tcp" ? line.match(/^tcp\s+\d+\s+\d+\s+(\S+)/)?.[1] ?? "UNKNOWN" : "ACTIVE";
|
||||
const packets = numericValue(line, "packets");
|
||||
const bytes = numericValue(line, "bytes");
|
||||
parsed.push({
|
||||
protocol,
|
||||
state,
|
||||
sourceAddress,
|
||||
sourcePort,
|
||||
destinationAddress,
|
||||
destinationPort,
|
||||
packets,
|
||||
bytes
|
||||
});
|
||||
}
|
||||
return { available: true, flows: parsed.slice(0, 150) };
|
||||
} catch {
|
||||
return { available: false, flows: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(): Promise<Activity> {
|
||||
const [clientList, dns, activeFlows] = await Promise.all([clients(), dnsQueries(), flows()]);
|
||||
const messages: string[] = [];
|
||||
if (!dns.available) messages.push("DNS query metadata is unavailable; restart the hotspot after installing its dnsmasq configuration.");
|
||||
if (!activeFlows.available) messages.push("Active flows are unavailable because conntrack is not installed or accessible.");
|
||||
return networkActivitySchema.parse({
|
||||
collectedAt: new Date().toISOString(),
|
||||
clients: clientList,
|
||||
dnsQueries: dns.queries,
|
||||
flows: activeFlows.flows,
|
||||
dnsAvailable: dns.available,
|
||||
flowsAvailable: activeFlows.available,
|
||||
messages
|
||||
});
|
||||
}
|
||||
|
||||
async function publish(activity: Activity): Promise<void> {
|
||||
const temporary = `${config.NETWORK_ACTIVITY_PATH}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(activity)}\n`, { mode: 0o644 });
|
||||
await chmod(temporary, 0o644);
|
||||
await rename(temporary, config.NETWORK_ACTIVITY_PATH);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => { stopped = true; });
|
||||
process.on("SIGTERM", () => { stopped = true; });
|
||||
while (!stopped) {
|
||||
try {
|
||||
await publish(await collect());
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : "Network activity collection failed");
|
||||
}
|
||||
await sleep(config.NETWORK_OBSERVER_INTERVAL_MS);
|
||||
}
|
||||
@@ -21,7 +21,8 @@ function testConfig(): AppConfig {
|
||||
enabled: false,
|
||||
statusPath: `/tmp/pi-car-companion-test-${process.pid}-missing-network-status.json`,
|
||||
commandPath: `/tmp/pi-car-companion-test-${process.pid}-network-command.json`,
|
||||
configurationRequestPath: `/tmp/pi-car-companion-test-${process.pid}-network-configuration.json`
|
||||
configurationRequestPath: `/tmp/pi-car-companion-test-${process.pid}-network-configuration.json`,
|
||||
activityPath: `/tmp/pi-car-companion-test-${process.pid}-network-activity.json`
|
||||
},
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
@@ -189,6 +190,7 @@ describe("authentication boundary", () => {
|
||||
it("keeps network status and controls behind authentication and explicit configuration", async () => {
|
||||
const app = await createApp();
|
||||
expect((await app.inject({ method: "GET", url: "/api/network" })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: "GET", url: "/api/network/activity" })).statusCode).toBe(401);
|
||||
|
||||
const token = await csrf(app);
|
||||
await setup(app, token);
|
||||
@@ -203,6 +205,9 @@ describe("authentication boundary", () => {
|
||||
const status = await app.inject({ method: "GET", url: "/api/network", headers: { cookie } });
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(status.json()).toMatchObject({ supported: false, enabled: false, mode: "disabled" });
|
||||
const activity = await app.inject({ method: "GET", url: "/api/network/activity", headers: { cookie } });
|
||||
expect(activity.statusCode).toBe(200);
|
||||
expect(activity.json()).toMatchObject({ supported: false, clients: [], dnsQueries: [], flows: [] });
|
||||
|
||||
const action = await app.inject({
|
||||
method: "POST",
|
||||
|
||||
@@ -27,7 +27,8 @@ describe("ADB configuration", () => {
|
||||
enabled: false,
|
||||
statusPath: "/var/lib/pi-car-companion/network-status.json",
|
||||
commandPath: "/var/lib/pi-car-companion/network-command.json",
|
||||
configurationRequestPath: "/var/lib/pi-car-companion/network-configuration-request.json"
|
||||
configurationRequestPath: "/var/lib/pi-car-companion/network-configuration-request.json",
|
||||
activityPath: "/var/lib/pi-car-companion/network-activity.json"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readNetworkActivity } from "../src/network-activity.js";
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("network activity snapshot", () => {
|
||||
it("accepts only bounded sanitized observer output", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-activity-test-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "activity.json");
|
||||
await writeFile(path, JSON.stringify({
|
||||
collectedAt: "2026-07-31T20:00:00.000Z",
|
||||
clients: [{ ipAddress: "10.42.0.20", macAddress: "aa:bb:cc:dd:ee:ff", hostname: "head-unit", leaseExpiresAt: null, state: "REACHABLE", connected: true }],
|
||||
dnsQueries: [{ timestamp: "2026-07-31T20:00:00.000Z", clientAddress: "10.42.0.20", type: "A", name: "example.com" }],
|
||||
flows: [{ protocol: "tcp", state: "ESTABLISHED", sourceAddress: "10.42.0.20", sourcePort: 50000, destinationAddress: "1.1.1.1", destinationPort: 443, packets: 4, bytes: 1000 }],
|
||||
dnsAvailable: true,
|
||||
flowsAvailable: true,
|
||||
messages: []
|
||||
}));
|
||||
await expect(readNetworkActivity(path)).resolves.toMatchObject({ supported: true, clients: [{ hostname: "head-unit" }] });
|
||||
});
|
||||
|
||||
it("does not expose malformed observer files", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-car-activity-test-"));
|
||||
directories.push(directory);
|
||||
const path = join(directory, "activity.json");
|
||||
await writeFile(path, '{"clients":"not-an-array"}');
|
||||
await expect(readNetworkActivity(path)).resolves.toMatchObject({ supported: false, clients: [] });
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,8 @@ function config(directory: string, enabled = true): AppConfig {
|
||||
enabled,
|
||||
statusPath: join(directory, "status.json"),
|
||||
commandPath: join(directory, "command.json"),
|
||||
configurationRequestPath: join(directory, "configuration.json")
|
||||
configurationRequestPath: join(directory, "configuration.json"),
|
||||
activityPath: join(directory, "activity.json")
|
||||
},
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
|
||||
@@ -22,7 +22,8 @@ function config(updateStatusPath: string, versionFile: string): AppConfig {
|
||||
enabled: false,
|
||||
statusPath: join(tmpdir(), "missing-network-status.json"),
|
||||
commandPath: join(tmpdir(), "network-command.json"),
|
||||
configurationRequestPath: join(tmpdir(), "network-configuration.json")
|
||||
configurationRequestPath: join(tmpdir(), "network-configuration.json"),
|
||||
activityPath: join(tmpdir(), "network-activity.json")
|
||||
},
|
||||
adb: { enabled: false, executable: "adb", serial: null, timeoutMs: 3_000 }
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=Pi Car Companion network activity observer
|
||||
After=NetworkManager.service pi-car-companion-network.service
|
||||
Wants=NetworkManager.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/pi-car-companion/companion.env
|
||||
Environment=HOME=/var/lib/pi-car-companion
|
||||
WorkingDirectory=/opt/pi-car-companion/current
|
||||
ExecStart=/usr/bin/node /opt/pi-car-companion/current/server/dist/network-observer.js
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStopSec=15s
|
||||
UMask=0027
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=true
|
||||
SystemCallArchitectures=native
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
ReadWritePaths=/var/lib/pi-car-companion
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
+90
-4
@@ -19,11 +19,13 @@ import {
|
||||
getAuthState,
|
||||
getStatus,
|
||||
getNetworkStatus,
|
||||
getNetworkActivity,
|
||||
getUpdateStatus,
|
||||
post,
|
||||
type AuthState,
|
||||
type SystemStatus,
|
||||
type NetworkStatus,
|
||||
type NetworkActivity,
|
||||
type UpdateStatus
|
||||
} from "./api";
|
||||
|
||||
@@ -152,7 +154,7 @@ function AccountScreen({
|
||||
}
|
||||
|
||||
function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () => Promise<void> }) {
|
||||
const [page, setPage] = useState<"home" | "settings">("home");
|
||||
const [page, setPage] = useState<"home" | "network" | "settings">("home");
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [connection, setConnection] = useState<"connecting" | "live" | "offline">("connecting");
|
||||
@@ -194,6 +196,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
<div className="brand brand-compact"><Gauge size={30} weight="duotone" /><span>Pi Companion</span></div>
|
||||
<nav aria-label="Primary navigation">
|
||||
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => setPage("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
|
||||
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => setPage("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
|
||||
<button className="nav-item" disabled><TerminalWindow size={24} /><span>Jobs</span><small>Soon</small></button>
|
||||
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => setPage("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
|
||||
</nav>
|
||||
@@ -202,8 +205,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
<main className="dashboard">
|
||||
<header className="dashboard-header">
|
||||
<div>
|
||||
<p className="eyebrow">{page === "home" ? "System overview" : "Companion settings"}</p>
|
||||
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : "Settings"}</h1>
|
||||
<p className="eyebrow">{page === "home" ? "System overview" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
|
||||
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "network" ? "Network activity" : "Settings"}</h1>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<div className={`connection-state ${connection}`}>
|
||||
@@ -218,12 +221,95 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
|
||||
{page === "home" ? <>
|
||||
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
|
||||
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
|
||||
</> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkActivityPage() {
|
||||
const [activity, setActivity] = useState<NetworkActivity | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setActivity(await getNetworkActivity());
|
||||
setError("");
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Network activity is unavailable");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
if (paused) return;
|
||||
const interval = window.setInterval(() => void refresh(), 2_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [paused, refresh]);
|
||||
|
||||
const connected = activity?.clients.filter((client) => client.connected).length ?? 0;
|
||||
return (
|
||||
<div className="activity-layout">
|
||||
<section className="activity-summary">
|
||||
<div><span>Connected clients</span><strong>{connected}</strong></div>
|
||||
<div><span>Recent DNS queries</span><strong>{activity?.dnsQueries.length ?? 0}</strong></div>
|
||||
<div><span>Active flows</span><strong>{activity?.flows.length ?? 0}</strong></div>
|
||||
<div><span>Snapshot</span><strong>{activity?.supported ? new Date(activity.collectedAt).toLocaleTimeString() : "Unavailable"}</strong></div>
|
||||
<button className="secondary-button text-button" onClick={() => setPaused((value) => !value)}>{paused ? "Resume live view" : "Pause live view"}</button>
|
||||
</section>
|
||||
|
||||
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
|
||||
{activity?.messages.map((message) => <div className="activity-message" key={message}><WarningCircle size={18} />{message}</div>)}
|
||||
|
||||
<section className="activity-clients activity-panel">
|
||||
<div className="activity-heading"><div><h2>Hotspot clients</h2><p>DHCP leases and current neighbor reachability.</p></div></div>
|
||||
<div className="client-grid">
|
||||
{activity?.clients.length ? activity.clients.map((client) => (
|
||||
<div className="client-row" key={`${client.ipAddress}-${client.macAddress}`}>
|
||||
<span className={`client-dot ${client.connected ? "online" : ""}`} aria-hidden="true" />
|
||||
<div><strong>{client.hostname ?? client.ipAddress}</strong><small>{client.hostname ? client.ipAddress : client.macAddress}</small></div>
|
||||
<code>{client.hostname ? client.macAddress : client.state}</code>
|
||||
<span>{client.connected ? "Connected" : client.state}</span>
|
||||
</div>
|
||||
)) : <ActivityEmpty text="No hotspot clients observed." />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="activity-columns">
|
||||
<section className="activity-panel">
|
||||
<div className="activity-heading"><div><h2>DNS requests</h2><p>Query metadata from clients during the last 15 minutes.</p></div><span>{activity?.dnsAvailable ? "Live" : "Unavailable"}</span></div>
|
||||
<div className="activity-table-wrap">
|
||||
<table className="activity-table"><thead><tr><th>Time</th><th>Client</th><th>Type</th><th>Name</th></tr></thead><tbody>
|
||||
{activity?.dnsQueries.map((query, index) => <tr key={`${query.timestamp}-${query.clientAddress}-${query.name}-${index}`}><td>{new Date(query.timestamp).toLocaleTimeString()}</td><td><code>{query.clientAddress}</code></td><td>{query.type}</td><td className="request-name">{query.name}</td></tr>)}
|
||||
</tbody></table>
|
||||
{!activity?.dnsQueries.length && <ActivityEmpty text="No DNS requests captured yet." />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="activity-panel">
|
||||
<div className="activity-heading"><div><h2>Active connections</h2><p>Destination metadata only; encrypted content remains private.</p></div><span>{activity?.flowsAvailable ? "Live" : "Unavailable"}</span></div>
|
||||
<div className="activity-table-wrap">
|
||||
<table className="activity-table"><thead><tr><th>Client</th><th>Protocol</th><th>Destination</th><th>State</th></tr></thead><tbody>
|
||||
{activity?.flows.map((flow, index) => <tr key={`${flow.sourceAddress}-${flow.sourcePort}-${flow.destinationAddress}-${flow.destinationPort}-${index}`}><td><code>{flow.sourceAddress}</code></td><td>{flow.protocol.toUpperCase()}</td><td><code>{flow.destinationAddress}:{flow.destinationPort}</code><small>{portLabel(flow.destinationPort)}</small></td><td>{flow.state}</td></tr>)}
|
||||
</tbody></table>
|
||||
{!activity?.flows.length && <ActivityEmpty text="No active client connections observed." />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p className="activity-privacy">This page records bounded connection metadata only. It does not capture payloads, passwords, cookies, or HTTPS paths.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityEmpty({ text }: { text: string }) {
|
||||
return <div className="activity-empty">{text}</div>;
|
||||
}
|
||||
|
||||
function portLabel(port: number): string {
|
||||
return ({ 53: "DNS", 80: "HTTP", 123: "NTP", 443: "HTTPS", 853: "DNS over TLS" } as Record<number, string>)[port] ?? "";
|
||||
}
|
||||
|
||||
function SettingsPage({ csrfToken }: { csrfToken: string }) {
|
||||
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||
const [network, setNetwork] = useState<NetworkStatus | null>(null);
|
||||
|
||||
@@ -89,6 +89,17 @@ export type NetworkAdapterStatus = {
|
||||
address: string | null;
|
||||
};
|
||||
|
||||
export type NetworkActivity = {
|
||||
supported: boolean;
|
||||
collectedAt: string;
|
||||
clients: Array<{ ipAddress: string; macAddress: string; hostname: string | null; leaseExpiresAt: string | null; state: string; connected: boolean }>;
|
||||
dnsQueries: Array<{ timestamp: string; clientAddress: string; type: string; name: string }>;
|
||||
flows: Array<{ protocol: "tcp" | "udp"; state: string; sourceAddress: string; sourcePort: number; destinationAddress: string; destinationPort: number; packets: number | null; bytes: number | null }>;
|
||||
dnsAvailable: boolean;
|
||||
flowsAvailable: boolean;
|
||||
messages: string[];
|
||||
};
|
||||
|
||||
type ErrorResponse = { error?: { code?: string; message?: string } };
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -135,3 +146,7 @@ export async function getUpdateStatus(): Promise<UpdateStatus> {
|
||||
export async function getNetworkStatus(): Promise<NetworkStatus> {
|
||||
return parseResponse<NetworkStatus>(await fetch("/api/network", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function getNetworkActivity(): Promise<NetworkActivity> {
|
||||
return parseResponse<NetworkActivity>(await fetch("/api/network/activity", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
@@ -103,6 +103,38 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.system-strip div { min-width: 0; display: grid; }
|
||||
.system-strip strong { overflow: hidden; color: #d9dfd3; font-size: 0.88rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.activity-layout { display: grid; gap: 18px; }
|
||||
.activity-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; align-items: center; gap: 1px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.activity-summary > div { min-height: 82px; display: grid; align-content: center; gap: 7px; padding: 15px 20px; background: var(--surface); }
|
||||
.activity-summary span { color: var(--muted); font-size: 0.76rem; }
|
||||
.activity-summary strong { font: 750 1.25rem/1 ui-monospace, "Cascadia Code", monospace; }
|
||||
.activity-summary button { margin: 0 16px; }
|
||||
.activity-message { display: flex; align-items: center; gap: 9px; padding: 11px 14px; color: #ffd5cf; background: #321d19; border: 1px solid #6b332a; border-radius: 9px; font-size: 0.8rem; }
|
||||
.activity-panel { min-width: 0; padding: 21px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.activity-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.activity-heading h2 { margin: 0 0 5px; font-size: 1.12rem; }
|
||||
.activity-heading p { margin: 0; color: var(--muted); font-size: 0.78rem; }
|
||||
.activity-heading > span { color: var(--accent); font: 0.72rem/1.4 ui-monospace, "Cascadia Code", monospace; text-transform: uppercase; }
|
||||
.client-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.client-row { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; gap: 11px; min-height: 55px; padding: 9px 13px; background: #121610; border-radius: 8px; }
|
||||
.client-row div { min-width: 0; display: grid; gap: 3px; }
|
||||
.client-row strong, .client-row small, .client-row code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.client-row strong { font-size: 0.86rem; }
|
||||
.client-row small, .client-row > span:last-child { color: var(--muted); font-size: 0.7rem; }
|
||||
.client-row code { color: #cbd3c5; font-size: 0.72rem; }
|
||||
.client-dot { width: 8px; height: 8px; border-radius: 50%; background: #777f72; }
|
||||
.client-dot.online { background: var(--accent); box-shadow: 0 0 0 4px rgba(185, 231, 105, 0.09); }
|
||||
.activity-columns { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: 18px; }
|
||||
.activity-table-wrap { max-height: 285px; overflow: auto; border: 1px solid var(--line); border-radius: 9px; }
|
||||
.activity-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
|
||||
.activity-table th { position: sticky; top: 0; z-index: 1; padding: 10px 11px; color: var(--muted); background: #151914; font-size: 0.67rem; letter-spacing: 0.06em; text-align: left; text-transform: uppercase; }
|
||||
.activity-table td { max-width: 280px; padding: 10px 11px; border-top: 1px solid var(--line); white-space: nowrap; }
|
||||
.activity-table td.request-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
.activity-table td small { display: block; margin-top: 3px; color: var(--muted); }
|
||||
.activity-table code { color: #d7ded2; font-size: 0.72rem; }
|
||||
.activity-empty { min-height: 74px; display: grid; place-items: center; padding: 18px; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||
.activity-privacy { margin: 0; color: var(--muted); font-size: 0.72rem; text-align: right; }
|
||||
|
||||
.settings-layout { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.7fr); gap: 18px; align-items: start; }
|
||||
.settings-panel { padding: 28px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||
.network-panel { grid-column: 1 / -1; }
|
||||
@@ -167,6 +199,9 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.network-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.hotspot-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.activity-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.activity-summary button { margin: 12px; }
|
||||
.activity-columns { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
@@ -178,6 +213,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.sidebar { position: fixed; top: auto; bottom: 0; z-index: 10; width: 100%; height: 72px; flex-direction: row; padding: 8px 12px; border-top: 1px solid var(--line); border-right: 0; }
|
||||
.brand-compact { display: none; }
|
||||
.sidebar nav { display: flex; flex: 1; }
|
||||
.sidebar nav .nav-item:disabled { display: none; }
|
||||
.nav-item { width: 72px; min-height: 56px; }
|
||||
.nav-item.logout { margin: 0 0 0 auto; }
|
||||
.dashboard { padding: 26px 18px; }
|
||||
@@ -192,6 +228,7 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.update-facts { grid-template-columns: 1fr; }
|
||||
.network-overview { grid-template-columns: 1fr; }
|
||||
.hotspot-fields { grid-template-columns: 1fr; }
|
||||
.activity-summary, .client-grid { grid-template-columns: 1fr; }
|
||||
.settings-actions, .dialog-actions { flex-direction: column; }
|
||||
.settings-actions button, .dialog-actions button { width: 100%; }
|
||||
.skeleton-grid { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in New Issue
Block a user