Initial Commit : Network Map
This commit is contained in:
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic>? aiResult;
|
||||
final String? errorMessage;
|
||||
final List<String> appliedDeviceIds;
|
||||
final List<String> 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<String, dynamic> map) {
|
||||
List<String> 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<String, dynamic>
|
||||
? map['ai_result'] as Map<String, dynamic>
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
@@ -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<int> 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<String, dynamic> map) {
|
||||
final rawTrunk = map['trunk_vlans'];
|
||||
final trunk = <int>[];
|
||||
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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> toInsertMap() => {
|
||||
'name': name,
|
||||
if (address != null) 'address': address,
|
||||
if (notes != null) 'notes': notes,
|
||||
if (createdBy != null) 'created_by': createdBy,
|
||||
};
|
||||
|
||||
Map<String, dynamic> toUpdateMap() => {
|
||||
'name': name,
|
||||
'address': address,
|
||||
'notes': notes,
|
||||
};
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> toInsertMap() => {
|
||||
'vlan_id': vlanId,
|
||||
'name': name,
|
||||
if (description != null) 'description': description,
|
||||
if (color != null) 'color': color,
|
||||
};
|
||||
}
|
||||
@@ -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<NetworkPort> 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<NetworkLocation> locations;
|
||||
final List<TopologyNode> nodes;
|
||||
final List<TopologyEdge> edges;
|
||||
final List<NetworkVlan> 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<NetworkLocation> allLocations,
|
||||
required List<NetworkDevice> allDevices,
|
||||
required List<NetworkPort> allPorts,
|
||||
required List<NetworkLink> allLinks,
|
||||
required List<NetworkVlan> allVlans,
|
||||
}) {
|
||||
final siteLocations = site == null
|
||||
? <NetworkLocation>[]
|
||||
: 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 = <String, List<NetworkPort>>{};
|
||||
for (final port in allPorts) {
|
||||
if (!siteDeviceIds.contains(port.deviceId)) continue;
|
||||
portsByDevice.putIfAbsent(port.deviceId, () => []).add(port);
|
||||
}
|
||||
|
||||
final portToDevice = <String, String>{};
|
||||
final portLabel = <String, String>{};
|
||||
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 = <TopologyEdge>[];
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (_) {
|
||||
|
||||
@@ -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<List<NetworkDevice>>((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<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkPortsProvider = FutureProvider<List<NetworkPort>>((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<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkLinksProvider = FutureProvider<List<NetworkLink>>((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<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkVlansProvider = FutureProvider<List<NetworkVlan>>((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<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkDeviceByIdProvider =
|
||||
FutureProvider.family<NetworkDevice?, String>((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<List<NetworkPort>, 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<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
class NetworkDevicesController {
|
||||
NetworkDevicesController(this.ref);
|
||||
final Ref ref;
|
||||
|
||||
Future<NetworkDevice> 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<void> 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<void> 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<NetworkPort> createPort({
|
||||
required String deviceId,
|
||||
required String portNumber,
|
||||
NetworkPortKind? portKind,
|
||||
int? speedMbps,
|
||||
int? accessVlan,
|
||||
bool isTrunk = false,
|
||||
List<int> 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<void> 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<void> updatePort({
|
||||
required String id,
|
||||
required String deviceId,
|
||||
required String portNumber,
|
||||
NetworkPortKind? portKind,
|
||||
int? speedMbps,
|
||||
int? accessVlan,
|
||||
bool isTrunk = false,
|
||||
List<int> 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<NetworkLink> 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<void> deleteLink(String id) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_links').delete().eq('id', id);
|
||||
ref.invalidate(networkLinksProvider);
|
||||
}
|
||||
|
||||
Future<NetworkVlan> 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<void> deleteVlan(String id) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_vlans').delete().eq('id', id);
|
||||
ref.invalidate(networkVlansProvider);
|
||||
}
|
||||
}
|
||||
|
||||
final networkDevicesControllerProvider =
|
||||
Provider<NetworkDevicesController>((ref) => NetworkDevicesController(ref));
|
||||
@@ -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<List<NetworkImport>>((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<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkImportByIdProvider =
|
||||
FutureProvider.family<NetworkImport?, String>((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<String, dynamic>? 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<String> 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<NetworkImportResult> 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<String, dynamic>.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<String, dynamic>.from(asMap['ai_result'] as Map)
|
||||
: null;
|
||||
|
||||
ref.invalidate(networkImportByIdProvider(importId));
|
||||
ref.invalidate(networkImportsProvider);
|
||||
|
||||
return NetworkImportResult(
|
||||
status: NetworkImportStatus.fromWire(statusStr),
|
||||
aiResult: aiResult,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> 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<void> markApplied({
|
||||
required String importId,
|
||||
required List<String> deviceIds,
|
||||
required List<String> 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<NetworkImportController>((ref) => NetworkImportController(ref));
|
||||
@@ -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<List<NetworkSite>>((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<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final networkLocationsProvider = FutureProvider<List<NetworkLocation>>((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<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
final selectedSiteIdProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
class NetworkSitesController {
|
||||
NetworkSitesController(this.ref);
|
||||
final Ref ref;
|
||||
|
||||
Future<NetworkSite> 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<void> 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<void> deleteSite(String id) async {
|
||||
final client = ref.read(supabaseClientProvider);
|
||||
await client.from('network_sites').delete().eq('id', id);
|
||||
ref.invalidate(networkSitesProvider);
|
||||
}
|
||||
|
||||
Future<NetworkLocation> 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<NetworkLocation> 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<NetworkSitesController>((ref) => NetworkSitesController(ref));
|
||||
@@ -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<TopologyGraph, String?>((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<TopologyViewMode>((ref) => TopologyViewMode.physical);
|
||||
@@ -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<GoRouter>((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<GoRouter>((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<GoRouter>((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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<NetworkMapDeviceEditScreen> createState() =>
|
||||
_NetworkMapDeviceEditScreenState();
|
||||
}
|
||||
|
||||
class _NetworkMapDeviceEditScreenState
|
||||
extends ConsumerState<NetworkMapDeviceEditScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<NetworkLocation> 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<String?> _resolveLocationIdToSave() async {
|
||||
if (_locationId != null) return _locationId;
|
||||
if (_siteId == null) return null;
|
||||
final loc = await ref
|
||||
.read(networkSitesControllerProvider)
|
||||
.ensureDefaultLocation(_siteId!);
|
||||
return loc.id;
|
||||
}
|
||||
|
||||
Future<void> _showAddLocationDialog() async {
|
||||
if (_siteId == null) return;
|
||||
final nameCtrl = TextEditingController();
|
||||
NetworkLocationKind kind = NetworkLocationKind.room;
|
||||
final ok = await showDialog<bool>(
|
||||
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<NetworkLocationKind>(
|
||||
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<void> _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<void> _addPort(String deviceId) async {
|
||||
await showPortEditDialog(
|
||||
context: context,
|
||||
ref: ref,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final existingAsync = widget.deviceId == null
|
||||
? const AsyncValue<NetworkDevice?>.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<NetworkDeviceKind>(
|
||||
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<NetworkDeviceRole?>(
|
||||
initialValue: _role,
|
||||
items: [
|
||||
const DropdownMenuItem<NetworkDeviceRole?>(
|
||||
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<String?>(
|
||||
initialValue: _siteId,
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
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<String?>(
|
||||
initialValue: _locationId,
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = <String, String>{
|
||||
for (final p in allPorts) p.id: p.deviceId,
|
||||
};
|
||||
final portLabel = <String, String>{
|
||||
for (final p in allPorts) p.id: p.portNumber,
|
||||
};
|
||||
final deviceById = <String, NetworkDevice>{
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <String, dynamic>{};
|
||||
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<void> _applyExtraction({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required String importId,
|
||||
required List<Map<String, dynamic>> acceptedSites,
|
||||
required List<Map<String, dynamic>> acceptedDevices,
|
||||
required List<Map<String, dynamic>> acceptedPorts,
|
||||
required List<Map<String, dynamic>> acceptedLinks,
|
||||
required List<Map<String, dynamic>> 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 = <String, String>{};
|
||||
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<String> 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 = <String, String>{};
|
||||
final createdDeviceIds = <String>[];
|
||||
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 = <String, String>{};
|
||||
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 = <String>[];
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NetworkMapImportScreen> createState() =>
|
||||
_NetworkMapImportScreenState();
|
||||
}
|
||||
|
||||
class _NetworkMapImportScreenState extends ConsumerState<NetworkMapImportScreen> {
|
||||
bool _uploading = false;
|
||||
String? _statusMessage;
|
||||
|
||||
Future<void> _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!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _showAddSiteDialog(BuildContext context, WidgetRef ref) async {
|
||||
final nameCtrl = TextEditingController();
|
||||
final addressCtrl = TextEditingController();
|
||||
final result = await showDialog<bool>(
|
||||
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<NetworkDevice> 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<void> _showBulkAssignDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<NetworkDevice> 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<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setLocal) => AlertDialog(
|
||||
title: Text('Assign ${devices.length} devices to a site'),
|
||||
content: DropdownButtonFormField<String>(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TopologyViewMode>(
|
||||
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}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _showAddDialog(BuildContext context, WidgetRef ref) async {
|
||||
final idCtrl = TextEditingController();
|
||||
final nameCtrl = TextEditingController();
|
||||
final descCtrl = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>().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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> aiResult;
|
||||
final Future<void> Function({
|
||||
required List<Map<String, dynamic>> acceptedDevices,
|
||||
required List<Map<String, dynamic>> acceptedPorts,
|
||||
required List<Map<String, dynamic>> acceptedLinks,
|
||||
required List<Map<String, dynamic>> acceptedVlans,
|
||||
required List<Map<String, dynamic>> acceptedSites,
|
||||
}) onApply;
|
||||
|
||||
@override
|
||||
State<ImportDiffView> createState() => _ImportDiffViewState();
|
||||
}
|
||||
|
||||
class _ImportDiffViewState extends State<ImportDiffView> {
|
||||
late final Set<int> _enabledDevices;
|
||||
late final Set<int> _enabledPorts;
|
||||
late final Set<int> _enabledLinks;
|
||||
late final Set<int> _enabledVlans;
|
||||
late final Set<int> _enabledSites;
|
||||
bool _applying = false;
|
||||
|
||||
List<Map<String, dynamic>> _asList(dynamic value) {
|
||||
if (value is! List) return const [];
|
||||
return value
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.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<bool?> 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<void> _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<String>().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<String>().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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<bool> showPortEditDialog({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required String deviceId,
|
||||
NetworkPort? existing,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
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<int> _parseTrunkVlans(String input) {
|
||||
return input
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.map(int.tryParse)
|
||||
.whereType<int>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> _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) : <int>[];
|
||||
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<NetworkPortKind?>(
|
||||
initialValue: _kind,
|
||||
items: [
|
||||
const DropdownMenuItem<NetworkPortKind?>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = <String>[
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<TopologyCanvas> createState() => _TopologyCanvasState();
|
||||
}
|
||||
|
||||
class _TopologyCanvasState extends State<TopologyCanvas>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// ─── Hover state ─────────────────────────────────────────────────────────
|
||||
final ValueNotifier<String?> _hoveredEdgeId = ValueNotifier<String?>(null);
|
||||
final ValueNotifier<String?> _hoveredDeviceId = ValueNotifier<String?>(null);
|
||||
|
||||
// ─── Animation ───────────────────────────────────────────────────────────
|
||||
late AnimationController _layoutAnim;
|
||||
|
||||
// ─── Layout snapshots (for interpolation) ────────────────────────────────
|
||||
Map<String, Offset> _prevPositions = const {};
|
||||
Map<String, Offset> _targetPositions = const {};
|
||||
Map<String, List<Offset>> _prevWaypoints = const {};
|
||||
Map<String, List<Offset>> _targetWaypoints = const {};
|
||||
Size _canvasSize = const Size(600, 400);
|
||||
Map<String, List<String>> _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<String, Offset>.from(_currentPositions())
|
||||
: <String, Offset>{};
|
||||
final newPrevWaypoints = animate
|
||||
? Map<String, List<Offset>>.from(_currentWaypoints())
|
||||
: <String, List<Offset>>{};
|
||||
|
||||
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<String, int>? 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<String, Offset> _currentPositions() {
|
||||
final t = _layoutAnim.value;
|
||||
if (t >= 1.0) return _targetPositions;
|
||||
if (t <= 0.0) return _prevPositions.isEmpty ? _targetPositions : _prevPositions;
|
||||
final out = <String, Offset>{};
|
||||
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<String, List<Offset>> _currentWaypoints() {
|
||||
final t = _layoutAnim.value;
|
||||
if (t >= 1.0) return _targetWaypoints;
|
||||
if (t <= 0.0) return _prevWaypoints.isEmpty ? _targetWaypoints : _prevWaypoints;
|
||||
final out = <String, List<Offset>>{};
|
||||
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<String, List<String>> _computeDeviceEdgeMap() {
|
||||
final map = <String, List<String>>{};
|
||||
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<String, Offset> positions,
|
||||
Map<String, List<Offset>> 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 <Offset>[];
|
||||
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 = <Offset>[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<Offset> waypoints;
|
||||
|
||||
/// Line-jump points along the *main* segment. Only populated for straight
|
||||
/// edges (curved/waypoint edges skip line jumps).
|
||||
final List<Offset> 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 = <Offset>[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<String?> hoveredEdgeId;
|
||||
final ValueNotifier<String?> hoveredDeviceId;
|
||||
final List<String> 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -369,6 +369,13 @@ List<NavSection> _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',
|
||||
|
||||
@@ -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://<project>.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 <id>`) 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.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tasks": {
|
||||
"start": "deno run --allow-net --allow-env --allow-read index.ts"
|
||||
},
|
||||
"imports": {}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
"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<ImportRow>();
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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'))
|
||||
);
|
||||
Reference in New Issue
Block a user