/** * @license * Copyright 2025-2026 Open Home Foundation * SPDX-License-Identifier: Apache-2.0 */ import { consume } from "@lit/context"; import "@material/web/divider/divider"; import { isTestNodeId, type BorderRouterEntry, type MatterClient, type MatterNode } from "@matter-server/ws-client"; import { mdiClose, mdiLinkVariantOff, mdiRefresh, mdiSignalCellular1, mdiSignalCellular2, mdiSignalCellular3, } from "@mdi/js"; import { LitElement, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { clientContext } from "../../client/client-context.js"; import "../../components/ha-svg-icon"; import { formatNodeAddressFromAny, getEffectiveFabricIndex } from "../../util/format_hex.js"; import { reducedMotionStyles } from "../../util/shared-styles.js"; import type { SignalLevel, ThreadEdgePair, ThreadExternalDevice } from "./network-types.js"; import type { NodeConnection } from "./network-utils.js"; import { decodeMeshcopStateBitmap, formatThreadVersion, getDeviceName, getNetworkType, getNodeConnectionsFromPairs, getRoutableDestinationsCount, getSignalColorFromRssi, getThreadChannel, getThreadExtendedAddressHex, getThreadRole, getThreadRoleName, getThreadVersion, getWiFiDiagnostics, getWiFiSecurityTypeName, getWiFiVersionName, stripMdnsHostname, } from "./network-utils.js"; import "./update-connections-dialog.js"; declare global { interface HTMLElementTagNameMap { "network-details": NetworkDetails; } } @customElement("network-details") export class NetworkDetails extends LitElement { @property() public selectedNodeId: number | string | null = null; @property({ type: Boolean }) public hideOfflineNodes = false; @property({ type: Boolean }) public hideWeakSignalEdges = false; @property({ type: Boolean }) public hideMediumSignalEdges = false; @property({ type: Boolean }) public hideStrongSignalEdges = false; @property({ type: Object }) public nodes: Record = {}; @property({ type: Object }) public unknownDevices: ReadonlyMap = new Map(); @property({ attribute: false }) public borderRouters: ReadonlyMap = new Map(); @property({ type: Object }) public wifiAccessPoints: Map = new Map(); @property({ type: Object }) public threadEdgePairs: Map = new Map(); @consume({ context: clientContext }) private client!: MatterClient; @state() private _showUpdateDialog: boolean = false; private _handleClose(): void { this.dispatchEvent( new CustomEvent("close", { bubbles: true, composed: true, }), ); } private _handleSelectNode(nodeId: number | string): void { this.dispatchEvent( new CustomEvent("select-node", { detail: { nodeId }, bubbles: true, composed: true, }), ); } /** Handle keyboard interaction for clickable elements (Enter/Space activates) */ private _handleKeyDown(event: KeyboardEvent, nodeId: number | string): void { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this._handleSelectNode(nodeId); } } private _getSignalIcon(level: SignalLevel): string { switch (level) { case "strong": return mdiSignalCellular3; case "medium": return mdiSignalCellular2; case "weak": return mdiSignalCellular1; case "none": return mdiLinkVariantOff; } } /** * Format a node ID as hex for Matter log format display. * Returns format like "@1:7b" for node ID 123. */ private _formatNodeIdHex(nodeId: number | bigint | string): string { // For unknown devices (not in nodes), we can't determine if it's a test node, // so we use the fabric index if available const node = this.nodes[String(nodeId)]; const isTestNode = node ? isTestNodeId(node.node_id) : false; const fabricIndex = getEffectiveFabricIndex(this.client?.serverInfo?.fabric_index, isTestNode); return formatNodeAddressFromAny(fabricIndex, nodeId); } private _getExternalDeviceLabel(conn: NodeConnection): TemplateResult { const device = this.unknownDevices.get(String(conn.connectedNodeId)); if (device?.kind === "br" && device.hostname) { return html`${stripMdnsHostname(device.hostname)}`; } return html`External: ${conn.extAddressHex}`; } private _renderWiFiInfo(node: MatterNode): TemplateResult | typeof nothing { const wifiDiag = getWiFiDiagnostics(node); if (!wifiDiag.bssid && wifiDiag.rssi === null) { return nothing; } const signalColor = getSignalColorFromRssi(wifiDiag.rssi); return html`

WiFi Network

${wifiDiag.bssid ? html`
BSSID: ${wifiDiag.bssid}
` : nothing} ${wifiDiag.rssi !== null ? html`
Signal: ${wifiDiag.rssi} dBm
` : nothing} ${wifiDiag.channel !== null ? html`
Channel: ${wifiDiag.channel}
` : nothing} ${wifiDiag.securityType !== null ? html`
Security: ${getWiFiSecurityTypeName(wifiDiag.securityType)}
` : nothing} ${wifiDiag.wifiVersion !== null ? html`
WiFi Version: ${getWiFiVersionName(wifiDiag.wifiVersion)}
` : nothing}
`; } private _renderThreadInfo(node: MatterNode): TemplateResult | typeof nothing { const threadRole = getThreadRole(node); const channel = getThreadChannel(node); const extAddressHex = getThreadExtendedAddressHex(node); const threadVersion = getThreadVersion(node); // Get connections from edge pairs with the same filter pipeline as the graph const nodeId = String(node.node_id); const connections = getNodeConnectionsFromPairs(nodeId, this.threadEdgePairs, this.nodes, { hideOfflineNodes: this.hideOfflineNodes, hideWeakSignalEdges: this.hideWeakSignalEdges, hideMediumSignalEdges: this.hideMediumSignalEdges, hideStrongSignalEdges: this.hideStrongSignalEdges, }); return html`

Thread Network

Role: ${getThreadRoleName(threadRole)}
${threadVersion !== undefined ? html`
Thread version: ${formatThreadVersion(threadVersion)}
` : nothing} ${channel !== undefined ? html`
Channel: ${channel}
` : nothing} ${extAddressHex ? html`
Extended Address: ${extAddressHex}
` : nothing}
Direct neighbors: ${connections.length}
${(() => { const routableCount = getRoutableDestinationsCount(node); return routableCount > 0 ? html`
Routable destinations: ${routableCount}
` : nothing; })()}
${connections.length > 0 ? html`

Connections (${connections.length})

${connections .toSorted((a, b) => { const score = (conn: NodeConnection): number => { if (conn.rssi !== null && conn.rssi !== undefined) { return conn.rssi; } if (conn.lqi !== null && conn.lqi !== undefined) { return conn.lqi; } return -Infinity; }; return score(b) - score(a); }) .map((conn: NodeConnection) => { return html`
this._handleSelectNode(conn.connectedNodeId)} @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, conn.connectedNodeId)} >
${conn.connectedNode ? html`Node ${conn.connectedNodeId} ${this._formatNodeIdHex( conn.connectedNodeId, )}: ${getDeviceName(conn.connectedNode)}` : this._getExternalDeviceLabel(conn)}
${conn.rssi !== null ? html`RSSI: ${conn.rssi} dBm` : nothing}${conn.rssi !== null && conn.lqi !== null ? ", " : nothing}${conn.lqi !== null ? html`LQI: ${conn.lqi}` : nothing}${conn.bidirectionalLqi !== undefined ? html`, Bidir: ${conn.bidirectionalLqi}` : nothing}${conn.pathCost !== undefined ? html`, Cost: ${conn.pathCost}` : nothing} ${conn.isReverseOnly ? html` ← one-way ` : !conn.isOutgoing ? html` (reverse) ` : nothing}
`; })}
` : nothing} `; } private _renderNodeInfo(node: MatterNode): TemplateResult | typeof nothing { const networkType = getNetworkType(node); return html`

