From 5d0a66bb528352a9e967bbd5019c2f9639200388 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Thu, 21 May 2026 06:29:14 +0800 Subject: [PATCH] Initial Commit : Network Map --- lib/models/network/network_device.dart | 274 +++++ lib/models/network/network_import.dart | 175 +++ lib/models/network/network_link.dart | 112 ++ lib/models/network/network_location.dart | 93 ++ lib/models/network/network_port.dart | 148 +++ lib/models/network/network_site.dart | 52 + lib/models/network/network_vlan.dart | 40 + lib/models/network/topology_graph.dart | 143 +++ lib/providers/connectivity_provider.dart | 2 +- .../network_map/network_devices_provider.dart | 246 ++++ .../network_map/network_import_provider.dart | 155 +++ .../network_map/network_sites_provider.dart | 104 ++ .../network_topology_provider.dart | 43 + lib/routing/app_router.dart | 90 ++ .../network_map/layout/sugiyama_layout.dart | 1043 +++++++++++++++++ .../network_map_device_edit_screen.dart | 465 ++++++++ .../network_map_device_screen.dart | 203 ++++ .../network_map_import_review_screen.dart | 259 ++++ .../network_map_import_screen.dart | 170 +++ .../network_map_overview_screen.dart | 380 ++++++ .../network_map/network_map_site_screen.dart | 86 ++ .../network_map/network_map_vlan_screen.dart | 147 +++ .../network_map/widgets/device_node.dart | 174 +++ .../network_map/widgets/import_diff_view.dart | 326 ++++++ .../network_map/widgets/port_edit_dialog.dart | 236 ++++ .../network_map/widgets/port_list_tile.dart | 75 ++ .../network_map/widgets/topology_canvas.dart | 823 +++++++++++++ lib/widgets/app_shell.dart | 8 + supabase/DEPLOYMENT.md | 31 + supabase/functions/extract_topology/deno.json | 6 + supabase/functions/extract_topology/index.ts | 345 ++++++ ...60519000000_network_infrastructure_map.sql | 329 ++++++ 32 files changed, 6782 insertions(+), 1 deletion(-) create mode 100644 lib/models/network/network_device.dart create mode 100644 lib/models/network/network_import.dart create mode 100644 lib/models/network/network_link.dart create mode 100644 lib/models/network/network_location.dart create mode 100644 lib/models/network/network_port.dart create mode 100644 lib/models/network/network_site.dart create mode 100644 lib/models/network/network_vlan.dart create mode 100644 lib/models/network/topology_graph.dart create mode 100644 lib/providers/network_map/network_devices_provider.dart create mode 100644 lib/providers/network_map/network_import_provider.dart create mode 100644 lib/providers/network_map/network_sites_provider.dart create mode 100644 lib/providers/network_map/network_topology_provider.dart create mode 100644 lib/screens/network_map/layout/sugiyama_layout.dart create mode 100644 lib/screens/network_map/network_map_device_edit_screen.dart create mode 100644 lib/screens/network_map/network_map_device_screen.dart create mode 100644 lib/screens/network_map/network_map_import_review_screen.dart create mode 100644 lib/screens/network_map/network_map_import_screen.dart create mode 100644 lib/screens/network_map/network_map_overview_screen.dart create mode 100644 lib/screens/network_map/network_map_site_screen.dart create mode 100644 lib/screens/network_map/network_map_vlan_screen.dart create mode 100644 lib/screens/network_map/widgets/device_node.dart create mode 100644 lib/screens/network_map/widgets/import_diff_view.dart create mode 100644 lib/screens/network_map/widgets/port_edit_dialog.dart create mode 100644 lib/screens/network_map/widgets/port_list_tile.dart create mode 100644 lib/screens/network_map/widgets/topology_canvas.dart create mode 100644 supabase/DEPLOYMENT.md create mode 100644 supabase/functions/extract_topology/deno.json create mode 100644 supabase/functions/extract_topology/index.ts create mode 100644 supabase/migrations/20260519000000_network_infrastructure_map.sql diff --git a/lib/models/network/network_device.dart b/lib/models/network/network_device.dart new file mode 100644 index 00000000..bf19313f --- /dev/null +++ b/lib/models/network/network_device.dart @@ -0,0 +1,274 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +enum NetworkDeviceKind { + router, + switchDevice, + ap, + firewall, + server, + endpoint, + patchPanel, + other; + + String get wire { + switch (this) { + case NetworkDeviceKind.router: + return 'router'; + case NetworkDeviceKind.switchDevice: + return 'switch'; + case NetworkDeviceKind.ap: + return 'ap'; + case NetworkDeviceKind.firewall: + return 'firewall'; + case NetworkDeviceKind.server: + return 'server'; + case NetworkDeviceKind.endpoint: + return 'endpoint'; + case NetworkDeviceKind.patchPanel: + return 'patch_panel'; + case NetworkDeviceKind.other: + return 'other'; + } + } + + String get label { + switch (this) { + case NetworkDeviceKind.router: + return 'Router'; + case NetworkDeviceKind.switchDevice: + return 'Switch'; + case NetworkDeviceKind.ap: + return 'Access Point'; + case NetworkDeviceKind.firewall: + return 'Firewall'; + case NetworkDeviceKind.server: + return 'Server'; + case NetworkDeviceKind.endpoint: + return 'Endpoint'; + case NetworkDeviceKind.patchPanel: + return 'Patch Panel'; + case NetworkDeviceKind.other: + return 'Other'; + } + } + + static NetworkDeviceKind fromWire(String? value) { + switch (value) { + case 'router': + return NetworkDeviceKind.router; + case 'switch': + return NetworkDeviceKind.switchDevice; + case 'ap': + return NetworkDeviceKind.ap; + case 'firewall': + return NetworkDeviceKind.firewall; + case 'server': + return NetworkDeviceKind.server; + case 'endpoint': + return NetworkDeviceKind.endpoint; + case 'patch_panel': + return NetworkDeviceKind.patchPanel; + default: + return NetworkDeviceKind.other; + } + } +} + +enum NetworkDeviceRole { + core, + distribution, + access, + edge, + endpoint; + + String get wire { + switch (this) { + case NetworkDeviceRole.core: + return 'core'; + case NetworkDeviceRole.distribution: + return 'distribution'; + case NetworkDeviceRole.access: + return 'access'; + case NetworkDeviceRole.edge: + return 'edge'; + case NetworkDeviceRole.endpoint: + return 'endpoint'; + } + } + + String get label { + switch (this) { + case NetworkDeviceRole.core: + return 'Core'; + case NetworkDeviceRole.distribution: + return 'Distribution'; + case NetworkDeviceRole.access: + return 'Access'; + case NetworkDeviceRole.edge: + return 'Edge'; + case NetworkDeviceRole.endpoint: + return 'Endpoint'; + } + } + + static NetworkDeviceRole? fromWire(String? value) { + switch (value) { + case 'core': + return NetworkDeviceRole.core; + case 'distribution': + return NetworkDeviceRole.distribution; + case 'access': + return NetworkDeviceRole.access; + case 'edge': + return NetworkDeviceRole.edge; + case 'endpoint': + return NetworkDeviceRole.endpoint; + default: + return null; + } + } +} + +enum NetworkImportSource { + manual, + aiImport, + agent; + + String get wire { + switch (this) { + case NetworkImportSource.manual: + return 'manual'; + case NetworkImportSource.aiImport: + return 'ai_import'; + case NetworkImportSource.agent: + return 'agent'; + } + } + + static NetworkImportSource fromWire(String? value) { + switch (value) { + case 'ai_import': + return NetworkImportSource.aiImport; + case 'agent': + return NetworkImportSource.agent; + default: + return NetworkImportSource.manual; + } + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'network_devices'), +) +class NetworkDevice extends OfflineFirstWithSupabaseModel { + final String id; + final String name; + final NetworkDeviceKind kind; + final NetworkDeviceRole? role; + final String? vendor; + final String? model; + final String? serial; + final String? mgmtIp; + final String? mac; + final String? locationId; + final NetworkImportSource importSource; + final String? notes; + final DateTime createdAt; + final DateTime updatedAt; + + NetworkDevice({ + required this.id, + required this.name, + required this.kind, + this.role, + this.vendor, + this.model, + this.serial, + this.mgmtIp, + this.mac, + this.locationId, + this.importSource = NetworkImportSource.manual, + this.notes, + required this.createdAt, + required this.updatedAt, + }); + + factory NetworkDevice.fromMap(Map map) { + return NetworkDevice( + id: map['id'] as String, + name: (map['name'] as String?) ?? '', + kind: NetworkDeviceKind.fromWire(map['kind'] as String?), + role: NetworkDeviceRole.fromWire(map['role'] as String?), + vendor: map['vendor'] as String?, + model: map['model'] as String?, + serial: map['serial'] as String?, + mgmtIp: map['mgmt_ip']?.toString(), + mac: map['mac']?.toString(), + locationId: map['location_id'] as String?, + importSource: NetworkImportSource.fromWire(map['import_source'] as String?), + notes: map['notes'] as String?, + createdAt: + DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(), + updatedAt: + DateTime.tryParse(map['updated_at']?.toString() ?? '') ?? DateTime.now(), + ); + } + + Map toInsertMap() => { + 'name': name, + 'kind': kind.wire, + if (role != null) 'role': role!.wire, + if (vendor != null) 'vendor': vendor, + if (model != null) 'model': model, + if (serial != null) 'serial': serial, + if (mgmtIp != null) 'mgmt_ip': mgmtIp, + if (mac != null) 'mac': mac, + if (locationId != null) 'location_id': locationId, + 'import_source': importSource.wire, + if (notes != null) 'notes': notes, + }; + + Map toUpdateMap() => { + 'name': name, + 'kind': kind.wire, + 'role': role?.wire, + 'vendor': vendor, + 'model': model, + 'serial': serial, + 'mgmt_ip': mgmtIp, + 'mac': mac, + 'location_id': locationId, + 'notes': notes, + }; + + NetworkDevice copyWith({ + String? name, + NetworkDeviceKind? kind, + NetworkDeviceRole? role, + String? vendor, + String? model, + String? serial, + String? mgmtIp, + String? mac, + String? locationId, + String? notes, + }) { + return NetworkDevice( + id: id, + name: name ?? this.name, + kind: kind ?? this.kind, + role: role ?? this.role, + vendor: vendor ?? this.vendor, + model: model ?? this.model, + serial: serial ?? this.serial, + mgmtIp: mgmtIp ?? this.mgmtIp, + mac: mac ?? this.mac, + locationId: locationId ?? this.locationId, + importSource: importSource, + notes: notes ?? this.notes, + createdAt: createdAt, + updatedAt: updatedAt, + ); + } +} diff --git a/lib/models/network/network_import.dart b/lib/models/network/network_import.dart new file mode 100644 index 00000000..f58cabeb --- /dev/null +++ b/lib/models/network/network_import.dart @@ -0,0 +1,175 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +enum NetworkImportFileKind { + pdf, + image, + vsdx, + csv, + xlsx, + docx, + other; + + String get wire { + switch (this) { + case NetworkImportFileKind.pdf: + return 'pdf'; + case NetworkImportFileKind.image: + return 'image'; + case NetworkImportFileKind.vsdx: + return 'vsdx'; + case NetworkImportFileKind.csv: + return 'csv'; + case NetworkImportFileKind.xlsx: + return 'xlsx'; + case NetworkImportFileKind.docx: + return 'docx'; + case NetworkImportFileKind.other: + return 'other'; + } + } + + static NetworkImportFileKind fromExtension(String filename) { + final lower = filename.toLowerCase(); + if (lower.endsWith('.pdf')) return NetworkImportFileKind.pdf; + if (lower.endsWith('.vsdx') || lower.endsWith('.vsd')) { + return NetworkImportFileKind.vsdx; + } + if (lower.endsWith('.csv')) return NetworkImportFileKind.csv; + if (lower.endsWith('.xlsx') || lower.endsWith('.xls')) { + return NetworkImportFileKind.xlsx; + } + if (lower.endsWith('.docx') || lower.endsWith('.doc')) { + return NetworkImportFileKind.docx; + } + if (lower.endsWith('.png') || + lower.endsWith('.jpg') || + lower.endsWith('.jpeg') || + lower.endsWith('.webp') || + lower.endsWith('.heic')) { + return NetworkImportFileKind.image; + } + return NetworkImportFileKind.other; + } +} + +enum NetworkImportStatus { + pending, + extracting, + review, + applied, + discarded, + failed; + + String get wire { + switch (this) { + case NetworkImportStatus.pending: + return 'pending'; + case NetworkImportStatus.extracting: + return 'extracting'; + case NetworkImportStatus.review: + return 'review'; + case NetworkImportStatus.applied: + return 'applied'; + case NetworkImportStatus.discarded: + return 'discarded'; + case NetworkImportStatus.failed: + return 'failed'; + } + } + + static NetworkImportStatus fromWire(String? value) { + switch (value) { + case 'extracting': + return NetworkImportStatus.extracting; + case 'review': + return NetworkImportStatus.review; + case 'applied': + return NetworkImportStatus.applied; + case 'discarded': + return NetworkImportStatus.discarded; + case 'failed': + return NetworkImportStatus.failed; + default: + return NetworkImportStatus.pending; + } + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'network_imports'), +) +class NetworkImport extends OfflineFirstWithSupabaseModel { + final String id; + final String storagePath; + final NetworkImportFileKind fileKind; + final NetworkImportStatus status; + final Map? aiResult; + final String? errorMessage; + final List appliedDeviceIds; + final List appliedLinkIds; + final DateTime? appliedAt; + final String createdBy; + final DateTime createdAt; + + NetworkImport({ + required this.id, + required this.storagePath, + required this.fileKind, + required this.status, + this.aiResult, + this.errorMessage, + this.appliedDeviceIds = const [], + this.appliedLinkIds = const [], + this.appliedAt, + required this.createdBy, + required this.createdAt, + }); + + factory NetworkImport.fromMap(Map map) { + List idList(dynamic raw) { + if (raw is List) { + return raw.map((v) => v.toString()).toList(); + } + return const []; + } + + return NetworkImport( + id: map['id'] as String, + storagePath: (map['storage_path'] as String?) ?? '', + fileKind: _fileKindFromWire(map['file_kind'] as String?), + status: NetworkImportStatus.fromWire(map['status'] as String?), + aiResult: map['ai_result'] is Map + ? map['ai_result'] as Map + : null, + errorMessage: map['error_message'] as String?, + appliedDeviceIds: idList(map['applied_device_ids']), + appliedLinkIds: idList(map['applied_link_ids']), + appliedAt: map['applied_at'] == null + ? null + : DateTime.tryParse(map['applied_at']?.toString() ?? ''), + createdBy: map['created_by'] as String, + createdAt: + DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(), + ); + } + + static NetworkImportFileKind _fileKindFromWire(String? value) { + switch (value) { + case 'pdf': + return NetworkImportFileKind.pdf; + case 'image': + return NetworkImportFileKind.image; + case 'vsdx': + return NetworkImportFileKind.vsdx; + case 'csv': + return NetworkImportFileKind.csv; + case 'xlsx': + return NetworkImportFileKind.xlsx; + case 'docx': + return NetworkImportFileKind.docx; + default: + return NetworkImportFileKind.other; + } + } +} diff --git a/lib/models/network/network_link.dart b/lib/models/network/network_link.dart new file mode 100644 index 00000000..b6be81bc --- /dev/null +++ b/lib/models/network/network_link.dart @@ -0,0 +1,112 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +enum NetworkLinkKind { + copper, + fiber, + wireless, + virtual, + unknown; + + String get wire { + switch (this) { + case NetworkLinkKind.copper: + return 'copper'; + case NetworkLinkKind.fiber: + return 'fiber'; + case NetworkLinkKind.wireless: + return 'wireless'; + case NetworkLinkKind.virtual: + return 'virtual'; + case NetworkLinkKind.unknown: + return 'unknown'; + } + } + + String get label { + switch (this) { + case NetworkLinkKind.copper: + return 'Copper'; + case NetworkLinkKind.fiber: + return 'Fiber'; + case NetworkLinkKind.wireless: + return 'Wireless'; + case NetworkLinkKind.virtual: + return 'Virtual'; + case NetworkLinkKind.unknown: + return 'Unknown'; + } + } + + static NetworkLinkKind? fromWire(String? value) { + switch (value) { + case 'copper': + return NetworkLinkKind.copper; + case 'fiber': + return NetworkLinkKind.fiber; + case 'wireless': + return NetworkLinkKind.wireless; + case 'virtual': + return NetworkLinkKind.virtual; + case 'unknown': + return NetworkLinkKind.unknown; + default: + return null; + } + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'network_links'), +) +class NetworkLink extends OfflineFirstWithSupabaseModel { + final String id; + final String portA; + final String portB; + final NetworkLinkKind? linkKind; + final String? cableLabel; + final String? notes; + final DateTime createdAt; + + NetworkLink({ + required this.id, + required this.portA, + required this.portB, + this.linkKind, + this.cableLabel, + this.notes, + required this.createdAt, + }); + + factory NetworkLink.fromMap(Map map) { + return NetworkLink( + id: map['id'] as String, + portA: map['port_a'] as String, + portB: map['port_b'] as String, + linkKind: NetworkLinkKind.fromWire(map['link_kind'] as String?), + cableLabel: map['cable_label'] as String?, + notes: map['notes'] as String?, + createdAt: + DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(), + ); + } + + /// Insert helper that enforces the canonical port_a < port_b ordering + /// required by the database CHECK constraint. + static Map insertMapFor({ + required String portA, + required String portB, + NetworkLinkKind? linkKind, + String? cableLabel, + String? notes, + }) { + final ordered = portA.compareTo(portB) < 0 ? [portA, portB] : [portB, portA]; + return { + 'port_a': ordered[0], + 'port_b': ordered[1], + 'link_kind': ?linkKind?.wire, + 'cable_label': ?cableLabel, + 'notes': ?notes, + }; + } +} diff --git a/lib/models/network/network_location.dart b/lib/models/network/network_location.dart new file mode 100644 index 00000000..59fea43a --- /dev/null +++ b/lib/models/network/network_location.dart @@ -0,0 +1,93 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +enum NetworkLocationKind { + building, + floor, + room, + rack, + wallJack, + other; + + String get wire { + switch (this) { + case NetworkLocationKind.building: + return 'building'; + case NetworkLocationKind.floor: + return 'floor'; + case NetworkLocationKind.room: + return 'room'; + case NetworkLocationKind.rack: + return 'rack'; + case NetworkLocationKind.wallJack: + return 'wall_jack'; + case NetworkLocationKind.other: + return 'other'; + } + } + + static NetworkLocationKind fromWire(String? value) { + switch (value) { + case 'building': + return NetworkLocationKind.building; + case 'floor': + return NetworkLocationKind.floor; + case 'room': + return NetworkLocationKind.room; + case 'rack': + return NetworkLocationKind.rack; + case 'wall_jack': + return NetworkLocationKind.wallJack; + default: + return NetworkLocationKind.other; + } + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'network_locations'), +) +class NetworkLocation extends OfflineFirstWithSupabaseModel { + final String id; + final String siteId; + final String? parentId; + final String name; + final NetworkLocationKind kind; + final String? positionLabel; + final String? notes; + final DateTime createdAt; + + NetworkLocation({ + required this.id, + required this.siteId, + this.parentId, + required this.name, + required this.kind, + this.positionLabel, + this.notes, + required this.createdAt, + }); + + factory NetworkLocation.fromMap(Map map) { + return NetworkLocation( + id: map['id'] as String, + siteId: map['site_id'] as String, + parentId: map['parent_id'] as String?, + name: (map['name'] as String?) ?? '', + kind: NetworkLocationKind.fromWire(map['kind'] as String?), + positionLabel: map['position_label'] as String?, + notes: map['notes'] as String?, + createdAt: + DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(), + ); + } + + Map toInsertMap() => { + 'site_id': siteId, + if (parentId != null) 'parent_id': parentId, + 'name': name, + 'kind': kind.wire, + if (positionLabel != null) 'position_label': positionLabel, + if (notes != null) 'notes': notes, + }; +} diff --git a/lib/models/network/network_port.dart b/lib/models/network/network_port.dart new file mode 100644 index 00000000..5454ae68 --- /dev/null +++ b/lib/models/network/network_port.dart @@ -0,0 +1,148 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +enum NetworkPortKind { + rj45, + sfp, + sfpPlus, + console, + power, + wireless, + virtual, + other; + + String get wire { + switch (this) { + case NetworkPortKind.rj45: + return 'rj45'; + case NetworkPortKind.sfp: + return 'sfp'; + case NetworkPortKind.sfpPlus: + return 'sfp_plus'; + case NetworkPortKind.console: + return 'console'; + case NetworkPortKind.power: + return 'power'; + case NetworkPortKind.wireless: + return 'wireless'; + case NetworkPortKind.virtual: + return 'virtual'; + case NetworkPortKind.other: + return 'other'; + } + } + + String get label { + switch (this) { + case NetworkPortKind.rj45: + return 'RJ45'; + case NetworkPortKind.sfp: + return 'SFP'; + case NetworkPortKind.sfpPlus: + return 'SFP+'; + case NetworkPortKind.console: + return 'Console'; + case NetworkPortKind.power: + return 'Power'; + case NetworkPortKind.wireless: + return 'Wireless'; + case NetworkPortKind.virtual: + return 'Virtual'; + case NetworkPortKind.other: + return 'Other'; + } + } + + static NetworkPortKind? fromWire(String? value) { + switch (value) { + case 'rj45': + return NetworkPortKind.rj45; + case 'sfp': + return NetworkPortKind.sfp; + case 'sfp_plus': + return NetworkPortKind.sfpPlus; + case 'console': + return NetworkPortKind.console; + case 'power': + return NetworkPortKind.power; + case 'wireless': + return NetworkPortKind.wireless; + case 'virtual': + return NetworkPortKind.virtual; + case 'other': + return NetworkPortKind.other; + default: + return null; + } + } +} + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'network_ports'), +) +class NetworkPort extends OfflineFirstWithSupabaseModel { + final String id; + final String deviceId; + final String portNumber; + final NetworkPortKind? portKind; + final int? speedMbps; + final int? accessVlan; + final bool isTrunk; + final List trunkVlans; + final String? notes; + + NetworkPort({ + required this.id, + required this.deviceId, + required this.portNumber, + this.portKind, + this.speedMbps, + this.accessVlan, + this.isTrunk = false, + this.trunkVlans = const [], + this.notes, + }); + + factory NetworkPort.fromMap(Map map) { + final rawTrunk = map['trunk_vlans']; + final trunk = []; + if (rawTrunk is List) { + for (final v in rawTrunk) { + if (v is int) { + trunk.add(v); + } else if (v is num) { + trunk.add(v.toInt()); + } else { + final parsed = int.tryParse(v?.toString() ?? ''); + if (parsed != null) trunk.add(parsed); + } + } + } + return NetworkPort( + id: map['id'] as String, + deviceId: map['device_id'] as String, + portNumber: (map['port_number'] as String?) ?? '', + portKind: NetworkPortKind.fromWire(map['port_kind'] as String?), + speedMbps: map['speed_mbps'] is int + ? map['speed_mbps'] as int + : int.tryParse(map['speed_mbps']?.toString() ?? ''), + accessVlan: map['access_vlan'] is int + ? map['access_vlan'] as int + : int.tryParse(map['access_vlan']?.toString() ?? ''), + isTrunk: (map['is_trunk'] as bool?) ?? false, + trunkVlans: trunk, + notes: map['notes'] as String?, + ); + } + + Map toInsertMap() => { + 'device_id': deviceId, + 'port_number': portNumber, + if (portKind != null) 'port_kind': portKind!.wire, + if (speedMbps != null) 'speed_mbps': speedMbps, + if (accessVlan != null) 'access_vlan': accessVlan, + 'is_trunk': isTrunk, + if (trunkVlans.isNotEmpty) 'trunk_vlans': trunkVlans, + if (notes != null) 'notes': notes, + }; +} diff --git a/lib/models/network/network_site.dart b/lib/models/network/network_site.dart new file mode 100644 index 00000000..02f18cb5 --- /dev/null +++ b/lib/models/network/network_site.dart @@ -0,0 +1,52 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'network_sites'), +) +class NetworkSite extends OfflineFirstWithSupabaseModel { + final String id; + final String name; + final String? address; + final String? notes; + final DateTime createdAt; + final DateTime updatedAt; + final String? createdBy; + + NetworkSite({ + required this.id, + required this.name, + this.address, + this.notes, + required this.createdAt, + required this.updatedAt, + this.createdBy, + }); + + factory NetworkSite.fromMap(Map map) { + return NetworkSite( + id: map['id'] as String, + name: (map['name'] as String?) ?? '', + address: map['address'] as String?, + notes: map['notes'] as String?, + createdAt: + DateTime.tryParse(map['created_at']?.toString() ?? '') ?? DateTime.now(), + updatedAt: + DateTime.tryParse(map['updated_at']?.toString() ?? '') ?? DateTime.now(), + createdBy: map['created_by'] as String?, + ); + } + + Map toInsertMap() => { + 'name': name, + if (address != null) 'address': address, + if (notes != null) 'notes': notes, + if (createdBy != null) 'created_by': createdBy, + }; + + Map toUpdateMap() => { + 'name': name, + 'address': address, + 'notes': notes, + }; +} diff --git a/lib/models/network/network_vlan.dart b/lib/models/network/network_vlan.dart new file mode 100644 index 00000000..3a97ff18 --- /dev/null +++ b/lib/models/network/network_vlan.dart @@ -0,0 +1,40 @@ +import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; +import 'package:brick_supabase/brick_supabase.dart'; + +@ConnectOfflineFirstWithSupabase( + supabaseConfig: SupabaseSerializable(tableName: 'network_vlans'), +) +class NetworkVlan extends OfflineFirstWithSupabaseModel { + final String id; + final int vlanId; + final String name; + final String? description; + final String? color; + + NetworkVlan({ + required this.id, + required this.vlanId, + required this.name, + this.description, + this.color, + }); + + factory NetworkVlan.fromMap(Map map) { + return NetworkVlan( + id: map['id'] as String, + vlanId: map['vlan_id'] is int + ? map['vlan_id'] as int + : int.tryParse(map['vlan_id']?.toString() ?? '') ?? 0, + name: (map['name'] as String?) ?? '', + description: map['description'] as String?, + color: map['color'] as String?, + ); + } + + Map toInsertMap() => { + 'vlan_id': vlanId, + 'name': name, + if (description != null) 'description': description, + if (color != null) 'color': color, + }; +} diff --git a/lib/models/network/topology_graph.dart b/lib/models/network/topology_graph.dart new file mode 100644 index 00000000..d0fb0d12 --- /dev/null +++ b/lib/models/network/topology_graph.dart @@ -0,0 +1,143 @@ +import 'network_device.dart'; +import 'network_link.dart'; +import 'network_location.dart'; +import 'network_port.dart'; +import 'network_site.dart'; +import 'network_vlan.dart'; + +/// Client-side aggregate view-model assembled from raw network_* rows. +/// +/// The canvas consumes this directly rather than re-querying tables for every +/// pan/zoom event. Built once per site by [TopologyGraph.build] and cached in +/// the topology provider. +class TopologyNode { + TopologyNode({ + required this.device, + required this.ports, + }); + + final NetworkDevice device; + final List ports; + + String get id => device.id; +} + +class TopologyEdge { + TopologyEdge({ + required this.link, + required this.fromDeviceId, + required this.toDeviceId, + required this.fromPortLabel, + required this.toPortLabel, + }); + + final NetworkLink link; + final String fromDeviceId; + final String toDeviceId; + final String fromPortLabel; + final String toPortLabel; +} + +enum TopologyViewMode { + physical, + logical; + + String get label { + switch (this) { + case TopologyViewMode.physical: + return 'Physical'; + case TopologyViewMode.logical: + return 'Logical'; + } + } +} + +class TopologyGraph { + TopologyGraph({ + required this.site, + required this.locations, + required this.nodes, + required this.edges, + required this.vlans, + }); + + final NetworkSite? site; + final List locations; + final List nodes; + final List edges; + final List vlans; + + bool get isEmpty => nodes.isEmpty; + + /// Build a graph for a specific site from raw tables. + /// + /// Filters devices to those in the site's location subtree. If [site] is + /// null, all devices with no location are included (orphan view). + static TopologyGraph build({ + required NetworkSite? site, + required List allLocations, + required List allDevices, + required List allPorts, + required List allLinks, + required List allVlans, + }) { + final siteLocations = site == null + ? [] + : allLocations.where((l) => l.siteId == site.id).toList(); + final siteLocationIds = siteLocations.map((l) => l.id).toSet(); + + final siteDevices = site == null + ? allDevices.where((d) => d.locationId == null).toList() + : allDevices + .where((d) => + d.locationId != null && siteLocationIds.contains(d.locationId)) + .toList(); + final siteDeviceIds = siteDevices.map((d) => d.id).toSet(); + + final portsByDevice = >{}; + for (final port in allPorts) { + if (!siteDeviceIds.contains(port.deviceId)) continue; + portsByDevice.putIfAbsent(port.deviceId, () => []).add(port); + } + + final portToDevice = {}; + final portLabel = {}; + for (final port in allPorts) { + portToDevice[port.id] = port.deviceId; + portLabel[port.id] = port.portNumber; + } + + final nodes = siteDevices + .map((d) => TopologyNode( + device: d, + ports: portsByDevice[d.id] ?? const [], + )) + .toList(); + + final edges = []; + for (final link in allLinks) { + final fromDeviceId = portToDevice[link.portA]; + final toDeviceId = portToDevice[link.portB]; + if (fromDeviceId == null || toDeviceId == null) continue; + if (!siteDeviceIds.contains(fromDeviceId) || + !siteDeviceIds.contains(toDeviceId)) { + continue; + } + edges.add(TopologyEdge( + link: link, + fromDeviceId: fromDeviceId, + toDeviceId: toDeviceId, + fromPortLabel: portLabel[link.portA] ?? '', + toPortLabel: portLabel[link.portB] ?? '', + )); + } + + return TopologyGraph( + site: site, + locations: siteLocations, + nodes: nodes, + edges: edges, + vlans: allVlans, + ); + } +} diff --git a/lib/providers/connectivity_provider.dart b/lib/providers/connectivity_provider.dart index 6d6a846c..c005f9eb 100644 --- a/lib/providers/connectivity_provider.dart +++ b/lib/providers/connectivity_provider.dart @@ -64,7 +64,7 @@ class ConnectivityMonitor { } try { final res = await http - .head(Uri.parse('https://google.com')) + .head(Uri.parse('https://tasq.supabase.crmc.ph')) .timeout(const Duration(seconds: 5)); return res.statusCode < 500; } catch (_) { diff --git a/lib/providers/network_map/network_devices_provider.dart b/lib/providers/network_map/network_devices_provider.dart new file mode 100644 index 00000000..6e17ace1 --- /dev/null +++ b/lib/providers/network_map/network_devices_provider.dart @@ -0,0 +1,246 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/network/network_device.dart'; +import '../../models/network/network_link.dart'; +import '../../models/network/network_port.dart'; +import '../../models/network/network_vlan.dart'; +import '../supabase_provider.dart'; + +final networkDevicesProvider = FutureProvider>((ref) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client.from('network_devices').select().order('name'); + return (rows as List) + .map((r) => NetworkDevice.fromMap(r as Map)) + .toList(); +}); + +final networkPortsProvider = FutureProvider>((ref) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client.from('network_ports').select().order('port_number'); + return (rows as List) + .map((r) => NetworkPort.fromMap(r as Map)) + .toList(); +}); + +final networkLinksProvider = FutureProvider>((ref) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client.from('network_links').select(); + return (rows as List) + .map((r) => NetworkLink.fromMap(r as Map)) + .toList(); +}); + +final networkVlansProvider = FutureProvider>((ref) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client.from('network_vlans').select().order('vlan_id'); + return (rows as List) + .map((r) => NetworkVlan.fromMap(r as Map)) + .toList(); +}); + +final networkDeviceByIdProvider = + FutureProvider.family((ref, id) async { + final client = ref.watch(supabaseClientProvider); + final row = await client + .from('network_devices') + .select() + .eq('id', id) + .maybeSingle(); + if (row == null) return null; + return NetworkDevice.fromMap(row); +}); + +final networkPortsByDeviceProvider = + FutureProvider.family, String>((ref, deviceId) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client + .from('network_ports') + .select() + .eq('device_id', deviceId) + .order('port_number'); + return (rows as List) + .map((r) => NetworkPort.fromMap(r as Map)) + .toList(); +}); + +class NetworkDevicesController { + NetworkDevicesController(this.ref); + final Ref ref; + + Future createDevice({ + required String name, + required NetworkDeviceKind kind, + NetworkDeviceRole? role, + String? vendor, + String? model, + String? serial, + String? mgmtIp, + String? mac, + String? locationId, + NetworkImportSource importSource = NetworkImportSource.manual, + String? notes, + }) async { + final client = ref.read(supabaseClientProvider); + final inserted = await client + .from('network_devices') + .insert({ + 'name': name, + 'kind': kind.wire, + 'role': ?role?.wire, + 'vendor': ?vendor, + 'model': ?model, + 'serial': ?serial, + 'mgmt_ip': ?mgmtIp, + 'mac': ?mac, + 'location_id': ?locationId, + 'import_source': importSource.wire, + 'notes': ?notes, + }) + .select() + .single(); + ref.invalidate(networkDevicesProvider); + return NetworkDevice.fromMap(inserted); + } + + Future updateDevice(NetworkDevice device) async { + final client = ref.read(supabaseClientProvider); + await client + .from('network_devices') + .update(device.toUpdateMap()) + .eq('id', device.id); + ref.invalidate(networkDevicesProvider); + ref.invalidate(networkDeviceByIdProvider(device.id)); + } + + Future deleteDevice(String id) async { + final client = ref.read(supabaseClientProvider); + await client.from('network_devices').delete().eq('id', id); + ref.invalidate(networkDevicesProvider); + ref.invalidate(networkPortsProvider); + ref.invalidate(networkLinksProvider); + } + + Future createPort({ + required String deviceId, + required String portNumber, + NetworkPortKind? portKind, + int? speedMbps, + int? accessVlan, + bool isTrunk = false, + List trunkVlans = const [], + String? notes, + }) async { + final client = ref.read(supabaseClientProvider); + final inserted = await client + .from('network_ports') + .insert({ + 'device_id': deviceId, + 'port_number': portNumber, + 'port_kind': ?portKind?.wire, + 'speed_mbps': ?speedMbps, + 'access_vlan': ?accessVlan, + 'is_trunk': isTrunk, + if (trunkVlans.isNotEmpty) 'trunk_vlans': trunkVlans, + 'notes': ?notes, + }) + .select() + .single(); + ref.invalidate(networkPortsProvider); + ref.invalidate(networkPortsByDeviceProvider(deviceId)); + return NetworkPort.fromMap(inserted); + } + + Future deletePort(String id, {String? deviceId}) async { + final client = ref.read(supabaseClientProvider); + await client.from('network_ports').delete().eq('id', id); + ref.invalidate(networkPortsProvider); + ref.invalidate(networkLinksProvider); + if (deviceId != null) { + ref.invalidate(networkPortsByDeviceProvider(deviceId)); + } + } + + Future updatePort({ + required String id, + required String deviceId, + required String portNumber, + NetworkPortKind? portKind, + int? speedMbps, + int? accessVlan, + bool isTrunk = false, + List trunkVlans = const [], + String? notes, + }) async { + final client = ref.read(supabaseClientProvider); + await client.from('network_ports').update({ + 'port_number': portNumber, + 'port_kind': portKind?.wire, + 'speed_mbps': speedMbps, + 'access_vlan': accessVlan, + 'is_trunk': isTrunk, + 'trunk_vlans': trunkVlans.isEmpty ? null : trunkVlans, + 'notes': notes, + }).eq('id', id); + ref.invalidate(networkPortsProvider); + ref.invalidate(networkPortsByDeviceProvider(deviceId)); + } + + Future createLink({ + required String portA, + required String portB, + NetworkLinkKind? linkKind, + String? cableLabel, + String? notes, + }) async { + final client = ref.read(supabaseClientProvider); + final inserted = await client + .from('network_links') + .insert(NetworkLink.insertMapFor( + portA: portA, + portB: portB, + linkKind: linkKind, + cableLabel: cableLabel, + notes: notes, + )) + .select() + .single(); + ref.invalidate(networkLinksProvider); + return NetworkLink.fromMap(inserted); + } + + Future deleteLink(String id) async { + final client = ref.read(supabaseClientProvider); + await client.from('network_links').delete().eq('id', id); + ref.invalidate(networkLinksProvider); + } + + Future createVlan({ + required int vlanId, + required String name, + String? description, + String? color, + }) async { + final client = ref.read(supabaseClientProvider); + final inserted = await client + .from('network_vlans') + .insert({ + 'vlan_id': vlanId, + 'name': name, + 'description': ?description, + 'color': ?color, + }) + .select() + .single(); + ref.invalidate(networkVlansProvider); + return NetworkVlan.fromMap(inserted); + } + + Future deleteVlan(String id) async { + final client = ref.read(supabaseClientProvider); + await client.from('network_vlans').delete().eq('id', id); + ref.invalidate(networkVlansProvider); + } +} + +final networkDevicesControllerProvider = + Provider((ref) => NetworkDevicesController(ref)); diff --git a/lib/providers/network_map/network_import_provider.dart b/lib/providers/network_map/network_import_provider.dart new file mode 100644 index 00000000..5cfb34ad --- /dev/null +++ b/lib/providers/network_map/network_import_provider.dart @@ -0,0 +1,155 @@ +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../models/network/network_import.dart'; +import '../supabase_provider.dart'; + +final networkImportsProvider = FutureProvider>((ref) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client + .from('network_imports') + .select() + .order('created_at', ascending: false); + return (rows as List) + .map((r) => NetworkImport.fromMap(r as Map)) + .toList(); +}); + +final networkImportByIdProvider = + FutureProvider.family((ref, id) async { + final client = ref.watch(supabaseClientProvider); + final row = await client + .from('network_imports') + .select() + .eq('id', id) + .maybeSingle(); + if (row == null) return null; + return NetworkImport.fromMap(row); +}); + +class NetworkImportResult { + NetworkImportResult({required this.status, this.aiResult, this.errorMessage}); + + final NetworkImportStatus status; + final Map? aiResult; + final String? errorMessage; +} + +class NetworkImportController { + NetworkImportController(this.ref); + final Ref ref; + + /// Upload a source document, create a network_imports row, and trigger the + /// extract_topology Edge Function. Returns the import_id immediately so the + /// UI can navigate to the extraction-progress screen. + Future uploadAndQueueExtraction({ + required Uint8List bytes, + required String filename, + required String createdBy, + }) async { + final client = ref.read(supabaseClientProvider); + final fileKind = NetworkImportFileKind.fromExtension(filename); + final sanitized = filename + .replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_') + .substring(0, filename.length > 80 ? 80 : filename.length); + final timestamp = DateTime.now().millisecondsSinceEpoch; + final storagePath = '$createdBy/${timestamp}_$sanitized'; + + await client.storage.from('network-imports').uploadBinary( + storagePath, + bytes, + fileOptions: const FileOptions(upsert: false), + ); + + final inserted = await client + .from('network_imports') + .insert({ + 'storage_path': storagePath, + 'file_kind': fileKind.wire, + 'status': NetworkImportStatus.pending.wire, + 'created_by': createdBy, + }) + .select() + .single(); + + ref.invalidate(networkImportsProvider); + return inserted['id'] as String; + } + + /// Invoke the extract_topology Edge Function and return the parsed result. + /// + /// Caller is expected to already have polled/awaited the import being in + /// pending status. Long-running (5–60s) — UI should show progress. + Future runExtraction(String importId) async { + final client = ref.read(supabaseClientProvider); + final response = await client.functions.invoke( + 'extract_topology', + body: {'import_id': importId}, + ); + + final data = response.data; + if (data is! Map) { + return NetworkImportResult( + status: NetworkImportStatus.failed, + errorMessage: 'Unexpected response from extract_topology', + ); + } + final asMap = Map.from(data); + + if (asMap['error'] != null) { + ref.invalidate(networkImportByIdProvider(importId)); + ref.invalidate(networkImportsProvider); + return NetworkImportResult( + status: NetworkImportStatus.failed, + errorMessage: asMap['error'].toString(), + ); + } + + final statusStr = asMap['status']?.toString() ?? 'failed'; + final aiResult = asMap['ai_result'] is Map + ? Map.from(asMap['ai_result'] as Map) + : null; + + ref.invalidate(networkImportByIdProvider(importId)); + ref.invalidate(networkImportsProvider); + + return NetworkImportResult( + status: NetworkImportStatus.fromWire(statusStr), + aiResult: aiResult, + ); + } + + Future discard(String importId) async { + final client = ref.read(supabaseClientProvider); + await client + .from('network_imports') + .update({'status': NetworkImportStatus.discarded.wire}) + .eq('id', importId); + ref.invalidate(networkImportsProvider); + ref.invalidate(networkImportByIdProvider(importId)); + } + + Future markApplied({ + required String importId, + required List deviceIds, + required List linkIds, + }) async { + final client = ref.read(supabaseClientProvider); + await client + .from('network_imports') + .update({ + 'status': NetworkImportStatus.applied.wire, + 'applied_device_ids': deviceIds, + 'applied_link_ids': linkIds, + 'applied_at': DateTime.now().toUtc().toIso8601String(), + }) + .eq('id', importId); + ref.invalidate(networkImportsProvider); + ref.invalidate(networkImportByIdProvider(importId)); + } +} + +final networkImportControllerProvider = + Provider((ref) => NetworkImportController(ref)); diff --git a/lib/providers/network_map/network_sites_provider.dart b/lib/providers/network_map/network_sites_provider.dart new file mode 100644 index 00000000..6ecea7c5 --- /dev/null +++ b/lib/providers/network_map/network_sites_provider.dart @@ -0,0 +1,104 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/network/network_site.dart'; +import '../../models/network/network_location.dart'; +import '../supabase_provider.dart'; + +final networkSitesProvider = FutureProvider>((ref) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client.from('network_sites').select().order('name'); + return (rows as List) + .map((r) => NetworkSite.fromMap(r as Map)) + .toList(); +}); + +final networkLocationsProvider = FutureProvider>((ref) async { + final client = ref.watch(supabaseClientProvider); + final rows = await client.from('network_locations').select().order('name'); + return (rows as List) + .map((r) => NetworkLocation.fromMap(r as Map)) + .toList(); +}); + +final selectedSiteIdProvider = StateProvider((ref) => null); + +class NetworkSitesController { + NetworkSitesController(this.ref); + final Ref ref; + + Future createSite({ + required String name, + String? address, + String? notes, + String? createdBy, + }) async { + final client = ref.read(supabaseClientProvider); + final inserted = await client + .from('network_sites') + .insert({ + 'name': name, + 'address': ?address, + 'notes': ?notes, + 'created_by': ?createdBy, + }) + .select() + .single(); + ref.invalidate(networkSitesProvider); + return NetworkSite.fromMap(inserted); + } + + Future updateSite(NetworkSite site) async { + final client = ref.read(supabaseClientProvider); + await client.from('network_sites').update(site.toUpdateMap()).eq('id', site.id); + ref.invalidate(networkSitesProvider); + } + + Future deleteSite(String id) async { + final client = ref.read(supabaseClientProvider); + await client.from('network_sites').delete().eq('id', id); + ref.invalidate(networkSitesProvider); + } + + Future createLocation({ + required String siteId, + String? parentId, + required String name, + required NetworkLocationKind kind, + String? positionLabel, + String? notes, + }) async { + final client = ref.read(supabaseClientProvider); + final inserted = await client + .from('network_locations') + .insert({ + 'site_id': siteId, + 'parent_id': ?parentId, + 'name': name, + 'kind': kind.wire, + 'position_label': ?positionLabel, + 'notes': ?notes, + }) + .select() + .single(); + ref.invalidate(networkLocationsProvider); + return NetworkLocation.fromMap(inserted); + } + + /// Returns an existing root location for the site, or creates a "Main" + /// location of kind=other if none exist. Used by the device assignment flow + /// so admins can pick a site without having to think about location hierarchy. + Future ensureDefaultLocation(String siteId) async { + final locations = await ref.read(networkLocationsProvider.future); + for (final l in locations) { + if (l.siteId == siteId) return l; + } + return createLocation( + siteId: siteId, + name: 'Main', + kind: NetworkLocationKind.other, + ); + } +} + +final networkSitesControllerProvider = + Provider((ref) => NetworkSitesController(ref)); diff --git a/lib/providers/network_map/network_topology_provider.dart b/lib/providers/network_map/network_topology_provider.dart new file mode 100644 index 00000000..97768e5a --- /dev/null +++ b/lib/providers/network_map/network_topology_provider.dart @@ -0,0 +1,43 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/network/network_site.dart'; +import '../../models/network/topology_graph.dart'; +import 'network_devices_provider.dart'; +import 'network_sites_provider.dart'; + +/// Aggregate topology for a specific site (or null for orphan devices). +/// +/// This rebuilds whenever any of the underlying tables changes — invalidation +/// flows from the granular providers (sites, locations, devices, ports, links, +/// vlans) up through this aggregate. +final topologyForSiteProvider = + FutureProvider.family((ref, siteId) async { + final sites = await ref.watch(networkSitesProvider.future); + final locations = await ref.watch(networkLocationsProvider.future); + final devices = await ref.watch(networkDevicesProvider.future); + final ports = await ref.watch(networkPortsProvider.future); + final links = await ref.watch(networkLinksProvider.future); + final vlans = await ref.watch(networkVlansProvider.future); + + NetworkSite? site; + if (siteId != null) { + for (final s in sites) { + if (s.id == siteId) { + site = s; + break; + } + } + } + + return TopologyGraph.build( + site: site, + allLocations: locations, + allDevices: devices, + allPorts: ports, + allLinks: links, + allVlans: vlans, + ); +}); + +final topologyViewModeProvider = + StateProvider((ref) => TopologyViewMode.physical); diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index debd59b6..4967486c 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -30,6 +30,13 @@ import '../widgets/app_shell.dart'; import '../screens/teams/teams_screen.dart'; import '../screens/it_service_requests/it_service_requests_list_screen.dart'; import '../screens/it_service_requests/it_service_request_detail_screen.dart'; +import '../screens/network_map/network_map_overview_screen.dart'; +import '../screens/network_map/network_map_site_screen.dart'; +import '../screens/network_map/network_map_device_screen.dart'; +import '../screens/network_map/network_map_device_edit_screen.dart'; +import '../screens/network_map/network_map_import_screen.dart'; +import '../screens/network_map/network_map_import_review_screen.dart'; +import '../screens/network_map/network_map_vlan_screen.dart'; import '../theme/m3_motion.dart'; import '../utils/navigation.dart'; @@ -77,6 +84,12 @@ final appRouterProvider = Provider((ref) { role == 'programmer' || role == 'dispatcher' || role == 'it_staff'; + final isNetworkMapRoute = state.matchedLocation.startsWith('/network-map'); + final hasNetworkMapAccess = + role == 'admin' || + role == 'it_staff' || + role == 'dispatcher' || + role == 'programmer'; if (!isSignedIn && !isAuthRoute) { return '/login'; @@ -102,6 +115,9 @@ final appRouterProvider = Provider((ref) { if (isReportsRoute && !hasReportsAccess) { return '/tickets'; } + if (isNetworkMapRoute && !hasNetworkMapAccess) { + return '/tickets'; + } // Attendance & Whereabouts: not accessible to standard users final isStandardOnly = role == 'standard'; final isAttendanceRoute = @@ -265,6 +281,80 @@ final appRouterProvider = Provider((ref) { child: const PermissionsScreen(), ), ), + GoRoute( + path: '/network-map', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: const NetworkMapOverviewScreen(), + ), + routes: [ + GoRoute( + path: 'import', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: const NetworkMapImportScreen(), + ), + routes: [ + GoRoute( + path: ':importId/review', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: NetworkMapImportReviewScreen( + importId: state.pathParameters['importId'] ?? '', + ), + ), + ), + ], + ), + GoRoute( + path: 'vlans', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: const NetworkMapVlanScreen(), + ), + ), + GoRoute( + path: 'site/:siteId', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: NetworkMapSiteScreen( + siteId: state.pathParameters['siteId'] ?? '', + ), + ), + routes: [ + GoRoute( + path: 'device/new', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: NetworkMapDeviceEditScreen( + initialSiteId: state.pathParameters['siteId'], + ), + ), + ), + ], + ), + GoRoute( + path: 'device/:deviceId', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: NetworkMapDeviceScreen( + deviceId: state.pathParameters['deviceId'] ?? '', + ), + ), + routes: [ + GoRoute( + path: 'edit', + pageBuilder: (context, state) => M3SharedAxisPage( + key: state.pageKey, + child: NetworkMapDeviceEditScreen( + deviceId: state.pathParameters['deviceId'], + ), + ), + ), + ], + ), + ], + ), GoRoute( path: '/notifications', pageBuilder: (context, state) => M3SharedAxisPage( diff --git a/lib/screens/network_map/layout/sugiyama_layout.dart b/lib/screens/network_map/layout/sugiyama_layout.dart new file mode 100644 index 00000000..e0875c5d --- /dev/null +++ b/lib/screens/network_map/layout/sugiyama_layout.dart @@ -0,0 +1,1043 @@ +// Sugiyama layered graph layout — pure-Dart implementation. +// +// This file implements the four classical Sugiyama phases: +// 1. Cycle removal (greedy DFS back-edge reversal) +// 2. Layer assignment (role-driven + longest-path fallback) +// 3. Crossing reduction (virtual nodes + alternating barycenter-median sweeps) +// 4. Coordinate assignment (Brandes-Köpf, four-way average) +// +// The output is a [SugiyamaResult] with final (x, y) positions for every real +// node plus polyline waypoints for any edge that spans more than one layer. +// Virtual nodes used internally during Phase 3/4 are NOT rendered as visible +// nodes; their coordinates only inform the edge waypoints. +// +// The file imports only `dart:math` and `dart:ui` so the layout can be safely +// dispatched to an isolate via `compute()` without pulling in Flutter binding +// initialization. + +import 'dart:math' as math; +import 'dart:ui'; + +// ─── Public API ──────────────────────────────────────────────────────────── + +class SugiyamaInput { + SugiyamaInput({ + required this.nodeIds, + required this.edges, + this.preassignedLayers, + this.nodeSizes, + this.config = const SugiyamaConfig(), + }); + + /// All real (non-virtual) node IDs participating in the graph. + final List nodeIds; + + /// Directed edges. The layout treats them as undirected for layering and + /// crossing reduction, but the direction is preserved for downstream + /// renderers that may care about arrow style. + final List edges; + + /// Optional layer hints. If a node ID maps to an int here, that layer is + /// used as a constraint during Phase 2. Nodes without a hint get longest- + /// path assignment, respecting hinted nodes as floor/ceiling constraints. + final Map? preassignedLayers; + + /// Optional per-node bounding box. Larger nodes get more horizontal space + /// during Phase 4 compaction. If not provided, [SugiyamaConfig.defaultNodeSize] + /// is used. + final Map? nodeSizes; + + final SugiyamaConfig config; +} + +class SugiyamaEdge { + const SugiyamaEdge({required this.id, required this.from, required this.to}); + + /// Stable opaque identifier used for downstream rendering (port label chips + /// look this up). Not used by the layout algorithm itself. + final String id; + + final String from; + final String to; +} + +class SugiyamaConfig { + const SugiyamaConfig({ + this.layerSpacing = 144, + this.nodeSpacing = 96, + this.maxSweeps = 24, + this.useBrandesKopf = true, + this.defaultNodeSize = const Size(160, 110), + this.virtualNodeCap = 1000, + }); + + /// Vertical distance between consecutive layers (top of next layer to top + /// of current layer). Used as the per-layer Y stride; per-layer adjustment + /// based on actual node heights is applied during coordinate assignment. + final double layerSpacing; + + /// Minimum horizontal spacing between centers of two same-layer nodes. + final double nodeSpacing; + + /// Hard upper bound on barycenter sweeps. Most graphs converge in <8. + final int maxSweeps; + + /// When false, use a simpler median-X coordinate assignment instead of + /// Brandes-Köpf. Useful as a debugging fallback if the alignment phase + /// misbehaves on pathological inputs. + final bool useBrandesKopf; + + /// Default bounding box used when [SugiyamaInput.nodeSizes] doesn't list a + /// specific node. Matches the standard DeviceNode dimensions. + final Size defaultNodeSize; + + /// Maximum virtual nodes to insert during Phase 3. If the graph would + /// exceed this (many long-span edges), Phase 3 falls back to barycenter + /// without virtuals — quality degrades gracefully rather than crashing. + final int virtualNodeCap; +} + +class SugiyamaResult { + SugiyamaResult({ + required this.nodePositions, + required this.edgeWaypoints, + required this.canvasSize, + required this.crossingCount, + required this.layerCount, + required this.computeTime, + required this.virtualNodeCount, + }); + + /// Top-left position of every real node. + final Map nodePositions; + + /// For each edge ID whose span > 1 layer, a list of intermediate (x, y) + /// waypoints (one per virtual node it passed through). Edges that span a + /// single layer have no entry here; the renderer should draw them straight. + final Map> edgeWaypoints; + + /// Bounding box of the entire layout. Includes node width/height at the + /// extremes plus a half-spacing margin. + final Size canvasSize; + + /// Total crossings remaining after Phase 3. Useful for diagnostics; a value + /// near 0 means the layout is very clean. + final int crossingCount; + + /// Number of layers in the final layered graph (max layer index + 1). + final int layerCount; + + /// Wall-clock time for the entire layout pipeline. + final Duration computeTime; + + /// Number of virtual nodes inserted during Phase 3. For diagnostics; 0 if + /// the cap was exceeded and Phase 3 fell back to no-virtuals mode. + final int virtualNodeCount; +} + +/// Compute a Sugiyama layout for the given input. Synchronous; safe to call +/// from an isolate via `compute()`. +SugiyamaResult computeSugiyamaLayout(SugiyamaInput input) { + final stopwatch = Stopwatch()..start(); + + if (input.nodeIds.isEmpty) { + stopwatch.stop(); + return SugiyamaResult( + nodePositions: const {}, + edgeWaypoints: const {}, + canvasSize: const Size(600, 400), + crossingCount: 0, + layerCount: 0, + computeTime: stopwatch.elapsed, + virtualNodeCount: 0, + ); + } + + // Phase 1 — cycle removal: build a DAG so layering is well-defined. + final cycleResult = _removeCycles(input.nodeIds, input.edges); + + // Phase 2 — layer assignment. + final layerMap = _assignLayers( + nodeIds: input.nodeIds, + edges: cycleResult.acyclicEdges, + preassigned: input.preassignedLayers, + ); + + // Phase 3 — virtual nodes + crossing reduction. + final crossingResult = _reduceCrossings( + nodeIds: input.nodeIds, + edges: cycleResult.acyclicEdges, + layers: layerMap, + config: input.config, + ); + + // Phase 4 — coordinate assignment (Brandes-Köpf or simple). + final coords = _assignCoordinates( + realNodeIds: input.nodeIds.toSet(), + layerOf: crossingResult.layerOf, + layerOrder: crossingResult.layerOrder, + upper: crossingResult.upper, + lower: crossingResult.lower, + nodeSizes: input.nodeSizes ?? const {}, + config: input.config, + ); + + // Build final node positions (real nodes only) and edge waypoints. + final positions = {}; + for (final id in input.nodeIds) { + final x = coords.x[id]; + final y = coords.y[id]; + if (x != null && y != null) { + final size = (input.nodeSizes ?? const {})[id] ?? input.config.defaultNodeSize; + // Position is top-left; coords are node centers. + positions[id] = Offset(x - size.width / 2, y - size.height / 2); + } + } + + final waypoints = >{}; + for (final entry in crossingResult.edgeVirtualChain.entries) { + final virtuals = entry.value; + if (virtuals.isEmpty) continue; + final points = []; + for (final v in virtuals) { + final x = coords.x[v]; + final y = coords.y[v]; + if (x != null && y != null) points.add(Offset(x, y)); + } + if (points.isNotEmpty) waypoints[entry.key] = points; + } + + // Compute canvas bounds. + double minX = double.infinity, minY = double.infinity; + double maxX = -double.infinity, maxY = -double.infinity; + for (final id in input.nodeIds) { + final pos = positions[id]; + if (pos == null) continue; + final size = (input.nodeSizes ?? const {})[id] ?? input.config.defaultNodeSize; + if (pos.dx < minX) minX = pos.dx; + if (pos.dy < minY) minY = pos.dy; + if (pos.dx + size.width > maxX) maxX = pos.dx + size.width; + if (pos.dy + size.height > maxY) maxY = pos.dy + size.height; + } + // Normalize so the top-left of the bounding box is at (margin, margin). + final margin = input.config.nodeSpacing; + final offsetX = margin - (minX.isFinite ? minX : 0); + final offsetY = margin - (minY.isFinite ? minY : 0); + final normalizedPositions = { + for (final entry in positions.entries) + entry.key: entry.value.translate(offsetX, offsetY), + }; + final normalizedWaypoints = >{ + for (final entry in waypoints.entries) + entry.key: [for (final p in entry.value) p.translate(offsetX, offsetY)], + }; + + final width = + (maxX.isFinite ? maxX : 600) - (minX.isFinite ? minX : 0) + margin * 2; + final height = + (maxY.isFinite ? maxY : 400) - (minY.isFinite ? minY : 0) + margin * 2; + + stopwatch.stop(); + return SugiyamaResult( + nodePositions: normalizedPositions, + edgeWaypoints: normalizedWaypoints, + canvasSize: Size(width, height), + crossingCount: crossingResult.crossingCount, + layerCount: crossingResult.layerOrder.length, + computeTime: stopwatch.elapsed, + virtualNodeCount: crossingResult.virtualCount, + ); +} + +// ─── Phase 1: Cycle removal ──────────────────────────────────────────────── + +class _CycleResult { + _CycleResult({required this.acyclicEdges, required this.reversed}); + + /// Edges with cycle-breaking back-edges reversed. The total edge count + /// equals the input edge count. + final List acyclicEdges; + + /// IDs of edges that were reversed. Stored for downstream renderers that + /// may want to draw the reversed direction differently (we currently don't). + final Set reversed; +} + +_CycleResult _removeCycles(List nodes, List edges) { + // Adjacency list for the original graph. + final adj = >{}; + for (final e in edges) { + adj.putIfAbsent(e.from, () => []).add(e); + } + + // DFS coloring: 0 = unvisited, 1 = on-stack, 2 = done. + final color = {for (final n in nodes) n: 0}; + final reversed = {}; + + void visit(String v) { + color[v] = 1; + for (final e in adj[v] ?? const []) { + final c = color[e.to] ?? 0; + if (c == 1) { + // Back-edge: this would close a cycle. Mark for reversal. + reversed.add(e.id); + } else if (c == 0) { + visit(e.to); + } + } + color[v] = 2; + } + + for (final n in nodes) { + if ((color[n] ?? 0) == 0) visit(n); + } + + final acyclicEdges = [ + for (final e in edges) + if (reversed.contains(e.id)) + SugiyamaEdge(id: e.id, from: e.to, to: e.from) + else + e, + ]; + + return _CycleResult(acyclicEdges: acyclicEdges, reversed: reversed); +} + +// ─── Phase 2: Layer assignment ───────────────────────────────────────────── + +/// Longest-path assignment, respecting preassigned layers as floor constraints. +/// Returns a map from node id → layer index (0 = topmost). +Map _assignLayers({ + required List nodeIds, + required List edges, + required Map? preassigned, +}) { + final adj = >{}; + final inDeg = {for (final n in nodeIds) n: 0}; + for (final e in edges) { + adj.putIfAbsent(e.from, () => []).add(e.to); + inDeg[e.to] = (inDeg[e.to] ?? 0) + 1; + } + + // Topological order via Kahn's algorithm. + final queue = [ + for (final entry in inDeg.entries) + if (entry.value == 0) entry.key, + ]; + final topo = []; + final indeg = Map.from(inDeg); + while (queue.isNotEmpty) { + final n = queue.removeAt(0); + topo.add(n); + for (final m in adj[n] ?? const []) { + indeg[m] = (indeg[m] ?? 0) - 1; + if (indeg[m] == 0) queue.add(m); + } + } + // Handle any leftovers (shouldn't happen post-Phase-1 but guard for safety). + for (final n in nodeIds) { + if (!topo.contains(n)) topo.add(n); + } + + final layer = {}; + + // Helper to enforce preassigned hints as both floor AND ceiling for the + // hinted node itself. Non-hinted nodes use computed layer freely. + int initialLayerFor(String n) { + final hint = preassigned?[n]; + return hint ?? 0; + } + + for (final n in topo) { + var best = initialLayerFor(n); + // For preassigned nodes, the hint is authoritative. + if (preassigned?.containsKey(n) ?? false) { + layer[n] = preassigned![n]!; + continue; + } + // For unassigned nodes, longest-path from parents. + var hasParent = false; + for (final e in edges) { + if (e.to != n) continue; + hasParent = true; + final parentLayer = layer[e.from]; + if (parentLayer != null && parentLayer + 1 > best) { + best = parentLayer + 1; + } + } + if (!hasParent) { + // Source node with no hint → put on the topmost free layer. + best = 0; + } + layer[n] = best; + } + + // Second pass: push sinks down so they don't crowd above their parents. + // For each unassigned node, ensure layer ≥ max(parent.layer) + 1. + for (final n in topo) { + if (preassigned?.containsKey(n) ?? false) continue; + var maxParent = -1; + for (final e in edges) { + if (e.to != n) continue; + final p = layer[e.from]; + if (p != null && p > maxParent) maxParent = p; + } + if (maxParent >= 0 && (layer[n] ?? 0) <= maxParent) { + layer[n] = maxParent + 1; + } + } + + // Normalize: shift so the minimum layer is 0. + if (layer.isEmpty) return layer; + final minLayer = layer.values.reduce(math.min); + if (minLayer != 0) { + for (final k in layer.keys.toList()) { + layer[k] = layer[k]! - minLayer; + } + } + + return layer; +} + +// ─── Phase 3: Crossing reduction (virtual nodes + barycenter sweeps) ────── + +class _CrossingResult { + _CrossingResult({ + required this.layerOf, + required this.layerOrder, + required this.upper, + required this.lower, + required this.edgeVirtualChain, + required this.crossingCount, + required this.virtualCount, + }); + + /// Final layer index for every node (real and virtual). + final Map layerOf; + + /// For each layer, the ordered list of node IDs (real and virtual). + final List> layerOrder; + + /// Adjacency to upper neighbors (one layer above): node → list of node IDs. + final Map> upper; + + /// Adjacency to lower neighbors. + final Map> lower; + + /// For each original edge ID, the chain of virtual node IDs it passed + /// through (empty for single-layer edges). + final Map> edgeVirtualChain; + + /// Total crossings remaining after the best sweep. + final int crossingCount; + + /// How many virtual nodes were inserted. + final int virtualCount; +} + +_CrossingResult _reduceCrossings({ + required List nodeIds, + required List edges, + required Map layers, + required SugiyamaConfig config, +}) { + // Step 1: insert virtual nodes for multi-layer edges. + final layerOf = Map.from(layers); + final upper = >{}; + final lower = >{}; + final edgeVirtualChain = >{}; + var virtualCounter = 0; + var virtualCap = config.virtualNodeCap; + + void connect(String from, String to) { + upper.putIfAbsent(to, () => []).add(from); + lower.putIfAbsent(from, () => []).add(to); + } + + for (final e in edges) { + final lFrom = layerOf[e.from]; + final lTo = layerOf[e.to]; + if (lFrom == null || lTo == null) continue; + if (lFrom == lTo) { + // Same-layer edge: rare in well-formed network data. We still record + // adjacency so crossing reduction sees it, but it cannot affect crossings. + connect(e.from, e.to); + continue; + } + final low = math.min(lFrom, lTo); + final high = math.max(lFrom, lTo); + if (high - low == 1) { + connect(e.from, e.to); + continue; + } + // Insert virtual nodes for every intermediate layer. + if (virtualCounter + (high - low - 1) > virtualCap) { + // Cap exceeded — degrade gracefully: connect endpoints directly. + // The renderer will draw a straight line, which is uglier but stable. + connect(e.from, e.to); + continue; + } + final chain = []; + String prev = lFrom < lTo ? e.from : e.to; + final startLayer = lFrom < lTo ? lFrom : lTo; + final endLayer = lFrom < lTo ? lTo : lFrom; + final endpoint = lFrom < lTo ? e.to : e.from; + for (var l = startLayer + 1; l < endLayer; l++) { + final vid = '__v${virtualCounter++}'; + layerOf[vid] = l; + chain.add(vid); + connect(prev, vid); + prev = vid; + } + connect(prev, endpoint); + edgeVirtualChain[e.id] = chain; + } + + // Step 2: initial ordering per layer (real nodes by input order, virtuals + // appended). We'll refine via sweeps. + final layerCount = + layerOf.values.isEmpty ? 0 : layerOf.values.reduce(math.max) + 1; + final layerOrder = List>.generate(layerCount, (_) => []); + for (final id in nodeIds) { + final l = layerOf[id]; + if (l != null) layerOrder[l].add(id); + } + for (final entry in layerOf.entries) { + if (!entry.key.startsWith('__v')) continue; + layerOrder[entry.value].add(entry.key); + } + + // Step 3: alternating down + up sweeps using median barycenter. + var crossings = _countAllCrossings(layerOrder, upper); + var bestCrossings = crossings; + var bestOrder = _cloneOrder(layerOrder); + var noImprovement = 0; + + for (var sweep = 0; sweep < config.maxSweeps && noImprovement < 4; sweep++) { + final isDown = sweep.isEven; + if (isDown) { + // Top → bottom: layer l reorders by median X of its upper neighbors. + for (var l = 1; l < layerCount; l++) { + _reorderByMedian(layerOrder, l, upper, layerOrder[l - 1]); + } + } else { + // Bottom → top: layer l reorders by median X of its lower neighbors. + for (var l = layerCount - 2; l >= 0; l--) { + _reorderByMedian(layerOrder, l, lower, layerOrder[l + 1]); + } + } + final c = _countAllCrossings(layerOrder, upper); + if (c < bestCrossings) { + bestCrossings = c; + bestOrder = _cloneOrder(layerOrder); + noImprovement = 0; + } else { + noImprovement++; + } + crossings = c; + } + + return _CrossingResult( + layerOf: layerOf, + layerOrder: bestOrder, + upper: upper, + lower: lower, + edgeVirtualChain: edgeVirtualChain, + crossingCount: bestCrossings, + virtualCount: virtualCounter, + ); +} + +List> _cloneOrder(List> source) { + return [for (final layer in source) List.from(layer)]; +} + +/// Reorder the given layer by the median index of each node's neighbors in +/// [reference] (the adjacent layer). Nodes with no neighbors keep their +/// relative order. +void _reorderByMedian( + List> layerOrder, + int layerIndex, + Map> adjacency, + List reference, +) { + final indexOf = { + for (var i = 0; i < reference.length; i++) reference[i]: i, + }; + final layer = layerOrder[layerIndex]; + final keys = {}; + for (var i = 0; i < layer.length; i++) { + final id = layer[i]; + final neighbors = adjacency[id] ?? const []; + final indexes = [ + for (final n in neighbors) + if (indexOf.containsKey(n)) indexOf[n]!, + ]..sort(); + if (indexes.isEmpty) { + keys[id] = i.toDouble(); // stable: keep current position + } else if (indexes.length.isOdd) { + keys[id] = indexes[indexes.length ~/ 2].toDouble(); + } else { + // Even count: use the average of the two middle indexes (Eades-Wormald). + final m1 = indexes[indexes.length ~/ 2 - 1]; + final m2 = indexes[indexes.length ~/ 2]; + keys[id] = (m1 + m2) / 2; + } + } + layer.sort((a, b) { + final cmp = (keys[a] ?? 0).compareTo(keys[b] ?? 0); + if (cmp != 0) return cmp; + // Stable tiebreak by current index. + return layer.indexOf(a).compareTo(layer.indexOf(b)); + }); +} + +/// Count edge crossings between every pair of adjacent layers. O(E log E) via +/// the standard merge-sort inversion count, applied per layer pair. +int _countAllCrossings( + List> layerOrder, Map> upper) { + var total = 0; + for (var l = 1; l < layerOrder.length; l++) { + total += _countCrossingsBetween(layerOrder[l - 1], layerOrder[l], upper); + } + return total; +} + +int _countCrossingsBetween( + List top, + List bottom, + Map> upper, +) { + // Build the list of (top-index, bottom-index) pairs sorted by bottom-index. + // Crossings = inversions in the list of top-indexes when read in bottom-order. + final topIndex = { + for (var i = 0; i < top.length; i++) top[i]: i, + }; + final pairs = []; + for (var bi = 0; bi < bottom.length; bi++) { + final neighbors = upper[bottom[bi]] ?? const []; + final sorted = [ + for (final n in neighbors) + if (topIndex.containsKey(n)) topIndex[n]!, + ]..sort(); + pairs.addAll(sorted); + } + // Inversion count via merge sort. + return _mergeSortInversions(pairs, 0, pairs.length - 1); +} + +int _mergeSortInversions(List a, int lo, int hi) { + if (lo >= hi) return 0; + final mid = (lo + hi) ~/ 2; + var count = _mergeSortInversions(a, lo, mid) + + _mergeSortInversions(a, mid + 1, hi); + // Merge. + final tmp = []; + var i = lo; + var j = mid + 1; + while (i <= mid && j <= hi) { + if (a[i] <= a[j]) { + tmp.add(a[i++]); + } else { + tmp.add(a[j++]); + count += mid - i + 1; + } + } + while (i <= mid) { + tmp.add(a[i++]); + } + while (j <= hi) { + tmp.add(a[j++]); + } + for (var k = 0; k < tmp.length; k++) { + a[lo + k] = tmp[k]; + } + return count; +} + +// ─── Phase 4: Coordinate assignment ─────────────────────────────────────── + +class _CoordResult { + _CoordResult({required this.x, required this.y}); + + /// X coordinate of every node center (real and virtual). + final Map x; + + /// Y coordinate of every node center. + final Map y; +} + +_CoordResult _assignCoordinates({ + required Set realNodeIds, + required Map layerOf, + required List> layerOrder, + required Map> upper, + required Map> lower, + required Map nodeSizes, + required SugiyamaConfig config, +}) { + // Y coordinates: layer * layerSpacing, adjusted for max node height per layer. + final y = {}; + var cursorY = 0.0; + for (var l = 0; l < layerOrder.length; l++) { + var maxH = config.defaultNodeSize.height; + for (final id in layerOrder[l]) { + final h = nodeSizes[id]?.height ?? config.defaultNodeSize.height; + if (h > maxH) maxH = h; + } + final layerY = cursorY + maxH / 2; + for (final id in layerOrder[l]) { + y[id] = layerY; + } + cursorY += maxH + config.layerSpacing; + } + + // X coordinates: + // - Brandes-Köpf when enabled: 4-pass alignment + averaging. + // - Fallback: simple per-layer compaction using median-X. + Map x; + if (config.useBrandesKopf) { + x = _brandesKopf( + layerOrder: layerOrder, + upper: upper, + lower: lower, + nodeSizes: nodeSizes, + config: config, + ); + } else { + x = _simpleXAssignment(layerOrder, nodeSizes, config); + } + + return _CoordResult(x: x, y: y); +} + +/// Simple per-layer X assignment: nodes laid out left-to-right with +/// [nodeSpacing] between centers, using max(node width, defaultNodeSize.width) +/// per node. +Map _simpleXAssignment( + List> layerOrder, + Map nodeSizes, + SugiyamaConfig config, +) { + final x = {}; + for (final layer in layerOrder) { + var cursor = 0.0; + for (final id in layer) { + final w = nodeSizes[id]?.width ?? config.defaultNodeSize.width; + x[id] = cursor + w / 2; + cursor += w + config.nodeSpacing; + } + } + // Center each layer horizontally by shifting to the global midpoint. + var globalMaxX = 0.0; + for (final layer in layerOrder) { + if (layer.isEmpty) continue; + final lastId = layer.last; + final lastX = x[lastId] ?? 0; + final lastW = + nodeSizes[lastId]?.width ?? config.defaultNodeSize.width; + if (lastX + lastW / 2 > globalMaxX) globalMaxX = lastX + lastW / 2; + } + for (final layer in layerOrder) { + if (layer.isEmpty) continue; + final lastId = layer.last; + final lastX = x[lastId] ?? 0; + final lastW = + nodeSizes[lastId]?.width ?? config.defaultNodeSize.width; + final layerW = lastX + lastW / 2; + final shift = (globalMaxX - layerW) / 2; + if (shift > 0) { + for (final id in layer) { + x[id] = (x[id] ?? 0) + shift; + } + } + } + return x; +} + +/// Brandes-Köpf horizontal coordinate assignment. Four passes (combinations of +/// up/down and left/right), then averages the resulting X coordinates. +/// +/// Reference: U. Brandes & B. Köpf, "Fast and Simple Horizontal Coordinate +/// Assignment", Graph Drawing 2001. +Map _brandesKopf({ + required List> layerOrder, + required Map> upper, + required Map> lower, + required Map nodeSizes, + required SugiyamaConfig config, +}) { + // Step 1: mark "type 1" conflicts — edges between two non-virtual nodes + // that cross an edge involving a virtual node. These are the "hard" cases. + final type1Conflicts = _markType1Conflicts(layerOrder, upper); + + // Run 4 passes: (up, left), (up, right), (down, left), (down, right). + final passResults = >[]; + for (final goingDown in [false, true]) { + for (final goingLeft in [false, true]) { + final root = {}; + final align = {}; + for (final layer in layerOrder) { + for (final id in layer) { + root[id] = id; + align[id] = id; + } + } + + _verticalAlign( + layerOrder: layerOrder, + adjacency: goingDown ? lower : upper, + type1Conflicts: type1Conflicts, + root: root, + align: align, + goingDown: goingDown, + goingLeft: goingLeft, + ); + + final x = _horizontalCompaction( + layerOrder: layerOrder, + root: root, + align: align, + nodeSizes: nodeSizes, + config: config, + goingLeft: goingLeft, + ); + passResults.add(x); + } + } + + // Step 2: balance — for each node, average the four x coordinates after + // normalizing each pass to the same min-X. Brandes-Köpf paper uses median + // of 4 values, which is equivalent to average for our 4-pass case. + final balanced = {}; + for (final layer in layerOrder) { + for (final id in layer) { + final xs = [ + for (final r in passResults) + if (r[id] != null) r[id]!, + ]..sort(); + if (xs.isEmpty) continue; + // Average of the middle two (or all 4): produces stable result. + if (xs.length >= 2) { + balanced[id] = (xs[xs.length ~/ 2 - 1] + xs[xs.length ~/ 2]) / 2; + } else { + balanced[id] = xs.first; + } + } + } + return balanced; +} + +/// Mark edges that are type-1 conflicts in Brandes-Köpf terminology: an edge +/// (u, v) is a conflict if it crosses an edge (u', v') where exactly one of +/// u/v is virtual and one of u'/v' is also virtual (different orientation). +/// We approximate by checking: if both endpoints are virtual, the edge is +/// never the "conflicting" one; otherwise, it conflicts with any virtual-to- +/// virtual edge that crosses it. +Set _markType1Conflicts( + List> layerOrder, + Map> upper, +) { + final conflicts = {}; + for (var l = 1; l < layerOrder.length; l++) { + final top = layerOrder[l - 1]; + final bot = layerOrder[l]; + final topIndex = { + for (var i = 0; i < top.length; i++) top[i]: i, + }; + // For each bottom node, find its upper neighbors and sort by top-index. + var k0 = 0; + var k1 = top.length - 1; + for (var bi = 0; bi < bot.length; bi++) { + final v = bot[bi]; + final isVirtual = v.startsWith('__v'); + if (!isVirtual && bi < bot.length - 1) continue; + + // For inner-segment endpoints (both virtual), tighten search bounds. + if (isVirtual || bi == bot.length - 1) { + final neighborTopIndexes = [ + for (final u in upper[v] ?? const []) + if (topIndex.containsKey(u)) topIndex[u]!, + ]..sort(); + if (neighborTopIndexes.isEmpty) continue; + final k1Local = neighborTopIndexes.last; + // Walk preceding bottom nodes from k0 to bi, marking conflicts. + for (var bj = k0; bj <= bi; bj++) { + final bjNode = bot[bj]; + for (final un in upper[bjNode] ?? const []) { + final ui = topIndex[un]; + if (ui == null) continue; + if (ui < k0 || ui > k1Local) { + // This edge crosses an inner segment — type-1 conflict. + conflicts.add('$un|$bjNode'); + } + } + } + k0 = bi + 1; + k1 = k1Local; + } + } + // k1 used only as scratch; keep loop tidy + if (k1 < 0) k1 = top.length - 1; + } + return conflicts; +} + +/// Vertical alignment phase: link each node to its median neighbor in +/// [adjacency] (upper for "up" passes, lower for "down" passes). Skip linkings +/// that would conflict with already-marked type-1 conflicts. +void _verticalAlign({ + required List> layerOrder, + required Map> adjacency, + required Set type1Conflicts, + required Map root, + required Map align, + required bool goingDown, + required bool goingLeft, +}) { + final iter = goingDown + ? List.generate(layerOrder.length, (i) => i) + : List.generate(layerOrder.length, (i) => layerOrder.length - 1 - i); + + for (final layerIdx in iter) { + final layer = layerOrder[layerIdx]; + var r = goingLeft ? -1 : layerOrder.fold(0, (acc, l) => math.max(acc, l.length)); + final orderedNodes = goingLeft ? layer : layer.reversed.toList(); + for (final v in orderedNodes) { + final neighbors = adjacency[v] ?? const []; + if (neighbors.isEmpty) continue; + // Build sorted list of neighbor indexes within their layer. + final adjLayerIdx = goingDown ? layerIdx + 1 : layerIdx - 1; + if (adjLayerIdx < 0 || adjLayerIdx >= layerOrder.length) continue; + final adjLayer = layerOrder[adjLayerIdx]; + final adjIndex = { + for (var i = 0; i < adjLayer.length; i++) adjLayer[i]: i, + }; + final sortedNeighbors = <(int, String)>[ + for (final n in neighbors) + if (adjIndex.containsKey(n)) (adjIndex[n]!, n), + ]..sort((a, b) => a.$1.compareTo(b.$1)); + if (sortedNeighbors.isEmpty) continue; + + // Pick the median neighbor(s) per Brandes-Köpf. + final m1 = (sortedNeighbors.length - 1) ~/ 2; + final m2 = sortedNeighbors.length ~/ 2; + final medians = m1 == m2 ? [sortedNeighbors[m1]] : [sortedNeighbors[m1], sortedNeighbors[m2]]; + + for (final (mIdx, mNode) in medians) { + if (align[v] != v) continue; // already aligned + // Check direction: only consider neighbors strictly to one side of r. + final wantStrictlyAfter = !goingLeft; + if ((wantStrictlyAfter && mIdx > r) || + (!wantStrictlyAfter && mIdx < r)) { + // Check type-1 conflict. + final edgeKey = goingDown ? '$v|$mNode' : '$mNode|$v'; + if (type1Conflicts.contains(edgeKey)) continue; + align[mNode] = v; + root[v] = root[mNode] ?? mNode; + align[v] = root[v]!; + r = mIdx; + } + } + } + } +} + +/// Horizontal compaction: lay out aligned chains while respecting minimum +/// spacing. The resulting X positions are then optionally mirrored for +/// "leftward" passes. +Map _horizontalCompaction({ + required List> layerOrder, + required Map root, + required Map align, + required Map nodeSizes, + required SugiyamaConfig config, + required bool goingLeft, +}) { + // Each root node anchors a chain. We compute x for each root, then propagate + // to its aligned descendants. + final sink = {}; + final shift = {}; + final x = {}; + + for (final layer in layerOrder) { + for (final id in layer) { + sink[id] = id; + shift[id] = double.infinity; + x[id] = double.nan; + } + } + + // Position roots in topological scan order; descendants inherit. + void placeBlock(String v) { + if (!x[v]!.isNaN) return; + x[v] = 0; + var w = v; + do { + final layerOfW = _findLayer(layerOrder, w); + if (layerOfW == -1) break; + final layer = layerOrder[layerOfW]; + final idx = layer.indexOf(w); + if (goingLeft ? idx < layer.length - 1 : idx > 0) { + final pred = goingLeft ? layer[idx + 1] : layer[idx - 1]; + final u = root[pred]!; + placeBlock(u); + final widthPred = + nodeSizes[pred]?.width ?? config.defaultNodeSize.width; + final widthW = nodeSizes[w]?.width ?? config.defaultNodeSize.width; + final minDelta = (widthPred + widthW) / 2 + config.nodeSpacing; + if (sink[v] == v) sink[v] = sink[u]!; + if (sink[v] != sink[u]) { + final candidate = goingLeft + ? x[u]! - x[v]! - minDelta + : x[u]! + minDelta - x[v]!; + final s = shift[sink[u]!]!; + shift[sink[u]!] = goingLeft + ? (s == double.infinity ? candidate : math.max(s, candidate)) + : (s == double.infinity ? candidate : math.min(s, candidate)); + } else { + x[v] = goingLeft + ? math.min(x[v]!, x[u]! - minDelta) + : math.max(x[v]!, x[u]! + minDelta); + } + } + w = align[w]!; + } while (w != v); + } + + for (final layer in layerOrder) { + for (final id in layer) { + if (root[id] == id) placeBlock(id); + } + } + + // Propagate root coordinates to aligned descendants and apply shifts. + for (final layer in layerOrder) { + for (final id in layer) { + final r = root[id]!; + var xr = x[r]!; + if (xr.isNaN) xr = 0; + final s = shift[sink[r]!]!; + final adjusted = s.isFinite ? xr + s : xr; + x[id] = adjusted; + } + } + + // Normalize: shift so minimum x = 0. + final values = x.values.where((v) => v.isFinite).toList(); + if (values.isEmpty) return x; + final minX = values.reduce(math.min); + for (final k in x.keys.toList()) { + final v = x[k]!; + x[k] = v.isFinite ? v - minX : 0; + } + + return x; +} + +int _findLayer(List> layerOrder, String id) { + for (var i = 0; i < layerOrder.length; i++) { + if (layerOrder[i].contains(id)) return i; + } + return -1; +} diff --git a/lib/screens/network_map/network_map_device_edit_screen.dart b/lib/screens/network_map/network_map_device_edit_screen.dart new file mode 100644 index 00000000..eed9f5d9 --- /dev/null +++ b/lib/screens/network_map/network_map_device_edit_screen.dart @@ -0,0 +1,465 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/network/network_device.dart'; +import '../../models/network/network_location.dart'; +import '../../providers/network_map/network_devices_provider.dart'; +import '../../providers/network_map/network_sites_provider.dart'; +import '../../widgets/app_state_view.dart'; +import '../../widgets/responsive_body.dart'; +import 'widgets/port_edit_dialog.dart'; +import 'widgets/port_list_tile.dart'; + +/// Add or edit a device. If [deviceId] is null, creates a new device. +class NetworkMapDeviceEditScreen extends ConsumerStatefulWidget { + const NetworkMapDeviceEditScreen({ + super.key, + this.deviceId, + this.initialSiteId, + }); + + final String? deviceId; + final String? initialSiteId; + + @override + ConsumerState createState() => + _NetworkMapDeviceEditScreenState(); +} + +class _NetworkMapDeviceEditScreenState + extends ConsumerState { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _vendorCtrl = TextEditingController(); + final _modelCtrl = TextEditingController(); + final _serialCtrl = TextEditingController(); + final _mgmtIpCtrl = TextEditingController(); + final _macCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + NetworkDeviceKind _kind = NetworkDeviceKind.switchDevice; + NetworkDeviceRole? _role; + String? _siteId; + String? _locationId; + bool _saving = false; + bool _initialized = false; + + @override + void dispose() { + _nameCtrl.dispose(); + _vendorCtrl.dispose(); + _modelCtrl.dispose(); + _serialCtrl.dispose(); + _mgmtIpCtrl.dispose(); + _macCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + void _prefillFromDevice(NetworkDevice d, List locations) { + if (_initialized) return; + _nameCtrl.text = d.name; + _vendorCtrl.text = d.vendor ?? ''; + _modelCtrl.text = d.model ?? ''; + _serialCtrl.text = d.serial ?? ''; + _mgmtIpCtrl.text = d.mgmtIp ?? ''; + _macCtrl.text = d.mac ?? ''; + _notesCtrl.text = d.notes ?? ''; + _kind = d.kind; + _role = d.role; + // Resolve the device's location → its site for the pickers. + if (d.locationId != null) { + _locationId = d.locationId; + for (final l in locations) { + if (l.id == d.locationId) { + _siteId = l.siteId; + break; + } + } + } + _initialized = true; + } + + /// Resolve a location_id to save. Preference order: + /// 1. Explicitly picked location. + /// 2. Site picked but no specific location → use/create the site's default. + /// 3. Nothing picked → null (unassigned). + Future _resolveLocationIdToSave() async { + if (_locationId != null) return _locationId; + if (_siteId == null) return null; + final loc = await ref + .read(networkSitesControllerProvider) + .ensureDefaultLocation(_siteId!); + return loc.id; + } + + Future _showAddLocationDialog() async { + if (_siteId == null) return; + final nameCtrl = TextEditingController(); + NetworkLocationKind kind = NetworkLocationKind.room; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setLocal) => AlertDialog( + title: const Text('New location'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameCtrl, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Name (e.g. Floor 3, Rack U-12)', + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: kind, + items: NetworkLocationKind.values + .map((k) => DropdownMenuItem( + value: k, + child: Text(_locationKindLabel(k)), + )) + .toList(), + onChanged: (v) => setLocal( + () => kind = v ?? NetworkLocationKind.room, + ), + decoration: const InputDecoration(labelText: 'Kind'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Create'), + ), + ], + ), + ), + ); + if (ok == true && nameCtrl.text.trim().isNotEmpty) { + final created = await ref.read(networkSitesControllerProvider).createLocation( + siteId: _siteId!, + name: nameCtrl.text.trim(), + kind: kind, + ); + if (mounted) setState(() => _locationId = created.id); + } + } + + String _locationKindLabel(NetworkLocationKind k) { + switch (k) { + case NetworkLocationKind.building: + return 'Building'; + case NetworkLocationKind.floor: + return 'Floor'; + case NetworkLocationKind.room: + return 'Room'; + case NetworkLocationKind.rack: + return 'Rack'; + case NetworkLocationKind.wallJack: + return 'Wall jack'; + case NetworkLocationKind.other: + return 'Other'; + } + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() => _saving = true); + try { + final controller = ref.read(networkDevicesControllerProvider); + final locationId = await _resolveLocationIdToSave(); + if (widget.deviceId == null) { + await controller.createDevice( + name: _nameCtrl.text.trim(), + kind: _kind, + role: _role, + vendor: _vendorCtrl.text.trim().isEmpty ? null : _vendorCtrl.text.trim(), + model: _modelCtrl.text.trim().isEmpty ? null : _modelCtrl.text.trim(), + serial: _serialCtrl.text.trim().isEmpty ? null : _serialCtrl.text.trim(), + mgmtIp: _mgmtIpCtrl.text.trim().isEmpty ? null : _mgmtIpCtrl.text.trim(), + mac: _macCtrl.text.trim().isEmpty ? null : _macCtrl.text.trim(), + locationId: locationId, + notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), + ); + } else { + final existing = + await ref.read(networkDeviceByIdProvider(widget.deviceId!).future); + if (existing == null) return; + final updated = existing.copyWith( + name: _nameCtrl.text.trim(), + kind: _kind, + role: _role, + vendor: _vendorCtrl.text.trim().isEmpty ? null : _vendorCtrl.text.trim(), + model: _modelCtrl.text.trim().isEmpty ? null : _modelCtrl.text.trim(), + serial: _serialCtrl.text.trim().isEmpty ? null : _serialCtrl.text.trim(), + mgmtIp: _mgmtIpCtrl.text.trim().isEmpty ? null : _mgmtIpCtrl.text.trim(), + mac: _macCtrl.text.trim().isEmpty ? null : _macCtrl.text.trim(), + locationId: locationId, + notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), + ); + await controller.updateDevice(updated); + } + if (mounted) context.pop(); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _addPort(String deviceId) async { + await showPortEditDialog( + context: context, + ref: ref, + deviceId: deviceId, + ); + } + + @override + Widget build(BuildContext context) { + final existingAsync = widget.deviceId == null + ? const AsyncValue.data(null) + : ref.watch(networkDeviceByIdProvider(widget.deviceId!)); + final sitesAsync = ref.watch(networkSitesProvider); + final locationsAsync = ref.watch(networkLocationsProvider); + + // For a new device under a specific site (from the /site/:id/device/new + // route), pre-fill the site picker so the user can save without picking. + if (!_initialized && widget.deviceId == null && widget.initialSiteId != null) { + _siteId = widget.initialSiteId; + } + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + title: Text(widget.deviceId == null ? 'New device' : 'Edit device'), + actions: [ + TextButton( + onPressed: _saving ? null : _save, + child: _saving ? const Text('Saving…') : const Text('Save'), + ), + ], + ), + body: existingAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => AppErrorView(error: e), + data: (device) { + if (device != null) { + _prefillFromDevice(device, locationsAsync.valueOrNull ?? const []); + } + final locationsForSite = (locationsAsync.valueOrNull ?? const []) + .where((l) => _siteId != null && l.siteId == _siteId) + .toList(); + return ResponsiveBody( + maxWidth: 720, + child: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Name *'), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Name is required' + : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _kind, + items: NetworkDeviceKind.values + .map((k) => + DropdownMenuItem(value: k, child: Text(k.label))) + .toList(), + onChanged: (v) => setState(() => _kind = v ?? _kind), + decoration: const InputDecoration(labelText: 'Type *'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _role, + items: [ + const DropdownMenuItem( + value: null, child: Text('(none)')), + for (final r in NetworkDeviceRole.values) + DropdownMenuItem(value: r, child: Text(r.label)), + ], + onChanged: (v) => setState(() => _role = v), + decoration: + const InputDecoration(labelText: 'Logical role'), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _siteId, + items: [ + const DropdownMenuItem( + value: null, child: Text('Unassigned')), + for (final s in sitesAsync.valueOrNull ?? []) + DropdownMenuItem(value: s.id, child: Text(s.name)), + ], + onChanged: (v) => setState(() { + _siteId = v; + // Reset location when site changes — old location wouldn't + // belong to the new site. + _locationId = null; + }), + decoration: const InputDecoration( + labelText: 'Site', + helperText: + 'Devices must be assigned to a site to appear on the topology canvas.', + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: _locationId, + items: [ + const DropdownMenuItem( + value: null, + child: Text('(Default — auto-create "Main")'), + ), + for (final l in locationsForSite) + DropdownMenuItem( + value: l.id, + child: Text( + '${l.name} · ${_locationKindLabel(l.kind)}', + ), + ), + ], + onChanged: _siteId == null + ? null + : (v) => setState(() => _locationId = v), + decoration: InputDecoration( + labelText: 'Location', + helperText: _siteId == null + ? 'Pick a site first.' + : 'Specific building / floor / room / rack within the site.', + enabled: _siteId != null, + ), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: 'Add new location to this site', + onPressed: _siteId == null + ? null + : _showAddLocationDialog, + icon: const Icon(Icons.add_location_alt_outlined), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: _vendorCtrl, + decoration: const InputDecoration( + labelText: 'Vendor (e.g. Ruijie, TP-Link)', + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _modelCtrl, + decoration: const InputDecoration(labelText: 'Model'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _serialCtrl, + decoration: const InputDecoration(labelText: 'Serial'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _mgmtIpCtrl, + decoration: + const InputDecoration(labelText: 'Management IP'), + keyboardType: TextInputType.text, + ), + const SizedBox(height: 12), + TextFormField( + controller: _macCtrl, + decoration: const InputDecoration(labelText: 'MAC address'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _notesCtrl, + decoration: const InputDecoration(labelText: 'Notes'), + maxLines: 3, + ), + if (widget.deviceId != null) ...[ + const SizedBox(height: 24), + Row( + children: [ + Text('Ports', + style: Theme.of(context).textTheme.titleMedium), + const Spacer(), + TextButton.icon( + onPressed: () => _addPort(widget.deviceId!), + icon: const Icon(Icons.add), + label: const Text('Add port'), + ), + ], + ), + _PortsList(deviceId: widget.deviceId!), + ], + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +class _PortsList extends ConsumerWidget { + const _PortsList({required this.deviceId}); + final String deviceId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId)); + return portsAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Text('Error: $e'), + data: (ports) { + if (ports.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text('No ports yet.'), + ); + } + return Column( + children: [ + for (final p in ports) + PortListTile( + port: p, + canEdit: true, + onTap: () => showPortEditDialog( + context: context, + ref: ref, + deviceId: deviceId, + existing: p, + ), + onDelete: () => ref + .read(networkDevicesControllerProvider) + .deletePort(p.id, deviceId: deviceId), + ), + ], + ); + }, + ); + } +} diff --git a/lib/screens/network_map/network_map_device_screen.dart b/lib/screens/network_map/network_map_device_screen.dart new file mode 100644 index 00000000..e0e87f75 --- /dev/null +++ b/lib/screens/network_map/network_map_device_screen.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/network/network_device.dart'; +import '../../providers/network_map/network_devices_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../widgets/app_state_view.dart'; +import '../../widgets/responsive_body.dart'; +import 'widgets/port_edit_dialog.dart'; +import 'widgets/port_list_tile.dart'; + +class NetworkMapDeviceScreen extends ConsumerWidget { + const NetworkMapDeviceScreen({super.key, required this.deviceId}); + + final String deviceId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final deviceAsync = ref.watch(networkDeviceByIdProvider(deviceId)); + final portsAsync = ref.watch(networkPortsByDeviceProvider(deviceId)); + final linksAsync = ref.watch(networkLinksProvider); + final allPortsAsync = ref.watch(networkPortsProvider); + final allDevicesAsync = ref.watch(networkDevicesProvider); + final profileAsync = ref.watch(currentProfileProvider); + final canEdit = profileAsync.maybeWhen( + data: (p) => p?.role == 'admin' || p?.role == 'it_staff', + orElse: () => false, + ); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.pop(), + ), + title: deviceAsync.when( + data: (d) => Text(d?.name ?? 'Device'), + loading: () => const Text('Device'), + error: (_, _) => const Text('Device'), + ), + actions: [ + if (canEdit) + IconButton( + tooltip: 'Edit device', + icon: const Icon(Icons.edit_outlined), + onPressed: () => context.go('/network-map/device/$deviceId/edit'), + ), + ], + ), + body: deviceAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => AppErrorView( + error: e, + onRetry: () => ref.invalidate(networkDeviceByIdProvider(deviceId)), + ), + data: (device) { + if (device == null) { + return const AppEmptyView( + icon: Icons.device_unknown_outlined, + title: 'Device not found', + ); + } + final ports = portsAsync.valueOrNull ?? const []; + final links = linksAsync.valueOrNull ?? const []; + final allPorts = allPortsAsync.valueOrNull ?? const []; + final allDevices = allDevicesAsync.valueOrNull ?? const []; + + final portToDevice = { + for (final p in allPorts) p.id: p.deviceId, + }; + final portLabel = { + for (final p in allPorts) p.id: p.portNumber, + }; + final deviceById = { + for (final d in allDevices) d.id: d, + }; + + // For each port of this device, find the link and the other end. + String? otherDeviceName(String portId) { + for (final link in links) { + String? otherPort; + if (link.portA == portId) otherPort = link.portB; + if (link.portB == portId) otherPort = link.portA; + if (otherPort == null) continue; + final otherDevId = portToDevice[otherPort]; + if (otherDevId == null) return null; + return deviceById[otherDevId]?.name; + } + return null; + } + + String? otherPortLabel(String portId) { + for (final link in links) { + if (link.portA == portId) return portLabel[link.portB]; + if (link.portB == portId) return portLabel[link.portA]; + } + return null; + } + + return ResponsiveBody( + maxWidth: 720, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + _MetaCard(device: device), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + 'Ports (${ports.length})', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (ports.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('No ports configured yet.'), + ) + else + for (final port in ports) + PortListTile( + port: port, + linkedDeviceName: otherDeviceName(port.id), + linkedPortLabel: otherPortLabel(port.id), + canEdit: canEdit, + onTap: canEdit + ? () => showPortEditDialog( + context: context, + ref: ref, + deviceId: device.id, + existing: port, + ) + : null, + ), + if (device.notes != null && device.notes!.trim().isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + 'Notes', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(device.notes!), + ), + ], + ], + ), + ); + }, + ), + ); + } +} + +class _MetaCard extends StatelessWidget { + const _MetaCard({required this.device}); + final NetworkDevice device; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + final rows = <(String, String)>[ + ('Type', device.kind.label), + if (device.role != null) ('Role', device.role!.label), + if (device.vendor != null) ('Vendor', device.vendor!), + if (device.model != null) ('Model', device.model!), + if (device.serial != null) ('Serial', device.serial!), + if (device.mgmtIp != null) ('Mgmt IP', device.mgmtIp!), + if (device.mac != null) ('MAC', device.mac!), + ]; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(device.name, style: tt.titleLarge), + const SizedBox(height: 8), + for (final (key, value) in rows) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 90, + child: Text(key, + style: tt.labelMedium?.copyWith(color: cs.onSurfaceVariant)), + ), + Expanded(child: Text(value, style: tt.bodyMedium)), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/network_map/network_map_import_review_screen.dart b/lib/screens/network_map/network_map_import_review_screen.dart new file mode 100644 index 00000000..795f56ee --- /dev/null +++ b/lib/screens/network_map/network_map_import_review_screen.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/network/network_device.dart'; +import '../../models/network/network_link.dart'; +import '../../models/network/network_port.dart'; +import '../../providers/network_map/network_devices_provider.dart'; +import '../../providers/network_map/network_import_provider.dart'; +import '../../providers/network_map/network_sites_provider.dart'; +import '../../widgets/app_state_view.dart'; +import 'widgets/import_diff_view.dart'; + +class NetworkMapImportReviewScreen extends ConsumerWidget { + const NetworkMapImportReviewScreen({super.key, required this.importId}); + + final String importId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final importAsync = ref.watch(networkImportByIdProvider(importId)); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/network-map'), + ), + title: const Text('Review extraction'), + actions: [ + TextButton.icon( + onPressed: () async { + await ref + .read(networkImportControllerProvider) + .discard(importId); + if (context.mounted) context.go('/network-map'); + }, + icon: const Icon(Icons.delete_outline), + label: const Text('Discard'), + ), + ], + ), + body: importAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => AppErrorView(error: e), + data: (imp) { + if (imp == null) { + return const AppEmptyView( + icon: Icons.upload_file_outlined, + title: 'Import not found', + ); + } + final aiResult = imp.aiResult ?? const {}; + return ImportDiffView( + aiResult: aiResult, + onApply: ({ + required acceptedSites, + required acceptedDevices, + required acceptedPorts, + required acceptedLinks, + required acceptedVlans, + }) async { + await _applyExtraction( + context: context, + ref: ref, + importId: importId, + acceptedSites: acceptedSites, + acceptedDevices: acceptedDevices, + acceptedPorts: acceptedPorts, + acceptedLinks: acceptedLinks, + acceptedVlans: acceptedVlans, + ); + }, + ); + }, + ), + ); + } + + Future _applyExtraction({ + required BuildContext context, + required WidgetRef ref, + required String importId, + required List> acceptedSites, + required List> acceptedDevices, + required List> acceptedPorts, + required List> acceptedLinks, + required List> acceptedVlans, + }) async { + final devicesCtrl = ref.read(networkDevicesControllerProvider); + final sitesCtrl = ref.read(networkSitesControllerProvider); + + // Create accepted sites first, keyed by AI-extracted site name. If the AI + // didn't extract any sites, we'll create a single "Imported" site so the + // devices have somewhere to live and become visible on the canvas. + final locationIdBySiteName = {}; + for (final raw in acceptedSites) { + final name = raw['name']?.toString(); + if (name == null || name.isEmpty) continue; + try { + final site = await sitesCtrl.createSite( + name: name, + address: raw['address']?.toString(), + ); + final loc = await sitesCtrl.ensureDefaultLocation(site.id); + locationIdBySiteName[name] = loc.id; + } catch (_) { + // Duplicate or RLS — skip; the unassigned bulk-move fallback will handle it. + } + } + + // Fallback site: if no sites were extracted/accepted, create one so all + // imported devices land in one visible place (admins can rename/split later). + String? fallbackLocationId; + Future getFallbackLocationId() async { + if (fallbackLocationId != null) return fallbackLocationId!; + final site = await sitesCtrl.createSite(name: 'Imported'); + final loc = await sitesCtrl.ensureDefaultLocation(site.id); + fallbackLocationId = loc.id; + return fallbackLocationId!; + } + + // Create devices, keyed by AI name → new device id. + final deviceIdByName = {}; + final createdDeviceIds = []; + for (final raw in acceptedDevices) { + final name = raw['name']?.toString() ?? 'Unnamed'; + final kind = NetworkDeviceKind.fromWire(raw['kind']?.toString()); + // Resolve a location for this device: prefer the AI-extracted site, fall + // back to a synthesized "Imported" site so devices are never orphaned. + String? locationId; + final aiSiteName = raw['site_name']?.toString(); + if (aiSiteName != null && locationIdBySiteName.containsKey(aiSiteName)) { + locationId = locationIdBySiteName[aiSiteName]; + } else { + try { + locationId = await getFallbackLocationId(); + } catch (_) { + // If site creation fails (RLS, dup), leave unassigned — user can + // bulk-move from the overview screen. + locationId = null; + } + } + final created = await devicesCtrl.createDevice( + name: name, + kind: kind, + vendor: raw['vendor']?.toString(), + model: raw['model']?.toString(), + mgmtIp: raw['mgmt_ip']?.toString(), + locationId: locationId, + importSource: NetworkImportSource.aiImport, + ); + deviceIdByName[name] = created.id; + createdDeviceIds.add(created.id); + } + + // Create ports for each accepted port (resolve device by name). + final portIdByDeviceAndNumber = {}; + for (final raw in acceptedPorts) { + final deviceName = raw['device_name']?.toString(); + final portNumber = raw['port_number']?.toString(); + if (deviceName == null || portNumber == null) continue; + final deviceId = deviceIdByName[deviceName]; + if (deviceId == null) continue; + try { + final port = await devicesCtrl.createPort( + deviceId: deviceId, + portNumber: portNumber, + portKind: NetworkPortKind.fromWire(raw['port_kind']?.toString()), + accessVlan: raw['access_vlan'] is int + ? raw['access_vlan'] as int + : int.tryParse(raw['access_vlan']?.toString() ?? ''), + isTrunk: raw['is_trunk'] == true, + ); + portIdByDeviceAndNumber['$deviceId|$portNumber'] = port.id; + } catch (_) { + // Port might already exist due to UNIQUE constraint — skip silently. + } + } + + // Create VLANs. + for (final raw in acceptedVlans) { + final vlanId = raw['vlan_id']; + final name = raw['name']?.toString(); + if (vlanId is! int || name == null) continue; + try { + await devicesCtrl.createVlan( + vlanId: vlanId, + name: name, + description: raw['description']?.toString(), + ); + } catch (_) { + // Duplicate VLAN id — skip. + } + } + + // Create links: need to find/create both endpoint ports. + final createdLinkIds = []; + for (final raw in acceptedLinks) { + final deviceA = raw['device_a']?.toString(); + final deviceB = raw['device_b']?.toString(); + final portALabel = raw['port_a']?.toString() ?? 'unknown'; + final portBLabel = raw['port_b']?.toString() ?? 'unknown'; + if (deviceA == null || deviceB == null) continue; + final devAId = deviceIdByName[deviceA]; + final devBId = deviceIdByName[deviceB]; + if (devAId == null || devBId == null) continue; + + // Resolve or create endpoint ports. + final aKey = '$devAId|$portALabel'; + final bKey = '$devBId|$portBLabel'; + String? portAId = portIdByDeviceAndNumber[aKey]; + String? portBId = portIdByDeviceAndNumber[bKey]; + try { + if (portAId == null) { + final p = await devicesCtrl.createPort( + deviceId: devAId, portNumber: portALabel); + portAId = p.id; + portIdByDeviceAndNumber[aKey] = p.id; + } + if (portBId == null) { + final p = await devicesCtrl.createPort( + deviceId: devBId, portNumber: portBLabel); + portBId = p.id; + portIdByDeviceAndNumber[bKey] = p.id; + } + } catch (_) { + continue; + } + + try { + final link = await devicesCtrl.createLink( + portA: portAId, + portB: portBId, + linkKind: NetworkLinkKind.fromWire(raw['link_kind']?.toString()), + ); + createdLinkIds.add(link.id); + } catch (_) { + // Link already exists or violates CHECK — skip. + } + } + + await ref.read(networkImportControllerProvider).markApplied( + importId: importId, + deviceIds: createdDeviceIds, + linkIds: createdLinkIds, + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text( + 'Applied ${createdDeviceIds.length} devices, ' + '${createdLinkIds.length} links.', + ), + )); + context.go('/network-map'); + } + } +} diff --git a/lib/screens/network_map/network_map_import_screen.dart b/lib/screens/network_map/network_map_import_screen.dart new file mode 100644 index 00000000..7e17e3d2 --- /dev/null +++ b/lib/screens/network_map/network_map_import_screen.dart @@ -0,0 +1,170 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/network/network_import.dart'; +import '../../providers/network_map/network_import_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../widgets/responsive_body.dart'; + +class NetworkMapImportScreen extends ConsumerStatefulWidget { + const NetworkMapImportScreen({super.key}); + + @override + ConsumerState createState() => + _NetworkMapImportScreenState(); +} + +class _NetworkMapImportScreenState extends ConsumerState { + bool _uploading = false; + String? _statusMessage; + + Future _pickAndStart() async { + final result = await FilePicker.platform.pickFiles( + withData: true, + type: FileType.custom, + allowedExtensions: const [ + 'pdf', 'png', 'jpg', 'jpeg', 'webp', 'vsdx', 'csv', 'xlsx', 'docx', + ], + ); + if (result == null || result.files.isEmpty) return; + final picked = result.files.first; + final bytes = picked.bytes; + if (bytes == null) { + setState(() => _statusMessage = 'Could not read file bytes.'); + return; + } + + setState(() { + _uploading = true; + _statusMessage = 'Uploading ${picked.name}…'; + }); + + final profile = ref.read(currentProfileProvider).valueOrNull; + if (profile == null) { + setState(() { + _uploading = false; + _statusMessage = 'Not signed in.'; + }); + return; + } + + try { + final controller = ref.read(networkImportControllerProvider); + final importId = await controller.uploadAndQueueExtraction( + bytes: bytes, + filename: picked.name, + createdBy: profile.id, + ); + + setState(() { + _statusMessage = 'Extracting topology — this can take up to a minute…'; + }); + + final result = await controller.runExtraction(importId); + if (!mounted) return; + + if (result.status == NetworkImportStatus.review) { + context.go('/network-map/import/$importId/review'); + } else if (result.status == NetworkImportStatus.failed) { + setState(() { + _statusMessage = + result.errorMessage ?? 'Extraction failed. Starting from blank canvas.'; + }); + } else { + setState(() { + _statusMessage = 'Unexpected status: ${result.status.wire}'; + }); + } + } catch (e) { + if (mounted) { + setState(() => _statusMessage = 'Error: $e'); + } + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/network-map'), + ), + title: const Text('Import network document'), + ), + body: ResponsiveBody( + maxWidth: 720, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.auto_awesome_outlined, color: cs.primary), + const SizedBox(width: 8), + Text( + 'AI-assisted extraction', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Upload an existing network document — Visio (.vsdx), PDF, ' + 'a photo of a whiteboard, or a spreadsheet — and we\'ll ' + 'try to extract devices, ports, links, and VLANs for you ' + 'to review. If extraction returns nothing useful, you ' + 'can still start with a blank canvas.', + ), + const SizedBox(height: 16), + const Text( + 'Supported: PDF, PNG/JPG/WebP, VSDX, CSV, XLSX, DOCX. ' + 'Max ~10 MB recommended.', + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _uploading ? null : _pickAndStart, + icon: _uploading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.upload_file_outlined), + label: Text(_uploading ? 'Working…' : 'Choose file'), + ), + if (_statusMessage != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Text(_statusMessage!), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/network_map/network_map_overview_screen.dart b/lib/screens/network_map/network_map_overview_screen.dart new file mode 100644 index 00000000..7a9681f4 --- /dev/null +++ b/lib/screens/network_map/network_map_overview_screen.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/network/network_device.dart'; +import '../../providers/network_map/network_devices_provider.dart'; +import '../../providers/network_map/network_sites_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; +import '../../widgets/responsive_body.dart'; +import 'widgets/device_node.dart'; + +class NetworkMapOverviewScreen extends ConsumerWidget { + const NetworkMapOverviewScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sitesAsync = ref.watch(networkSitesProvider); + final devicesAsync = ref.watch(networkDevicesProvider); + final profileAsync = ref.watch(currentProfileProvider); + final canEdit = profileAsync.maybeWhen( + data: (p) => p?.role == 'admin' || p?.role == 'it_staff', + orElse: () => false, + ); + + return Scaffold( + floatingActionButton: canEdit + ? FloatingActionButton.extended( + onPressed: () => _showAddSiteDialog(context, ref), + icon: const Icon(Icons.add_location_alt_outlined), + label: const Text('Add site'), + ) + : null, + body: ResponsiveBody( + maxWidth: double.infinity, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + AppPageHeader( + title: 'Network Infrastructure', + subtitle: 'Drillable map of switches, routers, APs, and links', + actions: canEdit + ? [ + IconButton.filledTonal( + tooltip: 'Import document', + onPressed: () => context.go('/network-map/import'), + icon: const Icon(Icons.upload_file_outlined), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + tooltip: 'VLANs', + onPressed: () => context.go('/network-map/vlans'), + icon: const Icon(Icons.lan_outlined), + ), + ] + : null, + ), + _SummaryRow( + siteCount: sitesAsync.valueOrNull?.length ?? 0, + deviceCount: devicesAsync.valueOrNull?.length ?? 0, + ), + const SizedBox(height: 16), + if (sitesAsync.isLoading) + const Padding( + padding: EdgeInsets.all(40), + child: Center(child: CircularProgressIndicator()), + ) + else if (sitesAsync.hasError) + AppErrorView( + error: sitesAsync.error!, + onRetry: () => ref.invalidate(networkSitesProvider), + ) + else if ((sitesAsync.valueOrNull ?? []).isEmpty) + AppEmptyView( + icon: Icons.hub_outlined, + title: 'No sites yet', + subtitle: canEdit + ? 'Add your first site or import an existing network diagram to get started.' + : 'No network sites have been recorded yet.', + action: canEdit + ? FilledButton.icon( + onPressed: () => _showAddSiteDialog(context, ref), + icon: const Icon(Icons.add_location_alt_outlined), + label: const Text('Add first site'), + ) + : null, + ) + else ...[ + for (final site in sitesAsync.valueOrNull!) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _SiteCard( + name: site.name, + address: site.address, + deviceCount: (devicesAsync.valueOrNull ?? []) + .where((d) => d.locationId != null) + .length, + onTap: () => context.go('/network-map/site/${site.id}'), + ), + ), + ], + _UnassignedSection( + devices: (devicesAsync.valueOrNull ?? []) + .where((d) => d.locationId == null) + .toList(), + canEdit: canEdit, + ), + ], + ), + ), + ); + } + + Future _showAddSiteDialog(BuildContext context, WidgetRef ref) async { + final nameCtrl = TextEditingController(); + final addressCtrl = TextEditingController(); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('New site'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: 'Name'), + autofocus: true, + ), + const SizedBox(height: 8), + TextField( + controller: addressCtrl, + decoration: const InputDecoration(labelText: 'Address (optional)'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Create'), + ), + ], + ), + ); + if (result == true && nameCtrl.text.trim().isNotEmpty) { + final profile = ref.read(currentProfileProvider).valueOrNull; + await ref.read(networkSitesControllerProvider).createSite( + name: nameCtrl.text.trim(), + address: addressCtrl.text.trim().isEmpty ? null : addressCtrl.text.trim(), + createdBy: profile?.id, + ); + } + } +} + +class _SummaryRow extends StatelessWidget { + const _SummaryRow({required this.siteCount, required this.deviceCount}); + final int siteCount; + final int deviceCount; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded(child: _SummaryCard(label: 'Sites', value: siteCount.toString(), icon: Icons.location_city_outlined)), + const SizedBox(width: 12), + Expanded(child: _SummaryCard(label: 'Devices', value: deviceCount.toString(), icon: Icons.devices_outlined)), + ], + ); + } +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({required this.label, required this.value, required this.icon}); + final String label; + final String value; + final IconData icon; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Icon(icon, color: cs.primary), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant)), + Text(value, style: tt.headlineSmall?.copyWith(fontWeight: FontWeight.w700)), + ], + ), + ], + ), + ), + ); + } +} + +class _UnassignedSection extends ConsumerWidget { + const _UnassignedSection({required this.devices, required this.canEdit}); + + final List devices; + final bool canEdit; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (devices.isEmpty) return const SizedBox.shrink(); + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 24), + child: Card( + color: cs.tertiaryContainer.withValues(alpha: 0.3), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.help_outline, color: cs.tertiary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Unassigned devices (${devices.length})', + style: tt.titleMedium, + ), + ), + if (canEdit) + TextButton.icon( + onPressed: () => _showBulkAssignDialog(context, ref, devices), + icon: const Icon(Icons.move_up), + label: const Text('Assign all to site…'), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'These devices have no site/location yet, so they don\'t show on ' + 'any topology canvas. Tap one to assign it.', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final d in devices) + DeviceNode( + device: d, + onTap: () => context.go('/network-map/device/${d.id}/edit'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _showBulkAssignDialog( + BuildContext context, + WidgetRef ref, + List devices, + ) async { + final sites = ref.read(networkSitesProvider).valueOrNull ?? const []; + if (sites.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Create a site first.')), + ); + return; + } + String? picked = sites.first.id; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setLocal) => AlertDialog( + title: Text('Assign ${devices.length} devices to a site'), + content: DropdownButtonFormField( + initialValue: picked, + items: [ + for (final s in sites) + DropdownMenuItem(value: s.id, child: Text(s.name)), + ], + onChanged: (v) => setLocal(() => picked = v), + decoration: const InputDecoration(labelText: 'Target site'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Assign'), + ), + ], + ), + ), + ); + if (ok != true || picked == null) return; + + final sitesCtrl = ref.read(networkSitesControllerProvider); + final devicesCtrl = ref.read(networkDevicesControllerProvider); + final location = await sitesCtrl.ensureDefaultLocation(picked!); + + for (final d in devices) { + final updated = d.copyWith(locationId: location.id); + await devicesCtrl.updateDevice(updated); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Moved ${devices.length} devices.')), + ); + } + } +} + +class _SiteCard extends StatelessWidget { + const _SiteCard({ + required this.name, + required this.address, + required this.deviceCount, + required this.onTap, + }); + + final String name; + final String? address; + final int deviceCount; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + return Card( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + CircleAvatar( + backgroundColor: cs.primaryContainer, + child: Icon(Icons.location_city, color: cs.onPrimaryContainer), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: tt.titleMedium), + if (address != null && address!.isNotEmpty) + Text(address!, style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant)), + ], + ), + ), + Text('$deviceCount devices', style: tt.labelMedium?.copyWith(color: cs.onSurfaceVariant)), + const SizedBox(width: 8), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/network_map/network_map_site_screen.dart b/lib/screens/network_map/network_map_site_screen.dart new file mode 100644 index 00000000..96801a53 --- /dev/null +++ b/lib/screens/network_map/network_map_site_screen.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/network/topology_graph.dart'; +import '../../providers/network_map/network_sites_provider.dart'; +import '../../providers/network_map/network_topology_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../widgets/app_state_view.dart'; +import 'widgets/topology_canvas.dart'; + +class NetworkMapSiteScreen extends ConsumerWidget { + const NetworkMapSiteScreen({super.key, required this.siteId}); + + final String siteId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final topologyAsync = ref.watch(topologyForSiteProvider(siteId)); + final viewMode = ref.watch(topologyViewModeProvider); + final sitesAsync = ref.watch(networkSitesProvider); + final profileAsync = ref.watch(currentProfileProvider); + final canEdit = profileAsync.maybeWhen( + data: (p) => p?.role == 'admin' || p?.role == 'it_staff', + orElse: () => false, + ); + + final siteName = sitesAsync.valueOrNull + ?.firstWhere( + (s) => s.id == siteId, + orElse: () => sitesAsync.valueOrNull!.first, + ) + .name; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/network-map'), + ), + title: Text(siteName ?? 'Site'), + actions: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: TopologyViewMode.physical, + label: Text('Physical'), + icon: Icon(Icons.business_outlined), + ), + ButtonSegment( + value: TopologyViewMode.logical, + label: Text('Logical'), + icon: Icon(Icons.account_tree_outlined), + ), + ], + selected: {viewMode}, + onSelectionChanged: (s) { + ref.read(topologyViewModeProvider.notifier).state = s.first; + }, + ), + const SizedBox(width: 8), + if (canEdit) + IconButton( + tooltip: 'Add device', + icon: const Icon(Icons.add_circle_outline), + onPressed: () => + context.go('/network-map/site/$siteId/device/new'), + ), + ], + ), + body: topologyAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => AppErrorView( + error: e, + onRetry: () => ref.invalidate(topologyForSiteProvider(siteId)), + ), + data: (graph) => TopologyCanvas( + graph: graph, + viewMode: viewMode, + onDeviceTap: (device) => + context.go('/network-map/device/${device.id}'), + ), + ), + ); + } +} diff --git a/lib/screens/network_map/network_map_vlan_screen.dart b/lib/screens/network_map/network_map_vlan_screen.dart new file mode 100644 index 00000000..f8e851de --- /dev/null +++ b/lib/screens/network_map/network_map_vlan_screen.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../providers/network_map/network_devices_provider.dart'; +import '../../providers/profile_provider.dart'; +import '../../widgets/app_page_header.dart'; +import '../../widgets/app_state_view.dart'; +import '../../widgets/responsive_body.dart'; + +class NetworkMapVlanScreen extends ConsumerWidget { + const NetworkMapVlanScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vlansAsync = ref.watch(networkVlansProvider); + final profileAsync = ref.watch(currentProfileProvider); + final canEdit = profileAsync.maybeWhen( + data: (p) => p?.role == 'admin' || p?.role == 'it_staff', + orElse: () => false, + ); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/network-map'), + ), + title: const Text('VLANs'), + ), + floatingActionButton: canEdit + ? FloatingActionButton.extended( + onPressed: () => _showAddDialog(context, ref), + icon: const Icon(Icons.add), + label: const Text('New VLAN'), + ) + : null, + body: ResponsiveBody( + maxWidth: 720, + child: vlansAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => AppErrorView(error: e), + data: (vlans) { + if (vlans.isEmpty) { + return const AppEmptyView( + icon: Icons.lan_outlined, + title: 'No VLANs defined', + subtitle: 'Add a VLAN to start tagging access ports.', + ); + } + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + const AppPageHeader(title: 'VLANs', subtitle: 'Network segmentation'), + for (final v in vlans) + Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: _parseColor(v.color) ?? + Theme.of(context).colorScheme.primaryContainer, + child: Text( + v.vlanId.toString(), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + title: Text(v.name), + subtitle: v.description == null ? null : Text(v.description!), + trailing: canEdit + ? IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => ref + .read(networkDevicesControllerProvider) + .deleteVlan(v.id), + ) + : null, + ), + ), + ], + ); + }, + ), + ), + ); + } + + Color? _parseColor(String? hex) { + if (hex == null) return null; + var s = hex.replaceFirst('#', ''); + if (s.length == 6) s = 'FF$s'; + final n = int.tryParse(s, radix: 16); + if (n == null) return null; + return Color(n); + } + + Future _showAddDialog(BuildContext context, WidgetRef ref) async { + final idCtrl = TextEditingController(); + final nameCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('New VLAN'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: idCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'VLAN ID (1-4094)'), + autofocus: true, + ), + const SizedBox(height: 8), + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: 'Name'), + ), + const SizedBox(height: 8), + TextField( + controller: descCtrl, + decoration: + const InputDecoration(labelText: 'Description (optional)'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Create'), + ), + ], + ), + ); + final vlanId = int.tryParse(idCtrl.text.trim()); + final name = nameCtrl.text.trim(); + if (ok == true && vlanId != null && vlanId >= 1 && vlanId <= 4094 && name.isNotEmpty) { + await ref.read(networkDevicesControllerProvider).createVlan( + vlanId: vlanId, + name: name, + description: descCtrl.text.trim().isEmpty ? null : descCtrl.text.trim(), + ); + } + } +} diff --git a/lib/screens/network_map/widgets/device_node.dart b/lib/screens/network_map/widgets/device_node.dart new file mode 100644 index 00000000..104d3e58 --- /dev/null +++ b/lib/screens/network_map/widgets/device_node.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; + +import '../../../models/network/network_device.dart'; + +/// A styled node representing one network device on the topology canvas. +/// +/// Designed to feel solid at ~120-160dp wide so it remains legible at the +/// "good for 200 nodes" zoom level called out in the V1 spec. +/// +/// The node has three visual states beyond its default rest: +/// - [isSelected]: stronger primary-tinted background + 2dp primary border. +/// - [isHighlighted]: softer secondary tint + primary outline, used when an +/// edge touching this device is hovered on the canvas. +/// - [isSelected] takes precedence over [isHighlighted]. +class DeviceNode extends StatelessWidget { + const DeviceNode({ + super.key, + required this.device, + this.portCount, + this.isSelected = false, + this.isHighlighted = false, + this.onTap, + }); + + final NetworkDevice device; + final int? portCount; + final bool isSelected; + final bool isHighlighted; + final VoidCallback? onTap; + + IconData _iconForKind(NetworkDeviceKind kind) { + switch (kind) { + case NetworkDeviceKind.router: + return Icons.router_outlined; + case NetworkDeviceKind.switchDevice: + return Icons.hub_outlined; + case NetworkDeviceKind.ap: + return Icons.wifi_outlined; + case NetworkDeviceKind.firewall: + return Icons.security_outlined; + case NetworkDeviceKind.server: + return Icons.dns_outlined; + case NetworkDeviceKind.endpoint: + return Icons.devices_outlined; + case NetworkDeviceKind.patchPanel: + return Icons.view_module_outlined; + case NetworkDeviceKind.other: + return Icons.device_unknown_outlined; + } + } + + Color _accentForRole(ColorScheme cs, NetworkDeviceRole? role) { + switch (role) { + case NetworkDeviceRole.core: + return cs.error; + case NetworkDeviceRole.distribution: + return cs.tertiary; + case NetworkDeviceRole.access: + return cs.primary; + case NetworkDeviceRole.edge: + return cs.secondary; + case NetworkDeviceRole.endpoint: + case null: + return cs.outline; + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + final accent = _accentForRole(cs, device.role); + + final bgColor = isSelected + ? cs.primaryContainer + : isHighlighted + ? cs.secondaryContainer.withValues(alpha: 0.6) + : cs.surfaceContainerHigh; + final borderColor = isSelected + ? cs.primary + : isHighlighted + ? cs.primary.withValues(alpha: 0.8) + : accent.withValues(alpha: 0.6); + final borderWidth = isSelected ? 2.0 : (isHighlighted ? 1.5 : 1.0); + + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: borderColor, width: borderWidth), + boxShadow: (isHighlighted || isSelected) + ? [ + BoxShadow( + color: cs.primary.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + width: 160, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(_iconForKind(device.kind), size: 18, color: accent), + const SizedBox(width: 6), + Expanded( + child: Text( + device.kind.label, + style: tt.labelSmall?.copyWith(color: accent), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + device.name, + style: tt.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (device.vendor != null || device.model != null) ...[ + const SizedBox(height: 2), + Text( + [ + if (device.vendor != null) device.vendor, + if (device.model != null) device.model, + ].whereType().join(' • '), + style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (portCount != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.cable_outlined, + size: 12, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '$portCount ports', + style: tt.labelSmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/network_map/widgets/import_diff_view.dart b/lib/screens/network_map/widgets/import_diff_view.dart new file mode 100644 index 00000000..838e87b6 --- /dev/null +++ b/lib/screens/network_map/widgets/import_diff_view.dart @@ -0,0 +1,326 @@ +import 'package:flutter/material.dart'; + +/// Side-by-side review of an AI extraction result. +/// +/// Left column: items the AI proposed (devices, ports, links, vlans), each +/// togglable on/off with confidence tag. Right column: a summary panel that +/// shows what will be applied. Apply button persists the toggled-on items. +class ImportDiffView extends StatefulWidget { + const ImportDiffView({ + super.key, + required this.aiResult, + required this.onApply, + }); + + final Map aiResult; + final Future Function({ + required List> acceptedDevices, + required List> acceptedPorts, + required List> acceptedLinks, + required List> acceptedVlans, + required List> acceptedSites, + }) onApply; + + @override + State createState() => _ImportDiffViewState(); +} + +class _ImportDiffViewState extends State { + late final Set _enabledDevices; + late final Set _enabledPorts; + late final Set _enabledLinks; + late final Set _enabledVlans; + late final Set _enabledSites; + bool _applying = false; + + List> _asList(dynamic value) { + if (value is! List) return const []; + return value + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + + @override + void initState() { + super.initState(); + _enabledDevices = { + for (var i = 0; i < _asList(widget.aiResult['devices']).length; i++) i, + }; + _enabledPorts = { + for (var i = 0; i < _asList(widget.aiResult['ports']).length; i++) i, + }; + _enabledLinks = { + for (var i = 0; i < _asList(widget.aiResult['links']).length; i++) i, + }; + _enabledVlans = { + for (var i = 0; i < _asList(widget.aiResult['vlans']).length; i++) i, + }; + _enabledSites = { + for (var i = 0; i < _asList(widget.aiResult['sites']).length; i++) i, + }; + } + + Color _confidenceColor(BuildContext context, String? c) { + final cs = Theme.of(context).colorScheme; + switch (c) { + case 'high': + return cs.primary; + case 'medium': + return cs.tertiary; + case 'low': + return cs.error; + default: + return cs.outline; + } + } + + Widget _itemTile({ + required String primary, + String? secondary, + String? confidence, + required bool enabled, + required ValueChanged onToggle, + }) { + return CheckboxListTile( + dense: true, + value: enabled, + onChanged: onToggle, + controlAffinity: ListTileControlAffinity.leading, + title: Text(primary), + subtitle: secondary != null ? Text(secondary) : null, + secondary: confidence == null + ? null + : Chip( + label: Text(confidence), + backgroundColor: + _confidenceColor(context, confidence).withValues(alpha: 0.15), + labelStyle: TextStyle(color: _confidenceColor(context, confidence)), + side: BorderSide.none, + padding: const EdgeInsets.symmetric(horizontal: 4), + ), + ); + } + + Future _onApplyPressed() async { + setState(() => _applying = true); + try { + final allSites = _asList(widget.aiResult['sites']); + final allDevices = _asList(widget.aiResult['devices']); + final allPorts = _asList(widget.aiResult['ports']); + final allLinks = _asList(widget.aiResult['links']); + final allVlans = _asList(widget.aiResult['vlans']); + + await widget.onApply( + acceptedSites: [ + for (var i = 0; i < allSites.length; i++) + if (_enabledSites.contains(i)) allSites[i], + ], + acceptedDevices: [ + for (var i = 0; i < allDevices.length; i++) + if (_enabledDevices.contains(i)) allDevices[i], + ], + acceptedPorts: [ + for (var i = 0; i < allPorts.length; i++) + if (_enabledPorts.contains(i)) allPorts[i], + ], + acceptedLinks: [ + for (var i = 0; i < allLinks.length; i++) + if (_enabledLinks.contains(i)) allLinks[i], + ], + acceptedVlans: [ + for (var i = 0; i < allVlans.length; i++) + if (_enabledVlans.contains(i)) allVlans[i], + ], + ); + } finally { + if (mounted) setState(() => _applying = false); + } + } + + @override + Widget build(BuildContext context) { + final sites = _asList(widget.aiResult['sites']); + final devices = _asList(widget.aiResult['devices']); + final ports = _asList(widget.aiResult['ports']); + final links = _asList(widget.aiResult['links']); + final vlans = _asList(widget.aiResult['vlans']); + final notes = widget.aiResult['notes']?.toString() ?? ''; + + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + if (notes.isNotEmpty) + Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(notes)), + ], + ), + ), + ), + if (sites.isNotEmpty) ...[ + _sectionHeader('Sites (${sites.length})'), + for (var i = 0; i < sites.length; i++) + _itemTile( + primary: sites[i]['name']?.toString() ?? '(unnamed)', + secondary: sites[i]['address']?.toString(), + enabled: _enabledSites.contains(i), + onToggle: (v) => setState(() { + if (v == true) { + _enabledSites.add(i); + } else { + _enabledSites.remove(i); + } + }), + ), + ], + if (devices.isNotEmpty) ...[ + _sectionHeader('Devices (${devices.length})'), + for (var i = 0; i < devices.length; i++) + _itemTile( + primary: devices[i]['name']?.toString() ?? '(unnamed)', + secondary: [ + devices[i]['kind']?.toString() ?? '', + if (devices[i]['vendor'] != null) devices[i]['vendor'], + if (devices[i]['model'] != null) devices[i]['model'], + ].whereType().where((s) => s.isNotEmpty).join(' • '), + confidence: devices[i]['confidence']?.toString(), + enabled: _enabledDevices.contains(i), + onToggle: (v) => setState(() { + if (v == true) { + _enabledDevices.add(i); + } else { + _enabledDevices.remove(i); + } + }), + ), + ], + if (ports.isNotEmpty) ...[ + _sectionHeader('Ports (${ports.length})'), + for (var i = 0; i < ports.length; i++) + _itemTile( + primary: + '${ports[i]['device_name'] ?? '?'} · ${ports[i]['port_number'] ?? '?'}', + secondary: ports[i]['port_kind']?.toString(), + enabled: _enabledPorts.contains(i), + onToggle: (v) => setState(() { + if (v == true) { + _enabledPorts.add(i); + } else { + _enabledPorts.remove(i); + } + }), + ), + ], + if (links.isNotEmpty) ...[ + _sectionHeader('Links (${links.length})'), + for (var i = 0; i < links.length; i++) + _itemTile( + primary: + '${links[i]['device_a'] ?? '?'} ↔ ${links[i]['device_b'] ?? '?'}', + secondary: [ + if (links[i]['port_a'] != null) 'A: ${links[i]['port_a']}', + if (links[i]['port_b'] != null) 'B: ${links[i]['port_b']}', + if (links[i]['link_kind'] != null) links[i]['link_kind'], + ].whereType().join(' • '), + confidence: links[i]['confidence']?.toString(), + enabled: _enabledLinks.contains(i), + onToggle: (v) => setState(() { + if (v == true) { + _enabledLinks.add(i); + } else { + _enabledLinks.remove(i); + } + }), + ), + ], + if (vlans.isNotEmpty) ...[ + _sectionHeader('VLANs (${vlans.length})'), + for (var i = 0; i < vlans.length; i++) + _itemTile( + primary: + 'VLAN ${vlans[i]['vlan_id'] ?? '?'} · ${vlans[i]['name'] ?? ''}', + secondary: vlans[i]['description']?.toString(), + enabled: _enabledVlans.contains(i), + onToggle: (v) => setState(() { + if (v == true) { + _enabledVlans.add(i); + } else { + _enabledVlans.remove(i); + } + }), + ), + ], + if (sites.isEmpty && + devices.isEmpty && + ports.isEmpty && + links.isEmpty && + vlans.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Text( + 'Extraction returned no structured items. You can still ' + 'discard this import and start from a blank canvas.', + ), + ), + ), + ], + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'Selected: ${_enabledSites.length} sites, ' + '${_enabledDevices.length} devices, ' + '${_enabledLinks.length} links', + style: Theme.of(context).textTheme.labelSmall, + ), + ), + FilledButton.icon( + onPressed: _applying ? null : _onApplyPressed, + icon: _applying + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check), + label: Text(_applying ? 'Applying…' : 'Apply selected'), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _sectionHeader(String label) { + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 4), + child: Text( + label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/screens/network_map/widgets/port_edit_dialog.dart b/lib/screens/network_map/widgets/port_edit_dialog.dart new file mode 100644 index 00000000..625033bb --- /dev/null +++ b/lib/screens/network_map/widgets/port_edit_dialog.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/network/network_port.dart'; +import '../../../providers/network_map/network_devices_provider.dart'; + +/// Opens an M3 dialog for creating or editing a network port. +/// +/// When [existing] is null, the dialog operates in "add" mode and creates a new +/// port on [deviceId]. When [existing] is supplied, the dialog edits that port +/// in place. Returns true if the user saved a change. +Future showPortEditDialog({ + required BuildContext context, + required WidgetRef ref, + required String deviceId, + NetworkPort? existing, +}) async { + final result = await showDialog( + context: context, + builder: (ctx) => _PortEditDialog(deviceId: deviceId, existing: existing), + ); + return result == true; +} + +class _PortEditDialog extends ConsumerStatefulWidget { + const _PortEditDialog({required this.deviceId, this.existing}); + + final String deviceId; + final NetworkPort? existing; + + @override + ConsumerState<_PortEditDialog> createState() => _PortEditDialogState(); +} + +class _PortEditDialogState extends ConsumerState<_PortEditDialog> { + final _numberCtrl = TextEditingController(); + final _speedCtrl = TextEditingController(); + final _vlanCtrl = TextEditingController(); + final _trunkVlansCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + NetworkPortKind? _kind; + bool _isTrunk = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + final e = widget.existing; + if (e != null) { + _numberCtrl.text = e.portNumber; + _kind = e.portKind; + _speedCtrl.text = e.speedMbps?.toString() ?? ''; + _vlanCtrl.text = e.accessVlan?.toString() ?? ''; + _isTrunk = e.isTrunk; + _trunkVlansCtrl.text = e.trunkVlans.join(', '); + _notesCtrl.text = e.notes ?? ''; + } else { + _kind = NetworkPortKind.rj45; + } + } + + @override + void dispose() { + _numberCtrl.dispose(); + _speedCtrl.dispose(); + _vlanCtrl.dispose(); + _trunkVlansCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + List _parseTrunkVlans(String input) { + return input + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .map(int.tryParse) + .whereType() + .toList(); + } + + Future _save() async { + final number = _numberCtrl.text.trim(); + if (number.isEmpty) return; + setState(() => _saving = true); + try { + final controller = ref.read(networkDevicesControllerProvider); + final speed = _speedCtrl.text.trim().isEmpty + ? null + : int.tryParse(_speedCtrl.text.trim()); + final vlan = _vlanCtrl.text.trim().isEmpty || _isTrunk + ? null + : int.tryParse(_vlanCtrl.text.trim()); + final trunkVlans = _isTrunk ? _parseTrunkVlans(_trunkVlansCtrl.text) : []; + final notes = _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(); + + if (widget.existing == null) { + await controller.createPort( + deviceId: widget.deviceId, + portNumber: number, + portKind: _kind, + speedMbps: speed, + accessVlan: vlan, + isTrunk: _isTrunk, + trunkVlans: trunkVlans, + notes: notes, + ); + } else { + await controller.updatePort( + id: widget.existing!.id, + deviceId: widget.deviceId, + portNumber: number, + portKind: _kind, + speedMbps: speed, + accessVlan: vlan, + isTrunk: _isTrunk, + trunkVlans: trunkVlans, + notes: notes, + ); + } + if (mounted) Navigator.of(context).pop(true); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + return AlertDialog( + title: Text(widget.existing == null ? 'Add port' : 'Edit port'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _numberCtrl, + autofocus: widget.existing == null, + decoration: const InputDecoration( + labelText: 'Port number *', + helperText: 'Exactly as labeled on the device (e.g. Gi0/1, eth0, 24)', + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _kind, + items: [ + const DropdownMenuItem( + value: null, + child: Text('(unknown)'), + ), + for (final k in NetworkPortKind.values) + DropdownMenuItem(value: k, child: Text(k.label)), + ], + onChanged: (v) => setState(() => _kind = v), + decoration: const InputDecoration(labelText: 'Kind'), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _speedCtrl, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: 'Speed (Mbps)', + helperText: 'e.g. 1000, 10000', + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _vlanCtrl, + enabled: !_isTrunk, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: 'Access VLAN', + helperText: _isTrunk + ? 'Disabled — trunk port' + : 'Single VLAN ID (1–4094)', + ), + ), + ), + ], + ), + const SizedBox(height: 12), + SwitchListTile.adaptive( + value: _isTrunk, + onChanged: (v) => setState(() => _isTrunk = v), + contentPadding: EdgeInsets.zero, + title: const Text('Trunk port'), + subtitle: Text( + 'Carries multiple VLANs', + style: tt.bodySmall, + ), + ), + if (_isTrunk) ...[ + const SizedBox(height: 4), + TextField( + controller: _trunkVlansCtrl, + decoration: const InputDecoration( + labelText: 'Allowed VLANs (comma-separated)', + helperText: 'e.g. 10, 20, 100-105', + ), + ), + ], + const SizedBox(height: 12), + TextField( + controller: _notesCtrl, + maxLines: 2, + decoration: const InputDecoration(labelText: 'Notes'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: Text(_saving ? 'Saving…' : 'Save'), + ), + ], + ); + } +} diff --git a/lib/screens/network_map/widgets/port_list_tile.dart b/lib/screens/network_map/widgets/port_list_tile.dart new file mode 100644 index 00000000..b93439e8 --- /dev/null +++ b/lib/screens/network_map/widgets/port_list_tile.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../../../models/network/network_port.dart'; + +class PortListTile extends StatelessWidget { + const PortListTile({ + super.key, + required this.port, + this.linkedDeviceName, + this.linkedPortLabel, + this.canEdit = false, + this.onTap, + this.onDelete, + }); + + final NetworkPort port; + final String? linkedDeviceName; + final String? linkedPortLabel; + final bool canEdit; + final VoidCallback? onTap; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + final isLinked = linkedDeviceName != null; + final subtitleParts = [ + if (port.portKind != null) port.portKind!.label, + if (port.speedMbps != null) '${port.speedMbps} Mbps', + if (port.isTrunk) 'Trunk', + if (!port.isTrunk && port.accessVlan != null) 'VLAN ${port.accessVlan}', + ]; + + return ListTile( + onTap: onTap, + leading: CircleAvatar( + backgroundColor: isLinked + ? cs.primaryContainer + : cs.surfaceContainerHighest, + child: Icon( + isLinked ? Icons.cable : Icons.cable_outlined, + color: isLinked ? cs.onPrimaryContainer : cs.onSurfaceVariant, + size: 18, + ), + ), + title: Text(port.portNumber, style: tt.bodyMedium), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (subtitleParts.isNotEmpty) + Text( + subtitleParts.join(' • '), + style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant), + ), + if (isLinked) + Text( + '↔ $linkedDeviceName${linkedPortLabel != null ? " · $linkedPortLabel" : ""}', + style: tt.bodySmall?.copyWith(color: cs.primary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + trailing: canEdit + ? IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete port', + onPressed: onDelete, + ) + : null, + ); + } +} diff --git a/lib/screens/network_map/widgets/topology_canvas.dart b/lib/screens/network_map/widgets/topology_canvas.dart new file mode 100644 index 00000000..ea789712 --- /dev/null +++ b/lib/screens/network_map/widgets/topology_canvas.dart @@ -0,0 +1,823 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../models/network/network_device.dart'; +import '../../../models/network/topology_graph.dart'; +import '../layout/sugiyama_layout.dart'; +import 'device_node.dart'; + +/// Pan + zoom topology canvas with Sugiyama-layered layout, M3-styled edges, +/// line-crossing jumps, port labels, multi-layer Bézier routing, and animated +/// transitions between view modes. +/// +/// Layout pipeline: +/// 1. On graph/viewMode change, [computeSugiyamaLayout] produces node +/// positions and edge waypoints. Positions are saved as a "target." +/// 2. An [AnimationController] interpolates from the previous positions to +/// the target over 600ms with [Curves.easeInOutCubicEmphasized]. +/// 3. Each frame during the transition, edge geometries and line-jump +/// crossing points are recomputed from the interpolated positions. +/// O(E²) per frame is acceptable for ≤500 edges. +/// +/// Rendering: +/// * Edges with no waypoints (single-layer span) draw as straight lines. +/// * Edges with waypoints (multi-layer span) draw as sequential quadratic +/// Bézier curves passing through each waypoint. The virtual nodes +/// informing those waypoints are never drawn as visible widgets. +/// * Line-jump arcs at crossings apply only to straight segments. +/// +/// Performance: +/// * Hover state on two [ValueNotifier]s; the painter is wrapped in a +/// [RepaintBoundary] so hover only repaints the painter, not the widget +/// tree. +/// * Pan/zoom is pure Matrix4 transform via [InteractiveViewer]; no rebuild. +class TopologyCanvas extends StatefulWidget { + const TopologyCanvas({ + super.key, + required this.graph, + required this.viewMode, + this.selectedDeviceId, + this.onDeviceTap, + }); + + final TopologyGraph graph; + final TopologyViewMode viewMode; + final String? selectedDeviceId; + final void Function(NetworkDevice device)? onDeviceTap; + + @override + State createState() => _TopologyCanvasState(); +} + +class _TopologyCanvasState extends State + with SingleTickerProviderStateMixin { + // ─── Hover state ───────────────────────────────────────────────────────── + final ValueNotifier _hoveredEdgeId = ValueNotifier(null); + final ValueNotifier _hoveredDeviceId = ValueNotifier(null); + + // ─── Animation ─────────────────────────────────────────────────────────── + late AnimationController _layoutAnim; + + // ─── Layout snapshots (for interpolation) ──────────────────────────────── + Map _prevPositions = const {}; + Map _targetPositions = const {}; + Map> _prevWaypoints = const {}; + Map> _targetWaypoints = const {}; + Size _canvasSize = const Size(600, 400); + Map> _deviceToEdges = const {}; + + // ─── Constants ─────────────────────────────────────────────────────────── + static const Size _nodeSize = Size(160, 110); + static const double _hitTolerance = 8; + static const double _jumpRadius = 6; + + @override + void initState() { + super.initState(); + _layoutAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _runLayout(animate: false); + } + + @override + void didUpdateWidget(covariant TopologyCanvas oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.graph, widget.graph) || + oldWidget.viewMode != widget.viewMode) { + _runLayout(animate: true); + } + } + + @override + void dispose() { + _hoveredEdgeId.dispose(); + _hoveredDeviceId.dispose(); + _layoutAnim.dispose(); + super.dispose(); + } + + // ─── Layout orchestration ──────────────────────────────────────────────── + + void _runLayout({required bool animate}) { + // Snapshot current interpolated positions BEFORE recomputing target. + final newPrev = animate + ? Map.from(_currentPositions()) + : {}; + final newPrevWaypoints = animate + ? Map>.from(_currentWaypoints()) + : >{}; + + final input = _buildSugiyamaInput(); + final result = computeSugiyamaLayout(input); + + setState(() { + _prevPositions = newPrev.isEmpty ? result.nodePositions : newPrev; + _targetPositions = result.nodePositions; + _prevWaypoints = + newPrevWaypoints.isEmpty ? result.edgeWaypoints : newPrevWaypoints; + _targetWaypoints = result.edgeWaypoints; + _canvasSize = result.canvasSize; + _deviceToEdges = _computeDeviceEdgeMap(); + }); + + if (animate) { + _layoutAnim.value = 0; + _layoutAnim.forward(); + } else { + _layoutAnim.value = 1.0; + } + } + + SugiyamaInput _buildSugiyamaInput() { + final nodeIds = widget.graph.nodes.map((n) => n.device.id).toList(); + final edges = widget.graph.edges + .map((e) => SugiyamaEdge( + id: e.link.id, + from: e.fromDeviceId, + to: e.toDeviceId, + )) + .toList(); + + Map? preassigned; + if (widget.viewMode == TopologyViewMode.logical) { + preassigned = {}; + for (final node in widget.graph.nodes) { + final role = node.device.role; + if (role != null) { + preassigned[node.device.id] = _roleToLayer(role); + } + } + } + // Physical view: no preassignment; the algorithm derives layers from + // graph structure via longest-path. We could supply location depth as a + // hint here in a future iteration. + + return SugiyamaInput( + nodeIds: nodeIds, + edges: edges, + preassignedLayers: preassigned, + // Sizes default for now; future: pass per-device width if cards become + // variable-width. + ); + } + + int _roleToLayer(NetworkDeviceRole role) { + switch (role) { + case NetworkDeviceRole.core: + return 0; + case NetworkDeviceRole.distribution: + return 1; + case NetworkDeviceRole.access: + return 2; + case NetworkDeviceRole.edge: + return 3; + case NetworkDeviceRole.endpoint: + return 4; + } + } + + // ─── Interpolation ─────────────────────────────────────────────────────── + + /// Node positions at the current animation `t`. Linearly interpolates between + /// previous and target positions. Nodes that newly appeared use target as + /// both endpoints (they pop in place). + Map _currentPositions() { + final t = _layoutAnim.value; + if (t >= 1.0) return _targetPositions; + if (t <= 0.0) return _prevPositions.isEmpty ? _targetPositions : _prevPositions; + final out = {}; + for (final entry in _targetPositions.entries) { + final target = entry.value; + final prev = _prevPositions[entry.key] ?? target; + out[entry.key] = Offset.lerp(prev, target, t)!; + } + return out; + } + + /// Edge waypoints at the current animation `t`. Lerps element-wise when the + /// previous and target waypoint lists have the same length; otherwise + /// snaps to target (the rare case of an edge going from N-layer to M-layer). + Map> _currentWaypoints() { + final t = _layoutAnim.value; + if (t >= 1.0) return _targetWaypoints; + if (t <= 0.0) return _prevWaypoints.isEmpty ? _targetWaypoints : _prevWaypoints; + final out = >{}; + for (final entry in _targetWaypoints.entries) { + final target = entry.value; + final prev = _prevWaypoints[entry.key]; + if (prev == null || prev.length != target.length) { + out[entry.key] = target; + continue; + } + out[entry.key] = [ + for (var i = 0; i < target.length; i++) + Offset.lerp(prev[i], target[i], t)!, + ]; + } + return out; + } + + Map> _computeDeviceEdgeMap() { + final map = >{}; + for (final edge in widget.graph.edges) { + map.putIfAbsent(edge.fromDeviceId, () => []).add(edge.link.id); + map.putIfAbsent(edge.toDeviceId, () => []).add(edge.link.id); + } + return map; + } + + // ─── Edge geometry ─────────────────────────────────────────────────────── + + List<_EdgeGeometry> _buildEdgeGeometries( + Map positions, + Map> waypoints, + ) { + final out = <_EdgeGeometry>[]; + for (final edge in widget.graph.edges) { + final fromTopLeft = positions[edge.fromDeviceId]; + final toTopLeft = positions[edge.toDeviceId]; + if (fromTopLeft == null || toTopLeft == null) continue; + + final fromCenter = Offset( + fromTopLeft.dx + _nodeSize.width / 2, + fromTopLeft.dy + _nodeSize.height / 2, + ); + final toCenter = Offset( + toTopLeft.dx + _nodeSize.width / 2, + toTopLeft.dy + _nodeSize.height / 2, + ); + + // For edges with waypoints (multi-layer), exit points face the first/last + // waypoint rather than the other endpoint, so the curve enters/exits the + // card cleanly. + final wps = waypoints[edge.link.id] ?? const []; + final firstAimAt = wps.isNotEmpty ? wps.first : toCenter; + final lastAimAt = wps.isNotEmpty ? wps.last : fromCenter; + final fromSide = _sideFacing(fromCenter, firstAimAt); + final toSide = _sideFacing(toCenter, lastAimAt); + final fromExit = _sideMidpoint(fromTopLeft, fromSide); + final toExit = _sideMidpoint(toTopLeft, toSide); + + out.add(_EdgeGeometry( + edgeId: edge.link.id, + fromDeviceId: edge.fromDeviceId, + toDeviceId: edge.toDeviceId, + fromPortLabel: edge.fromPortLabel, + toPortLabel: edge.toPortLabel, + fromExit: fromExit, + toExit: toExit, + fromSide: fromSide, + toSide: toSide, + waypoints: wps, + )); + } + return out; + } + + _CardSide _sideFacing(Offset from, Offset to) { + final dx = to.dx - from.dx; + final dy = to.dy - from.dy; + final aspectRatio = _nodeSize.width / _nodeSize.height; + if (dx.abs() * aspectRatio > dy.abs()) { + return dx >= 0 ? _CardSide.right : _CardSide.left; + } + return dy >= 0 ? _CardSide.bottom : _CardSide.top; + } + + Offset _sideMidpoint(Offset topLeft, _CardSide side) { + switch (side) { + case _CardSide.left: + return Offset(topLeft.dx, topLeft.dy + _nodeSize.height / 2); + case _CardSide.right: + return Offset( + topLeft.dx + _nodeSize.width, topLeft.dy + _nodeSize.height / 2); + case _CardSide.top: + return Offset(topLeft.dx + _nodeSize.width / 2, topLeft.dy); + case _CardSide.bottom: + return Offset( + topLeft.dx + _nodeSize.width / 2, topLeft.dy + _nodeSize.height); + } + } + + /// Line-jump arcs at crossings between *straight* edges. Curved (waypoint- + /// bearing) edges don't participate — they already route around obstacles + /// by going through their virtual-node waypoints. + void _computeLineJumps(List<_EdgeGeometry> geos) { + for (var i = 0; i < geos.length; i++) { + geos[i].jumpPoints.clear(); + } + for (var i = 0; i < geos.length; i++) { + final a = geos[i]; + if (a.waypoints.isNotEmpty) continue; + for (var j = i + 1; j < geos.length; j++) { + final b = geos[j]; + if (b.waypoints.isNotEmpty) continue; + if (_sharesDevice(a, b)) continue; + final hit = _segmentIntersection(a.fromExit, a.toExit, b.fromExit, b.toExit); + if (hit != null) b.jumpPoints.add(hit); + } + } + for (final geo in geos) { + if (geo.jumpPoints.isEmpty) continue; + geo.jumpPoints.sort((p1, p2) { + final d1 = (p1 - geo.fromExit).distanceSquared; + final d2 = (p2 - geo.fromExit).distanceSquared; + return d1.compareTo(d2); + }); + } + } + + bool _sharesDevice(_EdgeGeometry a, _EdgeGeometry b) { + return a.fromDeviceId == b.fromDeviceId || + a.fromDeviceId == b.toDeviceId || + a.toDeviceId == b.fromDeviceId || + a.toDeviceId == b.toDeviceId; + } + + Offset? _segmentIntersection(Offset p1, Offset p2, Offset p3, Offset p4) { + final s1x = p2.dx - p1.dx; + final s1y = p2.dy - p1.dy; + final s2x = p4.dx - p3.dx; + final s2y = p4.dy - p3.dy; + final denom = -s2x * s1y + s1x * s2y; + if (denom.abs() < 1e-6) return null; + final s = (-s1y * (p1.dx - p3.dx) + s1x * (p1.dy - p3.dy)) / denom; + final t = (s2x * (p1.dy - p3.dy) - s2y * (p1.dx - p3.dx)) / denom; + if (s >= 0 && s <= 1 && t >= 0 && t <= 1) { + return Offset(p1.dx + t * s1x, p1.dy + t * s1y); + } + return null; + } + + String? _hitTestEdge(Offset localPos, List<_EdgeGeometry> geos) { + String? bestId; + double bestDist = _hitTolerance; + for (final geo in geos) { + // For curved edges, sample the Bézier path and use the nearest sample. + // For straight edges, distance to segment. + final d = geo.waypoints.isEmpty + ? _distanceToSegment(localPos, geo.fromExit, geo.toExit) + : _distanceToCurve(localPos, geo); + if (d < bestDist) { + bestDist = d; + bestId = geo.edgeId; + } + } + return bestId; + } + + double _distanceToSegment(Offset p, Offset a, Offset b) { + final ax = a.dx, ay = a.dy, bx = b.dx, by = b.dy; + final dx = bx - ax; + final dy = by - ay; + final lenSq = dx * dx + dy * dy; + if (lenSq < 1e-6) return (p - a).distance; + var t = ((p.dx - ax) * dx + (p.dy - ay) * dy) / lenSq; + t = t.clamp(0.0, 1.0); + return (p - Offset(ax + t * dx, ay + t * dy)).distance; + } + + /// Approximate distance to a Bézier curve by sampling segments between the + /// path control points (fromExit, waypoints..., toExit) and taking the min + /// segment distance. Coarse but cheap. + double _distanceToCurve(Offset p, _EdgeGeometry geo) { + final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; + var best = double.infinity; + for (var i = 0; i < pts.length - 1; i++) { + final d = _distanceToSegment(p, pts[i], pts[i + 1]); + if (d < best) best = d; + } + return best; + } + + // ─── Build ─────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (widget.graph.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.hub_outlined, size: 56, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text( + 'No devices in this view yet', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + 'Import a document or add devices manually.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + return InteractiveViewer( + constrained: false, + minScale: 0.2, + maxScale: 2.5, + boundaryMargin: const EdgeInsets.all(200), + child: AnimatedBuilder( + animation: _layoutAnim, + builder: (context, _) { + final positions = _currentPositions(); + final waypoints = _currentWaypoints(); + final edgeGeos = _buildEdgeGeometries(positions, waypoints); + _computeLineJumps(edgeGeos); + + return SizedBox( + width: _canvasSize.width, + height: _canvasSize.height, + child: MouseRegion( + onHover: (event) { + final hit = _hitTestEdge(event.localPosition, edgeGeos); + if (hit != _hoveredEdgeId.value) { + _hoveredEdgeId.value = hit; + } + }, + onExit: (_) => _hoveredEdgeId.value = null, + child: Stack( + children: [ + // Painter — repaints on hover via inner AnimatedBuilder. + Positioned.fill( + child: RepaintBoundary( + child: AnimatedBuilder( + animation: _hoveredEdgeId, + builder: (_, _) { + return CustomPaint( + painter: _CanvasPainter( + edgeGeos: edgeGeos, + jumpRadius: _jumpRadius, + hoveredEdgeId: _hoveredEdgeId.value, + theme: _CanvasTheme.from(Theme.of(context)), + ), + ); + }, + ), + ), + ), + // Devices on top, hover-aware. + for (final node in widget.graph.nodes) + if (positions[node.device.id] != null) + Positioned( + left: positions[node.device.id]!.dx, + top: positions[node.device.id]!.dy, + child: _HoverAwareDevice( + node: node, + selectedId: widget.selectedDeviceId, + hoveredEdgeId: _hoveredEdgeId, + hoveredDeviceId: _hoveredDeviceId, + connectedEdgeIds: + _deviceToEdges[node.device.id] ?? const [], + onTap: widget.onDeviceTap, + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +enum _CardSide { left, right, top, bottom } + +class _EdgeGeometry { + _EdgeGeometry({ + required this.edgeId, + required this.fromDeviceId, + required this.toDeviceId, + required this.fromPortLabel, + required this.toPortLabel, + required this.fromExit, + required this.toExit, + required this.fromSide, + required this.toSide, + required this.waypoints, + }); + + final String edgeId; + final String fromDeviceId; + final String toDeviceId; + final String fromPortLabel; + final String toPortLabel; + final Offset fromExit; + final Offset toExit; + final _CardSide fromSide; + final _CardSide toSide; + + /// Intermediate points the edge passes through. Empty for single-layer + /// edges (drawn as straight lines). Non-empty for multi-layer edges (drawn + /// as sequential quadratic Béziers through these points). + final List waypoints; + + /// Line-jump points along the *main* segment. Only populated for straight + /// edges (curved/waypoint edges skip line jumps). + final List jumpPoints = []; +} + +// ─── Theme snapshot ──────────────────────────────────────────────────────── + +class _CanvasTheme { + const _CanvasTheme({ + required this.defaultEdgeColor, + required this.hoverEdgeColor, + required this.chipBg, + required this.chipHoverBg, + required this.chipBorder, + required this.chipHoverBorder, + required this.chipText, + required this.chipHoverText, + }); + + factory _CanvasTheme.from(ThemeData theme) { + final cs = theme.colorScheme; + return _CanvasTheme( + defaultEdgeColor: cs.outline.withValues(alpha: 0.75), + hoverEdgeColor: cs.primary, + chipBg: cs.surfaceContainerHigh.withValues(alpha: 0.95), + chipHoverBg: cs.primaryContainer, + chipBorder: cs.outlineVariant.withValues(alpha: 0.7), + chipHoverBorder: cs.primary, + chipText: cs.onSurfaceVariant, + chipHoverText: cs.onPrimaryContainer, + ); + } + + final Color defaultEdgeColor; + final Color hoverEdgeColor; + final Color chipBg; + final Color chipHoverBg; + final Color chipBorder; + final Color chipHoverBorder; + final Color chipText; + final Color chipHoverText; +} + +// ─── Painter ─────────────────────────────────────────────────────────────── + +class _CanvasPainter extends CustomPainter { + _CanvasPainter({ + required this.edgeGeos, + required this.jumpRadius, + required this.hoveredEdgeId, + required this.theme, + }); + + final List<_EdgeGeometry> edgeGeos; + final double jumpRadius; + final String? hoveredEdgeId; + final _CanvasTheme theme; + + @override + void paint(Canvas canvas, Size size) { + for (final geo in edgeGeos) { + if (geo.edgeId == hoveredEdgeId) continue; + _drawEdge(canvas, geo, hovered: false); + } + for (final geo in edgeGeos) { + if (geo.edgeId != hoveredEdgeId) continue; + _drawEdge(canvas, geo, hovered: true); + } + for (final geo in edgeGeos) { + if (geo.edgeId == hoveredEdgeId) continue; + _drawChips(canvas, geo, hovered: false); + } + for (final geo in edgeGeos) { + if (geo.edgeId != hoveredEdgeId) continue; + _drawChips(canvas, geo, hovered: true); + } + } + + void _drawEdge(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) { + final color = hovered ? theme.hoverEdgeColor : theme.defaultEdgeColor; + final strokeWidth = hovered ? 2.5 : 1.4; + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + if (geo.waypoints.isNotEmpty) { + _drawCurvedEdge(canvas, geo, paint); + } else { + _drawStraightEdge(canvas, geo, paint); + } + + // Endpoint plug dots. + final dotPaint = Paint()..color = color..style = PaintingStyle.fill; + final radius = hovered ? 4.0 : 3.0; + canvas.drawCircle(geo.fromExit, radius, dotPaint); + canvas.drawCircle(geo.toExit, radius, dotPaint); + } + + void _drawStraightEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) { + final dx = geo.toExit.dx - geo.fromExit.dx; + final dy = geo.toExit.dy - geo.fromExit.dy; + final len = math.sqrt(dx * dx + dy * dy); + if (len < 1) return; + final ux = dx / len; + final uy = dy / len; + + final path = Path()..moveTo(geo.fromExit.dx, geo.fromExit.dy); + for (final jump in geo.jumpPoints) { + final beforeX = jump.dx - ux * jumpRadius; + final beforeY = jump.dy - uy * jumpRadius; + final afterX = jump.dx + ux * jumpRadius; + final afterY = jump.dy + uy * jumpRadius; + path.lineTo(beforeX, beforeY); + path.arcToPoint( + Offset(afterX, afterY), + radius: Radius.circular(jumpRadius), + clockwise: false, + ); + } + path.lineTo(geo.toExit.dx, geo.toExit.dy); + canvas.drawPath(path, paint); + } + + /// Render an edge through its waypoints as sequential quadratic Bézier + /// curves. The control point for each segment is the waypoint itself, + /// with each segment's anchor being the midpoint between consecutive + /// waypoints. This produces a smooth C1-continuous curve passing AT each + /// waypoint, mimicking what the eye expects from "a cable bending through + /// multiple connection points." + void _drawCurvedEdge(Canvas canvas, _EdgeGeometry geo, Paint paint) { + final pts = [geo.fromExit, ...geo.waypoints, geo.toExit]; + final path = Path()..moveTo(pts.first.dx, pts.first.dy); + + if (pts.length == 2) { + path.lineTo(pts.last.dx, pts.last.dy); + canvas.drawPath(path, paint); + return; + } + // For each intermediate waypoint, draw a quadratic Bézier from the + // previous-anchor to the next-anchor, with this waypoint as the control. + // First anchor: midpoint between pts[0] and pts[1]. Last anchor: midpoint + // between pts[n-2] and pts[n-1]. This produces a smooth curve passing + // through every waypoint. + var anchor = Offset( + (pts[0].dx + pts[1].dx) / 2, + (pts[0].dy + pts[1].dy) / 2, + ); + path.lineTo(anchor.dx, anchor.dy); + for (var i = 1; i < pts.length - 1; i++) { + final nextAnchor = Offset( + (pts[i].dx + pts[i + 1].dx) / 2, + (pts[i].dy + pts[i + 1].dy) / 2, + ); + path.quadraticBezierTo(pts[i].dx, pts[i].dy, nextAnchor.dx, nextAnchor.dy); + anchor = nextAnchor; + } + path.lineTo(pts.last.dx, pts.last.dy); + canvas.drawPath(path, paint); + } + + void _drawChips(Canvas canvas, _EdgeGeometry geo, {required bool hovered}) { + _drawChip( + canvas, + anchor: geo.fromExit, + side: geo.fromSide, + label: geo.fromPortLabel, + hovered: hovered, + ); + _drawChip( + canvas, + anchor: geo.toExit, + side: geo.toSide, + label: geo.toPortLabel, + hovered: hovered, + ); + } + + void _drawChip( + Canvas canvas, { + required Offset anchor, + required _CardSide side, + required String label, + required bool hovered, + }) { + if (label.isEmpty) return; + final tp = TextPainter( + text: TextSpan( + text: label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: hovered ? theme.chipHoverText : theme.chipText, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + ellipsis: '…', + )..layout(maxWidth: 100); + + const padH = 6.0; + const padV = 2.0; + final chipW = tp.width + padH * 2; + final chipH = tp.height + padV * 2; + + const gap = 6.0; + Offset chipTopLeft; + switch (side) { + case _CardSide.left: + chipTopLeft = Offset(anchor.dx - gap - chipW, anchor.dy - chipH / 2); + break; + case _CardSide.right: + chipTopLeft = Offset(anchor.dx + gap, anchor.dy - chipH / 2); + break; + case _CardSide.top: + chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy - gap - chipH); + break; + case _CardSide.bottom: + chipTopLeft = Offset(anchor.dx - chipW / 2, anchor.dy + gap); + break; + } + + final rect = Rect.fromLTWH(chipTopLeft.dx, chipTopLeft.dy, chipW, chipH); + final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(6)); + + final bgPaint = Paint() + ..color = hovered ? theme.chipHoverBg : theme.chipBg + ..style = PaintingStyle.fill; + canvas.drawRRect(rrect, bgPaint); + + final borderPaint = Paint() + ..color = hovered ? theme.chipHoverBorder : theme.chipBorder + ..style = PaintingStyle.stroke + ..strokeWidth = hovered ? 1.2 : 0.7; + canvas.drawRRect(rrect, borderPaint); + + tp.paint(canvas, Offset(chipTopLeft.dx + padH, chipTopLeft.dy + padV)); + } + + @override + bool shouldRepaint(covariant _CanvasPainter old) { + return old.edgeGeos != edgeGeos || + old.hoveredEdgeId != hoveredEdgeId || + old.theme.defaultEdgeColor != theme.defaultEdgeColor; + } +} + +// ─── Hover-aware device ─────────────────────────────────────────────────── + +class _HoverAwareDevice extends StatelessWidget { + const _HoverAwareDevice({ + required this.node, + required this.selectedId, + required this.hoveredEdgeId, + required this.hoveredDeviceId, + required this.connectedEdgeIds, + required this.onTap, + }); + + final TopologyNode node; + final String? selectedId; + final ValueNotifier hoveredEdgeId; + final ValueNotifier hoveredDeviceId; + final List connectedEdgeIds; + final void Function(NetworkDevice device)? onTap; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => hoveredDeviceId.value = node.device.id, + onExit: (_) { + if (hoveredDeviceId.value == node.device.id) { + hoveredDeviceId.value = null; + } + }, + child: AnimatedBuilder( + animation: Listenable.merge([hoveredEdgeId, hoveredDeviceId]), + builder: (_, _) { + final isOwnHover = hoveredDeviceId.value == node.device.id; + final isEdgeEndpoint = hoveredEdgeId.value != null && + connectedEdgeIds.contains(hoveredEdgeId.value); + return DeviceNode( + device: node.device, + portCount: node.ports.length, + isSelected: selectedId == node.device.id, + isHighlighted: isOwnHover || isEdgeEndpoint, + onTap: () => onTap?.call(node.device), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index fcd546cc..07cb9844 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -369,6 +369,13 @@ List _buildSections(String role) { icon: Icons.miscellaneous_services_outlined, selectedIcon: Icons.miscellaneous_services, ), + if (!isStandard) + NavItem( + label: 'Network Map', + route: '/network-map', + icon: Icons.hub_outlined, + selectedIcon: Icons.hub, + ), NavItem( label: 'Announcement', route: '/announcements', @@ -610,6 +617,7 @@ String _routeToTitle(String location) { '/tickets' => 'Tickets', '/tasks' => 'Tasks', '/it-service-requests' => 'IT Service Requests', + '/network-map' => 'Network Map', '/notifications' => 'Notifications', '/whereabouts' => 'Whereabouts', '/workforce' => 'Workforce', diff --git a/supabase/DEPLOYMENT.md b/supabase/DEPLOYMENT.md new file mode 100644 index 00000000..fc722509 --- /dev/null +++ b/supabase/DEPLOYMENT.md @@ -0,0 +1,31 @@ +Postgres + pg_cron scheduled shift reminders + +1) Run migrations +- Apply `supabase/migrations/20260318_add_scheduled_notifications.sql` (and other pending migrations) to your Supabase DB. + +2) Enable pg_cron (requires Supabase project admin) +- If your Supabase tier supports it, enable the `pg_cron` extension. +- Example (run as a privileged role): + CREATE EXTENSION IF NOT EXISTS pg_cron; + SELECT cron.schedule('shift_reminders_every_min', '*/1 * * * *', $$SELECT public.enqueue_due_shift_notifications();$$); + +3) Deploy processor Edge Function +- Add `supabase/functions/process_scheduled_notifications/` to your Supabase functions and deploy with the following required env vars: + - `SUPABASE_URL` + - `SUPABASE_SERVICE_ROLE_KEY` + - `SEND_FCM_URL` (the HTTP URL for your existing `send_fcm` function, e.g., https://.functions.supabase.co/send_fcm) + - Optional: `PROCESSOR_BATCH_SIZE` (default 50) + +- You can deploy via `supabase functions deploy process_scheduled_notifications` or using your CI. + +4) Scheduling / triggering processor +- Option A (recommended): Use `pg_cron` to only enqueue rows, and configure a small interval GitHub Actions or Cloud Scheduler to call the Edge Function endpoint (POST) every minute. This keeps sending out of the DB. +- Option B: Use `pg_cron` to call the Edge Function HTTP endpoint directly if `pg_http` is available (not recommended unless approved). + +5) Verification +- Insert a test `duty_schedules` record 15 minutes ahead and run `SELECT public.enqueue_due_shift_notifications();` manually — confirm `scheduled_notifications` row appears. +- Call the Edge Function (`supabase functions invoke process_scheduled_notifications --project `) or POST to its URL — confirm `send_fcm` receives payload and `scheduled_notifications` row becomes processed. + +6) Monitoring +- Watch `cron.job_run_details` for cron runs and `scheduled_notifications` rows with `retry_count` > 3. +- Inspect `notification_pushes` table to ensure deduplication works. diff --git a/supabase/functions/extract_topology/deno.json b/supabase/functions/extract_topology/deno.json new file mode 100644 index 00000000..659b4838 --- /dev/null +++ b/supabase/functions/extract_topology/deno.json @@ -0,0 +1,6 @@ +{ + "tasks": { + "start": "deno run --allow-net --allow-env --allow-read index.ts" + }, + "imports": {} +} diff --git a/supabase/functions/extract_topology/index.ts b/supabase/functions/extract_topology/index.ts new file mode 100644 index 00000000..65b0ebfe --- /dev/null +++ b/supabase/functions/extract_topology/index.ts @@ -0,0 +1,345 @@ +// extract_topology — Supabase Edge Function +// Reads a network-imports file from storage, sends it to Gemini with a strict +// JSON-schema prompt, and writes the parsed draft back to network_imports. +// +// Auth: caller must be admin or it_staff. Mirrors teams_crud auth pattern. +// Gemini model selection: 2.5 Flash by default; 2.5 Pro for PDFs >5 pages. +// +// Env vars required: +// SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, GEMINI_API_KEY + +import { serve } from "https://deno.land/std@0.203.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const CORS_HEADERS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Authorization, apikey, x-client-info, x-requested-with, Origin, Accept", + "Access-Control-Max-Age": "3600", +}; + +const jsonResponse = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + +// Strict response schema we send to Gemini. Items get confidence scores so the +// review UI can rank "definitely a switch" above "might be a switch". +const RESPONSE_SCHEMA = { + type: "object", + properties: { + sites: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + address: { type: "string" }, + }, + required: ["name"], + }, + }, + devices: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + kind: { + type: "string", + enum: ["router", "switch", "ap", "firewall", "server", "endpoint", "patch_panel", "other"], + }, + vendor: { type: "string" }, + model: { type: "string" }, + mgmt_ip: { type: "string" }, + site_name: { type: "string" }, + location_label: { type: "string" }, + confidence: { type: "string", enum: ["high", "medium", "low"] }, + }, + required: ["name", "kind"], + }, + }, + ports: { + type: "array", + items: { + type: "object", + properties: { + device_name: { type: "string" }, + port_number: { type: "string" }, + port_kind: { + type: "string", + enum: ["rj45", "sfp", "sfp_plus", "console", "power", "wireless", "virtual", "other"], + }, + access_vlan: { type: "integer" }, + is_trunk: { type: "boolean" }, + }, + required: ["device_name", "port_number"], + }, + }, + links: { + type: "array", + items: { + type: "object", + properties: { + device_a: { type: "string" }, + port_a: { type: "string" }, + device_b: { type: "string" }, + port_b: { type: "string" }, + link_kind: { + type: "string", + enum: ["copper", "fiber", "wireless", "virtual", "unknown"], + }, + confidence: { type: "string", enum: ["high", "medium", "low"] }, + }, + required: ["device_a", "device_b"], + }, + }, + vlans: { + type: "array", + items: { + type: "object", + properties: { + vlan_id: { type: "integer" }, + name: { type: "string" }, + description: { type: "string" }, + }, + required: ["vlan_id", "name"], + }, + }, + notes: { type: "string" }, + }, +}; + +const EXTRACTION_PROMPT = `You are a network-documentation reader. Look at the attached file (which may be a network diagram in PDF, an image of a diagram, a photo of a whiteboard, a Visio export, or a spreadsheet/notes) and extract every network device, port, link, VLAN, and site you can identify. + +Rules: +- Output ONLY structured JSON conforming to the provided schema. No prose. +- For devices, classify kind precisely (switch / router / ap / etc.). If you genuinely cannot tell, use "other". +- For ports and links, give the exact labels as they appear ("Gi0/1", "Port 24", "uplink", etc.) — do not normalize. +- For each device and each link, attach a "confidence": "high" if the document clearly states it, "medium" if it's reasonably implied, "low" if it's a guess. +- If you cannot extract anything useful, return all arrays as empty and put a brief explanation in "notes". +- Do not invent items. Better to return less with high confidence than more with low. +- VLANs: only include if a VLAN ID number is visible. +- Sites: only include if a site/building name is clearly indicated.`; + +interface ImportRow { + id: string; + storage_path: string; + file_kind: string; + status: string; + created_by: string; +} + +function pickGeminiModel(fileKind: string, byteLength: number): string { + // Cheap default; escalate to Pro for big PDFs where layout reasoning matters. + if (fileKind === "pdf" && byteLength > 2 * 1024 * 1024) { + return "gemini-2.5-pro"; + } + return "gemini-2.5-flash"; +} + +function mimeForFileKind(fileKind: string): string { + switch (fileKind) { + case "pdf": + return "application/pdf"; + case "image": + // Default to PNG; Gemini accepts most image MIMEs and will sniff if needed. + return "image/png"; + case "vsdx": + return "application/vnd.ms-visio.drawing"; + case "csv": + return "text/csv"; + case "xlsx": + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + case "docx": + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + default: + return "application/octet-stream"; + } +} + +function bytesToBase64(bytes: Uint8Array): string { + // Deno has no btoa for binary; build base64 in chunks to avoid stack overflow. + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + const slice = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, Array.from(slice)); + } + return btoa(binary); +} + +serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + if (req.method !== "POST") { + return jsonResponse({ error: "Method not allowed" }, 405); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; + const anonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + const geminiKey = Deno.env.get("GEMINI_API_KEY") ?? ""; + if (!supabaseUrl || !anonKey || !serviceKey) { + return jsonResponse({ error: "Missing Supabase env config" }, 500); + } + if (!geminiKey) { + return jsonResponse({ error: "GEMINI_API_KEY not configured" }, 500); + } + + // Auth caller + const authHeader = req.headers.get("Authorization") ?? ""; + const token = authHeader.replace("Bearer ", "").trim(); + if (!token) return jsonResponse({ error: "Missing access token" }, 401); + + const authClient = createClient(supabaseUrl, anonKey, { + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + const { data: authData, error: authError } = await authClient.auth.getUser(); + if (authError || !authData?.user) return jsonResponse({ error: "Unauthorized" }, 401); + + const adminClient = createClient(supabaseUrl, serviceKey); + + // Caller must be admin or it_staff + const { data: profile, error: profileError } = await adminClient + .from("profiles") + .select("role") + .eq("id", authData.user.id) + .maybeSingle(); + const role = (profile?.role ?? "").toString().toLowerCase(); + if (profileError || (role !== "admin" && role !== "it_staff")) { + return jsonResponse({ error: "Forbidden" }, 403); + } + + // Body: { import_id: string } + let body: { import_id?: string } = {}; + try { + body = await req.json(); + } catch (_e) { + return jsonResponse({ error: "Invalid JSON body" }, 400); + } + const importId = body.import_id; + if (!importId || typeof importId !== "string") { + return jsonResponse({ error: "Missing import_id" }, 400); + } + + // Load the import row + const { data: imp, error: impErr } = await adminClient + .from("network_imports") + .select("id, storage_path, file_kind, status, created_by") + .eq("id", importId) + .maybeSingle(); + if (impErr || !imp) { + return jsonResponse({ error: "Import not found" }, 404); + } + if (imp.status === "applied" || imp.status === "discarded") { + return jsonResponse({ error: `Import already ${imp.status}` }, 409); + } + + // Mark extracting + await adminClient + .from("network_imports") + .update({ status: "extracting", error_message: null }) + .eq("id", importId); + + try { + // Download from storage + const { data: fileData, error: dlErr } = await adminClient + .storage + .from("network-imports") + .download(imp.storage_path); + if (dlErr || !fileData) { + throw new Error(dlErr?.message ?? "Failed to download import file"); + } + + const arrayBuf = await fileData.arrayBuffer(); + const bytes = new Uint8Array(arrayBuf); + const base64 = bytesToBase64(bytes); + const mime = mimeForFileKind(imp.file_kind); + const model = pickGeminiModel(imp.file_kind, bytes.length); + + // Call Gemini + const geminiUrl = + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${geminiKey}`; + const geminiBody = { + contents: [ + { + role: "user", + parts: [ + { text: EXTRACTION_PROMPT }, + { inlineData: { mimeType: mime, data: base64 } }, + ], + }, + ], + generationConfig: { + responseMimeType: "application/json", + responseSchema: RESPONSE_SCHEMA, + temperature: 0.1, + }, + }; + + const geminiRes = await fetch(geminiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(geminiBody), + }); + + if (!geminiRes.ok) { + const errBody = await geminiRes.text(); + throw new Error(`Gemini ${geminiRes.status}: ${errBody.slice(0, 500)}`); + } + + const geminiJson = await geminiRes.json(); + const textOut: string = + geminiJson?.candidates?.[0]?.content?.parts?.[0]?.text ?? ""; + + let parsed: unknown; + try { + parsed = JSON.parse(textOut); + } catch (_e) { + throw new Error("Gemini returned non-JSON output"); + } + + // Minimum-quality gate: must have at least one device, or status=failed. + const deviceCount = Array.isArray((parsed as { devices?: unknown[] })?.devices) + ? (parsed as { devices: unknown[] }).devices.length + : 0; + + if (deviceCount === 0) { + await adminClient + .from("network_imports") + .update({ + status: "failed", + ai_result: parsed as Record, + error_message: "No devices extracted from document. Start from blank canvas.", + }) + .eq("id", importId); + return jsonResponse( + { status: "failed", reason: "no_devices", ai_result: parsed }, + 200, + ); + } + + // Store + mark for review + await adminClient + .from("network_imports") + .update({ + status: "review", + ai_result: parsed as Record, + error_message: null, + }) + .eq("id", importId); + + return jsonResponse({ status: "review", ai_result: parsed }); + } catch (err) { + const msg = (err as Error).message ?? String(err); + await adminClient + .from("network_imports") + .update({ status: "failed", error_message: msg }) + .eq("id", importId); + return jsonResponse({ error: msg }, 500); + } +}); diff --git a/supabase/migrations/20260519000000_network_infrastructure_map.sql b/supabase/migrations/20260519000000_network_infrastructure_map.sql new file mode 100644 index 00000000..cdfc18f7 --- /dev/null +++ b/supabase/migrations/20260519000000_network_infrastructure_map.sql @@ -0,0 +1,329 @@ +-- Network Infrastructure Map — V1 schema. +-- Adds 7 tables (sites, locations, devices, ports, links, vlans, imports) +-- plus a private Storage bucket for uploaded source documents. +-- RLS: read for admin/it_staff/dispatcher/programmer; write for admin/it_staff only. +-- See plan: C:\Users\marcr\.claude\plans\i-m-thinking-of-nested-lagoon.md + +-- ============================================================================= +-- TABLES +-- ============================================================================= + +-- Sites: top-level physical locations (campus, building treated standalone) +CREATE TABLE IF NOT EXISTS public.network_sites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + address text, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + created_by uuid REFERENCES public.profiles(id) +); + +-- Locations: nested physical hierarchy within a site +CREATE TABLE IF NOT EXISTS public.network_locations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + site_id uuid NOT NULL REFERENCES public.network_sites(id) ON DELETE CASCADE, + parent_id uuid REFERENCES public.network_locations(id) ON DELETE CASCADE, + name text NOT NULL, + kind text NOT NULL CHECK (kind IN ('building','floor','room','rack','wall_jack','other')), + position_label text, + notes text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_network_locations_site ON public.network_locations(site_id); +CREATE INDEX IF NOT EXISTS idx_network_locations_parent ON public.network_locations(parent_id); + +-- Devices: switches, routers, APs, endpoints, patch panels +CREATE TABLE IF NOT EXISTS public.network_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + kind text NOT NULL CHECK (kind IN + ('router','switch','ap','firewall','server','endpoint','patch_panel','other')), + role text CHECK (role IN ('core','distribution','access','edge','endpoint')), + vendor text, + model text, + serial text, + mgmt_ip inet, + mac macaddr, + location_id uuid REFERENCES public.network_locations(id) ON DELETE SET NULL, + import_source text NOT NULL DEFAULT 'manual' + CHECK (import_source IN ('manual','ai_import','agent')), + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_network_devices_location ON public.network_devices(location_id); +CREATE INDEX IF NOT EXISTS idx_network_devices_role ON public.network_devices(role); +CREATE INDEX IF NOT EXISTS idx_network_devices_kind ON public.network_devices(kind); + +-- Ports: physical/logical ports on a device +CREATE TABLE IF NOT EXISTS public.network_ports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL REFERENCES public.network_devices(id) ON DELETE CASCADE, + port_number text NOT NULL, + port_kind text CHECK (port_kind IN ('rj45','sfp','sfp_plus','console','power','wireless','virtual','other')), + speed_mbps integer, + access_vlan integer, + is_trunk boolean NOT NULL DEFAULT false, + trunk_vlans integer[], + notes text, + UNIQUE (device_id, port_number) +); +CREATE INDEX IF NOT EXISTS idx_network_ports_device ON public.network_ports(device_id); + +-- Links: port-to-port edges. Canonical ordering (port_a < port_b) prevents A↔B/B↔A dupes. +CREATE TABLE IF NOT EXISTS public.network_links ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + port_a uuid NOT NULL REFERENCES public.network_ports(id) ON DELETE CASCADE, + port_b uuid NOT NULL REFERENCES public.network_ports(id) ON DELETE CASCADE, + link_kind text CHECK (link_kind IN ('copper','fiber','wireless','virtual','unknown')), + cable_label text, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (port_a < port_b), + UNIQUE (port_a, port_b) +); + +-- VLANs (org-scoped, named) +CREATE TABLE IF NOT EXISTS public.network_vlans ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + vlan_id integer NOT NULL UNIQUE, + name text NOT NULL, + description text, + color text +); + +-- Import sessions: each upload + extraction state + raw AI result for audit/replay +CREATE TABLE IF NOT EXISTS public.network_imports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + storage_path text NOT NULL, + file_kind text NOT NULL + CHECK (file_kind IN ('pdf','image','vsdx','csv','xlsx','docx','other')), + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','extracting','review','applied','discarded','failed')), + ai_result jsonb, + error_message text, + applied_device_ids uuid[], + applied_link_ids uuid[], + applied_at timestamptz, + created_by uuid NOT NULL REFERENCES public.profiles(id), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_network_imports_status ON public.network_imports(status); +CREATE INDEX IF NOT EXISTS idx_network_imports_created_by ON public.network_imports(created_by); + +-- ============================================================================= +-- updated_at TRIGGERS +-- ============================================================================= +-- Reuses common pattern from prior Tasq migrations. + +CREATE OR REPLACE FUNCTION public.network_map_set_updated_at() +RETURNS trigger AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_network_sites_updated_at ON public.network_sites; +CREATE TRIGGER trg_network_sites_updated_at + BEFORE UPDATE ON public.network_sites + FOR EACH ROW EXECUTE FUNCTION public.network_map_set_updated_at(); + +DROP TRIGGER IF EXISTS trg_network_devices_updated_at ON public.network_devices; +CREATE TRIGGER trg_network_devices_updated_at + BEFORE UPDATE ON public.network_devices + FOR EACH ROW EXECUTE FUNCTION public.network_map_set_updated_at(); + +-- ============================================================================= +-- ROW LEVEL SECURITY +-- ============================================================================= +-- Pattern matches supabase/migrations/20260223090000_services_read_only_for_standard_it_dispatcher.sql +-- READ: admin + it_staff + dispatcher + programmer +-- WRITE: admin + it_staff + +ALTER TABLE public.network_sites ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.network_locations ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.network_devices ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.network_ports ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.network_links ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.network_vlans ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.network_imports ENABLE ROW LEVEL SECURITY; + +-- ----- network_sites ----- +DROP POLICY IF EXISTS "Network sites: select" ON public.network_sites; +CREATE POLICY "Network sites: select" ON public.network_sites + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','it_staff','dispatcher','programmer')) + ); + +DROP POLICY IF EXISTS "Network sites: write" ON public.network_sites; +CREATE POLICY "Network sites: write" ON public.network_sites + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ) WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +-- ----- network_locations ----- +DROP POLICY IF EXISTS "Network locations: select" ON public.network_locations; +CREATE POLICY "Network locations: select" ON public.network_locations + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','it_staff','dispatcher','programmer')) + ); + +DROP POLICY IF EXISTS "Network locations: write" ON public.network_locations; +CREATE POLICY "Network locations: write" ON public.network_locations + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ) WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +-- ----- network_devices ----- +DROP POLICY IF EXISTS "Network devices: select" ON public.network_devices; +CREATE POLICY "Network devices: select" ON public.network_devices + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','it_staff','dispatcher','programmer')) + ); + +DROP POLICY IF EXISTS "Network devices: write" ON public.network_devices; +CREATE POLICY "Network devices: write" ON public.network_devices + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ) WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +-- ----- network_ports ----- +DROP POLICY IF EXISTS "Network ports: select" ON public.network_ports; +CREATE POLICY "Network ports: select" ON public.network_ports + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','it_staff','dispatcher','programmer')) + ); + +DROP POLICY IF EXISTS "Network ports: write" ON public.network_ports; +CREATE POLICY "Network ports: write" ON public.network_ports + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ) WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +-- ----- network_links ----- +DROP POLICY IF EXISTS "Network links: select" ON public.network_links; +CREATE POLICY "Network links: select" ON public.network_links + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','it_staff','dispatcher','programmer')) + ); + +DROP POLICY IF EXISTS "Network links: write" ON public.network_links; +CREATE POLICY "Network links: write" ON public.network_links + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ) WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +-- ----- network_vlans ----- +DROP POLICY IF EXISTS "Network vlans: select" ON public.network_vlans; +CREATE POLICY "Network vlans: select" ON public.network_vlans + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','it_staff','dispatcher','programmer')) + ); + +DROP POLICY IF EXISTS "Network vlans: write" ON public.network_vlans; +CREATE POLICY "Network vlans: write" ON public.network_vlans + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ) WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +-- ----- network_imports ----- +DROP POLICY IF EXISTS "Network imports: select" ON public.network_imports; +CREATE POLICY "Network imports: select" ON public.network_imports + FOR SELECT USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','it_staff','dispatcher','programmer')) + ); + +DROP POLICY IF EXISTS "Network imports: write" ON public.network_imports; +CREATE POLICY "Network imports: write" ON public.network_imports + FOR ALL USING ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ) WITH CHECK ( + EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +-- ============================================================================= +-- STORAGE BUCKET (private) for uploaded source documents +-- ============================================================================= +-- The Edge Function `extract_topology` uses the service role to read from this +-- bucket. Authenticated admin/it_staff clients upload via signed URLs or with +-- their RLS-bound JWT. + +INSERT INTO storage.buckets (id, name, public) +VALUES ('network-imports', 'network-imports', false) +ON CONFLICT (id) DO NOTHING; + +-- Storage policies: only admin/it_staff can upload; same group + dispatcher/programmer can read. +DROP POLICY IF EXISTS "Network imports bucket: select" ON storage.objects; +CREATE POLICY "Network imports bucket: select" ON storage.objects + FOR SELECT USING ( + bucket_id = 'network-imports' + AND EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() + AND p.role IN ('admin','it_staff','dispatcher','programmer')) + ); + +DROP POLICY IF EXISTS "Network imports bucket: write" ON storage.objects; +CREATE POLICY "Network imports bucket: write" ON storage.objects + FOR INSERT WITH CHECK ( + bucket_id = 'network-imports' + AND EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +DROP POLICY IF EXISTS "Network imports bucket: update" ON storage.objects; +CREATE POLICY "Network imports bucket: update" ON storage.objects + FOR UPDATE USING ( + bucket_id = 'network-imports' + AND EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + ); + +DROP POLICY IF EXISTS "Network imports bucket: delete" ON storage.objects; +CREATE POLICY "Network imports bucket: delete" ON storage.objects + FOR DELETE USING ( + bucket_id = 'network-imports' + AND EXISTS (SELECT 1 FROM public.profiles p + WHERE p.id = auth.uid() AND p.role IN ('admin','it_staff')) + );