Initial Commit : Network Map

This commit is contained in:
2026-05-21 06:29:14 +08:00
parent 82141dd61c
commit 5d0a66bb52
32 changed files with 6782 additions and 1 deletions
@@ -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 (560s) — 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);