Device Info

Name: ${getDeviceName(node)}
Vendor: ${node.vendorName ?? "Unknown"}
Product: ${node.productName ?? "Unknown"}
${node.serialNumber ? html`
Serial: ${node.serialNumber}
` : nothing}
Network: ${networkType.charAt(0).toUpperCase() + networkType.slice(1)}
Status: ${node.available ? "Online" : "Offline"}
${networkType === "thread" ? html` ${this._renderThreadInfo(node)} ` : nothing} ${networkType === "wifi" ? html` ${this._renderWiFiInfo(node)} ` : nothing} `; } private _renderUnknownDeviceInfo(deviceId: string): TemplateResult | typeof nothing { const device = this.unknownDevices.get(deviceId); if (!device || device.kind !== "unknown") { return html`

Unknown device data not available

`; } const unknown = device; return html`

Unknown Device

Type: ${unknown.isRouter ? "Router (external)" : "End Device (external)"}
Extended Address: ${unknown.extAddressHex}
${unknown.networkName !== undefined ? html`
Thread Network: ${unknown.networkName}
` : nothing} ${unknown.extendedPanIdHex !== undefined ? html`
Extended PAN ID: ${unknown.extendedPanIdHex}
` : nothing} ${unknown.bestRssi !== null ? html`
Best RSSI: ${unknown.bestRssi} dBm
` : nothing}
${this._renderExternalDeviceNeighbors(deviceId)}

