From 5822fb5d1d383642386af5a9b6a1f66821832467 Mon Sep 17 00:00:00 2001 From: sepehr Date: Thu, 16 Oct 2025 17:33:07 +0300 Subject: [PATCH 01/11] feat: major device management enhancements - Add direct web access button for device WebFig - Add web proxy-based access with auto-login via MikroWizard - Fix updatable/upgradable filters and add more filter options - Redesign devices table UI with improved UX - Add bulk scan feature with CSV import capability - Add parallel scanning functionality - Add upgrade firmware feature - Add scan history button with enhanced report viewing --- src/app/views/devices/devices.component.html | 347 ++++++++++++++++--- src/app/views/devices/devices.component.ts | 292 +++++++++++++++- src/app/views/devices/devices.module.ts | 2 + 3 files changed, 589 insertions(+), 52 deletions(-) diff --git a/src/app/views/devices/devices.component.html b/src/app/views/devices/devices.component.html index a27a48a..fc93cd8 100644 --- a/src/app/views/devices/devices.component.html +++ b/src/app/views/devices/devices.component.html @@ -8,19 +8,25 @@
- - - | + + size="sm" style="color: #fff;"> Scan +
- + + + + + + Group @@ -37,6 +43,10 @@ (selectedRows)="onSelectedRows($event)" [autoResizeWidth]=true> + @@ -55,7 +65,7 @@
{{value}}
-
@@ -115,9 +125,9 @@ - + Upgrade Firmware @@ -154,8 +164,9 @@ Firmware
  • - +
  • + @@ -259,8 +270,6 @@ -
    Empty username and password means system default configuration
    @@ -269,39 +278,68 @@ -
    Scan History :
    +
    Task History
    - - - - - -   {{value}} - - - -   {{value}} - - - -   {{value}} - - - - {{value}} - - - - - - - - -
    -
    -
    + +
    + + Filter by Type + + +
    + + + + + + {{getTaskTypeLabel(value)}} + + + + +
    +
    Start: {{item.started}}
    +
    End: {{item.ended}}
    +
    +
    +
    + + +
    +
    {{item.start_ip}} - {{item.end_ip}}
    +
    User: {{item.username}}
    +
    +
    +
    {{item.device_count}} devices
    +
    {{item.task_id}}
    +
    +
    +
    + + +
    + ✓ {{item.success_count}} + ✗ {{item.failed_count}} +
    +
    +
    + + + + + + +
    + + @@ -396,4 +444,213 @@
    + + + +
    Add Devices from CSV
    + +
    + +
    +
    Upload CSV File
    +

    Please upload a CSV file containing device information with columns: IP Address, Username, Password, API Port

    + +
    +
    Preview (First 3 rows):
    + + + + + + + + + + + +
    {{header}}
    {{cell}}
    +
    Column Mapping:
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + Loading... +
    +
    {{uploadStatus}}
    +
    +
    +
    Upload Complete
    +
    + Success: {{uploadResult.success}} devices added successfully
    + Failed: {{uploadResult.failed}} devices failed to add +
    + +
    +
    + + + + + +
    + + + + +
    Web Access Options
    + +
    + +

    Choose how to access the device:

    +
    + + +
    +
    + + + +
    + + + + +
    {{getTaskTypeLabel(selectedTaskDetails?.task_type)}} Results
    + +
    + +
    + + + Task Type: {{getTaskTypeLabel(selectedTaskDetails.task_type)}}
    + Started: {{selectedTaskDetails.started}}
    + Completed: {{selectedTaskDetails.ended}} +
    + +
    + IP Range: {{selectedTaskDetails.start_ip}} - {{selectedTaskDetails.end_ip}}
    + Username: {{selectedTaskDetails.username}} +
    +
    + Task ID: {{selectedTaskDetails.task_id}}
    + Total Devices: {{selectedTaskDetails.device_count}} +
    +
    +
    +
    +
    +
    +
    Detailed Results:
    +
    + +
    +
    + + + + + + + + + + + + + + + + + +
    IP AddressStatusError DetailsError Details
    {{result.ip}} + + {{result.added ? 'Success' : 'Failed'}} + + + {{result.failures || 'N/A'}} + + {{result.faileres || 'N/A'}} +
    + +
    + Success: {{selectedTaskDetails.success_count}} + Failed: {{selectedTaskDetails.failed_count}} +
    +
    + + + + +
    + \ No newline at end of file diff --git a/src/app/views/devices/devices.component.ts b/src/app/views/devices/devices.component.ts index ef56bf8..832760d 100644 --- a/src/app/views/devices/devices.component.ts +++ b/src/app/views/devices/devices.component.ts @@ -39,7 +39,7 @@ export class DevicesComponent implements OnInit, OnDestroy { public tz: string; public ispro:boolean=false; - constructor( + constructor( private data_provider: dataProvider, private route: ActivatedRoute, private router: Router, @@ -72,6 +72,7 @@ export class DevicesComponent implements OnInit, OnDestroy { @ViewChild("grid", { static: true }) gridComponent: GuiGridComponent; @ViewChildren(ToasterComponent) viewChildren!: QueryList; public source: Array = []; + public originalSource: Array = []; public columns: Array = []; public loading: boolean = true; public rows: any = []; @@ -94,6 +95,28 @@ export class DevicesComponent implements OnInit, OnDestroy { public show_pass: boolean = false; public ExecutedDataModalVisible: boolean = false; public ExecutedData: any = []; + public filteredExecutedData: any = []; + public selectedTaskType: string = 'all'; + public detailsModalVisible: boolean = false; + public selectedTaskDetails: any = null; + public detailsCurrentPage: number = 1; + public detailsPageSize: number = 10; + public detailsPaginatedResults: any[] = []; + public detailsSearchTerm: string = ''; + public filteredDetailsResults: any[] = []; + public showWebAccessModal: boolean = false; + public currentDeviceInfo: any = null; + public addDeviceModalVisible: boolean = false; + public addDeviceStep: number = 1; + public csvFile: File | null = null; + public csvData: any[] = []; + public csvHeaders: string[] = []; + public csvPreview: any[] = []; + public columnMapping = { ip: '', username: '', password: '', port: '' }; + public uploadStatus: string = 'Processing devices...'; + public uploadResult = { success: 0, failed: 0, resultFile: null }; + public currentTaskId: string = ''; + public statusCheckTimer: any; toasterForm = { autohide: true, @@ -165,10 +188,12 @@ export class DevicesComponent implements OnInit, OnDestroy { this.check_firmware(); break; case "update": - this.update_firmware(); + this.ConfirmAction = "update"; + this.ConfirmModalVisible = true; break; case "upgrade": - this.upgrade_firmware(); + this.ConfirmAction = "upgrade"; + this.ConfirmModalVisible = true; break; case "logauth": this.router.navigate(["/authlog", { devid: dev.id }]); @@ -183,7 +208,8 @@ export class DevicesComponent implements OnInit, OnDestroy { this.router.navigate(["/backups", { devid: dev.id }]); break; case "reboot": - this.reboot_devices(); + this.ConfirmAction = "reboot"; + this.ConfirmModalVisible = true; break; case "delete": this.ConfirmAction = "delete"; @@ -374,6 +400,7 @@ export class DevicesComponent implements OnInit, OnDestroy { } check_firmware() { var _self = this; + this.ConfirmModalVisible = false; this.data_provider .check_firmware(this.Selectedrows.toString()) .then((res) => { @@ -396,6 +423,7 @@ export class DevicesComponent implements OnInit, OnDestroy { update_firmware() { var _self = this; + this.ConfirmModalVisible = false; this.data_provider .update_firmware(this.Selectedrows.toString()) .then((res) => { @@ -417,6 +445,7 @@ export class DevicesComponent implements OnInit, OnDestroy { upgrade_firmware() { var _self = this; + this.ConfirmModalVisible = false; this.data_provider .upgrade_firmware(this.Selectedrows.toString()) .then((res) => { @@ -436,6 +465,7 @@ export class DevicesComponent implements OnInit, OnDestroy { reboot_devices() { var _self = this; + this.ConfirmModalVisible = false; this.data_provider .reboot_devices(this.Selectedrows.toString()) .then((res) => { @@ -492,11 +522,12 @@ export class DevicesComponent implements OnInit, OnDestroy { ); } else{ - _self.source = res.map((x: any) => { + _self.originalSource = res.map((x: any) => { if (x.upgrade_availble) _self.upgrades.push(x); if (x.update_availble) _self.updates.push(x); return x; }); + _self.source = [..._self.originalSource]; _self.device_interval(); _self.loading = false; } @@ -637,26 +668,273 @@ export class DevicesComponent implements OnInit, OnDestroy { _self.tz, "yyyy-MM-dd HH:mm:ss XXX" ); - d.start_ip=d.info.start_ip; - d.end_ip=d.info.end_ip; + d.start_ip=d.info.start_ip || 'N/A'; + d.end_ip=d.info.end_ip || 'N/A'; + d.task_id=d.info.task_id || 'N/A'; + d.device_count=d.info.device_count || 0; + d.username=d.info.username || 'N/A'; d.result=JSON.parse(d.result); + d.success_count = d.result.filter((r: any) => r.added === true).length; + d.failed_count = d.result.filter((r: any) => r.added === false).length; index += 1; return d; }); + _self.filteredExecutedData = [..._self.ExecutedData]; } }); } + filterByTaskType() { + if (this.selectedTaskType === 'all') { + this.filteredExecutedData = [...this.ExecutedData]; + } else { + this.filteredExecutedData = this.ExecutedData.filter((d: any) => d.task_type === this.selectedTaskType); + } + } + showTaskDetails(task: any) { + this.selectedTaskDetails = task; + this.detailsCurrentPage = 1; + this.updateDetailsPagination(); + this.detailsModalVisible = true; + } + updateDetailsPagination() { + if (!this.selectedTaskDetails?.result) return; + + // Filter results based on search term + this.filteredDetailsResults = this.selectedTaskDetails.result.filter((result: any) => + result.ip.toLowerCase().includes(this.detailsSearchTerm.toLowerCase()) || + (result.failures && result.failures.toLowerCase().includes(this.detailsSearchTerm.toLowerCase())) || + (result.faileres && result.faileres.toLowerCase().includes(this.detailsSearchTerm.toLowerCase())) + ); + + const startIndex = (this.detailsCurrentPage - 1) * this.detailsPageSize; + const endIndex = startIndex + this.detailsPageSize; + this.detailsPaginatedResults = this.filteredDetailsResults.slice(startIndex, endIndex); + } + onDetailsPageChange(page: number) { + this.detailsCurrentPage = page; + this.updateDetailsPagination(); + } + getTotalDetailsPages(): number { + if (!this.filteredDetailsResults) return 0; + return Math.ceil(this.filteredDetailsResults.length / this.detailsPageSize); + } + onDetailsSearch() { + this.detailsCurrentPage = 1; + this.updateDetailsPagination(); + } + closeDetailsModal() { + this.detailsModalVisible = false; + this.selectedTaskDetails = null; + this.detailsPaginatedResults = []; + this.filteredDetailsResults = []; + this.detailsCurrentPage = 1; + this.detailsSearchTerm = ''; + } + filterUpdatable() { + this.source = this.originalSource.filter(device => device.update_availble); + } + filterUpgradable() { + this.source = this.originalSource.filter(device => device.upgrade_availble); + } + + clearFilter() { + this.source = [...this.originalSource]; + } + + webAccess(device: any) { + this.currentDeviceInfo = device; + if (this.ispro) { + this.showWebAccessModal = true; + } else { + this.openDirectAccess(); + } + } + + openProxyAccess() { + if (this.currentDeviceInfo?.id) { + window.open(`/api/proxy/init?devid=${this.currentDeviceInfo.id}`, '_blank'); + } else { + const ip = this.currentDeviceInfo?.ip; + if (ip) { + window.open(`/api/proxy/init?dev_ip=${ip}`, '_blank'); + } + } + this.showWebAccessModal = false; + } + + openDirectAccess() { + const ip = this.currentDeviceInfo?.ip; + if (ip) { + window.open(`http://${ip}`, '_blank'); + } + this.showWebAccessModal = false; + } + + closeWebAccessModal() { + this.showWebAccessModal = false; + } + + openAddDeviceModal() { + this.addDeviceModalVisible = true; + this.resetAddDeviceForm(); + } + + closeAddDeviceModal() { + this.addDeviceModalVisible = false; + this.resetAddDeviceForm(); + } + + resetAddDeviceForm() { + this.addDeviceStep = 1; + this.csvFile = null; + this.csvData = []; + this.csvHeaders = []; + this.csvPreview = []; + this.columnMapping = { ip: '', username: '', password: '', port: '' }; + this.uploadStatus = 'Processing devices...'; + this.uploadResult = { success: 0, failed: 0, resultFile: null }; + this.currentTaskId = ''; + clearTimeout(this.statusCheckTimer); + } + + onFileSelected(event: any) { + const file = event.target.files[0]; + if (file && file.type === 'text/csv') { + this.csvFile = file; + this.parseCSV(file); + } + } + + parseCSV(file: File) { + const reader = new FileReader(); + reader.onload = (e: any) => { + const csv = e.target.result; + const lines = csv.split('\n').filter((line: string) => line.trim()); + + if (lines.length > 0) { + this.csvHeaders = lines[0].split(',').map((header: string) => header.trim()); + this.csvData = lines.slice(1).map((line: string) => + line.split(',').map((cell: string) => cell.trim()) + ); + this.csvPreview = this.csvData.slice(0, 3); + } + }; + reader.readAsText(file); + } + + isValidMapping(): boolean { + return this.columnMapping.ip !== '' && + this.columnMapping.username !== '' && + this.columnMapping.password !== '' && + this.columnMapping.port !== '' && + this.csvData.length > 0; + } + + uploadDevices() { + if (!this.isValidMapping()) return; + + this.addDeviceStep = 2; + + const devices = this.csvData.map(row => ({ + ip: row[parseInt(this.columnMapping.ip)], + username: row[parseInt(this.columnMapping.username)], + password: row[parseInt(this.columnMapping.password)], + port: row[parseInt(this.columnMapping.port)] + })); + + this.data_provider.bulk_add_devices(devices).then((res) => { + if ('error' in res) { + this.addDeviceStep = 3; + this.show_toast('Error', 'Failed to start device upload', 'danger'); + this.uploadResult = { success: 0, failed: devices.length, resultFile: null }; + } else if ('taskId' in res) { + this.currentTaskId = res.taskId; + this.uploadStatus = 'Processing devices...'; + this.checkUploadStatus(); + } else { + this.addDeviceStep = 3; + this.show_toast('Error', 'Invalid response from server', 'danger'); + this.uploadResult = { success: 0, failed: devices.length, resultFile: null }; + } + }).catch(() => { + this.addDeviceStep = 3; + this.uploadResult = { success: 0, failed: devices.length, resultFile: null }; + this.show_toast('Error', 'Failed to upload devices', 'danger'); + }); + } + + checkUploadStatus() { + clearTimeout(this.statusCheckTimer); + + this.data_provider.bulk_add_status(this.currentTaskId).then((res) => { + if ('error' in res) { + this.addDeviceStep = 3; + this.show_toast('Error', 'Failed to check upload status', 'danger'); + this.uploadResult = { success: 0, failed: 0, resultFile: null }; + return; + } + + if (res.status === 'completed') { + this.addDeviceStep = 3; + this.uploadResult = { + success: res.success || 0, + failed: res.failed || 0, + resultFile: res.resultFile || null + }; + this.show_toast('Success', `${res.success} devices added successfully`, 'success'); + this.initGridTable(); + } else if (res.status === 'failed') { + this.addDeviceStep = 3; + this.show_toast('Error', res.message || 'Upload failed', 'danger'); + this.uploadResult = { success: 0, failed: 0, resultFile: null }; + } else { + // Still processing + this.uploadStatus = res.message || 'Processing devices...'; + this.statusCheckTimer = setTimeout(() => { + this.checkUploadStatus(); + }, 3000); + } + }).catch(() => { + this.addDeviceStep = 3; + this.show_toast('Error', 'Failed to check upload status', 'danger'); + this.uploadResult = { success: 0, failed: 0, resultFile: null }; + }); + } + + downloadResults() { + if (this.uploadResult.resultFile) { + const link = document.createElement('a'); + link.href = this.uploadResult.resultFile; + link.download = 'device_upload_results.csv'; + link.click(); + } + } + + getTaskTypeLabel(taskType: string): string { + switch(taskType) { + case 'ip-scan': return 'IP Scan'; + case 'bulk-add': return 'Bulk Add'; + default: return taskType; + } + } + + getStatusColor(success: number, failed: number): string { + if (failed === 0) return 'success'; + if (success === 0) return 'danger'; + return 'warning'; + } ngOnDestroy(): void { clearTimeout(this.scan_timer); + clearTimeout(this.statusCheckTimer); } } diff --git a/src/app/views/devices/devices.module.ts b/src/app/views/devices/devices.module.ts index f16b801..53cd568 100644 --- a/src/app/views/devices/devices.module.ts +++ b/src/app/views/devices/devices.module.ts @@ -17,6 +17,7 @@ import { ModalModule, ListGroupModule, TooltipModule, + TableModule, } from "@coreui/angular"; import { MatMenuModule } from "@angular/material/menu"; import { DevicesRoutingModule } from "./devices-routing.module"; @@ -44,6 +45,7 @@ import { GuiGridModule } from "@generic-ui/ngx-grid"; ListGroupModule, MatMenuModule, TooltipModule, + TableModule, ], declarations: [DevicesComponent], }) From 996c189076d58b892e42e7027f222f6987f25737 Mon Sep 17 00:00:00 2001 From: sepehr Date: Thu, 16 Oct 2025 17:33:19 +0300 Subject: [PATCH 02/11] feat: redesign device group management interface - Complete UI/UX redesign of device groups table - Enhanced member addition interface - Add user management directly from device groups - Add firmware group actions (update and upgrade) - Improved bulk operations for device groups --- .../devices_group/devgroup.component.html | 460 ++++++++++++++---- .../devices_group/devgroup.component.scss | 108 ++++ .../views/devices_group/devgroup.component.ts | 252 ++++++++++ .../views/devices_group/devgroup.module.ts | 8 + 4 files changed, 742 insertions(+), 86 deletions(-) create mode 100644 src/app/views/devices_group/devgroup.component.scss diff --git a/src/app/views/devices_group/devgroup.component.html b/src/app/views/devices_group/devgroup.component.html index fb48f5f..57f80a0 100644 --- a/src/app/views/devices_group/devgroup.component.html +++ b/src/app/views/devices_group/devgroup.component.html @@ -25,29 +25,67 @@ - All Devices + All Devices - 0 Members - {{value.length}} Members + + 0 + + + {{value.length}} + - - {{value}} + {{formatCreateTime(value)}} - - - - - + + + + {{value.length}} + + + + + + + + + +
    +
  • Actions Menu
  • + + + + + +
    +
    @@ -59,92 +97,146 @@ - - -
    Group Edit
    + + +
    Edit Device Group
    - - -
    - - + + +
    +
    + +
    - - -
    Group Members :
    - - - -   {{value}} - - - - {{value}} - - - - - - - - -
    - -
    -
    - + + {{groupMembers.length}} Devices + +
    + + + + +
    Current Group Members
    +
    + + +
    +
    + +
    + +
    No devices in this group
    +

    Click "Add Devices" to start adding devices to this group

    +
    +
    + + + +
    + + {{value}} +
    +
    +
    + + + {{value}} + + + + + + + +
    +
    +
    +
    - - + + - - -
    Members not in Group
    + + +
    Add Devices to Group
    - - -
    Members Availble to add:
    - - - -   {{value}} - - - - {{value}} - - - - - {{value}} - - - -
    -
    -
    + + +
    + + + {{NewMemberRows.length}} device(s) selected for addition + +
    + + + + +
    Available Devices ({{availbleMembers.length}} total)
    +
    + +
    + +
    All devices are already in groups
    +

    No available devices to add to this group

    +
    +
    + + + +
    + + {{value}} +
    +
    +
    + + + {{value}} + + + + + {{value}} + + +
    +
    +
    +
    - - - + +
    + Select devices from the list above to add them to the group +
    +
    + + +
    @@ -175,4 +267,200 @@ Close
    + + + +
    User Permissions - {{selectedGroup?.name}}
    + +
    + + + +
    Add User Permission
    +
    + + + +
    + + +
    +
    + {{user.username}} ({{user.first_name}} {{user.last_name}}) +
    +
    +
    + No users found +
    +
    +
    + +
    + + +
    +
    + {{perm.name}} +
    +
    +
    + No permissions found +
    +
    +
    + + + +
    +
    +
    + + +
    Current Permissions ({{selectedGroup?.assigned_users?.length || 0}} users)
    +
    + +
    + +

    No users have permissions for this group

    +
    +
    + + + + + + + + + + + + + + + + + + + +
    #UserNamePermissionActions
    {{i + 1}} +
    + + {{user.username}} +
    +
    {{user.first_name}} {{user.last_name}} + {{user.perm_name}} + +
    + + +
    +
    +
    +
    +
    +
    + + + +
    + + + +
    Change Permission for {{editingUser?.username}}
    + +
    + +
    + +
    +
    + + +
    +
    + + + + +
    + + + +
    Remove Permission
    + +
    + +
    + +

    Are you sure you want to remove {{removingUser?.username}}'s permission from group {{selectedGroup?.name}}?

    +

    This action cannot be undone.

    +
    +
    + + + + +
    + + +
    Confirm Firmware {{firmwareAction | titlecase}}
    + +
    + +
    + +

    Are you sure you want to {{firmwareAction}} firmware for all devices in group {{selectedGroupForFirmware?.name}}?

    +

    This action will affect all devices in this group and may take some time to complete.

    +
    +
    + + + +
    \ No newline at end of file diff --git a/src/app/views/devices_group/devgroup.component.scss b/src/app/views/devices_group/devgroup.component.scss new file mode 100644 index 0000000..c845baa --- /dev/null +++ b/src/app/views/devices_group/devgroup.component.scss @@ -0,0 +1,108 @@ +.users-summary { + min-height: 60px; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.user-badges { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +.user-badges c-badge { + font-size: 0.7rem; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 768px) { + .users-summary { + min-height: auto; + } + + .user-badges c-badge { + max-width: 60px; + font-size: 0.65rem; + } +} + +/* Search Select Components */ +.search-select-wrapper { + position: relative; +} + +.search-label { + display: block; + font-size: 0.8rem; + color: #6c757d; + margin-bottom: 0.25rem; + font-weight: 500; +} + +.search-input { + width: 100%; +} + +.search-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ced4da; + border-top: none; + border-radius: 0 0 0.375rem 0.375rem; + max-height: 200px; + overflow-y: auto; + z-index: 1000; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.search-option { + padding: 0.5rem 0.75rem; + cursor: pointer; + border-bottom: 1px solid #f1f3f4; + font-size: 0.85rem; + transition: background-color 0.2s ease; +} + +.search-option:hover { + background-color: #f8f9fa; +} + +.search-option:last-child { + border-bottom: none; +} + +.search-no-results { + padding: 0.75rem; + text-align: center; + color: #6c757d; + font-size: 0.8rem; + font-style: italic; + background: white; + border: 1px solid #ced4da; + border-top: none; + border-radius: 0 0 0.375rem 0.375rem; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; +} + +.compact-select { + font-size: 0.85rem; + height: calc(1.8em + 0.75rem + 2px); + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; +} + +.compact-select:focus { + border-color: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} \ No newline at end of file diff --git a/src/app/views/devices_group/devgroup.component.ts b/src/app/views/devices_group/devgroup.component.ts index 5ee1248..5614bfa 100644 --- a/src/app/views/devices_group/devgroup.component.ts +++ b/src/app/views/devices_group/devgroup.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from "@angular/core"; import { dataProvider } from "../../providers/mikrowizard/data"; import { Router } from "@angular/router"; import { loginChecker } from "../../providers/login_checker"; +import { formatInTimeZone } from "date-fns-tz"; import { GuiSearching, GuiSelectedRow, @@ -31,10 +32,12 @@ interface IUser { @Component({ templateUrl: "devgroup.component.html", + styleUrls: ["devgroup.component.scss"] }) export class DevicesGroupComponent implements OnInit { public uid: number; public uname: string; + public tz: string; constructor( private data_provider: dataProvider, @@ -50,6 +53,7 @@ export class DevicesGroupComponent implements OnInit { this.data_provider.getSessionInfo().then((res) => { _self.uid = res.uid; _self.uname = res.name; + _self.tz = res.tz; const userId = _self.uid; if (res.role != "admin") { @@ -83,6 +87,30 @@ export class DevicesGroupComponent implements OnInit { id: 0, name: "", }; + public selectedGroup: any = null; + public UserManagementModalVisible: boolean = false; + public EditPermissionModalVisible: boolean = false; + public RemovePermissionModalVisible: boolean = false; + public availableUsers: any[] = []; + public availablePermissions: any[] = []; + public selectedUserId: string = ""; + public selectedPermId: string = ""; + public selectedUser: any = null; + public selectedPermission: any = null; + public userSearch: string = ''; + public permissionSearch: string = ''; + public filteredUsers: any[] = []; + public filteredPermissions: any[] = []; + public showUserDropdown: boolean = false; + public showPermissionDropdown: boolean = false; + public editingUser: any = null; + public removingUser: any = null; + public newPermissionId: string = ""; + private deviceCache: { [key: number]: any[] } = {}; + private loadingDevices: { [key: number]: boolean } = {}; + public FirmwareConfirmModalVisible: boolean = false; + public firmwareAction: string = ""; + public selectedGroupForFirmware: any = null; public DefaultCurrentGroup: any = { array_agg: [], created: "", @@ -182,6 +210,9 @@ export class DevicesGroupComponent implements OnInit { save_group() { var _self = this; this.data_provider.update_save_group(this.currentGroup).then((res) => { + // Clear device cache for this group + delete this.deviceCache[this.currentGroup.id]; + delete this.loadingDevices[this.currentGroup.id]; _self.initGridTable(); _self.EditGroupModalVisible = false; }); @@ -237,4 +268,225 @@ export class DevicesGroupComponent implements OnInit { this.loading = false; }); } + + manageUsers(group: any): void { + this.selectedGroup = { ...group }; + this.loadAvailableUsers(); + this.loadAvailablePermissions(); + this.UserManagementModalVisible = true; + } + + loadAvailableUsers(): void { + this.data_provider.get_users(1, 1000, "").then((res) => { + this.availableUsers = res.filter((user: any) => + !this.selectedGroup.assigned_users.some((assignedUser: any) => assignedUser.user_id === user.id) + ); + this.filteredUsers = [...this.availableUsers]; + }); + } + + loadAvailablePermissions(): void { + this.data_provider.get_perms(1, 1000, "").then((res) => { + this.availablePermissions = res; + this.filteredPermissions = [...this.availablePermissions]; + }); + } + + filterUsers(event: any): void { + const query = event.target.value.toLowerCase(); + this.filteredUsers = this.availableUsers.filter((user: any) => + user.username.toLowerCase().includes(query) || + (user.first_name + ' ' + user.last_name).toLowerCase().includes(query) + ); + } + + filterPermissions(event: any): void { + const query = event.target.value.toLowerCase(); + this.filteredPermissions = this.availablePermissions.filter((perm: any) => + perm.name.toLowerCase().includes(query) + ); + } + + selectUser(user: any): void { + this.selectedUser = user; + this.selectedUserId = user.id; + this.userSearch = user.username + ' (' + user.first_name + ' ' + user.last_name + ')'; + this.showUserDropdown = false; + } + + selectPermission(perm: any): void { + this.selectedPermission = perm; + this.selectedPermId = perm.id; + this.permissionSearch = perm.name; + this.showPermissionDropdown = false; + } + + hideUserDropdown(): void { + setTimeout(() => this.showUserDropdown = false, 200); + } + + hidePermissionDropdown(): void { + setTimeout(() => this.showPermissionDropdown = false, 200); + } + + addUserPermission(): void { + if (!this.selectedUser || !this.selectedPermission) return; + + console.log('Adding user permission:', { + userId: this.selectedUser.id, + permissionId: this.selectedPermission.id, + groupId: this.selectedGroup.id, + selectedUser: this.selectedUser, + selectedPermission: this.selectedPermission + }); + + this.data_provider.Add_user_perm(this.selectedUser.id, +this.selectedPermission.id, this.selectedGroup.id) + .then((res) => { + console.log('Add user permission response:', res); + this.initGridTable(); + this.selectedUserId = ""; + this.selectedPermId = ""; + this.selectedUser = null; + this.selectedPermission = null; + this.userSearch = ""; + this.permissionSearch = ""; + // Refresh the selected group data + this.data_provider.get_devgroup_list().then((groups) => { + this.selectedGroup = groups.find((g: any) => g.id === this.selectedGroup.id); + this.loadAvailableUsers(); + }); + }); + } + + editUserPermission(user: any): void { + this.editingUser = { ...user }; + this.newPermissionId = user.perm_id.toString(); + this.EditPermissionModalVisible = true; + } + + updateUserPermission(): void { + if (!this.newPermissionId) return; + + // Remove old permission and add new one + this.data_provider.Delete_user_perm(this.editingUser.id).then(() => { + this.data_provider.Add_user_perm(this.editingUser.user_id, +this.newPermissionId, this.selectedGroup.id) + .then(() => { + this.EditPermissionModalVisible = false; + this.initGridTable(); + // Refresh the selected group data + this.data_provider.get_devgroup_list().then((groups) => { + this.selectedGroup = groups.find((g: any) => g.id === this.selectedGroup.id); + }); + }); + }); + } + + removeUserPermission(user: any): void { + this.removingUser = { ...user }; + this.RemovePermissionModalVisible = true; + } + + confirmRemovePermission(): void { + this.data_provider.Delete_user_perm(this.removingUser.id).then(() => { + this.RemovePermissionModalVisible = false; + this.initGridTable(); + // Refresh the selected group data + this.data_provider.get_devgroup_list().then((groups) => { + this.selectedGroup = groups.find((g: any) => g.id === this.selectedGroup.id); + this.loadAvailableUsers(); + }); + }); + } + + getPermissionColor(permName: string): string { + const colorMap: { [key: string]: string } = { + 'full': 'success', + 'read': 'info', + 'write': 'warning', + 'admin': 'danger', + 'test': 'secondary' + }; + return colorMap[permName] || 'primary'; + } + + getUsersTooltip(users: any[]): string { + if (users.length === 0) return 'No users assigned'; + + const maxShow = 10; + const userList = users.slice(0, maxShow).map((user, index) => + `• ${user.username} (${user.perm_name})` + ).join('\n'); + + return users.length > maxShow + ? `${userList}\n━━━━━━━━━━━━━━━━\n+${users.length - maxShow} more users` + : userList; + } + + getDevicesTooltip(group: any): string { + if (group.id === 1) return 'All devices in the system'; + if (!group.array_agg || group.array_agg[0] === null) return 'No devices assigned'; + + // Check if data is cached + if (this.deviceCache[group.id]) { + const devices = this.deviceCache[group.id]; + const maxShow = 10; + const deviceList = devices.slice(0, maxShow).map(device => + `• ${device.name} (${device.ip})` + ).join('\n'); + + return devices.length > maxShow + ? `${deviceList}\n━━━━━━━━━━━━━━━━\n+${devices.length - maxShow} more devices` + : deviceList; + } + + // Check if already loading + if (this.loadingDevices[group.id]) { + return 'Loading devices...'; + } + + // Start loading + this.loadingDevices[group.id] = true; + this.data_provider.get_devgroup_members(group.id).then((devices) => { + this.deviceCache[group.id] = devices; + this.loadingDevices[group.id] = false; + }).catch(() => { + this.loadingDevices[group.id] = false; + }); + + return 'Loading devices...'; + } + + formatCreateTime(dateString: string): string { + if (!dateString || !this.tz) return dateString; + return formatInTimeZone( + dateString.split(".")[0] + ".000Z", + this.tz, + "yyyy-MM-dd HH:mm:ss XXX" + ); + } + + groupFirmwareAction(group: any, action: string): void { + this.selectedGroupForFirmware = group; + this.firmwareAction = action; + this.FirmwareConfirmModalVisible = true; + } + + confirmGroupFirmwareAction(): void { + if (!this.selectedGroupForFirmware) return; + + this.data_provider.group_firmware_action(this.selectedGroupForFirmware.id, this.firmwareAction) + .then((res) => { + if ("error" in res) { + console.error('Firmware action failed:', res.error); + } else { + const actionText = this.firmwareAction === 'update' ? 'Update' : 'Upgrade'; + console.log(`${actionText} firmware initiated for group: ${this.selectedGroupForFirmware.name}`); + } + this.FirmwareConfirmModalVisible = false; + }) + .catch((error) => { + console.error('Firmware action error:', error); + this.FirmwareConfirmModalVisible = false; + }); + } } diff --git a/src/app/views/devices_group/devgroup.module.ts b/src/app/views/devices_group/devgroup.module.ts index 0fe8c12..1dc3e06 100644 --- a/src/app/views/devices_group/devgroup.module.ts +++ b/src/app/views/devices_group/devgroup.module.ts @@ -2,6 +2,7 @@ import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; import { + AlertModule, ButtonGroupModule, ButtonModule, CardModule, @@ -9,15 +10,19 @@ import { GridModule, CollapseModule, ModalModule, + TooltipModule, + ListGroupModule, } from "@coreui/angular"; import { DevicesGroupRoutingModule } from "./devgroup-routing.module"; import { DevicesGroupComponent } from "./devgroup.component"; import { GuiGridModule } from "@generic-ui/ngx-grid"; import { BadgeModule } from "@coreui/angular"; import { FormsModule } from "@angular/forms"; +import { MatMenuModule } from "@angular/material/menu"; @NgModule({ imports: [ DevicesGroupRoutingModule, + AlertModule, CardModule, CommonModule, GridModule, @@ -29,6 +34,9 @@ import { FormsModule } from "@angular/forms"; CollapseModule, ModalModule, BadgeModule, + TooltipModule, + MatMenuModule, + ListGroupModule, ], declarations: [DevicesGroupComponent], }) From b20a3d78261f8605c0a06dde12e0dbc8d5fa8583 Mon Sep 17 00:00:00 2001 From: sepehr Date: Thu, 16 Oct 2025 17:33:27 +0300 Subject: [PATCH 03/11] feat: add network topology maps for pro users - Add comprehensive network visualization module - Pro feature with advanced topology mapping - Interactive network device mapping - Integration with navigation and routing --- src/app/app-routing.module.ts | 5 + src/app/containers/default-layout/_nav.ts | 6 + src/app/views/maps/code-typescript.txt | 377 +++++++++++++++++ src/app/views/maps/maps-routing.module.ts | 21 + src/app/views/maps/maps.component.html | 82 ++++ src/app/views/maps/maps.component.scss | 256 ++++++++++++ src/app/views/maps/maps.component.ts | 479 ++++++++++++++++++++++ src/app/views/maps/maps.module.ts | 60 +++ 8 files changed, 1286 insertions(+) create mode 100644 src/app/views/maps/code-typescript.txt create mode 100644 src/app/views/maps/maps-routing.module.ts create mode 100644 src/app/views/maps/maps.component.html create mode 100644 src/app/views/maps/maps.component.scss create mode 100644 src/app/views/maps/maps.component.ts create mode 100644 src/app/views/maps/maps.module.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 3c4186a..2b30133 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -49,6 +49,11 @@ const routes: Routes = [ loadChildren: () => import('./views/devices_group/devgroup.module').then((m) => m.DevicesGroupModule) }, + { + path: 'maps', + loadChildren: () => + import('./views/maps/maps.module').then((m) => m.MapsModule) + }, { path: 'authlog', loadChildren: () => diff --git a/src/app/containers/default-layout/_nav.ts b/src/app/containers/default-layout/_nav.ts index d313617..00c76ce 100644 --- a/src/app/containers/default-layout/_nav.ts +++ b/src/app/containers/default-layout/_nav.ts @@ -28,6 +28,12 @@ export const navItems: INavData[] = [ // linkProps: { fragment: 'someAnchor' }, icon: 'fa-solid fa-layer-group' }, + { + name: 'Network Maps', + url: '/maps', + icon:'fa-solid fa-map', + attributes: { 'pro':true } + }, // { // name: 'Tools', // url: '/login', diff --git a/src/app/views/maps/code-typescript.txt b/src/app/views/maps/code-typescript.txt new file mode 100644 index 0000000..64f7093 --- /dev/null +++ b/src/app/views/maps/code-typescript.txt @@ -0,0 +1,377 @@ +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from "@angular/core"; +import { dataProvider } from "../../providers/mikrowizard/data"; +import { loginChecker } from "../../providers/login_checker"; +import { Router } from "@angular/router"; +import { formatInTimeZone } from "date-fns-tz"; +import { Network } from 'vis-network/peer'; +import { DataSet } from 'vis-data'; + +@Component({ + templateUrl: "maps.component.html", + styleUrls: ["maps.component.scss"], +}) +export class MapsComponent implements OnInit { + public uid: number; + public uname: string; + public ispro: boolean = false; + public tz: string; + public savedPositions: any = {}; + public savedPositionsKey = "network-layout"; + public selectedDevice: any = null; + constructor( + private data_provider: dataProvider, + private router: Router, + private login_checker: loginChecker + ) { + var _self = this; + if (!this.login_checker.isLoggedIn()) { + setTimeout(function () { + _self.router.navigate(["login"]); + }, 100); + } + this.data_provider.getSessionInfo().then((res) => { + _self.uid = res.uid; + _self.uname = res.name; + _self.ispro = res.ISPRO; + if (!_self.ispro) + setTimeout(function () { + _self.router.navigate(["dashboard"]); + }, 100); + _self.tz = res.tz; + }); + } + + @ViewChild('network', { static: true }) networkContainer: ElementRef | undefined; + + mikrotikData: any[] = []; + + + + + ngOnInit(): void { + this.loadFontAwesome(); + this.savedPositions = JSON.parse(localStorage.getItem(this.savedPositionsKey) || "{}"); + this.loadNetworkData(); + } + + loadNetworkData(): void { + this.data_provider.getNetworkMap().then((res) => { + this.mikrotikData = res; + console.dir(res); + setTimeout(() => { + this.createNetworkMap(); + }, 100); + }); + } + + loadFontAwesome() { + if (!document.querySelector('link[href*="font-awesome"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'; + document.head.appendChild(link); + } + } + +createNetworkMap() { + const container = this.networkContainer?.nativeElement; + if (!container) return; + + let nodes = new DataSet([]); + let edges = new DataSet([]); + let deviceMap: { [key: string]: string } = {}; // uniqueId to nodeId mapping + let allDevices: { [key: string]: any } = {}; // uniqueId to device info mapping + let macToDevice: { [key: string]: string } = {}; // MAC -> uniqueId mapping + const hasSavedPositions = Object.keys(this.savedPositions).length > 0; + let nodeIdCounter = 1; + const getUniqueId = (obj: any): string => { + if (obj.device_id) return `dev_${obj.device_id}`; + if (obj.mac) return `mac_${obj.mac}`; + if (obj.software_id) return `sw_${obj.software_id}`; + if (obj.hostname) return `host_${obj.hostname}`; + return `unknown_${obj.address || Math.random().toString(36).slice(2)}`; + }; + // Collect all devices + this.mikrotikData.forEach((device) => { + const deviceId = device.device_id || `${device.name}_${Date.now()}`; + + if (!allDevices[deviceId]) { + allDevices[deviceId] = { + name: device.name, + type: 'Router', + brand: 'MikroTik' + }; + } + + Object.entries(device.interfaces).forEach(([_, iface]: [string, any]) => { + if (iface.mac) { + macToDevice[iface.mac] = deviceId; + } + + iface.neighbors.forEach((neighbor: any) => { + const neighborId = + neighbor.device_id || + neighbor.software_id || + `${neighbor.hostname}_${neighbor.mac}_${neighbor.address || 'unknown'}`; + + if (!allDevices[neighborId]) { + allDevices[neighborId] = { + name: neighbor.hostname || neighbor.mac || 'Unknown', + type: neighbor.type || 'Router', + brand: neighbor.brand || 'MikroTik' + }; + } + + if (neighbor.mac) { + macToDevice[neighbor.mac] = neighborId; + } + }); + }); + }); + + // Create nodes + Object.entries(allDevices).forEach(([uniqueId, device]: [string, any]) => { + const nodeId = `node_${nodeIdCounter++}`; + deviceMap[uniqueId] = nodeId; + + nodes.add({ + id: nodeId, + label: device.name, + shape: 'image', + image: this.getDeviceIcon(device.type || 'Unknown', device.brand || 'Unknown'), + size: 15, + font: { size: 11, color: '#333', face: 'Arial, sans-serif' }, + ...(hasSavedPositions && this.savedPositions[nodeId] + ? { x: this.savedPositions[nodeId].x, y: this.savedPositions[nodeId].y } + : {}) + } as any); + }); + + // Create edges + this.mikrotikData.forEach((device) => { + Object.entries(device.interfaces).forEach(([ifaceName, iface]: [string, any]) => { + const sourceDeviceId = macToDevice[iface.mac]; + iface.neighbors.forEach((neighbor: any) => { + const targetDeviceId = macToDevice[neighbor.mac]; + + if (deviceMap[sourceDeviceId] && deviceMap[targetDeviceId]) { + const edgeId = `${sourceDeviceId}_${targetDeviceId}`; + const reverseId = `${targetDeviceId}_${sourceDeviceId}`; + + if (!edges.get().find(e => e.id === edgeId || e.id === reverseId)) { + edges.add({ + id: edgeId, + from: deviceMap[sourceDeviceId], + to: deviceMap[targetDeviceId], + label: ifaceName, + color: { color: '#34495e', highlight: '#3498db' }, + width: 3, + smooth: { type: 'continuous', roundness: 0.1 }, + font: { + size: 12, + color: '#2c3e50', + face: 'Arial, sans-serif', + strokeWidth: 1, + strokeColor: '#ffffff', + align: 'horizontal' + } + } as any); + } + } + }); + }); + }); + + const data = { nodes, edges }; + const options = { physics: { enabled: true, stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -8000, centralGravity: 0.3, springLength: 200, springConstant: 0.04, damping: 0.09 } }, interaction: { hover: true, dragNodes: true, dragView: true, zoomView: true, hoverConnectedEdges: false, selectConnectedEdges: false, navigationButtons: false, keyboard: false }, nodes: { borderWidth: 3, shadow: true }, edges: { shadow: true, smooth: true, length: 150 }, manipulation: { enabled: false } }; + const network = new Network(container, data, options); + + // Keep your existing events (dragEnd, click, stabilization, etc.) + // No changes needed below + network.on('dragEnd', () => { + const positions = network.getPositions(); + this.savedPositions = positions; + localStorage.setItem(this.savedPositionsKey, JSON.stringify(positions)); + }); + + network.on('click', (event: any) => { + if (event.nodes[0]) { + const clickedNode = nodes.get(event.nodes[0]); + const canvasPosition = network.canvasToDOM(event.pointer.canvas); + const containerRect = container.getBoundingClientRect(); + const mainContainer = document.querySelector('.main-container') as HTMLElement; + const mainRect = mainContainer?.getBoundingClientRect() || containerRect; + + let adjustedX = canvasPosition.x + containerRect.left - mainRect.left + 20; + let adjustedY = canvasPosition.y + containerRect.top - mainRect.top - 50; + + const popupWidth = 280; + const popupHeight = 200; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (adjustedX + popupWidth > viewportWidth) adjustedX -= popupWidth + 40; + if (adjustedY + popupHeight > viewportHeight) adjustedY -= popupHeight + 40; + if (adjustedX < 20) adjustedX = 20; + if (adjustedY < 20) adjustedY = 20; + + this.handleNodeClick(clickedNode, { x: adjustedX, y: adjustedY }); + } + }); + + network.on('stabilizationIterationsDone', () => { + network.fit(); + if (!hasSavedPositions) { + const positions = network.getPositions(); + this.savedPositions = positions; + localStorage.setItem(this.savedPositionsKey, JSON.stringify(positions)); + } + }); + + if (hasSavedPositions) { + setTimeout(() => network.fit(), 500); + } +} + + handleNodeClick(node: any, position?: { x: number, y: number }) { + this.selectedDevice = node; + if (position) { + this.selectedDevice.popupPosition = position; + } + } + + closeDeviceDetails() { + this.selectedDevice = null; + } + + getDeviceInterfaces() { + if (!this.selectedDevice) return []; + + const device = this.mikrotikData.find(d => d.name === this.selectedDevice.label); + if (!device) return []; + + return Object.entries(device.interfaces).map(([name, data]: [string, any]) => ({ + name, + address: data.address, + mac: data.mac + })); + } + + getNodeColor(deviceType: string): string { + const colors = { + 'gateway': '#dc3545', + 'router': '#fd7e14', + 'switch': '#6f42c1', + 'ap': '#20c997', + 'cpe': '#0dcaf0' + }; + return (colors as any)[deviceType] || '#6c757d'; + } + + getNodeBorderColor(deviceType: string): string { + const borderColors = { + 'gateway': '#b02a37', + 'router': '#e8681a', + 'switch': '#59359a', + 'ap': '#1aa179', + 'cpe': '#0baccc' + }; + return (borderColors as any)[deviceType] || '#495057'; + } + + getDeviceIcon(deviceType: string, brand: string): string { + const basePath = './assets/Network-Icons-SVG/'; + const type = deviceType.toLowerCase(); + const brandName = brand.toLowerCase(); + + // MikroTik devices + if (brandName === 'mikrotik') { + if (type === 'switch') { + return `${basePath}cumulus-switch-v2.svg`; + } + return `${basePath}cumulus-router-v2.svg`; + } + + // Cisco devices + if (brandName === 'cisco') { + if (type === 'switch') { + return `${basePath}cisco-switch-l2.svg`; + } + return `${basePath}cisco-router.svg`; + } + + // Juniper devices + if (brandName === 'juniper') { + if (type === 'switch') { + return `${basePath}juniper-switch-l2.svg`; + } + return `${basePath}juniper-router.svg`; + } + + // HPE/Aruba devices + if (brandName === 'hpe/aruba' || brandName === 'aruba' || brandName === 'hpe') { + if (type === 'server') { + return `${basePath}generic-server-1.svg`; + } + return `${basePath}arista-switch.svg`; + } + + // Ubiquiti devices + if (brandName === 'ubiquiti' || brandName === 'ubnt') { + if (type === 'switch') { + return `${basePath}generic-switch-l2-v1-colour.svg`; + } + return `${basePath}generic-router-colour.svg`; + } + + // Default icons by type + const defaultIcons = { + 'switch': `${basePath}generic-switch-l2-v1-colour.svg`, + 'router': `${basePath}generic-router-colour.svg`, + 'router/switch': `${basePath}generic-router-colour.svg`, + 'server': `${basePath}generic-server-1.svg`, + 'unknown': `${basePath}generic-router-colour.svg` + }; + + return (defaultIcons as any)[type] || `${basePath}generic-router-colour.svg`; + } + + getDefaultPosition(deviceName: string, index: number): { x: number, y: number } { + const positions = { + 'Core Router': { x: 0, y: 0 }, + 'Edge Router': { x: -200, y: -100 }, + 'Distribution Switch': { x: 200, y: -100 }, + 'Access Point 1': { x: 100, y: 100 }, + 'Access Point 2': { x: 300, y: 100 }, + 'Customer Router 1': { x: 0, y: 200 }, + 'Customer Router 2': { x: 200, y: 200 } + }; + return (positions as any)[deviceName] || { x: index * 100, y: index * 50 }; + } + + webAccess() { + if (!this.selectedDevice) return; + const device = this.mikrotikData.find(d => d.name === this.selectedDevice.label); + if (device) { + const firstInterface = Object.values(device.interfaces)[0] as any; + const ip = firstInterface.address.split('/')[0]; + window.open(`http://${ip}`, '_blank'); + } + } + + showMoreInfo() { + console.log('More info for:', this.selectedDevice); + // Implement modal or detailed view + } + + pingDevice() { + console.log('Ping device:', this.selectedDevice); + // Implement ping functionality + } + + configureDevice() { + console.log('Configure device:', this.selectedDevice); + // Implement configuration interface + } + +} diff --git a/src/app/views/maps/maps-routing.module.ts b/src/app/views/maps/maps-routing.module.ts new file mode 100644 index 0000000..db9975a --- /dev/null +++ b/src/app/views/maps/maps-routing.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { MapsComponent } from './maps.component'; + +const routes: Routes = [ + { + path: '', + component: MapsComponent, + data: { + title: $localize`Maps Wall` + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class MapsRoutingModule { +} diff --git a/src/app/views/maps/maps.component.html b/src/app/views/maps/maps.component.html new file mode 100644 index 0000000..3319749 --- /dev/null +++ b/src/app/views/maps/maps.component.html @@ -0,0 +1,82 @@ + + + + +
    +
    +
    +
    + +
    +
    +
    + {{ selectedDevice.label }} + +
    + +
    +
    +
    Type: {{getDeviceInfo()?.type}} ({{getDeviceInfo()?.brand}})
    +
    Board: {{getDeviceInfo()?.board}}
    +
    Version: {{getDeviceInfo()?.version}}
    +
    System: {{getDeviceInfo()?.systemDescription}}
    +
    IP: {{getPrimaryIP()}}
    +
    Neighbors: {{getNeighborCount()}}
    +
    + +
    +
    +
    + {{ interface.name }} + ({{ interface.neighbors }} neighbors) +
    + {{ interface.address }} +
    +
    + +
    + + + +
    +
    +
    +
    + + + + +
    Web Access Options
    + +
    + +

    Choose how to access the device:

    +
    + + +
    +
    + + + +
    diff --git a/src/app/views/maps/maps.component.scss b/src/app/views/maps/maps.component.scss new file mode 100644 index 0000000..26682f1 --- /dev/null +++ b/src/app/views/maps/maps.component.scss @@ -0,0 +1,256 @@ +:host { + .network-container { + height: calc(100vh - 165px); + margin: 0; + position: relative; + } + + .network-col { + padding: 0; + } + + .network-card { + height: 100%; + border: none; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + border-radius: 12px; + overflow: hidden; + position: relative; + } + + .refresh-btn { + position: absolute; + top: 10px; + right: 10px; + z-index: 1000; + padding: 6px 12px; + font-size: 12px; + } + + .network-canvas { + width: 100%; + height: calc(100vh - 200px); + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-radius: 8px; + border: 1px solid #dee2e6; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + + ::ng-deep .vis-network { + cursor: default; + + .vis-item { + cursor: pointer; + + &:hover { + cursor: pointer; + } + + &:active { + cursor: grabbing; + } + } + } + } + + .floating-sidebar { + position: fixed; + z-index: 10000; + animation: slideIn 0.3s ease; + } + + .device-panel { + background: rgba(44, 62, 80, 0.95); + backdrop-filter: blur(10px); + border-radius: 8px; + border: 1px solid rgba(52, 73, 94, 0.8); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + width: 280px; + max-height: calc(100vh - 40px); + color: white; + display: flex; + flex-direction: column; + } + + .panel-header { + padding: 12px 16px; + border-bottom: 1px solid rgba(52, 73, 94, 0.5); + display: flex; + justify-content: space-between; + align-items: center; + } + + .device-name { + font-weight: 600; + font-size: 14px; + color: #ecf0f1; + } + + .close-btn { + color: #bdc3c7; + border: none; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: rgba(255, 255, 255, 0.2); + color: white; + } + } + + .panel-content { + padding: 16px; + overflow-y: auto; + flex: 1; + + /* Override global scrollbar styles */ + scrollbar-width: thin !important; + scrollbar-color: #bdc3c7 rgba(52, 73, 94, 0.3) !important; + + /* Custom scrollbar styling for webkit */ + &::-webkit-scrollbar { + width: 8px !important; + } + + &::-webkit-scrollbar-track { + background: rgba(52, 73, 94, 0.3) !important; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #bdc3c7 !important; + border-radius: 4px; + + &:hover { + background: #ecf0f1 !important; + } + } + } + + .interfaces-section { + margin-bottom: 16px; + margin-top: 3px; + + .interface-row { + max-height: none; + } + } + + .section-title { + font-size: 12px; + font-weight: 600; + color: #95a5a6; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .interface-row { + display: flex; + flex-direction: column; + padding: 1px 0; + border-bottom: 1px solid rgba(52, 73, 94, 0.3); + font-size: 12px; + gap: 2px; + + &:last-child { + border-bottom: none; + } + } + + .if-header { + display: flex; + justify-content: space-between; + align-items: center; + } + + .if-name { + color: #3498db; + font-weight: 500; + } + + .if-ip { + color: #ecf0f1; + font-family: monospace; + word-break: break-all; + } + + .if-neighbors { + color: #95a5a6; + font-size: 11px; + } + + .actions-section { + display: flex; + gap: 8px; + + &:has(button:only-child) { + button { + flex: 1; + } + } + + &:not(:has(button:only-child)) { + display: grid; + grid-template-columns: 1fr 1fr; + } + } + + .compact-btn { + padding: 6px 8px; + font-size: 11px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 4px; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + } + } + + @keyframes slideIn { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } + } +} + +.mdc-line-ripple.mdc-line-ripple--deactivating.ng-star-inserted { + display: none!important; +} + +::ng-deep .main-container{ + padding:0!important; + margin-top:-10px; +} + +::ng-deep .header{ + margin-bottom: 0.9rem!important; +} + +@media only screen and (max-width: 768px) { + :host .floating-sidebar { + position: fixed; + top: 10px; + right: 10px; + left: 10px; + width: auto; + } + + :host .device-panel { + width: 100%; + } +} \ No newline at end of file diff --git a/src/app/views/maps/maps.component.ts b/src/app/views/maps/maps.component.ts new file mode 100644 index 0000000..d545e8a --- /dev/null +++ b/src/app/views/maps/maps.component.ts @@ -0,0 +1,479 @@ +import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from "@angular/core"; +import { dataProvider } from "../../providers/mikrowizard/data"; +import { loginChecker } from "../../providers/login_checker"; +import { Router } from "@angular/router"; +import { formatInTimeZone } from "date-fns-tz"; +import { Network } from 'vis-network/peer'; +import { DataSet } from 'vis-data'; + +@Component({ + templateUrl: "maps.component.html", + styleUrls: ["maps.component.scss"], +}) +export class MapsComponent implements OnInit { + public uid: number; + public uname: string; + public ispro: boolean = false; + public tz: string; + public savedPositions: any = {}; + public savedPositionsKey = "network-layout"; + public selectedDevice: any = null; + public showWebAccessModal: boolean = false; + public showMoreInfoModal: boolean = false; + public currentDeviceInfo: any = null; + constructor( + private data_provider: dataProvider, + private router: Router, + private login_checker: loginChecker + ) { + var _self = this; + if (!this.login_checker.isLoggedIn()) { + setTimeout(function () { + _self.router.navigate(["login"]); + }, 100); + } + this.data_provider.getSessionInfo().then((res) => { + _self.uid = res.uid; + _self.uname = res.name; + _self.ispro = res.ISPRO; + if (!_self.ispro) + setTimeout(function () { + _self.router.navigate(["dashboard"]); + }, 100); + _self.tz = res.tz; + }); + } + + @ViewChild('network', { static: true }) networkContainer: ElementRef | undefined; + + mikrotikData: any[] = []; + + ngOnInit(): void { + this.loadFontAwesome(); + this.savedPositions = JSON.parse(localStorage.getItem(this.savedPositionsKey) || "{}"); + this.loadNetworkData(); + } + + loadNetworkData(): void { + this.data_provider.getNetworkMap().then((res) => { + this.mikrotikData = res; + console.dir(res); + setTimeout(() => { + this.createNetworkMap(); + }, 100); + }); + } + + refreshData(): void { + this.selectedDevice = null; + this.loadNetworkData(); + } + + + + loadFontAwesome() { + if (!document.querySelector('link[href*="font-awesome"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'; + document.head.appendChild(link); + } + } + + createNetworkMap() { + const container = this.networkContainer?.nativeElement; + if (!container) return; + + let nodes = new DataSet([]); + let edges = new DataSet([]); + let deviceMap: { [key: string]: string } = {}; // uniqueId (hostname) to nodeId mapping + let allDevices: { [key: string]: any } = {}; // uniqueId to device info mapping + let macToDevice: { [key: string]: string } = {}; // MAC -> uniqueId (hostname) mapping + const hasSavedPositions = Object.keys(this.savedPositions).length > 0; + let nodeIdCounter = 1; + + // Collect all devices using hostname as consistent unique ID + this.mikrotikData.forEach((device) => { + const deviceId = device.name; // Use name (hostname) as unique ID + + if (!allDevices[deviceId]) { + allDevices[deviceId] = { + name: device.name, + type: 'Router', + brand: 'MikroTik' + }; + } + + Object.entries(device.interfaces).forEach(([_, iface]: [string, any]) => { + if (iface.mac) { + macToDevice[iface.mac] = deviceId; // Map to hostname + } + + if (iface.neighbors && Array.isArray(iface.neighbors)) { + iface.neighbors.forEach((neighbor: any) => { + const neighborId = neighbor.hostname || 'Unknown'; // Use hostname + + if (!allDevices[neighborId]) { + allDevices[neighborId] = { + name: neighbor.hostname || neighbor.mac || 'Unknown', + type: neighbor.type || 'Router', + brand: neighbor.brand || 'MikroTik' + }; + } + + if (neighbor.mac) { + macToDevice[neighbor.mac] = neighborId; // Map to hostname + } + }); + } + }); + }); + + // Create nodes + Object.entries(allDevices).forEach(([uniqueId, device]: [string, any]) => { + const nodeId = `node_${nodeIdCounter++}`; + deviceMap[uniqueId] = nodeId; + + nodes.add({ + id: nodeId, + label: device.name, + shape: 'image', + image: this.getDeviceIcon(device.type || 'Unknown', device.brand || 'Unknown'), + size: 15, + font: { size: 11, color: '#333', face: 'Arial, sans-serif' }, + ...(hasSavedPositions && this.savedPositions[nodeId] + ? { x: this.savedPositions[nodeId].x, y: this.savedPositions[nodeId].y } + : {}) + } as any); + }); + +// Create edges - collect all connections first +let connectionMap: { [key: string]: string[] } = {}; + +this.mikrotikData.forEach((device) => { + const deviceName = device.name; + Object.entries(device.interfaces).forEach(([ifaceName, iface]: [string, any]) => { + const sourceDeviceId = iface.mac ? macToDevice[iface.mac] : deviceName; + + if (iface.neighbors && Array.isArray(iface.neighbors)) { + iface.neighbors.forEach((neighbor: any) => { + const targetDeviceId = neighbor.mac ? macToDevice[neighbor.mac] : null; + + if (deviceMap[sourceDeviceId] && targetDeviceId && deviceMap[targetDeviceId]) { + const connectionKey = [sourceDeviceId, targetDeviceId].sort().join('_'); + const interfacePair = neighbor.interface ? `${ifaceName}↔${neighbor.interface}` : ifaceName; + + if (!connectionMap[connectionKey]) { + connectionMap[connectionKey] = []; + } + + if (!connectionMap[connectionKey].includes(interfacePair)) { + connectionMap[connectionKey].push(interfacePair); + } + } + }); + } + }); +}); + +// Create edges with combined labels +Object.entries(connectionMap).forEach(([connectionKey, interfacePairs]) => { + const [sourceDeviceId, targetDeviceId] = connectionKey.split('_'); + let edgeLabel = interfacePairs.join('\n'); + + // Limit to max 2 interface pairs to avoid overcrowding + if (interfacePairs.length > 2) { + edgeLabel = interfacePairs.slice(0, 2).join('\n') + '\n+' + (interfacePairs.length - 2); + } + + edges.add({ + id: connectionKey, + from: deviceMap[sourceDeviceId], + to: deviceMap[targetDeviceId], + label: edgeLabel, + color: { color: '#34495e', highlight: '#3498db' }, + width: 3, + smooth: { type: 'continuous', roundness: 0.1 }, + font: { + size: 9, + color: '#2c3e50', + face: 'Arial, sans-serif', + strokeWidth: 2, + strokeColor: '#ffffff', + align: 'horizontal' + } + } as any); +}); + const data = { nodes, edges }; + const options = { physics: { enabled: true, stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -8000, centralGravity: 0.3, springLength: 200, springConstant: 0.04, damping: 0.09 } }, interaction: { hover: true, dragNodes: true, dragView: true, zoomView: true, hoverConnectedEdges: false, selectConnectedEdges: false, navigationButtons: false, keyboard: false }, nodes: { borderWidth: 3, shadow: true }, edges: { shadow: true, smooth: true, length: 150 }, manipulation: { enabled: false } }; + const network = new Network(container, data, options); + + // Keep your existing events (dragEnd, click, stabilization, etc.) + // No changes needed below + network.on('dragEnd', () => { + const positions = network.getPositions(); + this.savedPositions = positions; + localStorage.setItem(this.savedPositionsKey, JSON.stringify(positions)); + }); + + network.on('click', (event: any) => { + if (event.nodes[0]) { + const clickedNode = nodes.get(event.nodes[0]); + const canvasPosition = network.canvasToDOM(event.pointer.canvas); + const containerRect = container.getBoundingClientRect(); + const mainContainer = document.querySelector('.main-container') as HTMLElement; + const mainRect = mainContainer?.getBoundingClientRect() || containerRect; + + let adjustedX = canvasPosition.x + containerRect.left - mainRect.left + 20; + let adjustedY = canvasPosition.y + containerRect.top - mainRect.top - 50; + + const popupWidth = 280; + const maxPopupHeight = window.innerHeight - 40; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (adjustedX + popupWidth > viewportWidth) adjustedX -= popupWidth + 40; + if (adjustedY + maxPopupHeight > viewportHeight) adjustedY = viewportHeight - maxPopupHeight - 20; + if (adjustedX < 20) adjustedX = 20; + if (adjustedY < 20) adjustedY = 20; + + this.handleNodeClick(clickedNode, { x: adjustedX, y: adjustedY }); + } + }); + + network.on('stabilizationIterationsDone', () => { + network.fit(); + if (!hasSavedPositions) { + const positions = network.getPositions(); + this.savedPositions = positions; + localStorage.setItem(this.savedPositionsKey, JSON.stringify(positions)); + } + }); + + if (hasSavedPositions) { + setTimeout(() => network.fit(), 500); + } + } + + handleNodeClick(node: any, position?: { x: number, y: number }) { + this.selectedDevice = node; + if (position) { + this.selectedDevice.popupPosition = position; + } + } + + closeDeviceDetails() { + this.selectedDevice = null; + } + + getDeviceInfo() { + if (!this.selectedDevice) return null; + + const device = this.mikrotikData.find(d => d.name === this.selectedDevice.label); + + if (device) { + // Main device found + const interfaces = Object.entries(device.interfaces).map(([name, data]: [string, any]) => ({ + name, + address: data.address || 'N/A', + mac: data.mac || 'N/A', + neighbors: data.neighbors?.length || 0 + })); + + const interfaceWithNeighbors = Object.values(device.interfaces) + .find((iface: any) => iface.neighbors?.length > 0) as any; + const firstNeighbor = interfaceWithNeighbors?.neighbors?.[0]; + + return { + name: device.name, + deviceId: device.device_id, + type: firstNeighbor?.type || 'Router', + brand: firstNeighbor?.brand || 'MikroTik', + board: firstNeighbor?.board || 'Unknown', + version: firstNeighbor?.version || 'Unknown', + systemDescription: firstNeighbor?.['system-description'] || null, + softwareId: firstNeighbor?.['software-id'] || 'N/A', + interfaces + }; + } else { + // Search in neighbor data + for (const mainDevice of this.mikrotikData) { + for (const iface of Object.values(mainDevice.interfaces)) { + const neighbor = (iface as any).neighbors?.find((n: any) => n.hostname === this.selectedDevice.label); + if (neighbor) { + return { + name: neighbor.hostname, + deviceId: null, + type: neighbor.type || 'Router', + brand: neighbor.brand || 'MikroTik', + board: neighbor.board || 'Unknown', + version: neighbor.version || 'Unknown', + systemDescription: neighbor['system-description'] || null, + softwareId: neighbor['software-id'] || 'N/A', + interfaces: [{ name: neighbor.interface || 'Unknown', address: neighbor.address || 'N/A', mac: neighbor.mac || 'N/A', neighbors: 0 }] + }; + } + } + } + } + + return null; + } + + getNodeColor(deviceType: string): string { + const colors = { + 'gateway': '#dc3545', + 'router': '#fd7e14', + 'switch': '#6f42c1', + 'ap': '#20c997', + 'cpe': '#0dcaf0' + }; + return (colors as any)[deviceType] || '#6c757d'; + } + + getNodeBorderColor(deviceType: string): string { + const borderColors = { + 'gateway': '#b02a37', + 'router': '#e8681a', + 'switch': '#59359a', + 'ap': '#1aa179', + 'cpe': '#0baccc' + }; + return (borderColors as any)[deviceType] || '#495057'; + } + + getDeviceIcon(deviceType: string, brand: string): string { + const basePath = './assets/Network-Icons-SVG/'; + const type = deviceType.toLowerCase(); + const brandName = brand.toLowerCase(); + + // MikroTik devices + if (brandName === 'mikrotik') { + if (type === 'switch') { + return `${basePath}cumulus-switch-v2.svg`; + } + return `${basePath}cumulus-router-v2.svg`; + } + + // Cisco devices + if (brandName === 'cisco') { + if (type === 'switch') { + return `${basePath}cisco-switch-l2.svg`; + } + return `${basePath}cisco-router.svg`; + } + + // Juniper devices + if (brandName === 'juniper') { + if (type === 'switch') { + return `${basePath}juniper-switch-l2.svg`; + } + return `${basePath}juniper-router.svg`; + } + + // HPE/Aruba devices + if (brandName === 'hpe/aruba' || brandName === 'aruba' || brandName === 'hpe') { + if (type === 'server') { + return `${basePath}generic-server-1.svg`; + } + return `${basePath}arista-switch.svg`; + } + + // Ubiquiti devices + if (brandName === 'ubiquiti' || brandName === 'ubnt') { + if (type === 'switch') { + return `${basePath}generic-switch-l2-v1-colour.svg`; + } + return `${basePath}generic-router-colour.svg`; + } + + // Default icons by type + const defaultIcons = { + 'switch': `${basePath}generic-switch-l2-v1-colour.svg`, + 'router': `${basePath}generic-router-colour.svg`, + 'router/switch': `${basePath}generic-router-colour.svg`, + 'server': `${basePath}generic-server-1.svg`, + 'unknown': `${basePath}generic-router-colour.svg` + }; + + return (defaultIcons as any)[type] || `${basePath}generic-router-colour.svg`; + } + + getDefaultPosition(deviceName: string, index: number): { x: number, y: number } { + const positions = { + 'Core Router': { x: 0, y: 0 }, + 'Edge Router': { x: -200, y: -100 }, + 'Distribution Switch': { x: 200, y: -100 }, + 'Access Point 1': { x: 100, y: 100 }, + 'Access Point 2': { x: 300, y: 100 }, + 'Customer Router 1': { x: 0, y: 200 }, + 'Customer Router 2': { x: 200, y: 200 } + }; + return (positions as any)[deviceName] || { x: index * 100, y: index * 50 }; + } + + webAccess() { + if (!this.selectedDevice) return; + this.currentDeviceInfo = this.getDeviceInfo(); + this.showWebAccessModal = true; + this.closeDeviceDetails(); + } + + openProxyAccess() { + if (this.currentDeviceInfo?.deviceId) { + window.open(`/api/proxy/init?devid=${this.currentDeviceInfo.deviceId}`, '_blank'); + } else { + const ip = this.currentDeviceInfo?.interfaces.find((iface: any) => iface.address !== 'N/A')?.address?.split('/')[0]; + if (ip) { + window.open(`/api/proxy/init?dev_ip=${ip}`, '_blank'); + } + } + this.showWebAccessModal = false; + } + + openDirectAccess() { + const ip = this.currentDeviceInfo?.interfaces.find((iface: any) => iface.address !== 'N/A')?.address?.split('/')[0]; + if (ip) { + window.open(`http://${ip}`, '_blank'); + } + this.showWebAccessModal = false; + } + + closeWebAccessModal() { + this.showWebAccessModal = false; + } + + getNeighborCount() { + const deviceInfo = this.getDeviceInfo(); + return deviceInfo?.interfaces.reduce((total, iface) => total + iface.neighbors, 0) || 0; + } + + getPrimaryIP() { + const deviceInfo = this.getDeviceInfo(); + const primaryInterface = deviceInfo?.interfaces.find(iface => iface.address !== 'N/A'); + return primaryInterface?.address?.split('/')[0] || 'N/A'; + } + + getDeviceInterfaces() { + const deviceInfo = this.getDeviceInfo(); + return deviceInfo?.interfaces || []; + } + + showMoreInfo() { + const deviceInfo = this.getDeviceInfo(); + if (deviceInfo?.deviceId) { + window.open(`/#/device-stats;id=${deviceInfo.deviceId}`, '_blank'); + } + } + + pingDevice(devid: number) { + console.log('Ping device:', this.selectedDevice); + // Implement ping functionality + } + + configureDevice(devid: number) { + console.log('Configure device:', this.selectedDevice); + // Implement configuration interface + } + +} \ No newline at end of file diff --git a/src/app/views/maps/maps.module.ts b/src/app/views/maps/maps.module.ts new file mode 100644 index 0000000..beb35e3 --- /dev/null +++ b/src/app/views/maps/maps.module.ts @@ -0,0 +1,60 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { ReactiveFormsModule } from "@angular/forms"; +import { FormsModule } from "@angular/forms"; + +import { + ButtonGroupModule, + ButtonModule, + CardModule, + GridModule, + WidgetModule, + ProgressModule, + TemplateIdDirective, + TooltipModule, + BadgeModule, + CarouselModule, + ListGroupModule, + ModalModule, + TableModule, + UtilitiesModule +} from "@coreui/angular"; +import { IconModule } from "@coreui/icons-angular"; + +import { ChartjsModule } from "@coreui/angular-chartjs"; +import { NgScrollbarModule } from 'ngx-scrollbar'; +import { MapsRoutingModule } from "./maps-routing.module"; +import { MapsComponent } from "./maps.component"; +import { ClipboardModule } from "@angular/cdk/clipboard"; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; + +@NgModule({ + imports: [ + MapsRoutingModule, + CardModule, + WidgetModule, + CommonModule, + GridModule, + ProgressModule, + ReactiveFormsModule, + ButtonModule, + ModalModule, + FormsModule, + TemplateIdDirective, + ButtonModule, + ButtonGroupModule, + ChartjsModule, + CarouselModule, + BadgeModule, + ClipboardModule, + ListGroupModule, + NgScrollbarModule, + TableModule, + TooltipModule, + UtilitiesModule, + InfiniteScrollModule, + IconModule + ], + declarations: [MapsComponent], +}) +export class MapsModule {} From 2f68c499365d8fdd7e5d36181a998d6bbc3738e9 Mon Sep 17 00:00:00 2001 From: sepehr Date: Thu, 16 Oct 2025 17:33:47 +0300 Subject: [PATCH 04/11] feat: redesign user tasks with advanced scheduling - Complete UI/UX redesign of user tasks interface - Add cron selector with examples - Improved task scheduling interface - Enhanced task management and monitoring --- .../user_tasks/user_tasks.component.html | 503 ++++++++++++------ .../user_tasks/user_tasks.component.scss | 371 +++++++++++++ .../views/user_tasks/user_tasks.component.ts | 188 ++++++- src/app/views/user_tasks/user_tasks.module.ts | 6 + 4 files changed, 884 insertions(+), 184 deletions(-) create mode 100644 src/app/views/user_tasks/user_tasks.component.scss diff --git a/src/app/views/user_tasks/user_tasks.component.html b/src/app/views/user_tasks/user_tasks.component.html index f7da0bc..f37fd7c 100644 --- a/src/app/views/user_tasks/user_tasks.component.html +++ b/src/app/views/user_tasks/user_tasks.component.html @@ -57,146 +57,255 @@ - -
    Editing device {{SelectedTask['name']}}
    -
    Adding new task
    + +
    + Edit Task: {{SelectedTask['name']}} +
    +
    + Create New Task +
    - -
    - - + + +
    +
    +
    Basic Information
    + Define the task name, description and type +
    + + + + + + + + + + +
    - -
    - - + +
    +
    +
    Task Configuration
    + Configure task-specific settings and parameters +
    + + +
    + + +
    Backup Configuration
    +
    + +
    + + This task will create configuration backups of selected devices. Backups are stored securely and can be restored later. +
    +
    +
    +
    + + + + +
    Firmware Update Strategy
    +
    + +
    + + + + + +
    + +
    + + Uses global MikroWizard update strategy settings. Check Settings page for configuration. +
    + +
    + + Downloads latest firmware from mikrotik.com. Server needs internet access. +
    + +
    + + + + + + + + + + +
    +
    +
    + + +
    + + +
    Script/Snippet Configuration
    +
    + + + + + + The selected script will be executed on all target devices when this task runs. + + +
    +
    - - - - - -
    Update Version Strategy
    - - - - - - - - - - - - - - - - - - The version of firmware will be selected based on global settings of Mikrowizard Update strategy. -
    - Please check settings page for more info and configuration -
    -
    - - - - The version of firmware will be selected based on latest availble version from Mikrotik website!. -
    - V6 Firmware update Behavior and safe install is based on global Mikrowizard setting.(check settings page) -
    - **with this option MikroWizard will download latest availble firmware from mikrotik.com. Please keep in mind that server needs internet access to mikrotik.com
    -
    - - - - - - * The version of firmware to install routers - - - - - - - * The version of firmware to install on V6 routers - - -
    -
    - - - - -
    - - + + +
    +
    +
    Schedule Configuration
    + Set when this task should run automatically +
    +
    + +
    +
    + + +
    +
    +
    +
    {{cron.label}}
    +
    {{cron.value}}
    +
    {{cron.description}}
    +
    +
    +
    + No matching cron presets found +
    +
    + + {{getCronDescription()}} + +
    + + Quick Examples: + * * * * * = every minute | + 0 2 * * * = daily at 2 AM | + 0 */6 * * * = every 6 hours + +
    +
    + + +
    +
    +
    Target Selection
    + Choose which devices or groups this task will affect +
    +
    + + + + +
    - - - - - -
    Members :
    - - - -   {{value}} - - - - {{value}} - - - - - - - - -
    - + + + +
    + Selected {{SelectedTask['selection_type'] === 'devices' ? 'Devices' : 'Groups'}} +
    +
    + {{SelectedMembers.length}} selected + +
    +
    + +
    + +
    No {{SelectedTask['selection_type']}} selected
    +

    Click "Add {{SelectedTask['selection_type'] === 'devices' ? 'Devices' : 'Groups'}}" to select targets for this task

    +
    +
    + + + +
    + + {{value}} +
    +
    +
    + + + {{value}} + + + + + + + +
    +
    +
    +
    +
    - - - - + +
    + All fields marked with * are required +
    +
    + + + +
    - - -
    Editing Group
    + + +
    + Add {{SelectedTask['selection_type'] === 'devices' ? 'Devices' : 'Groups'}} to Task +
    - - -
    Group Members :
    - - - -   {{value}} - - - - {{value}} - - - - - {{value}} - - - -
    -
    -
    + + +
    + + + {{NewMemberRows.length}} {{SelectedTask['selection_type']}}(s) selected for addition to this task + +
    + + + + +
    + + Available {{SelectedTask['selection_type'] === 'devices' ? 'Devices' : 'Groups'}} ({{availbleMembers.length}} total) +
    +
    + +
    + +
    All {{SelectedTask['selection_type']}} are already assigned
    +

    No available {{SelectedTask['selection_type']}} to add to this task

    +
    +
    + + + +
    + + {{value}} +
    +
    +
    + + + {{value}} + + + + + {{value}} + + +
    +
    +
    +
    - - - + +
    + Select {{SelectedTask['selection_type']}} from the list above to add them to this task +
    +
    + + +
    @@ -340,4 +491,6 @@ Close
    - \ No newline at end of file + + + \ No newline at end of file diff --git a/src/app/views/user_tasks/user_tasks.component.scss b/src/app/views/user_tasks/user_tasks.component.scss new file mode 100644 index 0000000..ccea1e6 --- /dev/null +++ b/src/app/views/user_tasks/user_tasks.component.scss @@ -0,0 +1,371 @@ +/* Task Form Sections */ +.task-form-section, .task-config-section, .schedule-section, .target-section { + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 0.75rem; + background: #f8f9fa; +} + +.section-header { + border-bottom: 1px solid #dee2e6; + padding-bottom: 0.375rem; + margin-bottom: 0.75rem; +} + +.section-title { + color: #495057; + font-weight: 600; + font-size: 0.875rem; +} + +/* Form Inputs */ +.form-input { + border: 1px solid #ced4da; + border-radius: 4px; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.form-input:focus { + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +/* Strategy Buttons */ +.strategy-buttons .btn-group { + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.strategy-buttons button { + font-size: 0.8rem; + padding: 0.375rem 0.75rem; +} + +/* Cron Dropdown Styles */ +.cron-input-wrapper { + position: relative; +} + +.input-group { + position: relative; + display: flex; + flex-wrap: nowrap; + align-items: stretch; + width: 100%; +} + +.input-group .search-input { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: none; + flex: 1; +} + +.input-group .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 1px solid #ced4da; + flex-shrink: 0; +} + +.search-select-wrapper { + position: relative; + width: 100%; +} + +.search-input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 0.9rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +.search-input:focus { + border-color: #80bdff; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); + outline: 0; +} + +.search-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ced4da; + border-top: none; + border-radius: 0 0 6px 6px; + max-height: 300px; + overflow-y: auto; + z-index: 1000; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.search-option { + padding: 0.5rem 0.75rem; + cursor: pointer; + border-bottom: 1px solid #f8f9fa; + transition: background-color 0.15s ease-in-out; +} + +.search-option:hover { + background-color: #f8f9fa; +} + +.search-option:last-child { + border-bottom: none; +} + +.search-no-results { + padding: 0.75rem; + text-align: center; + color: #6c757d; + font-style: italic; +} + +/* Cron-specific dropdown styles */ +.cron-dropdown { + max-height: 400px; +} + +.cron-option { + padding: 0.75rem; + border-bottom: 1px solid #e9ecef; +} + +.cron-option:hover { + background-color: #e3f2fd; +} + +.cron-option.selected { + background-color: #bbdefb; + border-left: 4px solid #2196f3; +} + +.cron-option.selected:hover { + background-color: #90caf9; +} + +.cron-label { + font-weight: 600; + color: #495057; + font-size: 0.9rem; +} + +.cron-value { + font-family: 'Courier New', monospace; + color: #007bff; + font-size: 0.85rem; + margin: 0.25rem 0; + background: #f8f9fa; + padding: 0.25rem 0.5rem; + border-radius: 4px; + display: inline-block; +} + +.cron-description { + color: #6c757d; + font-size: 0.8rem; + font-style: italic; +} + +/* Target Type Selector */ +.target-type-selector .btn-group { + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.target-type-selector button { + font-size: 0.8rem; + padding: 0.375rem 0.75rem; +} + +/* Card Enhancements */ +.c-card { + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border: 1px solid #e9ecef; +} + +.c-card-header { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-bottom: 1px solid #dee2e6; + font-weight: 600; +} + +/* Alert Enhancements */ +.alert { + border-radius: 8px; + border: none; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.alert-info { + background: linear-gradient(135deg, #d1ecf1 0%, #bee5eb 100%); + color: #0c5460; +} + +.alert-success { + background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%); + color: #155724; +} + +.alert-warning { + background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%); + color: #856404; +} + +/* Badge Enhancements */ +.badge { + font-size: 0.7rem; + padding: 0.25em 0.5em; + border-radius: 4px; +} + +/* Button Enhancements */ +.btn { + border-radius: 4px; + font-weight: 500; + transition: all 0.15s ease-in-out; +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; +} + +.btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); +} + +/* Modal Enhancements */ +.modal-header { + border-bottom: 1px solid #dee2e6; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); +} + +.modal-footer { + border-top: 1px solid #dee2e6; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); +} + +/* Grid Enhancements */ +.gui-grid { + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .task-form-section, .task-config-section, .schedule-section, .target-section { + padding: 0.5rem; + } + + .section-title { + font-size: 0.8rem; + } + + .strategy-buttons button, + .target-type-selector button { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + } + + .form-input { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + } + + .cron-label { + font-size: 0.8rem; + } + + .cron-value { + font-size: 0.75rem; + } + + .cron-description { + font-size: 0.7rem; + } + + .c-modal-body { + padding: 0.75rem !important; + } +} + +@media (max-width: 576px) { + .task-form-section, .task-config-section, .schedule-section, .target-section { + padding: 0.375rem; + margin-bottom: 0.75rem; + } + + .section-header { + margin-bottom: 0.5rem; + } + + .strategy-buttons button, + .target-type-selector button { + font-size: 0.7rem; + padding: 0.25rem 0.375rem; + } + + .input-group .btn { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + } +} + +/* Animation for smooth transitions */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +.search-dropdown { + animation: fadeIn 0.2s ease-out; +} + +/* Focus states for accessibility */ +.search-option:focus, +.cron-option:focus { + outline: 2px solid #007bff; + outline-offset: -2px; + background-color: #e3f2fd; +} + +/* Loading states */ +.loading-spinner { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid #f3f3f3; + border-top: 2px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Code examples */ +code { + background-color: #f8f9fa; + color: #e83e8c; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.875em; + font-family: 'Courier New', monospace; +} + +/* Improved spacing */ +.mt-1 { margin-top: 0.25rem !important; } +.mt-2 { margin-top: 0.5rem !important; } +.me-1 { margin-right: 0.25rem !important; } +.me-2 { margin-right: 0.5rem !important; } +.ms-2 { margin-left: 0.5rem !important; } +.d-block { display: block !important; } \ No newline at end of file diff --git a/src/app/views/user_tasks/user_tasks.component.ts b/src/app/views/user_tasks/user_tasks.component.ts index 63d6163..b19e2e6 100644 --- a/src/app/views/user_tasks/user_tasks.component.ts +++ b/src/app/views/user_tasks/user_tasks.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy } from "@angular/core"; +import { Component, OnInit, OnDestroy, QueryList, ViewChildren } from "@angular/core"; import { dataProvider } from "../../providers/mikrowizard/data"; import { Router } from "@angular/router"; import { loginChecker } from "../../providers/login_checker"; @@ -16,16 +16,28 @@ import { } from "@generic-ui/ngx-grid"; import { NgxSuperSelectOptions } from "ngx-super-select"; import { _getFocusedElementPierceShadowDom } from "@angular/cdk/platform"; +import { AppToastComponent } from "../toast-simple/toast.component"; +import { ToasterComponent } from "@coreui/angular"; @Component({ templateUrl: "user_tasks.component.html", + styleUrls: ["user_tasks.component.scss"] }) export class UserTasksComponent implements OnInit { public uid: number; public uname: string; public ispro: boolean = false; + @ViewChildren(ToasterComponent) viewChildren!: QueryList; + toasterForm = { + autohide: true, + delay: 10000, + position: "fixed", + fade: true, + closeButton: true, + }; + constructor( private data_provider: dataProvider, private router: Router, @@ -76,6 +88,55 @@ export class UserTasksComponent implements OnInit { public firmwaretoinstallv6: string = "none"; public updateBehavior: string = "keep"; public firms_loaded: boolean = false; + public predefinedCrons: any[] = [ + // High Frequency Monitoring + { label: 'Every minute', value: '* * * * *', description: 'Critical monitoring - runs every minute' }, + { label: 'Every 2 minutes', value: '*/2 * * * *', description: 'High frequency monitoring' }, + { label: 'Every 5 minutes', value: '*/5 * * * *', description: 'Standard monitoring interval' }, + { label: 'Every 10 minutes', value: '*/10 * * * *', description: 'Regular monitoring checks' }, + { label: 'Every 15 minutes', value: '*/15 * * * *', description: 'Moderate monitoring frequency' }, + { label: 'Every 30 minutes', value: '*/30 * * * *', description: 'Low frequency monitoring' }, + + // Hourly Operations + { label: 'Every hour', value: '0 * * * *', description: 'Hourly network checks' }, + { label: 'Every 2 hours', value: '0 */2 * * *', description: 'Bi-hourly operations' }, + { label: 'Every 4 hours', value: '0 */4 * * *', description: 'Quarterly daily checks' }, + { label: 'Every 6 hours', value: '0 */6 * * *', description: 'Four times daily' }, + { label: 'Every 8 hours', value: '0 */8 * * *', description: 'Three times daily' }, + { label: 'Every 12 hours', value: '0 */12 * * *', description: 'Twice daily operations' }, + + // Daily Maintenance + { label: 'Daily at midnight', value: '0 0 * * *', description: 'Daily maintenance at 00:00' }, + { label: 'Daily at 1 AM', value: '0 1 * * *', description: 'Daily backup at 01:00' }, + { label: 'Daily at 2 AM', value: '0 2 * * *', description: 'Daily maintenance at 02:00' }, + { label: 'Daily at 3 AM', value: '0 3 * * *', description: 'Low traffic maintenance at 03:00' }, + { label: 'Daily at 6 AM', value: '0 6 * * *', description: 'Pre-business hours check' }, + { label: 'Daily at 6 PM', value: '0 18 * * *', description: 'End of business day backup' }, + { label: 'Daily at 10 PM', value: '0 22 * * *', description: 'Evening maintenance at 22:00' }, + + // Business Hours + { label: 'Workdays at 8 AM', value: '0 8 * * 1-5', description: 'Start of business day - Mon to Fri' }, + { label: 'Workdays at 9 AM', value: '0 9 * * 1-5', description: 'Business hours start check' }, + { label: 'Workdays at 12 PM', value: '0 12 * * 1-5', description: 'Midday check - Mon to Fri' }, + { label: 'Workdays at 5 PM', value: '0 17 * * 1-5', description: 'End of business day - Mon to Fri' }, + { label: 'Workdays at 6 PM', value: '0 18 * * 1-5', description: 'After hours backup - Mon to Fri' }, + + // Weekly Operations + { label: 'Weekly (Sunday midnight)', value: '0 0 * * 0', description: 'Weekly maintenance - Sunday 00:00' }, + { label: 'Weekly (Monday midnight)', value: '0 0 * * 1', description: 'Weekly start - Monday 00:00' }, + { label: 'Weekly (Friday 6 PM)', value: '0 18 * * 5', description: 'End of week backup - Friday 18:00' }, + { label: 'Weekly (Saturday 2 AM)', value: '0 2 * * 6', description: 'Weekend maintenance - Saturday 02:00' }, + + // Monthly Operations + { label: 'Monthly (1st at midnight)', value: '0 0 1 * *', description: 'Monthly maintenance - 1st of month' }, + { label: 'Monthly (1st at 2 AM)', value: '0 2 1 * *', description: 'Monthly backup - 1st at 02:00' }, + { label: 'Monthly (15th at midnight)', value: '0 0 15 * *', description: 'Mid-month maintenance - 15th' }, + { label: 'Monthly (last day)', value: '0 0 28-31 * *', description: 'End of month operations' } + ]; + public showCronDropdown: boolean = false; + public cronSearch: string = ''; + public selectedCronPreset: any = null; + public filteredCrons: any[] = []; public sorting = { enabled: true, multiSorting: true, @@ -156,22 +217,43 @@ export class UserTasksComponent implements OnInit { this.initGridTable(); } + show_toast(title: string, body: string, color: string) { + const { ...props } = { ...this.toasterForm, color, title, body }; + const componentRef = this.viewChildren.first.addToast( + AppToastComponent, + props, + {} + ); + componentRef.instance["closeButton"] = props.closeButton; + } + submit(action: string) { var _self = this; if (action == "add") { this.data_provider .Add_task(_self.SelectedTask, _self.SelectedTaskItems) .then((res) => { - _self.initGridTable(); + if (res && res.status === 'failed') { + _self.show_toast("Error", res.err, "danger"); + } else { + _self.show_toast("Success", "Task created successfully", "success"); + _self.initGridTable(); + _self.EditTaskModalVisible = false; + } }); } else { this.data_provider .Edit_task(_self.SelectedTask, _self.SelectedTaskItems) .then((res) => { - _self.initGridTable(); + if (res && res.status === 'failed') { + _self.show_toast("Error", res.err, "danger"); + } else { + _self.show_toast("Success", "Task updated successfully", "success"); + _self.initGridTable(); + _self.EditTaskModalVisible = false; + } }); } - this.EditTaskModalVisible = false; } onSelectedRowsNewMembers(rows: Array): void { @@ -197,7 +279,7 @@ export class UserTasksComponent implements OnInit { this.SelectedTask = { id: 0, action: "add", - taskcron: "* * * * *", + cron: "0 2 * * *", desc_cron: "", description: "", members: "", @@ -206,7 +288,9 @@ export class UserTasksComponent implements OnInit { snippetid: "", task_type: "backup", }; - this.SelectedTask['data'] = { 'strategy': 'system', 'version_to_install': '', 'version_to_install_6': '' } + this.SelectedTask['data'] = { 'strategy': 'system', 'version_to_install': '', 'version_to_install_6': '' }; + this.cronSearch = ''; + this.selectedCronPreset = null; this.SelectedMembers = []; this.SelectedTaskItems = []; this.EditTaskModalVisible = true; @@ -215,6 +299,12 @@ export class UserTasksComponent implements OnInit { var _self = this; this.SelectedTask = { ...item }; + + // Initialize cron search and preset tracking + this.cronSearch = ''; + const currentCron = this.SelectedTask['cron']; + this.selectedCronPreset = this.predefinedCrons.find(cron => cron.value === currentCron) || null; + if (this.SelectedTask['task_type'] == 'firmware' && 'data' in this.SelectedTask && this.SelectedTask['data']) { this.SelectedTask['data'] = JSON.parse(this.SelectedTask['data']); if (this.SelectedTask['data']['strategy'] == 'defined') { @@ -243,7 +333,7 @@ export class UserTasksComponent implements OnInit { } } - _self.data_provider.get_snippets("", "", "", 0, 1000).then((res) => { + _self.data_provider.get_snippets("", "", "", 0, 1000,false).then((res) => { _self.Snippets = res.map((x: any) => { return { id: x.id, name: x.name }; }); @@ -315,7 +405,7 @@ export class UserTasksComponent implements OnInit { onSnippetsValueChanged(v: any) { var _self = this; if (v == "" || v.length < 3) return; - _self.data_provider.get_snippets(v, "", "", 0, 1000).then((res) => { + _self.data_provider.get_snippets(v, "", "", 0, 1000,false).then((res) => { _self.Snippets = res.map((x: any) => { return { id: String(x.id), name: x.name }; }); @@ -333,7 +423,12 @@ export class UserTasksComponent implements OnInit { } else { var _self = this; this.data_provider.Delete_task(_self.SelectedTask["id"]).then((res) => { - _self.initGridTable(); + if (res && res.status === 'failed') { + _self.show_toast("Error", res.err, "danger"); + } else { + _self.show_toast("Success", "Task deleted successfully", "success"); + _self.initGridTable(); + } _self.DeleteConfirmModalVisible = false; }); } @@ -363,4 +458,79 @@ export class UserTasksComponent implements OnInit { _self.loading = false; }); } + + selectCron(cron: any): void { + this.SelectedTask['cron'] = cron.value; + this.selectedCronPreset = cron; + this.cronSearch = ''; + this.showCronDropdown = false; + } + + filterCrons(event: any): void { + const searchTerm = event.target.value.toLowerCase(); + this.cronSearch = searchTerm; + this.selectedCronPreset = null; + + if (searchTerm.length > 0) { + this.filteredCrons = this.predefinedCrons.filter(cron => + cron.label.toLowerCase().includes(searchTerm) || + cron.description.toLowerCase().includes(searchTerm) || + cron.value.includes(searchTerm) + ); + } else { + this.filteredCrons = this.predefinedCrons; + } + } + + hideCronDropdown(): void { + setTimeout(() => { + this.showCronDropdown = false; + }, 200); + } + + onCronInputFocus(): void { + this.filteredCrons = this.predefinedCrons; + this.showCronDropdown = true; + + // If current cron matches a preset, highlight it + const currentCron = this.SelectedTask['cron']; + this.selectedCronPreset = this.predefinedCrons.find(cron => cron.value === currentCron) || null; + } + + onCronInputChange(event: any): void { + this.SelectedTask['cron'] = event.target.value; + this.selectedCronPreset = null; + + // Check if the entered value matches any preset + const enteredValue = event.target.value; + const matchingPreset = this.predefinedCrons.find(cron => cron.value === enteredValue); + if (matchingPreset) { + this.selectedCronPreset = matchingPreset; + } + } + + getCronDescription(): string { + if (this.selectedCronPreset) { + return this.selectedCronPreset.description; + } + + const currentCron = this.SelectedTask['cron']; + const matchingPreset = this.predefinedCrons.find(cron => cron.value === currentCron); + return matchingPreset ? matchingPreset.description : 'Custom cron expression'; + } + + onTaskTypeChange(): void { + if (this.SelectedTask['task_type'] === 'snippet') { + this.loadSnippets(); + } + } + + loadSnippets(): void { + var _self = this; + _self.data_provider.get_snippets("", "", "", 0, 10).then((res) => { + _self.Snippets = res.map((x: any) => { + return { id: x.id, name: x.name }; + }); + }); + } } diff --git a/src/app/views/user_tasks/user_tasks.module.ts b/src/app/views/user_tasks/user_tasks.module.ts index fdc3f47..e59ef63 100644 --- a/src/app/views/user_tasks/user_tasks.module.ts +++ b/src/app/views/user_tasks/user_tasks.module.ts @@ -9,6 +9,9 @@ import { GridModule, ModalModule, ButtonGroupModule, + BadgeModule, + AlertModule, + ToastModule, } from "@coreui/angular"; import { UserTasksRoutingModule } from "./user_tasks-routing.module"; import { UserTasksComponent } from "./user_tasks.component"; @@ -25,6 +28,9 @@ import { NgxSuperSelectModule} from "ngx-super-select"; FormModule, ButtonModule, ButtonGroupModule, + BadgeModule, + AlertModule, + ToastModule, GuiGridModule, ModalModule, ReactiveFormsModule, From cdc2e0cabf22c59ebd97138ab464032445e38eaf Mon Sep 17 00:00:00 2001 From: sepehr Date: Thu, 16 Oct 2025 17:33:57 +0300 Subject: [PATCH 05/11] feat: redesign sync and cloner interface for pro users - Complete UI/UX overhaul of cloner module - Enhanced configuration synchronization - Pro feature improvements - Better cloning workflow and interface --- src/app/views/cloner/cloner-styles.scss | 494 +++++++++++++++++++++ src/app/views/cloner/cloner.component.html | 386 ++++++++++------ src/app/views/cloner/cloner.component.ts | 31 +- src/app/views/cloner/cloner.module.ts | 7 +- src/app/views/cloner/cloner.scss | 1 + 5 files changed, 783 insertions(+), 136 deletions(-) create mode 100644 src/app/views/cloner/cloner-styles.scss diff --git a/src/app/views/cloner/cloner-styles.scss b/src/app/views/cloner/cloner-styles.scss new file mode 100644 index 0000000..dd8953e --- /dev/null +++ b/src/app/views/cloner/cloner-styles.scss @@ -0,0 +1,494 @@ +/* Modern Cloner Component Styles */ + +/* Form Sections */ +.cloner-form-section { + border-radius: 6px; + background: #f8f9fa; + padding: 0.75rem; + border: 1px solid #e9ecef; +} + +.section-header { + border-bottom: 1px solid #e9ecef; + padding-bottom: 0.5rem; +} + +.section-title { + color: #495057; + font-weight: 600; + font-size: 0.95rem; +} + +.form-input { + border-radius: 6px; + border: 1px solid #ced4da; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + transition: all 0.2s ease; +} + +.form-input-sm { + border-radius: 4px; + border: 1px solid #ced4da; + padding: 0.375rem 0.5rem; + font-size: 0.85rem; + height: calc(1.5em + 0.75rem + 2px); +} + +.form-input:focus { + border-color: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +.form-label-sm { + display: block; + font-size: 0.8rem; + color: #6c757d; + margin-bottom: 0.25rem; + font-weight: 500; +} + +.form-label-xs { + display: block; + font-size: 0.75rem; + color: #6c757d; + margin-bottom: 0.125rem; + font-weight: 500; +} + +.form-select-sm { + font-size: 0.85rem; + height: calc(1.8em + 0.75rem + 2px); + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; +} + +.form-select-xs { + font-size: 0.8rem; + height: calc(1.5em + 0.75rem + 2px); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; +} + +/* Commands Configuration */ +.commands-container { + background: white; + border-radius: 8px; + border: 1px solid #dee2e6; + overflow: hidden; +} + +.commands-container-compact { + background: white; + border-radius: 6px; + border: 1px solid #dee2e6; + overflow: hidden; +} + +.commands-nav { + background: #f8f9fa; + border-bottom: 2px solid #e9ecef; + padding: 0.5rem 1rem; +} + +.commands-nav-compact { + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + padding: 0.25rem 0.5rem; +} + +.commands-nav .nav-item { + margin-bottom: -2px; + cursor: pointer; +} + +.commands-nav .nav-link { + color: #6c757d; + border-style: none none solid; + border-width: 2px; + position: relative; + bottom: -1px; + cursor: pointer; + padding: 0.5rem 1rem; + font-size: 0.9rem; + font-weight: 500; +} + +.commands-nav .nav-link:hover, +.commands-nav .nav-link:focus { + border-color: #0d6efd; + color: #0d6efd; +} + +.commands-nav .nav-link.active { + color: #0d6efd; + background: transparent; + border-color: #0d6efd; +} + +.command-sections { + padding: 1rem; + min-height: 200px; +} + +.command-sections-compact { + padding: 0.5rem; + min-height: 120px; +} + +.command-category { + margin-bottom: 1.5rem; +} + +.command-category-compact { + margin-bottom: 0.75rem; +} + +.category-title { + color: #0d6efd; + margin-bottom: 0.75rem; + font-size: 0.9rem; + font-weight: 600; + border-bottom: 1px solid #e9ecef; + padding-bottom: 0.25rem; +} + +.category-title-compact { + color: #0d6efd; + margin-bottom: 0.375rem; + font-size: 0.8rem; + font-weight: 600; + border-bottom: 1px solid #e9ecef; + padding-bottom: 0.125rem; +} + +.commands-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 0.5rem; +} + +.commands-grid-compact { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.25rem; +} + +.command-item { + background: white; + border: 1px solid #e0e0e0; + border-radius: 6px; + transition: all 0.2s ease; +} + +.command-item-compact { + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + transition: all 0.2s ease; +} + +.command-item:hover { + border-color: #0d6efd; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.command-content { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; +} + +.command-content-compact { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.25rem 0.5rem; +} + +.command-name { + font-size: 0.8rem; + font-weight: 600; + color: #495057; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + margin-right: 0.5rem; +} + +.command-name-compact { + font-size: 0.7rem; + font-weight: 600; + color: #495057; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + margin-right: 0.25rem; +} + +/* Custom Switch Styling */ +.custom-switch { + position: relative; + width: 40px; + height: 20px; + flex-shrink: 0; +} + +.custom-switch-compact { + position: relative; + width: 32px; + height: 16px; + flex-shrink: 0; +} + +.custom-switch-compact input { + display: none; +} + +.custom-switch-compact .custom-control-label { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 16px; + background: #ccc; + cursor: pointer; + transition: all 0.3s ease; +} + +.custom-switch-compact .custom-control-label::after { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 12px; + height: 12px; + background: #fff; + border-radius: 50%; + transition: all 0.3s ease; +} + +.custom-switch-compact input:checked + .custom-control-label { + background: #0d6efd; +} + +.custom-switch-compact input:checked + .custom-control-label::after { + left: calc(100% - 14px); +} + +.custom-switch input { + display: none; +} + +.custom-control-label { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 20px; + background: #ccc; + cursor: pointer; + transition: all 0.3s ease; +} + +.custom-control-label::after { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + transition: all 0.3s ease; +} + +.custom-switch input:checked + .custom-control-label { + background: #0d6efd; +} + +.custom-switch input:checked + .custom-control-label::after { + left: calc(100% - 18px); +} + +/* Master Device Selection */ +.master-selection { + background: white; + border-radius: 6px; + border: 1px solid #dee2e6; + padding: 0.75rem; +} + +.master-selection-compact { + background: white; + border-radius: 4px; + border: 1px solid #dee2e6; + padding: 0.5rem; +} + +.master-device { + display: flex; + align-items: center; + padding: 0.5rem; + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; +} + +.master-device-compact { + display: flex; + align-items: center; + padding: 0.375rem; + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 4px; +} + +.master-info { + display: flex; + align-items: center; +} + +.no-master { + display: flex; + align-items: center; + padding: 0.5rem; + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 6px; +} + +.no-master-compact { + display: flex; + align-items: center; + padding: 0.375rem; + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 4px; +} + +/* Peers Container */ +.peers-container { + background: white; + border-radius: 8px; + border: 1px solid #dee2e6; + overflow: hidden; +} + +.empty-peers { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + text-align: center; +} + +.empty-icon { + margin-bottom: 1rem; +} + +.empty-text strong { + display: block; + margin-bottom: 0.5rem; + color: #495057; +} + +.add-members-section { + padding: 1rem; + background: #f8f9fa; + border-top: 1px solid #dee2e6; +} + +/* Modal Enhancements */ +.c-modal-header.bg-light { + background: #f8f9fa !important; + border-bottom: 1px solid #dee2e6; +} + +.c-modal-header.bg-success { + background: #198754 !important; + border-bottom: 1px solid #146c43; +} + +.c-modal-footer.bg-light { + background: #f8f9fa !important; + border-top: 1px solid #dee2e6; +} + +.c-modal-body { + max-height: 80vh; + overflow-y: auto; +} + +/* Button Groups */ +.btn-group .btn { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; +} + +.btn-group .btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .cloner-form-section { + padding: 0.75rem; + margin-bottom: 0.75rem; + } + + .commands-grid { + grid-template-columns: 1fr; + } + + .command-content { + padding: 0.5rem; + } + + .command-name { + font-size: 0.75rem; + } + + .c-modal-dialog { + margin: 0.25rem; + } + + .section-title { + font-size: 0.9rem; + } + + .commands-nav .nav-link { + padding: 0.375rem 0.75rem; + font-size: 0.8rem; + } + + .master-device, + .no-master { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } +} + +@media (max-width: 576px) { + .c-modal-footer { + flex-direction: column; + align-items: stretch; + } + + .c-modal-footer > div { + width: 100%; + text-align: center; + margin-bottom: 0.5rem; + } + + .c-modal-footer > div:last-child { + margin-bottom: 0; + } +} \ No newline at end of file diff --git a/src/app/views/cloner/cloner.component.html b/src/app/views/cloner/cloner.component.html index b622ec7..67fd369 100644 --- a/src/app/views/cloner/cloner.component.html +++ b/src/app/views/cloner/cloner.component.html @@ -15,17 +15,27 @@ + + + {{value}} + + {{value}} - - - {{value}} + + + {{value == 'twoway' ? 'Two Way' : 'Master Mode'}} - + + + {{value ? 'Active' : 'Inactive'}} + + + {{value}} @@ -46,109 +56,190 @@ - - -
    Editing Cloner {{SelectedCloner['name']}}
    -
    Adding new task
    + + +
    Edit Cloner: {{SelectedCloner['name']}}
    +
    Add New Cloner
    - - - - - - - - - - - - - - - - - - - - - - - - - - -