This device appears in Thread neighbor tables but is not commissioned to this fabric. It may be a Thread Border Router whose Thread radio MAC differs from its MeshCoP border-agent ID (common with Apple and Aqara), or a device from another Matter ecosystem.

`; } /** * Neighbor list shared by external-device panels (unknown + BR). Uses the * same edge pairs as the graph so panel and graph agree on which links * survive filtering. Sorted by best RSSI/LQI signal, descending. */ private _renderExternalDeviceNeighbors(deviceId: string): TemplateResult | typeof nothing { const connections = getNodeConnectionsFromPairs(deviceId, this.threadEdgePairs, this.nodes, { hideOfflineNodes: this.hideOfflineNodes, hideWeakSignalEdges: this.hideWeakSignalEdges, hideMediumSignalEdges: this.hideMediumSignalEdges, hideStrongSignalEdges: this.hideStrongSignalEdges, }); if (connections.length === 0) return nothing; return html`

Neighbors (${connections.length})

${connections .toSorted((a, b) => { const score = (conn: NodeConnection): number => { if (conn.rssi !== null && conn.rssi !== undefined) return conn.rssi; if (conn.lqi !== null && conn.lqi !== undefined) return conn.lqi; return -Infinity; }; return score(b) - score(a); }) .map((conn: NodeConnection) => { if (!conn.connectedNode) return nothing; return html`
this._handleSelectNode(conn.connectedNodeId)} @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, conn.connectedNodeId)} >
Node ${conn.connectedNodeId} ${this._formatNodeIdHex(conn.connectedNodeId)}: ${getDeviceName(conn.connectedNode)}
${conn.rssi !== null ? html`RSSI: ${conn.rssi} dBm, ` : nothing} ${conn.lqi !== null ? html`LQI: ${conn.lqi}` : nothing}
`; })}
`; } /** * Identity rows for a Border Router (network name, vendor, model, Thread version, ext address). * Caller controls the surrounding
+ heading. */ private _renderBorderRouterIdentityRows(br: BorderRouterEntry, includeExtAddr: boolean): TemplateResult { return html` ${br.networkName ? html`
Network name: ${br.networkName}
` : nothing} ${br.vendorName ? html`
Vendor: ${br.vendorName}
` : nothing} ${br.modelName ? html`
Model: ${br.modelName}
` : nothing} ${br.threadVersion ? html`
Thread version: ${br.threadVersion}
` : nothing} ${includeExtAddr ? html`
Extended Address: ${br.extAddressHex}
` : nothing} `; } /** * Render the MeshCoP state bitmap as decoded fields (BBR role, connection mode, Thread * interface status, availability, ePSKc) plus the raw hex underneath. Reserved values are * rendered as numeric so a future spec extension stays visible. */ private _renderStateBitmap(hex: string | undefined): TemplateResult | typeof nothing { if (hex === undefined) return nothing; const decoded = decodeMeshcopStateBitmap(hex); if (decoded === undefined) { return html`
State bitmap: ${hex}
`; } const stateParts = new Array(); stateParts.push(decoded.bbr ? `BBR (${decoded.bbrFunction ?? "?"})` : "not BBR"); if (decoded.threadRole !== undefined) { stateParts.push(`Thread ${decoded.threadRole}`); } if (decoded.threadInterfaceStatus !== undefined) { stateParts.push(decoded.threadInterfaceStatus); } return html`
State: ${stateParts.join(", ")}
Connection: ${decoded.connectionMode ?? `reserved (${decoded.connectionModeValue})`}
Availability: ${decoded.availability ?? `reserved (${decoded.availabilityValue})`}
ePSKc: ${decoded.epskcSupported ? "supported" : "not supported"}
${decoded.multiAilStateValue !== 0 ? html`
Multi-AIL: ${decoded.multiAilState ?? `reserved (${decoded.multiAilStateValue})`}
` : nothing}
State bitmap (raw): ${hex}
`; } /** * Network-info rows for a Border Router (extended PAN ID, partition, timestamps, state, domain, agent ID). * Returns nothing if no fields are populated, so the caller can skip the surrounding section. */ private _renderBorderRouterNetworkRows(br: BorderRouterEntry): TemplateResult | typeof nothing { const hasAny = br.extendedPanIdHex !== undefined || br.partitionIdHex !== undefined || br.activeTimestampHex !== undefined || br.stateBitmapHex !== undefined || br.domainName !== undefined || br.borderAgentIdHex !== undefined; if (!hasAny) return nothing; return html` ${br.extendedPanIdHex ? html`
Extended PAN ID: ${br.extendedPanIdHex}
` : nothing} ${br.partitionIdHex ? html`
Partition ID: ${br.partitionIdHex}
` : nothing} ${br.activeTimestampHex ? html`
Active timestamp: ${br.activeTimestampHex}
` : nothing} ${this._renderStateBitmap(br.stateBitmapHex)} ${br.domainName ? html`
Domain: ${br.domainName}
` : nothing} ${br.borderAgentIdHex ? html`
Border agent ID: ${br.borderAgentIdHex}
` : nothing} `; } /** * Address rows for a Border Router (hostname, IPs, ports, sources). */ private _renderBorderRouterAddressRows(br: BorderRouterEntry): TemplateResult | typeof nothing { const hasAny = br.hostname !== undefined || br.addresses.length > 0 || br.meshcopPort !== undefined || br.trelPort !== undefined || br.sources.length > 0; if (!hasAny) return nothing; return html` ${br.hostname ? html`
Hostname: ${br.hostname}
` : nothing} ${br.addresses.map( addr => html`
Address: ${addr}
`, )} ${br.meshcopPort !== undefined ? html`
meshcop port: ${br.meshcopPort}
` : nothing} ${br.trelPort !== undefined ? html`
trel port: ${br.trelPort}
` : nothing} ${br.sources.length > 0 ? html`
Sources: ${br.sources.join(", ")}
` : nothing} `; } private _renderBorderRouterInfo(deviceId: string): TemplateResult | typeof nothing { const device = this.unknownDevices.get(deviceId); if (!device || device.kind !== "br") { return html`

Border router data not available

`; } const br = device; const networkRows = this._renderBorderRouterNetworkRows(br); const addressRows = this._renderBorderRouterAddressRows(br); return html`

Border Router

${this._renderBorderRouterIdentityRows(br, true)} ${br.bestRssi !== null ? html`
Best RSSI: ${br.bestRssi} dBm
` : nothing}
${networkRows !== nothing ? html`

Thread Network

${networkRows}
` : nothing} ${addressRows !== nothing ? html`

Addresses

${addressRows}
` : nothing} ${this._renderExternalDeviceNeighbors(deviceId)} `; } /** * Determine if update connections button should be shown. */ private _canUpdateConnections(): boolean { if (this.selectedNodeId === null) return false; // WiFi APs: no update possible (not a Matter device) const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_"); if (isAccessPoint) return false; // External devices (unknown or BR) gate on having online seenBy nodes const isExternal = typeof this.selectedNodeId === "string" && (this.selectedNodeId.startsWith("unknown_") || this.selectedNodeId.startsWith("br_")); if (isExternal) { return this._getOnlineSeenByNodes().length > 0; } // Regular nodes: check network type (no update for ethernet) const node = this.nodes[this.selectedNodeId.toString()]; if (!node) return false; const networkType = getNetworkType(node); if (networkType === "ethernet" || networkType === "unknown") return false; return true; } /** * Get the type of the currently selected node for dialog variant. */ private _getSelectedNodeType(): "online" | "offline" | "unknown" { if ( typeof this.selectedNodeId === "string" && (this.selectedNodeId.startsWith("unknown_") || this.selectedNodeId.startsWith("br_")) ) { return "unknown"; } const node = this.nodes[this.selectedNodeId!.toString()]; if (!node || node.available === false) { return "offline"; } return "online"; } /** * Get online neighbors for a Thread node. */ private _getOnlineNeighbors(nodeId: string): string[] { const node = this.nodes[nodeId]; if (!node) return []; const networkType = getNetworkType(node); if (networkType === "thread") { // Use edge pairs without filters to get ALL connections (for update dialog) const connections = getNodeConnectionsFromPairs(nodeId, this.threadEdgePairs, this.nodes); return connections .filter(conn => { if (conn.isUnknown) return false; return conn.connectedNode?.available === true; }) .map(conn => String(conn.connectedNodeId)); } // WiFi nodes don't have peer connections (just AP) return []; } /** * Get online nodes that see an unknown device. */ private _getOnlineSeenByNodes(): string[] { if ( typeof this.selectedNodeId !== "string" || (!this.selectedNodeId.startsWith("unknown_") && !this.selectedNodeId.startsWith("br_")) ) { return []; } const device = this.unknownDevices.get(this.selectedNodeId); if (!device) return []; return device.seenBy.filter(nodeId => { const node = this.nodes[nodeId.toString()]; return node?.available === true; }); } /** * Get the name of the selected node for display in dialog. */ private _getSelectedNodeName(): string { if (typeof this.selectedNodeId === "string") { if (this.selectedNodeId.startsWith("br_")) { const device = this.unknownDevices.get(this.selectedNodeId); if (!device || device.kind !== "br") return "Border Router"; const label = device.networkName ?? device.vendorName ?? "Border Router"; return `${label} (${device.extAddressHex.slice(-8)})`; } if (this.selectedNodeId.startsWith("unknown_")) { const device = this.unknownDevices.get(this.selectedNodeId); if (!device || device.kind !== "unknown") return "External Device"; const typeLabel = device.isRouter ? "External Router" : "External Device"; return `${typeLabel} (${device.extAddressHex.slice(-8)})`; } } const node = this.nodes[this.selectedNodeId!.toString()]; return node ? getDeviceName(node) : "Unknown"; } private _handleUpdateConnections(): void { this._showUpdateDialog = true; } private _handleDialogClose(): void { this._showUpdateDialog = false; this.dispatchEvent( new CustomEvent("connections-updated", { bubbles: true, composed: true, }), ); } private _renderWiFiAccessPointInfo(apId: string): TemplateResult | typeof nothing { const ap = this.wifiAccessPoints.get(apId); if (!ap) { return html`

Access point data not available

`; } return html`

WiFi Access Point

BSSID: ${ap.bssid}
Connected devices: ${ap.connectedNodes.length}
${ap.connectedNodes.length > 0 ? html`

Connected Nodes

${ap.connectedNodes .toSorted((a, b) => { const nodeA = this.nodes[a.toString()]; const nodeB = this.nodes[b.toString()]; const rssiA = nodeA ? (getWiFiDiagnostics(nodeA)?.rssi ?? -Infinity) : -Infinity; const rssiB = nodeB ? (getWiFiDiagnostics(nodeB)?.rssi ?? -Infinity) : -Infinity; return rssiB - rssiA; }) .map(nodeId => { const node = this.nodes[nodeId.toString()]; if (!node) return nothing; const wifiDiag = getWiFiDiagnostics(node); const signalColor = getSignalColorFromRssi(wifiDiag.rssi); return html`
this._handleSelectNode(nodeId)} @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)} >
Node ${nodeId} ${this._formatNodeIdHex(nodeId)}: ${getDeviceName(node)}
${wifiDiag.rssi !== null ? html`
${wifiDiag.rssi} dBm
` : nothing}
`; })}
` : nothing}

This is a WiFi access point that Matter devices connect to. It is not a Matter device itself.

`; } /** * Annotation shown on a commissioned Thread node that is also a discovered Border Router. * Mirrors the BR Identity/Network/Addresses sections, sans the redundant ext-address row. */ private _renderCommissionedNodeBorderRouterAnnotation(node: MatterNode): TemplateResult | typeof nothing { const xaHex = getThreadExtendedAddressHex(node); if (!xaHex) return nothing; const br = this.borderRouters.get(xaHex); if (!br) return nothing; const networkRows = this._renderBorderRouterNetworkRows(br); const addressRows = this._renderBorderRouterAddressRows(br); return html`

Also a Border Router

${this._renderBorderRouterIdentityRows(br, false)} ${networkRows !== nothing ? html`
Thread Network
${networkRows} ` : nothing} ${addressRows !== nothing ? html`
Addresses
${addressRows} ` : nothing}
`; } override render() { if (this.selectedNodeId === null) { return html`

Select a device to view details

`; } // Check if this is an unknown Thread device const isUnknown = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_"); if (isUnknown) { const onlineSeenByNodes = this._getOnlineSeenByNodes(); return html`

External Device

${onlineSeenByNodes.length > 0 ? html` ` : nothing}
${this._renderUnknownDeviceInfo(this.selectedNodeId as string)}
${this._showUpdateDialog ? html` ` : nothing} `; } // Check if this is a discovered Border Router (mDNS-enriched external device) const borderRouterId = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("br_") ? this.selectedNodeId : null; if (borderRouterId !== null) { const onlineSeenByNodes = this._getOnlineSeenByNodes(); return html`

Border Router

${onlineSeenByNodes.length > 0 ? html` ` : nothing}
${this._renderBorderRouterInfo(borderRouterId)}
${this._showUpdateDialog ? html` ` : nothing} `; } // Check if this is a WiFi access point const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_"); if (isAccessPoint) { return html`

Access Point

${this._renderWiFiAccessPointInfo(this.selectedNodeId as string)}
`; } const node = this.nodes[this.selectedNodeId.toString()]; if (!node) { return html`

Device not found

`; } const canUpdate = this._canUpdateConnections(); const nodeType = this._getSelectedNodeType(); const onlineNeighbors = this._getOnlineNeighbors(String(this.selectedNodeId)); return html`

Node ${this.selectedNodeId} ${this._formatNodeIdHex(this.selectedNodeId)}

${canUpdate ? html` ` : nothing}
${this._renderNodeInfo(node)}${this._renderCommissionedNodeBorderRouterAnnotation(node)}
${this._showUpdateDialog ? html` ` : nothing} `; } static override styles = [ reducedMotionStyles, css` :host { display: block; height: 100%; } .empty-state { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--md-sys-color-on-surface-variant, #666); text-align: center; padding: 24px; } .details-panel { display: flex; flex-direction: column; height: 100%; background-color: var(--md-sys-color-surface, #fff); border-radius: 8px; border: 1px solid var(--md-sys-color-outline-variant, #ccc); overflow: hidden; } .header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background-color: var(--md-sys-color-surface-container, #f5f5f5); border-bottom: 1px solid var(--md-sys-color-outline-variant, #ccc); } .header h3 { margin: 0; font-size: 1rem; font-weight: 500; color: var(--md-sys-color-on-surface, #333); } .node-id-hex { font-size: 0.75em; font-weight: 400; color: var(--md-sys-color-on-surface-variant, #666); font-family: var(--monospace-font, monospace); } .header-actions { display: flex; align-items: center; gap: 4px; } .action-button { background: none; border: none; padding: 4px; cursor: pointer; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .action-button:hover { background-color: var(--md-sys-color-surface-container-high, #e8e8e8); } .action-button ha-svg-icon { --icon-primary-color: var(--md-sys-color-on-surface-variant, #666); } .close-button { background: none; border: none; padding: 4px; cursor: pointer; border-radius: 50%; display: flex; align-items: center; justify-content: center; } .close-button:hover { background-color: var(--md-sys-color-surface-container-high, #e8e8e8); } .close-button ha-svg-icon { --icon-primary-color: var(--md-sys-color-on-surface-variant, #666); } .content { flex: 1; overflow-y: auto; padding: 0; } .section { padding: 16px; } .section h4 { margin: 0 0 12px 0; font-size: 0.875rem; font-weight: 500; color: var(--md-sys-color-primary, #6200ee); text-transform: uppercase; letter-spacing: 0.5px; } .subsection-label { margin: 12px 0 4px 0; font-size: 0.75rem; font-weight: 500; color: var(--md-sys-color-on-surface-variant, #666); text-transform: uppercase; letter-spacing: 0.4px; } .info-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 0.875rem; } .label { color: var(--md-sys-color-on-surface-variant, #666); } .value { color: var(--md-sys-color-on-surface, #333); font-weight: 500; text-align: right; word-break: break-all; max-width: 60%; } .value.mono { font-family: var(--monospace-font, monospace); font-size: 0.8rem; } .status-online { color: var(--signal-color-strong, #4caf50); } .status-offline { color: var(--danger-color, #f44336); } .neighbors-list { display: flex; flex-direction: column; gap: 8px; } .neighbor-item { display: flex; align-items: flex-start; gap: 12px; padding: 8px; background-color: var(--md-sys-color-surface-container, #f5f5f5); border-radius: 4px; } .neighbor-item.clickable { cursor: pointer; transition: background-color 0.15s; } .neighbor-item.clickable:hover { background-color: var(--md-sys-color-surface-container-high, #e8e8e8); } .neighbor-item ha-svg-icon { flex-shrink: 0; margin-top: 2px; } .neighbor-info { flex: 1; min-width: 0; } .neighbor-name { font-size: 0.875rem; color: var(--md-sys-color-on-surface, #333); word-break: break-word; } .neighbor-signal { font-size: 0.75rem; color: var(--md-sys-color-on-surface-variant, #666); margin-top: 2px; } .direction-hint { font-style: italic; opacity: 0.8; } .direction-hint.reverse-only { font-style: normal; font-weight: 500; opacity: 1; color: var(--md-sys-color-error, #b3261e); cursor: help; } .route-info { color: var(--md-sys-color-tertiary, #7d5260); font-size: 0.85em; } .footer { padding: 12px 16px; border-top: 1px solid var(--md-sys-color-outline-variant, #ccc); text-align: center; } .view-link { color: var(--md-sys-color-primary, #6200ee); text-decoration: none; font-size: 0.875rem; font-weight: 500; } .view-link:hover { text-decoration: underline; } md-divider { --md-divider-color: var(--md-sys-color-outline-variant, #ccc); } .hint-text { font-size: 0.8rem; color: var(--md-sys-color-on-surface-variant, #666); line-height: 1.4; margin: 0; } .connected-nodes-list { display: flex; flex-direction: column; gap: 8px; } .connected-node-item { display: flex; align-items: center; justify-content: space-between; padding: 8px; background-color: var(--md-sys-color-surface-container, #f5f5f5); border-radius: 4px; } .connected-node-item.clickable { cursor: pointer; transition: background-color 0.15s; } .connected-node-item.clickable:hover { background-color: var(--md-sys-color-surface-container-high, #e8e8e8); } .connected-node-item .node-name { font-size: 0.875rem; color: var(--md-sys-color-on-surface, #333); word-break: break-word; } .connected-node-item .node-signal { font-size: 0.8rem; font-weight: 500; flex-shrink: 0; margin-left: 8px; } `, ]; }