Compare commits

...

149 Commits
0.11.1 ... main

Author SHA1 Message Date
Manuel Aranda Rosales ff9ea3d1f1 Merge pull request 'develop' (#32) from develop into main
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
Reviewed-on: #32
2025-06-27 13:54:53 +02:00
Manuel Aranda Rosales d6a092ce75 Merge branch 'main' into develop
testing/ogGui-multibranch/pipeline/pr-main Build queued... Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-27 13:54:28 +02:00
Manuel Aranda Rosales 483168146b Added changelog 2025-06-27 13:54:04 +02:00
Manuel Aranda Rosales 2cc10615a1 Merge pull request 'develop' (#31) from develop into main
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
Reviewed-on: #31
2025-06-27 12:46:30 +02:00
Manuel Aranda Rosales f16581e4ed Merge branch 'main' into develop
testing/ogGui-multibranch/pipeline/pr-main Build queued... Details
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-06-27 12:43:55 +02:00
Manuel Aranda Rosales f2e4f5d081 Added logs real time ogLive 2025-06-27 12:43:27 +02:00
Manuel Aranda Rosales 24f45e6ba6 Merge pull request 'develop' (#30) from develop into main
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
Reviewed-on: #30
2025-06-27 10:03:27 +02:00
Manuel Aranda Rosales c2c5bb68be Merge branch 'main' into develop
testing/ogGui-multibranch/pipeline/pr-main Build started... Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-27 10:03:12 +02:00
Manuel Aranda Rosales 01390a1fab Updated trace filters and added logs in real time
testing/ogGui-multibranch/pipeline/pr-main There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-27 09:57:34 +02:00
Manuel Aranda Rosales 642a439f21 Updated trace and fixed status global 2025-06-27 09:36:56 +02:00
Manuel Aranda Rosales d11d3f2d75 Merge pull request 'Updated maximum file size' (#29) from develop into main
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
Reviewed-on: #29
2025-06-27 08:17:02 +02:00
Manuel Aranda Rosales 0403385421 Updated maximum file size
testing/ogGui-multibranch/pipeline/pr-main Build queued... Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-27 08:16:09 +02:00
Manuel Aranda Rosales e024c7a246 Merge pull request 'develop' (#28) from develop into main
oggui-debian-package/pipeline/head There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag There was a failure building this commit Details
Reviewed-on: #28
2025-06-26 16:23:03 +02:00
Manuel Aranda Rosales ef1316c166 Merge branch 'main' into develop
testing/ogGui-multibranch/pipeline/pr-main There was a failure building this commit Details
2025-06-26 16:22:40 +02:00
Manuel Aranda Rosales a3d60a55df Added changelog
testing/ogGui-multibranch/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/pr-main There was a failure building this commit Details
2025-06-26 16:20:18 +02:00
Manuel Aranda Rosales 3a52ebee39 Fixed conflicts
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-06-26 16:05:23 +02:00
Manuel Aranda Rosales f528404725 refs #2338. Kill job constants
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-26 15:59:40 +02:00
Manuel Aranda Rosales ce00b92751 refs #2339. Assistants new UX
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-26 15:58:56 +02:00
Manuel Aranda Rosales 212c4f9eec refs #2252. Queue actions
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-26 15:57:04 +02:00
Manuel Aranda Rosales 90d969ccd3 refs 2335. Groups new UX
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-26 15:55:19 +02:00
Manuel Aranda Rosales d526bb851a refs 2336. Traces new UX
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-26 15:53:40 +02:00
Manuel Aranda Rosales 537a220fc4 refs 2334. Scroll to top button 2025-06-26 15:50:42 +02:00
Manuel Aranda Rosales 3e8f8cc3db refs 2337. Global status new UX
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-26 15:49:44 +02:00
Manuel Aranda Rosales 5b8dba4835 refs #1984. Integration ogGit. Crete and deploy Image. Show git images in repository
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-26 15:47:44 +02:00
Manuel Aranda Rosales a1c2fb7c2e refs #1984. Integration ogGit. Crete and deploy Image. Show git images in repository
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-06-26 15:45:37 +02:00
Manuel Aranda Rosales c290c94675 Merge pull request 'develop' (#27) from develop into main
oggui-debian-package/pipeline/tag This commit looks good Details
oggui-debian-package/pipeline/head This commit looks good Details
Reviewed-on: #27
2025-06-09 12:25:12 +02:00
Manuel Aranda Rosales 9216137dee Merge branch 'main' into develop 2025-06-09 12:24:14 +02:00
Manuel Aranda Rosales 15b37d9158 Fixed test 2025-06-09 12:23:07 +02:00
Manuel Aranda Rosales b6c00e12d8 Fixed test 2025-06-09 12:21:20 +02:00
Manuel Aranda Rosales b4ba0b1244 refs #2182. Changed Output messages 2025-06-09 12:05:35 +02:00
Lucas Lara García 06d0a83aab refs #2169 Refactor legend component to enhance client status display and add translations for client states 2025-06-04 13:13:30 +02:00
Lucas Lara García 3bc07b56cd refs #2171 Add LogoutGuard and update AuthService logout method to support redirect options 2025-06-04 10:07:18 +02:00
Lucas Lara García e964c6b47a refs #2148 Refactor dialog components to use <mat-dialog-content> and <mat-dialog-actions> for better responsiveness 2025-06-03 10:58:41 +02:00
Lucas Lara García 36f709f7c1 Hide edit user button for super-admin 2025-06-02 10:59:00 +02:00
Manuel Aranda Rosales f840272e0d Merge pull request 'develop' (#25) from develop into main
oggui-debian-package/pipeline/tag There was a failure building this commit Details
oggui-debian-package/pipeline/head There was a failure building this commit Details
Reviewed-on: #25
2025-06-02 08:38:22 +02:00
Manuel Aranda Rosales a8817bf49a Solve conflics 2025-06-02 08:37:50 +02:00
Manuel Aranda Rosales 6c40d78f15 solve conflicts 2025-06-02 08:34:39 +02:00
Manuel Aranda Rosales c2f3ea3caa Added changelog 2025-06-02 08:32:26 +02:00
Manuel Aranda Rosales 62001e4a44 Fixed test 2025-06-02 08:29:21 +02:00
Manuel Aranda Rosales 6c7951be31 refs #2118. Fixed bug in manage-repo form 2025-06-02 08:09:59 +02:00
Manuel Aranda Rosales f94d522420 refs #2158. Added script to existint command-task 2025-06-02 08:09:20 +02:00
Lucas Lara García 8882fd40a5 test: enhance ChangeParentComponent tests with necessary imports and providers 2025-05-30 09:31:56 +02:00
Manuel Aranda Rosales 4e23723717 refs #2098. Move clients to different OU
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-29 16:00:53 +02:00
Manuel Aranda Rosales 1ce50857ac refs #2098. Move clients to different OU 2025-05-29 15:31:27 +02:00
Lucas Lara García 3cd46e1166 feat: implement page not found component with styled layout
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-29 11:58:47 +02:00
Lucas Lara García 7137768939 refactor: remove Admin and Dashboard components; implement role-based routing and guards
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-29 11:51:20 +02:00
Lucas Lara García 79188ffbc5 refs #2078 and #2079 Add user category checks for client management actions in groups component
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-28 12:15:58 +02:00
Lucas Lara García 84fd9d0335 refs #2078 and #2079 Implement role-based command filtering and UI adjustments for user permissions
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-27 14:14:01 +02:00
Lucas Lara García a26f2481fa fix: update BootSoPartition and RemoveCacheImage component tests with new dependencies and mock data
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-26 13:10:51 +02:00
Manuel Aranda Rosales a6ff85139b Removed temp. ogGit
oggui-debian-package/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-23 14:14:12 +02:00
Lucas Lara García c7ed41a1a5 refs #2089 and #2091 Implement AuthService for user authentication and role management
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-23 13:56:07 +02:00
Manuel Aranda Rosales 4e6dbde59c Merge branch 'develop' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into develop
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-23 13:28:33 +02:00
Manuel Aranda Rosales fcfcd58d93 refs #2085. RemoveCacheImage 2025-05-23 13:28:22 +02:00
Manuel Aranda Rosales 98b4d3c4f0 refs #2074. BootOs 2025-05-23 13:27:43 +02:00
Lucas Lara García faf3fa0613 fix: reset pagination on filter change in ClientTaskLogsComponent
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-22 09:28:05 +02:00
Lucas Lara García 11b1c26f50 Text fixed: add MatInputModule to ClientTaskLogsComponent test setup
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-21 09:58:56 +02:00
Lucas Lara García cd45009751 refs #2049 Improve date validation and update parameter naming in task logs
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-20 16:20:05 +02:00
Lucas Lara García b55f15f16b refs #2049 enhance date filtering in task logs with max date constraint and validation
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-20 14:29:11 +02:00
Lucas Lara García c824953b1e refactor: add date filters to task logs component and improve validation
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-20 13:59:10 +02:00
Lucas Lara García 9d21a6fefc refactor: streamline trace loading parameters and enhance logging
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-20 13:51:17 +02:00
Lucas Lara García a84372214b refactor: simplify progress container structure and enhance trace status display
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-20 13:26:35 +02:00
Lucas Lara García 4c8b6c7dbd refactor: update task logs table to improve trace information display and localization
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-20 13:17:52 +02:00
Manuel Aranda Rosales d83f0148fb Merge pull request 'develop' (#23) from develop into main
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
Reviewed-on: #23
2025-05-19 16:58:23 +02:00
Manuel Aranda Rosales 3d2f9dfa5e Merge branch 'main' into develop
testing/ogGui-multibranch/pipeline/pr-main Build queued... Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-19 16:58:05 +02:00
Manuel Aranda Rosales 6433adbecd Added changelog
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-19 16:57:07 +02:00
Manuel Aranda Rosales 35bb0e6e62 Merge branch 'develop' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into develop
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-19 16:19:43 +02:00
Manuel Aranda Rosales 76835be2a2 Improvements 2025-05-19 16:19:37 +02:00
Lucas Lara García a6ce1ee4cf fix: update command filter to use ID or UUID for selection
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-19 13:32:00 +02:00
Lucas Lara García a57675631f refs #2040 update joyride steps and translations for task logs component
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-19 13:26:31 +02:00
Manuel Aranda Rosales 9fcfeb7f4e Merge branch 'develop' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into develop
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-19 08:13:00 +02:00
Manuel Aranda Rosales f70dcd109a Assistants improvements 2025-05-19 08:12:53 +02:00
Manuel Aranda Rosales 2f202e5037 refs #2031. New config tls var 2025-05-19 08:12:07 +02:00
Lucas Lara García b0d255a7d1 refactor: prevent default setting of organizational unit when not in edit mode and streamline data loading logic
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-16 13:46:15 +02:00
Lucas Lara García 4ff21afb57 refactor: conditionally update network settings based on excludeParentChanges flag
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-16 12:59:03 +02:00
Lucas Lara García 639f55edb3 refactor: update command filter to use name instead of id and remove date range handling 2025-05-14 10:07:19 +02:00
Nicolas Arenas f41cbf2cef Adding oggui postinst debug
oggui-debian-package/pipeline/head This commit looks good Details
2025-05-14 06:12:46 +02:00
Nicolas Arenas b82f139c9d Adding oggui postinst debug
oggui-debian-package/pipeline/head This commit looks good Details
2025-05-14 06:04:51 +02:00
Manuel Aranda Rosales bad345c2fd Merge pull request 'develop' (#22) from develop into main
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
Reviewed-on: #22
2025-05-13 08:37:13 +02:00
Manuel Aranda Rosales 56a8c563ff Merge branch 'main' into develop 2025-05-13 08:36:28 +02:00
Manuel Aranda Rosales 14986ce524 Added changelog 2025-05-13 08:35:43 +02:00
Manuel Aranda Rosales 15be37a4d0 refs #1984. Added partitionInfo to list 2025-05-13 08:26:58 +02:00
Manuel Aranda Rosales 1c1f75811c refs #1984. Git integration UX changes 2025-05-12 22:44:55 +02:00
Manuel Aranda Rosales e3adf08bd9 refs #1984. Git integration UX changes 2025-05-12 16:28:48 +02:00
Manuel Aranda Rosales 114483410b refs #1827. UX changes. New calendar styles 2025-05-12 16:26:35 +02:00
Lucas Lara García b65d715443 Refactor command filter to use command ID instead of name in ClientTaskLogs component
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-09 14:27:42 +02:00
Lucas Lara García 53d1191ddf Erefs #1963 nhance ClientTaskLogs component to include date range filtering in trace loading and reset functionality
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-09 13:52:28 +02:00
Lucas Lara García 600d5f9b37 refs #1963 Enhance loadTraces method to include command and status filters in HTTP parameters
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-09 13:41:00 +02:00
Lucas Lara García d10a209a25 Enhance unit tests for ClientTaskLogs and OutputDialog components by adding necessary imports, providers, and schemas
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-09 13:07:19 +02:00
Manuel Aranda Rosales 757de78dc4 refs #1969. Partition changes
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-08 17:09:56 +02:00
Manuel Aranda Rosales 7ea5013cf4 refs #1972. General styles changed. Show image details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-08 16:33:22 +02:00
Lucas Lara García 5777be9417 Enhance resetFilters method to include client input parameter and update template button accordingly
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-08 14:13:11 +02:00
Lucas Lara García 28336603ad Refactor resetFilters method to accept input parameters for command and status filters
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-08 14:11:45 +02:00
Lucas Lara García 60684d2c50 Implement ClientTaskLogs component with enhanced UI and functionality
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-08 13:59:24 +02:00
Lucas Lara García 1ba62b9283 Add ClientTaskLogs component and integrate with Groups component
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
- Introduced ClientTaskLogsComponent to display client task logs.
- Updated GroupsComponent to include a button for opening client task logs.
- Added translations for 'procedimientosCliente' in both English and Spanish locales.
- Created associated HTML, CSS, and spec files for ClientTaskLogsComponent.
2025-05-08 13:02:38 +02:00
Manuel Aranda Rosales 242f7a374c refs #1972. General styles changed. New 2025
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-08 12:56:14 +02:00
Manuel Aranda Rosales a5730a1de4 refs #1971. Trace UX changes. Some improvements and fixed filters 2025-05-08 12:54:58 +02:00
Manuel Aranda Rosales 81766471ee Fixed wrong dir
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-08 09:06:20 +02:00
Manuel Aranda Rosales 9a84e45cb8 Pushed app.module 2025-05-08 09:05:56 +02:00
Manuel Aranda Rosales ef3158a045 refs #1922. Show new fields in clients table.
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-08 08:06:57 +02:00
Manuel Aranda Rosales 9ba8c1771d refs #1906. Updated UX to CommandTask
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-05-08 08:03:45 +02:00
Manuel Aranda Rosales 59e8c3842e Merge branch 'develop' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into develop 2025-05-08 08:01:38 +02:00
Manuel Aranda Rosales fc51481977 refs #1968. Pxe some UX improvements 2025-05-08 08:01:14 +02:00
Lucas Lara García f35ba106ba refs #1953 Refactor manage organizational unit component: replace hardcoded strings with translation keys and update localization files
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-07 13:45:40 +02:00
Lucas Lara García 2dce55ce1d Refactor legend component: update remote access titles to use translation keys and enhance localization files
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-07 12:19:00 +02:00
Lucas Lara García bdd6206e18 Refactor execute command component: replace command names with translation keys and update localization files for execute commands
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-07 11:58:51 +02:00
Lucas Lara García a3f99958a3 Refactor groups component: update tour steps, improve button layout, and enhance translation keys
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-06 14:09:12 +02:00
Lucas Lara García 8cc1854d09 Refactor partition type display: simplify client name presentation and improve table structure
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-02 13:34:02 +02:00
Lucas Lara García f6dbd6dad9 refs #1941 Refactor partition type organizer: enhance data structure and improve client grouping logic
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-05-02 13:24:06 +02:00
Manuel Aranda Rosales 35512ce65f General improvements
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-30 13:25:37 +02:00
Manuel Aranda Rosales 1e05176758 refs #1827. UX changes. New calendar styles
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-04-30 13:17:16 +02:00
Manuel Aranda Rosales 83ab784c48 refs #1922. Show new fields in clients table. Fixed wrong details 2025-04-30 13:16:18 +02:00
Manuel Aranda Rosales bf100943a0 refs #1906. Updated UX to CommandTask
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-04-30 13:15:06 +02:00
Nicolas Arenas 1fbec200ab Updates control
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-29 15:57:49 +02:00
Nicolas Arenas 253af06ad5 Updtes control description
testing/ogGui-multibranch/pipeline/head Something is wrong with the build of this commit Details
oggui-debian-package/pipeline/head Something is wrong with the build of this commit Details
2025-04-29 15:53:05 +02:00
Manuel Aranda Rosales 1df6578efd Merge branch 'develop' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into develop 2025-04-29 14:15:31 +02:00
Lucas Lara García ed073deb5d Refactor runScriptContext handling in ExecuteCommandComponent and RunScriptAssistantComponent for improved data management; update related HTML to reflect changes.
testing/ogGui-multibranch/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/head Something is wrong with the build of this commit Details
2025-04-29 14:05:43 +02:00
Manuel Aranda Rosales d47eaf49a7 Merge branch 'develop' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into develop 2025-04-28 08:20:16 +02:00
Lucas Lara García 34ea12fc50 Translate table headers in PartitionTypeOrganizatorComponent to Spanish for better localization
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-25 14:23:39 +02:00
Lucas Lara García 6fd04fb46e refs #1889 Add PartitionTypeOrganizatorComponent: implement partition management modal and integrate into GroupsComponent
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-25 14:02:15 +02:00
Lucas Lara García 2bc17e8b56 Refactor updateCommandStates method in ExecuteCommandComponent: restore and optimize command state management logic for improved functionality
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-04-24 14:09:54 +02:00
Lucas Lara García 9582ce338c refs #1928 Add runScriptContext input to ExecuteCommandComponent and update related components for improved script execution context handling
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-04-24 14:08:42 +02:00
Lucas Lara García e8b713ea09 refs #1931 Refactor table styles in ClientDetailsComponent: remove border-radius and box-shadow for a cleaner look
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-23 15:03:59 +02:00
Lucas Lara García 256b3ba788 refs #1931 Enhance ClientDetailsComponent layout and styling: increase dialog width, adjust chart dimensions, and improve disk layout for better data presentation
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-23 14:56:17 +02:00
Lucas Lara García f016c66d55 refs #1931 Refactor ClientMainViewComponent and related files: remove component and styles, update routing, and adjust dialog dimensions for improved client detail display
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-23 14:22:12 +02:00
Lucas Lara García 70e21c6ca2 refs #1931 Refactor ClientDetailsComponent to enhance layout and improve data handling in client details view
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-23 13:51:45 +02:00
Lucas Lara García a40be684b5 refs #1931 Load partitions data and log relevant information for debugging
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-23 13:05:33 +02:00
Lucas Lara García ca0140e275 refs #1931 Add ClientDetailsComponent for enhanced client information display and management
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-23 12:45:32 +02:00
Lucas Lara García 88aaf39b65 refs #1884 Update command enabling logic to include 'create-image' for active clients
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-22 13:30:01 +02:00
Lucas Lara García a418d26615 refs #1884 Refactor command state handling to improve client status management and enable/disable commands based on client states
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-22 13:19:22 +02:00
Lucas Lara García 6621f7a8fe refs #1884 Enhance command execution logic to handle 'disconnected' state and update UI for multiple clients
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-22 12:59:49 +02:00
Nicolas Arenas 5dc1677851 Fix typo
testing/ogGui-multibranch/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
oggui-debian-package/pipeline/head Something is wrong with the build of this commit Details
2025-04-22 12:13:42 +02:00
Nicolas Arenas 73a69f79c9 Preserve config files
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-22 11:54:28 +02:00
Lucas Lara García 558a3205ab refs #1884 Add clientState input to execute-command component for dynamic state handling
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-21 14:56:09 +02:00
Manuel Aranda Rosales 858a204036 Merge pull request 'develop' (#21) from develop into main
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
oggui-debian-package/pipeline/head This commit looks good Details
Reviewed-on: #21
2025-04-16 14:36:15 +02:00
Manuel Aranda Rosales 844d3dc0f0 Merge branch 'develop' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into develop
testing/ogGui-multibranch/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/pr-main Build started... Details
2025-04-16 14:33:12 +02:00
Manuel Aranda Rosales 23bf2b51ea refs #1924. Refresh status card view 2025-04-16 14:33:03 +02:00
Manuel Aranda Rosales e4e6a8907e Merge branch 'main' into develop 2025-04-16 14:32:39 +02:00
Manuel Aranda Rosales 0096daca42 refs #1924. Refresh status card view 2025-04-16 14:32:17 +02:00
Lucas Lara García de56a23a2b refs #1920 use all clients by default in execute command
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-16 11:34:47 +02:00
Manuel Aranda Rosales 3bae27d88e Merge pull request 'develop' (#20) from develop into main
testing/ogGui-multibranch/pipeline/head This commit looks good Details
Reviewed-on: #20
2025-04-16 11:29:37 +02:00
Manuel Aranda Rosales 265b4888c3 Merge branch 'main' into develop
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/pr-main There was a failure building this commit Details
2025-04-16 11:27:54 +02:00
Manuel Aranda Rosales ce1a06d51b Added changelog
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-16 11:23:11 +02:00
Manuel Aranda Rosales a0d833726d refs #1922. Show new fields in clients table
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-16 11:17:11 +02:00
Manuel Aranda Rosales c7919cf412 refs #1921. Crreate image version bug in selector
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-04-16 10:52:02 +02:00
Manuel Aranda Rosales cbe15bba4d refs #1907. Updated global status sync endpoints
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2025-04-16 10:50:27 +02:00
Manuel Aranda Rosales b6e8134810 Merge branch 'develop' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into develop 2025-04-15 18:37:50 +02:00
Manuel Aranda Rosales 6205f3ad2f refs #1919. Fixed bug when removed partition 2025-04-15 18:37:36 +02:00
Lucas Lara García 436267cfb9 refs #1883 Move 'Execute commands' to tree node actions
testing/ogGui-multibranch/pipeline/head This commit looks good Details
2025-04-15 15:17:03 +02:00
Lucas Lara García e9a00119aa Fix incorrect path for loading config.json in ConfigService
testing/ogGui-multibranch/pipeline/head Something is wrong with the build of this commit Details
2025-04-11 13:20:53 +02:00
218 changed files with 18193 additions and 3886 deletions

View File

@ -1,4 +1,87 @@
# Changelog # Changelog
## [0.16.0] - 2025-06-27
### Added
- Sistema de logs en tiempo real.
### Improved
- Se ha mejorado el comportamiento de algunos filtros en la parte de trazas.
---
## [0.15.0] - 2025-06-26
### Added
- Se ha añadido integracion con OgGit. Ahora se pueden crear y desplegar imagenes.
- Ahora se pueden gestionar cola de acciones y vaciarlas tanto a nivel de aula como de cliente.
- Nuevos componentes que ayudan a la mejora general de la UX
- Mejora en el comportamiento de los asistentes.
- Se puede cancelar tareas desde la parte de Trazas
### Changed
- Se ha cambiado la vista de las imagenes de Git
---
## [0.14.1] - 2025-06-09
### Fixed
- Se han corregido los errores en produccion que hacia que no salieran mensajes desde la API correctamente.
---
## [0.14.0] - 2025-06-02
### Added
- Se ha añadido funcionalidad de usuarios/roles para separar las vistas segun los permisos.
- Nuevo boton de "mover" en la pantalla de grupos, para mover equipos entre aulas y grupos.
### Improved
- Se ha completado la opcion de inicion de sesion y eliminar imagen cache.
---
## [0.13.1] - 2025-05-23
### Changed
- Desactivado temporalmente la funcionalidad de ogGit.
---
## [0.13.0] - 2025-05-20
### Added
- Se ha añadido nuevo campo "SSL_ENABLED" en el apartado configuracion.
### Improved
- Mejoras en el asistente de particionado.
- Se han añadido mejoras en los filtros de las trazas.
---
## [0.12.0] - 2025-5-13
### Added
- Se ha añadido un nuevo modal del detalle de las acciones ejecutadas por cada cliente.
- Se ha añadido un modulo para la gestion de las tareas y acciones programadas.
- Se han añadido nuevos campos en el listado general de clientes.
### Improved
- Se ha cambiado la pagina de detalles de un cliente, por un modal.
- Se han actualizado gran parte de las ayudas contextuales de las distintas parrillas de datos.
- Se ha mejorado y corregido los errores del particionador.
- Mejoras en la pantalla de trazas.
- Cambios en la estetica general de la aplicacion
- Añadida la primera version de la la integracion con ogGit
- Se ha mejorado la responsividad de la aplicacion, para pantallas pequeñas.
### Fixed
- Se ha corregido un error que hacia que no apareciesen los calendarios en la pantalla de editar OU.
- En la pantalla de hacer deploy, al seleccionar imagen ahora deja desmarcarla.
## [0.11.2] - 2025-4-16
### Fixed
- Se ha corregido un error en la actualizacion del estado de los pcs en la vista tarjetas.
---
## [0.11.1] - 2025-4-16
### Improved
- Nuevos campos en la tabla de clientes. Tipo de firmware y mac.
## Fixed
- Se ha corregido error al crear OUs, que no refrescaba la web.
- Se ha corregido error en el formulario de creacion de imagenes. Si se seleccionaba una imagen para un versionado, no dejaba deseleccionar.
- Se ha corregido un bug en el particionador que impedia ejecutar, cuando eliminabamos una particion.
---
## [0.11.0] - 2025-4-11 ## [0.11.0] - 2025-4-11
### Added ### Added
- Se ha diseñado el nuevo formulario para poder ejecutar script. Sistema mejorado con variables etiquetadas. - Se ha diseñado el nuevo formulario para poder ejecutar script. Sistema mejorado con variables etiquetadas.

4
debian/control vendored
View File

@ -9,5 +9,5 @@ Package: oggui
Architecture: any Architecture: any
Maintainer: Nicolas Arenas <nicolas.arenas@qindel.com> Maintainer: Nicolas Arenas <nicolas.arenas@qindel.com>
Depends: ${shlibs:Depends}, ${misc:Depends}, nginx Depends: ${shlibs:Depends}, ${misc:Depends}, nginx
Description: OpenGnsys GUI Description: OpenGnsys GUI created for the Opengnsys Team
Una interfaz gráfica para OpenGnsys. Opengnsys Graphical Intercface

29
debian/oggui.postinst vendored
View File

@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
set -e set -e
set -x
. /usr/share/debconf/confmodule . /usr/share/debconf/confmodule
@ -13,12 +14,28 @@ OGMERCURE_URL="$RET"
USER="opengnsys" USER="opengnsys"
CONFIG_FILE="/opt/opengnsys/oggui/browser/assets/config.json" CONFIG_FILE="/opt/opengnsys/oggui/browser/assets/config.json"
restore_config_if_modified() {
local new="$1"
local backup="$1.bak"
if [ -f "$backup" ]; then
if ! cmp -s "$new" "$backup"; then
echo ">>> Archivo modificado por el usuario detectado en $new"
echo " - Guardando archivo nuevo como ${new}.new"
mv -f "$new" "${new}.new"
echo " - Restaurando archivo anterior desde backup"
mv -f "$backup" "$new"
else
echo ">>> El archivo $new no ha cambiado desde la última versión, eliminando backup"
rm -f "$backup"
fi
fi
}
# Detectar si es una instalación nueva o una actualización # Detectar si es una instalación nueva o una actualización
if [ "$1" = "configure" ] && [ -z "$2" ]; then if [ "$1" = "configure" ] && [ -z "$2" ]; then
if [ ! -f "$CONFIG_FILE" ]; then jq --arg apiUrl "$OGCORE_URL" --arg mercureUrl "$OGMERCURE_URL" '.apiUrl = $apiUrl | .mercureUrl = $mercureUrl' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
jq --arg apiUrl "$OGCORE_URL" --arg mercureUrl "$OGMERCURE_URL" \
'.apiUrl = $apiUrl | .mercureUrl = $mercureUrl' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
fi
ln -s /opt/opengnsys/oggui/etc/nginx/oggui.conf /etc/nginx/sites-enabled/oggui.conf ln -s /opt/opengnsys/oggui/etc/nginx/oggui.conf /etc/nginx/sites-enabled/oggui.conf
ln -s $CONFIG_FILE /opt/opengnsys/oggui/etc/config.json ln -s $CONFIG_FILE /opt/opengnsys/oggui/etc/config.json
mkdir -p /etc/nginx/certs/ mkdir -p /etc/nginx/certs/
@ -29,6 +46,10 @@ if [ "$1" = "configure" ] && [ -z "$2" ]; then
elif [ "$1" = "configure" ] && [ -n "$2" ]; then elif [ "$1" = "configure" ] && [ -n "$2" ]; then
cd /opt/opengnsys/oggui cd /opt/opengnsys/oggui
echo "Actualización desde la versión $2" echo "Actualización desde la versión $2"
# Si upgrade recupero los archivos de configuracion
echo ">>> Backup de archivos de configuración reales en /opt/opengnsys"
restore_config_if_modified "/opt/opengnsys/oggui/etc/nginx/oggui.conf"
restore_config_if_modified "$CONFIG_FILE"
fi fi
# Cambiar la propiedad de los archivos al usuario especificado # Cambiar la propiedad de los archivos al usuario especificado

17
debian/oggui.preinst vendored
View File

@ -2,6 +2,16 @@
set -e set -e
backup_file_if_exists() {
local original="$1"
local backup="$1.bak"
if [ -e "$original" ]; then
echo " - Guardando backup de $original en $backup"
cp -a "$original" "$backup"
fi
}
CONFIG_FILE="/opt/opengnsys/oggui/browser/assets/config.json"
# Asegurarse de que el usuario exista # Asegurarse de que el usuario exista
USER="opengnsys" USER="opengnsys"
HOME_DIR="/opt/opengnsys" HOME_DIR="/opt/opengnsys"
@ -12,4 +22,11 @@ else
useradd -m -d "$HOME_DIR" -s /bin/bash "$USER" useradd -m -d "$HOME_DIR" -s /bin/bash "$USER"
fi fi
# Si upgrade hago backup del archivo de configuración
if [ "$1" = "upgrade" ]; then
echo ">>> Backup de archivos de configuración reales en /opt/opengnsys"
backup_file_if_exists "/opt/opengnsys/oggui/etc/nginx/sites-available/oggui.conf"
backup_file_if_exists "$CONFIG_FILE"
fi
exit 0 exit 0

View File

@ -63,8 +63,8 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "7kb", "maximumWarning": "35kb",
"maximumError": "10kb" "maximumError": "40kb"
} }
], ],
"outputHashing": "all" "outputHashing": "all"

View File

@ -3,30 +3,26 @@ import { RouterModule, Routes } from '@angular/router';
import { MainLayoutComponent } from './layout/main-layout/main-layout.component'; import { MainLayoutComponent } from './layout/main-layout/main-layout.component';
import { AuthLayoutComponent } from './layout/auth-layout/auth-layout.component'; import { AuthLayoutComponent } from './layout/auth-layout/auth-layout.component';
import { LoginComponent } from './components/login/login.component'; import { LoginComponent } from './components/login/login.component';
import { DashboardComponent } from './components/dashboard/dashboard.component';
import { PageNotFoundComponent } from './shared/page-not-found/page-not-found.component'; import { PageNotFoundComponent } from './shared/page-not-found/page-not-found.component';
import { AdminComponent } from './components/admin/admin.component';
import { UsersComponent } from './components/admin/users/users/users.component'; import { UsersComponent } from './components/admin/users/users/users.component';
import { RolesComponent } from './components/admin/roles/roles/roles.component'; import { RolesComponent } from './components/admin/roles/roles/roles.component';
import { GroupsComponent } from './components/groups/groups.component'; import { GroupsComponent } from './components/groups/groups.component';
import { PXEimagesComponent } from './components/ogboot/pxe-images/pxe-images.component'; import { PXEimagesComponent } from './components/ogboot/pxe-images/pxe-images.component';
import { PxeComponent } from './components/ogboot/pxe/pxe.component'; import { PxeComponent } from './components/ogboot/pxe/pxe.component';
import { PxeBootFilesComponent } from './components/ogboot/pxe-boot-files/pxe-boot-files.component'; import { PxeBootFilesComponent } from './components/ogboot/pxe-boot-files/pxe-boot-files.component';
import {OgbootStatusComponent} from "./components/ogboot/ogboot-status/ogboot-status.component"; import { OgbootStatusComponent } from "./components/ogboot/ogboot-status/ogboot-status.component";
import { CalendarComponent } from "./components/calendar/calendar.component"; import { CalendarComponent } from "./components/calendar/calendar.component";
import { CommandsComponent } from './components/commands/main-commands/commands.component'; import { CommandsComponent } from './components/commands/main-commands/commands.component';
import { CommandsGroupsComponent } from './components/commands/commands-groups/commands-groups.component'; import { CommandsGroupsComponent } from './components/commands/commands-groups/commands-groups.component';
import { CommandsTaskComponent } from './components/commands/commands-task/commands-task.component'; import { CommandsTaskComponent } from './components/commands/commands-task/commands-task.component';
import { TaskLogsComponent } from './components/commands/commands-task/task-logs/task-logs.component'; import { TaskLogsComponent } from './components/task-logs/task-logs.component';
import { ClientMainViewComponent } from './components/groups/components/client-main-view/client-main-view.component'; import { SoftwareComponent } from "./components/software/software.component";
import { ImagesComponent } from './components/images/images.component'; import { SoftwareProfileComponent } from "./components/software-profile/software-profile.component";
import {SoftwareComponent} from "./components/software/software.component"; import { OperativeSystemComponent } from "./components/operative-system/operative-system.component";
import {SoftwareProfileComponent} from "./components/software-profile/software-profile.component";
import {OperativeSystemComponent} from "./components/operative-system/operative-system.component";
import { import {
PartitionAssistantComponent PartitionAssistantComponent
} from "./components/groups/components/client-main-view/partition-assistant/partition-assistant.component"; } from "./components/groups/components/client-main-view/partition-assistant/partition-assistant.component";
import {RepositoriesComponent} from "./components/repositories/repositories.component"; import { RepositoriesComponent } from "./components/repositories/repositories.component";
import { import {
CreateClientImageComponent CreateClientImageComponent
} from "./components/groups/components/client-main-view/create-image/create-image.component"; } from "./components/groups/components/client-main-view/create-image/create-image.component";
@ -36,52 +32,52 @@ import {
import { import {
MainRepositoryViewComponent MainRepositoryViewComponent
} from "./components/repositories/main-repository-view/main-repository-view.component"; } from "./components/repositories/main-repository-view/main-repository-view.component";
import {EnvVarsComponent} from "./components/admin/env-vars/env-vars.component"; import { EnvVarsComponent } from "./components/admin/env-vars/env-vars.component";
import {MenusComponent} from "./components/menus/menus.component"; import { MenusComponent } from "./components/menus/menus.component";
import {OgDhcpSubnetsComponent} from "./components/ogdhcp/og-dhcp-subnets.component"; import { OgDhcpSubnetsComponent } from "./components/ogdhcp/og-dhcp-subnets.component";
import {StatusComponent} from "./components/ogdhcp/status/status.component"; import { StatusComponent } from "./components/ogdhcp/status/status.component";
import { import {
RunScriptAssistantComponent RunScriptAssistantComponent
} from "./components/groups/components/client-main-view/run-script-assistant/run-script-assistant.component"; } from "./components/groups/components/client-main-view/run-script-assistant/run-script-assistant.component";
import { roleGuard } from './guards/role.guard';
import { LogoutGuard } from './guards/logout.guard';
const routes: Routes = [ const routes: Routes = [
{ path: '', redirectTo: 'auth/login', pathMatch: 'full' }, { path: '', redirectTo: 'auth/login', pathMatch: 'full' },
{ path: '', component: MainLayoutComponent, {
path: '', component: MainLayoutComponent,
children: [ children: [
{ path: 'dashboard', component: DashboardComponent }, { path: 'users', component: UsersComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin'] } },
{ path: 'admin', component: AdminComponent }, { path: 'env-vars', component: EnvVarsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin'] } },
{ path: 'users', component: UsersComponent }, { path: 'roles', component: RolesComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin'] } },
{ path: 'env-vars', component: EnvVarsComponent },
{ path: 'user-groups', component: RolesComponent },
{ path: 'groups', component: GroupsComponent }, { path: 'groups', component: GroupsComponent },
{ path: 'pxe-images', component: PXEimagesComponent }, { path: 'pxe-images', component: PXEimagesComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'pxe', component: PxeComponent }, { path: 'pxe', component: PxeComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'pxe-boot-file', component: PxeBootFilesComponent }, { path: 'pxe-boot-file', component: PxeBootFilesComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'ogboot-status', component: OgbootStatusComponent }, { path: 'ogboot-status', component: OgbootStatusComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'subnets', component: OgDhcpSubnetsComponent }, { path: 'subnets', component: OgDhcpSubnetsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'ogdhcp-status', component: StatusComponent }, { path: 'ogdhcp-status', component: StatusComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands', component: CommandsComponent }, { path: 'commands', component: CommandsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-groups', component: CommandsGroupsComponent }, { path: 'commands-groups', component: CommandsGroupsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-task', component: CommandsTaskComponent }, { path: 'commands-task', component: CommandsTaskComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-logs', component: TaskLogsComponent }, { path: 'commands-logs', component: TaskLogsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'calendars', component: CalendarComponent }, { path: 'calendars', component: CalendarComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/deploy-image', component: DeployImageComponent }, { path: 'clients/deploy-image', component: DeployImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/partition-assistant', component: PartitionAssistantComponent }, { path: 'clients/partition-assistant', component: PartitionAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/run-script', component: RunScriptAssistantComponent }, { path: 'clients/run-script', component: RunScriptAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/:id', component: ClientMainViewComponent }, { path: 'clients/:id/create-image', component: CreateClientImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/:id/create-image', component: CreateClientImageComponent }, { path: 'repositories', component: RepositoriesComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'repositories', component: RepositoriesComponent }, { path: 'repository/:id', component: MainRepositoryViewComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'repository/:id', component: MainRepositoryViewComponent }, { path: 'software', component: SoftwareComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'software', component: SoftwareComponent }, { path: 'software-profiles', component: SoftwareProfileComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'software-profiles', component: SoftwareProfileComponent }, { path: 'operative-systems', component: OperativeSystemComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'operative-systems', component: OperativeSystemComponent }, { path: 'menus', component: MenusComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'menus', component: MenusComponent },
], ],
}, },
{ {
path: 'auth', path: 'auth',
component: AuthLayoutComponent, component: AuthLayoutComponent,
children: [ children: [
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent, canActivate: [LogoutGuard] },
], ],
}, },
{ path: '**', component: PageNotFoundComponent }, { path: '**', component: PageNotFoundComponent },

View File

@ -17,7 +17,6 @@ import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSidenavModule } from '@angular/material/sidenav';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { AdminComponent } from './components/admin/admin.component';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -67,7 +66,7 @@ import { PXEimagesComponent } from './components/ogboot/pxe-images/pxe-images.co
import { CreatePXEImageComponent } from './components/ogboot/pxe-images/create-image/create-image/create-image.component'; import { CreatePXEImageComponent } from './components/ogboot/pxe-images/create-image/create-image/create-image.component';
import { InfoImageComponent } from './components/ogboot/pxe-images/info-image/info-image/info-image.component'; import { InfoImageComponent } from './components/ogboot/pxe-images/info-image/info-image/info-image.component';
import { PxeComponent } from './components/ogboot/pxe/pxe.component'; import { PxeComponent } from './components/ogboot/pxe/pxe.component';
import { CreatePxeTemplateComponent } from './components/ogboot/pxe/create-pxeTemplate/create-pxe-template.component'; import { CreatePxeTemplateComponent } from './components/ogboot/pxe/manage-pxeTemplate/create-pxe-template.component';
import { PxeBootFilesComponent } from './components/ogboot/pxe-boot-files/pxe-boot-files.component'; import { PxeBootFilesComponent } from './components/ogboot/pxe-boot-files/pxe-boot-files.component';
import { MatExpansionPanel, MatExpansionPanelDescription, MatExpansionPanelTitle } from "@angular/material/expansion"; import { MatExpansionPanel, MatExpansionPanelDescription, MatExpansionPanelTitle } from "@angular/material/expansion";
import { OgbootStatusComponent } from './components/ogboot/ogboot-status/ogboot-status.component'; import { OgbootStatusComponent } from './components/ogboot/ogboot-status/ogboot-status.component';
@ -87,12 +86,12 @@ import { CreateCommandGroupComponent } from './components/commands/commands-grou
import { DetailCommandGroupComponent } from './components/commands/commands-groups/detail-command-group/detail-command-group.component'; import { DetailCommandGroupComponent } from './components/commands/commands-groups/detail-command-group/detail-command-group.component';
import { CreateTaskComponent } from './components/commands/commands-task/create-task/create-task.component'; import { CreateTaskComponent } from './components/commands/commands-task/create-task/create-task.component';
import { DetailTaskComponent } from './components/commands/commands-task/detail-task/detail-task.component'; import { DetailTaskComponent } from './components/commands/commands-task/detail-task/detail-task.component';
import { TaskLogsComponent } from './components/commands/commands-task/task-logs/task-logs.component'; import { TaskLogsComponent } from './components/task-logs/task-logs.component';
import { MatSliderModule } from '@angular/material/slider'; import { MatSliderModule } from '@angular/material/slider';
import { ClientMainViewComponent } from './components/groups/components/client-main-view/client-main-view.component';
import { ImagesComponent } from './components/images/images.component'; import { ImagesComponent } from './components/images/images.component';
import { CreateImageComponent } from './components/images/create-image/create-image.component'; import { CreateImageComponent } from './components/images/create-image/create-image.component';
import { CreateClientImageComponent } from './components/groups/components/client-main-view/create-image/create-image.component'; import { CreateClientImageComponent } from './components/groups/components/client-main-view/create-image/create-image.component';
import { CreateRepositoryModalComponent } from './components/groups/components/client-main-view/create-image/create-repository-modal/create-repository-modal.component';
import { PartitionAssistantComponent } from './components/groups/components/client-main-view/partition-assistant/partition-assistant.component'; import { PartitionAssistantComponent } from './components/groups/components/client-main-view/partition-assistant/partition-assistant.component';
import { SoftwareComponent } from './components/software/software.component'; import { SoftwareComponent } from './components/software/software.component';
import { CreateSoftwareComponent } from './components/software/create-software/create-software.component'; import { CreateSoftwareComponent } from './components/software/create-software/create-software.component';
@ -118,7 +117,7 @@ import { CreateMultipleClientComponent } from './components/groups/shared/client
import { ExportImageComponent } from './components/images/export-image/export-image.component'; import { ExportImageComponent } from './components/images/export-image/export-image.component';
import { ImportImageComponent } from "./components/repositories/import-image/import-image.component"; import { ImportImageComponent } from "./components/repositories/import-image/import-image.component";
import { LoadingComponent } from './shared/loading/loading.component'; import { LoadingComponent } from './shared/loading/loading.component';
import { InputDialogComponent } from './components/commands/commands-task/task-logs/input-dialog/input-dialog.component'; import { InputDialogComponent } from './components/task-logs/input-dialog/input-dialog.component';
import { ManageOrganizationalUnitComponent } from './components/groups/shared/organizational-units/manage-organizational-unit/manage-organizational-unit.component'; import { ManageOrganizationalUnitComponent } from './components/groups/shared/organizational-units/manage-organizational-unit/manage-organizational-unit.component';
import { BackupImageComponent } from './components/repositories/backup-image/backup-image.component'; import { BackupImageComponent } from './components/repositories/backup-image/backup-image.component';
import { ServerInfoDialogComponent } from "./components/ogdhcp/server-info-dialog/server-info-dialog.component"; import { ServerInfoDialogComponent } from "./components/ogdhcp/server-info-dialog/server-info-dialog.component";
@ -130,7 +129,7 @@ import { ShowClientsComponent } from './components/ogdhcp/show-clients/show-clie
import { OperationResultDialogComponent } from './components/ogdhcp/operation-result-dialog/operation-result-dialog.component'; import { OperationResultDialogComponent } from './components/ogdhcp/operation-result-dialog/operation-result-dialog.component';
import { ManageClientComponent } from './components/groups/shared/clients/manage-client/manage-client.component'; import { ManageClientComponent } from './components/groups/shared/clients/manage-client/manage-client.component';
import { ConvertImageComponent } from './components/repositories/convert-image/convert-image.component'; import { ConvertImageComponent } from './components/repositories/convert-image/convert-image.component';
import { registerLocaleData } from '@angular/common'; import {NgOptimizedImage, registerLocaleData} from '@angular/common';
import localeEs from '@angular/common/locales/es'; import localeEs from '@angular/common/locales/es';
import { GlobalStatusComponent } from './components/global-status/global-status.component'; import { GlobalStatusComponent } from './components/global-status/global-status.component';
import { ShowMonoliticImagesComponent } from './components/repositories/show-monolitic-images/show-monolitic-images.component'; import { ShowMonoliticImagesComponent } from './components/repositories/show-monolitic-images/show-monolitic-images.component';
@ -141,8 +140,25 @@ import {
SaveScriptComponent SaveScriptComponent
} from "./components/groups/components/client-main-view/run-script-assistant/save-script/save-script.component"; } from "./components/groups/components/client-main-view/run-script-assistant/save-script/save-script.component";
import { EditImageComponent } from './components/repositories/edit-image/edit-image.component'; import { EditImageComponent } from './components/repositories/edit-image/edit-image.component';
import { ShowGitImagesComponent } from './components/repositories/show-git-images/show-git-images.component'; import { ShowGitCommitsComponent } from './components/repositories/show-git-images/show-git-images.component';
import { RenameImageComponent } from './components/repositories/rename-image/rename-image.component'; import { RenameImageComponent } from './components/repositories/rename-image/rename-image.component';
import { ClientDetailsComponent } from './components/groups/shared/client-details/client-details.component';
import { PartitionTypeOrganizatorComponent } from './components/groups/shared/partition-type-organizator/partition-type-organizator.component';
import { CreateTaskScheduleComponent } from './components/commands/commands-task/create-task-schedule/create-task-schedule.component';
import { ShowTaskScheduleComponent } from './components/commands/commands-task/show-task-schedule/show-task-schedule.component';
import { ShowTaskScriptComponent } from './components/commands/commands-task/show-task-script/show-task-script.component';
import { CreateTaskScriptComponent } from './components/commands/commands-task/create-task-script/create-task-script.component';
import { ViewParametersModalComponent } from './components/commands/commands-task/show-task-script/view-parameters-modal/view-parameters-modal.component';
import { OutputDialogComponent } from './components/task-logs/output-dialog/output-dialog.component';
import { ClientTaskLogsComponent } from './components/task-logs/client-task-logs/client-task-logs.component';
import { BootSoPartitionComponent } from './components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component';
import { RemoveCacheImageComponent } from './components/commands/main-commands/execute-command/remove-cache-image/remove-cache-image.component';
import { ChangeParentComponent } from './components/groups/shared/change-parent/change-parent.component';
import { SoftwareProfilePartitionComponent } from './components/commands/main-commands/execute-command/software-profile-partition/software-profile-partition.component';
import { ClientPendingTasksComponent } from './components/task-logs/client-pending-tasks/client-pending-tasks.component';
import { QueueConfirmationModalComponent } from './shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { ModalOverlayComponent } from './shared/modal-overlay/modal-overlay.component';
import { ScrollToTopComponent } from './shared/scroll-to-top/scroll-to-top.component';
export function HttpLoaderFactory(http: HttpClient) { export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './locale/', '.json'); return new TranslateHttpLoader(http, './locale/', '.json');
@ -162,7 +178,6 @@ registerLocaleData(localeEs, 'es-ES');
HeaderComponent, HeaderComponent,
SidebarComponent, SidebarComponent,
LoginComponent, LoginComponent,
AdminComponent,
MainLayoutComponent, MainLayoutComponent,
UsersComponent, UsersComponent,
RolesComponent, RolesComponent,
@ -172,6 +187,7 @@ registerLocaleData(localeEs, 'es-ES');
GroupsComponent, GroupsComponent,
ManageClientComponent, ManageClientComponent,
DeleteModalComponent, DeleteModalComponent,
QueueConfirmationModalComponent,
ClassroomViewComponent, ClassroomViewComponent,
ClientViewComponent, ClientViewComponent,
ShowOrganizationalUnitComponent, ShowOrganizationalUnitComponent,
@ -194,6 +210,8 @@ registerLocaleData(localeEs, 'es-ES');
CalendarComponent, CalendarComponent,
CreateCalendarComponent, CreateCalendarComponent,
CreateClientImageComponent, CreateClientImageComponent,
CreateRepositoryModalComponent,
PartitionAssistantComponent,
CreateCalendarRuleComponent, CreateCalendarRuleComponent,
CommandsGroupsComponent, CommandsGroupsComponent,
CommandsTaskComponent, CommandsTaskComponent,
@ -204,10 +222,8 @@ registerLocaleData(localeEs, 'es-ES');
TaskLogsComponent, TaskLogsComponent,
ServerInfoDialogComponent, ServerInfoDialogComponent,
StatusComponent, StatusComponent,
ClientMainViewComponent,
ImagesComponent, ImagesComponent,
CreateImageComponent, CreateImageComponent,
PartitionAssistantComponent,
SoftwareComponent, SoftwareComponent,
CreateSoftwareComponent, CreateSoftwareComponent,
SoftwareProfileComponent, SoftwareProfileComponent,
@ -221,7 +237,6 @@ registerLocaleData(localeEs, 'es-ES');
ExecuteCommandOuComponent, ExecuteCommandOuComponent,
DeployImageComponent, DeployImageComponent,
MainRepositoryViewComponent, MainRepositoryViewComponent,
ExecuteCommandOuComponent,
EnvVarsComponent, EnvVarsComponent,
MenusComponent, MenusComponent,
CreateMenuComponent, CreateMenuComponent,
@ -242,8 +257,24 @@ registerLocaleData(localeEs, 'es-ES');
RunScriptAssistantComponent, RunScriptAssistantComponent,
SaveScriptComponent, SaveScriptComponent,
EditImageComponent, EditImageComponent,
ShowGitImagesComponent, ShowGitCommitsComponent,
RenameImageComponent RenameImageComponent,
ClientDetailsComponent,
PartitionTypeOrganizatorComponent,
CreateTaskScheduleComponent,
ShowTaskScheduleComponent,
ShowTaskScriptComponent,
CreateTaskScriptComponent,
ViewParametersModalComponent,
OutputDialogComponent,
ClientTaskLogsComponent,
BootSoPartitionComponent,
RemoveCacheImageComponent,
ChangeParentComponent,
SoftwareProfilePartitionComponent,
ClientPendingTasksComponent,
ModalOverlayComponent,
ScrollToTopComponent
], ],
bootstrap: [AppComponent], bootstrap: [AppComponent],
imports: [BrowserModule, imports: [BrowserModule,
@ -292,7 +323,7 @@ registerLocaleData(localeEs, 'es-ES');
progressAnimation: 'increasing', progressAnimation: 'increasing',
closeButton: true closeButton: true
} }
), MatGridList, MatTree, MatTreeNode, MatNestedTreeNode, MatTreeNodeToggle, MatTreeNodeDef, MatTreeNodePadding, MatTreeNodeOutlet, MatPaginator, MatGridTile, MatExpansionPanel, MatExpansionPanelTitle, MatExpansionPanelDescription, MatRadioGroup, MatRadioButton, MatAutocompleteTrigger ), MatGridList, MatTree, MatTreeNode, MatNestedTreeNode, MatTreeNodeToggle, MatTreeNodeDef, MatTreeNodePadding, MatTreeNodeOutlet, MatPaginator, MatGridTile, MatExpansionPanel, MatExpansionPanelTitle, MatExpansionPanelDescription, MatRadioGroup, MatRadioButton, MatAutocompleteTrigger, NgOptimizedImage
], ],
schemas: [ schemas: [
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,

View File

@ -1,48 +0,0 @@
/* Estilos del contenedor para centrar los botones */
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
/* Estilos del contenedor de cada botón y texto */
.button-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 0 10px;
}
/* Estilos del texto debajo de los botones */
span{
margin: 0;
font-size: 20px;
text-align: center;
}
/* Media query para hacer los botones responsive */
@media (max-width: 900px) {
button {
height: 120px;
width: 120px;
}
}
@media (max-width: 600px) {
button {
height: 90px;
width: 90px;
}
}
@media (max-width: 400px) {
button {
height: 70px;
width: 70px;
}
span{
font-size: 14px;
}
}

View File

@ -1,10 +0,0 @@
<div class="container">
<button class="action-button" routerLink="/users">
<mat-icon>group</mat-icon>
<span>{{ 'labelUsers' | translate }}</span>
</button>
<button class="action-button" routerLink="/user-groups">
<mat-icon>admin_panel_settings</mat-icon>
<span>{{ 'labelRoles' | translate }}</span>
</button>
</div>

View File

@ -1,48 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AdminComponent } from './admin.component';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { Router } from '@angular/router';
describe('AdminComponent', () => {
let component: AdminComponent;
let fixture: ComponentFixture<AdminComponent>;
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AdminComponent],
imports: [
RouterTestingModule,
MatButtonModule,
MatIconModule,
TranslateModule.forRoot()
]
}).compileComponents();
router = TestBed.inject(Router);
});
beforeEach(() => {
fixture = TestBed.createComponent(AdminComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('debería crear el componente', () => {
expect(component).toBeTruthy();
});
it('debería renderizar dos botones', () => {
const buttons = fixture.nativeElement.querySelectorAll('button');
expect(buttons.length).toBe(2);
});
it('debería tener un botón con routerLink a "/users"', () => {
const button = fixture.nativeElement.querySelector('button[routerLink="/users"]');
expect(button).toBeTruthy();
expect(button.querySelector('mat-icon').textContent.trim()).toBe('group');
});
});

View File

@ -1,13 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-admin',
templateUrl: './admin.component.html',
styleUrl: './admin.component.css'
})
export class AdminComponent {
}

View File

@ -16,12 +16,20 @@
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
<mat-header-cell *matHeaderCellDef> Valor </mat-header-cell> <mat-header-cell *matHeaderCellDef> Valor </mat-header-cell>
<mat-cell *matCellDef="let variable"> <mat-cell *matCellDef="let variable">
<mat-form-field class="value-input"> <!-- Si es booleano, usamos checkbox -->
<mat-checkbox *ngIf="isBoolean(variable.value)"
[checked]="variable.value === 'true'"
(change)="variable.value = $event.checked ? 'true' : 'false'">
</mat-checkbox>
<!-- Si no es booleano, usamos input -->
<mat-form-field *ngIf="!isBoolean(variable.value)" class="value-input">
<input matInput [(ngModel)]="variable.value" /> <input matInput [(ngModel)]="variable.value" />
</mat-form-field> </mat-form-field>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row> <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table> </mat-table>

View File

@ -31,12 +31,15 @@ export class EnvVarsComponent {
this.envVars = Object.entries(response.vars).map(([name, value]) => ({ name, value })); this.envVars = Object.entries(response.vars).map(([name, value]) => ({ name, value }));
}, },
error: (err) => { error: (err) => {
console.error('Error al cargar las variables de entorno:', err);
this.toastService.error('No se pudieron cargar las variables de entorno.'); this.toastService.error('No se pudieron cargar las variables de entorno.');
} }
}); });
} }
isBoolean(value: string): boolean {
return value === 'true' || value === 'false';
}
saveEnvVars(): void { saveEnvVars(): void {
const vars = this.envVars.reduce((acc, variable) => { const vars = this.envVars.reduce((acc, variable) => {
acc[variable.name] = variable.value; acc[variable.name] = variable.value;

View File

@ -22,7 +22,12 @@
.time-fields { .time-fields {
display: flex; display: flex;
gap: 15px; /* Espacio entre los campos */ gap: 15px;
}
.hour-fields {
display: flex;
gap: 15px;
} }
.time-field { .time-field {
@ -35,3 +40,73 @@
gap: 1em; gap: 1em;
padding: 1.5em; padding: 1.5em;
} }
.custom-text {
font-style: italic;
font-size: 0.875rem;
color: #666;
margin: 4px 0 12px;
}
.weekday-toggle-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 40px 0 40px 0;
width: 100%;
justify-content: space-between;
}
.weekday-toggle {
flex: 1 1 calc(14.28% - 10px);
padding: 10px 0;
border-radius: 999px;
border: 1px solid #ccc;
background-color: #f5f5f5;
cursor: pointer;
font-size: 14px;
text-align: center;
transition: all 0.2s ease;
min-width: 40px;
}
.weekday-toggle.selected {
background-color: #1976d2;
color: white;
border-color: #1976d2;
}
.availability-summary {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 12px 16px;
margin-top: 16px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.summary-text {
color: #0d47a1;
line-height: 1.4;
}
.unavailability-summary {
background-color: #ffebee;
border-left: 4px solid #d32f2f;
padding: 12px 16px;
margin-top: 16px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.summary-text {
color: #b71c1c;
line-height: 1.4;
}

View File

@ -1,24 +1,26 @@
<h2 mat-dialog-title>{{ isEditMode ? ('editCalendar' | translate) : ('addCalendar' | translate) }}</h2> <h2 mat-dialog-title>{{ isEditMode ? ('editCalendar' | translate) : ('addCalendar' | translate) }}</h2>
<mat-dialog-content class="form-container"> <mat-dialog-content class="form-container">
<mat-slide-toggle [(ngModel)]="isRemoteAvailable" class="example-margin"> <mat-checkbox [(ngModel)]="isRemoteAvailable">
{{ 'remoteAvailability' | translate }} {{ 'remoteAvailability' | translate }}
</mat-slide-toggle> </mat-checkbox>
<mat-divider style="margin: 10px 0;"></mat-divider>
<div *ngIf="!isRemoteAvailable" class="form-group"> <div *ngIf="!isRemoteAvailable" class="form-group">
<mat-label>{{ 'selectWeekDays' | translate }}</mat-label> <mat-label>{{ 'selectWeekDays' | translate }}</mat-label>
<div class="row"> <p class="custom-text"> (Los dias y horas seleccionados se marcarán como aula no disponible para remote pc.) </p>
<div class="col-md-6 checkbox-group"> <div class="weekday-toggle-group full-width">
<mat-checkbox *ngFor="let day of weekDays.slice(0, (weekDays.length / 2) + 1)" [(ngModel)]="busyWeekDays[day]"> <button
{{ day }} *ngFor="let day of weekDays"
</mat-checkbox> type="button"
</div> class="weekday-toggle"
<div class="col-md-6 checkbox-group"> [class.selected]="busyWeekDays[day]"
<mat-checkbox *ngFor="let day of weekDays.slice(weekDays.length / 2 + 1)" [(ngModel)]="busyWeekDays[day]"> (click)="busyWeekDays[day] = !busyWeekDays[day]">
{{ day }} {{ day.slice(0, 3) }}
</mat-checkbox> </button>
</div>
</div> </div>
<div class="time-fields"> <div class="time-fields">
<mat-form-field appearance="fill" class="time-field"> <mat-form-field appearance="fill" class="time-field">
<mat-label>{{ 'startTime' | translate }}</mat-label> <mat-label>{{ 'startTime' | translate }}</mat-label>
@ -30,12 +32,24 @@
<input matInput [(ngModel)]="busyToHour" type="time" placeholder="{{ 'endTimePlaceholder' | translate }}" [required]="!isRemoteAvailable"> <input matInput [(ngModel)]="busyToHour" type="time" placeholder="{{ 'endTimePlaceholder' | translate }}" [required]="!isRemoteAvailable">
</mat-form-field> </mat-form-field>
</div> </div>
<mat-divider></mat-divider>
<div class="unavailability-summary" *ngIf="busyFromHour && busyToHour && busyWeekDays && getSelectedDays().length">
<mat-icon style="width: 50px;" color="warn">block</mat-icon>
<span class="summary-text">
El aula estará <strong>no disponible</strong> para Remote PC los días:
<strong>{{ getSelectedDays().join(', ') }}</strong> de
<strong>{{ busyFromHour }}</strong> a <strong>{{ busyToHour }}</strong>.
</span>
</div>
</div> </div>
<div *ngIf="isRemoteAvailable" class="form-group"> <div *ngIf="isRemoteAvailable" class="form-group">
<mat-form-field appearance="fill" class="full-width"> <mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'reasonLabel' | translate }}</mat-label> <mat-label>{{ 'reasonLabel' | translate }}</mat-label>
<input matInput [(ngModel)]="availableReason" placeholder="{{ 'reasonPlaceholder' | translate }}" [required]="isRemoteAvailable"> <input matInput [(ngModel)]="availableReason" placeholder="{{ 'reasonPlaceholder' | translate }}" [required]="isRemoteAvailable">
<mat-hint>Razón por la cual el aula SI está disponible para su uso en Remote PC</mat-hint>
</mat-form-field> </mat-form-field>
<div class="time-fields"> <div class="time-fields">
<mat-form-field appearance="fill" class="full-width"> <mat-form-field appearance="fill" class="full-width">
@ -53,6 +67,32 @@
<mat-datepicker #picker2></mat-datepicker> <mat-datepicker #picker2></mat-datepicker>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="hour-fields">
<mat-form-field appearance="fill" class="time-field">
<mat-label>{{ 'startTime' | translate }}</mat-label>
<input matInput [(ngModel)]="busyFromHour" type="time" placeholder="{{ 'startTimePlaceholder' | translate }}" [required]="!isRemoteAvailable">
</mat-form-field>
<mat-form-field appearance="fill" class="time-field">
<mat-label>{{ 'endTime' | translate }}</mat-label>
<input matInput [(ngModel)]="busyToHour" type="time" placeholder="{{ 'endTimePlaceholder' | translate }}" [required]="!isRemoteAvailable">
</mat-form-field>
</div>
<mat-divider></mat-divider>
<div class="availability-summary" *ngIf="availableFromDate && availableToDate">
<mat-icon color="primary" style="width: 50px;">info</mat-icon>
<span class="summary-text">
El aula estará <strong>disponible</strong> para reserva desde el
<strong>{{ availableFromDate | date:'fullDate' }}</strong> hasta el
<strong>{{ availableToDate | date:'fullDate' }}</strong>
<span *ngIf="busyFromHour && busyToHour">
en el horario de <strong>{{ busyFromHour }}</strong> a <strong>{{ busyToHour }}</strong>.
</span>
</span>
</div>
</div> </div>
</mat-dialog-content> </mat-dialog-content>

View File

@ -64,8 +64,8 @@ export class CreateCalendarRuleComponent {
this.dialogRef.close(); this.dialogRef.close();
} }
toggleAdditionalForm(): void { getSelectedDays(): string[] {
this.showAdditionalForm = !this.showAdditionalForm; return Object.keys(this.busyWeekDays || {}).filter(day => this.busyWeekDays[day]);
} }
getSelectedDaysIndices() { getSelectedDaysIndices() {
@ -74,6 +74,11 @@ export class CreateCalendarRuleComponent {
.filter(index => index !== -1); .filter(index => index !== -1);
} }
convertDateToLocalISO(date: Date): string {
const adjustedDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
return adjustedDate.toISOString();
}
submitRule(): void { submitRule(): void {
this.getSelectedDaysIndices() this.getSelectedDaysIndices()
const selectedDaysArray = Object.keys(this.busyWeekDays).map((day, index) => this.busyWeekDays[index]); const selectedDaysArray = Object.keys(this.busyWeekDays).map((day, index) => this.busyWeekDays[index]);
@ -83,8 +88,8 @@ export class CreateCalendarRuleComponent {
busyWeekDays: this.selectedDaysIndices, busyWeekDays: this.selectedDaysIndices,
busyFromHour: this.busyFromHour, busyFromHour: this.busyFromHour,
busyToHour: this.busyToHour, busyToHour: this.busyToHour,
availableFromDate: this.availableFromDate, availableFromDate: this.availableFromDate ? this.convertDateToLocalISO(this.availableFromDate) : null,
availableToDate: this.availableToDate, availableToDate: this.availableToDate ? this.convertDateToLocalISO(this.availableToDate) : null,
isRemoteAvailable: this.isRemoteAvailable, isRemoteAvailable: this.isRemoteAvailable,
availableReason: this.availableReason availableReason: this.availableReason
}; };
@ -93,7 +98,7 @@ export class CreateCalendarRuleComponent {
this.http.put(`${this.baseUrl}${this.ruleId}`, formData) this.http.put(`${this.baseUrl}${this.ruleId}`, formData)
.subscribe({ .subscribe({
next: (response) => { next: (response) => {
this.toastService.success('Calendar updated successfully'); this.toastService.success('Calendar rule updated successfully');
this.dialogRef.close(true); this.dialogRef.close(true);
}, },
error: (error) => { error: (error) => {
@ -105,7 +110,7 @@ export class CreateCalendarRuleComponent {
this.http.post(`${this.baseUrl}/remote-calendar-rules`, formData) this.http.post(`${this.baseUrl}/remote-calendar-rules`, formData)
.subscribe({ .subscribe({
next: (response) => { next: (response) => {
this.toastService.success('Calendar created successfully'); this.toastService.success('Calendar rule created successfully');
this.dialogRef.close(true); this.dialogRef.close(true);
}, },
error: (error) => { error: (error) => {

View File

@ -25,7 +25,7 @@
.time-fields { .time-fields {
display: flex; display: flex;
gap: 15px; /* Espacio entre los campos */ gap: 15px;
} }
.time-field { .time-field {
@ -34,24 +34,25 @@
.list-item-content { .list-item-content {
display: flex; display: flex;
align-items: flex-start; /* Alinea el contenido al inicio */ align-items: flex-start;
justify-content: space-between; /* Espacio entre los textos y los íconos */ justify-content: space-between;
width: 100%; /* Asegúrate de que el contenido ocupe todo el ancho */ width: 100%;
} }
.text-content { .text-content {
flex-grow: 1; /* Permite que este contenedor ocupe el espacio disponible */ flex-grow: 1;
margin-right: 16px; /* Espaciado a la derecha para separar de los íconos */ margin-right: 16px;
margin-left: 10px; margin-left: 10px;
margin-bottom: 16px;
} }
.icon-container { .icon-container {
display: flex; display: flex;
align-items: center; /* Alinea los íconos verticalmente */ align-items: center;
} }
.right-icon { .right-icon {
margin-left: 8px; /* Espaciado entre los íconos */ margin-left: 8px;
cursor: pointer; cursor: pointer;
} }
@ -61,3 +62,14 @@
gap: 1em; gap: 1em;
padding: 1.5em; padding: 1.5em;
} }
.rule-available {
background-color: #e8f5e9;
border-left: 4px solid #4caf50;
}
.rule-unavailable {
background-color: #ffebee;
border-left: 4px solid #f44336;
}

View File

@ -18,14 +18,20 @@
<mat-list *ngIf="isEditMode"> <mat-list *ngIf="isEditMode">
<ng-container *ngFor="let rule of remoteCalendarRules;"> <ng-container *ngFor="let rule of remoteCalendarRules;">
<mat-list-item> <mat-list-item
[ngClass]="{
'rule-available': rule.isRemoteAvailable,
'rule-unavailable': !rule.isRemoteAvailable
}"
>
<div class="list-item-content"> <div class="list-item-content">
<mat-icon matListItemIcon>event_available</mat-icon> <mat-icon matListItemIcon>event_available</mat-icon>
<div class="text-content"> <div class="text-content">
<div matListItemTitle>{{ rule.isRemoteAvailable ? ('statusAvailable' | translate) : ('statusUnavailable' | translate) }}</div> <div matListItemTitle>{{ rule.isRemoteAvailable ? ('remotePcStatusAvailable' | translate) : ('remotePcStatusUnavailable' | translate) }}</div>
<div matListItemLine *ngIf="!rule.isRemoteAvailable">{{ rule.busyFromHour }} - {{ rule.busyToHour }}</div> <div matListItemLine *ngIf="!rule.isRemoteAvailable">Días: <strong>{{ rule.busyWeekDaysMap }}</strong></div>
<div matListItemLine *ngIf="!rule.isRemoteAvailable">{{ rule.busyWeekDaysMap }}</div> <div matListItemLine *ngIf="rule.isRemoteAvailable">Razón: {{ rule.availableReason }}</div>
<div matListItemLine *ngIf="rule.isRemoteAvailable">{{ rule.availableReason }} | {{ rule.availableFromDate | date }} - {{ rule.availableToDate | date }}</div> <div matListItemLine *ngIf="rule.isRemoteAvailable">Días: <strong>{{ rule.availableFromDate | date }} - {{ rule.availableToDate | date }}</strong></div>
<div matListItemLine>Horario: {{ rule.busyFromHour }} - {{ rule.busyToHour }}</div>
</div> </div>
<div class="icon-container"> <div class="icon-container">
<button mat-icon-button color="primary" class="right-icon" (click)="createRule(rule)"> <button mat-icon-button color="primary" class="right-icon" (click)="createRule(rule)">

View File

@ -33,15 +33,6 @@
<ng-container *ngIf="column.columnDef !== 'commands'"> <ng-container *ngIf="column.columnDef !== 'commands'">
{{ column.cell(commandGroup) }} {{ column.cell(commandGroup) }}
</ng-container> </ng-container>
<ng-container *ngIf="column.columnDef === 'commands'" joyrideStep="viewCommandsStep" text="{{ 'viewCommandsStepText' | translate }}">
<button class="action-button" [matMenuTriggerFor]="menu">{{ 'viewCommands' | translate }}</button>
<mat-menu #menu="matMenu">
<button mat-menu-item *ngFor="let command of commandGroup.commands">
{{ command.name }}
</button>
</mat-menu>
</ng-container>
</td> </td>
</ng-container> </ng-container>

View File

@ -1,3 +1,5 @@
<app-loading [isLoading]="loading"></app-loading>
<div class="header-container"> <div class="header-container">
<button mat-icon-button color="primary" (click)="iniciarTour()"> <button mat-icon-button color="primary" (click)="iniciarTour()">
<mat-icon>help</mat-icon> <mat-icon>help</mat-icon>
@ -23,36 +25,28 @@
<div *ngIf="!loading"> <div *ngIf="!loading">
<table mat-table [dataSource]="tasks" class="mat-elevation-z8" joyrideStep="tableStep" text="{{ 'tableStepText' | translate }}"> <table mat-table [dataSource]="tasks" class="mat-elevation-z8" joyrideStep="tableStep" text="{{ 'tableStepText' | translate }}">
<ng-container matColumnDef="taskid"> <ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ 'idColumn' | translate }} </th> <th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let task"> {{ task.id }} </td> <td mat-cell *matCellDef="let task">
</ng-container> <ng-container *ngIf="column.columnDef !== 'management'">
{{ column.cell(task) }}
</ng-container>
<ng-container matColumnDef="notes"> <ng-container *ngIf="column.columnDef === 'management'">
<th mat-header-cell *matHeaderCellDef> {{ 'infoColumn' | translate }} </th> <button class="action-button" (click)="openShowScheduleDialog(task)"> Programaciones</button>
<td mat-cell *matCellDef="let task"> {{ task.notes }} </td> <button class="action-button" style="margin-left: 0.5vw;" (click)="openShowScriptDialog(task)">Acciones</button>
</ng-container> </ng-container>
</td>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> {{ 'createdByColumn' | translate }} </th>
<td mat-cell *matCellDef="let task"> {{ task.createdBy }} </td>
</ng-container>
<ng-container matColumnDef="scheduledDate">
<th mat-header-cell *matHeaderCellDef> {{ 'executionDateColumn' | translate }} </th>
<td mat-cell *matCellDef="let task"> {{ task.dateTime | date:'short' }} </td>
</ng-container>
<ng-container matColumnDef="enabled">
<th mat-header-cell *matHeaderCellDef> {{ 'statusColumn' | translate }} </th>
<td mat-cell *matCellDef="let task"> {{ task.enabled ? ('enabled' | translate) : ('disabled' | translate) }} </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef style="text-align: center;">{{ 'columnActions' | translate }}</th> <th mat-header-cell *matHeaderCellDef style="text-align: center;">{{ 'columnActions' | translate }}</th>
<td mat-cell *matCellDef="let task" style="text-align: center;" joyrideStep="actionsStep" text="{{ 'actionsStepText' | translate }}"> <td mat-cell *matCellDef="let task" style="text-align: center;" joyrideStep="actionsStep" text="{{ 'actionsStepText' | translate }}">
<button mat-icon-button color="info" (click)="viewTaskDetails(task)"> <button mat-icon-button color="primary" (click)="manageScheduleAction(task)">
<mat-icon>visibility</mat-icon> <mat-icon>watch</mat-icon>
</button>
<button mat-icon-button color="primary" (click)="manageScriptAction(task)">
<mat-icon>code-blocks</mat-icon>
</button> </button>
<button mat-icon-button color="primary" (click)="editTask(task)"> <button mat-icon-button color="primary" (click)="editTask(task)">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>

View File

@ -7,6 +7,13 @@ import { DetailTaskComponent } from './detail-task/detail-task.component';
import { DeleteModalComponent } from '../../../shared/delete_modal/delete-modal/delete-modal.component'; import { DeleteModalComponent } from '../../../shared/delete_modal/delete-modal/delete-modal.component';
import { JoyrideService } from 'ngx-joyride'; import { JoyrideService } from 'ngx-joyride';
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import {CreateTaskScheduleComponent} from "./create-task-schedule/create-task-schedule.component";
import {ShowClientsComponent} from "../../ogdhcp/show-clients/show-clients.component";
import {Subnet} from "../../ogdhcp/og-dhcp-subnets.component";
import {ShowTaskScheduleComponent} from "./show-task-schedule/show-task-schedule.component";
import {ShowTaskScriptComponent} from "./show-task-script/show-task-script.component";
import {CreateTaskScriptComponent} from "./create-task-script/create-task-script.component";
import {DatePipe} from "@angular/common";
@Component({ @Component({
selector: 'app-commands-task', selector: 'app-commands-task',
@ -21,7 +28,18 @@ export class CommandsTaskComponent implements OnInit {
itemsPerPage: number = 10; itemsPerPage: number = 10;
page: number = 1; page: number = 1;
pageSizeOptions: number[] = [5, 10, 20, 40, 100]; pageSizeOptions: number[] = [5, 10, 20, 40, 100];
displayedColumns: string[] = ['taskid', 'notes', 'name', 'scheduledDate', 'enabled', 'actions']; datePipe: DatePipe = new DatePipe('es-ES');
columns = [
{ columnDef: 'id', header: 'ID', cell: (task: any) => task.id },
{ columnDef: 'name', header: 'Nombre de tarea', cell: (task: any) => task.name },
{ columnDef: 'organizationalUnit', header: 'Ámbito', cell: (task: any) => task.organizationalUnit.name },
{ columnDef: 'management', header: 'Gestiones', cell: (task: any) => task.schedules },
{ columnDef: 'nextExecution', header: 'Próxima ejecución', cell: (task: any) => this.datePipe.transform(task.nextExecution, 'dd/MM/yyyy HH:mm:ss', 'UTC') },
{ columnDef: 'createdBy', header: 'Creado por', cell: (task: any) => task.createdBy },
];
displayedColumns: string[] = ['id', 'name', 'organizationalUnit', 'management', 'nextExecution', 'createdBy', 'actions'];
loading: boolean = false; loading: boolean = false;
private apiUrl: string; private apiUrl: string;
@ -56,24 +74,25 @@ export class CommandsTaskComponent implements OnInit {
); );
} }
viewTaskDetails(task: any): void {
this.dialog.open(DetailTaskComponent, {
width: '800px',
data: { task },
}).afterClosed().subscribe(() => this.loadTasks());
}
openCreateTaskModal(): void { openCreateTaskModal(): void {
this.dialog.open(CreateTaskComponent, { this.dialog.open(CreateTaskComponent, {
width: '800px', width: '800px',
}).afterClosed().subscribe(() => this.loadTasks()); }).afterClosed().subscribe(result => {
if (result) {
this.loadTasks();
}
})
} }
editTask(task: any): void { editTask(task: any): void {
this.dialog.open(CreateTaskComponent, { this.dialog.open(CreateTaskComponent, {
width: '800px', width: '800px',
data: { task }, data: { task },
}).afterClosed().subscribe(() => this.loadTasks()); }).afterClosed().subscribe(result => {
if (result) {
this.loadTasks();
}
})
} }
deleteTask(task: any): void { deleteTask(task: any): void {
@ -95,12 +114,66 @@ export class CommandsTaskComponent implements OnInit {
}); });
} }
manageScheduleAction(task: any): void {
this.dialog.open(CreateTaskScheduleComponent, {
width: '800px',
data: { task },
}).afterClosed().subscribe( result => {
if (result) {
this.loadTasks();
}
})
}
manageScriptAction(task: any): void {
this.dialog.open(CreateTaskScriptComponent, {
width: '900px',
data: { task },
}).afterClosed().subscribe( result => {
if (result) {
this.loadTasks();
}
})
}
onPageChange(event: any): void { onPageChange(event: any): void {
this.page = event.pageIndex + 1; this.page = event.pageIndex + 1;
this.itemsPerPage = event.pageSize; this.itemsPerPage = event.pageSize;
this.loadTasks(); this.loadTasks();
} }
openShowScheduleDialog(commandTask: any) {
const dialogRef = this.dialog.open(ShowTaskScheduleComponent, {
width: '85vw',
height: '85vh',
maxWidth: '85vw',
maxHeight: '85vh',
data: { commandTask: commandTask }
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.loadTasks();
}
});
}
openShowScriptDialog(commandTask: any) {
const dialogRef = this.dialog.open(ShowTaskScriptComponent, {
width: '85vw',
height: '85vh',
maxWidth: '85vw',
maxHeight: '85vh',
data: { commandTask: commandTask }
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.loadTasks();
}
});
}
iniciarTour(): void { iniciarTour(): void {
this.joyrideService.startTour({ this.joyrideService.startTour({
steps: [ steps: [

View File

@ -0,0 +1,128 @@
.dialog-title {
font-weight: bold;
}
.task-form {
padding: 20px;
}
.full-width {
width: 100%;
}
.custom-time {
display: flex;
gap: 15px;
}
.w-half {
width: 50%;
}
mat-form-field {
margin-bottom: 16px;
}
form {
display: flex;
flex-direction: column;
gap: 16px;
margin: auto;
}
mat-form-field {
width: 100%;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
.weekday-toggle-group {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 16px;
}
.weekday-toggle {
flex: 1;
padding: 8px 0;
border: 1px solid #ccc;
border-radius: 4px;
background: #f5f5f5;
cursor: pointer;
font-weight: 500;
transition: background 0.2s ease;
}
.weekday-toggle.selected {
background: #1976d2;
color: white;
border-color: #1976d2;
}
.month-toggle-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.month-toggle-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
justify-content: space-between;
}
.month-toggle {
flex: 1;
padding: 8px;
min-width: 48px;
border: 1px solid #ccc;
border-radius: 6px;
background-color: #f0f0f0;
text-align: center;
cursor: pointer;
transition: background-color 0.2s ease;
}
.month-toggle.selected {
background-color: #4caf50;
color: white;
border-color: #4caf50;
}
.summary-card {
background: #f9fafb;
border-left: 5px solid #3f51b5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border-radius: 12px;
padding: 16px;
transition: box-shadow 0.3s ease;
}
.summary-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.summary-card {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 12px 16px;
margin-top: 16px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.summary-text {
color: #0d47a1;
line-height: 1.4;
}

View File

@ -0,0 +1,87 @@
<h2 mat-dialog-title class="dialog-title">Programar accion</h2>
<mat-dialog-content class="dialog-content">
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="task-form">
<mat-form-field appearance="fill" class="w-full">
<mat-label>Repetición</mat-label>
<mat-select formControlName="recurrenceType">
<mat-option *ngFor="let type of recurrenceTypes" [value]="type">{{ type | titlecase }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="w-full" *ngIf="form.get('recurrenceType')?.value === 'none'">
<mat-label>Fecha de ejecución</mat-label>
<input matInput [matDatepicker]="oneTimePicker" formControlName="executionDate">
<mat-datepicker-toggle matSuffix [for]="oneTimePicker"></mat-datepicker-toggle>
<mat-datepicker #oneTimePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill" class="w-full">
<mat-label>Hora</mat-label>
<input matInput formControlName="executionTime" placeholder="08:00" type="time">
</mat-form-field>
<!-- Mostrar solo si no es 'none' -->
<div *ngIf="form.get('recurrenceType')?.value !== 'none'" class="mb-4">
<label>Días de la semana:</label>
<div class="weekday-toggle-group">
<button
*ngFor="let day of weekDays"
type="button"
class="weekday-toggle"
[class.selected]="selectedDays[day]"
(click)="toggleDay(day)">
{{ day.slice(0, 3) }}
</button>
</div>
</div>
<!-- Selección de meses -->
<div *ngIf="form.get('recurrenceType')?.value !== 'none'" >
<label>Meses:</label>
<div class="month-toggle-row" *ngFor="let row of monthRows">
<button
*ngFor="let month of row"
type="button"
class="month-toggle"
[class.selected]="selectedMonths[month]"
(click)="toggleMonth(month)">
{{ month.slice(0, 3) }}
</button>
</div>
</div>
<!-- Rango de fechas -->
<div *ngIf="form.get('recurrenceType')?.value !== 'none'" class="custom-time" formGroupName="recurrenceDetails">
<mat-form-field appearance="fill" class="w-half">
<mat-label>Desde</mat-label>
<input matInput [matDatepicker]="fromPicker" formControlName="initDate">
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
<mat-datepicker #fromPicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill" class="w-half">
<mat-label>Hasta</mat-label>
<input matInput [matDatepicker]="toPicker" formControlName="endDate">
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
<mat-datepicker #toPicker></mat-datepicker>
</mat-form-field>
</div>
<mat-checkbox formControlName="enabled">Activar tarea</mat-checkbox>
<mat-card *ngIf="summaryText" class="summary-card">
<mat-icon color="primary" style="width: 50px;">info</mat-icon>
<span class="summary-text">
{{ summaryText }}
</span>
</mat-card>
</form>
</mat-dialog-content>
<mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="onCancel()">{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" (click)="onSubmit()" >{{ 'buttonSave' | translate }}</button>
</mat-dialog-actions>

View File

@ -0,0 +1,100 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateTaskScheduleComponent } from './create-task-schedule.component';
import {LoadingComponent} from "../../../../shared/loading/loading.component";
import {HttpClientTestingModule} from "@angular/common/http/testing";
import {ToastrModule} from "ngx-toastr";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {MatDividerModule} from "@angular/material/divider";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInputModule} from "@angular/material/input";
import {MatIconModule} from "@angular/material/icon";
import {MatButtonModule} from "@angular/material/button";
import {MatTableModule} from "@angular/material/table";
import {MatPaginatorModule} from "@angular/material/paginator";
import {MatTooltipModule} from "@angular/material/tooltip";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from "@angular/material/dialog";
import {MatSelectModule} from "@angular/material/select";
import {MatTabsModule} from "@angular/material/tabs";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import {MatListModule} from "@angular/material/list";
import {MatCardModule} from "@angular/material/card";
import {MatMenuModule} from "@angular/material/menu";
import {MatTreeModule} from "@angular/material/tree";
import {TranslateModule, TranslateService} from "@ngx-translate/core";
import {JoyrideModule} from "ngx-joyride";
import {ConfigService} from "@services/config.service";
import {ActivatedRoute} from "@angular/router";
import {MatDatepickerModule} from "@angular/material/datepicker";
import {MatButtonToggleModule} from "@angular/material/button-toggle";
import {
DateAdapter,
MAT_DATE_FORMATS,
MAT_NATIVE_DATE_FORMATS,
MatNativeDateModule,
provideNativeDateAdapter
} from "@angular/material/core";
import {MatCheckboxModule} from "@angular/material/checkbox";
describe('CreateTaskScheduleComponent', () => {
let component: CreateTaskScheduleComponent;
let fixture: ComponentFixture<CreateTaskScheduleComponent>;
beforeEach(async () => {
const mockConfigService = {
apiUrl: 'http://mock-api-url',
mercureUrl: 'http://mock-mercure-url'
};
await TestBed.configureTestingModule({
declarations: [CreateTaskScheduleComponent, LoadingComponent],
imports: [
HttpClientTestingModule,
ToastrModule.forRoot(),
BrowserAnimationsModule,
MatDividerModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatTooltipModule,
FormsModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
MatDialogModule,
MatSelectModule,
MatTabsModule,
MatAutocompleteModule,
MatListModule,
MatCardModule,
MatMenuModule,
MatTreeModule,
MatDatepickerModule,
MatButtonToggleModule,
MatNativeDateModule,
MatCheckboxModule,
TranslateModule.forRoot(),
JoyrideModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: {} },
{ provide: MAT_DIALOG_DATA, useValue: { data: { id: 123 } } },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: ActivatedRoute, useValue: { queryParams: { subscribe: () => {} } } },
{ provide: MAT_DATE_FORMATS, useValue: MAT_NATIVE_DATE_FORMATS },
]
}).compileComponents();
fixture = TestBed.createComponent(CreateTaskScheduleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,203 @@
import {Component, Inject, OnInit} from '@angular/core';
import {FormBuilder, FormGroup} from "@angular/forms";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {HttpClient} from "@angular/common/http";
import {ToastrService} from "ngx-toastr";
import {ConfigService} from "@services/config.service";
@Component({
selector: 'app-create-task-schedule',
templateUrl: './create-task-schedule.component.html',
styleUrl: './create-task-schedule.component.css'
})
export class CreateTaskScheduleComponent implements OnInit{
form: FormGroup;
baseUrl: string;
apiUrl: string;
recurrenceTypes = ['none', 'custom'];
weekDays: string[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
isSingleDateSelected: boolean = true;
monthsList: string[] = [
'january', 'february', 'march', 'april', 'may', 'june',
'july', 'august', 'september', 'october', 'november', 'december'
];
monthRows: string[][] = [];
editing: boolean = false;
selectedMonths: { [key: string]: boolean } = {};
selectedDays: { [key: string]: boolean } = {};
constructor(
private fb: FormBuilder,
public dialogRef: MatDialogRef<CreateTaskScheduleComponent>,
private http: HttpClient,
private toastr: ToastrService,
private configService: ConfigService,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
this.apiUrl = `${this.baseUrl}/command-task-schedules`;
this.form = this.fb.group({
executionDate: [new Date()],
executionTime: ['08:00'],
recurrenceType: ['none'],
recurrenceDetails: this.fb.group({
daysOfWeek: [[]],
months: this.fb.control([]),
initDate: [null],
endDate: [null]
}),
enabled: [true]
});
if (this.data.schedule) {
this.editing = true;
this.loadData();
}
this.form.get('recurrenceType')?.valueChanges.subscribe((value) => {
if (value === 'none') {
this.form.get('recurrenceDetails')?.disable();
} else {
this.form.get('recurrenceDetails')?.enable();
}
});
}
ngOnInit(): void {
this.monthRows = [
this.monthsList.slice(0, 6),
this.monthsList.slice(6, 12)
];
}
loadData(): void {
this.http.get<any>(`${this.baseUrl}${this.data.schedule['@id']}`).subscribe(
(data) => {
const formattedExecutionTime = this.formatExecutionTime(data.executionTime);
this.form.patchValue({
executionDate: data.executionDate,
executionTime: formattedExecutionTime,
recurrenceType: data.recurrenceType,
recurrenceDetails: {
...data.recurrenceDetails,
initDate: data.recurrenceDetails.initDate || null,
endDate: data.recurrenceDetails.endDate || null,
daysOfWeek: data.recurrenceDetails.daysOfWeek || [],
months: data.recurrenceDetails.months || []
},
enabled: data.enabled
});
this.selectedDays = data.recurrenceDetails.daysOfWeek.reduce((acc: any, day: string) => {
acc[day] = true;
return acc;
}, {});
this.selectedMonths = data.recurrenceDetails.months.reduce((acc: any, month: string) => {
acc[month] = true;
return acc;
}, {});
},
(error) => {
console.error('Error loading schedule data', error);
}
);
}
formatExecutionTime(time: string | Date): string {
const date = (time instanceof Date) ? time : new Date(time);
if (isNaN(date.getTime())) {
console.error('Invalid execution time:', time);
return '';
}
return date.toISOString().substring(11, 16);
}
convertDateToLocalISO(date: Date): string {
date = new Date(date);
const adjustedDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
return adjustedDate.toISOString();
}
onSubmit() {
const formData = this.form.value;
const payload: any = {
commandTask: this.data.task['@id'],
executionDate: formData.recurrenceType === 'none' ? this.convertDateToLocalISO(formData.executionDate) : null,
executionTime: formData.executionTime,
recurrenceType: formData.recurrenceType,
recurrenceDetails: {
...formData.recurrenceDetails,
initDate: formData.recurrenceDetails?.initDate || null,
endDate: formData.recurrenceDetails?.endDate || null,
daysOfWeek: formData.recurrenceDetails?.daysOfWeek || [],
months: formData.recurrenceDetails?.months || []
},
enabled: formData.enabled
}
if (this.editing) {
const taskId = this.data.task.uuid;
this.http.patch<any>(`${this.baseUrl}${this.data.schedule['@id']}`, payload).subscribe({
next: () => {
this.toastr.success('Programacion de tarea actualizada con éxito');
this.dialogRef.close(true);
},
error: () => {
this.toastr.error('Error al actualizar la tarea');
}
});
} else {
this.http.post<any>(this.apiUrl, payload).subscribe({
next: () => {
this.toastr.success('Programacion de tarea creada con éxito');
this.dialogRef.close(true);
},
error: () => {
this.toastr.error('Error al crear la tarea');
}
});
}
}
onCancel(): void {
this.dialogRef.close(false);
}
get summaryText(): string {
const recurrence = this.form.get('recurrenceType')?.value;
const start = this.form.get('recurrenceType')?.value === 'none' ? this.form.get('executionDate')?.value : this.form.get('recurrenceDetails.initDate')?.value;
const end = this.form.get('recurrenceType')?.value === 'none' ? this.form.get('executionDate')?.value : this.form.get('recurrenceDetails.endDate')?.value;
const time = this.form.get('executionTime')?.value;
const days = Object.keys(this.selectedDays).filter(day => this.selectedDays[day]);
const months = Object.keys(this.selectedMonths).filter(month => this.selectedMonths[month]);
if (recurrence === 'none') {
return `Esta acción se ejecutará una sola vez el ${ this.formatDate(start)} a las ${time}.`;
}
return `Esta acción se ejecutará todos los ${days.join(', ')} de ${months.join(', ')}, desde el ${this.formatDate(start)} hasta el ${this.formatDate(end)} a las ${time}.`;
}
formatDate(date: string | Date): string {
const realDate = (date instanceof Date) ? date : new Date(date);
return new Intl.DateTimeFormat('es-ES', { dateStyle: 'long' }).format(realDate);
}
toggleDay(day: string) {
this.selectedDays[day] = !this.selectedDays[day];
const days = Object.keys(this.selectedDays).filter(d => this.selectedDays[d]);
this.form.get('recurrenceDetails.daysOfWeek')?.setValue(days);
}
toggleMonth(month: string) {
this.selectedMonths[month] = !this.selectedMonths[month];
const months = Object.keys(this.selectedMonths).filter(m => this.selectedMonths[m]);
this.form.get('recurrenceDetails.months')?.setValue(months);
}
}

View File

@ -0,0 +1,284 @@
.divider {
margin: 20px 0;
}
table {
width: 100%;
margin-top: 50px;
}
.task-form {
padding: 20px;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.deploy-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
gap: 10px;
}
.script-container {
gap: 20px;
padding: 20px;
background-color: #eaeff6;
border-radius: 12px;
margin-top: 20px;
}
.script-content {
flex: 2;
min-width: 60%;
}
.script-params {
flex: 1;
min-width: 35%;
}
@media (max-width: 768px) {
.script-container {
flex-direction: column;
}
.script-content, .script-params {
min-width: 100%;
}
}
.select-container {
margin-top: 20px;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.input-group {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 20px;
}
.input-field {
flex: 1 1 calc(33.33% - 16px);
min-width: 250px;
}
.script-preview {
background-color: #f4f4f4;
border: 1px solid #ccc;
padding: 10px;
border-radius: 5px;
font-family: monospace;
white-space: pre-wrap;
min-height: 50px;
}
.custom-width {
width: 50%;
margin-bottom: 16px;
}
.search-string {
flex: 2;
padding: 5px;
}
.search-boolean {
flex: 1;
padding: 5px;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.client-item {
position: relative;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
.client-details {
margin-top: 4px;
}
.client-name {
font-size: 0.9em;
font-weight: 600;
color: #333;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
}
.client-ip {
display: block;
font-size: 0.9em;
color: #666;
}
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}
.button-row {
display: flex;
padding-right: 1em;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
::ng-deep .custom-tooltip {
white-space: pre-line !important;
max-width: 200px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px;
border-radius: 4px;
}
.selected-client {
background-color: #a0c2e5 !important;
color: white !important;
}
.button-row {
display: flex;
padding-right: 1em;
}
.disabled-client {
pointer-events: none;
opacity: 0.5;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.new-command-container {
display: flex;
flex-direction: column;
gap: 10px;
padding: 15px;
background-color: #eaeff6;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1);
margin-top: 15px;
}
.new-command-container mat-form-field {
width: 100%;
}
.new-command-container textarea {
font-family: monospace;
resize: vertical;
}
.new-command-container .action-button {
align-self: flex-end;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.full-width {
width: 100%;
}
.script-selector-card {
margin: 20px 20px;
padding: 16px;
}
.toggle-options {
display: flex;
justify-content: start;
margin: 16px 0;
}

View File

@ -0,0 +1,65 @@
<h2 mat-dialog-title class="dialog-title">Añadir acción a: {{ data.task?.name }}</h2>
<mat-dialog-content class="dialog-content">
<div class="task-form">
<div class="toggle-options">
<mat-button-toggle-group [(ngModel)]="commandType" exclusive>
<mat-button-toggle value="new">
<mat-icon>edit</mat-icon> Nuevo Script
</mat-button-toggle>
<mat-button-toggle value="existing">
<mat-icon>storage</mat-icon> Script Guardado
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<div *ngIf="commandType === 'new'" class="new-command-container">
<mat-form-field appearance="fill" class="custom-width">
<mat-label>Orden de ejecucion </mat-label>
<input matInput type="number" [(ngModel)]="executionOrder" placeholder="Orden de ejecución">
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>Ingrese el script</mat-label>
<textarea matInput [(ngModel)]="newScript" rows="6" placeholder="Escriba su script aquí"></textarea>
</mat-form-field>
<button mat-flat-button color="primary" (click)="saveNewScript()">Guardar Script</button>
</div>
<div *ngIf="commandType === 'existing'">
<mat-form-field appearance="fill" class="custom-width">
<mat-label>Seleccione script a ejecutar</mat-label>
<mat-select [(ngModel)]="selectedScript" (selectionChange)="onScriptChange()">
<mat-option *ngFor="let script of scripts" [value]="script">{{ script.name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="selectedScript && commandType === 'existing'" class="script-container">
<mat-form-field appearance="fill" class="custom-width">
<mat-label>Orden de ejecucion </mat-label>
<input matInput type="number" [(ngModel)]="executionOrder" placeholder="Orden de ejecución">
</mat-form-field>
<div class="script-content">
<h3>Script:</h3>
<div class="script-preview" [innerHTML]="scriptContent"></div>
</div>
<div class="script-params" *ngIf="parameterNames.length > 0">
<h3>Ingrese los parámetros:</h3>
<div *ngFor="let paramName of parameterNames">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ paramName }}</mat-label>
<input matInput [ngModel]="parameters[paramName]" (ngModelChange)="onParamChange(paramName, $event)" placeholder="Valor para {{ paramName }}">
</mat-form-field>
</div>
</div>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="onCancel()">{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" (click)="onSubmit()" >{{ 'buttonSave' | translate }}</button>
</mat-dialog-actions>

View File

@ -0,0 +1,89 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CreateTaskScriptComponent } from './create-task-script.component';
import {GroupsComponent} from "../../../groups/groups.component";
import {ExecuteCommandComponent} from "../../main-commands/execute-command/execute-command.component";
import {LoadingComponent} from "../../../../shared/loading/loading.component";
import {HttpClientTestingModule} from "@angular/common/http/testing";
import {ToastrModule} from "ngx-toastr";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {MatDividerModule} from "@angular/material/divider";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInputModule} from "@angular/material/input";
import {MatIconModule} from "@angular/material/icon";
import {MatButtonModule} from "@angular/material/button";
import {MatTableModule} from "@angular/material/table";
import {MatPaginatorModule} from "@angular/material/paginator";
import {MatTooltipModule} from "@angular/material/tooltip";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from "@angular/material/dialog";
import {MatSelectModule} from "@angular/material/select";
import {MatTabsModule} from "@angular/material/tabs";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import {MatListModule} from "@angular/material/list";
import {MatCardModule} from "@angular/material/card";
import {MatMenuModule} from "@angular/material/menu";
import {MatTreeModule} from "@angular/material/tree";
import {TranslateModule, TranslateService} from "@ngx-translate/core";
import {JoyrideModule} from "ngx-joyride";
import {ConfigService} from "@services/config.service";
import {ActivatedRoute} from "@angular/router";
import {MatButtonToggleModule} from "@angular/material/button-toggle";
describe('CreateTaskScriptComponent', () => {
let component: CreateTaskScriptComponent;
let fixture: ComponentFixture<CreateTaskScriptComponent>;
beforeEach(async () => {
const mockConfigService = {
apiUrl: 'http://mock-api-url',
mercureUrl: 'http://mock-mercure-url'
};
await TestBed.configureTestingModule({
declarations: [CreateTaskScriptComponent, LoadingComponent],
imports: [
HttpClientTestingModule,
ToastrModule.forRoot(),
BrowserAnimationsModule,
MatDividerModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatTooltipModule,
FormsModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
MatDialogModule,
MatSelectModule,
MatTabsModule,
MatAutocompleteModule,
MatListModule,
MatCardModule,
MatMenuModule,
MatButtonToggleModule,
MatTreeModule,
TranslateModule.forRoot(),
JoyrideModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: {} },
{ provide: MAT_DIALOG_DATA, useValue: { data: { id: 123 } } },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: ActivatedRoute, useValue: { queryParams: { subscribe: () => {} } } },
]
}).compileComponents();
fixture = TestBed.createComponent(CreateTaskScriptComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,138 @@
import {Component, EventEmitter, Inject, OnInit, Output} from '@angular/core';
import {SelectionModel} from "@angular/cdk/collections";
import {HttpClient} from "@angular/common/http";
import {ToastrService} from "ngx-toastr";
import {ConfigService} from "@services/config.service";
import {ActivatedRoute, Router} from "@angular/router";
import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from "@angular/material/dialog";
import {
SaveScriptComponent
} from "../../../groups/components/client-main-view/run-script-assistant/save-script/save-script.component";
import {FormBuilder, FormGroup} from "@angular/forms";
@Component({
selector: 'app-create-task-script',
templateUrl: './create-task-script.component.html',
styleUrl: './create-task-script.component.css'
})
export class CreateTaskScriptComponent implements OnInit {
form: FormGroup;
baseUrl: string;
@Output() dataChange = new EventEmitter<any>();
errorMessage = '';
loading: boolean = false;
scripts: any[] = [];
scriptContent: string = "";
parameters: any = {};
commandType: string = 'existing';
selectedScript: any = null;
newScript: string = '';
executionOrder: Number = 0;
selection = new SelectionModel(true, []);
parameterNames: string[] = Object.keys(this.parameters);
constructor(
private fb: FormBuilder,
private http: HttpClient,
public dialogRef: MatDialogRef<CreateTaskScriptComponent>,
private toastService: ToastrService,
private configService: ConfigService,
private router: Router,
private dialog: MatDialog,
private route: ActivatedRoute,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
this.loadScripts()
this.form = this.fb.group({
content: [''],
order: [''],
})
}
ngOnInit(): void {
}
loadScripts(): void {
this.loading = true;
this.http.get(`${this.baseUrl}/commands?readOnly=false&enabled=true`).subscribe((data: any) => {
this.scripts = data['hydra:member'];
this.loading = false;
}, (error) => {
this.toastService.error(error.error['hydra:description']);
this.loading = false;
});
}
saveNewScript() {
if (!this.newScript.trim()) {
this.toastService.error('Debe ingresar un script antes de guardar.');
return;
}
const dialogRef = this.dialog.open(SaveScriptComponent, {
width: '400px',
data: this.newScript
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.toastService.success('Script guardado correctamente');
}
});
}
onScriptChange() {
if (this.selectedScript) {
this.scriptContent = this.selectedScript.script;
const matches = this.scriptContent.match(/@(\w+)/g) || [];
const uniqueParams = Array.from(new Set(matches.map(m => m.slice(1))));
this.parameters = {};
uniqueParams.forEach(param => this.parameters[param] = '');
this.parameterNames = uniqueParams;
this.updateScript();
}
}
onParamChange(name: string, value: string): void {
this.parameters[name] = value;
this.updateScript();
}
updateScript(): void {
let updatedScript = this.selectedScript.script;
for (const [key, value] of Object.entries(this.parameters)) {
const regex = new RegExp(`@${key}\\b`, 'g');
updatedScript = updatedScript.replace(regex, value || `@${key}`);
}
this.scriptContent = updatedScript;
}
onSubmit() {
this.http.post(`${this.baseUrl}/command-task-scripts`, {
commandTask: this.data.task['@id'],
content: this.commandType === 'existing' ? this.scriptContent : this.newScript,
order: this.executionOrder,
type: 'run-script',
}).subscribe({
next: () => {
this.toastService.success('Tarea creada con éxito');
this.dialogRef.close(true);
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
})
}
onCancel(): void {
this.dialogRef.close(false);
}
}

View File

@ -6,23 +6,60 @@
padding: 20px; padding: 20px;
} }
.select-task {
padding: 20px;
margin-bottom: 16px;
}
.full-width { .full-width {
width: 100%; width: 100%;
} }
.button-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
padding: 1.5rem;
}
mat-form-field { mat-form-field {
margin-bottom: 16px; margin-bottom: 16px;
} }
.loading-spinner {
display: block;
margin: 0 auto;
align-items: center;
justify-content: center;
}
.section-title { .section-title {
margin-top: 24px; margin-top: 24px;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 500; font-weight: 500;
} }
.summary-section {
background-color: #f9f9f9;
border-bottom: 1px solid #ddd;
margin-bottom: 10px;
}
.summary-block {
margin-top: 10px;
}
.date-time-row {
display: flex;
gap: 16px;
margin-top: 12px;
}
.half-width {
flex: 1;
min-width: 0;
}
.full-width {
width: 100%;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}

View File

@ -1,106 +1,83 @@
<h2 mat-dialog-title class="dialog-title">{{ editing ? ('editTask' | translate) : ('createTask' | translate) }}</h2> <h2 mat-dialog-title class="dialog-title">
{{ editing ? ('editTask' | translate) : ('createTask' | translate) }}
</h2>
<form [formGroup]="taskForm" class="task-form"> <mat-dialog-content class="dialog-content">
<mat-dialog-content> <mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
<h3 class="section-title">Información</h3> <!-- Toggle entre crear o añadir -->
<mat-divider></mat-divider> <mat-radio-group *ngIf="data?.source === 'assistant'" [(ngModel)]="taskMode" class="task-mode-selection" name="taskMode">
<mat-radio-button value="create">Crear tarea</mat-radio-button>
<mat-radio-button value="add">Introducir en tarea existente</mat-radio-button>
</mat-radio-group>
<!-- Selección de tarea existente -->
<div *ngIf="taskMode === 'add'" class="select-task">
<mat-form-field appearance="fill" class="full-width"> <mat-form-field appearance="fill" class="full-width">
<mat-label>Información</mat-label> <mat-label>Seleccione una tarea</mat-label>
<textarea matInput formControlName="notes" placeholder="Ingresa tus notas aquí"></textarea> <mat-select [(ngModel)]="selectedExistingTask" name="existingTask">
<mat-option *ngFor="let task of existingTasks" [value]="task">{{ task.name }}</mat-option>
</mat-select>
</mat-form-field> </mat-form-field>
<h3 class="section-title">{{ 'informationSectionTitle' | translate }}</h3>
<mat-divider></mat-divider>
<mat-form-field appearance="fill" class="full-width"> <mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'informationLabel' | translate }}</mat-label> <mat-label>Orden de ejecución</mat-label>
<input
matInput
type="number"
[(ngModel)]="executionOrder"
name="executionOrder"
min="1"
placeholder="Introduce el orden"
>
</mat-form-field>
</div>
<!-- Formulario de nueva tarea -->
<form *ngIf="taskMode === 'create' && taskForm && !loading" [formGroup]="taskForm" class="task-form">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'nameLabel' | translate }}</mat-label>
<input matInput formControlName="name" placeholder="{{ 'nameLabel' | translate }}">
<mat-error *ngIf="taskForm.get('name')?.invalid">{{ 'requiredFieldError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'notesLabel' | translate }}</mat-label>
<textarea matInput formControlName="notes" placeholder="{{ 'notesPlaceholder' | translate }}"></textarea> <textarea matInput formControlName="notes" placeholder="{{ 'notesPlaceholder' | translate }}"></textarea>
</mat-form-field> </mat-form-field>
<h3 class="section-title">{{ 'commandSelectionSectionTitle' | translate }}</h3>
<mat-divider></mat-divider>
<mat-form-field appearance="fill" class="full-width"> <mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectCommandsLabel' | translate }}</mat-label> <mat-label>Ámbito</mat-label>
<mat-select formControlName="commandGroup" (selectionChange)="onCommandGroupChange()"> <mat-select formControlName="scope" (selectionChange)="onScopeChange($event.value)">
<mat-option *ngFor="let group of availableCommandGroups" [value]="group.uuid"> <mat-option value="organizational-unit">Unidad Organizativa</mat-option>
{{ group.name }} <mat-option value="classrooms-group">Grupo de aulas</mat-option>
</mat-option> <mat-option value="classroom">Aulas</mat-option>
</mat-select> <mat-option value="clients-group">Grupos de clientes</mat-option>
<mat-error *ngIf="taskForm.get('commandGroup')?.invalid">{{ 'requiredFieldError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectIndividualCommandsLabel' | translate }}</mat-label>
<mat-select formControlName="extraCommands" multiple>
<mat-option *ngFor="let command of availableIndividualCommands" [value]="command.uuid">
{{ command.name }}
</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<h3 class="section-title">{{ 'executionDateTimeSectionTitle' | translate }}</h3>
<mat-divider></mat-divider>
<mat-form-field appearance="fill" class="full-width"> <mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'executionDateLabel' | translate }}</mat-label> <mat-label>{{ 'organizationalUnitLabel' | translate }}</mat-label>
<input matInput [matDatepicker]="picker" formControlName="date" placeholder="{{ 'selectDatePlaceholder' | translate }}"> <mat-select formControlName="organizationalUnit">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
<mat-error *ngIf="taskForm.get('date')?.invalid">{{ 'requiredFieldError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'executionTimeLabel' | translate }}</mat-label>
<input matInput type="time" formControlName="time" placeholder="{{ 'selectTimePlaceholder' | translate }}">
<mat-error *ngIf="taskForm.get('time')?.invalid">{{ 'requiredFieldError' | translate }}</mat-error>
</mat-form-field>
<h3 class="section-title">{{ 'destinationSelectionSectionTitle' | translate }}</h3>
<mat-divider></mat-divider>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectOrganizationalUnitLabel' | translate }}</mat-label>
<mat-select formControlName="organizationalUnit" (selectionChange)="onOrganizationalUnitChange()">
<mat-option *ngFor="let unit of availableOrganizationalUnits" [value]="unit['@id']"> <mat-option *ngFor="let unit of availableOrganizationalUnits" [value]="unit['@id']">
{{ unit.name }} <div class="unit-name">{{ unit.name }}</div>
</mat-option> <div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-select>
<mat-error *ngIf="taskForm.get('organizationalUnit')?.invalid">{{ 'requiredFieldError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectClassroomLabel' | translate }}</mat-label>
<mat-select formControlName="selectedChild" (selectionChange)="onChildChange()">
<mat-option *ngFor="let child of selectedUnitChildren" [value]="child['@id']">
{{ child.name }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="fill" class="full-width"> <mat-checkbox *ngIf="!editing" formControlName="scheduleAfterCreate">
<mat-label>{{ 'selectClientsLabel' | translate }}</mat-label> ¿Quieres programar la tarea al finalizar su creación?
<mat-select formControlName="selectedClients" multiple> </mat-checkbox>
<mat-option (click)="toggleSelectAll()" [selected]="areAllSelected()"> </form>
{{ 'selectAllClients' | translate }} </mat-dialog-content>
</mat-option>
<mat-option *ngFor="let client of selectedClients" [value]="client.uuid">
{{ client.name }} ({{ client.ip }})
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width"> <mat-dialog-actions class="action-container">
<mat-label>Selecciona Clientes</mat-label> <button class="ordinary-button" (click)="close()">{{ 'buttonCancel' | translate }}</button>
<mat-select formControlName="selectedClients" multiple> <button
<mat-option (click)="toggleSelectAll()" [selected]="areAllSelected()"> class="submit-button"
Seleccionar todos (click)="taskMode === 'create' ? saveTask() : addToExistingTask()"
</mat-option> >
<mat-option *ngFor="let client of selectedClients" [value]="client.uuid"> {{ 'buttonSave' | translate }}
{{ client.name }} ({{ client.ip }}) </button>
</mat-option> </mat-dialog-actions>
</mat-select>
</mat-form-field>
</mat-dialog-content>
</form>
<div class="button-container">
<button class="submit-button" (click)="saveTask()">{{ 'buttonSave' | translate }}</button>
</div>

View File

@ -1,9 +1,12 @@
import { Component, OnInit, Inject } from '@angular/core'; import { Component, OnInit, Inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from '@angular/material/dialog';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import {of} from "rxjs";
import {startWith, switchMap} from "rxjs/operators";
import {CreateTaskScheduleComponent} from "../create-task-schedule/create-task-schedule.component";
@Component({ @Component({
selector: 'app-create-task', selector: 'app-create-task',
@ -19,171 +22,185 @@ export class CreateTaskComponent implements OnInit {
apiUrl: string; apiUrl: string;
editing: boolean = false; editing: boolean = false;
availableOrganizationalUnits: any[] = []; availableOrganizationalUnits: any[] = [];
selectedUnitChildren: any[] = []; clients: any[] = [];
selectedClients: any[] = []; allOrganizationalUnits: any[] = [];
selectedClientIds: Set<string> = new Set(); loading: boolean = false;
taskMode: 'create' | 'add' = 'create';
existingTasks: any[] = [];
selectedExistingTask: string | null = null;
executionOrder: number | null = null;
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
private http: HttpClient, private http: HttpClient,
private configService: ConfigService, private configService: ConfigService,
private toastr: ToastrService, private toastr: ToastrService,
private dialog: MatDialog,
public dialogRef: MatDialogRef<CreateTaskComponent>, public dialogRef: MatDialogRef<CreateTaskComponent>,
@Inject(MAT_DIALOG_DATA) public data: any @Inject(MAT_DIALOG_DATA) public data: any
) { ) {
this.baseUrl = this.configService.apiUrl; this.baseUrl = this.configService.apiUrl;
this.apiUrl = `${this.baseUrl}/command-tasks`; this.apiUrl = `${this.baseUrl}/command-tasks`;
this.taskForm = this.fb.group({ this.taskForm = this.fb.group({
commandGroup: ['', Validators.required], scope: [ this.data?.scope ? this.data.scope : '', Validators.required],
extraCommands: [[]], name: ['', Validators.required],
date: ['', Validators.required], organizationalUnit: [ this.data?.organizationalUnit ? this.data.organizationalUnit : null, Validators.required],
time: ['', Validators.required],
notes: [''], notes: [''],
organizationalUnit: ['', Validators.required], scheduleAfterCreate: [false]
selectedChild: [''],
selectedClients: [[]]
}); });
} }
ngOnInit(): void { ngOnInit(): void {
this.loadCommandGroups(); this.loading = true;
this.loadIndividualCommands(); const observables = [
this.loadOrganizationalUnits(); this.loadCommandGroups(),
if (this.data && this.data.task) { this.loadIndividualCommands(),
this.editing = true; this.loadOrganizationalUnits(),
this.loadTaskData(this.data.task); this.startUnitsFilter(),
} this.loadTasks()
];
Promise.all(observables).then(() => {
if (this.data.task) {
this.editing = true;
this.loadData().then(() => {
this.loading = false;
})
} else {
this.loading = false;
}
}).catch(() => {
this.loading = false;
})
} }
loadCommandGroups(): void { loadData(): Promise<void> {
return new Promise((resolve, reject) => {
this.http.get<any>(`${this.baseUrl}${this.data.task['@id']}`).subscribe(
(data) => {
this.taskForm.patchValue({
name: data.name,
scope: data.scope,
organizationalUnit: data.organizationalUnit ? data.organizationalUnit['@id'] : null,
notes: data.notes,
});
resolve();
},
(error) => {
this.toastr.error('Error al cargar los datos de la tarea');
reject(error);
}
);
})
}
loadTasks(): Promise<void> {
return new Promise((resolve, reject) => {
this.http.get<any>(`${this.apiUrl}?page=1&itemsPerPage=100`).subscribe(
(data) => {
this.existingTasks = data['hydra:member'];
resolve();
},
(error) => {
this.toastr.error('Error al cargar las tareas existentes');
reject(error);
}
);
});
}
onScopeChange(scope: string): void {
this.filterUnits(scope).subscribe(filteredUnits => {
this.availableOrganizationalUnits = filteredUnits;
this.taskForm.get('organizationalUnit')?.setValue('');
});
}
startUnitsFilter(): Promise<void> {
return new Promise((resolve, reject) => {
this.taskForm.get('scope')?.valueChanges.pipe(
startWith(this.taskForm.get('scope')?.value),
switchMap((value) => this.filterUnits(value))
).subscribe(filteredUnits => {
this.availableOrganizationalUnits = filteredUnits;
resolve();
}, error => {
this.toastr.error('Error al filtrar las unidades organizacionales');
reject(error);
});
})
}
filterUnits(value: string) {
const filtered = this.allOrganizationalUnits.filter(unit => unit.type === value);
return of(filtered);
}
loadCommandGroups(): Promise<void> {
return new Promise((resolve, reject) => {
this.http.get<any>(`${this.baseUrl}/command-groups`).subscribe( this.http.get<any>(`${this.baseUrl}/command-groups`).subscribe(
(data) => { (data) => {
this.availableCommandGroups = data['hydra:member']; this.availableCommandGroups = data['hydra:member'];
resolve();
}, },
(error) => { (error) => {
this.toastr.error('Error al cargar los grupos de comandos'); this.toastr.error('Error al cargar los grupos de comandos');
} reject(error);
); });
}
loadIndividualCommands(): void {
this.http.get<any>(`${this.baseUrl}/commands`).subscribe(
(data) => {
this.availableIndividualCommands = data['hydra:member'];
},
(error) => {
this.toastr.error('Error al cargar los comandos individuales');
}
);
}
loadOrganizationalUnits(): void {
this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=30`).subscribe(
(data) => {
this.availableOrganizationalUnits = data['hydra:member'].filter((unit: any) => unit['type'] === 'organizational-unit');
},
(error) => {
this.toastr.error('Error al cargar las unidades organizacionales');
}
);
}
loadTaskData(task: any): void {
this.taskForm.patchValue({
commandGroup: task.commandGroup ? task.commandGroup['@id'] : '',
extraCommands: task.commands ? task.commands.map((cmd: any) => cmd['@id']) : [],
date: task.dateTime ? task.dateTime.split('T')[0] : '',
time: task.dateTime ? task.dateTime.split('T')[1].slice(0, 5) : '',
notes: task.notes || '',
organizationalUnit: task.organizationalUnit ? task.organizationalUnit['@id'] : ''
}); });
if (task.commandGroup) {
this.selectedGroupCommands = task.commandGroup.commands;
}
} }
private collectClassrooms(unit: any): any[] { loadIndividualCommands(): Promise<void> {
let classrooms = []; return new Promise((resolve, reject) => {
if (unit.type === 'classroom') { this.http.get<any>(`${this.baseUrl}/commands`).subscribe(
classrooms.push(unit); (data) => {
} this.availableIndividualCommands = data['hydra:member'];
if (unit.children && unit.children.length > 0) { resolve();
for (let child of unit.children) { },
classrooms = classrooms.concat(this.collectClassrooms(child)); (error) => {
} this.toastr.error('Error al cargar los comandos individuales');
} reject(error);
return classrooms; });
});
} }
onOrganizationalUnitChange(): void { loadOrganizationalUnits(): Promise<void> {
const selectedUnitId = this.taskForm.get('organizationalUnit')?.value; return new Promise((resolve, reject) => {
const selectedUnit = this.availableOrganizationalUnits.find(unit => unit['@id'] === selectedUnitId); this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=100`).subscribe(
(data) => {
if (selectedUnit) { this.allOrganizationalUnits = data['hydra:member'];
this.selectedUnitChildren = this.collectClassrooms(selectedUnit); this.availableOrganizationalUnits = [...this.allOrganizationalUnits];
} else { resolve();
this.selectedUnitChildren = []; },
} (error) => {
this.toastr.error('Error al cargar las unidades organizacionales');
this.taskForm.patchValue({ selectedChild: '', selectedClients: [] }); reject(error);
this.selectedClients = []; }
this.selectedClientIds.clear(); );
});
} }
onChildChange(): void { addToExistingTask() {
const selectedChildId = this.taskForm.get('selectedChild')?.value; if (!this.selectedExistingTask) {
this.toastr.error('Debes seleccionar una tarea existente.');
if (!selectedChildId) {
this.selectedClients = [];
return; return;
} }
const url = `${this.baseUrl}${selectedChildId}`.replace(/([^:]\/)\/+/g, '$1'); if (this.executionOrder == null || this.executionOrder < 1) {
this.toastr.error('Debes introducir un orden de ejecución válido (mayor que 0).');
this.http.get<any>(url).subscribe( return;
(data) => {
if (Array.isArray(data.clients) && data.clients.length > 0) {
this.selectedClients = data.clients;
} else {
this.selectedClients = [];
this.toastr.warning('El aula seleccionada no tiene clientes.');
}
this.taskForm.patchValue({ selectedClients: [] });
this.selectedClientIds.clear();
},
(error) => {
this.toastr.error('Error al cargar los detalles del aula seleccionada');
}
);
}
toggleSelectAll() {
const allSelected = this.areAllSelected();
if (allSelected) {
this.selectedClientIds.clear();
} else {
this.selectedClients.forEach(client => this.selectedClientIds.add(client.uuid));
} }
this.taskForm.get('selectedClients')!.setValue(Array.from(this.selectedClientIds));
const data = {
taskId: this.selectedExistingTask,
executionOrder: this.executionOrder
};
this.toastr.success('Tarea actualizada con éxito');
this.dialogRef.close(data);
} }
areAllSelected(): boolean {
return this.selectedClients.length > 0 && this.selectedClients.every(client => this.selectedClientIds.has(client.uuid));
}
onCommandGroupChange(): void {
const selectedGroupId = this.taskForm.get('commandGroup')?.value;
this.http.get<any>(`${this.baseUrl}/command-groups/${selectedGroupId}`).subscribe(
(data) => {
this.selectedGroupCommands = data.commands;
},
(error) => {
this.toastr.error('Error al cargar los comandos del grupo seleccionado');
}
);
}
saveTask(): void { saveTask(): void {
if (this.taskForm.invalid) { if (this.taskForm.invalid) {
@ -192,22 +209,14 @@ export class CreateTaskComponent implements OnInit {
} }
const formData = this.taskForm.value; const formData = this.taskForm.value;
const dateTime = this.combineDateAndTime(formData.date, formData.time);
const selectedCommands = formData.extraCommands && formData.extraCommands.length > 0
? formData.extraCommands.map((id: any) => `/commands/${id}`)
: null;
const payload: any = { const payload: any = {
commandGroups: formData.commandGroup ? [`/command-groups/${formData.commandGroup}`] : null, name: formData.name,
dateTime: dateTime, scope: formData.scope,
organizationalUnit: formData.organizationalUnit,
notes: formData.notes || '', notes: formData.notes || '',
clients: Array.from(this.selectedClientIds).map((uuid: string) => `/clients/${uuid}`),
}; };
if (selectedCommands) {
payload.commands = selectedCommands;
}
if (this.editing) { if (this.editing) {
const taskId = this.data.task.uuid; const taskId = this.data.task.uuid;
this.http.patch<any>(`${this.apiUrl}/${taskId}`, payload).subscribe({ this.http.patch<any>(`${this.apiUrl}/${taskId}`, payload).subscribe({
@ -221,9 +230,21 @@ export class CreateTaskComponent implements OnInit {
}); });
} else { } else {
this.http.post<any>(this.apiUrl, payload).subscribe({ this.http.post<any>(this.apiUrl, payload).subscribe({
next: () => { next: response => {
this.toastr.success('Tarea creada con éxito'); this.toastr.success('Tarea creada con éxito');
this.dialogRef.close(true); this.dialogRef.close(response);
if (formData.scheduleAfterCreate) {
const dialogRef = this.dialog.open(CreateTaskScheduleComponent, {
width: '800px',
data: { task: response }
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.toastr.success('Tarea programada correctamente');
}
});
}
}, },
error: () => { error: () => {
this.toastr.error('Error al crear la tarea'); this.toastr.error('Error al crear la tarea');
@ -232,14 +253,7 @@ export class CreateTaskComponent implements OnInit {
} }
} }
combineDateAndTime(date: string, time: string): string {
const dateObj = new Date(date);
const [hours, minutes] = time.split(':').map(Number);
dateObj.setHours(hours, minutes, 0);
return dateObj.toISOString();
}
close(): void { close(): void {
this.dialogRef.close(); this.dialogRef.close(false);
} }
} }

View File

@ -0,0 +1,89 @@
.full-width {
width: 100%;
}
form {
padding: 20px;
}
.spacing-container {
margin-top: 20px;
margin-bottom: 16px;
}
.list-item-content {
display: flex;
align-items: flex-start;
justify-content: space-between;
width: 100%;
}
.text-content {
flex-grow: 1;
margin-right: 16px;
margin-left: 10px;
}
.icon-container {
display: flex;
align-items: center;
}
.right-icon {
margin-left: 8px;
cursor: pointer;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
.lists-container {
padding: 16px;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin: 1.5rem 0rem 1.5rem 0rem;
box-sizing: border-box;
}
.search-string {
flex: 1;
padding: 5px;
}
.search-select {
flex: 1;
padding: 5px;
}
table {
width: 100%;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.2);
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
}
mat-spinner {
margin: 0 auto;
align-self: center;
}
.subnets-button-row {
display: flex;
gap: 15px;
}

View File

@ -0,0 +1,100 @@
<app-loading [isLoading]="loading"></app-loading>
<h2 mat-dialog-title>Gestionar programaciones de tareas en {{ data.commandTask?.name }}</h2>
<mat-dialog-content>
<div class="search-container">
<mat-form-field appearance="fill" class="search-string" joyrideStep="searchNameStep"
text="Busca subredes por nombre para localizar una subred específica rápidamente.">
<mat-label i18n="@@searchLabel">Buscar nombre del cliente</mat-label>
<input matInput placeholder="Búsqueda" [(ngModel)]="filters['name']" i18n-placeholder="@@searchPlaceholder"
(keyup.enter)="loadData()" i18n-placeholder="@@searchPlaceholder">
<mat-icon matSuffix>search</mat-icon>
<button *ngIf="filters['name']" mat-icon-button matSuffix aria-label="Clear tree search"
(click)="filters['name'] = ''; loadData()">
<mat-icon>close</mat-icon>
</button>
<mat-hint i18n="@@searchHint">Pulsar 'enter' para buscar</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill" class="search-string" joyrideStep="searchIpStep" text="Busca programaciones por tipo.">
<mat-label i18n="@@searchLabel">Buscar por tipo</mat-label>
<input matInput placeholder="Búsqueda" [(ngModel)]="filters['recurrence']" i18n-placeholder="@@searchPlaceholder"
(keyup.enter)="loadData()" i18n-placeholder="@@searchPlaceholder">
<mat-icon matSuffix>search</mat-icon>
<button *ngIf="filters['ip']" mat-icon-button matSuffix aria-label="Clear tree search"
(click)="filters['ip'] = ''; loadData()">
<mat-icon>close</mat-icon>
</button>
<mat-hint i18n="@@searchHint">Pulsar 'enter' para buscar</mat-hint>
</mat-form-field>
</div>
<app-loading [isLoading]="loading"></app-loading>
<table *ngIf="!loading" mat-table [dataSource]="dataSource" class="mat-elevation-z8" joyrideStep="tableStep"
text="Visualiza y administra las subredes listadas según los filtros aplicados.">
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let schedule">
<ng-container *ngIf="column.columnDef === 'recurrenceType'">
<mat-chip style="padding: 10px; margin: 5px;">
<ng-container *ngIf="column.cell(schedule) === 'none'; else scheduledTemplate">
No programado
<div style="font-size: 12px;">
{{ schedule.executionDate | date }}
</div>
</ng-container>
<ng-template #scheduledTemplate>
Programado
<div style="font-size: 12px;">
{{ schedule.recurrenceDetails.initDate | date }} → {{ schedule.recurrenceDetails.endDate | date}}
</div>
</ng-template>
</mat-chip>
</ng-container>
<ng-container *ngIf="column.columnDef === 'executionTime'">
{{ schedule.executionTime | date: 'HH:mm' }}
</ng-container>
<ng-container *ngIf="column.columnDef !== 'recurrenceType' && column.columnDef !== 'executionTime' && column.columnDef !== 'enabled'">
{{ column.cell(schedule) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'enabled'">
<mat-chip>
<ng-container *ngIf="schedule.enabled">
Activo
</ng-container>
<ng-container *ngIf="!schedule.enabled">
Inactivo
</ng-container>
</mat-chip>
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: center;">Acciones</th>
<td mat-cell *matCellDef="let schedule" style="text-align: center;">
<button mat-icon-button color="primary" (click)="editSchedule(schedule)">
<mat-icon i18n="@@deleteElementTooltip">edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="deleteSchedule(schedule)">
<mat-icon i18n="@@deleteElementTooltip">delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<div class="paginator-container" joyrideStep="paginationStep"
text="Navega entre las páginas de subredes usando el paginador.">
<mat-paginator [length]="length" [pageSize]="itemsPerPage" [pageIndex]="page" [pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)">
</mat-paginator>
</div>
</mat-dialog-content>
<mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="onNoClick()">Cerrar</button>
</mat-dialog-actions>

View File

@ -0,0 +1,86 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShowTaskScheduleComponent } from './show-task-schedule.component';
import {LoadingComponent} from "../../../../shared/loading/loading.component";
import {HttpClientTestingModule} from "@angular/common/http/testing";
import {ToastrModule} from "ngx-toastr";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {MatDividerModule} from "@angular/material/divider";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInputModule} from "@angular/material/input";
import {MatIconModule} from "@angular/material/icon";
import {MatButtonModule} from "@angular/material/button";
import {MatTableModule} from "@angular/material/table";
import {MatPaginatorModule} from "@angular/material/paginator";
import {MatTooltipModule} from "@angular/material/tooltip";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from "@angular/material/dialog";
import {MatSelectModule} from "@angular/material/select";
import {MatTabsModule} from "@angular/material/tabs";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import {MatListModule} from "@angular/material/list";
import {MatCardModule} from "@angular/material/card";
import {MatMenuModule} from "@angular/material/menu";
import {MatTreeModule} from "@angular/material/tree";
import {TranslateModule} from "@ngx-translate/core";
import {JoyrideModule} from "ngx-joyride";
import {ConfigService} from "@services/config.service";
import {ActivatedRoute} from "@angular/router";
import {CreateTaskScheduleComponent} from "../create-task-schedule/create-task-schedule.component";
describe('ShowTaskScheduleComponent', () => {
let component: ShowTaskScheduleComponent;
let fixture: ComponentFixture<ShowTaskScheduleComponent>;
beforeEach(async () => {
const mockConfigService = {
apiUrl: 'http://mock-api-url',
mercureUrl: 'http://mock-mercure-url'
};
await TestBed.configureTestingModule({
declarations: [ShowTaskScheduleComponent, LoadingComponent],
imports: [
HttpClientTestingModule,
ToastrModule.forRoot(),
BrowserAnimationsModule,
MatDividerModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatTooltipModule,
FormsModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
MatDialogModule,
MatSelectModule,
MatTabsModule,
MatAutocompleteModule,
MatListModule,
MatCardModule,
MatMenuModule,
MatTreeModule,
TranslateModule.forRoot(),
JoyrideModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: {} },
{ provide: MAT_DIALOG_DATA, useValue: { data: { id: 123 } } },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: ActivatedRoute, useValue: { queryParams: { subscribe: () => {} } } },
]
}).compileComponents();
fixture = TestBed.createComponent(ShowTaskScheduleComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,107 @@
import {Component, Inject, OnInit} from '@angular/core';
import {MatTableDataSource} from "@angular/material/table";
import {Client} from "../../../groups/model/model";
import {ToastrService} from "ngx-toastr";
import {HttpClient} from "@angular/common/http";
import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from "@angular/material/dialog";
import {ConfigService} from "@services/config.service";
import {DeleteModalComponent} from "../../../../shared/delete_modal/delete-modal/delete-modal.component";
import {CreateTaskScheduleComponent} from "../create-task-schedule/create-task-schedule.component";
import {DatePipe} from "@angular/common";
@Component({
selector: 'app-show-task-schedule',
templateUrl: './show-task-schedule.component.html',
styleUrl: './show-task-schedule.component.css'
})
export class ShowTaskScheduleComponent implements OnInit{
baseUrl: string;
dataSource = new MatTableDataSource<any>([]);
length = 0;
itemsPerPage: number = 10;
pageSizeOptions: number[] = [5, 10, 20];
page = 0;
loading: boolean = false;
filters: { [key: string]: string } = {};
datePipe: DatePipe = new DatePipe('es-ES');
columns = [
{ columnDef: 'id', header: 'ID', cell: (schedule: any) => schedule.id },
{ columnDef: 'recurrenceType', header: 'Recurrencia', cell: (schedule: any) => schedule.recurrenceType },
{ columnDef: 'time', header: 'Hora de ejecución', cell: (schedule: any) => this.datePipe.transform(schedule.executionTime, 'HH:mm', 'UTC') },
{ columnDef: 'daysOfWeek', header: 'Dias de la semana', cell: (schedule: any) => schedule.recurrenceDetails.daysOfWeek },
{ columnDef: 'months', header: 'Meses', cell: (schedule: any) => schedule.recurrenceDetails.months },
{ columnDef: 'enabled', header: 'Activo', cell: (schedule: any) => schedule.enabled }
];
displayedColumns: string[] = ['id', 'recurrenceType', 'time', 'daysOfWeek', 'months', 'enabled', 'actions'];
constructor(
private toastService: ToastrService,
private http: HttpClient,
public dialogRef: MatDialogRef<ShowTaskScheduleComponent>,
public dialog: MatDialog,
private configService: ConfigService,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
}
ngOnInit(): void {
if (this.data) {
this.loadData();
}
}
loadData() {
this.loading = true;
this.http.get<any>(`${this.baseUrl}/command-task-schedules?page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}&commandTask.id=${this.data.commandTask?.id}`, { params: this.filters }).subscribe(
(data) => {
this.dataSource.data = data['hydra:member'];
this.length = data['hydra:totalItems'];
this.loading = false;
},
(error) => {
this.loading = false;
}
);
}
editSchedule(schedule: any): void {
this.dialog.open(CreateTaskScheduleComponent, {
width: '800px',
data: { schedule: schedule, task: this.data.commandTask }
}).afterClosed().subscribe(() => this.loadData());
}
deleteSchedule(schedule: any): void {
const dialogRef = this.dialog.open(DeleteModalComponent, {
width: '300px',
data: { name: 'tarea programada' }
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.http.delete(`${this.baseUrl}${schedule['@id']}`).subscribe(
() => {
this.toastService.success('Programación eliminada correctamente');
this.loadData();
},
(error) => {
this.toastService.error(error.error['hydra:description']);
}
);
}
})
}
onNoClick(): void {
this.dialogRef.close(false);
}
onPageChange(event: any) {
this.page = event.pageIndex;
this.itemsPerPage = event.pageSize;
this.loadData()
}
}

View File

@ -0,0 +1,89 @@
.full-width {
width: 100%;
}
form {
padding: 20px;
}
.spacing-container {
margin-top: 20px;
margin-bottom: 16px;
}
.list-item-content {
display: flex;
align-items: flex-start;
justify-content: space-between;
width: 100%;
}
.text-content {
flex-grow: 1;
margin-right: 16px;
margin-left: 10px;
}
.icon-container {
display: flex;
align-items: center;
}
.right-icon {
margin-left: 8px;
cursor: pointer;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
.lists-container {
padding: 16px;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin: 1.5rem 0rem 1.5rem 0rem;
box-sizing: border-box;
}
.search-string {
flex: 1;
padding: 5px;
}
.search-select {
flex: 1;
padding: 5px;
}
table {
width: 100%;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.2);
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
}
mat-spinner {
margin: 0 auto;
align-self: center;
}
.subnets-button-row {
display: flex;
gap: 15px;
}

View File

@ -0,0 +1,70 @@
<app-loading [isLoading]="loading"></app-loading>
<h2 mat-dialog-title>Gestionar scripts de tareas en {{ data.commandTask?.name }}</h2>
<mat-dialog-content>
<div class="search-container">
<mat-form-field appearance="fill" class="search-string" joyrideStep="searchNameStep"
text="Busca subredes por nombre para localizar una subred específica rápidamente.">
<mat-label i18n="@@searchLabel">Buscar contenido de script</mat-label>
<input matInput placeholder="Búsqueda" [(ngModel)]="filters['name']" i18n-placeholder="@@searchPlaceholder"
(keyup.enter)="loadData()" i18n-placeholder="@@searchPlaceholder">
<mat-icon matSuffix>search</mat-icon>
<button *ngIf="filters['name']" mat-icon-button matSuffix aria-label="Clear tree search"
(click)="filters['name'] = ''; loadData()">
<mat-icon>close</mat-icon>
</button>
<mat-hint i18n="@@searchHint">Pulsar 'enter' para buscar</mat-hint>
</mat-form-field>
</div>
<app-loading [isLoading]="loading"></app-loading>
<table *ngIf="!loading" mat-table [dataSource]="dataSource" class="mat-elevation-z8" joyrideStep="tableStep"
text="Visualiza y administra las subredes listadas según los filtros aplicados.">
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let schedule">
<ng-container *ngIf="column.columnDef === 'content'; else checkOtherColumn">
<div style="background-color: #f5f5f5; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; white-space: pre-wrap; font-size: 13px;">
{{ column.cell(schedule) }}
</div>
</ng-container>
<ng-template #checkOtherColumn>
<ng-container *ngIf="column.columnDef === 'parameters'; else normalCell">
<button mat-stroked-button color="primary" (click)="openParametersModal(schedule.parameters)">
Ver parámetros
</button>
</ng-container>
</ng-template>
<ng-template #normalCell>
{{ column.cell(schedule) }}
</ng-template>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: center;">Acciones</th>
<td mat-cell *matCellDef="let schedule" style="text-align: center;">
<button mat-icon-button color="warn" (click)="deleteTaskScript(schedule)">
<mat-icon i18n="@@deleteElementTooltip">delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<div class="paginator-container" joyrideStep="paginationStep"
text="Navega entre las páginas de subredes usando el paginador.">
<mat-paginator [length]="length" [pageSize]="itemsPerPage" [pageIndex]="page" [pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)">
</mat-paginator>
</div>
</mat-dialog-content>
<mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="onNoClick()">Cerrar</button>
</mat-dialog-actions>

View File

@ -0,0 +1,86 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShowTaskScriptComponent } from './show-task-script.component';
import {LoadingComponent} from "../../../../shared/loading/loading.component";
import {HttpClientTestingModule} from "@angular/common/http/testing";
import {ToastrModule} from "ngx-toastr";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {MatDividerModule} from "@angular/material/divider";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInputModule} from "@angular/material/input";
import {MatIconModule} from "@angular/material/icon";
import {MatButtonModule} from "@angular/material/button";
import {MatTableModule} from "@angular/material/table";
import {MatPaginatorModule} from "@angular/material/paginator";
import {MatTooltipModule} from "@angular/material/tooltip";
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from "@angular/material/dialog";
import {MatSelectModule} from "@angular/material/select";
import {MatTabsModule} from "@angular/material/tabs";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import {MatListModule} from "@angular/material/list";
import {MatCardModule} from "@angular/material/card";
import {MatMenuModule} from "@angular/material/menu";
import {MatTreeModule} from "@angular/material/tree";
import {TranslateModule} from "@ngx-translate/core";
import {JoyrideModule} from "ngx-joyride";
import {ConfigService} from "@services/config.service";
import {ActivatedRoute} from "@angular/router";
import {ShowTaskScheduleComponent} from "../show-task-schedule/show-task-schedule.component";
describe('ShowTaskScriptComponent', () => {
let component: ShowTaskScriptComponent;
let fixture: ComponentFixture<ShowTaskScriptComponent>;
beforeEach(async () => {
const mockConfigService = {
apiUrl: 'http://mock-api-url',
mercureUrl: 'http://mock-mercure-url'
};
await TestBed.configureTestingModule({
declarations: [ShowTaskScriptComponent, LoadingComponent],
imports: [
HttpClientTestingModule,
ToastrModule.forRoot(),
BrowserAnimationsModule,
MatDividerModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatButtonModule,
MatTableModule,
MatPaginatorModule,
MatTooltipModule,
FormsModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
MatDialogModule,
MatSelectModule,
MatTabsModule,
MatAutocompleteModule,
MatListModule,
MatCardModule,
MatMenuModule,
MatTreeModule,
TranslateModule.forRoot(),
JoyrideModule.forRoot(),
],
providers: [
{ provide: MatDialogRef, useValue: {} },
{ provide: MAT_DIALOG_DATA, useValue: { data: { id: 123 } } },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: ActivatedRoute, useValue: { queryParams: { subscribe: () => {} } } },
]
}).compileComponents();
fixture = TestBed.createComponent(ShowTaskScriptComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,104 @@
import {Component, Inject, OnInit} from '@angular/core';
import {MatTableDataSource} from "@angular/material/table";
import {ToastrService} from "ngx-toastr";
import {HttpClient} from "@angular/common/http";
import {MAT_DIALOG_DATA, MatDialog, MatDialogRef} from "@angular/material/dialog";
import {ConfigService} from "@services/config.service";
import {CreateTaskScheduleComponent} from "../create-task-schedule/create-task-schedule.component";
import {DeleteModalComponent} from "../../../../shared/delete_modal/delete-modal/delete-modal.component";
import {ViewParametersModalComponent} from "./view-parameters-modal/view-parameters-modal.component";
@Component({
selector: 'app-show-task-script',
templateUrl: './show-task-script.component.html',
styleUrl: './show-task-script.component.css'
})
export class ShowTaskScriptComponent implements OnInit{
baseUrl: string;
dataSource = new MatTableDataSource<any>([]);
length = 0;
itemsPerPage: number = 10;
pageSizeOptions: number[] = [5, 10, 20];
page = 0;
loading: boolean = false;
filters: { [key: string]: string } = {};
columns = [
{ columnDef: 'id', header: 'ID', cell: (client: any) => client.id },
{ columnDef: 'order', header: 'Orden', cell: (client: any) => client.order },
{ columnDef: 'content', header: 'Script', cell: (client: any) => client.content },
{ columnDef: 'type', header: 'Type', cell: (client: any) => client.type },
{ columnDef: 'parameters', header: 'Parameters', cell: (client: any) => client.parameters },
];
displayedColumns: string[] = ['id', 'order', 'type', 'parameters', 'content', 'actions'];
constructor(
private toastService: ToastrService,
private http: HttpClient,
public dialogRef: MatDialogRef<ShowTaskScriptComponent>,
public dialog: MatDialog,
private configService: ConfigService,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
}
ngOnInit(): void {
if (this.data) {
this.loadData();
}
}
loadData() {
this.loading = true;
this.http.get<any>(`${this.baseUrl}/command-task-scripts?page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}&commandTask.id=${this.data.commandTask?.id}`, { params: this.filters }).subscribe(
(data) => {
this.dataSource.data = data['hydra:member'];
this.length = data['hydra:totalItems'];
this.loading = false;
},
(error) => {
this.loading = false;
}
);
}
deleteTaskScript(schedule: any): void {
const dialogRef = this.dialog.open(DeleteModalComponent, {
width: '300px',
data: { name: 'script de una tarea' }
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.http.delete(`${this.baseUrl}${schedule['@id']}`).subscribe(
() => {
this.toastService.success('Eliminado correctamente');
this.loadData();
},
(error) => {
this.toastService.error(error.error['hydra:description']);
}
);
}
})
}
onNoClick(): void {
this.dialogRef.close(false);
}
openParametersModal(parameters: any): void {
this.dialog.open(ViewParametersModalComponent, {
width: '900px',
data: parameters
});
}
onPageChange(event: any) {
this.page = event.pageIndex;
this.itemsPerPage = event.pageSize;
this.loadData()
}
}

View File

@ -0,0 +1,7 @@
<h2 mat-dialog-title>Parámetros</h2>
<mat-dialog-content>
<pre>{{ data | json }}</pre>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="close()">Cerrar</button>
</mat-dialog-actions>

View File

@ -0,0 +1,69 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ViewParametersModalComponent } from './view-parameters-modal.component';
import {LoadingComponent} from "../../../../../shared/loading/loading.component";
import {HttpClientTestingModule, provideHttpClientTesting} from "@angular/common/http/testing";
import {ToastrModule, ToastrService} from "ngx-toastr";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {MatDividerModule} from "@angular/material/divider";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInputModule} from "@angular/material/input";
import {MatIconModule} from "@angular/material/icon";
import {MatButtonModule} from "@angular/material/button";
import {MatTableModule} from "@angular/material/table";
import {MatPaginatorModule} from "@angular/material/paginator";
import {MatTooltipModule} from "@angular/material/tooltip";
import {FormBuilder, FormsModule, ReactiveFormsModule} from "@angular/forms";
import {MatProgressSpinnerModule} from "@angular/material/progress-spinner";
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from "@angular/material/dialog";
import {MatSelectModule} from "@angular/material/select";
import {MatTabsModule} from "@angular/material/tabs";
import {MatAutocompleteModule} from "@angular/material/autocomplete";
import {MatListModule} from "@angular/material/list";
import {MatCardModule} from "@angular/material/card";
import {MatMenuModule} from "@angular/material/menu";
import {MatTreeModule} from "@angular/material/tree";
import {TranslateModule} from "@ngx-translate/core";
import {JoyrideModule} from "ngx-joyride";
import {ConfigService} from "@services/config.service";
import {ActivatedRoute} from "@angular/router";
import {InputDialogComponent} from "../../../../task-logs/input-dialog/input-dialog.component";
import {provideHttpClient} from "@angular/common/http";
describe('ViewParametersModalComponent', () => {
let component: ViewParametersModalComponent;
let fixture: ComponentFixture<ViewParametersModalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ViewParametersModalComponent],
imports: [
MatDialogModule,
TranslateModule.forRoot(),
],
providers: [
FormBuilder,
ToastrService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: MatDialogRef,
useValue: {}
},
{
provide: MAT_DIALOG_DATA,
useValue: {}
}
]
})
.compileComponents();
fixture = TestBed.createComponent(ViewParametersModalComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,18 @@
import {Component, Inject} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
@Component({
selector: 'app-view-parameters-modal',
templateUrl: './view-parameters-modal.component.html',
styleUrl: './view-parameters-modal.component.css'
})
export class ViewParametersModalComponent {
constructor(
public dialogRef: MatDialogRef<ViewParametersModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
close(): void {
this.dialogRef.close();
}
}

View File

@ -1,118 +0,0 @@
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 10px;
border-bottom: 1px solid #ddd;
}
.header-container-title {
flex-grow: 1;
text-align: left;
margin-left: 1em;
}
.calendar-button-row {
display: flex;
gap: 15px;
}
.lists-container {
padding: 16px;
}
.imagesLists-container {
flex: 1;
}
.card.unidad-card {
height: 100%;
box-sizing: border-box;
}
table {
width: 100%;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
margin: 1.5rem 0rem 1.5rem 0rem;
box-sizing: border-box;
}
.search-string {
flex: 1;
padding: 5px;
}
.search-boolean {
flex: 1;
padding: 5px;
}
.search-select {
flex: 2;
padding: 5px;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0, 0, 0, 0.2);
}
.progress-container {
display: flex;
align-items: center;
gap: 10px;
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
}
.chip-failed {
background-color: #e87979 !important;
color: white;
}
.chip-success {
background-color: #46c446 !important;
color: white;
}
.chip-pending {
background-color: #bebdbd !important;
color: black;
}
.chip-in-progress {
background-color: #f5a623 !important;
color: white;
}
.status-progress-flex {
display: flex;
align-items: center;
gap: 8px;
}
button.cancel-button {
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
}
.cancel-button {
color: red;
background-color: transparent;
border: none;
padding: 0;
}
.cancel-button mat-icon {
color: red;
}

View File

@ -1,128 +0,0 @@
<div class="header-container">
<button mat-icon-button color="primary" (click)="iniciarTour()">
<mat-icon>help</mat-icon>
</button>
<div class="header-container-title">
<h2 joyrideStep="titleStep" text="{{ 'titleStepText' | translate }}">{{ 'adminCommandsTitle' |
translate }}</h2>
</div>
<div class="images-button-row">
<button class="action-button" (click)="resetFilters()" joyrideStep="resetFiltersStep"
text="{{ 'resetFiltersStepText' | translate }}">
{{ 'resetFilters' | translate }}
</button>
</div>
</div>
<div class="search-container">
<mat-form-field appearance="fill" class="search-select" joyrideStep="clientSelectStep"
text="{{ 'clientSelectStepText' | translate }}">
<input type="text" matInput [formControl]="clientControl" [matAutocomplete]="clientAuto"
placeholder="{{ 'filterClientPlaceholder' | translate }}">
<mat-autocomplete #clientAuto="matAutocomplete" [displayWith]="displayFnClient"
(optionSelected)="onOptionClientSelected($event.option.value)">
<mat-option *ngFor="let client of filteredClients | async" [value]="client">
{{ client.name }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
<mat-form-field appearance="fill" class="search-select" joyrideStep="commandSelectStep"
text="{{ 'commandSelectStepText' | translate }}">
<input type="text" matInput [formControl]="commandControl" [matAutocomplete]="commandAuto"
placeholder="{{ 'filterCommandPlaceholder' | translate }}">
<mat-autocomplete #commandAuto="matAutocomplete" [displayWith]="displayFnCommand"
(optionSelected)="onOptionCommandSelected($event.option.value)">
<mat-option *ngFor="let command of filteredCommands | async" [value]="command">
{{ command.name }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
<mat-form-field appearance="fill" class="search-boolean">
<mat-label i18n="@@searchLabel">Estado</mat-label>
<mat-select [(ngModel)]="filters['status']" (selectionChange)="loadTraces()" placeholder="Seleccionar opción">
<mat-option [value]="undefined">Todos</mat-option>
<mat-option [value]="'failed'">Fallido</mat-option>
<mat-option [value]="'pending'">Pendiente de ejecutar</mat-option>
<mat-option [value]="'in-progress'">Ejecutando</mat-option>
<mat-option [value]="'success'">Completado con éxito</mat-option>
<mat-option [value]="'cancelled'">Cancelado</mat-option>
</mat-select>
</mat-form-field>
</div>
<app-loading [isLoading]="loading"></app-loading>
<div *ngIf="!loading">
<table mat-table [dataSource]="traces" class="mat-elevation-z8" joyrideStep="tableStep"
text="{{ 'tableStepText' | translate }}">
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let trace">
<ng-container [ngSwitch]="column.columnDef">
<ng-container *ngSwitchCase="'status'">
<ng-container *ngIf="trace.status === 'in-progress' && trace.progress; else statusChip">
<div class="progress-container">
<mat-progress-bar class="example-margin" [mode]="mode" [value]="trace.progress" [bufferValue]="bufferValue">
</mat-progress-bar>
<span>{{trace.progress}}%</span>
</div>
</ng-container>
<ng-template #statusChip>
<div class="status-progress-flex">
<mat-chip [ngClass]="{
'chip-failed': trace.status === 'failed',
'chip-success': trace.status === 'success',
'chip-pending': trace.status === 'pending',
'chip-in-progress': trace.status === 'in-progress',
'chip-cancelled': trace.status === 'cancelled'
}">
{{
trace.status === 'failed' ? 'Fallido' :
trace.status === 'in-progress' ? 'En ejecución' :
trace.status === 'success' ? 'Finalizado con éxito' :
trace.status === 'pending' ? 'Pendiente de ejecutar' :
trace.status === 'cancelled' ? 'Cancelado' :
trace.status
}}
</mat-chip>
<button *ngIf="trace.status === 'in-progress' && trace.command === 'deploy-image'"
mat-icon-button
(click)="cancelTrace(trace)"
class="cancel-button"
matTooltip="Cancelar transmisión de imagen">
<mat-icon>cancel</mat-icon>
</button>
</div>
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'input'">
<button mat-icon-button (click)="openInputModal(trace.input)">
<mat-icon>info</mat-icon>
</button>
</ng-container>
<ng-container *ngSwitchDefault>
{{ column.cell(trace) }}
</ng-container>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
<div class="paginator-container" joyrideStep="paginationStep" text="{{ 'paginationStepText' | translate }}">
<mat-paginator [length]="length" [pageSize]="itemsPerPage" [pageIndex]="page" [pageSizeOptions]="pageSizeOptions"
(page)="onPageChange($event)">
</mat-paginator>
</div>

View File

@ -1,309 +0,0 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin } from 'rxjs';
import { FormControl } from '@angular/forms';
import { map, startWith } from 'rxjs/operators';
import { DatePipe } from '@angular/common';
import { JoyrideService } from 'ngx-joyride';
import { MatDialog } from "@angular/material/dialog";
import { InputDialogComponent } from "./input-dialog/input-dialog.component";
import { ProgressBarMode } from '@angular/material/progress-bar';
import { DeleteModalComponent } from "../../../../shared/delete_modal/delete-modal/delete-modal.component";
import { ToastrService } from "ngx-toastr";
import { ConfigService } from '@services/config.service';
@Component({
selector: 'app-task-logs',
templateUrl: './task-logs.component.html',
styleUrls: ['./task-logs.component.css']
})
export class TaskLogsComponent implements OnInit {
baseUrl: string;
mercureUrl: string;
traces: any[] = [];
groupedTraces: any[] = [];
commands: any[] = [];
clients: any[] = [];
length: number = 0;
itemsPerPage: number = 20;
page: number = 0;
loading: boolean = true;
pageSizeOptions: number[] = [10, 20, 30, 50];
datePipe: DatePipe = new DatePipe('es-ES');
mode: ProgressBarMode = 'buffer';
progress = 0;
bufferValue = 0;
columns = [
{
columnDef: 'id',
header: 'ID',
cell: (trace: any) => `${trace.id}`,
},
{
columnDef: 'command',
header: 'Comando',
cell: (trace: any) => `${trace.command}`
},
{
columnDef: 'client',
header: 'Client',
cell: (trace: any) => `${trace.client?.name}`
},
{
columnDef: 'status',
header: 'Estado',
cell: (trace: any) => `${trace.status}`
},
{
columnDef: 'jobId',
header: 'Hilo de trabajo',
cell: (trace: any) => `${trace.jobId}`
},
{
columnDef: 'input',
header: 'Input',
cell: (trace: any) => `${trace.input}`
},
{
columnDef: 'output',
header: 'Logs',
cell: (trace: any) => `${trace.output}`
},
{
columnDef: 'executedAt',
header: 'Programación de ejecución',
cell: (trace: any) => `${this.datePipe.transform(trace.executedAt, 'dd/MM/yyyy hh:mm:ss')}`,
},
{
columnDef: 'finishedAt',
header: 'Finalización',
cell: (trace: any) => `${this.datePipe.transform(trace.finishedAt, 'dd/MM/yyyy hh:mm:ss')}`,
},
];
displayedColumns = [...this.columns.map(column => column.columnDef)];
filters: { [key: string]: string } = {};
filteredClients!: Observable<any[]>;
clientControl = new FormControl();
filteredCommands!: Observable<any[]>;
commandControl = new FormControl();
constructor(private http: HttpClient,
private joyrideService: JoyrideService,
private dialog: MatDialog,
private cdr: ChangeDetectorRef,
private configService: ConfigService,
private toastService: ToastrService
) {
this.baseUrl = this.configService.apiUrl;
this.mercureUrl = this.configService.mercureUrl;
}
ngOnInit(): void {
this.loadTraces();
this.loadCommands();
//this.loadClients();
this.filteredCommands = this.commandControl.valueChanges.pipe(
startWith(''),
map(value => (typeof value === 'string' ? value : value?.name)),
map(name => (name ? this._filterCommands(name) : this.commands.slice()))
);
this.filteredClients = this.clientControl.valueChanges.pipe(
startWith(''),
map(value => (typeof value === 'string' ? value : value?.name)),
map(name => (name ? this._filterClients(name) : this.clients.slice()))
);
const eventSource = new EventSource(`${this.mercureUrl}?topic=`
+ encodeURIComponent(`traces`));
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data && data['@id']) {
this.updateTracesStatus(data['@id'], data.status, data.progress);
}
}
}
private updateTracesStatus(clientUuid: string, newStatus: string, progress: Number): void {
const traceIndex = this.traces.findIndex(trace => trace['@id'] === clientUuid);
if (traceIndex !== -1) {
const updatedTraces = [...this.traces];
updatedTraces[traceIndex] = {
...updatedTraces[traceIndex],
status: newStatus,
progress: progress
};
this.traces = updatedTraces;
this.cdr.detectChanges();
console.log(`Estado actualizado para la traza ${clientUuid}: ${newStatus}`);
} else {
console.warn(`Traza con UUID ${clientUuid} no encontrado en la lista.`);
}
}
private _filterClients(name: string): any[] {
const filterValue = name.toLowerCase();
return this.clients.filter(client => client.name.toLowerCase().includes(filterValue));
}
private _filterCommands(name: string): any[] {
const filterValue = name.toLowerCase();
return this.commands.filter(command => command.name.toLowerCase().includes(filterValue));
}
displayFnClient(client: any): string {
return client && client.name ? client.name : '';
}
displayFnCommand(command: any): string {
return command && command.name ? command.name : '';
}
onOptionCommandSelected(selectedCommand: any): void {
this.filters['command.id'] = selectedCommand.id;
this.loadTraces();
}
onOptionClientSelected(selectedClient: any): void {
this.filters['client.id'] = selectedClient.id;
this.loadTraces();
}
openInputModal(inputData: any): void {
this.dialog.open(InputDialogComponent, {
width: '700px',
data: { input: inputData }
});
}
cancelTrace(trace: any): void {
this.dialog.open(DeleteModalComponent, {
width: '300px',
data: { name: trace.jobId },
}).afterClosed().subscribe((result) => {
if (result) {
this.http.post(`${this.baseUrl}/traces/server/${trace.uuid}/cancel`, {}).subscribe({
next: () => {
this.toastService.success('Transmision de imagen cancelada');
this.loadTraces();
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
console.error(error.error['hydra:description']);
}
});
}
});
}
loadTraces(): void {
this.loading = true;
const url = `${this.baseUrl}/traces?page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`;
const params = { ...this.filters };
if (params['status'] === undefined) {
delete params['status'];
}
this.http.get<any>(url, { params }).subscribe(
(data) => {
this.traces = data['hydra:member'];
this.length = data['hydra:totalItems'];
this.groupedTraces = this.groupByCommandId(this.traces);
this.loading = false;
},
(error) => {
console.error('Error fetching traces', error);
this.loading = false;
}
);
}
loadCommands() {
this.loading = true;
this.http.get<any>(`${this.baseUrl}/commands?&page=1&itemsPerPage=10000`).subscribe(
response => {
this.commands = response['hydra:member'];
this.loading = false;
},
error => {
console.error('Error fetching commands:', error);
this.loading = false;
}
);
}
loadClients() {
this.loading = true;
this.http.get<any>(`${this.baseUrl}/clients?&page=1&itemsPerPage=10000`).subscribe(
response => {
const clientIds = response['hydra:member'].map((client: any) => client['@id']);
const clientDetailsRequests: Observable<any>[] = clientIds.map((id: string) => this.http.get<any>(`${this.baseUrl}${id}`));
forkJoin(clientDetailsRequests).subscribe(
(clients: any[]) => {
this.clients = clients;
this.loading = false;
},
(error: any) => {
console.error('Error fetching client details:', error);
this.loading = false;
}
);
},
(error: any) => {
console.error('Error fetching clients:', error);
this.loading = false;
}
);
}
resetFilters() {
this.loading = true;
this.filters = {};
this.loadTraces();
}
groupByCommandId(traces: any[]): any[] {
const grouped: { [key: string]: any[] } = {};
traces.forEach(trace => {
const commandId = trace.command.id;
if (!grouped[commandId]) {
grouped[commandId] = [];
}
grouped[commandId].push(trace);
});
return Object.keys(grouped).map(key => ({
commandId: key,
traces: grouped[key]
}));
}
onPageChange(event: any): void {
this.page = event.pageIndex;
this.itemsPerPage = event.pageSize;
this.length = event.length;
this.loadTraces();
}
iniciarTour(): void {
this.joyrideService.startTour({
steps: [
'titleStep',
'resetFiltersStep',
'clientSelectStep',
'commandSelectStep',
'tableStep',
'paginationStep'
],
showPrevButton: true,
themeColor: '#3f51b5'
});
}
}

View File

@ -75,6 +75,7 @@ export class CreateCommandComponent implements OnInit{
readOnly: this.createCommandForm.value.readOnly, readOnly: this.createCommandForm.value.readOnly,
enabled: this.createCommandForm.value.enabled, enabled: this.createCommandForm.value.enabled,
comments: this.createCommandForm.value.comments, comments: this.createCommandForm.value.comments,
parameters: this.createCommandForm.value.parameters,
}; };
if (this.commandId) { if (this.commandId) {

View File

@ -0,0 +1,117 @@
.dialog-content {
display: flex;
flex-direction: column;
padding: 40px;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
.select-container {
margin-top: 20px;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
.button-row {
display: flex;
padding-right: 1em;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
}
.client-item {
position: relative;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.selected-client {
background-color: #a0c2e5 !important;
color: white !important;
}
.loading-spinner {
display: block;
margin: 0 auto;
align-items: center;
justify-content: center;
}
.client-details {
margin-top: 4px;
}
.client-name {
font-size: 0.9em;
font-weight: 600;
color: #333;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
}
.client-ip {
display: block;
font-size: 0.9em;
color: #666;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
@media (max-width: 600px) {
.form-field {
width: 100%;
}
.dialog-actions {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
}

View File

@ -0,0 +1,100 @@
<h2 mat-dialog-title> Seleccionar particion para arrancar SO</h2>
<mat-dialog-content class="dialog-content">
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
<div *ngIf="!loading" class="select-container">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title> Clientes </mat-panel-title>
<mat-panel-description>
Listado de clientes para arrancar un SO
<mat-icon>desktop_windows</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>
<div class="button-row">
<button class="action-button" (click)="toggleSelectAll()">
{{ allSelected ? 'Desmarcar todos' : 'Marcar todos' }}
</button>
</div>
<div class="clients-grid">
<div *ngFor="let client of data.clients" class="client-item">
<div class="client-card"
(click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}" >
<img
[src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon"
class="client-image" />
<div class="client-details">
<span class="client-name">{{ client.name | slice:0:20 }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
</div>
<mat-divider></mat-divider>
<mat-radio-group [(ngModel)]="selectedModelClient" (change)="loadPartitions(selectedModelClient)">
<mat-radio-button [value]="client"
color="primary"
[disabled]="!client.selected"
(click)="$event.stopPropagation()">
Modelo
</mat-radio-button>
</mat-radio-group>
</div>
</div>
</div>
</mat-expansion-panel>
</div>
<mat-divider *ngIf="!loading" style="margin-top: 20px;"></mat-divider>
<div *ngIf="!loading" class="partition-table-container">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">Seleccionar partición</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
[disabled]="!row.operativeSystem"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
</div>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</mat-dialog-content>
<div mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="close()">Cancelar</button>
<button class="submit-button" (click)="execute()" [disabled]="!selectedPartition">Ejecutar</button>
</div>

View File

@ -0,0 +1,82 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BootSoPartitionComponent } from './boot-so-partition.component';
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatButtonModule } from "@angular/material/button";
import { MatMenuModule } from "@angular/material/menu";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { MatTableModule } from "@angular/material/table";
import { MatSelectModule } from "@angular/material/select";
import { MatIconModule } from "@angular/material/icon";
import { ToastrModule, ToastrService } from "ngx-toastr";
import { TranslateModule } from "@ngx-translate/core";
import { DataService } from "../../data.service";
import { provideHttpClient } from "@angular/common/http";
import { provideHttpClientTesting } from "@angular/common/http/testing";
import { ConfigService } from "@services/config.service";
import { MatExpansionModule } from '@angular/material/expansion';
import { MatDividerModule } from '@angular/material/divider';
describe('BootSoPartitionComponent', () => {
let component: BootSoPartitionComponent;
let fixture: ComponentFixture<BootSoPartitionComponent>;
beforeEach(async () => {
const mockConfigService = {
apiUrl: 'http://mock-api-url',
mercureUrl: 'http://mock-mercure-url'
};
await TestBed.configureTestingModule({
declarations: [BootSoPartitionComponent],
imports: [
ReactiveFormsModule,
FormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
MatExpansionModule,
MatMenuModule,
BrowserAnimationsModule,
MatTableModule,
MatDividerModule,
MatSelectModule,
MatIconModule,
ToastrModule.forRoot(),
TranslateModule.forRoot()
],
providers: [
FormBuilder,
ToastrService,
DataService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: MatDialogRef,
useValue: {}
},
{
provide: MAT_DIALOG_DATA,
useValue: {
clients: []
}
},
{ provide: ConfigService, useValue: mockConfigService }
]
})
.compileComponents();
fixture = TestBed.createComponent(BootSoPartitionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,146 @@
import { Component, Inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { MatTableDataSource } from "@angular/material/table";
import { ConfigService } from "@services/config.service";
import { HttpClient } from "@angular/common/http";
import { ToastrService } from "ngx-toastr";
@Component({
selector: 'app-boot-so-partition',
templateUrl: './boot-so-partition.component.html',
styleUrl: './boot-so-partition.component.css'
})
export class BootSoPartitionComponent implements OnInit {
baseUrl: string;
selectedPartition: any = null;
dataSource = new MatTableDataSource<any>();
clientId: string | null = null;
selectedClients: any[] = [];
selectedModelClient: any = null;
filteredPartitions: any[] = [];
allSelected: boolean = false;
clientData: any[] = [];
loading: boolean = false;
columns = [
{
columnDef: 'diskNumber',
header: 'Disco',
cell: (partition: any) => partition.diskNumber
},
{
columnDef: 'partitionNumber',
header: 'Particion',
cell: (partition: any) => partition.partitionNumber
},
{
columnDef: 'size',
header: 'Tamaño',
cell: (partition: any) => `${partition.size} MB`
},
{
columnDef: 'partitionCode',
header: 'Tipo de partición',
cell: (partition: any) => partition.partitionCode
},
{
columnDef: 'filesystem',
header: 'Sistema de ficheros',
cell: (partition: any) => partition.filesystem
},
{
columnDef: 'operativeSystem',
header: 'SO',
cell: (partition: any) => partition.operativeSystem?.name
}
];
displayedColumns = ['select', ...this.columns.map(column => column.columnDef)];
constructor(
@Inject(MAT_DIALOG_DATA) public data: { clients: any },
private dialogRef: MatDialogRef<BootSoPartitionComponent>,
private configService: ConfigService,
private http: HttpClient,
private toastService: ToastrService,
) {
this.baseUrl = this.configService.apiUrl;
this.clientId = this.data.clients?.length ? this.data.clients[0]['@id'] : null;
this.data.clients.forEach((client: { selected: boolean; status: string }) => client.selected = true);
this.selectedClients = this.data.clients.filter((client: { selected: boolean }) => client.selected);
this.selectedModelClient = this.data.clients.find((client: { selected: boolean }) => client.selected) || null;
if (this.selectedModelClient) {
this.loadPartitions(this.selectedModelClient);
}
}
ngOnInit() {
}
loadPartitions(client: any) {
const url = `${this.baseUrl}${client.uuid}`;
this.http.get(url).subscribe(
(response: any) => {
if (response.partitions) {
this.dataSource.data = response.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0;
});
}
},
(error) => {
console.error('Error al cargar los datos del cliente:', error);
}
);
}
toggleClientSelection(client: any) {
client.selected = !client.selected;
this.updateSelectedClients();
}
updateSelectedClients() {
this.selectedClients = this.data.clients.filter(
(client: { selected: boolean; state: string }) => client.selected && client.state === "og-live"
);
if (!this.selectedClients.includes(this.selectedModelClient)) {
this.selectedModelClient = null;
this.filteredPartitions = [];
}
}
toggleSelectAll() {
this.allSelected = !this.allSelected;
this.data.clients.forEach((client: { selected: boolean; status: string }) => {
if (client.status === "og-live") {
client.selected = this.allSelected;
}
});
}
close() {
this.dialogRef.close();
}
execute(): void {
this.loading = true;
this.http.post(`${this.baseUrl}/clients/server/boot-client`, {
clients: this.selectedClients.map((client: any) => client.uuid),
partition: this.selectedPartition['@id']
}).subscribe(
response => {
this.toastService.success('Cliente actualizado correctamente');
this.dialogRef.close();
this.loading = false;
},
error => {
this.toastService.error(error.error['hydra:description']);
this.loading = false;
}
);
}
}

View File

@ -8,12 +8,17 @@
[matMenuTriggerFor]="commandMenu"> [matMenuTriggerFor]="commandMenu">
{{ buttonText }} {{ buttonText }}
</button> </button>
<button mat-menu-item *ngSwitchCase="'menu-item'" [matMenuTriggerFor]="commandMenu" [disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
<span>{{ buttonText }}</span>
</button>
</ng-container> </ng-container>
<mat-menu #commandMenu="matMenu"> <mat-menu #commandMenu="matMenu">
<button mat-menu-item [disabled]="command.disabled <button mat-menu-item [disabled]="command.disabled
|| (command.slug === 'create-image' && clientData.length > 1)" || (command.slug === 'create-image' && clientData.length > 1)" *ngFor="let command of arrayCommands"
*ngFor="let command of arrayCommands" (click)="onCommandSelect(command.slug)"> (click)="onCommandSelect(command.slug)">
{{ command.name }} {{ command.translationKey | translate }}
</button> </button>
</mat-menu> </mat-menu>

View File

@ -3,6 +3,11 @@ import { HttpClient } from '@angular/common/http';
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { ToastrService } from "ngx-toastr"; import { ToastrService } from "ngx-toastr";
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import { BootSoPartitionComponent } from "./boot-so-partition/boot-so-partition.component";
import { MatDialog } from "@angular/material/dialog";
import { RemoveCacheImageComponent } from "./remove-cache-image/remove-cache-image.component";
import { AuthService } from '@services/auth.service';
import {SoftwareProfilePartitionComponent} from "./software-profile-partition/software-profile-partition.component";
@Component({ @Component({
selector: 'app-execute-command', selector: 'app-execute-command',
@ -10,8 +15,10 @@ import { ConfigService } from '@services/config.service';
styleUrls: ['./execute-command.component.css'] styleUrls: ['./execute-command.component.css']
}) })
export class ExecuteCommandComponent implements OnInit { export class ExecuteCommandComponent implements OnInit {
@Input() runScriptContext: any = null;
@Input() clientState: string = 'off';
@Input() clientData: any[] = []; @Input() clientData: any[] = [];
@Input() buttonType: 'icon' | 'text' = 'icon'; @Input() buttonType: 'icon' | 'text' | 'menu-item' = 'icon';
@Input() buttonText: string = 'Ejecutar Comandos'; @Input() buttonText: string = 'Ejecutar Comandos';
@Input() icon: string = 'terminal'; @Input() icon: string = 'terminal';
@Input() disabled: boolean = false; @Input() disabled: boolean = false;
@ -19,17 +26,17 @@ export class ExecuteCommandComponent implements OnInit {
loading: boolean = true; loading: boolean = true;
arrayCommands: any[] = [ arrayCommands: any[] = [
{ name: 'Enceder', slug: 'power-on', disabled: false }, { translationKey: 'executeCommands.powerOn', slug: 'power-on', disabled: false },
{ name: 'Apagar', slug: 'power-off', disabled: false }, { translationKey: 'executeCommands.powerOff', slug: 'power-off', disabled: false },
{ name: 'Reiniciar', slug: 'reboot', disabled: false }, { translationKey: 'executeCommands.reboot', slug: 'reboot', disabled: false },
{ name: 'Iniciar Sesión', slug: 'login', disabled: true }, { translationKey: 'executeCommands.login', slug: 'login', disabled: true },
{ name: 'Crear imagen', slug: 'create-image', disabled: false }, { translationKey: 'executeCommands.createImage', slug: 'create-image', disabled: false },
{ name: 'Clonar/desplegar imagen', slug: 'deploy-image', disabled: false }, { translationKey: 'executeCommands.deployImage', slug: 'deploy-image', disabled: false },
{ name: 'Eliminar Imagen Cache', slug: 'delete-image-cache', disabled: true }, { translationKey: 'executeCommands.deleteImageCache', slug: 'remove-cache-image', disabled: false },
{ name: 'Particionar y Formatear', slug: 'partition', disabled: false }, { translationKey: 'executeCommands.partition', slug: 'partition', disabled: false },
{ name: 'Inventario Software', slug: 'software-inventory', disabled: true }, { translationKey: 'executeCommands.softwareInventory', slug: 'software-inventory', disabled: false },
{ name: 'Inventario Hardware', slug: 'hardware-inventory', disabled: true }, { translationKey: 'executeCommands.hardwareInventory', slug: 'hardware-inventory', disabled: true },
{ name: 'Ejecutar comando', slug: 'run-script', disabled: false }, { translationKey: 'executeCommands.runScript', slug: 'run-script', disabled: false },
]; ];
client: any = {}; client: any = {};
@ -38,13 +45,88 @@ export class ExecuteCommandComponent implements OnInit {
private http: HttpClient, private http: HttpClient,
private router: Router, private router: Router,
private configService: ConfigService, private configService: ConfigService,
private toastService: ToastrService private toastService: ToastrService,
public auth: AuthService,
private dialog: MatDialog,
) { ) {
this.baseUrl = this.configService.apiUrl; this.baseUrl = this.configService.apiUrl;
} }
ngOnInit(): void { ngOnInit(): void {
this.clientData = this.clientData || []; this.clientData = this.clientData || [];
const allowed = this.getAllowedCommandsByRole();
this.arrayCommands = this.arrayCommands.filter(c => allowed.includes(c.slug));
this.updateCommandStates();
}
ngOnChanges(): void {
this.updateCommandStates();
}
private getAllowedCommandsByRole(): string[] {
const role = this.auth.userCategory;
const permissions: Record<string, string[]> = {
'super-admin': ['*'],
'ou-admin': ['*'],
'ou-operator': [
'power-on',
'power-off',
'reboot',
'login',
'deploy-image',
'software-inventory',
'hardware-inventory',
'remove-cache-image',
'partition'
],
'ou-minimal': [
'power-on',
'power-off'
]
};
const allowed = permissions[role] || [];
return allowed.includes('*') ? this.arrayCommands.map(c => c.slug) : allowed;
}
private updateCommandStates(): void {
let states: string[] = [];
if (this.clientData.length > 0) {
states = this.clientData.map(client => client.status);
} else if (this.clientState) {
states = [this.clientState];
}
const allOffOrDisconnected = states.every(state => state === 'off' || state === 'disconnected');
const allSameState = states.every(state => state === states[0]);
const multipleClients = this.clientData.length > 1;
this.arrayCommands = this.arrayCommands.map(command => {
if (allOffOrDisconnected) {
command.disabled = command.slug !== 'power-on';
} else if (allSameState) {
if (states[0] === 'off' || states[0] === 'disconnected') {
command.disabled = command.slug !== 'power-on';
} else {
command.disabled = !['power-off', 'reboot', 'login', 'create-image', 'deploy-image', 'remove-cache-image', 'partition', 'run-script', 'software-inventory'].includes(command.slug);
}
} else {
if (command.slug === 'create-image'|| command.slug === 'software-inventory') {
command.disabled = multipleClients;
} else if (
['power-on', 'power-off', 'reboot', 'login', 'deploy-image', 'partition', 'remove-cache-image', 'run-script'].includes(command.slug)
) {
command.disabled = false;
} else {
command.disabled = true;
}
}
return command;
});
} }
onCommandSelect(action: any): void { onCommandSelect(action: any): void {
@ -79,6 +161,18 @@ export class ExecuteCommandComponent implements OnInit {
if (action === 'power-on') { if (action === 'power-on') {
this.powerOnClient(); this.powerOnClient();
} }
if (action === 'remove-cache-image') {
this.removeImageCache();
}
if (action === 'hardware-inventory') {
this.hardwareInventory();
}
if (action === 'software-inventory') {
this.softwareInventory();
}
} }
rebootClient(): void { rebootClient(): void {
@ -89,24 +183,108 @@ export class ExecuteCommandComponent implements OnInit {
this.toastService.success('Cliente actualizado correctamente'); this.toastService.success('Cliente actualizado correctamente');
}, },
error => { error => {
this.toastService.error('Error de conexión con el cliente'); this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente');
} }
); );
} }
loginClient(): void { loginClient(): void {
this.http.post(`${this.baseUrl}/clients/server/login-client`, { const clientDataToSend = this.clientData.map(client => ({
name: client.name,
mac: client.mac,
uuid: '/clients/' + client.uuid,
status: client.status,
partitions: client.partitions,
firmwareType: client.firmwareType,
ip: client.ip
}));
const dialogRef = this.dialog.open(BootSoPartitionComponent, {
width: '70vw',
height: 'auto',
data: { clients: clientDataToSend }
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.toastService.success('Petición de arranque de SO enviada correctamente');
}
});
}
removeImageCache(): void {
const clientDataToSend = this.clientData.map(client => ({
name: client.name,
mac: client.mac,
uuid: '/clients/' + client.uuid,
status: client.status,
partitions: client.partitions,
firmwareType: client.firmwareType,
ip: client.ip
}));
const dialogRef = this.dialog.open(RemoveCacheImageComponent, {
width: '70vw',
height: 'auto',
data: { clients: clientDataToSend }
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.toastService.success('Petición de borrado de caché de imagen enviada correctamente');
}
});
}
hardwareInventory(): void {
if (this.clientData.length === 0) {
this.toastService.error('No hay clientes seleccionados');
return;
}
const clientId = this.clientData[0].uuid;
this.http.post(`${this.baseUrl}/clients/server/${clientId}/hardware-inventory`, {
clients: this.clientData.map((client: any) => client['@id']) clients: this.clientData.map((client: any) => client['@id'])
}).subscribe( }).subscribe(
response => { response => {
this.toastService.success('Cliente actualizado correctamente'); this.toastService.success('Inventario de hardware actualizado correctamente');
}, },
error => { error => {
this.toastService.error('Error de conexión con el cliente'); this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente');
} }
); );
} }
softwareInventory(): void {
if (this.clientData.length === 0) {
this.toastService.error('No hay clientes seleccionados');
return;
}
const clientDataToSend = {
clientId: this.clientData[0].uuid,
name: this.clientData[0].name,
mac: this.clientData[0].mac,
status: this.clientData[0].status,
partitions: this.clientData[0].partitions,
firmwareType: this.clientData[0].firmwareType,
ip: this.clientData[0].ip
}
const clientId = this.clientData[0].uuid;
const dialogRef = this.dialog.open(SoftwareProfilePartitionComponent, {
width: '70vw',
height: 'auto',
data: { client: clientDataToSend }
});
dialogRef.afterClosed().subscribe(result => {
});
}
powerOnClient(): void { powerOnClient(): void {
this.http.post(`${this.baseUrl}/image-repositories/wol`, { this.http.post(`${this.baseUrl}/image-repositories/wol`, {
clients: this.clientData.map((client: any) => client['@id']) clients: this.clientData.map((client: any) => client['@id'])
@ -115,7 +293,7 @@ export class ExecuteCommandComponent implements OnInit {
this.toastService.success('Petición de encendido enviada correctamente'); this.toastService.success('Petición de encendido enviada correctamente');
}, },
error => { error => {
this.toastService.error('Error de conexión con el cliente'); this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente');
} }
); );
} }
@ -128,7 +306,7 @@ export class ExecuteCommandComponent implements OnInit {
this.toastService.success('Petición de apagado enviada correctamente'); this.toastService.success('Petición de apagado enviada correctamente');
}, },
error => { error => {
this.toastService.error('Error de conexión con el cliente'); this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente');
} }
); );
} }
@ -137,7 +315,7 @@ export class ExecuteCommandComponent implements OnInit {
const clientDataToSend = this.clientData.map(client => ({ const clientDataToSend = this.clientData.map(client => ({
name: client.name, name: client.name,
mac: client.mac, mac: client.mac,
uuid: '/clients/'+client.uuid, uuid: '/clients/' + client.uuid,
status: client.status, status: client.status,
partitions: client.partitions, partitions: client.partitions,
firmwareType: client.firmwareType, firmwareType: client.firmwareType,
@ -145,9 +323,10 @@ export class ExecuteCommandComponent implements OnInit {
})); }));
this.router.navigate(['/clients/partition-assistant'], { this.router.navigate(['/clients/partition-assistant'], {
queryParams: { clientData: JSON.stringify(clientDataToSend) } queryParams: {
}).then(r => { clientData: JSON.stringify(clientDataToSend),
console.log('Navigated to partition assistant with data:', this.clientData); runScriptContext: JSON.stringify(this.runScriptContext)
}
}); });
} }
@ -161,16 +340,17 @@ export class ExecuteCommandComponent implements OnInit {
const clientDataToSend = this.clientData.map(client => ({ const clientDataToSend = this.clientData.map(client => ({
name: client.name, name: client.name,
mac: client.mac, mac: client.mac,
uuid: '/clients/'+client.uuid, uuid: '/clients/' + client.uuid,
status: client.status, status: client.status,
partitions: client.partitions, partitions: client.partitions,
ip: client.ip ip: client.ip
})); }));
this.router.navigate(['/clients/deploy-image'], { this.router.navigate(['/clients/deploy-image'], {
queryParams: { clientData: JSON.stringify(clientDataToSend) } queryParams: {
}).then(r => { clientData: JSON.stringify(clientDataToSend),
console.log('Navigated to deploy image with data:', this.clientData); runScriptContext: JSON.stringify(this.runScriptContext)
}
}); });
} }
@ -178,18 +358,17 @@ export class ExecuteCommandComponent implements OnInit {
const clientDataToSend = this.clientData.map(client => ({ const clientDataToSend = this.clientData.map(client => ({
name: client.name, name: client.name,
mac: client.mac, mac: client.mac,
uuid: '/clients/'+client.uuid, uuid: '/clients/' + client.uuid,
status: client.status, status: client.status,
partitions: client.partitions, partitions: client.partitions,
ip: client.ip ip: client.ip
})); }));
this.router.navigate(['/clients/run-script'], { this.router.navigate(['/clients/run-script'], {
queryParams: { clientData: JSON.stringify(clientDataToSend) } queryParams: {
}).then(() => { clientData: JSON.stringify(clientDataToSend),
console.log('Navigated to run script with data:', clientDataToSend); runScriptContext: JSON.stringify(this.runScriptContext)
}); }
})
} }
} }

View File

@ -0,0 +1,117 @@
.dialog-content {
display: flex;
flex-direction: column;
padding: 40px;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
.select-container {
margin-top: 20px;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
.button-row {
display: flex;
padding-right: 1em;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
}
.client-item {
position: relative;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.selected-client {
background-color: #a0c2e5 !important;
color: white !important;
}
.loading-spinner {
display: block;
margin: 0 auto;
align-items: center;
justify-content: center;
}
.client-details {
margin-top: 4px;
}
.client-name {
font-size: 0.9em;
font-weight: 600;
color: #333;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
}
.client-ip {
display: block;
font-size: 0.9em;
color: #666;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
@media (max-width: 600px) {
.form-field {
width: 100%;
}
.dialog-actions {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
}

View File

@ -0,0 +1,105 @@
<h2 mat-dialog-title> Seleccionar imagen para eliminar de la cache</h2>
<mat-dialog-content class="dialog-content">
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
<div *ngIf="!loading" class="select-container">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title> Clientes </mat-panel-title>
<mat-panel-description>
Listado de clientes para arrancar un SO
<mat-icon>desktop_windows</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>
<div class="button-row">
<button class="action-button" (click)="toggleSelectAll()">
{{ allSelected ? 'Desmarcar todos' : 'Marcar todos' }}
</button>
</div>
<div class="clients-grid">
<div *ngFor="let client of data.clients" class="client-item">
<div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected, 'disabled-client': client.status !== 'og-live'}" >
<img
[src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon"
class="client-image" />
<div class="client-details">
<span class="client-name">{{ client.name | slice:0:20 }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
</div>
<mat-divider></mat-divider>
<mat-radio-group [(ngModel)]="selectedModelClient" (change)="loadPartitions(selectedModelClient)">
<mat-radio-button [value]="client"
color="primary"
[disabled]="!client.selected"
(click)="$event.stopPropagation()">
Modelo
</mat-radio-button>
</mat-radio-group>
</div>
</div>
</div>
</mat-expansion-panel>
</div>
<mat-divider *ngIf="!loading" style="margin-top: 20px;"></mat-divider>
<div *ngIf="!loading" class="partition-table-container">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">Seleccionar imagen</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
[disabled]="!row.operativeSystem"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size' && column.columnDef !== 'operativeSystem'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
</div>
</ng-container>
<ng-container *ngIf="column.columnDef === 'operativeSystem'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.operativeSystem?.name }} </span>
<span style="font-size: 0.75rem; color: gray;">{{ image.image?.name}} </span>
</div>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</mat-dialog-content>
<div mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="close()">Cancelar</button>
<button class="submit-button" (click)="execute()" [disabled]="!selectedPartition">Ejecutar</button>
</div>

View File

@ -0,0 +1,92 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RemoveCacheImageComponent } from './remove-cache-image.component';
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatButtonModule } from "@angular/material/button";
import { MatMenuModule } from "@angular/material/menu";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { MatTableModule } from "@angular/material/table";
import { MatSelectModule } from "@angular/material/select";
import { MatIconModule } from "@angular/material/icon";
import { ToastrModule, ToastrService } from "ngx-toastr";
import { TranslateModule } from "@ngx-translate/core";
import { DataService } from "../../data.service";
import { provideHttpClient } from "@angular/common/http";
import { provideHttpClientTesting } from "@angular/common/http/testing";
import { ConfigService } from "@services/config.service";
import { MatExpansionModule } from '@angular/material/expansion';
import { MatDividerModule } from '@angular/material/divider';
import { MatRadioModule } from '@angular/material/radio';
describe('RemoveCacheImageComponent', () => {
let component: RemoveCacheImageComponent;
let fixture: ComponentFixture<RemoveCacheImageComponent>;
beforeEach(async () => {
const mockConfigService = {
apiUrl: 'http://mock-api-url',
mercureUrl: 'http://mock-mercure-url'
};
await TestBed.configureTestingModule({
declarations: [RemoveCacheImageComponent],
imports: [
ReactiveFormsModule,
FormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
MatMenuModule,
MatExpansionModule,
BrowserAnimationsModule,
MatTableModule,
MatDividerModule,
MatSelectModule,
MatRadioModule,
MatIconModule,
ToastrModule.forRoot(),
TranslateModule.forRoot()
],
providers: [
FormBuilder,
ToastrService,
DataService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: MatDialogRef,
useValue: {}
},
{
provide: MAT_DIALOG_DATA,
useValue: {
clients: [
{
'@id': '/clients/1',
uuid: 'client-uuid-1',
selected: false,
status: 'og-live',
state: 'og-live'
}
]
}
},
{ provide: ConfigService, useValue: mockConfigService }
]
})
.compileComponents();
fixture = TestBed.createComponent(RemoveCacheImageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,154 @@
import {Component, Inject} from '@angular/core';
import {MatTableDataSource} from "@angular/material/table";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ConfigService} from "@services/config.service";
import {HttpClient} from "@angular/common/http";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-remove-cache-image',
templateUrl: './remove-cache-image.component.html',
styleUrl: './remove-cache-image.component.css'
})
export class RemoveCacheImageComponent {
baseUrl: string;
selectedPartition: any = null;
dataSource = new MatTableDataSource<any>();
clientId: string | null = null;
selectedClients: any[] = [];
selectedModelClient: any = null;
filteredPartitions: any[] = [];
allSelected: boolean = false;
clientData: any[] = [];
loading: boolean = false;
columns = [
{
columnDef: 'diskNumber',
header: 'Disco',
cell: (partition: any) => partition.diskNumber
},
{
columnDef: 'partitionNumber',
header: 'Particion',
cell: (partition: any) => partition.partitionNumber
},
{
columnDef: 'size',
header: 'Tamaño',
cell: (partition: any) => `${partition.size} MB`
},
{
columnDef: 'partitionCode',
header: 'Tipo de partición',
cell: (partition: any) => partition.partitionCode
},
{
columnDef: 'filesystem',
header: 'Sistema de ficheros',
cell: (partition: any) => partition.filesystem
},
{
columnDef: 'operativeSystem',
header: 'SO',
cell: (partition: any) => partition.operativeSystem?.name
}
];
displayedColumns = ['select', ...this.columns.map(column => column.columnDef)];
constructor(
@Inject(MAT_DIALOG_DATA) public data: { clients: any },
private dialogRef: MatDialogRef<RemoveCacheImageComponent>,
private configService: ConfigService,
private http: HttpClient,
private toastService: ToastrService,
) {
this.baseUrl = this.configService.apiUrl;
this.clientId = this.data.clients?.length ? this.data.clients[0]['@id'] : null;
this.data.clients.forEach((client: { selected: boolean; status: string }) => {
if (client.status === 'og-live') {
client.selected = true;
}
});
this.selectedClients = this.data.clients.filter(
(client: { status: string }) => client.status === 'og-live'
);
this.selectedModelClient = this.data.clients.find(
(client: { status: string }) => client.status === 'og-live'
) || null;
if (this.selectedModelClient) {
this.loadPartitions(this.selectedModelClient);
}
}
ngOnInit() {
}
loadPartitions(client: any) {
const url = `${this.baseUrl}${client.uuid}`;
this.http.get(url).subscribe(
(response: any) => {
if (response.partitions) {
this.dataSource.data = response.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0 && partition.image;
});
}
},
(error) => {
console.error('Error al cargar los datos del cliente:', error);
}
);
}
toggleClientSelection(client: any) {
client.selected = !client.selected;
this.updateSelectedClients();
}
updateSelectedClients() {
this.selectedClients = this.data.clients.filter(
(client: { selected: boolean; state: string }) => client.selected && client.state === "og-live"
);
if (!this.selectedClients.includes(this.selectedModelClient)) {
this.selectedModelClient = null;
this.filteredPartitions = [];
}
}
toggleSelectAll() {
this.allSelected = !this.allSelected;
this.data.clients.forEach((client: { selected: boolean; status: string }) => {
if (client.status === "og-live") {
client.selected = this.allSelected;
}
});
}
close() {
this.dialogRef.close();
}
execute(): void {
this.loading = true;
this.http.post(`${this.baseUrl}/clients/server/remove-cache-image`, {
clients: this.selectedClients.map((client: any) => client.uuid),
partition: this.selectedPartition['@id']
}).subscribe(
response => {
this.toastService.success('Cliente actualizado correctamente');
this.dialogRef.close();
this.loading = false;
},
error => {
this.toastService.error(error.error['hydra:description']);
this.loading = false;
}
);
}
}

View File

@ -0,0 +1,117 @@
.dialog-content {
display: flex;
flex-direction: column;
padding: 40px;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
.select-container {
margin-top: 20px;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
.button-row {
display: flex;
padding-right: 1em;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
}
.client-item {
position: relative;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.selected-client {
background-color: #a0c2e5 !important;
color: white !important;
}
.loading-spinner {
display: block;
margin: 0 auto;
align-items: center;
justify-content: center;
}
.client-details {
margin-top: 4px;
}
.client-name {
font-size: 0.9em;
font-weight: 600;
color: #333;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
}
.client-ip {
display: block;
font-size: 0.9em;
color: #666;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
@media (max-width: 600px) {
.form-field {
width: 100%;
}
.dialog-actions {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
}

View File

@ -0,0 +1,55 @@
<h2 mat-dialog-title> Seleccionar partición para inventariar</h2>
<mat-dialog-content class="dialog-content">
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
<mat-divider *ngIf="!loading" style="margin-top: 20px;"></mat-divider>
<div *ngIf="!loading" class="partition-table-container">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">Seleccionar imagen</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size' && column.columnDef !== 'operativeSystem'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
</div>
</ng-container>
<ng-container *ngIf="column.columnDef === 'operativeSystem'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.operativeSystem?.name }} </span>
<span style="font-size: 0.75rem; color: gray;">{{ image.image?.name}} </span>
</div>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</mat-dialog-content>
<div mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="close()">Cancelar</button>
<button class="submit-button" (click)="execute()" [disabled]="!selectedPartition">Ejecutar</button>
</div>

View File

@ -0,0 +1,71 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SoftwareProfilePartitionComponent } from './software-profile-partition.component';
import {FormBuilder, ReactiveFormsModule} from "@angular/forms";
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from "@angular/material/dialog";
import {MatFormFieldModule} from "@angular/material/form-field";
import {MatInputModule} from "@angular/material/input";
import {MatCheckboxModule} from "@angular/material/checkbox";
import {MatButtonModule} from "@angular/material/button";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {ToastrModule, ToastrService} from "ngx-toastr";
import {TranslateModule} from "@ngx-translate/core";
import {DataService} from "../../data.service";
import {provideHttpClient} from "@angular/common/http";
import {provideHttpClientTesting} from "@angular/common/http/testing";
import {ConfigService} from "@services/config.service";
import {MatDividerModule} from "@angular/material/divider";
import {MatTableModule} from "@angular/material/table";
describe('SoftwareProfilePartitionComponent', () => {
let component: SoftwareProfilePartitionComponent;
let fixture: ComponentFixture<SoftwareProfilePartitionComponent>;
beforeEach(async () => {
const mockConfigService = {
apiUrl: 'http://mock-api-url',
mercureUrl: 'http://mock-mercure-url'
};
await TestBed.configureTestingModule({
declarations: [SoftwareProfilePartitionComponent],
imports: [
ReactiveFormsModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatCheckboxModule,
MatButtonModule,
BrowserAnimationsModule,
MatDividerModule,
MatTableModule,
ToastrModule.forRoot(),
TranslateModule.forRoot()
],
providers: [
FormBuilder,
ToastrService,
DataService,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: MatDialogRef,
useValue: {}
},
{
provide: MAT_DIALOG_DATA,
useValue: {}
},
{ provide: ConfigService, useValue: mockConfigService }
]
})
.compileComponents();
fixture = TestBed.createComponent(SoftwareProfilePartitionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,107 @@
import {Component, Inject, OnInit} from '@angular/core';
import {MatTableDataSource} from "@angular/material/table";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ConfigService} from "@services/config.service";
import {HttpClient} from "@angular/common/http";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-software-profile-partition',
templateUrl: './software-profile-partition.component.html',
styleUrl: './software-profile-partition.component.css'
})
export class SoftwareProfilePartitionComponent implements OnInit{
baseUrl: string;
selectedPartition: any = null;
dataSource = new MatTableDataSource<any>();
clientId: string | null = null;
selectedClients: any[] = [];
selectedModelClient: any = null;
filteredPartitions: any[] = [];
allSelected: boolean = false;
clientData: any[] = [];
loading: boolean = false;
columns = [
{
columnDef: 'diskNumber',
header: 'Disco',
cell: (partition: any) => partition.diskNumber
},
{
columnDef: 'partitionNumber',
header: 'Particion',
cell: (partition: any) => partition.partitionNumber
},
{
columnDef: 'size',
header: 'Tamaño',
cell: (partition: any) => `${partition.size} MB`
},
{
columnDef: 'partitionCode',
header: 'Tipo de partición',
cell: (partition: any) => partition.partitionCode
},
{
columnDef: 'filesystem',
header: 'Sistema de ficheros',
cell: (partition: any) => partition.filesystem
},
{
columnDef: 'operativeSystem',
header: 'SO',
cell: (partition: any) => partition.operativeSystem?.name
}
];
displayedColumns = ['select', ...this.columns.map(column => column.columnDef)];
constructor(
@Inject(MAT_DIALOG_DATA) public data: { client: any },
private dialogRef: MatDialogRef<SoftwareProfilePartitionComponent>,
private configService: ConfigService,
private http: HttpClient,
private toastService: ToastrService,
) {
this.baseUrl = this.configService.apiUrl;
this.clientId = this.data.client?.clientId
}
ngOnInit() {
this.loadPartitions();
}
loadPartitions() {
const url = `${this.baseUrl}/clients/${this.data.client?.clientId}`;
this.http.get(url).subscribe(
(response: any) => {
if (response.partitions) {
this.dataSource.data = response.partitions;
}
},
(error) => {
console.error('Error al cargar los datos del cliente:', error);
}
);
}
close() {
this.dialogRef.close();
}
execute(): void {
this.loading = true;
this.http.post(`${this.baseUrl}/clients/server/${this.data.client.clientId}/software-inventory`, {
partition: this.selectedPartition['@id'],
}).subscribe(
response => {
this.toastService.success('Inventario de software actualizado correctamente');
this.dialogRef.close(response);
},
error => {
this.toastService.error(error.error['hydra:description'] || 'Error al actualizar el inventario de software');
}
);
}
}

View File

@ -1 +0,0 @@
<p>dashboard works!</p>

View File

@ -1,23 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
describe('DashboardComponent', () => {
let component: DashboardComponent;
let fixture: ComponentFixture<DashboardComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DashboardComponent]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
});

View File

@ -1,10 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.css'
})
export class DashboardComponent {
}

View File

@ -1,44 +1,557 @@
/* ===== HEADER DE BIENVENIDA ===== */
.welcome-header {
background: #3f51b5;
color: white;
padding: 2rem 2rem 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
position: relative;
overflow: hidden;
}
.welcome-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="white" opacity="0.1"/><circle cx="75" cy="75" r="1" fill="white" opacity="0.1"/><circle cx="50" cy="10" r="0.5" fill="white" opacity="0.1"/><circle cx="10" cy="60" r="0.5" fill="white" opacity="0.1"/><circle cx="90" cy="40" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.2;
}
.welcome-content {
display: flex;
align-items: center;
gap: 1.5rem;
position: relative;
z-index: 1;
}
.welcome-icon {
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.welcome-icon mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: white;
}
.welcome-text {
flex: 1;
}
.welcome-title {
margin: 0 0 0.5rem 0;
font-size: 2rem;
font-weight: 700;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.welcome-subtitle {
margin: 0 0 0.25rem 0;
font-size: 1.1rem;
font-weight: 500;
opacity: 0.95;
}
.welcome-description {
margin: 0;
font-size: 0.9rem;
opacity: 0.8;
}
.welcome-actions {
display: flex;
gap: 0.5rem;
position: relative;
z-index: 1;
}
.help-button,
.refresh-button {
background: rgba(255, 255, 255, 0.2) !important;
color: white !important;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
}
.help-button:hover,
.refresh-button:hover {
background: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* ===== CONTENIDO PRINCIPAL ===== */
mat-dialog-content { mat-dialog-content {
height: calc(100% - 64px); height: calc(100% - 200px);
overflow: auto; overflow: auto;
padding-top: 0.5em !important; padding: 0 !important;
background: #f8f9fa;
} }
.content-container { .main-content {
min-height: 100%; padding: 2rem;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
} }
/* ===== SPINNER DE CARGA ===== */
.spinner-container { .spinner-container {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
z-index: 1000; z-index: 1000;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.loading-content {
text-align: center;
} }
.loading-spinner { .loading-spinner {
width: 100px; width: 60px !important;
height: 100px; height: 60px !important;
margin-bottom: 1rem;
}
.loading-text {
margin: 0;
color: #666;
font-weight: 500;
}
/* ===== RESUMEN DEL SISTEMA ===== */
.system-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.overview-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
border: 1px solid rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
}
.overview-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
opacity: 0;
transition: opacity 0.3s ease;
}
.overview-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.overview-card:hover::before {
opacity: 1;
}
.overview-icon {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.overview-icon mat-icon {
color: white;
font-size: 24px;
width: 24px;
height: 24px;
}
.overview-content h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
}
.overview-content p {
margin: 0;
font-size: 0.9rem;
color: #6c757d;
}
/* ===== BADGES DE ESTADO ===== */
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.online {
background: linear-gradient(135deg, #28a745, #20c997);
color: white;
}
.status-badge.offline {
background: linear-gradient(135deg, #dc3545, #c82333);
color: white;
}
.status-badge.info {
background: linear-gradient(135deg, #17a2b8, #138496);
color: white;
}
/* ===== TABS PRINCIPALES ===== */
.main-tabs {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
::ng-deep .main-tabs .mat-tab-header {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
::ng-deep .main-tabs .mat-tab-label {
font-weight: 600;
color: #6c757d;
transition: all 0.3s ease;
}
::ng-deep .main-tabs .mat-tab-label-active {
color: #667eea;
}
::ng-deep .main-tabs .mat-ink-bar {
background: linear-gradient(90deg, #667eea, #764ba2);
height: 3px;
}
.tab-content {
padding: 2rem;
}
/* ===== CONTENEDOR DE ERRORES ===== */
.error-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
padding: 2rem;
} }
.error-card { .error-card {
margin: 20px auto; background: white;
max-width: 600px; border-radius: 16px;
text-align: center; padding: 3rem 2rem;
background-color: rgb(243, 243, 243); text-align: center;
color: rgb(48, 48, 48); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #e9ecef;
max-width: 400px;
}
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: #dc3545;
margin-bottom: 1rem;
}
.error-card h3 {
margin: 0 0 1rem 0;
color: #2c3e50;
font-weight: 600;
} }
.error-card p { .error-card p {
margin-top: 0; margin: 0 0 2rem 0;
color: #6c757d;
line-height: 1.5;
}
/* ===== REPOSITORIOS ===== */
.repositories-container {
padding: 1rem;
}
.repositories-selector-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.repository-selector {
display: flex;
justify-content: center;
padding: 1rem 0;
}
.repository-content {
padding: 1rem;
}
.repository-select-field {
min-width: 300px;
max-width: 500px;
width: 100%;
}
.selected-repository-content {
animation: fadeInUp 0.6s ease-out;
}
.no-repository-selected {
text-align: center;
padding: 3rem 2rem;
color: #6c757d;
}
.no-repository-selected .no-data-icon {
font-size: 4rem;
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.no-repository-selected h3 {
margin: 0 0 1rem 0;
color: #2c3e50;
font-weight: 600;
}
.no-repository-selected p {
margin: 0;
font-size: 1rem;
line-height: 1.5;
}
/* ===== FOOTER CON ACCIONES ===== */
.action-container {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
background: white;
border-top: 1px solid #e9ecef;
border-radius: 0 0 12px 12px;
}
.action-info {
flex: 1;
}
.last-update {
margin: 0;
font-size: 0.85rem;
color: #6c757d;
}
.action-buttons {
display: flex;
gap: 1rem;
}
.secondary-button {
color: #6c757d !important;
border: 1px solid #dee2e6 !important;
transition: all 0.3s ease;
}
.secondary-button:hover {
background: #f8f9fa !important;
border-color: #667eea !important;
color: #667eea !important;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.welcome-header {
padding: 1.5rem 1rem 1rem 1rem;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.welcome-content {
flex-direction: column;
gap: 1rem;
}
.welcome-title {
font-size: 1.5rem;
}
.welcome-subtitle {
font-size: 1rem;
}
.main-content {
padding: 1rem;
}
.system-overview {
grid-template-columns: 1fr;
gap: 1rem;
}
.overview-card {
padding: 1rem;
}
.action-container {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.action-buttons {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.welcome-header {
padding: 1rem;
}
.welcome-icon {
width: 50px;
height: 50px;
}
.welcome-icon mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
}
.welcome-title {
font-size: 1.25rem;
}
.overview-card {
flex-direction: column;
text-align: center;
}
.tab-content {
padding: 1rem;
}
.repository-select-field {
min-width: 250px;
max-width: 100%;
}
}
/* ===== ANIMACIONES ===== */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.system-overview,
.main-tabs {
animation: fadeInUp 0.6s ease-out;
}
.overview-card {
animation: fadeInUp 0.6s ease-out;
}
.overview-card:nth-child(1) { animation-delay: 0.1s; }
.overview-card:nth-child(2) { animation-delay: 0.2s; }
.overview-card:nth-child(3) { animation-delay: 0.3s; }
/* ===== ESTILOS GLOBALES DEL DIALOG ===== */
::ng-deep .mat-dialog-container {
border-radius: 16px !important;
overflow: hidden !important;
padding: 0 !important;
}
::ng-deep .mat-dialog-content {
margin: 0 !important;
padding: 0 !important;
}
/* ===== LOADING INDIVIDUAL POR TAB ===== */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
padding: 2rem;
}
.loading-container .loading-content {
text-align: center;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.loading-container .loading-spinner {
width: 60px !important;
height: 60px !important;
margin-bottom: 1rem;
}
.loading-container .loading-text {
margin: 0;
color: #666;
font-weight: 500;
} }

View File

@ -1,94 +1,209 @@
<header> <!-- Header con bienvenida -->
<h1 mat-dialog-title>{{'GlobalStatus' | translate}}</h1> <div class="welcome-header">
</header> <div class="welcome-content">
<mat-dialog-content [ngClass]="{'loading': loading}"> <div class="welcome-icon">
<div class="spinner-container" *ngIf="loading"> <mat-icon>dashboard</mat-icon>
<mat-spinner class="loading-spinner"></mat-spinner> </div>
<div class="welcome-text">
<h1 class="welcome-title">{{ 'GlobalStatus' | translate }}</h1>
<p class="welcome-subtitle">Bienvenido a la consola de administración de OpenGnsys</p>
<p class="welcome-description">Estado general de todos los componentes de la plataforma</p>
</div>
</div> </div>
<mat-tab-group (selectedTabChange)="onTabChange($event)"> <div class="welcome-actions">
<mat-tab label="OgBoot"> <button mat-icon-button class="refresh-button" (click)="refreshAll()" matTooltip="Actualizar datos">
<div *ngIf="!loading && !errorOgBoot" class="content-container"> <mat-icon>refresh</mat-icon>
<app-status-tab </button>
[loading]="loading" </div>
[diskUsage]="ogBootDiskUsage" </div>
[servicesStatus]="ogBootServicesStatus"
[installedOgLives]="installedOgLives"
[diskUsageChartData]="ogBootDiskUsageChartData"
[view]="view"
[colorScheme]="colorScheme"
[isDoughnut]="isDoughnut"
[showLabels]="showLabels"
[isDhcp]="isDhcp"
[isRepository]="false">
</app-status-tab>
</div>
<mat-card *ngIf="!loading && errorOgBoot" class="error-card">
<mat-card-content>
<p>{{ 'errorLoadingData' | translate }}</p>
</mat-card-content>
</mat-card>
</mat-tab>
<mat-tab label="Dhcp"> <!-- Contenido principal -->
<div *ngIf="!loading && !errorDhcp" class="content-container"> <mat-dialog-content [ngClass]="{'loading': loading}">
<app-status-tab <!-- Spinner de carga -->
[loading]="loading" <div class="spinner-container" *ngIf="loading">
[diskUsage]="dhcpDiskUsage" <div class="loading-content">
[servicesStatus]="dhcpServicesStatus" <mat-spinner class="loading-spinner"></mat-spinner>
[subnets]="subnets" <p class="loading-text">Cargando estado del sistema...</p>
[diskUsageChartData]="dhcpDiskUsageChartData" </div>
[view]="view" </div>
[colorScheme]="colorScheme"
[isDoughnut]="isDoughnut"
[showLabels]="showLabels"
[isDhcp]="isDhcp"
[isRepository]="false">
</app-status-tab>
</div>
<mat-card *ngIf="!loading && errorDhcp" class="error-card">
<mat-card-content>
<p>{{ 'errorLoadingData' | translate }}</p>
</mat-card-content>
</mat-card>
</mat-tab>
<mat-tab label="Repositorios"> <!-- Contenido principal cuando no está cargando -->
<mat-tab-group> <div *ngIf="!loading" class="main-content">
<mat-tab *ngFor="let repository of repositories" [label]="repository.name"> <!-- Resumen rápido del sistema -->
<div *ngIf="!loading && !errorRepositories[repository.uuid] && repositoryStatuses[repository.uuid]"> <div class="system-overview">
<app-status-tab <div class="overview-card">
[loading]="loading" <div class="overview-icon">
[diskUsage]="repositoryStatuses[repository.uuid].disk" <mat-icon>cloud</mat-icon>
[servicesStatus]="repositoryStatuses[repository.uuid].services" </div>
[processesStatus]="repositoryStatuses[repository.uuid].processes" <div class="overview-content">
[ramUsage]="repositoryStatuses[repository.uuid].ram" <h3>Repositorios</h3>
[cpuUsage]="repositoryStatuses[repository.uuid].cpu" <p>Total: <span class="status-badge info">{{ repositories.length }}</span></p>
[diskUsageChartData]="[ </div>
{ name: 'Usado', value: repositoryStatuses[repository.uuid].disk.used }, </div>
{ name: 'Disponible', value: repositoryStatuses[repository.uuid].disk.available }
]" <div class="overview-card">
[ramUsageChartData]="[ <div class="overview-icon">
{ name: 'Usado', value: repositoryStatuses[repository.uuid].ram.used }, <mat-icon>storage</mat-icon>
{ name: 'Disponible', value: repositoryStatuses[repository.uuid].ram.available } </div>
]" <div class="overview-content">
[view]="view" <h3>OgBoot Server</h3>
[colorScheme]="colorScheme" <p *ngIf="!errorOgBoot">Estado: <span class="status-badge online">Operativo</span></p>
[isDoughnut]="isDoughnut" <p *ngIf="errorOgBoot">Estado: <span class="status-badge offline">Error</span></p>
[showLabels]="showLabels" </div>
[isDhcp]="false" </div>
[isRepository]="true">
</app-status-tab> <div class="overview-card">
<div class="overview-icon">
<mat-icon>router</mat-icon>
</div>
<div class="overview-content">
<h3>DHCP Server</h3>
<p *ngIf="!errorDhcp">Estado: <span class="status-badge online">Operativo</span></p>
<p *ngIf="errorDhcp">Estado: <span class="status-badge offline">Error</span></p>
</div>
</div>
</div>
<!-- Tabs principales -->
<mat-tab-group (selectedTabChange)="onTabChange($event)" class="main-tabs">
<mat-tab label="{{ 'repositoryLabel' | translate }}">
<div class="repositories-container">
<div *ngIf="repositories.length === 0" class="no-repositories">
<mat-icon class="no-data-icon">cloud_off</mat-icon>
<h3>No hay repositorios disponibles</h3>
<p>No se encontraron repositorios configurados en el sistema.</p>
</div> </div>
<mat-card *ngIf="!loading && errorRepositories[repository.uuid]" class="error-card">
<mat-card-content> <div *ngIf="repositories.length > 0" class="repositories-selector-container">
<p>{{ 'errorLoadingData' | translate }}</p> <!-- Selector de repositorio -->
</mat-card-content> <div class="repository-selector">
</mat-card> <mat-form-field appearance="outline" class="repository-select-field">
</mat-tab> <mat-label>Seleccionar repositorio</mat-label>
</mat-tab-group> <mat-select [(ngModel)]="selectedRepositoryUuid" (selectionChange)="onRepositoryChange($event.value)">
</mat-tab> <mat-option *ngFor="let repository of repositories" [value]="repository.uuid">
</mat-tab-group> {{ repository.name }}
</mat-option>
</mat-select>
<mat-icon matSuffix>storage</mat-icon>
</mat-form-field>
</div>
<!-- Información del repositorio seleccionado -->
<div *ngIf="selectedRepositoryUuid && !errorRepositories[selectedRepositoryUuid] && repositoryStatuses[selectedRepositoryUuid]" class="selected-repository-content">
<div class="repository-item">
<div class="repository-header">
<h3>{{ getSelectedRepositoryName() }}</h3>
</div>
<div class="repository-content">
<app-status-tab [loading]="loading" [diskUsage]="repositoryStatuses[selectedRepositoryUuid].disk"
[servicesStatus]="repositoryStatuses[selectedRepositoryUuid].services"
[processesStatus]="repositoryStatuses[selectedRepositoryUuid].processes"
[ramUsage]="repositoryStatuses[selectedRepositoryUuid].ram" [cpuUsage]="repositoryStatuses[selectedRepositoryUuid].cpu"
[diskUsageChartData]="[
{ name: 'Usado', value: repositoryStatuses[selectedRepositoryUuid].disk.used },
{ name: 'Disponible', value: repositoryStatuses[selectedRepositoryUuid].disk.available }
]" [ramUsageChartData]="[
{ name: 'Usado', value: repositoryStatuses[selectedRepositoryUuid].ram.used },
{ name: 'Disponible', value: repositoryStatuses[selectedRepositoryUuid].ram.available }
]" [view]="view" [colorScheme]="colorScheme" [isDoughnut]="isDoughnut" [showLabels]="showLabels"
[isDhcp]="false" [isRepository]="true">
</app-status-tab>
</div>
</div>
</div>
<!-- Mensaje cuando no hay repositorio seleccionado -->
<div *ngIf="!selectedRepositoryUuid" class="no-repository-selected">
<mat-icon class="no-data-icon">storage</mat-icon>
<h3>Selecciona un repositorio</h3>
<p>Elige un repositorio de la lista para ver su estado detallado.</p>
</div>
<!-- Error al cargar repositorio -->
<div *ngIf="selectedRepositoryUuid && errorRepositories[selectedRepositoryUuid]" class="error-container">
<div class="error-card">
<mat-icon class="error-icon">error_outline</mat-icon>
<h3>Error de conexión</h3>
<p>No se pudo conectar con el repositorio seleccionado</p>
<button mat-raised-button color="primary" (click)="retryRepositoryStatus(selectedRepositoryUuid)">
<mat-icon>refresh</mat-icon>
Reintentar
</button>
</div>
</div>
</div>
</div>
</mat-tab>
<mat-tab label="OgBoot Server">
<div *ngIf="!errorOgBoot && !loadingOgBoot" class="tab-content">
<app-status-tab [loading]="loadingOgBoot" [diskUsage]="ogBootDiskUsage" [servicesStatus]="ogBootServicesStatus"
[installedOgLives]="installedOgLives" [diskUsageChartData]="ogBootDiskUsageChartData" [view]="view"
[colorScheme]="colorScheme" [isDoughnut]="isDoughnut" [showLabels]="showLabels" [isDhcp]="isDhcp"
[isRepository]="false">
</app-status-tab>
</div>
<div *ngIf="loadingOgBoot" class="loading-container">
<div class="loading-content">
<mat-spinner class="loading-spinner"></mat-spinner>
<p class="loading-text">Cargando estado de OgBoot...</p>
</div>
</div>
<div *ngIf="errorOgBoot" class="error-container">
<div class="error-card">
<mat-icon class="error-icon">error_outline</mat-icon>
<h3>Error de conexión</h3>
<p>No se pudo conectar con el servidor OgBoot</p>
<button mat-raised-button color="primary" (click)="loadOgBootStatus()">
<mat-icon>refresh</mat-icon>
Reintentar
</button>
</div>
</div>
</mat-tab>
<mat-tab label="DHCP Server">
<div *ngIf="!errorDhcp && !loadingDhcp" class="tab-content">
<app-status-tab [loading]="loadingDhcp" [diskUsage]="dhcpDiskUsage" [servicesStatus]="dhcpServicesStatus"
[subnets]="subnets" [diskUsageChartData]="dhcpDiskUsageChartData" [view]="view" [colorScheme]="colorScheme"
[isDoughnut]="isDoughnut" [showLabels]="showLabels" [isDhcp]="true" [isRepository]="false">
</app-status-tab>
</div>
<div *ngIf="loadingDhcp" class="loading-container">
<div class="loading-content">
<mat-spinner class="loading-spinner"></mat-spinner>
<p class="loading-text">Cargando estado de DHCP...</p>
</div>
</div>
<div *ngIf="errorDhcp" class="error-container">
<div class="error-card">
<mat-icon class="error-icon">error_outline</mat-icon>
<h3>Error de conexión</h3>
<p>No se pudo conectar con el servidor DHCP</p>
<button mat-raised-button color="primary" (click)="loadDhcpStatus()">
<mat-icon>refresh</mat-icon>
Reintentar
</button>
</div>
</div>
</mat-tab>
</mat-tab-group>
</div>
</mat-dialog-content> </mat-dialog-content>
<!-- Footer con acciones -->
<mat-dialog-actions class="action-container"> <mat-dialog-actions class="action-container">
<button class="ordinary-button" [mat-dialog-close]="true">{{ 'closeButton' | translate }}</button> <div class="action-info">
<p class="last-update">Última actualización: {{ lastUpdateTime }}</p>
</div>
<div class="action-buttons">
<button mat-button class="secondary-button" (click)="refreshAll()">
<mat-icon>refresh</mat-icon>
Actualizar
</button>
<button mat-raised-button color="primary" [mat-dialog-close]="true">
{{ 'closeButton' | translate }}
</button>
</div>
</mat-dialog-actions> </mat-dialog-actions>

View File

@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import { MatTabChangeEvent } from '@angular/material/tabs'; import { MatTabChangeEvent } from '@angular/material/tabs';
import {ToastrService} from "ngx-toastr";
@Component({ @Component({
selector: 'app-global-status', selector: 'app-global-status',
@ -11,6 +12,9 @@ import { MatTabChangeEvent } from '@angular/material/tabs';
export class GlobalStatusComponent implements OnInit { export class GlobalStatusComponent implements OnInit {
baseUrl: string; baseUrl: string;
loading: boolean = false; loading: boolean = false;
loadingOgBoot: boolean = false;
loadingDhcp: boolean = false;
loadingRepositories: boolean = false;
errorOgBoot: boolean = false; errorOgBoot: boolean = false;
errorDhcp: boolean = false; errorDhcp: boolean = false;
errorRepositories: { [key: string]: boolean } = {}; errorRepositories: { [key: string]: boolean } = {};
@ -25,6 +29,8 @@ export class GlobalStatusComponent implements OnInit {
repositoriesUrl: string; repositoriesUrl: string;
repositories: any[] = []; repositories: any[] = [];
repositoryStatuses: { [key: string]: any } = {}; repositoryStatuses: { [key: string]: any } = {};
lastUpdateTime: string = '';
selectedRepositoryUuid: string = '';
ogBootApiUrl: string; ogBootApiUrl: string;
ogBootDiskUsage: any = {}; ogBootDiskUsage: any = {};
@ -38,27 +44,109 @@ export class GlobalStatusComponent implements OnInit {
isDhcp: boolean = false; isDhcp: boolean = false;
isRepository: boolean = false; isRepository: boolean = false;
// Loading específicos para cada sección
loadingOgBootOgLives: boolean = false;
loadingOgBootServices: boolean = false;
loadingOgBootDisk: boolean = false;
loadingDhcpSubnets: boolean = false;
loadingDhcpServices: boolean = false;
loadingDhcpDisk: boolean = false;
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
private toastService: ToastrService,
private http: HttpClient private http: HttpClient
) { ) {
this.baseUrl = this.configService.apiUrl; this.baseUrl = this.configService.apiUrl;
this.ogBootApiUrl = `${this.baseUrl}/og-boot/status`; this.ogBootApiUrl = `${this.baseUrl}/og-boot/status`;
this.dhcpApiUrl = `${this.baseUrl}/og-dhcp/status`; this.dhcpApiUrl = `${this.baseUrl}/og-dhcp/status`;
this.repositoriesUrl = `${this.baseUrl}/image-repositories`; this.repositoriesUrl = `${this.baseUrl}/image-repositories`;
this.ogBootDiskUsageChartData = [];
this.dhcpDiskUsageChartData = [];
} }
ngOnInit(): void { ngOnInit(): void {
this.updateLastUpdateTime();
this.loadOgBootStatus(); this.loadOgBootStatus();
this.loadDhcpStatus();
this.loadRepositories(false);
this.syncSubnets();
this.syncTemplates();
this.syncOgLives();
}
syncSubnets() {
const timeoutId = setTimeout(() => {
this.toastService.error('Error al sincronizar las subredes: tiempo de espera agotado');
}, 3500);
this.http.post(`${this.baseUrl}/subnets/sync`, {}).subscribe({
next: (response) => {
clearTimeout(timeoutId);
this.toastService.success('Sincronización con componente DHCP exitosa');
},
error: (error) => {
clearTimeout(timeoutId);
this.toastService.error(error.error['hydra:description'] || 'Error al sincronizar las subredes');
}
});
}
syncTemplates() {
const timeoutId = setTimeout(() => {
this.toastService.error('Error al sincronizar las plantillas Pxe: tiempo de espera agotado');
}, 3500);
this.http.post(`${this.baseUrl}/pxe-templates/sync`, {})
.subscribe(response => {
clearTimeout(timeoutId);
this.toastService.success('Sincronización de las plantillas Pxe completada');
}, error => {
clearTimeout(timeoutId);
this.toastService.error(error.error['hydra:description'] || 'Error al sincronizar las plantillas Pxe');
});
}
syncOgLives(): void {
const timeoutId = setTimeout(() => {
this.toastService.error('Error al sincronizar las imagenes ogLive : tiempo de espera agotado');
}, 3500);
this.http.post(`${this.baseUrl}/og-lives/sync`, {})
.subscribe(response => {
clearTimeout(timeoutId);
this.toastService.success('Sincronización con los ogLives completada');
}, error => {
clearTimeout(timeoutId);
this.toastService.error(error.error['hydra:description'] || 'Error al sincronizar las imagenes ogLive');
});
} }
[key: string]: any; [key: string]: any;
loadStatus(apiUrl: string, diskUsage: any, servicesStatus: any, diskUsageChartData: any[], installedOgLives: any[], isDhcp: boolean, errorState: string): void { loadStatus(apiUrl: string, diskUsage: any, servicesStatus: any, diskUsageChartData: any[], installedOgLives: any[], isDhcp: boolean, errorState: string, showLoading: boolean = true): void {
this.loading = true; if (isDhcp) {
this.loadingDhcp = true;
} else {
this.loadingOgBoot = true;
}
if (showLoading) {
this.loading = true;
}
this[errorState] = false; this[errorState] = false;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
this.loading = false; if (showLoading) {
this.loading = false;
}
if (isDhcp) {
this.loadingDhcp = false;
} else {
this.loadingOgBoot = false;
}
this[errorState] = true; this[errorState] = true;
}, 3500); }, 3500);
this.http.get<any>(apiUrl).subscribe({ this.http.get<any>(apiUrl).subscribe({
@ -80,6 +168,7 @@ export class GlobalStatusComponent implements OnInit {
if (data.message.installed_oglives) { if (data.message.installed_oglives) {
installedOgLives.push(...data.message.installed_oglives); installedOgLives.push(...data.message.installed_oglives);
} }
this.loadingOgBootOgLives = false;
} }
diskUsageChartData.length = 0; diskUsageChartData.length = 0;
@ -88,23 +177,41 @@ export class GlobalStatusComponent implements OnInit {
{ name: 'Disponible', value: parseFloat(diskUsage.available) } { name: 'Disponible', value: parseFloat(diskUsage.available) }
); );
this.loading = false; if (showLoading) {
this.loading = false;
}
if (isDhcp) {
this.loadingDhcp = false;
} else {
this.loadingOgBoot = false;
}
clearTimeout(timeoutId); clearTimeout(timeoutId);
}, },
error: error => { error: error => {
console.log(error); this.toastService.error(error.error['hydra:description'] || 'Error al cargar el estado de ogBoot');
this.loading = false; if (showLoading) {
this.loading = false;
}
if (isDhcp) {
this.loadingDhcp = false;
} else {
this.loadingOgBoot = false;
}
this[errorState] = true; this[errorState] = true;
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}); });
} }
loadRepositories(): void { loadRepositories(showLoading: boolean = true): void {
this.loading = true; if (showLoading) {
this.loading = true;
}
this.errorRepositories = {}; this.errorRepositories = {};
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
this.loading = false; if (showLoading) {
this.loading = false;
}
this.repositories.forEach(repository => { this.repositories.forEach(repository => {
if (!(repository.uuid in this.errorRepositories)) { if (!(repository.uuid in this.errorRepositories)) {
this.errorRepositories[repository.uuid] = true; this.errorRepositories[repository.uuid] = true;
@ -124,14 +231,15 @@ export class GlobalStatusComponent implements OnInit {
this.errorRepositories[repository.uuid] = errorOccurred; this.errorRepositories[repository.uuid] = errorOccurred;
if (remainingRepositories === 0) { if (remainingRepositories === 0) {
this.loading = false; if (showLoading) {
this.loading = false;
}
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
}); });
}); });
}, },
error => { error => {
console.error('Error fetching repositories', error);
this.loading = false; this.loading = false;
this.repositories.forEach(repository => { this.repositories.forEach(repository => {
this.errorRepositories[repository.uuid] = true; this.errorRepositories[repository.uuid] = true;
@ -165,7 +273,7 @@ export class GlobalStatusComponent implements OnInit {
callback(false); callback(false);
}, },
error => { error => {
console.error(`Error fetching status for repository ${repositoryUuid}`, error); this.toastService.error(error.error['hydra:description'] || 'Error al cargar el estado del repositorio');
clearTimeout(timeoutId); clearTimeout(timeoutId);
callback(true); callback(true);
} }
@ -174,23 +282,86 @@ export class GlobalStatusComponent implements OnInit {
loadOgBootStatus(): void { loadOgBootStatus(): void {
this.isDhcp = false; this.isDhcp = false;
this.loadStatus(this.ogBootApiUrl, this.ogBootDiskUsage, this.ogBootServicesStatus, this.ogBootDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorOgBoot'); this.loadStatus(this.ogBootApiUrl, this.ogBootDiskUsage, this.ogBootServicesStatus, this.ogBootDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorOgBoot', false);
} }
loadDhcpStatus(): void { loadDhcpStatus(): void {
this.isDhcp = true; this.isDhcp = true;
this.loadStatus(this.dhcpApiUrl, this.dhcpDiskUsage, this.dhcpServicesStatus, this.dhcpDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorDhcp'); this.loadStatus(this.dhcpApiUrl, this.dhcpDiskUsage, this.dhcpServicesStatus, this.dhcpDiskUsageChartData, this.installedOgLives, this.isDhcp, 'errorDhcp', false);
} }
onTabChange(event: MatTabChangeEvent): void { onTabChange(event: MatTabChangeEvent): void {
if (event.tab.textLabel === 'OgBoot') { switch (event.index) {
this.loadOgBootStatus(); case 0:
} else if (event.tab.textLabel === 'Dhcp') { if (this.repositories.length === 0) {
this.loadDhcpStatus(); this.loadRepositories(false);
} else if (event.tab.textLabel === 'Repositorios') { }
if (this.repositories.length === 0) { break;
this.loadRepositories(); case 1:
} this.loadOgBootStatus();
break;
case 2:
this.loadDhcpStatus();
break;
default:
break;
} }
} }
onRepositoryChange(repositoryUuid: string): void {
this.selectedRepositoryUuid = repositoryUuid;
if (repositoryUuid && !this.repositoryStatuses[repositoryUuid]) {
this.loadRepositoryStatus(repositoryUuid, (errorOccurred: boolean) => {
if (errorOccurred) {
this.errorRepositories[repositoryUuid] = true;
}
});
}
}
getSelectedRepositoryName(): string {
const selectedRepo = this.repositories.find(repo => repo.uuid === this.selectedRepositoryUuid);
return selectedRepo ? selectedRepo.name : '';
}
refreshAll(): void {
this.loading = true;
this.updateLastUpdateTime();
this.loadStatus(this.ogBootApiUrl, this.ogBootDiskUsage, this.ogBootServicesStatus, this.ogBootDiskUsageChartData, this.installedOgLives, false, 'errorOgBoot', true);
this.loadStatus(this.dhcpApiUrl, this.dhcpDiskUsage, this.dhcpServicesStatus, this.dhcpDiskUsageChartData, this.installedOgLives, true, 'errorDhcp', true);
this.loadRepositories(true);
this.syncSubnets();
this.syncTemplates();
this.syncOgLives();
this.toastService.success('Datos actualizados correctamente');
}
updateLastUpdateTime(): void {
const now = new Date();
this.lastUpdateTime = now.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
getCurrentTime(): string {
const now = new Date();
return now.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
retryRepositoryStatus(repositoryUuid: string): void {
this.loadRepositoryStatus(repositoryUuid, (errorOccurred: boolean) => {
this.errorRepositories[repositoryUuid] = errorOccurred;
if (!errorOccurred) {
this.toastService.success('Estado del repositorio actualizado correctamente');
} else {
this.toastService.error('Error al cargar el estado del repositorio');
}
});
}
} }

View File

@ -1,84 +1,533 @@
/* ===== LAYOUT PRINCIPAL ===== */
.dashboard { .dashboard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem;
padding: 0;
} }
.disk-usage-container, /* ===== SECCIÓN DE RECURSOS ===== */
.ram-usage-container { .resources-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
}
/* Layout específico para repositorios - gráficas en una sola fila */
.resources-section.repository-layout {
grid-template-columns: 1fr !important;
gap: 1.5rem;
}
.resources-section.repository-layout .resource-card {
min-width: 0;
}
.resource-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.resource-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #667eea, #764ba2);
opacity: 0;
transition: opacity 0.3s ease;
}
.resource-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.resource-card:hover::before {
opacity: 1;
}
.resource-header {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
} }
.disk-usage, .resource-icon {
.ram-usage { background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.resource-icon mat-icon {
color: white;
font-size: 20px;
width: 20px;
height: 20px;
}
.resource-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.resource-content {
display: flex;
gap: 1.5rem;
align-items: center;
}
.chart-container {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: row;
align-items: center;
justify-content: center; justify-content: center;
} }
.service-list, .resource-info {
.process-list { flex: 1;
margin-top: 0em;
margin-bottom: 0.5em;
}
.services-status,
.processes-status {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem;
} }
.services-status li { .info-item {
margin: 5px 0; display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #f1f3f4;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 0.9rem;
color: #6c757d;
font-weight: 500;
}
.info-value {
font-size: 0.9rem;
color: #2c3e50;
font-weight: 600;
}
.usage-percentage {
color: #667eea;
font-weight: 700;
}
/* ===== CPU USAGE DISPLAY ===== */
.cpu-usage-display {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.cpu-circle {
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 50%;
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
text-align: center;
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.cpu-percentage {
font-size: 1.8rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.25rem;
}
.cpu-label {
font-size: 0.8rem;
opacity: 0.9;
}
/* ===== SECCIÓN DE SERVICIOS ===== */
.services-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.service-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.service-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.service-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
} }
.processes-status li { .service-icon {
margin: 5px 0; background: linear-gradient(135deg, #28a745, #20c997);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
flex-shrink: 0;
} }
.status-led { .service-icon mat-icon {
width: 10px; color: white;
height: 10px; font-size: 20px;
width: 20px;
height: 20px;
}
.service-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.service-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.service-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.3s ease;
}
.service-item:hover {
background: #e9ecef;
transform: translateX(4px);
}
.service-status {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
margin-right: 10px; transition: all 0.3s ease;
} }
.status-led.active { .status-indicator.active {
background-color: green; background: linear-gradient(135deg, #28a745, #20c997);
box-shadow: 0 0 8px rgba(40, 167, 69, 0.4);
} }
.status-led.inactive { .status-indicator.inactive {
background-color: red; background: linear-gradient(135deg, #dc3545, #c82333);
box-shadow: 0 0 8px rgba(220, 53, 69, 0.4);
} }
.disk-title, .service-name {
.ram-title { font-size: 0.9rem;
margin-bottom: 0px; font-weight: 500;
color: #2c3e50;
} }
.service-title, .service-state {
.process-title { font-size: 0.8rem;
margin-top: 0px; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.25rem 0.75rem;
border-radius: 12px;
transition: all 0.3s ease;
} }
table { .service-state.active {
background: linear-gradient(135deg, #28a745, #20c997);
color: white;
}
.service-state.inactive {
background: linear-gradient(135deg, #dc3545, #c82333);
color: white;
}
/* ===== SECCIÓN DE DATOS ===== */
.data-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.data-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.data-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
.data-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.data-icon {
background: linear-gradient(135deg, #17a2b8, #138496);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.data-icon mat-icon {
color: white;
font-size: 20px;
width: 20px;
height: 20px;
}
.data-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.table-container {
overflow-x: auto;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: white;
} }
th, .data-table th {
td { background: linear-gradient(135deg, #f8f9fa, #e9ecef);
border: 1px solid #ddd; color: #495057;
padding: 8px; padding: 1rem;
font-weight: 600;
text-align: left;
border-bottom: 2px solid #dee2e6;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
th { .data-table td {
background-color: #f4f4f4; padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f3f4;
color: #2c3e50;
font-size: 0.9rem;
}
.data-table tr:hover {
background: #f8f9fa;
}
.data-table tr:last-child td {
border-bottom: none;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.dashboard {
gap: 1rem;
}
.resources-section {
grid-template-columns: 1fr;
gap: 1rem;
}
.services-section {
grid-template-columns: 1fr;
gap: 1rem;
}
.resource-content {
flex-direction: column;
gap: 1rem;
}
.resource-card,
.service-card,
.data-card {
padding: 1rem;
}
.cpu-circle {
width: 100px;
height: 100px;
}
.cpu-percentage {
font-size: 1.5rem;
}
.service-item {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.service-item:hover {
transform: none;
}
}
@media (max-width: 480px) {
.resource-header,
.service-header,
.data-header {
flex-direction: column;
text-align: center;
gap: 0.5rem;
}
.resource-icon,
.service-icon,
.data-icon {
width: 35px;
height: 35px;
}
.resource-icon mat-icon,
.service-icon mat-icon,
.data-icon mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
.resource-title,
.service-title,
.data-title {
font-size: 1.1rem;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.data-table th,
.data-table td {
padding: 0.5rem;
font-size: 0.8rem;
}
}
/* ===== ANIMACIONES ===== */
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.resource-card,
.service-card,
.data-card {
animation: slideInUp 0.6s ease-out;
}
.resource-card:nth-child(1) { animation-delay: 0.1s; }
.resource-card:nth-child(2) { animation-delay: 0.2s; }
.resource-card:nth-child(3) { animation-delay: 0.3s; }
.service-card:nth-child(1) { animation-delay: 0.4s; }
.service-card:nth-child(2) { animation-delay: 0.5s; }
.data-card { animation-delay: 0.6s; }
/* ===== ESTILOS PARA LOS GRÁFICOS ===== */
::ng-deep .chart-container ngx-charts-pie-chart {
width: 100% !important;
height: auto !important;
}
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
display: block !important;
margin-top: 1rem !important;
}
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend .legend-labels {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
}
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend .legend-label {
display: flex !important;
align-items: center !important;
margin: 0.25rem 0 !important;
} }

View File

@ -1,113 +1,206 @@
<app-loading [isLoading]="loading"></app-loading> <app-loading [isLoading]="loading"></app-loading>
<div *ngIf="!loading" class="dashboard"> <div *ngIf="!loading" class="dashboard">
<!-- Disk Usage Section --> <!-- Sección de uso de recursos -->
<div class="disk-usage-container"> <div class="resources-section">
<h3 class="disk-title">{{ 'diskUsageTitle' | translate }}</h3> <!-- Disk Usage Section -->
<div class="disk-usage" joyrideStep="diskUsageStep" text="{{ 'diskUsageDescription' | translate }}"> <div class="resource-card disk-usage-container">
<ngx-charts-pie-chart [view]="view" [scheme]="colorScheme" [results]="diskUsageChartData" [doughnut]="isDoughnut" <div class="resource-header">
[labels]="showLabels"> <div class="resource-icon">
</ngx-charts-pie-chart> <mat-icon>storage</mat-icon>
<div class="disk-usage-info"> </div>
<p>{{ 'totalLabel' | translate }}: <strong>{{ isRepository ? diskUsage.total : formatBytes(diskUsage.total) }}</strong></p> <h3 class="resource-title">{{ 'diskUsageTitle' | translate }}</h3>
<p>{{ 'usedLabel' | translate }}: <strong>{{ isRepository ? diskUsage.used : formatBytes(diskUsage.used) }}</strong></p> </div>
<p>{{ 'availableLabel' | translate }}: <strong>{{ isRepository ? diskUsage.available : formatBytes(diskUsage.available) }}</strong></p> <div class="resource-content">
<p>{{ 'usedPercentageLabel' | translate }}: <strong>{{ isRepository ? diskUsage.used_percentage : diskUsage.percentage }}</strong></p> <div class="chart-container" joyrideStep="diskUsageStep" text="{{ 'diskUsageDescription' | translate }}">
<ngx-charts-pie-chart [view]="view" [scheme]="colorScheme" [results]="diskUsageChartData" [doughnut]="isDoughnut"
[labels]="showLabels">
</ngx-charts-pie-chart>
</div>
<div class="resource-info">
<div class="info-item">
<span class="info-label">{{ 'totalLabel' | translate }}:</span>
<span class="info-value">{{ isRepository ? diskUsage.total : formatBytes(diskUsage.total) }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'usedLabel' | translate }}:</span>
<span class="info-value">{{ isRepository ? diskUsage.used : formatBytes(diskUsage.used) }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'availableLabel' | translate }}:</span>
<span class="info-value">{{ isRepository ? diskUsage.available : formatBytes(diskUsage.available) }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'usedPercentageLabel' | translate }}:</span>
<span class="info-value usage-percentage">{{ isRepository ? diskUsage.used_percentage : diskUsage.percentage }}%</span>
</div>
</div>
</div>
</div>
<!-- RAM Usage Section -->
<div class="resource-card ram-usage-container" *ngIf="isRepository">
<div class="resource-header">
<div class="resource-icon">
<mat-icon>memory</mat-icon>
</div>
<h3 class="resource-title">{{ 'RamUsage' | translate }}</h3>
</div>
<div class="resource-content">
<div class="chart-container">
<ngx-charts-pie-chart [view]="view" [scheme]="colorScheme" [results]="ramUsageChartData" [doughnut]="isDoughnut"
[labels]="showLabels">
</ngx-charts-pie-chart>
</div>
<div class="resource-info">
<div class="info-item">
<span class="info-label">{{ 'totalLabel' | translate }}:</span>
<span class="info-value">{{ ramUsage.total }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'usedLabel' | translate }}:</span>
<span class="info-value">{{ ramUsage.used }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'availableLabel' | translate }}:</span>
<span class="info-value">{{ ramUsage.available }}</span>
</div>
<div class="info-item">
<span class="info-label">{{ 'usedPercentageLabel' | translate }}:</span>
<span class="info-value usage-percentage">{{ ramUsage.used_percentage }}%</span>
</div>
</div>
</div>
</div>
<!-- CPU Usage Section -->
<div class="resource-card cpu-usage-container" *ngIf="isRepository">
<div class="resource-header">
<div class="resource-icon">
<mat-icon>speed</mat-icon>
</div>
<h3 class="resource-title">{{ 'CpuUsage' | translate }}</h3>
</div>
<div class="resource-content">
<div class="cpu-usage-display">
<div class="cpu-circle">
<div class="cpu-percentage">{{ cpuUsage.used_percentage }}%</div>
<div class="cpu-label">Uso actual</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- RAM Usage Section --> <!-- Sección de servicios y procesos -->
<div class="ram-usage-container" *ngIf="isRepository"> <div class="services-section">
<h3 class="ram-title">{{ 'RamUsage' | translate }}</h3> <!-- Services Status Section -->
<div class="ram-usage"> <div class="service-card services-status" joyrideStep="servicesStatusStep" text="{{ 'servicesStatusDescription' | translate }}">
<ngx-charts-pie-chart [view]="view" [scheme]="colorScheme" [results]="ramUsageChartData" [doughnut]="isDoughnut" <div class="service-header">
[labels]="showLabels"> <div class="service-icon">
</ngx-charts-pie-chart> <mat-icon>settings</mat-icon>
<div class="ram-usage-info"> </div>
<p>{{ 'totalLabel' | translate }}: <strong>{{ ramUsage.total }}</strong></p> <h3 class="service-title">{{ 'servicesTitle' | translate }}</h3>
<p>{{ 'usedLabel' | translate }}: <strong>{{ ramUsage.used }}</strong></p> </div>
<p>{{ 'availableLabel' | translate }}: <strong>{{ ramUsage.available }}</strong></p> <div class="service-list">
<p>{{ 'usedPercentageLabel' | translate }}: <strong>{{ ramUsage.used_percentage }}</strong></p> <div class="service-item" *ngFor="let service of getServices()">
<div class="service-status">
<span class="status-indicator"
[ngClass]="{ 'active': service.status === 'active', 'inactive': service.status !== 'active' }"></span>
<span class="service-name">{{ service.name }}</span>
</div>
<span class="service-state" [ngClass]="{ 'active': service.status === 'active', 'inactive': service.status !== 'active' }">
{{ service.status | translate }}
</span>
</div>
</div>
</div>
<!-- Processes Status Section -->
<div class="service-card processes-status" *ngIf="isRepository">
<div class="service-header">
<div class="service-icon">
<mat-icon>list_alt</mat-icon>
</div>
<h3 class="service-title">{{ 'processes' | translate }}</h3>
</div>
<div class="service-list">
<div class="service-item" *ngFor="let process of getProcesses()">
<div class="service-status">
<span class="status-indicator"
[ngClass]="{ 'active': process.status === 'running', 'inactive': process.status !== 'running' }"></span>
<span class="service-name">{{ process.name }}</span>
</div>
<span class="service-state" [ngClass]="{ 'active': process.status === 'running', 'inactive': process.status !== 'running' }">
{{ process.status }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- CPU Usage Section --> <!-- Sección de datos específicos -->
<div class="cpu-usage-container" *ngIf="isRepository"> <div class="data-section">
<h3 class="cpu-title">{{ 'CpuUsage' | translate }}</h3> <!-- Installed OgLives Section -->
<div class="cpu-usage"> <div class="data-card" *ngIf="!isRepository && !isDhcp">
<p>{{ 'usedLabel' | translate }}: <strong>{{ cpuUsage.used_percentage }}</strong></p> <div class="data-header">
<div class="data-icon">
<mat-icon>computer</mat-icon>
</div>
<h3 class="data-title">{{ 'InstalledOglivesTitle' | translate }}</h3>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>{{ 'idLabel' | translate }}</th>
<th>{{ 'kernelLabel' | translate }}</th>
<th>{{ 'architectureLabel' | translate }}</th>
<th>{{ 'revisionLabel' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of installedOgLives">
<td>{{ item.id }}</td>
<td>{{ item.kernel }}</td>
<td>{{ item.architecture }}</td>
<td>{{ item.revision }}</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div>
<!-- Services Status Section --> <!-- Subnets Section -->
<div class="services-status" joyrideStep="servicesStatusStep" text="{{ 'servicesStatusDescription' | translate }}"> <div class="data-card" *ngIf="isDhcp">
<h3 class="service-title">{{ 'servicesTitle' | translate }}</h3> <div class="data-header">
<ul class="service-list"> <div class="data-icon">
<li *ngFor="let service of getServices()"> <mat-icon>router</mat-icon>
<span class="status-led" </div>
[ngClass]="{ 'active': service.status === 'active', 'inactive': service.status !== 'active' }"></span> <h3 class="data-title">{{ 'subnets' | translate }}</h3>
{{ service.name }}: {{ service.status | translate }} </div>
</li> <div class="table-container">
</ul> <table class="data-table">
</div> <thead>
<tr>
<!-- Processes Status Section --> <th>{{ 'idLabel' | translate }}</th>
<div class="processes-status" *ngIf="isRepository"> <th>{{ 'bootFileNameLabel' | translate }}</th>
<h3 class="process-title">{{ 'processes' | translate }}</h3> <th>{{ 'nextServerLabel' | translate }}</th>
<ul class="process-list"> <th>{{ 'ipLabel' | translate }}</th>
<li *ngFor="let process of getProcesses()"> <th>{{ 'clientsLabel' | translate }}</th>
<span class="status-led" </tr>
[ngClass]="{ 'active': process.status === 'running', 'inactive': process.status !== 'running' }"></span> </thead>
{{ process.name }}: {{ process.status }} <tbody>
</li> <tr *ngFor="let item of subnets">
</ul> <td>{{ item.id }}</td>
</div> <td>{{ item['boot-file-name'] }}</td>
<td>{{ item['next-server'] }}</td>
<!-- Installed OgLives / Subnets Section --> <td>{{ item.subnet }}</td>
<div *ngIf="!isRepository && !isDhcp"> <td>{{ item.reservations.length }}</td>
<h3>{{ 'InstalledOglivesTitle' | translate }}</h3> </tr>
<table> </tbody>
<thead> </table>
<tr> </div>
<th>{{ 'idLabel' | translate }}</th> </div>
<th>{{ 'kernelLabel' | translate }}</th>
<th>{{ 'architectureLabel' | translate }}</th>
<th>{{ 'revisionLabel' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of installedOgLives">
<td>{{ item.id }}</td>
<td>{{ item.kernel }}</td>
<td>{{ item.architecture }}</td>
<td>{{ item.revision }}</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="isDhcp">
<h3>{{ 'subnets' | translate }}</h3>
<table>
<thead>
<tr>
<th>{{ 'idLabel' | translate }}</th>
<th>{{ 'bootFileNameLabel' | translate }}</th>
<th>{{ 'nextServerLabel' | translate }}</th>
<th>{{ 'ipLabel' | translate }}</th>
<th>{{ 'clientsLabel' | translate }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of subnets">
<td>{{ item.id }}</td>
<td>{{ item['boot-file-name'] }}</td>
<td>{{ item['next-server'] }}</td>
<td>{{ item.subnet }}</td>
<td>{{ item.reservations.length }}</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>

View File

@ -5,6 +5,13 @@
padding: 10px; padding: 10px;
} }
.table-header-container {
display: flex;
align-items: center;
padding: 10px;
gap: 20px;
}
.client-container { .client-container {
flex-grow: 1; flex-grow: 1;
box-sizing: border-box; box-sizing: border-box;
@ -273,7 +280,7 @@
} }
.table-container { .table-container {
flex: 3; flex: 5;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@ -29,8 +29,12 @@
</div> </div>
</div> </div>
</div> </div>
<div class="header-container"> <div class="table-header-container">
<h2 class="title" i18n="@@adminImagesTitle">Discos/Particiones</h2> <h2 class="title" i18n="@@adminImagesTitle">Discos/Particiones</h2>
<mat-chip *ngIf="clientData.firmwareType" class="firmware-chip">
<mat-icon>memory</mat-icon>
{{ clientData.firmwareType }}
</mat-chip>
</div> </div>
<div class="disk-container"> <div class="disk-container">

View File

@ -1,105 +1,677 @@
.title { /* Contenedor principal modernizado */
font-size: 24px;
}
.calendar-button-row {
display: flex;
justify-content: flex-start;
margin-top: 16px;
}
.divider {
margin: 20px 0;
}
.lists-container {
padding: 16px;
}
.card.unidad-card {
height: 100%;
box-sizing: border-box;
}
table {
width: 100%;
margin-top: 50px;
background-color: #eaeff6;
}
.search-container {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0 5px;
box-sizing: border-box;
}
.select-container { .select-container {
gap: 16px; gap: 24px;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 20px; padding: 32px;
background: white !important;
border-radius: 4px;
margin: 20px 0;
align-items: center;
}
/* Secciones del formulario */
.form-section {
background: white !important;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
border: 1px solid #bbdefb;
}
.form-section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.form-section-title mat-icon {
color: #2196f3;
} }
.selector { .selector {
display: flex; display: flex;
gap: 16px; gap: 20px;
width: 100%; width: 100%;
margin-top: 30px; margin-top: 16px;
box-sizing: border-box; box-sizing: border-box;
align-items: start;
} }
.half-width { .half-width {
flex: 1; flex: 1;
max-width: 50%; max-width: calc(50% - 10px);
} }
.full-width {
.search-string {
flex: 2;
padding: 5px;
}
.search-boolean {
flex: 1; flex: 1;
padding: 5px; width: 100%;
} }
/* Header modernizado */
.header-container { .header-container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 10px 10px; padding: 24px 32px;
border-bottom: 1px solid #ddd; background: white;
} border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.mat-elevation-z8 { margin-bottom: 20px;
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
} }
.header-container-title { .header-container-title {
flex-grow: 1; flex-grow: 1;
text-align: left; text-align: left;
padding-left: 1em; }
.header-container-title h2 {
margin: 0 0 8px 0;
color: #333;
font-weight: 600;
}
/* Estilos modernos para el badge de destino */
.destination-info {
margin-top: 12px;
}
.destination-badge {
display: inline-flex;
align-items: center;
background: #e3f2fd;
color: #1565c0;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #bbdefb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.destination-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin-right: 12px;
color: #1976d2;
}
.destination-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.destination-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #1976d2;
line-height: 1;
}
.destination-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0d47a1;
} }
.button-row { .button-row {
display: flex; display: flex;
padding-right: 1em; gap: 12px;
padding-right: 0;
} }
/* Tabla de particiones modernizada */
.partition-table-container { .partition-table-container {
background-color: #eaeff6; background: white;
padding: 20px; padding: 24px;
border-radius: 12px; border-radius: 12px;
margin-top: 20px; margin-top: 24px;
}
.partition-table-container h3 {
margin: 0 0 20px 0;
color: #333;
font-weight: 600;
font-size: 18px;
}
.repository-label {
font-weight: 500;
margin-right: 8px;
}
mat-chip {
margin-top: 8px !important;
border-radius: 20px !important;
}
mat-icon {
margin-right: 4px;
}
/* Botón de crear repositorio modernizado */
.create-repository-button {
color: white;
border: none;
padding: 12px 20px;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.create-repository-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
}
.create-repository-button mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
/* Botones modernizados */
.action-button {
border-radius: 8px;
font-weight: 500;
padding: 12px 24px;
transition: all 0.3s ease;
}
.action-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* Campos de formulario modernizados */
mat-form-field {
margin-bottom: 8px;
}
::ng-deep .mat-form-field-appearance-fill .mat-form-field-flex {
border-radius: 8px;
background-color: white !important;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
}
::ng-deep .mat-form-field-appearance-fill.mat-focused .mat-form-field-flex {
background-color: white;
border-color: #2196f3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
/* Overlay de carga para creación de repositorio */
.creating-repository-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
color: white;
backdrop-filter: blur(4px);
}
.creating-repository-overlay p {
margin-top: 16px;
font-size: 16px;
font-weight: 500;
}
/* Estilo para hacer el backdrop no clickeable */
::ng-deep .non-clickable-backdrop {
pointer-events: none !important;
}
/* Responsive design */
@media (max-width: 768px) {
.select-container {
padding: 16px;
}
.header-container {
padding: 16px;
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.selector {
flex-direction: column;
gap: 16px;
}
.half-width {
max-width: 100%;
}
.create-repository-button {
min-width: 100%;
margin-left: 0;
}
.button-row {
justify-content: center;
}
.destination-badge {
padding: 10px 14px;
border-radius: 10px;
}
.destination-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 10px;
}
.destination-value {
max-width: 150px;
font-size: 13px;
}
.destination-label {
font-size: 10px;
}
}
/* Estilos para elementos específicos */
.unit-name {
font-weight: 500;
color: #2c3e50;
}
/* Estilos para las opciones de acción Git */
.git-action-selector {
margin: 24px 0;
padding: 20px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px;
border: 1px solid #dee2e6;
}
.action-chips-container {
margin-bottom: 16px;
}
::ng-deep .action-chip {
margin: 8px !important;
padding: 12px 20px !important;
border-radius: px !important;
font-weight: 500 !important;
font-size: 14px !important;
transition: all 0.3s ease !important;
border: 2px solid transparent !important;
background: white !important;
color: #6c757d !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
min-height: 48px !important;
}
::ng-deep .action-chip:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important;
}
::ng-deep .action-chip.mat-mdc-chip-selected {
border-color: #667eea !important;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2) !important;
}
::ng-deep .create-chip.mat-mdc-chip-selected {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%) !important;
color: white !important;
}
::ng-deep .update-chip.mat-mdc-chip-selected {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%) !important;
color: white !important;
}
::ng-deep .action-chip mat-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
.action-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(102, 126, 234, 0.1);
border-radius: 8px;
border-left: 4px solid #667eea;
color: #495057;
font-size: 14px;
}
.action-hint mat-icon {
color: #667eea;
font-size: 16px;
width: 16px;
height: 16px;
}
.git-action-section {
background: white !important;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
border-left: 4px solid #667eea;
}
/* Eliminar sombra de la tabla */
.mat-elevation-z8 {
box-shadow: none !important;
}
/* Animaciones para transiciones de formulario */
.form-transition {
transition: all 0.3s ease-in-out;
opacity: 1;
transform: translateY(0);
}
.form-transition.ng-enter {
opacity: 0;
transform: translateY(10px);
}
.form-transition.ng-enter-active {
opacity: 1;
transform: translateY(0);
}
.form-transition.ng-leave {
opacity: 1;
transform: translateY(0);
}
.form-transition.ng-leave-active {
opacity: 0;
transform: translateY(-10px);
}
/* Estilos para los formularios específicos */
.git-form-section {
background: white;
border-radius: 4px;
padding: 20px;
margin-top: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #e9ecef;
transition: all 0.3s ease;
}
.git-form-section:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
/* Estilos para la sección de repositorio Git */
.git-repository-section {
background: white !important;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #e9ecef;
}
.repository-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
}
.repository-header h4 {
margin: 0;
color: #2c3e50;
font-weight: 600;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.repository-selector {
display: flex;
gap: 20px;
align-items: flex-start;
}
.repository-field {
flex: 0 0 300px;
min-width: 250px;
}
.repository-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 8px;
color: #6c757d;
font-size: 13px;
line-height: 1.4;
}
.info-item mat-icon {
color: #667eea;
font-size: 16px;
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
}
.create-repository-button {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border: none;
padding: 10px 16px;
border-radius: 6px;
font-weight: 500;
font-size: 14px;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.2);
}
.create-repository-button:hover:not(:disabled) {
background: linear-gradient(135deg, #218838 0%, #1ea085 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
}
.create-repository-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.create-repository-button mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.no-repositories-hint {
display: flex;
align-items: flex-start;
gap: 8px;
color: #dc3545;
font-weight: 500;
font-size: 13px;
padding: 4px 4px 4px 12px;
background: rgba(220, 53, 69, 0.1);
border-radius: 6px;
border-left: 3px solid #dc3545;
line-height: 1.4;
}
.no-repositories-hint mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
}
/* Estilos para el hint del formulario */
::ng-deep .mat-form-field-hint {
display: flex !important;
align-items: center !important;
gap: 6px !important;
color: #6c757d !important;
font-size: 12px !important;
}
::ng-deep .mat-form-field-hint mat-icon {
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #667eea !important;
}
/* Responsive */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
gap: 20px;
text-align: center;
}
.button-row {
flex-wrap: wrap;
justify-content: center;
}
.selector {
flex-direction: column;
}
.half-width {
min-width: auto;
}
.clients-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.input-group {
grid-template-columns: 1fr;
}
.select-container {
padding: 0 16px;
}
.form-section {
padding: 20px;
}
/* Responsive para repositorio Git */
.repository-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.repository-selector {
flex-direction: column;
gap: 16px;
}
.repository-field {
flex: none;
width: 100%;
min-width: auto;
}
.repository-info {
flex: none;
}
.create-repository-button {
width: 100%;
justify-content: center;
}
}
/* Botón flotante para scroll hacia arriba */
.scroll-to-top-button {
position: fixed;
bottom: 30px;
left: 30px;
z-index: 1000;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.scroll-to-top-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Animación de entrada/salida */
.scroll-to-top-button {
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive para el botón */
@media (max-width: 768px) {
.scroll-to-top-button {
bottom: 20px;
left: 20px;
}
} }

View File

@ -1,59 +1,205 @@
<app-loading [isLoading]="loading"></app-loading> <!-- Overlay de carga para creación de repositorio -->
<app-modal-overlay
[isVisible]="creatingRepository"
message="Creando repositorio...">
</app-modal-overlay>
<div class="header-container"> <div class="header-container">
<div class="header-container-title"> <div class="header-container-title">
<h2 > <h2>
Crear imagen desde {{ clientName }} Crear imagen desde {{ clientName }}
</h2> </h2>
<div class="destination-info">
<div class="destination-badge">
<mat-icon class="destination-icon">cloud_upload</mat-icon>
<div class="destination-content">
<span class="destination-label">Destino</span>
<span class="destination-value">{{ selectedRepository?.name || 'No hay repositorio asociado' }}</span>
</div>
</div>
</div>
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="action-button" [disabled]="!selectedPartition" (click)="save()">Ejecutar</button> <button class="action-button" id="execute-button" [disabled]="!selectedPartition" (click)="save()">Ejecutar</button>
</div> </div>
</div> </div>
<mat-divider></mat-divider>
<div class="select-container"> <div class="select-container">
<div class="selector"> <!-- Sección: Configuración de tipo de imagen -->
<mat-form-field appearance="fill" class="half-width"> <div class="form-section">
<mat-label>Nombre canónico</mat-label> <div class="form-section-title">
<input matInput [disabled]="selectedImage" [(ngModel)]="name" placeholder="Nombre canónico. En minúscula y sin espacios" required> <mat-icon>settings</mat-icon>
</mat-form-field> Configuración de tipo de imagen
</div>
<mat-form-field appearance="fill" class="half-width"> <div class="selector">
<mat-label>Seleccione imagen</mat-label> <mat-form-field appearance="fill" class="half-width">
<mat-select [(ngModel)]="selectedImage" name="selectedImage" (selectionChange)="resetCanonicalName()" required> <mat-label>Tipo de imagen</mat-label>
<mat-option *ngFor="let image of images" [value]="image">{{ image?.name }}</mat-option> <mat-select [(ngModel)]="imageType" class="full-width" (selectionChange)="onImageTypeSelected($event.value)">
</mat-select> <mat-option [value]="'monolithic'">Monolítica</mat-option>
<mat-hint>Seleccione la imagen para sobreescribir si se requiere. </mat-hint> <mat-option [value]="'git'">Git</mat-option>
</mat-form-field> </mat-select>
</mat-form-field>
</div>
</div> </div>
<!-- Sección: Configuración Git (solo para tipo git) -->
<div class="form-section" *ngIf="imageType === 'git'">
<div class="form-section-title">
<mat-icon>code</mat-icon>
Configuración Git
</div>
<div class="partition-table-container"> <div class="git-repository-section">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8"> <div class="repository-header">
<ng-container matColumnDef="select"> <button *ngIf="imageType === 'git'"
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">Seleccionar partición</th> class="create-repository-button"
<td mat-cell *matCellDef="let row"> (click)="openCreateRepositoryModal()"
<mat-radio-group [disabled]="creatingRepository">
[(ngModel)]="selectedPartition" <mat-icon>add</mat-icon>
[disabled]="!row.operativeSystem" <span>Crear nuevo repositorio / SO</span>
> </button>
<mat-radio-button [value]="row"> </div>
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef"> <div class="selector">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th> <mat-form-field appearance="fill" class="full-width">
<td mat-cell *matCellDef="let image"> <mat-label>Seleccionar repositorio Git</mat-label>
{{ column.cell(image) }} <mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositorySelected($event.value)" required>
</td> <mat-option [value]="null">Seleccionar repositorio git / SO</mat-option>
</ng-container> <mat-option *ngFor="let repo of gitRepositories" [value]="repo">{{ repo.name }}</mat-option>
</mat-select>
<mat-spinner *ngIf="loadingGitRepositories" matSuffix diameter="20"></mat-spinner>
<mat-hint>
<mat-icon>info</mat-icon>
Selecciona el repositorio git para obtener las imágenes disponibles.
<span *ngIf="gitRepositories.length === 0" class="no-repositories-hint">
No hay repositorios disponibles. Crea uno nuevo para continuar.
</span>
</mat-hint>
</mat-form-field>
</div>
</div>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <!-- Opciones de acción Git -->
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> <div class="git-action-selector">
</table> <div class="action-chips-container">
<mat-chip-listbox [(ngModel)]="gitAction" required class="action-chip-listbox">
<mat-chip-option value="create" class="action-chip create-chip firmware-chip" (click)="onGitActionSelected({value: 'create'})">
<span>Crear imagen</span>
</mat-chip-option>
<mat-chip-option value="update" class="action-chip update-chip firmware-chip" (click)="onGitActionSelected({value: 'update'})">
<span>Actualizar imagen</span>
</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="action-hint">
<mat-icon>info</mat-icon>
<span *ngIf="gitAction === 'create'">Crea una nueva imagen con el nombre especificado</span>
<span *ngIf="gitAction === 'update'">Actualiza una imagen existente seleccionada</span>
</div>
</div>
</div> </div>
<!-- Sección: Configuración general -->
<div class="form-section" *ngIf="imageType !== 'git'">
<div class="form-section-title">
<mat-icon>image</mat-icon>
Configuración de imagen
</div>
<!-- Opciones de acción para imágenes monolíticas -->
<div class="action-chips-container">
<mat-chip-listbox [(ngModel)]="monolithicAction" required class="action-chip-listbox">
<mat-chip-option value="create" class="action-chip create-chip firmware-chip" (click)="onMonolithicActionSelected({value: 'create'})">
<span>Crear imagen</span>
</mat-chip-option>
<mat-chip-option value="update" class="action-chip update-chip firmware-chip" (click)="onMonolithicActionSelected({value: 'update'})">
<span>Actualizar imagen</span>
</mat-chip-option>
</mat-chip-listbox>
</div>
<div class="action-hint">
<mat-icon>info</mat-icon>
<span *ngIf="monolithicAction === 'create'">Crea una nueva imagen con el nombre especificado</span>
<span *ngIf="monolithicAction === 'update'">Actualiza una imagen existente seleccionada</span>
</div>
<div class="selector" *ngIf="monolithicAction === 'create'">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Nombre canónico</mat-label>
<input matInput [(ngModel)]="name" placeholder="Nombre canónico. En minúscula y sin espacios" required>
<mat-hint>Introduce el nombre para la nueva imagen que se creará.</mat-hint>
</mat-form-field>
</div>
<div class="selector" *ngIf="monolithicAction === 'update'">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Seleccione imagen</mat-label>
<mat-select [(ngModel)]="selectedImage" name="selectedImage" (selectionChange)="resetCanonicalName()" required>
<mat-option [value]="null">Seleccionar imagen para actualizar</mat-option>
<mat-option *ngFor="let image of images" [value]="image">{{ image?.name }}</mat-option>
</mat-select>
<button *ngIf="selectedImage" mat-icon-button matSuffix aria-label="Clear client search"
(click)="selectedImage = null; resetCanonicalName()">
<mat-icon>close</mat-icon>
</button>
<mat-hint>Selecciona la imagen existente que quieres actualizar.</mat-hint>
</mat-form-field>
</div>
</div>
<!-- Sección: Selección de partición -->
<div class="form-section" #partitionSection id="partition-selection">
<div class="form-section-title">
<mat-icon>storage</mat-icon>
Selección de partición
</div>
<div class="partition-table-container">
<table mat-table [dataSource]="dataSource">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">Seleccionar partición</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
[disabled]="!row.operativeSystem"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
</div>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</div>
</div> </div>
<app-scroll-to-top
[threshold]="200"
targetElement=".header-container"
position="bottom-right"
[showTooltip]="true"
tooltipText="Volver arriba"
tooltipPosition="left">
</app-scroll-to-top>

View File

@ -1,10 +1,13 @@
import {Component, EventEmitter, OnInit, Output} from '@angular/core'; import {Component, EventEmitter, OnInit, Output, ViewChild, ElementRef} from '@angular/core';
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import { ToastrService } from "ngx-toastr"; import { ToastrService } from "ngx-toastr";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { MatTableDataSource } from "@angular/material/table"; import { MatTableDataSource } from "@angular/material/table";
import { SelectionModel } from "@angular/cdk/collections"; import { SelectionModel } from "@angular/cdk/collections";
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import {MatDialog} from "@angular/material/dialog";
import {QueueConfirmationModalComponent} from "../../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component";
import {CreateRepositoryModalComponent} from "./create-repository-modal/create-repository-modal.component";
@Component({ @Component({
selector: 'app-create-image', selector: 'app-create-image',
@ -14,43 +17,63 @@ import { ConfigService } from '@services/config.service';
export class CreateClientImageComponent implements OnInit{ export class CreateClientImageComponent implements OnInit{
baseUrl: string; baseUrl: string;
@Output() dataChange = new EventEmitter<any>(); @Output() dataChange = new EventEmitter<any>();
@ViewChild('partitionSection', { static: false }) partitionSection!: ElementRef;
errorMessage = ''; errorMessage = '';
clientId: string | null = null; clientId: string | null = null;
partitions: any[] = []; partitions: any[] = [];
images: any[] = []; images: any[] = [];
clientName: string = ''; clientName: string = '';
selectedPartition: any = null; private _selectedPartition: any = null;
name: string = ''; name: string = '';
client: any = null; client: any = null;
loading: boolean = false; loading: boolean = false;
selectedImage: any = null; selectedImage: any = null;
private _imageType: string = 'monolithic';
selectedRepository: any = null;
gitRepositories: any[] = [];
selectedGitRepository: any = null;
gitImageRepositories: any[] = [];
gitImageName: string = '';
loadingGitRepositories: boolean = false;
loadingGitImageRepositories: boolean = false;
creatingRepository: boolean = false;
gitAction: string = 'create';
monolithicAction: string = 'create';
existingImages: any[] = [];
selectedExistingImage: any = null;
loadingExistingImages: boolean = false;
dataSource = new MatTableDataSource<any>(); dataSource = new MatTableDataSource<any>();
columns = [ columns = [
{ {
columnDef: 'diskNumber', columnDef: 'diskNumber',
header: 'Disco', header: 'Disco',
cell: (partition: any) => `${partition.diskNumber}` cell: (partition: any) => partition.diskNumber
}, },
{ {
columnDef: 'partitionNumber', columnDef: 'partitionNumber',
header: 'Particion', header: 'Particion',
cell: (partition: any) => `${partition.partitionNumber}` cell: (partition: any) => partition.partitionNumber
}, },
{ {
columnDef: 'size', columnDef: 'size',
header: 'Tamaño', header: 'Tamaño',
cell: (partition: any) => `${partition.size} MB` cell: (partition: any) => `${partition.size} MB`
}, },
{
columnDef: 'partitionCode',
header: 'Tipo de partición',
cell: (partition: any) => partition.partitionCode
},
{ {
columnDef: 'filesystem', columnDef: 'filesystem',
header: 'Sistema de ficheros', header: 'Sistema de ficheros',
cell: (partition: any) => `${partition.filesystem}` cell: (partition: any) => partition.filesystem
}, },
{ {
columnDef: 'operativeSystem', columnDef: 'operativeSystem',
header: 'SO', header: 'SO',
cell: (partition: any) => `${partition.operativeSystem?.name}` cell: (partition: any) => partition.operativeSystem?.name
} }
]; ];
@ -63,11 +86,13 @@ export class CreateClientImageComponent implements OnInit{
private configService: ConfigService, private configService: ConfigService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private dialog: MatDialog
) { ) {
this.baseUrl = this.configService.apiUrl; this.baseUrl = this.configService.apiUrl;
} }
ngOnInit() { ngOnInit() {
console.log('CreateImageComponent ngOnInit ejecutado');
this.clientId = this.route.snapshot.paramMap.get('id'); this.clientId = this.route.snapshot.paramMap.get('id');
this.loadPartitions(); this.loadPartitions();
this.loadImages(); this.loadImages();
@ -80,6 +105,7 @@ export class CreateClientImageComponent implements OnInit{
if (response.partitions) { if (response.partitions) {
this.client = response; this.client = response;
this.clientName = response.name; this.clientName = response.name;
this.selectedRepository = response.repository;
this.dataSource.data = response.partitions.filter((partition: any) => { this.dataSource.data = response.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0; return partition.partitionNumber !== 0;
@ -92,8 +118,12 @@ export class CreateClientImageComponent implements OnInit{
); );
} }
onImageTypeSelected(event: any) {
this.imageType = event;
}
loadImages() { loadImages() {
const url = `${this.baseUrl}/images?created=false&page=1&itemsPerPage=1000`; const url = `${this.baseUrl}/images?created=false&type=${this.imageType}&page=1&itemsPerPage=100`;
this.http.get(url).subscribe( this.http.get(url).subscribe(
(response: any) => { (response: any) => {
this.images = response['hydra:member']; this.images = response['hydra:member'];
@ -104,44 +134,285 @@ export class CreateClientImageComponent implements OnInit{
); );
} }
loadGitRepositories() {
this.loadingGitRepositories = true;
const url = `${this.baseUrl}/git-repositories?repository=${this.selectedRepository.id}&page=1&itemsPerPage=100`;
return this.http.get(url).subscribe(
(response: any) => {
this.gitRepositories = response['hydra:member'];
this.loadingGitRepositories = false;
},
(error) => {
console.error('Error al cargar los repositorios git:', error);
this.loadingGitRepositories = false;
}
);
}
loadGitImageRepositories(gitRepository: any) {
this.loadingGitImageRepositories = true;
const url = `${this.baseUrl}/git-image-repositories?gitRepository.id=${gitRepository.id}&page=1&itemsPerPage=100`;
this.http.get(url).subscribe(
(response: any) => {
this.gitImageRepositories = response['hydra:member'];
this.loadingGitImageRepositories = false;
},
(error) => {
console.error('Error al cargar las imágenes de repositorio git:', error);
this.loadingGitImageRepositories = false;
}
);
}
onGitRepositorySelected(gitRepository: any) {
this.selectedGitRepository = gitRepository;
this.selectedExistingImage = null;
this.existingImages = [];
if (gitRepository) {
this.loadGitImageRepositories(gitRepository);
} else {
this.gitImageRepositories = [];
}
}
onGitActionSelected(event: any) {
console.log('onGitActionSelected llamado con:', event);
this.gitAction = event.value;
this.selectedExistingImage = null;
this.gitImageName = '';
// Si se selecciona 'update' y ya hay un repositorio Git seleccionado, cargar los repositorios de imágenes
if (event.value === 'update' && this.selectedGitRepository) {
this.loadGitImageRepositories(this.selectedGitRepository);
}
console.log('Antes del setTimeout');
// Hacer scroll hacia la sección de partición después de un delay más largo
setTimeout(() => {
console.log('Dentro del setTimeout, llamando a scrollToPartitionSection');
this.scrollToPartitionSection();
}, 300);
}
onMonolithicActionSelected(event: any) {
console.log('onMonolithicActionSelected llamado con:', event);
this.monolithicAction = event.value;
this.selectedImage = null;
this.name = '';
// Si se selecciona 'update', cargar las imágenes existentes
if (event.value === 'update') {
this.loadImages();
}
console.log('Antes del setTimeout (monolithic)');
// Hacer scroll hacia la sección de partición después de un delay más largo
setTimeout(() => {
console.log('Dentro del setTimeout (monolithic), llamando a scrollToPartitionSection');
this.scrollToPartitionSection();
}, 300);
}
loadExistingImages() {
if (!this.selectedExistingImage) return;
this.loadingExistingImages = true;
// Aquí deberías hacer el GET al endpoint externo
// Por ahora uso un endpoint de ejemplo, ajusta según tu API
const url = `${this.baseUrl}/images?gitImageRepository.id=${this.selectedExistingImage.id}&page=1&itemsPerPage=100`;
this.http.get(url).subscribe(
(response: any) => {
this.existingImages = response['hydra:member'] || [];
this.loadingExistingImages = false;
},
(error) => {
console.error('Error al cargar las imágenes existentes:', error);
this.loadingExistingImages = false;
this.toastService.error('Error al cargar las imágenes existentes');
}
);
}
resetGitSelections() {
this.selectedGitRepository = null;
this.selectedExistingImage = null;
this.gitImageName = '';
this.gitAction = 'create';
this.existingImages = [];
this.gitRepositories = [];
this.gitImageRepositories = [];
this.selectedImage = null;
this.name = '';
this.monolithicAction = 'create';
}
resetCanonicalName() { resetCanonicalName() {
this.name = this.selectedImage ? this.selectedImage.name : ''; this.name = this.selectedImage ? this.selectedImage.name : '';
} }
save(): void { save(): void {
this.loading = true;
if (!this.selectedPartition) { if (!this.selectedPartition) {
this.toastService.error('Debes seleccionar una partición'); this.toastService.error('Debes seleccionar una partición');
this.loading = false;
return; return;
} }
if (this.imageType === 'git') {
if (!this.selectedGitRepository) {
this.toastService.error('Debes seleccionar un repositorio Git');
return;
}
if (this.gitAction === 'update' && !this.selectedExistingImage) {
this.toastService.error('Debes seleccionar un repositorio de imágenes Git');
return;
}
}
if (this.imageType === 'monolithic') {
if (this.monolithicAction === 'create' && !this.name) {
this.toastService.error('Debes introducir un nombre canónico para la imagen');
return;
}
if (this.monolithicAction === 'update' && !this.selectedImage) {
this.toastService.error('Debes seleccionar una imagen para actualizar');
return;
}
}
if (this.selectedImage) { if (this.selectedImage) {
this.toastService.warning('Aviso: Está seleccionando una imagen previamente creada. Se procede a crear un backup de la misma. '); this.toastService.warning('Aviso: Está seleccionando una imagen previamente creada. Se procede a crear un backup de la misma. ');
} }
const payload = { const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
client: `/clients/${this.clientId}`, width: '400px',
name: this.name, disableClose: true,
partition: this.selectedPartition['@id'], hasBackdrop: true,
source: 'assistant', backdropClass: 'non-clickable-backdrop'
selectedImage: this.selectedImage?.['@id'] });
};
dialogRef.afterClosed().subscribe(result => {
if (result !== undefined) {
this.loading = true;
this.http.post(`${this.baseUrl}/images`, payload) let payload: any = {
.subscribe({ client: `/clients/${this.clientId}`,
next: (response) => { partition: this.selectedPartition['@id'],
this.toastService.success('Petición de creación de imagen enviada'); source: 'assistant',
this.loading = false; type: this.imageType,
this.router.navigate(['/commands-logs']); queue: result
}, };
error: (error) => {
this.toastService.error(error.error['hydra:description']); if (this.imageType === 'git') {
this.loading = false; payload.gitRepository = this.selectedGitRepository.name
payload.name = this.selectedGitRepository.name;
if (this.gitAction === 'create') {
payload.action = 'create';
} else {
payload.action = 'update';
}
} else {
if (this.monolithicAction === 'create') {
payload.name = this.name;
payload.action = 'create';
} else {
payload.selectedImage = this.selectedImage['@id'];
payload.action = 'update';
}
} }
this.http.post(`${this.baseUrl}/images`, payload)
.subscribe({
next: (response) => {
let actionText = 'creación';
if (this.imageType === 'git' && this.gitAction === 'update') {
actionText = 'actualización';
} else if (this.imageType === 'monolithic' && this.monolithicAction === 'update') {
actionText = 'actualización';
}
this.toastService.success(`Petición de ${actionText} de imagen enviada`);
this.loading = false;
this.router.navigate(['/commands-logs']);
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
this.loading = false;
}
});
} }
); });
}
openCreateRepositoryModal(): void {
this.creatingRepository = true;
const dialogRef = this.dialog.open(CreateRepositoryModalComponent, {
width: '600px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
data: {
clientRepository: this.selectedRepository
}
});
dialogRef.afterClosed().subscribe(result => {
this.creatingRepository = false;
if (result) {
this.loadGitRepositories();
setTimeout(() => {
const newRepository = this.gitRepositories.find(repo => repo['@id'] === result['@id']);
if (newRepository) {
this.selectedGitRepository = newRepository;
this.onGitRepositorySelected(newRepository);
}
}, 200);
}
});
}
get imageType(): string {
return this._imageType;
}
set imageType(value: string) {
this._imageType = value;
this.loadImages();
if (value === 'git') {
this.loadGitRepositories();
this.selectedImage = null;
this.name = '';
this.monolithicAction = 'create';
} else {
this.resetGitSelections();
}
}
onGitImageRepositorySelected(gitImageRepository: any) {
this.selectedExistingImage = gitImageRepository;
this.existingImages = [];
if (gitImageRepository) {
this.loadExistingImages();
}
}
scrollToPartitionSection() {
const partitionSection = document.getElementById('partition-selection');
if (partitionSection) {
partitionSection.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
get selectedPartition(): any {
return this._selectedPartition;
}
set selectedPartition(value: any) {
this._selectedPartition = value;
} }
} }

View File

@ -0,0 +1,40 @@
.dialog-content {
display: flex;
flex-direction: column;
padding: 40px;
}
.repository-form {
width: 100%;
display: flex;
flex-direction: column;
}
.form-field {
width: 100%;
margin-top: 16px;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}
@media (max-width: 600px) {
.form-field {
width: 100%;
}
.dialog-actions {
flex-direction: column;
align-items: stretch;
}
button {
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
}

View File

@ -0,0 +1,17 @@
<app-loading [isLoading]="loading"></app-loading>
<h2 mat-dialog-title>Crear nuevo repositorio de imágenes git</h2>
<mat-dialog-content class="dialog-content">
<form [formGroup]="repositoryForm" (ngSubmit)="save()" class="repository-form">
<mat-form-field appearance="fill" class="form-field">
<mat-label>Nombre del repositorio</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
</form>
</mat-dialog-content>
<div mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="close()">Cancelar</button>
<button class="submit-button" (click)="save()">Guardar</button>
</div>

View File

@ -0,0 +1,64 @@
import { Component, OnInit, Inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from "@angular/forms";
import { HttpClient } from "@angular/common/http";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { ToastrService } from "ngx-toastr";
import { ConfigService } from '@services/config.service';
@Component({
selector: 'app-create-repository-modal',
templateUrl: './create-repository-modal.component.html',
styleUrl: './create-repository-modal.component.css'
})
export class CreateRepositoryModalComponent implements OnInit {
baseUrl: string;
repositoryForm: FormGroup<any>;
loading: boolean = false;
clientRepository: any = null;
constructor(
private fb: FormBuilder,
private http: HttpClient,
public dialogRef: MatDialogRef<CreateRepositoryModalComponent>,
private toastService: ToastrService,
private configService: ConfigService,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
this.clientRepository = this.data?.clientRepository || null;
this.repositoryForm = this.fb.group({
name: [null, Validators.required],
});
}
ngOnInit() {
// El componente se inicializa
}
save(): void {
if (this.repositoryForm.valid) {
this.loading = true;
const payload = {
name: this.repositoryForm.value.name,
repository: this.clientRepository ? this.clientRepository.id : null
};
this.http.post(`${this.baseUrl}/git-repositories`, payload).subscribe({
next: (response) => {
this.toastService.success('Repositorio creado correctamente');
this.dialogRef.close(response);
},
error: (error) => {
this.toastService.error(error.error?.['hydra:description'] || 'Error al crear el repositorio');
this.loading = false;
}
});
} else {
this.toastService.error('Por favor, complete todos los campos requeridos');
}
}
close(): void {
this.dialogRef.close();
}
}

View File

@ -1,101 +1,140 @@
.header-container {
.divider {
margin: 20px 0;
}
table {
width: 100%;
margin-top: 50px;
background-color: #eaeff6;
}
.search-container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
width: 100%; padding: 24px 32px;
padding: 0 5px; background: white;
box-sizing: border-box; border-radius: 12px;
margin-bottom: 20px;
} }
.option-container { .header-container-title {
margin: 20px 0; flex-grow: 1;
width: 100%; text-align: left;
} }
.deploy-container { .header-container-title h2 {
margin: 0 0 8px 0;
color: #333;
font-weight: 600;
}
.header-container-title h4 {
margin: 0;
font-size: 16px;
opacity: 0.9;
font-weight: 400;
}
.button-row {
display: flex; display: flex;
justify-content: space-between; padding-right: 1em;
gap: 12px;
align-items: center; align-items: center;
width: 100%;
padding: 5px;
gap: 10px;
} }
.options-container { .action-button {
padding: 10px; margin-top: 10px;
margin-bottom: 10px;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
} }
.action-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Contenedor principal */
.select-container { .select-container {
background: white !important;
margin-top: 20px; margin-top: 20px;
align-items: center; align-items: center;
padding: 20px; padding: 20px;
box-sizing: border-box; box-sizing: border-box;
} }
.input-group { /* Secciones del formulario */
.form-section {
background: white !important;
border-radius: 16px;
padding: 32px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #bbdefb;
}
.form-section-title {
display: flex; display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
font-size: 20px;
font-weight: 600;
color: #2c3e50;
padding-bottom: 16px;
border-bottom: 2px solid #f8f9fa;
}
.form-section-title mat-icon {
color: #667eea;
font-size: 24px;
width: 24px;
height: 24px;
}
/* Selectores */
.selector {
display: flex;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px;
margin-top: 20px;
} }
mat-option .unit-name { .half-width {
display: block; flex: 1;
}
.input-field {
flex: 1 1 calc(33.33% - 16px);
min-width: 250px; min-width: 250px;
} }
.full-width { .full-width {
width: 100%; width: 100%;
margin-bottom: 16px;
} }
.search-string { /* Campos de formulario */
flex: 2; mat-form-field {
padding: 5px; width: 100%;
margin-bottom: 8px;
} }
.search-boolean { ::ng-deep .mat-form-field-appearance-fill .mat-form-field-flex {
flex: 1; background-color: #f8f9fa;
padding: 5px; border-radius: 8px;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
} }
.header-container { ::ng-deep .mat-form-field-appearance-fill.mat-focused .mat-form-field-flex {
display: flex; background-color: white;
justify-content: space-between; border-color: #2196f3;
align-items: center; box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
padding: 10px 10px;
border-bottom: 1px solid #ddd;
}
.mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
.paginator-container {
display: flex;
justify-content: end;
margin-bottom: 30px;
} }
/* Grid de clientes */
.clients-grid { .clients-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px; gap: 12px;
margin-top: 20px;
} }
.client-item { .client-item {
@ -103,96 +142,381 @@ mat-option .unit-name {
} }
.client-card { .client-card {
background: #ffffff; background: white;
border-radius: 6px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
padding: 8px; padding: 12px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: background-color 0.3s, transform 0.2s; transition: all 0.3s ease;
border: 2px solid transparent;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
} }
::ng-deep .custom-tooltip { .client-card:hover {
white-space: pre-line !important; transform: translateY(-2px);
max-width: 200px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
background: rgba(0, 0, 0, 0.8); border-color: #667eea;
color: white;
padding: 8px;
border-radius: 4px;
} }
.selected-client { .selected-client {
background-color: #a0c2e5 !important; /* Azul */ background: linear-gradient(135deg, #8fa1f0 0%, #9b7bc8 100%);
color: white !important; color: white;
border-color: #667eea;
}
.selected-client .client-name,
.selected-client .client-ip {
color: white;
}
.client-image {
width: 32px;
height: 32px;
margin-bottom: 8px;
} }
.client-details { .client-details {
margin-top: 4px; margin-bottom: 12px;
} }
.client-name { .client-name {
font-size: 0.9em; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #333; color: #2c3e50;
margin-bottom: 5px; margin-bottom: 2px;
display: block;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
} }
.client-ip { .client-ip {
font-size: 10px;
color: #6c757d;
display: block; display: block;
font-size: 0.9em; margin-bottom: 1px;
color: #666; white-space: nowrap;
} overflow: hidden;
text-overflow: ellipsis;
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}
.button-row {
display: flex;
padding-right: 1em;
} }
/* Tabla de particiones */
.partition-table-container { .partition-table-container {
background-color: #eaeff6; background: white !important;
padding: 20px;
border-radius: 12px; border-radius: 12px;
padding: 24px;
margin-top: 20px; margin-top: 20px;
} }
table {
width: 100%;
background: white;
border-radius: 8px;
overflow: hidden;
}
::ng-deep .mat-table {
background: white;
}
::ng-deep .mat-header-cell {
background: white !important;
color: #2c3e50;
font-weight: 600;
padding: 16px;
}
::ng-deep .mat-cell {
padding: 16px;
color: #495057;
}
/* Opciones avanzadas */
.input-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.input-field {
width: 100%;
}
/* Mensajes de error */
.error-message {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
padding: 16px 20px;
border-radius: 8px;
margin-top: 16px;
font-weight: 500;
box-shadow: 0 4px 16px rgba(255, 107, 107, 0.3);
}
/* Instrucciones */
.instructions-box {
margin-bottom: 20px;
}
.instructions-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
::ng-deep .instructions-card .mat-card-title {
color: #2c3e50;
font-weight: 600;
padding: 20px 20px 0 20px;
}
::ng-deep .instructions-card .mat-card-content {
padding: 20px;
}
.instructions-card pre {
background: white !important;
padding: 16px;
border-radius: 8px;
border: 1px solid #e9ecef;
overflow-x: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
}
/* Tooltip personalizado */
::ng-deep .custom-tooltip {
background: rgba(0, 0, 0, 0.9) !important;
color: white !important;
padding: 12px !important;
border-radius: 8px !important;
font-size: 12px !important;
max-width: 250px !important;
white-space: pre-line !important;
}
/* Responsive */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.button-row {
justify-content: center;
}
.selector {
flex-direction: column;
}
.half-width {
min-width: auto;
}
.clients-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.client-card {
padding: 8px;
}
.client-image {
width: 24px;
height: 24px;
margin-bottom: 6px;
}
.client-name {
font-size: 11px;
}
.client-ip {
font-size: 9px;
}
.input-group {
grid-template-columns: 1fr;
}
.select-container {
padding: 0 16px;
}
.form-section {
padding: 20px;
}
.destination-badge {
padding: 10px 14px;
border-radius: 10px;
}
.destination-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 10px;
}
.destination-value {
max-width: 150px;
font-size: 13px;
}
.destination-label {
font-size: 10px;
}
}
/* Estilos para elementos específicos */
.unit-name {
font-weight: 500;
color: #2c3e50;
}
/* Eliminar sombra de la tabla */
.mat-elevation-z8 {
box-shadow: none !important;
}
/* Estilos para el expansion panel */
::ng-deep .mat-expansion-panel {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important;
border-radius: 12px !important;
margin-bottom: 20px;
background: #f7fbff !important;
border: 1px solid #bbdefb !important;
}
::ng-deep .mat-expansion-panel-header {
padding: 20px 24px !important;
border-radius: 12px !important;
}
::ng-deep .mat-expansion-panel-header-title {
font-weight: 600 !important;
color: #2c3e50 !important;
}
::ng-deep .mat-expansion-panel-header-description {
color: #6c757d !important;
}
/* Otros estilos */
.divider {
margin: 20px 0;
}
.disabled-client { .disabled-client {
pointer-events: none; pointer-events: none;
opacity: 0.5; opacity: 0.5;
} }
.error-message {
background-color: #de2323;
padding: 20px;
border-radius: 12px;
margin-top: 20px;
color: white;
font-weight: bold;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
}
.mat-expansion-panel-header-description { .mat-expansion-panel-header-description {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.instructions-textarea textarea {
font-family: monospace;
white-space: pre;
}
/* Estilo para hacer el backdrop no clickeable */
::ng-deep .non-clickable-backdrop {
pointer-events: none;
}
/* Estilos modernos para el badge de destino */
.destination-info {
margin-top: 12px;
}
.destination-badge {
display: inline-flex;
align-items: center;
background: #e3f2fd;
color: #1565c0;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #bbdefb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.destination-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin-right: 12px;
color: #1976d2;
}
.destination-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.destination-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #1976d2;
line-height: 1;
}
.destination-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0d47a1;
}
.filters-row {
display: flex;
gap: 20px;
align-items: flex-end;
margin-bottom: 20px;
}
.git-gap {
gap: 40px;
}
.monolithic-row {
display: flex;
gap: 24px;
align-items: flex-end;
margin-bottom: 20px;
}
.monolithic-row .half-width {
flex: 1 1 200px;
min-width: 200px;
}
.monolithic-row .full-width {
flex: 2;
}
@media (max-width: 768px) {
.monolithic-row .full-width {
flex: 2;
}
}

View File

@ -5,12 +5,36 @@
<h2> <h2>
{{ 'deployImage' | translate }} {{ 'deployImage' | translate }}
</h2> </h2>
<div class="destination-info">
<div class="destination-badge">
<mat-icon class="destination-icon">cloud_download</mat-icon>
<div class="destination-content">
<span class="destination-label">Destino</span>
<span class="destination-value">{{ runScriptTitle }}</span>
</div>
</div>
</div>
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="action-button" [disabled]="!allSelected || !selectedModelClient || !selectedImage || !selectedMethod || !selectedPartition" (click)="save()">Ejecutar</button> <button class="action-button" id="execute-button"
[disabled]="!isFormValid()"
(click)="save()">Ejecutar</button>
</div>
<div class="button-row">
<button class="action-button" (click)="generateOgInstructions()">
Generar instrucciones
</button>
</div>
<div class="button-row">
<button class="action-button" color="accent"
[disabled]="!isFormValid()"
(click)="openScheduleModal()">
Opciones de programación
</button>
</div> </div>
</div> </div>
<mat-divider></mat-divider>
<div class="select-container"> <div class="select-container">
<mat-expansion-panel> <mat-expansion-panel>
@ -32,7 +56,7 @@
<div *ngFor="let client of clientData" class="client-item"> <div *ngFor="let client of clientData" class="client-item">
<div class="client-card" <div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)" (click)="client.status === 'og-live' && toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected, 'disabled-client': client.status !== 'og-live'}" [ngClass]="{'selected-client': client.selected}"
[matTooltip]="getPartitionsTooltip(client)" [matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above" matTooltipPosition="above"
matTooltipClass="custom-tooltip"> matTooltipClass="custom-tooltip">
@ -67,72 +91,183 @@
<mat-divider style="margin-top: 20px;"></mat-divider> <mat-divider style="margin-top: 20px;"></mat-divider>
<div class="select-container"> <div class="select-container">
<div class="deploy-container"> <!-- Sección: Configuración de imagen -->
<mat-form-field appearance="fill" class="full-width"> <div class="form-section">
<mat-label>Seleccione imagen</mat-label> <div class="form-section-title">
<mat-select [(ngModel)]="selectedImage" name="selectedImage"> <mat-icon>image</mat-icon>
<mat-option *ngFor="let image of images" [value]="image"> Configuración de imagen
<div class="unit-name"> {{ image.name }}</div> </div>
<div style="font-size: smaller; color: gray;">{{ image.description }}</div>
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width"> <div class="selector">
<mat-label>Seleccione método de deploy</mat-label> <mat-form-field appearance="fill" class="half-width">
<mat-select [(ngModel)]="selectedMethod" name="selectedMethod" (selectionChange)="validateImageSize()"> <mat-label>Tipo de imagen</mat-label>
<mat-option *ngFor="let method of allMethods" [value]="method.value">{{ method.name }}</mat-option> <mat-select [(ngModel)]="imageType" (selectionChange)="onImageTypeSelected($event.value)">
</mat-select> <mat-option [value]="'monolithic'">Monolítica</mat-option>
</mat-form-field> <mat-option [value]="'git'">Git</mat-option>
</mat-select>
</mat-form-field>
</div>
<!-- Selectores Git (solo si imageType === 'git') -->
<div *ngIf="imageType === 'git'" class="filters-row git-gap">
<mat-form-field appearance="fill" style="width: 300px;">
<mat-label>Seleccionar Repositorio</mat-label>
<mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositoryChange($event.value)" [disabled]="loadingRepositories">
<mat-option *ngFor="let repo of repositories" [value]="repo">
{{ repo }}
</mat-option>
</mat-select>
<mat-icon matSuffix *ngIf="loadingRepositories">hourglass_empty</mat-icon>
</mat-form-field>
<mat-form-field appearance="fill" style="width: 300px;">
<mat-label>Seleccionar Rama</mat-label>
<mat-select [(ngModel)]="selectedBranch" (selectionChange)="onGitBranchChange()" [disabled]="loadingBranches || !selectedRepository">
<mat-option *ngFor="let branch of branches" [value]="branch">
{{ branch }}
</mat-option>
</mat-select>
<mat-icon matSuffix *ngIf="loadingBranches">hourglass_empty</mat-icon>
</mat-form-field>
</div>
<!-- Tabla de commits (solo si imageType === 'git') -->
<div *ngIf="imageType === 'git' && commits.length > 0" class="commits-table-container">
<table mat-table [dataSource]="commits" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>Seleccionar</th>
<td mat-cell *matCellDef="let commit">
<mat-radio-group [(ngModel)]="selectedCommit" name="selectedCommit">
<mat-radio-button [value]="commit"></mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container matColumnDef="hexsha">
<th mat-header-cell *matHeaderCellDef>Commit ID</th>
<td mat-cell *matCellDef="let commit">
<code style="background-color: #f5f5f5; padding: 2px 4px; border-radius: 3px; font-family: monospace;">
{{ commit.hexsha }}
</code>
</td>
</ng-container>
<ng-container matColumnDef="message">
<th mat-header-cell *matHeaderCellDef>Mensaje</th>
<td mat-cell *matCellDef="let commit">{{ commit.message }}</td>
</ng-container>
<ng-container matColumnDef="committed_date">
<th mat-header-cell *matHeaderCellDef>Fecha</th>
<td mat-cell *matCellDef="let commit">{{ commit.committed_date * 1000 | date:'dd/MM/yyyy HH:mm:ss' }}</td>
</ng-container>
<ng-container matColumnDef="tags">
<th mat-header-cell *matHeaderCellDef>Tags</th>
<td mat-cell *matCellDef="let commit">
<mat-chip-list>
<mat-chip *ngFor="let tag of commit.tags" color="primary" selected>{{ tag }}</mat-chip>
<span *ngIf="!commit.tags || commit.tags.length === 0" style="color: #999; font-style: italic;">Sin tags</span>
</mat-chip-list>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['select','hexsha','message','committed_date','tags']"></tr>
<tr mat-row *matRowDef="let row; columns: ['select','hexsha','message','committed_date','tags'];"></tr>
</table>
</div>
<!-- Selector de método y de imagen solo si imageType === 'monolithic' -->
<div class="monolithic-row" *ngIf="imageType === 'monolithic'">
<mat-form-field appearance="fill" class="half-width">
<mat-label>Seleccione método de deploy</mat-label>
<mat-select [(ngModel)]="selectedMethod" name="selectedMethod" (selectionChange)="validateImageSize()">
<mat-option *ngFor="let method of allMethods" [value]="method.value">{{ method.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione imagen</mat-label>
<mat-select [(ngModel)]="selectedImage" name="selectedImage" [disabled] = "!imageType" >
<mat-option *ngFor="let image of images" [value]="image">
<div class="unit-name"> {{ image.name }}</div>
<div style="font-size: smaller; color: gray;">{{ image.description }}</div>
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="errorMessage" class="error-message">
{{ errorMessage }}
</div>
</div> </div>
<div *ngIf="errorMessage" class="error-message"> <!-- Sección: Selección de partición -->
{{ errorMessage }} <div class="form-section" id="partition-selection">
<div class="form-section-title">
<mat-icon>storage</mat-icon>
Selección de partición
</div>
<div class="partition-table-container">
<div *ngIf="showInstructions" class="instructions-box">
<mat-card class="instructions-card">
<mat-card-title>
Instrucciones generadas
<button mat-icon-button (click)="showInstructions = false" style="float: right;">
<mat-icon>close</mat-icon>
</button>
</mat-card-title>
<mat-card-content>
<pre>{{ ogInstructions }}</pre>
</mat-card-content>
</mat-card>
</div>
<table mat-table [dataSource]="filteredPartitions">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef style="text-align: start">Seleccionar partición</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group [(ngModel)]="selectedPartition" name="selectedPartition">
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
{{ column.cell(image) }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
</div> </div>
<div class="partition-table-container"> <!-- Sección: Opciones avanzadas -->
<table mat-table [dataSource]="filteredPartitions" class="mat-elevation-z8"> <div class="form-section" id="advanced-options" *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct') || isMethod('p2p')">
<ng-container matColumnDef="select"> <div class="form-section-title">
<th mat-header-cell *matHeaderCellDef style="text-align: start">Seleccionar partición</th> <mat-icon>settings</mat-icon>
<td mat-cell *matCellDef="let row"> <span *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">Opciones multicast</span>
<mat-radio-group [(ngModel)]="selectedPartition" name="selectedPartition" (change)="validateImageSize()"> <span *ngIf="isMethod('p2p')">Opciones torrent</span>
<mat-radio-button [value]="row"> </div>
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef"> <div class="input-group" *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">
<th mat-header-cell *matHeaderCellDef> {{ column.header }} </th>
<td mat-cell *matCellDef="let image">
{{ column.cell(image) }}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
<mat-divider></mat-divider>
<div class="options-container">
<h3 *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')" class="input-group">Opciones multicast</h3>
<h3 *ngIf="isMethod('p2p')" class="input-group">Opciones torrent</h3>
<div *ngIf="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')" class="input-group">
<mat-form-field appearance="fill" class="input-field"> <mat-form-field appearance="fill" class="input-field">
<mat-label>Puerto</mat-label> <mat-label>Puerto</mat-label>
<input matInput [(ngModel)]="mcastPort" name="mcastPort" type="number"> <input matInput [(ngModel)]="mcastPort" name="mcastPort" type="number"
[required]="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">
</mat-form-field> </mat-form-field>
<mat-form-field appearance="fill" class="input-field"> <mat-form-field appearance="fill" class="input-field">
<mat-label>Dirección</mat-label> <mat-label>Dirección</mat-label>
<input matInput [(ngModel)]="mcastIp" name="mcastIp"> <input matInput [(ngModel)]="mcastIp" name="mcastIp"
[required]="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">
</mat-form-field> </mat-form-field>
<mat-form-field appearance="fill" class="input-field"> <mat-form-field appearance="fill" class="input-field">
<mat-label i18n="@@mcastModeLabel">Modo Multicast</mat-label> <mat-label i18n="@@mcastModeLabel">Modo Multicast</mat-label>
<mat-select [(ngModel)]="mcastMode" name="mcastMode"> <mat-select [(ngModel)]="mcastMode" name="mcastMode"
<mat-option *ngFor="let option of multicastModeOptions" [value]="option.value"> [required]="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">
<mat-option *ngFor="let option of multicastModeOptions" [value]="option.value">
{{ option.name }} {{ option.name }}
</mat-option> </mat-option>
</mat-select> </mat-select>
@ -140,37 +275,50 @@
<mat-form-field appearance="fill" class="input-field"> <mat-form-field appearance="fill" class="input-field">
<mat-label>Velocidad</mat-label> <mat-label>Velocidad</mat-label>
<input matInput [(ngModel)]="mcastSpeed" name="mcastSpeed" type="number"> <input matInput [(ngModel)]="mcastSpeed" name="mcastSpeed" type="number"
[required]="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">
</mat-form-field> </mat-form-field>
<mat-form-field appearance="fill" class="input-field"> <mat-form-field appearance="fill" class="input-field">
<mat-label>Máximo Clientes</mat-label> <mat-label>Máximo Clientes</mat-label>
<input matInput [(ngModel)]="mcastMaxClients" name="mcastMaxClients" type="number"> <input matInput [(ngModel)]="mcastMaxClients" name="mcastMaxClients" type="number"
[required]="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">
</mat-form-field> </mat-form-field>
<mat-form-field appearance="fill" class="input-field"> <mat-form-field appearance="fill" class="input-field">
<mat-label>Tiempo Máximo de Espera</mat-label> <mat-label>Tiempo Máximo de Espera</mat-label>
<input matInput [(ngModel)]="mcastMaxTime" name="mcastMaxTime" type="number"> <input matInput [(ngModel)]="mcastMaxTime" name="mcastMaxTime" type="number"
[required]="isMethod('udpcast') || isMethod('uftp') || isMethod('udpcast-direct')">
</mat-form-field> </mat-form-field>
</div> </div>
<div *ngIf="isMethod('p2p')" class="input-group"> <div class="input-group" *ngIf="isMethod('p2p')">
<mat-form-field appearance="fill" class="input-field"> <mat-form-field appearance="fill" class="input-field">
<mat-label i18n="@@p2pModeLabel">Modo P2P</mat-label> <mat-label i18n="@@p2pModeLabel">Modo P2P</mat-label>
<mat-select [(ngModel)]="p2pMode" name="p2pMode"> <mat-select [(ngModel)]="p2pMode" name="p2pMode"
<mat-option *ngFor="let option of p2pModeOptions" [value]="option.value"> [required]="isMethod('p2p')">
<mat-option *ngFor="let option of p2pModeOptions" [value]="option.value">
{{ option.name }} {{ option.name }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="fill" class="input-field"> <mat-form-field appearance="fill" class="input-field" *ngIf="p2pMode === 'seeder'">
<mat-label>Semilla</mat-label> <mat-label>Semilla (minutos)</mat-label>
<input matInput [(ngModel)]="p2pTime" name="p2pTime" type="number"> <input matInput [(ngModel)]="p2pTime" name="p2pTime" type="number"
[required]="isMethod('p2p') && p2pMode === 'seeder'">
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
</div> </div>
<app-scroll-to-top
[threshold]="100"
targetElement=".header-container"
position="bottom-right"
[showTooltip]="true"
tooltipText="Volver arriba"
tooltipPosition="left">
</app-scroll-to-top>

View File

@ -15,10 +15,12 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ToastrModule, ToastrService } from 'ngx-toastr'; import { ToastrModule, ToastrService } from 'ngx-toastr';
import { ActivatedRoute, provideRouter } from '@angular/router'; import { ActivatedRoute, provideRouter } from '@angular/router';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatExpansionModule } from "@angular/material/expansion"; import { MatExpansionModule } from "@angular/material/expansion";
import { MatIconModule } from "@angular/material/icon";
import { MatTooltipModule } from "@angular/material/tooltip";
import { LoadingComponent } from "../../../../../shared/loading/loading.component"; import { LoadingComponent } from "../../../../../shared/loading/loading.component";
import { ScrollToTopComponent } from "../../../../../shared/scroll-to-top/scroll-to-top.component";
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
describe('DeployImageComponent', () => { describe('DeployImageComponent', () => {
@ -32,7 +34,7 @@ describe('DeployImageComponent', () => {
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [DeployImageComponent, LoadingComponent], declarations: [DeployImageComponent, LoadingComponent, ScrollToTopComponent],
imports: [ imports: [
ReactiveFormsModule, ReactiveFormsModule,
FormsModule, FormsModule,
@ -46,6 +48,8 @@ describe('DeployImageComponent', () => {
MatDividerModule, MatDividerModule,
MatRadioModule, MatRadioModule,
MatSelectModule, MatSelectModule,
MatIconModule,
MatTooltipModule,
BrowserAnimationsModule, BrowserAnimationsModule,
ToastrModule.forRoot(), ToastrModule.forRoot(),
TranslateModule.forRoot() TranslateModule.forRoot()
@ -73,8 +77,7 @@ describe('DeployImageComponent', () => {
} }
}, },
{ provide: ConfigService, useValue: mockConfigService } { provide: ConfigService, useValue: mockConfigService }
], ]
schemas: [NO_ERRORS_SCHEMA]
}) })
.compileComponents(); .compileComponents();

View File

@ -1,17 +1,20 @@
import { Component, EventEmitter, Output } from '@angular/core'; import {Component, EventEmitter, OnInit, Output} from '@angular/core';
import { MatTableDataSource } from "@angular/material/table"; import { MatTableDataSource } from "@angular/material/table";
import { SelectionModel } from "@angular/cdk/collections"; import { SelectionModel } from "@angular/cdk/collections";
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import { ToastrService } from "ngx-toastr"; import { ToastrService } from "ngx-toastr";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import {CreateTaskComponent} from "../../../../commands/commands-task/create-task/create-task.component";
import {MatDialog} from "@angular/material/dialog";
import {QueueConfirmationModalComponent} from "../../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component";
@Component({ @Component({
selector: 'app-deploy-image', selector: 'app-deploy-image',
templateUrl: './deploy-image.component.html', templateUrl: './deploy-image.component.html',
styleUrl: './deploy-image.component.css' styleUrl: './deploy-image.component.css'
}) })
export class DeployImageComponent { export class DeployImageComponent implements OnInit{
baseUrl: string; baseUrl: string;
@Output() dataChange = new EventEmitter<any>(); @Output() dataChange = new EventEmitter<any>();
@ -21,7 +24,7 @@ export class DeployImageComponent {
images: any[] = []; images: any[] = [];
selectedImage: any = null; selectedImage: any = null;
selectedMethod: string | null = null; selectedMethod: string | null = null;
selectedPartition: any = null; private _selectedPartition: any = null;
mcastIp: string = ''; mcastIp: string = '';
mcastPort: Number = 0; mcastPort: Number = 0;
mcastMode: string = ''; mcastMode: string = '';
@ -34,6 +37,12 @@ export class DeployImageComponent {
clientData: any = []; clientData: any = [];
loading: boolean = false; loading: boolean = false;
allSelected = true; allSelected = true;
runScriptContext: any = null;
ogInstructions: string = '';
deployImage: boolean = true;
showInstructions: boolean = false;
loadingCommits: boolean = false;
selectedGitRepository: string = '';
protected p2pModeOptions = [ protected p2pModeOptions = [
{ name: 'Leecher', value: 'leecher' }, { name: 'Leecher', value: 'leecher' },
@ -49,6 +58,7 @@ export class DeployImageComponent {
selectedModelClient: any = null; selectedModelClient: any = null;
filteredPartitions: any[] = []; filteredPartitions: any[] = [];
selectedRepository: any = null; selectedRepository: any = null;
imageType: string = 'monolithic';
allMethods = [ allMethods = [
{ name: 'Multicast', value: 'udpcast' }, { name: 'Multicast', value: 'udpcast' },
@ -90,38 +100,77 @@ export class DeployImageComponent {
displayedColumns = ['select', ...this.columns.map(column => column.columnDef)]; displayedColumns = ['select', ...this.columns.map(column => column.columnDef)];
selection = new SelectionModel(true, []); selection = new SelectionModel(true, []);
repositories: string[] = [];
loadingRepositories: boolean = false;
branches: string[] = [];
selectedBranch: string = '';
loadingBranches: boolean = false;
commits: any[] = [];
selectedCommit: any = null;
private initialGitLoad = true;
constructor( constructor(
private http: HttpClient, private http: HttpClient,
private toastService: ToastrService, private toastService: ToastrService,
private configService: ConfigService, private configService: ConfigService,
private router: Router, private router: Router,
private route: ActivatedRoute private route: ActivatedRoute,
private dialog: MatDialog,
) { ) {
this.baseUrl = this.configService.apiUrl; this.baseUrl = this.configService.apiUrl;
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe(params => {
if (params['clientData']) { if (params['clientData']) {
this.clientData = JSON.parse(params['clientData']); this.clientData = JSON.parse(params['clientData']);
} }
}); if (params['runScriptContext']) {
this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null; this.runScriptContext = params['runScriptContext'];
this.clientData.forEach((client: { selected: boolean; status: string}) => {
if (client.status === 'og-live') {
client.selected = true;
} }
}); });
this.selectedClients = this.clientData.filter( this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null;
(client: { status: string }) => client.status === 'og-live' this.clientData.forEach((client: { selected: boolean; status: string}) => { client.selected = true; });
);
this.selectedModelClient = this.clientData.find( this.selectedClients = this.clientData.filter((client: { selected: boolean; status: string}) => client.selected);
(client: { status: string }) => client.status === 'og-live'
) || null; this.selectedModelClient = this.clientData.find((client: { selected: boolean; status: string}) => client.selected) || null;
if (this.selectedModelClient) { if (this.selectedModelClient) {
this.loadPartitions(this.selectedModelClient); this.loadPartitions(this.selectedModelClient);
} }
} }
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
this.runScriptContext = params['runScriptContext'] ? JSON.parse(params['runScriptContext']) : null;
});
}
onImageTypeSelected(event: any) {
this.imageType = event;
if (event === 'git') {
this.selectedMethod = null;
this.loadGitRepositories();
}
this.loadImages();
setTimeout(() => {
this.scrollToPartitionSection();
}, 200);
}
get runScriptTitle(): string {
const ctx = this.runScriptContext;
if (!ctx) {
return '';
}
if (Array.isArray(ctx)) {
return ctx.map(c => c.name).join(', ');
}
if (typeof ctx === 'object' && 'name' in ctx) {
return ctx.name;
}
return String(ctx);
}
isMethod(method: string): boolean { isMethod(method: string): boolean {
return this.selectedMethod === method; return this.selectedMethod === method;
} }
@ -174,7 +223,7 @@ export class DeployImageComponent {
this.loadImages(); this.loadImages();
}, },
(error) => { (error) => {
console.error('Error al cargar los datos completos del cliente:', error); console.error('Error al cargar las particiones:', error);
} }
); );
} else { } else {
@ -186,11 +235,7 @@ export class DeployImageComponent {
toggleSelectAll() { toggleSelectAll() {
this.allSelected = !this.allSelected; this.allSelected = !this.allSelected;
this.clientData.forEach((client: { selected: boolean; status: string }) => { this.clientData.forEach((client: { selected: boolean; status: string }) => { client.selected = this.allSelected; });
if (client.status === "og-live") {
client.selected = this.allSelected;
}
});
} }
loadImages() { loadImages() {
@ -201,7 +246,13 @@ export class DeployImageComponent {
return; return;
} }
const url = `${this.baseUrl}/image-image-repositories?status=success&repository.id=${repositoryId}&page=1&itemsPerPage=1000`; let url = ''
if (this.imageType === 'monolithic') {
url = `${this.baseUrl}/image-image-repositories?status=success&repository.id=${repositoryId}&page=1&itemsPerPage=1000`;
} else {
url = `${this.baseUrl}/git-image-repositories?status=success&repository.id=${repositoryId}&page=1&itemsPerPage=1000`;
}
this.http.get(url).subscribe( this.http.get(url).subscribe(
(response: any) => { (response: any) => {
@ -222,73 +273,296 @@ export class DeployImageComponent {
} }
} }
this.errorMessage = ""; this.errorMessage = "";
if (this.selectedMethod) {
setTimeout(() => {
this.scrollToPartitionSection();
}, 300);
}
return true; return true;
} }
save(): void { save(): void {
this.loading = true;
if (!this.selectedClients.length) { if (!this.selectedClients.length) {
this.toastService.error('Debe seleccionar al menos un cliente'); this.toastService.error('Debe seleccionar al menos un cliente');
this.loading = false;
return; return;
} }
if (!this.selectedImage) { if (!this.selectedImage && this.imageType !== 'git') {
this.toastService.error('Debe seleccionar una imagen'); this.toastService.error('Debe seleccionar una imagen');
this.loading = false;
return; return;
} }
if (!this.selectedMethod) { if (!this.selectedMethod && this.imageType !== 'git') {
this.toastService.error('Debe seleccionar un método'); this.toastService.error('Debe seleccionar un método');
this.loading = false;
return; return;
} }
if (!this.selectedPartition) { if (!this.selectedPartition) {
this.toastService.error('Debe seleccionar una partición'); this.toastService.error('Debe seleccionar una partición');
this.loading = false;
return; return;
} }
if (this.imageType === 'git' && !this.selectedCommit) {
this.toastService.error('Debe seleccionar un commit');
return;
}
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop'
});
this.toastService.info('Preparando petición de despliegue'); this.toastService.info('Preparando petición de despliegue');
const payload = { dialogRef.afterClosed().subscribe(result => {
clients: this.selectedClients.map((client: any) => client.uuid), if (result !== undefined) {
method: this.selectedMethod, this.loading = true;
// partition: this.selectedPartition['@id'],
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
p2pMode: this.p2pMode,
p2pTime: this.p2pTime,
mcastIp: this.mcastIp,
mcastPort: this.mcastPort,
mcastMode: this.mcastMode,
mcastSpeed: this.mcastSpeed,
maxTime: this.mcastMaxTime,
maxClients: this.mcastMaxClients,
};
this.http.post(`${this.baseUrl}/image-image-repositories/${this.selectedImage.uuid}/deploy-image`, payload) let payload: any;
.subscribe({ let url: string;
next: (response) => {
this.toastService.success('Petición de despliegue enviada correctamente'); if (this.imageType === 'git') {
this.loading = false; payload = {
this.router.navigate(['/commands-logs']); type: 'git',
}, clients: this.selectedClients.map((client: any) => client.uuid),
error: (error) => { diskNumber: this.selectedPartition.diskNumber,
this.toastService.error(error.error['hydra:description'], 'Se ha detectado un error en el despliegue de imágenes.', { partitionNumber: this.selectedPartition.partitionNumber,
"closeButton": true, repositoryName: this.selectedGitRepository,
"newestOnTop": false, branch: this.selectedBranch,
"progressBar": false, hexsha: this.selectedCommit.hexsha,
"positionClass": "toast-bottom-right", queue: result
"timeOut": 0, };
"extendedTimeOut": 0, url = `${this.baseUrl}/git-repositories/deploy-image`;
"tapToDismiss": false } else {
}); payload = {
this.loading = false; clients: this.selectedClients.map((client: any) => client.uuid),
method: this.selectedMethod,
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
p2pMode: this.p2pMode,
p2pTime: this.p2pMode === 'seeder' ? this.p2pTime : 0,
mcastIp: this.mcastIp,
mcastPort: this.mcastPort,
mcastMode: this.mcastMode,
mcastSpeed: this.mcastSpeed,
maxTime: this.mcastMaxTime,
maxClients: this.mcastMaxClients,
type: this.imageType,
queue: result
};
url = `${this.baseUrl}/image-image-repositories/${this.selectedImage.uuid}/deploy-image`;
} }
this.http.post(url, payload)
.subscribe({
next: (response) => {
this.toastService.success('Petición de despliegue enviada correctamente');
this.loading = false;
this.router.navigate(['/commands-logs']);
},
error: (error) => {
this.toastService.error(error.error['hydra:description'], 'Se ha detectado un error en el despliegue de imágenes.', {
"closeButton": true,
"newestOnTop": false,
"progressBar": false,
"positionClass": "toast-bottom-right",
"timeOut": 0,
"extendedTimeOut": 0,
"tapToDismiss": false
});
this.loading = false;
}
});
}
});
}
isFormValid(): boolean {
if (!this.allSelected || !this.selectedModelClient || !this.selectedPartition) {
return false;
}
if (this.imageType === 'git') {
if (!this.selectedCommit) {
return false;
}
}
if (this.imageType !== 'git' && !this.selectedMethod) {
return false;
}
if (this.imageType !== 'git') {
if (this.isMethod('udpcast') || this.isMethod('uftp') || this.isMethod('udpcast-direct')) {
if (!this.mcastPort || !this.mcastIp || !this.mcastMode || !this.mcastSpeed || !this.mcastMaxClients || !this.mcastMaxTime) {
return false;
}
}
if (this.isMethod('p2p')) {
if (!this.p2pMode) {
return false;
}
if (this.p2pMode === 'seeder' && !this.p2pTime) {
return false;
}
}
}
return true;
}
openScheduleModal(): void {
const dialogRef = this.dialog.open(CreateTaskComponent, {
width: '800px',
data: {
scope: this.runScriptContext.type,
organizationalUnit: this.runScriptContext['@id'],
source: 'assistant'
}
});
dialogRef.afterClosed().subscribe((result: { [x: string]: any; }) => {
if (result) {
const payload = {
method: this.selectedMethod,
diskNumber: this.selectedPartition.diskNumber,
partitionNumber: this.selectedPartition.partitionNumber,
p2pMode: this.selectedMethod === 'p2p' ? this.p2pMode : null,
p2pTime: this.selectedMethod === 'p2p' && this.p2pMode === 'seeder' ? this.p2pTime : null,
mcastIp: this.selectedMethod === 'udpcast' ? this.mcastIp : null,
mcastPort: this.selectedMethod === 'udpcast' ? this.mcastPort : null,
mcastMode: this.selectedMethod === 'udpcast' ? this.mcastMode : null,
mcastSpeed: this.selectedMethod === 'udpcast' ? this.mcastSpeed : null,
maxTime: this.selectedMethod === 'udpcast' ? this.mcastMaxTime : null,
maxClients: this.selectedMethod === 'udpcast' ? this.mcastMaxClients : null,
};
this.http.post(`${this.baseUrl}/command-task-scripts`, {
commandTask: result['taskId'] ? result['taskId']['@id'] : result['@id'],
parameters: payload,
order: result['executionOrder'] || 1,
type: 'deploy-image',
}).subscribe({
next: () => {
this.toastService.success('Script añadido con éxito a la tarea');
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
})
}
});
}
generateOgInstructions() {
this.showInstructions = true;
this.ogInstructions = `og-deploy-image --image ${this.selectedImage.name} --partition ${this.selectedPartition.partitionNumber} --method ${this.selectedMethod}`;
}
scrollToPartitionSection() {
const partitionSection = document.getElementById('partition-selection');
if (partitionSection) {
partitionSection.scrollIntoView({
behavior: 'smooth',
block: 'start'
}); });
console.log('Scroll ejecutado');
}
}
scrollToAdvancedOptions() {
const advancedOptions = document.getElementById('advanced-options');
if (advancedOptions) {
advancedOptions.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
console.log('Scroll hacia opciones avanzadas ejecutado');
}
}
get selectedPartition(): any {
return this._selectedPartition;
}
set selectedPartition(value: any) {
this._selectedPartition = value;
}
loadGitRepositories() {
this.loadingRepositories = true;
this.http.get<any>(`${this.baseUrl}/image-repositories/server/git/${this.selectedRepository?.uuid}/get-collection`).subscribe(
data => {
this.repositories = data.repositories || [];
this.loadingRepositories = false;
if (this.repositories.length > 0) {
this.selectedGitRepository = this.repositories[0];
this.loadGitBranches();
}
},
error => {
this.toastService.error('Error al cargar los repositorios git');
this.loadingRepositories = false;
}
);
}
onGitRepositoryChange(event: any) {
this.selectedGitRepository = event;
this.selectedBranch = '';
this.branches = [];
this.commits = [];
this.selectedCommit = null;
this.loadGitBranches();
}
loadGitBranches() {
if (!this.selectedGitRepository) return;
this.loadingBranches = true;
this.http.post<any>(`${this.baseUrl}/image-repositories/server/git/${this.selectedRepository?.uuid}/branches`, { repositoryName: this.selectedGitRepository }).subscribe(
data => {
this.branches = data.branches || [];
this.loadingBranches = false;
if (this.branches.length > 0) {
this.selectedBranch = this.branches[0];
this.loadGitCommits();
}
},
error => {
this.toastService.error('Error al cargar las ramas');
this.loadingBranches = false;
}
);
}
onGitBranchChange() {
this.selectedCommit = null;
this.commits = [];
this.loadGitCommits();
}
loadGitCommits() {
if (!this.selectedGitRepository || !this.selectedBranch) return;
this.loadingCommits = true;
this.http.post<any>(`${this.baseUrl}/image-repositories/server/git/${this.selectedRepository?.uuid}/commits`, {
repositoryName: this.selectedGitRepository,
branch: this.selectedBranch
}).subscribe(
data => {
this.commits = data.commits || [];
this.loadingCommits = false;
},
error => {
this.toastService.error('Error al cargar los commits');
this.loadingCommits = false;
}
);
} }
} }

View File

@ -1,147 +1,420 @@
/* ===== ESTILOS PRINCIPALES ===== */
.partition-assistant { .partition-assistant {
padding: 40px; padding: 32px;
margin: 20px; margin: 20px;
background-color: #eaeff6; background: white !important;
border-radius: 12px; border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #bbdefb;
} }
/* ===== HEADER ===== */
.header-container { .header-container {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 10px 10px; padding: 24px 32px;
border-bottom: 1px solid #ddd; background: white;
border-radius: 12px;
margin-bottom: 20px;
}
.header-container-title {
flex-grow: 1;
text-align: left;
}
.header-container-title h2 {
margin: 0 0 8px 0;
color: #333;
font-weight: 600;
}
.header-container-title h4 {
margin: 0;
font-size: 16px;
opacity: 0.9;
font-weight: 400;
}
/* ===== DESTINATION BADGE ===== */
.destination-info {
margin-top: 12px;
}
.destination-badge {
display: inline-flex;
align-items: center;
background: #e3f2fd;
color: #1565c0;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #bbdefb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.destination-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin-right: 12px;
color: #1976d2;
}
.destination-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.destination-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #1976d2;
line-height: 1;
}
.destination-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0d47a1;
}
/* ===== BOTONES ===== */
.button-row {
display: flex;
gap: 12px;
align-items: center;
}
.action-button {
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
margin-top: 10px;
margin-bottom: 10px;
margin-right: 16px;
}
.action-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.row-button {
display: flex;
align-items: center;
gap: 30px;
}
/* ===== SELECTOR DE DISCO ===== */
.disk-selector-card {
padding: 32px;
margin: 20px;
background: white !important;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #bbdefb;
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
padding: 24px 32px;
background: transparent;
color: #1976d2;
border-bottom: 1.5px solid #1976d2;
}
.card-icon {
font-size: 28px;
width: 28px;
height: 28px;
margin-right: 16px;
color: #1976d2;
}
.card-title h3 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
color: #1976d2;
}
.card-title p {
margin: 0;
font-size: 14px;
opacity: 0.9;
color: #1976d2;
}
.card-content {
padding: 24px 32px;
}
.disk-select-field {
width: 100%;
margin-bottom: 20px;
}
/* Opciones del select */
::ng-deep .disk-option {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 8px 0;
}
.disk-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.disk-name {
font-weight: 600;
font-size: 16px;
color: #2c3e50;
} }
.disk-size { .disk-size {
font-size: 1rem; font-size: 14px;
font-weight: 600; color: #6c757d;
color: #555;
}
.partition-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
margin-bottom: 20px;
}
.partition-table th {
color: #333;
padding: 12px;
font-weight: 600;
}
.partition-table td {
padding: 10px;
text-align: center;
}
.partition-table select,
.partition-table input[type="number"],
.partition-table input[type="checkbox"] {
padding: 5px;
width: 100%;
}
.actions {
display: flex;
justify-content: flex-end;
padding-top: 10px;
}
button {
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 1rem;
font-weight: 500; font-weight: 500;
cursor: pointer;
transition: background-color 0.3s ease;
} }
button.mat-button { .disk-details {
background-color: #007bff;
color: white;
}
button.mat-button:hover {
background-color: #0056b3;
}
button.mat-flat-button {
background-color: #28a745;
color: white;
}
button.mat-flat-button:hover {
background-color: #218838;
}
button.remove-btn {
background-color: #dc3545;
color: white;
border-radius: 4px;
}
button.remove-btn:hover {
background-color: #c82333;
}
.error-message {
color: #dc3545;
font-size: 0.9rem;
padding: 10px;
background-color: #f8d7da;
border-radius: 4px;
margin-top: 10px;
}
.partition-assistant .row {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
margin-bottom: 20px; align-items: flex-end;
gap: 4px;
} }
.form-container { .partitions-count {
flex: 0 0 65%; font-size: 12px;
max-width: 65%; color: #667eea;
padding-right: 20px; font-weight: 500;
box-sizing: border-box; background: rgba(102, 126, 234, 0.1);
padding: 2px 8px;
border-radius: 12px;
} }
.chart-container { .usage-percent {
flex: 0 0 35%; font-size: 12px;
max-width: 35%; color: #6c757d;
font-weight: 500;
} }
.partition-bar { /* Información de selección */
.selection-info {
display: flex; display: flex;
height: 40px;
margin: 20px 0;
}
.partition-segment {
display: flex;
justify-content: center;
align-items: center; align-items: center;
text-align: center; padding: 16px 20px;
font-size: 10px; background: #e8f5e8;
color: white; border: 1px solid #c8e6c9;
height: 100%; border-radius: 12px;
margin-top: 16px;
} }
.chart-container ngx-charts-pie-chart { .info-icon {
display: block; font-size: 20px;
align-content: center; width: 20px;
justify-self: center; height: 20px;
margin-right: 12px;
color: #388e3c;
} }
.disk-select { .info-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-title {
font-size: 14px;
font-weight: 600;
color: #2e7d32;
}
.info-subtitle {
font-size: 12px;
color: #388e3c;
}
/* Mensaje cuando no hay discos */
.no-disks-message {
display: flex;
align-items: center;
padding: 16px 20px;
background: #fff3e0;
border: 1px solid #ffcc02;
border-radius: 12px;
margin-top: 16px;
}
.warning-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin-right: 12px;
color: #f57c00;
}
.message-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.message-title {
font-size: 14px;
font-weight: 600;
color: #e65100;
}
.message-subtitle {
font-size: 12px;
color: #f57c00;
}
/* ===== INFO BADGES ===== */
.info-badge {
display: inline-flex;
align-items: center;
background: #e8f5e8;
color: #2e7d32;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #c8e6c9;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
margin: 0 8px;
}
.info-badge:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.info-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #388e3c;
line-height: 1;
}
.info-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1b5e20;
}
/* Badge específico para firmware */
.info-badge:first-of-type {
background: #e3f2fd;
border-color: #bbdefb;
}
.info-badge:first-of-type .info-icon {
color: #1976d2;
}
.info-badge:first-of-type .info-label {
color: #1976d2;
}
.info-badge:first-of-type .info-value {
color: #0d47a1;
}
/* Badge específico para tabla de particiones */
.info-badge:last-of-type {
background: #fff3e0;
border-color: #ffcc02;
}
.info-badge:last-of-type .info-icon {
color: #f57c00;
}
.info-badge:last-of-type .info-label {
color: #f57c00;
}
.info-badge:last-of-type .info-value {
color: #e65100;
}
/* ===== SELECTOR DE CLIENTES ===== */
.select-container {
margin-top: 20px;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 20px; padding: 20px;
margin: 10px auto;
} }
/* Expansion panel */
::ng-deep .mat-expansion-panel {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important;
border-radius: 12px !important;
margin-bottom: 20px;
background: #f7fbff !important;
border: 1px solid #bbdefb !important;
}
::ng-deep .mat-expansion-panel-header {
padding: 20px 24px !important;
border-radius: 12px !important;
}
::ng-deep .mat-expansion-panel-header-title {
font-weight: 600 !important;
color: #2c3e50 !important;
}
::ng-deep .mat-expansion-panel-header-description {
color: #6c757d !important;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
/* Grid de clientes */
.clients-grid { .clients-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
@ -152,6 +425,40 @@ button.remove-btn:hover {
position: relative; position: relative;
} }
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
}
.client-card:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
.selected-client {
background: linear-gradient(135deg, #8fa1f0 0%, #9b7bc8 100%);
color: white;
border-color: #667eea;
}
.selected-client .client-name,
.selected-client .client-ip {
color: white;
}
.client-image {
width: 40px;
height: 40px;
margin: 0 auto 8px;
}
.client-details { .client-details {
margin-top: 4px; margin-top: 4px;
} }
@ -174,37 +481,7 @@ button.remove-btn:hover {
color: #666; color: #666;
} }
.header-container-title { /* Tooltip personalizado */
flex-grow: 1;
text-align: left;
padding-left: 1em;
}
.select-container {
margin-top: 20px;
align-items: center;
width: 100%;
box-sizing: border-box;
padding-left: 1em;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
::ng-deep .custom-tooltip { ::ng-deep .custom-tooltip {
white-space: pre-line !important; white-space: pre-line !important;
max-width: 200px; max-width: 200px;
@ -214,36 +491,357 @@ button.remove-btn:hover {
border-radius: 4px; border-radius: 4px;
} }
.selected-client { /* ===== LAYOUT PRINCIPAL ===== */
background-color: #a0c2e5 !important; .row {
display: flex;
flex-wrap: wrap;
margin-bottom: 20px;
}
.form-container {
flex: 0 0 65%;
max-width: 65%;
padding-right: 20px;
box-sizing: border-box;
background: white;
border-radius: 16px;
border: none;
}
/* ===== INFORMACIÓN DEL DISCO ===== */
.disk-space-info-container {
margin: 24px 0;
background: none;
border-radius: 0;
border: none;
padding: 0;
}
.disk-space-card {
display: flex;
gap: 24px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.space-info-item {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e9ecef;
min-width: 180px;
}
.space-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
.used-icon {
color: #dc3545;
}
.free-icon {
color: #28a745;
}
.total-icon {
color: #007bff;
}
.space-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.space-label {
font-size: 12px;
font-weight: 500;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.space-value {
font-size: 16px;
font-weight: 600;
color: #212529;
}
/* Barra de uso del disco */
.disk-usage-bar {
margin-top: 16px;
}
.usage-bar-container {
width: 100%;
height: 12px;
background: #e9ecef;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.usage-bar-fill {
height: 100%;
background: linear-gradient(90deg, #28a745, #20c997);
border-radius: 6px;
transition: width 0.3s ease;
}
.usage-percentage {
font-size: 14px;
font-weight: 600;
color: #666;
margin-top: 8px;
text-align: center;
}
/* ===== TABLA DE PARTICIONES ===== */
.partition-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.partition-table th {
background: white !important;
color: #495057;
padding: 16px 12px;
font-weight: 600;
text-align: left;
border-bottom: 2px solid #e9ecef;
font-size: 14px;
}
.partition-table th:nth-child(1), .partition-table td:nth-child(1) { width: 5%; }
.partition-table th:nth-child(2), .partition-table td:nth-child(2) { width: 20%; }
.partition-table th:nth-child(3), .partition-table td:nth-child(3) { width: 20%; }
.partition-table th:nth-child(4), .partition-table td:nth-child(4) { width: 15%; }
.partition-table th:nth-child(5), .partition-table td:nth-child(5) { width: 15%; }
.partition-table th:nth-child(6), .partition-table td:nth-child(6) { width: 5%; }
.partition-table th:nth-child(7), .partition-table td:nth-child(7) { width: 5%; }
.partition-table td {
padding: 6px 8px;
border-bottom: 1px solid #f1f3f4;
vertical-align: middle;
}
.partition-table select,
.partition-table input[type="number"],
.partition-table input[type="checkbox"] {
padding: 5px;
width: 100%;
}
.remove-btn {
background-color: #dc3545;
color: white !important; color: white !important;
border-radius: 4px !important;
border: none !important;
cursor: pointer !important;
border-radius: 4px !important;
padding: 7px 10px !important;
} }
.button-row { .remove-btn:hover {
display: flex; background-color: #c82333;
padding-right: 1em;
} }
.disabled-client { /* ===== GRÁFICA ===== */
pointer-events: none; .chart-container {
opacity: 0.5; flex: 0 0 35%;
max-width: 35%;
align-self: center;
justify-self: center;
} }
.row-button { .chart-header {
display: flex; text-align: center;
align-items: center; margin-bottom: 20px;
gap: 30px;
} }
.chart-header h3 {
.action-button { margin: 0 0 8px 0;
margin-top: 10px; color: #2c3e50;
margin-bottom: 10px; font-weight: 600;
font-size: 18px;
} }
.mat-expansion-panel-header-description { .chart-header p {
justify-content: space-between; margin: 0;
align-items: center; color: #6c757d;
font-size: 14px;
}
.chart-container ngx-charts-pie-chart {
width: 100% !important;
min-width: 0;
}
/* Forzar la leyenda debajo del gráfico */
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend {
position: relative !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
display: block !important;
margin-top: 20px !important;
}
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend .legend-labels {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
justify-content: center !important;
}
::ng-deep .chart-container ngx-charts-pie-chart .chart-legend .legend-label {
display: flex !important;
align-items: center !important;
margin: 5px 0 !important;
}
/* ===== ESTADOS DE ADVERTENCIA ===== */
/* Advertencia (90% a 99% usado) */
.warning {
color: #ff9800 !important;
}
.usage-bar-fill.warning {
background: linear-gradient(90deg, #ff9800, #ffb74d) !important;
}
.free-icon.warning {
color: #ff9800 !important;
}
/* Peligro (100% o más usado) */
.danger {
color: #f44336 !important;
font-weight: bold !important;
}
.usage-bar-fill.danger {
background: linear-gradient(90deg, #f44336, #ef5350) !important;
animation: pulse 2s infinite;
}
.free-icon.danger {
color: #f44336 !important;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
}
/* ===== INSTRUCCIONES ===== */
.instructions-box {
margin-top: 15px;
background-color: #f5f5f5;
border: 1px solid #ccc;
padding: 15px;
border-radius: 6px;
}
.instructions-card {
background: #f5f5f5;
box-shadow: none !important;
margin-top: 20px;
border-radius: 16px;
border: none;
}
.instructions-card pre {
font-family: monospace;
white-space: pre;
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
border: 1px solid #e9ecef;
overflow-x: auto;
font-size: 14px;
line-height: 1.5;
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
gap: 16px;
text-align: center;
}
.button-row {
flex-direction: column;
gap: 8px;
}
.destination-badge {
flex-direction: column;
text-align: center;
}
.destination-icon {
margin-right: 0;
margin-bottom: 8px;
}
.destination-value {
max-width: none;
}
.destination-label {
text-align: center;
}
.form-section {
padding: 20px;
}
.disk-space-card {
flex-direction: column;
gap: 12px;
}
.space-info-item {
min-width: auto;
}
.row {
flex-direction: column;
}
.chart-container {
flex: none;
width: 100%;
margin-top: 20px;
}
.partition-table {
font-size: 12px;
}
.partition-table th,
.partition-table td {
padding: 8px 4px;
}
} }

View File

@ -2,12 +2,33 @@
<div class="header-container"> <div class="header-container">
<div class="header-container-title"> <div class="header-container-title">
<h2 joyrideStep="groupsTitleStepText" text="{{ 'groupsTitleStepText' | translate }}"> <h2>
Asistente de particionado {{ 'partitionTitle' | translate }}
</h2> </h2>
<div class="destination-info">
<div class="destination-badge">
<mat-icon class="destination-icon">cloud_download</mat-icon>
<div class="destination-content">
<span class="destination-label">Destino</span>
<span class="destination-value">{{ runScriptTitle }}</span>
</div>
</div>
</div>
</div> </div>
<div class="subnets-button-row"> <div class="button-row">
<button class="action-button" [disabled]="data.status === 'busy' || !selectedModelClient || !allSelected" (click)="save()">Ejecutar</button> <button class="action-button" [disabled]="data.status === 'busy' || !selectedModelClient || !allSelected || !selectedDisk || (selectedDisk.totalDiskSize - selectedDisk.used) <= 0" (click)="save()">Ejecutar</button>
</div>
<div class="button-row">
<button class="action-button" (click)="generateInstructions()">
Generar instrucciones
</button>
</div>
<div class="button-row">
<button class="action-button" color="accent" [disabled]="data.status === 'busy' || !selectedModelClient || !allSelected || !selectedDisk || (selectedDisk.totalDiskSize - selectedDisk.used) <= 0" (click)="openScheduleModal()">
Opciones de programación
</button>
</div> </div>
</div> </div>
@ -31,7 +52,7 @@
<div *ngFor="let client of clientData" class="client-item"> <div *ngFor="let client of clientData" class="client-item">
<div class="client-card" <div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)" (click)="client.status === 'og-live' && toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected, 'disabled-client': client.status !== 'og-live'}" [ngClass]="{'selected-client': client.selected}"
[matTooltip]="getPartitionsTooltip(client)" [matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above" matTooltipPosition="above"
matTooltipClass="custom-tooltip"> matTooltipClass="custom-tooltip">
@ -62,31 +83,131 @@
</mat-expansion-panel> </mat-expansion-panel>
</div> </div>
<mat-divider style="margin-top: 20px;"></mat-divider>
<mat-dialog-content> <mat-dialog-content>
<div class="disk-select"> <div class="disk-selector-card">
<mat-form-field appearance="fill"> <div class="card-header">
<mat-label>Seleccionar disco</mat-label> <mat-icon class="card-icon">storage</mat-icon>
<mat-select [(ngModel)]="selectedDiskNumber"> <div class="card-title">
<mat-option *ngFor="let disk of disks" [value]="disk.diskNumber"> <h3>Selección de Disco</h3>
Disco {{ disk.diskNumber }} ({{ (disk.totalDiskSize / 1024).toFixed(2) }} GB) <p>Elige el disco donde se realizarán las operaciones de particionado</p>
</mat-option> </div>
</mat-select> </div>
</mat-form-field>
<div class="card-content">
<mat-form-field appearance="fill" class="disk-select-field">
<mat-label>Seleccionar disco</mat-label>
<mat-select [(ngModel)]="selectedDiskNumber" (selectionChange)="onDiskSelectionChange()">
<mat-option *ngFor="let disk of disks" [value]="disk.diskNumber">
<div class="disk-option">
<div class="disk-info">
<span class="disk-name">Disco {{ disk.diskNumber }}</span>
<span class="disk-size"> {{ (disk.totalDiskSize / 1024).toFixed(2) }} GB</span>
</div>
<div class="disk-details">
<span class="usage-percent"> {{ (disk.percentage || 0).toFixed(1) }}% usado</span>
</div>
</div>
</mat-option>
</mat-select>
<mat-hint>Selecciona el disco que deseas particionar</mat-hint>
</mat-form-field>
<div class="selection-info" *ngIf="selectedDisk">
<mat-icon class="info-icon">info</mat-icon>
<div class="info-text">
<span class="info-title">Disco seleccionado: {{ selectedDisk.diskNumber }}</span>
<span class="info-subtitle">Tamaño total: {{ (selectedDisk.totalDiskSize / 1024).toFixed(2) }} GB</span>
</div>
</div>
<div class="no-disks-message" *ngIf="!disks || disks.length === 0">
<mat-icon class="warning-icon">warning</mat-icon>
<div class="message-text">
<span class="message-title">No hay discos disponibles</span>
<span class="message-subtitle">Asegúrate de que el cliente modelo tenga discos configurados</span>
</div>
</div>
</div>
</div> </div>
<div class="partition-assistant" *ngIf="selectedDisk"> <div class="partition-assistant" *ngIf="selectedDisk">
<div class="row-button"> <div *ngIf="showInstructions" class="instructions-box">
<button class="action-button" (click)="addPartition(selectedDisk.diskNumber)">Añadir partición</button> <mat-card class="instructions-card">
<mat-chip *ngIf="selectedModelClient.firmwareType"> <mat-card-title>
Tabla de particiones: {{ selectedModelClient.firmwareType }} Instrucciones generadas
</mat-chip> <button mat-icon-button (click)="showInstructions = false" style="float: right;">
<mat-icon>close</mat-icon>
</button>
</mat-card-title>
<mat-card-content>
<pre>{{ generatedInstructions }}</pre>
</mat-card-content>
</mat-card>
</div> </div>
<div class="row-button">
<button class="action-button" [disabled]="partitionCode === 'MSDOS'" (click)="addPartition(selectedDisk.diskNumber)">Añadir partición</button>
<div class="info-badge" *ngIf="selectedModelClient.firmwareType">
<mat-icon class="info-icon">memory</mat-icon>
<div class="info-content">
<span class="info-label">Firmware</span>
<span class="info-value">{{ selectedModelClient.firmwareType }}</span>
</div>
</div>
<div class="info-badge" *ngIf="partitionCode">
<mat-icon class="info-icon">storage</mat-icon>
<div class="info-content">
<span class="info-label">Tabla de particiones</span>
<span class="info-value">{{ partitionCode }}</span>
</div>
</div>
</div>
<mat-divider style="padding: 10px;"></mat-divider>
<div class="row"> <div class="row">
<div class="form-container"> <div class="form-container">
<table class="partition-table"> <div class="disk-space-info-container" id="disk-info">
<div class="disk-space-card">
<div class="space-info-item">
<mat-icon class="space-icon used-icon">storage</mat-icon>
<div class="space-details">
<span class="space-label">Espacio usado</span>
<span class="space-value">{{ selectedDisk.used | number:'1.2-2' }} MB</span>
</div>
</div>
<div class="space-info-item">
<mat-icon class="space-icon free-icon" [ngClass]="{'warning': (selectedDisk.used / selectedDisk.totalDiskSize) >= 0.9 && (selectedDisk.used / selectedDisk.totalDiskSize) < 1, 'danger': (selectedDisk.used / selectedDisk.totalDiskSize) >= 1}">cloud_done</mat-icon>
<div class="space-details">
<span class="space-label">Espacio libre</span>
<span class="space-value" [ngClass]="{'warning': (selectedDisk.used / selectedDisk.totalDiskSize) >= 0.9 && (selectedDisk.used / selectedDisk.totalDiskSize) < 1, 'danger': (selectedDisk.used / selectedDisk.totalDiskSize) >= 1}">{{ (selectedDisk.totalDiskSize - selectedDisk.used) | number:'1.2-2' }} MB</span>
</div>
</div>
<div class="space-info-item">
<mat-icon class="space-icon total-icon">dns</mat-icon>
<div class="space-details">
<span class="space-label">Espacio total</span>
<span class="space-value">{{ selectedDisk.totalDiskSize | number:'1.2-2' }} MB</span>
</div>
</div>
</div>
<div class="disk-usage-bar">
<div class="usage-bar-container">
<div class="usage-bar-fill"
[style.width.%]="(selectedDisk.used / selectedDisk.totalDiskSize) * 100"
[ngClass]="{'warning': (selectedDisk.used / selectedDisk.totalDiskSize) >= 0.9 && (selectedDisk.used / selectedDisk.totalDiskSize) < 1, 'danger': (selectedDisk.used / selectedDisk.totalDiskSize) >= 1}"></div>
</div>
<span class="usage-percentage" [ngClass]="{'warning': (selectedDisk.used / selectedDisk.totalDiskSize) >= 0.9 && (selectedDisk.used / selectedDisk.totalDiskSize) < 1, 'danger': (selectedDisk.used / selectedDisk.totalDiskSize) >= 1}">{{ ((selectedDisk.used / selectedDisk.totalDiskSize) * 100) | number:'1.1-1' }}% usado</span>
</div>
</div>
<table class="partition-table" id="partition-table">
<thead> <thead>
<tr> <tr>
<th>Partición</th> <th>Partición</th>
@ -95,7 +216,6 @@
<th>Tamaño (MB)</th> <th>Tamaño (MB)</th>
<th>Tamaño (%)</th> <th>Tamaño (%)</th>
<th>Formatear</th> <th>Formatear</th>
<th>Eliminar</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -103,32 +223,32 @@
<tr *ngIf="!partition.removed"> <tr *ngIf="!partition.removed">
<td>{{ partition.partitionNumber }}</td> <td>{{ partition.partitionNumber }}</td>
<td> <td>
<select [(ngModel)]="partition.partitionCode" required> <select [(ngModel)]="partition.partitionCode" required [disabled]="partition.partitionNumber === 1 && partitionCode === 'GPT'">
<option *ngFor="let type of partitionTypes" [value]="type.name"> <option *ngFor="let type of partitionTypes" [value]="type.name">
{{ type.name }} {{ type.name }}
</option> </option>
</select> </select>
</td> </td>
<td> <td>
<select [(ngModel)]="partition.filesystem" required> <select [(ngModel)]="partition.filesystem" required [disabled]="partition.partitionNumber === 1 && partitionCode === 'GPT'">
<option *ngFor="let type of filesystemTypes" [value]="type.name"> <option *ngFor="let type of filesystemTypes" [value]="type.name">
{{ type.name }} {{ type.name }}
</option> </option>
</select> </select>
</td> </td>
<td> <td>
<input type="number" [(ngModel)]="partition.size" required <input [disabled]="partition.partitionNumber === 1 && partitionCode === 'GPT'" type="number" [(ngModel)]="partition.size" required
(input)="updatePartitionSize(selectedDisk.diskNumber, j, partition.size)" /> (input)="updatePartitionSize(selectedDisk.diskNumber, j, partition.size)" />
</td> </td>
<td> <td>
<input type="number" [(ngModel)]="partition.percentage" <input [disabled]="partition.partitionNumber === 1 && partitionCode === 'GPT'" type="number" [(ngModel)]="partition.percentage"
(input)="updatePartitionSizeFromPercentage(selectedDisk.diskNumber, j, partition.percentage)" /> (input)="updatePartitionSizeFromPercentage(selectedDisk.diskNumber, j, partition.percentage)" />
</td> </td>
<td> <td>
<input type="checkbox" [(ngModel)]="partition.format" /> <mat-checkbox type="checkbox" [(ngModel)]="partition.format" />
</td> </td>
<td> <td>
<button (click)="removePartition(selectedDisk.diskNumber, partition)" class="remove-btn">X</button> <button mat-button *ngIf="partitionCode !== 'MSDOS'" (click)="removePartition(selectedDisk.diskNumber, partition)" class="remove-btn">X</button>
</td> </td>
</tr> </tr>
</ng-container> </ng-container>
@ -136,10 +256,28 @@
</table> </table>
</div> </div>
<div class="chart-container"> <div class="chart-container" *ngIf="selectedDisk">
<ngx-charts-pie-chart [view]="view" [results]="selectedDisk.chartData" [doughnut]="true"> <div class="chart-header">
<h3>Distribución de Particiones</h3>
</div>
<ngx-charts-pie-chart
[results]="selectedDisk.chartData"
[doughnut]="true"
[gradient]="true"
[labels]="true"
[tooltipDisabled]="false"
[animations]="true">
</ngx-charts-pie-chart> </ngx-charts-pie-chart>
</div> </div>
</div> </div>
</div> </div>
</mat-dialog-content> </mat-dialog-content>
<app-scroll-to-top
[threshold]="200"
targetElement=".header-container"
position="bottom-right"
[showTooltip]="true"
tooltipText="Volver arriba"
tooltipPosition="left">
</app-scroll-to-top>

View File

@ -5,6 +5,9 @@ import {ActivatedRoute, Router} from "@angular/router";
import { PARTITION_TYPES } from '../../../../../shared/constants/partition-types'; import { PARTITION_TYPES } from '../../../../../shared/constants/partition-types';
import { FILESYSTEM_TYPES } from '../../../../../shared/constants/filesystem-types'; import { FILESYSTEM_TYPES } from '../../../../../shared/constants/filesystem-types';
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import {CreateTaskComponent} from "../../../../commands/commands-task/create-task/create-task.component";
import {MatDialog} from "@angular/material/dialog";
import {QueueConfirmationModalComponent} from "../../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component";
interface Partition { interface Partition {
uuid?: string; uuid?: string;
@ -25,7 +28,7 @@ interface Partition {
templateUrl: './partition-assistant.component.html', templateUrl: './partition-assistant.component.html',
styleUrls: ['./partition-assistant.component.css'] styleUrls: ['./partition-assistant.component.css']
}) })
export class PartitionAssistantComponent { export class PartitionAssistantComponent implements OnInit{
baseUrl: string; baseUrl: string;
private apiUrl: string; private apiUrl: string;
@Output() dataChange = new EventEmitter<any>(); @Output() dataChange = new EventEmitter<any>();
@ -41,13 +44,17 @@ export class PartitionAssistantComponent {
disks: { diskNumber: number; totalDiskSize: number; partitions: Partition[]; chartData: any[]; used: number; percentage: number }[] = []; disks: { diskNumber: number; totalDiskSize: number; partitions: Partition[]; chartData: any[]; used: number; percentage: number }[] = [];
clientData: any = []; clientData: any = [];
loading: boolean = false; loading: boolean = false;
runScriptContext: any = null;
showInstructions = false;
view: [number, number] = [400, 300]; view: [number, number] = [300, 200];
showLegend = true; showLegend = true;
showLabels = true; showLabels = true;
allSelected = true; allSelected = true;
selectedClients: any[] = []; selectedClients: any[] = [];
selectedModelClient: any = null; selectedModelClient: any = null;
partitionCode: string = '';
generatedInstructions: string = '';
constructor( constructor(
private http: HttpClient, private http: HttpClient,
@ -55,6 +62,7 @@ export class PartitionAssistantComponent {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private configService: ConfigService, private configService: ConfigService,
private dialog: MatDialog,
) { ) {
this.baseUrl = this.configService.apiUrl; this.baseUrl = this.configService.apiUrl;
this.apiUrl = this.baseUrl + '/partitions'; this.apiUrl = this.baseUrl + '/partitions';
@ -62,27 +70,28 @@ export class PartitionAssistantComponent {
if (params['clientData']) { if (params['clientData']) {
this.clientData = JSON.parse(params['clientData']); this.clientData = JSON.parse(params['clientData']);
} }
}); if (params['runScriptContext']) {
this.clientId = this.clientData?.[0]['@id']; this.runScriptContext = params['runScriptContext'];
this.clientData.forEach((client: { selected: boolean; status: string}) => {
if (client.status === 'og-live') {
client.selected = true;
} }
}); });
this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null;
this.clientData.forEach((client: { selected: boolean; status: string}) => { client.selected = true; });
this.selectedClients = this.clientData.filter( this.selectedClients = this.clientData.filter((client: { selected: boolean; status: string}) => client.selected);
(client: { status: string }) => client.status === 'og-live'
);
this.selectedModelClient = this.clientData.find( this.selectedModelClient = this.clientData.find((client: { selected: boolean; status: string}) => client.selected) || null;
(client: { status: string }) => client.status === 'og-live'
) || null;
if (this.selectedModelClient) { if (this.selectedModelClient) {
this.loadPartitions(this.selectedModelClient); this.loadPartitions(this.selectedModelClient);
} }
} }
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
this.runScriptContext = params['runScriptContext'] ? JSON.parse(params['runScriptContext']) : null;
});
}
get selectedDisk():any { get selectedDisk():any {
return this.disks.find(disk => disk.diskNumber === this.selectedDiskNumber) || null; return this.disks.find(disk => disk.diskNumber === this.selectedDiskNumber) || null;
} }
@ -95,6 +104,7 @@ export class PartitionAssistantComponent {
this.http.get(url).subscribe( this.http.get(url).subscribe(
(response) => { (response) => {
this.data = response; this.data = response;
this.partitionCode = this.data.partitions[0].partitionCode;
this.initializeDisks(); this.initializeDisks();
}, },
(error) => { (error) => {
@ -103,17 +113,34 @@ export class PartitionAssistantComponent {
); );
} }
get runScriptTitle(): string {
const ctx = this.runScriptContext;
if (!ctx) {
return '';
}
if (Array.isArray(ctx)) {
return ctx.map(c => c.name).join(', ');
}
if (typeof ctx === 'object' && 'name' in ctx) {
return ctx.name;
}
return String(ctx);
}
toggleSelectAll() { toggleSelectAll() {
this.allSelected = !this.allSelected; this.allSelected = !this.allSelected;
this.clientData.forEach((client: { selected: boolean; status: string }) => { this.clientData.forEach((client: { selected: boolean; status: string }) => { client.selected = this.allSelected; });
if (client.status === "og-live") {
client.selected = this.allSelected;
}
});
} }
initializeDisks() { initializeDisks() {
this.disks = []; this.disks = [];
// Verificar que hay datos válidos
if (!this.data || !this.data.partitions || !Array.isArray(this.data.partitions)) {
console.warn('No hay datos de particiones válidos');
return;
}
const partitionsFromData = this.data.partitions; const partitionsFromData = this.data.partitions;
this.originalPartitions = JSON.parse(JSON.stringify(partitionsFromData)); this.originalPartitions = JSON.parse(JSON.stringify(partitionsFromData));
@ -131,11 +158,11 @@ export class PartitionAssistantComponent {
disk!.partitions.push({ disk!.partitions.push({
uuid: partition.uuid, uuid: partition.uuid,
partitionNumber: partition.partitionNumber, partitionNumber: partition.partitionNumber,
size: this.convertBytesToGB(partition.size), size: this.convertBytesToGB(partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 512 : partition.size),
memoryUsage: partition.memoryUsage, memoryUsage: partition.memoryUsage,
partitionCode: partition.partitionCode, partitionCode: partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 'EFI' : partition.partitionCode,
filesystem: partition.filesystem, filesystem: partition.filesystem,
sizeBytes: partition.size, sizeBytes: partition.partitionNumber === 1 && this.partitionCode === 'GPT' ? 512 : partition.size,
format: false, format: false,
color: '#1f1b91', color: '#1f1b91',
percentage: 0, percentage: 0,
@ -232,7 +259,6 @@ export class PartitionAssistantComponent {
removedPartitions.length > 0 ? Math.max(...removedPartitions.map((p) => p.partitionNumber)) : 0; removedPartitions.length > 0 ? Math.max(...removedPartitions.map((p) => p.partitionNumber)) : 0;
const newPartitionNumber = maxPartitionNumber + 1; const newPartitionNumber = maxPartitionNumber + 1;
disk.partitions.push({ disk.partitions.push({
partitionNumber: newPartitionNumber, partitionNumber: newPartitionNumber,
size: 0, size: 0,
@ -276,64 +302,77 @@ export class PartitionAssistantComponent {
} }
getRemainingGB(partitions: Partition[], totalDiskSize: number): number { getRemainingGB(partitions: Partition[], totalDiskSize: number): number {
const totalUsedGB = partitions.reduce((acc, partition) => acc + partition.size, 0); const totalUsedGB = partitions
.filter(partition => !partition.removed)
.reduce((acc, partition) => acc + partition.size, 0);
return Math.max(0, totalDiskSize - totalUsedGB); return Math.max(0, totalDiskSize - totalUsedGB);
} }
save() { save() {
if (!this.selectedDisk) { if (!this.selectedDisk) {
this.toastService.error('No se ha seleccionado un disco.'); this.toastService.error('No se ha seleccionado un disco.');
return; return;
} }
this.loading = true; const totalPartitionSize = this.selectedDisk.partitions
.filter((partition: any) => !partition.removed)
const totalPartitionSize = this.selectedDisk.partitions.reduce((sum: any, partition: { size: any; }) => sum + partition.size, 0); .reduce((sum: any, partition: any) => sum + partition.size, 0);
if (totalPartitionSize > this.selectedDisk.totalDiskSize) { if (totalPartitionSize > this.selectedDisk.totalDiskSize) {
this.toastService.error('El tamaño total de las particiones en el disco seleccionado excede el tamaño total del disco.'); this.toastService.error('El tamaño total de las particiones en el disco seleccionado excede el tamaño total del disco.');
this.loading = false;
return; return;
} }
const modifiedPartitions = this.selectedDisk.partitions.filter((partition: { removed: any; format: any; }) => !partition.removed || partition.format); const modifiedPartitions = this.selectedDisk.partitions.filter((partition: { removed: any; format: any; }) => !partition.removed || partition.format);
if (modifiedPartitions.length === 0) { if (modifiedPartitions.length === 0) {
this.loading = false;
this.toastService.info('No hay cambios para guardar en el disco seleccionado.'); this.toastService.info('No hay cambios para guardar en el disco seleccionado.');
return; return;
} }
const newPartitions = modifiedPartitions.map((partition: { partitionNumber: any; memoryUsage: any; size: any; partitionCode: any; filesystem: any; uuid: any; removed: any; format: any; }) => ({ const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
diskNumber: this.selectedDisk.diskNumber, width: '400px',
partitionNumber: partition.partitionNumber, disableClose: true,
memoryUsage: partition.memoryUsage, hasBackdrop: true,
size: partition.size, backdropClass: 'non-clickable-backdrop'
partitionCode: partition.partitionCode, });
filesystem: partition.filesystem,
uuid: partition.uuid,
removed: partition.removed || false,
format: partition.format || false,
}));
if (newPartitions.length > 0) { dialogRef.afterClosed().subscribe(result => {
const bulkPayload = { if (result !== undefined) {
partitions: newPartitions, this.loading = true;
clients: this.selectedClients.map((client: any) => client.uuid), const newPartitions = modifiedPartitions.map((partition: { partitionNumber: any; memoryUsage: any; size: any; partitionCode: any; filesystem: any; uuid: any; removed: any; format: any; }) => ({
}; diskNumber: this.selectedDisk.diskNumber,
partitionNumber: partition.partitionNumber,
memoryUsage: partition.memoryUsage,
size: partition.size,
partitionCode: partition.partitionCode,
filesystem: partition.filesystem,
uuid: partition.uuid,
removed: partition.removed || false,
format: partition.format || false,
}));
this.http.post(this.apiUrl, bulkPayload).subscribe( const bulkPayload = {
(response) => { partitions: newPartitions,
this.toastService.success('Particiones creadas exitosamente para el disco seleccionado.'); clients: this.selectedClients.map((client: any) => client.uuid),
this.loading = false; queue: result
this.router.navigate(['/commands-logs']); };
},
(error) => { this.http.post(this.apiUrl, bulkPayload).subscribe(
this.loading = false; (response) => {
this.toastService.error('Error al crear las particiones.'); this.toastService.success('Particiones creadas exitosamente para el disco seleccionado.');
} this.loading = false;
); this.router.navigate(['/commands-logs']);
} },
(error) => {
this.loading = false;
this.toastService.error('Error al crear las particiones.');
}
);
}
});
} }
@ -344,6 +383,10 @@ export class PartitionAssistantComponent {
if (partitionToRemove) { if (partitionToRemove) {
partitionToRemove.removed = true; partitionToRemove.removed = true;
} }
disk.used = this.calculateUsedSpace(disk.partitions);
disk.percentage = (disk.used / disk.totalDiskSize) * 100;
this.updateDiskChart(disk); this.updateDiskChart(disk);
this.updatePartitionPercentages(disk.partitions, disk.totalDiskSize); this.updatePartitionPercentages(disk.partitions, disk.totalDiskSize);
} }
@ -362,20 +405,135 @@ export class PartitionAssistantComponent {
} }
calculateUsedSpace(partitions: Partition[]): number { calculateUsedSpace(partitions: Partition[]): number {
return partitions.reduce((acc, partition) => acc + partition.size, 0); return partitions
.filter(partition => !partition.removed)
.reduce((acc, partition) => acc + partition.size, 0);
} }
generateChartData(partitions: Partition[]): any[] { generateChartData(partitions: Partition[]): any[] {
return partitions.map((partition) => ({ const colors = [
name: `Partición ${partition.partitionNumber}`, '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
value: partition.percentage, '#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
color: partition.color '#F8C471', '#82E0AA', '#F1948A', '#85C1E9', '#D7BDE2'
})); ];
return partitions
.filter(partition => !partition.removed)
.map((partition, index) => ({
name: `Partición ${partition.partitionNumber}`,
value: partition.size,
color: colors[index % colors.length],
partition: partition
}));
} }
updateDiskChart(disk: any) { updateDiskChart(disk: any) {
console.log('disk', disk);
disk.chartData = this.generateChartData(disk.partitions); disk.chartData = this.generateChartData(disk.partitions);
disk.used = this.calculateUsedSpace(disk.partitions); disk.used = this.calculateUsedSpace(disk.partitions);
disk.percentage = (disk.used / disk.totalDiskSize) * 100; disk.percentage = (disk.used / disk.totalDiskSize) * 100;
} }
openScheduleModal(): void {
const dialogRef = this.dialog.open(CreateTaskComponent, {
width: '800px',
data: {
scope: this.runScriptContext.type,
organizationalUnit: this.runScriptContext['@id'],
source: 'assistant'
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
const modifiedPartitions = this.selectedDisk.partitions.filter((partition: { removed: any; format: any; }) => !partition.removed || partition.format);
if (modifiedPartitions.length === 0) {
this.loading = false;
this.toastService.info('No hay cambios para guardar en el disco seleccionado.');
return;
}
const newPartitions = modifiedPartitions.map((partition: { partitionNumber: any; memoryUsage: any; size: any; partitionCode: any; filesystem: any; uuid: any; removed: any; format: any; }) => ({
diskNumber: this.selectedDisk.diskNumber,
partitionNumber: partition.partitionNumber,
memoryUsage: partition.memoryUsage,
size: partition.size,
partitionCode: partition.partitionCode,
filesystem: partition.filesystem,
uuid: partition.uuid,
removed: partition.removed || false,
format: partition.format || false,
}));
const bulkPayload = {
partitions: newPartitions,
clients: this.selectedClients.map((client: any) => client.uuid),
};
this.http.post(`${this.baseUrl}/command-task-scripts`, {
commandTask: result['@id'],
parameters: bulkPayload.partitions,
order: 1,
type: 'partition-assistant',
}).subscribe({
next: () => {
this.toastService.success('Script añadido con éxito a la tarea');
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
})
}
});
}
generateInstructions(): void {
this.showInstructions = true;
this.generatedInstructions = `og-partition --disk ${this.selectedDiskNumber} --partitions ${this.selectedDisk.partitions.map((p: Partition) => `${p.partitionNumber}:${p.size}:${p.partitionCode}:${p.filesystem}:${p.format}`).join(',')}`;
}
onDiskSelected(diskNumber: number) {
this.selectedDiskNumber = diskNumber;
this.scrollToPartitionTable();
}
onDiskSelectionChange() {
if (this.selectedDiskNumber) {
this.scrollToPartitionTable();
}
}
scrollToPartitionTable() {
// Pequeño delay para asegurar que el contenido se haya renderizado
setTimeout(() => {
const diskInfo = document.getElementById('disk-info');
if (diskInfo) {
diskInfo.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
}
}, 100);
}
scrollToExecuteButton() {
console.log('scrollToExecuteButton llamado');
const executeButton = document.getElementById('execute-button');
console.log('Botón ejecutar encontrado:', executeButton);
if (executeButton) {
executeButton.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
console.log('Scroll hacia botón ejecutar completado');
} else {
console.error('No se encontró el botón execute-button');
}
}
} }

View File

@ -1,4 +1,3 @@
.divider { .divider {
margin: 20px 0; margin: 20px 0;
} }
@ -102,10 +101,287 @@ table {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 10px 10px; padding: 24px 32px;
border-bottom: 1px solid #ddd; background: white;
border-radius: 12px;
margin-bottom: 20px;
} }
.header-container-title {
flex-grow: 1;
text-align: left;
}
.header-container-title h2 {
margin: 0 0 8px 0;
color: #333;
font-weight: 600;
}
.header-container-title h4 {
margin: 0;
font-size: 16px;
opacity: 0.9;
font-weight: 400;
}
.button-row {
display: flex;
padding-right: 1em;
gap: 12px;
align-items: center;
}
.action-button {
margin-top: 10px;
margin-bottom: 10px;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
}
.action-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.select-container {
background: white !important;
margin-top: 20px;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.form-section {
background: white !important;
border-radius: 16px;
padding: 20px !important;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #bbdefb;
}
.form-section-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
font-size: 20px;
font-weight: 600;
color: #2c3e50;
padding-bottom: 16px;
border-bottom: 2px solid #f8f9fa;
}
.form-section-title mat-icon {
color: #667eea;
font-size: 24px;
width: 24px;
height: 24px;
}
/* Badges y chips */
.destination-badge {
display: inline-flex;
align-items: center;
background: #e3f2fd;
color: #1565c0;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #bbdefb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.destination-icon {
font-size: 20px;
width: 20px;
height: 20px;
margin-right: 12px;
color: #1976d2;
}
.destination-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.destination-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #1976d2;
line-height: 1;
}
.destination-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #0d47a1;
}
.info-badge {
display: inline-flex;
align-items: center;
background: #e8f5e8;
color: #2e7d32;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid #c8e6c9;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
margin: 0 8px;
}
.info-badge:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.info-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #388e3c;
line-height: 1;
}
.info-value {
font-size: 14px;
font-weight: 600;
line-height: 1.2;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1b5e20;
}
/* Clientes y tarjetas */
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-top: 20px;
}
.client-item {
position: relative;
}
.client-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
position: relative;
padding: 12px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.client-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.client-image {
width: 32px;
height: 32px;
margin-bottom: 8px;
}
.client-details {
margin-bottom: 12px;
}
.client-name {
font-size: 12px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 2px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.client-ip {
font-size: 10px;
color: #6c757d;
display: block;
margin-bottom: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected-client {
background: linear-gradient(135deg, #8fa1f0 0%, #9b7bc8 100%);
color: white;
border-color: #667eea;
}
.selected-client .client-name,
.selected-client .client-ip {
color: white;
}
::ng-deep .mat-expansion-panel {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08) !important;
border-radius: 12px !important;
margin-bottom: 20px;
background: #f7fbff !important;
border: 1px solid #bbdefb !important;
}
::ng-deep .mat-expansion-panel-header {
padding: 20px 24px !important;
border-radius: 12px !important;
}
::ng-deep .mat-expansion-panel-header-title {
font-weight: 600 !important;
color: #2c3e50 !important;
}
::ng-deep .mat-expansion-panel-header-description {
color: #6c757d !important;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.mat-elevation-z8 { .mat-elevation-z8 {
box-shadow: 0px 0px 0px rgba(0,0,0,0.2); box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
} }
@ -116,117 +392,11 @@ table {
margin-bottom: 30px; margin-bottom: 30px;
} }
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.client-item {
position: relative;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
.client-details {
margin-top: 4px;
}
.client-name {
font-size: 0.9em;
font-weight: 600;
color: #333;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
}
.client-ip {
display: block;
font-size: 0.9em;
color: #666;
}
.header-container-title {
flex-grow: 1;
text-align: left;
padding-left: 1em;
}
.button-row {
display: flex;
padding-right: 1em;
}
.client-card {
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
position: relative;
padding: 8px;
text-align: center;
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;
&:hover {
background-color: #f0f0f0;
transform: scale(1.02);
}
}
::ng-deep .custom-tooltip {
white-space: pre-line !important;
max-width: 200px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px;
border-radius: 4px;
}
.selected-client {
background-color: #a0c2e5 !important;
color: white !important;
}
.button-row {
display: flex;
padding-right: 1em;
}
.disabled-client { .disabled-client {
pointer-events: none; pointer-events: none;
opacity: 0.5; opacity: 0.5;
} }
.action-button {
margin-top: 10px;
margin-bottom: 10px;
}
.mat-expansion-panel-header-description {
justify-content: space-between;
align-items: center;
}
.new-command-container { .new-command-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -261,4 +431,85 @@ table {
width: 100%; width: 100%;
} }
/* Secciones del formulario */
.form-section {
background: white !important;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
border: 1px solid #bbdefb;
padding: 20px;
}
.form-section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.form-section-title mat-icon {
color: #2196f3;
}
.toggle-options {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.selected-toggle {
background: linear-gradient(135deg, #8fa1f0 0%, #9b7bc8 100%) !important;
color: white !important;
}
mat-spinner {
margin: 20px auto;
display: block;
}
/* Estilo para hacer el backdrop no clickeable */
::ng-deep .non-clickable-backdrop {
pointer-events: none !important;
}
::ng-deep .action-chip {
margin: 8px !important;
padding: 12px 20px !important;
border-radius: px !important;
font-weight: 500 !important;
font-size: 14px !important;
transition: all 0.3s ease !important;
border: 2px solid transparent !important;
background: white !important;
color: #6c757d !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
min-height: 48px !important;
}
::ng-deep .action-chip:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important;
}
::ng-deep .action-chip.mat-mdc-chip-selected {
border-color: #667eea !important;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2) !important;
}
::ng-deep .create-chip.mat-mdc-chip-selected {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%) !important;
color: white !important;
}
::ng-deep .update-chip.mat-mdc-chip-selected {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%) !important;
color: white !important;
}

View File

@ -5,9 +5,18 @@
<h2> <h2>
{{ 'runScript' | translate }} {{ 'runScript' | translate }}
</h2> </h2>
<h4>
{{ runScriptTitle }}
</h4>
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript)" (click)="save()">Ejecutar</button> <button class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript) || loading" (click)="save()">Ejecutar</button>
</div>
<div class="button-row">
<button color="accent" class="action-button" [disabled]="selectedClients.length < 1 || (commandType === 'existing' && !selectedScript) || loading" (click)="openScheduleModal()">
Opciones de programación
</button>
</div> </div>
</div> </div>
<mat-divider></mat-divider> <mat-divider></mat-divider>
@ -31,8 +40,8 @@
<div class="clients-grid"> <div class="clients-grid">
<div *ngFor="let client of clientData" class="client-item"> <div *ngFor="let client of clientData" class="client-item">
<div class="client-card" <div class="client-card"
(click)="client.status === 'og-live' && toggleClientSelection(client)" (click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected, 'disabled-client': client.status !== 'og-live'}" [ngClass]="{'selected-client': client.selected}"
[matTooltip]="getPartitionsTooltip(client)" [matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above" matTooltipPosition="above"
matTooltipClass="custom-tooltip"> matTooltipClass="custom-tooltip">
@ -53,51 +62,69 @@
</mat-expansion-panel> </mat-expansion-panel>
</div> </div>
<mat-divider style="margin-top: 20px;"></mat-divider>
<div class="select-container"> <div class="select-container">
<div class="command-toggle">
<mat-radio-group [(ngModel)]="commandType">
<mat-radio-button value="new">Comando nuevo</mat-radio-button>
<mat-radio-button value="existing">Comando existente</mat-radio-button>
</mat-radio-group>
</div>
<div *ngIf="commandType === 'new'" class="new-command-container"> <div class="form-section">
<mat-form-field appearance="fill" class="full-width"> <div class="form-section-title">
<mat-label>Ingrese el script</mat-label> <mat-icon>code</mat-icon>
<textarea matInput [(ngModel)]="newScript" rows="6" placeholder="Escriba su script aquí"></textarea> Configuración de script
</mat-form-field>
<button class="action-button" (click)="saveNewScript()">Guardar Comando</button>
</div>
<div *ngIf="commandType === 'existing'" class="select-container">
<mat-form-field appearance="fill" class="custom-width">
<mat-label>Seleccione script a ejecutar</mat-label>
<mat-select [(ngModel)]="selectedScript" (selectionChange)="onScriptChange()">
<mat-option *ngFor="let script of scripts" [value]="script">{{ script.name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="selectedScript && commandType === 'existing'" class="script-container">
<div class="script-content">
<h3> Script:</h3>
<div class="script-preview" [innerHTML]="scriptContent"></div>
</div> </div>
<div class="script-params" *ngIf="parameterNames.length > 0"> <div class="action-chips-container">
<h3>Ingrese los valores de los parámetros detectados:</h3> <mat-chip-listbox [(ngModel)]="commandType" required class="action-chip-listbox">
<div *ngFor="let paramName of parameterNames"> <mat-chip-option value="new" class="action-chip create-chip firmware-chip" (click)="commandType = 'new'">
<mat-form-field appearance="fill" class="full-width"> <span>Nuevo Script</span>
<mat-label>{{ paramName }}</mat-label> </mat-chip-option>
<input matInput <mat-chip-option value="existing" class="action-chip update-chip firmware-chip" (click)="commandType = 'existing'">
[ngModel]="parameters[paramName]" <span>Script Guardado</span>
(ngModelChange)="onParamChange(paramName, $event)" </mat-chip-option>
placeholder="Ingrese el valor"> </mat-chip-listbox>
</mat-form-field> </div>
<div *ngIf="commandType === 'new'" class="new-command-container">
<mat-form-field appearance="fill" class="full-width">
<mat-label>Ingrese el script</mat-label>
<textarea matInput [(ngModel)]="newScript" rows="6" placeholder="Escriba su script aquí"></textarea>
</mat-form-field>
<button mat-flat-button color="primary" (click)="saveNewScript()">Guardar Script</button>
</div>
<div *ngIf="commandType === 'existing'">
<mat-form-field appearance="fill" class="custom-width">
<mat-label>Seleccione script a ejecutar</mat-label>
<mat-select [(ngModel)]="selectedScript" (selectionChange)="onScriptChange()">
<mat-option *ngFor="let script of scripts" [value]="script">{{ script.name }}</mat-option>
</mat-select>
</mat-form-field>
</div>
<div *ngIf="selectedScript && commandType === 'existing'" class="script-container">
<div class="script-content">
<h3>Script:</h3>
<div class="script-preview" [innerHTML]="scriptContent"></div>
</div>
<div class="script-params" *ngIf="parameterNames.length > 0 && selectedScript.parameters">
<h3>Ingrese los parámetros:</h3>
<div *ngFor="let paramName of parameterNames">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ paramName }}</mat-label>
<input matInput [ngModel]="parameters[paramName]" (ngModelChange)="onParamChange(paramName, $event)" placeholder="Valor para {{ paramName }}">
</mat-form-field>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<app-scroll-to-top
[threshold]="200"
targetElement=".header-container"
position="bottom-right"
[showTooltip]="true"
tooltipText="Volver arriba"
tooltipPosition="left">
</app-scroll-to-top>

View File

@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RunScriptAssistantComponent } from './run-script-assistant.component'; import { RunScriptAssistantComponent } from './run-script-assistant.component';
import { DeployImageComponent } from "../deploy-image/deploy-image.component"; import { DeployImageComponent } from "../deploy-image/deploy-image.component";
import { LoadingComponent } from "../../../../../shared/loading/loading.component"; import { LoadingComponent } from "../../../../../shared/loading/loading.component";
import { ScrollToTopComponent } from "../../../../../shared/scroll-to-top/scroll-to-top.component";
import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms";
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from "@angular/material/dialog"; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field"; import { MatFormFieldModule } from "@angular/material/form-field";
@ -26,6 +27,10 @@ import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { HttpClientTestingModule } from '@angular/common/http/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {MatIconModule} from "@angular/material/icon"; import {MatIconModule} from "@angular/material/icon";
import {MatCardModule} from "@angular/material/card";
import {MatButtonToggleModule} from "@angular/material/button-toggle";
import { MatChipsModule } from "@angular/material/chips";
import { MatTooltipModule } from "@angular/material/tooltip";
export function HttpLoaderFactory(http: HttpClient) { export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http); return new TranslateHttpLoader(http);
@ -42,7 +47,7 @@ describe('RunScriptAssistantComponent', () => {
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [RunScriptAssistantComponent, DeployImageComponent, LoadingComponent], declarations: [RunScriptAssistantComponent, DeployImageComponent, LoadingComponent, ScrollToTopComponent],
imports: [ imports: [
ReactiveFormsModule, ReactiveFormsModule,
FormsModule, FormsModule,
@ -59,6 +64,10 @@ describe('RunScriptAssistantComponent', () => {
MatSelectModule, MatSelectModule,
BrowserAnimationsModule, BrowserAnimationsModule,
MatIconModule, MatIconModule,
MatCardModule,
MatButtonToggleModule,
MatChipsModule,
MatTooltipModule,
ToastrModule.forRoot(), ToastrModule.forRoot(),
HttpClientTestingModule, HttpClientTestingModule,
TranslateModule.forRoot({ TranslateModule.forRoot({

View File

@ -1,18 +1,20 @@
import {Component, EventEmitter, Output} from '@angular/core'; import {Component, EventEmitter, OnInit, Output} from '@angular/core';
import {SelectionModel} from "@angular/cdk/collections"; import { SelectionModel } from "@angular/cdk/collections";
import {HttpClient} from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import {ToastrService} from "ngx-toastr"; import { ToastrService } from "ngx-toastr";
import {ConfigService} from "@services/config.service"; import { ConfigService } from "@services/config.service";
import {ActivatedRoute, Router} from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import {SaveScriptComponent} from "./save-script/save-script.component"; import { SaveScriptComponent } from "./save-script/save-script.component";
import {MatDialog} from "@angular/material/dialog"; import { MatDialog } from "@angular/material/dialog";
import {CreateTaskComponent} from "../../../../commands/commands-task/create-task/create-task.component";
import {QueueConfirmationModalComponent} from "../../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component";
@Component({ @Component({
selector: 'app-run-script-assistant', selector: 'app-run-script-assistant',
templateUrl: './run-script-assistant.component.html', templateUrl: './run-script-assistant.component.html',
styleUrl: './run-script-assistant.component.css' styleUrl: './run-script-assistant.component.css'
}) })
export class RunScriptAssistantComponent { export class RunScriptAssistantComponent implements OnInit{
baseUrl: string; baseUrl: string;
@Output() dataChange = new EventEmitter<any>(); @Output() dataChange = new EventEmitter<any>();
@ -32,6 +34,7 @@ export class RunScriptAssistantComponent {
newScript: string = ''; newScript: string = '';
selection = new SelectionModel(true, []); selection = new SelectionModel(true, []);
parameterNames: string[] = Object.keys(this.parameters); parameterNames: string[] = Object.keys(this.parameters);
runScriptContext: any = null;
constructor( constructor(
private http: HttpClient, private http: HttpClient,
@ -46,19 +49,39 @@ export class RunScriptAssistantComponent {
if (params['clientData']) { if (params['clientData']) {
this.clientData = JSON.parse(params['clientData']); this.clientData = JSON.parse(params['clientData']);
} }
}); if (params['runScriptContext']) {
this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null; this.runScriptContext = params['runScriptContext'];
this.clientData.forEach((client: { selected: boolean; status: string}) => {
if (client.status === 'og-live') {
client.selected = true;
} }
}); });
this.selectedClients = this.clientData.filter( this.clientId = this.clientData?.length ? this.clientData[0]['@id'] : null;
(client: { status: string }) => client.status === 'og-live' this.clientData.forEach((client: { selected: boolean; status: string}) => { client.selected = true; });
);
this.selectedClients = this.clientData.filter((client: { selected: boolean; status: string}) => client.selected);
this.loadScripts() this.loadScripts()
} }
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
this.runScriptContext = params['runScriptContext'] ? JSON.parse(params['runScriptContext']) : null;
});
}
get runScriptTitle(): string {
const ctx = this.runScriptContext;
if (!ctx) {
return '';
}
if (Array.isArray(ctx)) {
return ctx.map(c => c.name).join(', ');
}
if (typeof ctx === 'object' && 'name' in ctx) {
return ctx.name;
}
return String(ctx);
}
loadScripts(): void { loadScripts(): void {
this.loading = true; this.loading = true;
@ -94,18 +117,12 @@ export class RunScriptAssistantComponent {
} }
updateSelectedClients() { updateSelectedClients() {
this.selectedClients = this.clientData.filter( this.selectedClients = this.clientData.filter((client: { selected: boolean; status: string}) => client.selected);
(client: { selected: boolean; status: string }) => client.selected && client.status === "og-live"
);
} }
toggleSelectAll() { toggleSelectAll() {
this.allSelected = !this.allSelected; this.allSelected = !this.allSelected;
this.clientData.forEach((client: { selected: boolean; status: string }) => { this.clientData.forEach((client: { selected: boolean; status: string }) => { client.selected = this.allSelected; });
if (client.status === "og-live") {
client.selected = this.allSelected;
}
});
} }
getPartitionsTooltip(client: any): string { getPartitionsTooltip(client: any): string {
@ -114,7 +131,7 @@ export class RunScriptAssistantComponent {
} }
return client.partitions return client.partitions
.map((p: { partitionNumber: any; size: any; filesystem: any }) => `#${p.partitionNumber} ${p.filesystem} - ${p.size / 1024 }GB`) .map((p: { partitionNumber: any; size: any; filesystem: any }) => `#${p.partitionNumber} ${p.filesystem} - ${p.size / 1024}GB`)
.join('\n'); .join('\n');
} }
@ -150,28 +167,62 @@ export class RunScriptAssistantComponent {
this.scriptContent = updatedScript; this.scriptContent = updatedScript;
} }
trackByIndex(index: number): number { save(): void {
return index; const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
disableClose: true,
hasBackdrop: true
});
dialogRef.afterClosed().subscribe(result => {
if (result !== undefined) {
this.loading = true;
this.http.post(`${this.baseUrl}/commands/run-script`, {
clients: this.selectedClients.map((client: any) => client.uuid),
script: this.commandType === 'existing' ? this.scriptContent : this.newScript,
queue: result
}).subscribe(
response => {
this.toastService.success('Script ejecutado correctamente');
this.dataChange.emit();
this.router.navigate(['/commands-logs']);
this.loading = false;
},
error => {
this.toastService.error('Error al ejecutar el script');
this.loading = false;
}
);
}
});
} }
openScheduleModal(): void {
save(): void { const dialogRef = this.dialog.open(CreateTaskComponent, {
this.loading = true; width: '800px',
data: {
this.http.post(`${this.baseUrl}/commands/run-script`, { scope: this.runScriptContext.type,
clients: this.selectedClients.map((client: any) => client.uuid), organizationalUnit: this.runScriptContext['@id'],
script: this.commandType === 'existing' ? this.scriptContent : this.newScript, source: 'assistant'
}).subscribe(
response => {
this.toastService.success('Script ejecutado correctamente');
this.dataChange.emit();
this.router.navigate(['/commands-logs']);
},
error => {
this.toastService.error('Error al ejecutar el script');
} }
); });
this.loading = false; dialogRef.afterClosed().subscribe(result => {
if (result) {
this.http.post(`${this.baseUrl}/command-task-scripts`, {
commandTask: result['@id'],
content: this.commandType === 'existing' ? this.scriptContent : this.newScript,
order: 1,
type: 'run-script',
}).subscribe({
next: () => {
this.toastService.success('Script añadido con éxito a la tarea');
},
error: (error) => {
this.toastService.error(error.error['hydra:description']);
}
})
}
});
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,33 @@
<app-modal-overlay
[isVisible]="loading"
message="Cargando...">
</app-modal-overlay>
<div class="groups-container"> <div class="groups-container">
<!-- HEADER --> <!-- HEADER -->
<div class="header-container" joyrideStep="tabsStep" text="{{ 'tabsStepText' | translate }}"> <div class="header-container">
<button mat-icon-button color="primary" (click)="iniciarTour()"> <button mat-icon-button color="primary" (click)="initTour()">
<mat-icon>help</mat-icon> <mat-icon>help</mat-icon>
</button> </button>
<div class="header-container-title"> <div class="header-container-title">
<h2 joyrideStep="groupsTitleStepText" text="{{ 'groupsTitleStepText' | translate }}"> <h2 joyrideStep="groupsTitleStep" text="{{ 'groupsTitleStepText' | translate }}">
{{ 'adminGroupsTitle' | translate }} {{ 'adminGroupsTitle' | translate }}
</h2> </h2>
</div> </div>
<div class="groups-button-row" joyrideStep="addStep" text="{{ 'groupsAddStepText' | translate }}"> <div class="groups-button-row">
<button class="action-button" (click)="addOU($event)" <div joyrideStep="addStep" text="{{ 'groupsAddStepText' | translate }}" style="display: flex; gap: 15px;">
matTooltip="{{ 'newOrganizationalUnitTooltip' | translate }}" matTooltipShowDelay="1000"> <button class="action-button" (click)="addOU($event)" *ngIf="auth.userCategory !== 'ou-minimal'"
{{ 'newOrganizationalUnitButton' | translate }} matTooltip="{{ 'newOrganizationalUnitTooltip' | translate }}" matTooltipShowDelay="1000">
</button> {{ 'newOrganizationalUnitButton' | translate }}
<button class="action-button" [matMenuTriggerFor]="menuClients">{{ 'newClientButton' | translate }}</button> </button>
<mat-menu #menuClients="matMenu"> <button class="action-button" [matMenuTriggerFor]="menuClients" *ngIf="auth.userCategory !== 'ou-minimal'">{{
<button mat-menu-item (click)="addClient($event)">{{ 'newSingleClientButton' | translate }}</button> 'newClientButton' | translate }}</button>
<button mat-menu-item (click)="addMultipleClients($event)">{{ 'newMultipleClientButton' | translate }}</button> <mat-menu #menuClients="matMenu">
</mat-menu> <button mat-menu-item (click)="addClient($event)">{{ 'newSingleClientButton' | translate }}</button>
<button mat-menu-item (click)="addMultipleClients($event)">{{ 'newMultipleClientButton' | translate
}}</button>
</mat-menu>
</div>
<button class="ordinary-button" (click)="openBottomSheet()" joyrideStep="keyStep" <button class="ordinary-button" (click)="openBottomSheet()" joyrideStep="keyStep"
text="{{ 'keyStepText' | translate }}" matTooltipShowDelay="1000"> text="{{ 'keyStepText' | translate }}" matTooltipShowDelay="1000">
{{ 'legendButton' | translate }} {{ 'legendButton' | translate }}
@ -81,7 +89,7 @@
</button> </button>
</mat-form-field> </mat-form-field>
<mat-form-field class="form-field search-select" appearance="outline"> <mat-form-field class="form-field search-select" appearance="outline">
<mat-select placeholder="Buscar por estado..." #clientSearchStatusInput <mat-select placeholder="{{ 'searchState' | translate }}" #clientSearchStatusInput
(selectionChange)="onClientFilterStatusInput($event.value)"> (selectionChange)="onClientFilterStatusInput($event.value)">
<mat-option *ngFor="let option of status" [value]="option.value"> <mat-option *ngFor="let option of status" [value]="option.value">
{{ option.name }} {{ option.name }}
@ -93,8 +101,6 @@
</button> </button>
</mat-form-field> </mat-form-field>
<mat-divider class="tree-mat-divider" style="padding-top: 10px;"></mat-divider>
<!-- Funcionalidad actualmente deshabilitada--> <!-- Funcionalidad actualmente deshabilitada-->
<!-- <mat-form-field appearance="outline"> <!-- <mat-form-field appearance="outline">
<mat-select (selectionChange)="loadSelectedFilter($event.value)" placeholder="Cargar filtro" disabled> <mat-select (selectionChange)="loadSelectedFilter($event.value)" placeholder="Cargar filtro" disabled>
@ -116,49 +122,108 @@
</div> </div>
<!-- Tree --> <!-- Tree -->
<div class="tree-container"> <div class="tree-container" joyrideStep="treePanelStep" text="{{ 'treePanelStepText' | translate }}">
<mat-tree [dataSource]="treeDataSource" [treeControl]="treeControl"> <div class="tree-header">
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id}" <h3 class="tree-title">
<mat-icon>account_tree</mat-icon>
{{ 'organizationalStructure' | translate }}
</h3>
<div class="tree-actions">
<button mat-icon-button (click)="expandAll()" matTooltip="{{ 'expandAll' | translate }}">
<mat-icon>unfold_more</mat-icon>
</button>
<button mat-icon-button (click)="collapseAll()" matTooltip="{{ 'collapseAll' | translate }}">
<mat-icon>unfold_less</mat-icon>
</button>
</div>
</div>
<mat-tree [dataSource]="treeDataSource" [treeControl]="treeControl" class="modern-tree">
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id, 'tree-node': true}"
*matTreeNodeDef="let node; when: hasChild" matTreeNodePadding (click)="onNodeClick($event, node)"> *matTreeNodeDef="let node; when: hasChild" matTreeNodePadding (click)="onNodeClick($event, node)">
<button mat-icon-button matTreeNodeToggle [disabled]="!node.expandable"
[ngClass]="{'disabled-toggle': !node.expandable}"> <div class="node-content">
<mat-icon>{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}</mat-icon> <button mat-icon-button matTreeNodeToggle [disabled]="!node.expandable"
</button> [ngClass]="{'disabled-toggle': !node.expandable}" class="expand-button">
<mat-icon class="node-icon {{ node.type }}"> <mat-icon class="expand-icon">{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}</mat-icon>
{{ </button>
node.type === 'organizational-unit' ? 'apartment'
: node.type === 'classrooms-group' ? 'meeting_room' <div class="node-info">
: node.type === 'classroom' ? 'school' <div class="node-main">
: node.type === 'clients-group' ? 'lan' <mat-icon class="node-icon {{ node.type }}" [matTooltip]="getNodeTypeTooltip(node.type)">
: node.type === 'client' ? 'computer' {{
: 'group' node.type === 'organizational-unit' ? 'business'
}} : node.type === 'classrooms-group' ? 'meeting_room'
</mat-icon> : node.type === 'classroom' ? 'school'
<span>{{ node.name }}</span> : node.type === 'clients-group' ? 'dns'
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)"> : node.type === 'client' ? 'computer'
<mat-icon>more_vert</mat-icon> : 'folder'
</button> }}
</mat-icon>
<span class="node-name" [matTooltip]="node.name">{{ node.name }}</span>
</div>
<div class="node-details">
<ng-container *ngIf="node.type === 'client'">
<span class="node-ip">{{ node.ip }}</span>
</ng-container>
<ng-container *ngIf="node.children && node.children.length > 0">
<span class="node-count">{{ node.children.length }} {{ getNodeCountLabel(node.children.length) }}</span>
</ng-container>
</div>
</div>
<div class="node-actions">
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)"
class="menu-button" matTooltip="{{ 'moreActions' | translate }}">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</div>
</mat-tree-node> </mat-tree-node>
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id}"
<mat-tree-node [ngClass]="{'selected-node': selectedNode?.id === node.id, 'tree-node': true}"
*matTreeNodeDef="let node; when: isLeafNode" matTreeNodePadding (click)="onNodeClick($event, node)"> *matTreeNodeDef="let node; when: isLeafNode" matTreeNodePadding (click)="onNodeClick($event, node)">
<button mat-icon-button matTreeNodeToggle [disabled]="true" class="disabled-toggle"></button>
<mat-icon style="color: green;"> <div class="node-content">
{{ <button mat-icon-button matTreeNodeToggle [disabled]="true" class="disabled-toggle expand-button">
node.type === 'organizational-unit' ? 'apartment' <mat-icon class="expand-icon">chevron_right</mat-icon>
: node.type === 'classrooms-group' ? 'meeting_room' </button>
: node.type === 'classroom' ? 'school'
: node.type === 'clients-group' ? 'lan' <div class="node-info">
: node.type === 'client' ? 'computer' <div class="node-main">
: 'group' <mat-icon class="node-icon {{ node.type }}" [ngClass]="{'client-status': node.type === 'client'}"
}} [matTooltip]="getNodeTypeTooltip(node.type)">
</mat-icon> {{
<span>{{ node.name }}</span> node.type === 'organizational-unit' ? 'business'
<ng-container *ngIf="node.type === 'client'"> : node.type === 'classrooms-group' ? 'meeting_room'
<span> - IP: {{ node.ip }}</span> : node.type === 'classroom' ? 'school'
</ng-container> : node.type === 'clients-group' ? 'dns'
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)"> : node.type === 'client' ? 'computer'
<mat-icon>more_vert</mat-icon> : 'folder'
</button> }}
</mat-icon>
<span class="node-name" [matTooltip]="node.name">{{ node.name }}</span>
</div>
<div class="node-details">
<ng-container *ngIf="node.type === 'client'">
<span class="node-ip">{{ node.ip }}</span>
<span class="node-mac">{{ node.mac }}</span>
<span class="node-status" [ngClass]="'status-' + (node.status || 'off')">
{{ getStatusLabel(node.status) }}
</span>
</ng-container>
</div>
</div>
<div class="node-actions">
<button mat-icon-button [matMenuTriggerFor]="menuNode" (click)="onMenuClick($event, node)"
class="menu-button" matTooltip="{{ 'moreActions' | translate }}">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</div>
</mat-tree-node> </mat-tree-node>
</mat-tree> </mat-tree>
</div> </div>
@ -178,28 +243,40 @@
<mat-icon>map</mat-icon> <mat-icon>map</mat-icon>
<span>{{ 'roomMap' | translate }}</span> <span>{{ 'roomMap' | translate }}</span>
</button> </button>
<button mat-menu-item (click)="addClient($event, selectedNode)"> <button mat-menu-item (click)="addClient($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>add</mat-icon> <mat-icon>add</mat-icon>
<span>{{ 'newSingleClientButton' | translate }}</span> <span>{{ 'newSingleClientButton' | translate }}</span>
</button> </button>
<button mat-menu-item (click)="addMultipleClients($event, selectedNode)"> <button mat-menu-item (click)="addMultipleClients($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>playlist_add</mat-icon> <mat-icon>playlist_add</mat-icon>
<span>{{ 'newMultipleClientButton' | translate }}</span> <span>{{ 'newMultipleClientButton' | translate }}</span>
</button> </button>
<button mat-menu-item (click)="addOU($event, selectedNode)"> <button mat-menu-item (click)="addOU($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>account_tree</mat-icon> <mat-icon>account_tree</mat-icon>
<span>{{ 'addOrganizationalUnit' | translate }}</span> <span>{{ 'addOrganizationalUnit' | translate }}</span>
</button> </button>
<button mat-menu-item (click)="onEditNode($event, selectedNode)"> <button mat-menu-item (click)="onEditNode($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span>{{ 'edit' | translate }}</span> <span>{{ 'edit' | translate }}</span>
</button> </button>
<button mat-menu-item (click)="onDeleteClick($event, selectedNode)"> <button mat-menu-item (click)="onDeleteClick($event, selectedNode)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span> <span>{{ 'delete' | translate }}</span>
</button> </button>
<button mat-menu-item (click)="openPartitionTypeModal($event, selectedNode)">
<mat-icon>storage</mat-icon>
<span>{{ 'partitions' | translate }}</span>
</button>
<button mat-menu-item (click)="openOUPendingTasks($event, selectedNode)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<app-execute-command [clientData]="selectedNode?.clients || []" [buttonType]="'menu-item'"
[buttonText]="'ejecutarComandos' | translate" [icon]="'terminal'"
[disabled]="!((selectedNode?.clients ?? []).length > 0)" [runScriptContext]="selectedNode?.name || ''"
[runScriptContext]="getRunScriptContext(selectedNode?.clients || [])">
</app-execute-command>
</mat-menu> </mat-menu>
</div> </div>
<mat-divider [vertical]="true"></mat-divider> <mat-divider [vertical]="true"></mat-divider>
@ -214,15 +291,23 @@
<strong>{{ selectedNode?.name }}</strong> <strong>{{ selectedNode?.name }}</strong>
</span> </span>
<div class="view-type-container"> <div class="view-type-container">
<app-execute-command [clientData]="selection.selected" [buttonType]="'text'" <button class="action-button" [disabled]="selection.selected.length === 0" (click)="changeParent($event)"
[buttonText]="'Ejecutar comandos'" [disabled]="selection.selected.length === 0"></app-execute-command> matTooltip="{{ 'moveClientsTooltip' | translate }}" matTooltipShowDelay="1000">
{{ 'changeOU' | translate }}
</button>
<div joyrideStep="executeCommandStep" text="{{ 'executeCommandStepText' | translate }}">
<app-execute-command [clientData]="selection.selected" [buttonType]="'text'"
[buttonText]="'ejecutarComandos' | translate" [disabled]="selection.selected.length === 0"
[runScriptContext]="getRunScriptContext(selection.selected)"></app-execute-command>
</div>
<mat-button-toggle-group name="viewType" aria-label="View Type" [hideSingleSelectionIndicator]="true" <mat-button-toggle-group name="viewType" aria-label="View Type" [hideSingleSelectionIndicator]="true"
(change)="toggleView($event.value)"> (change)="toggleView($event.value)" joyrideStep="tabsStep" text="{{ 'tabsStepText' | translate }}">
<mat-button-toggle value="list" [disabled]="currentView === 'list'"> <mat-button-toggle value="list" [disabled]="currentView === 'list'">
<mat-icon>list</mat-icon> <span class="type-view-text">{{ 'Vista Lista' | translate }}</span> <mat-icon>list</mat-icon> <span class="type-view-text">{{ 'vistalista' | translate }}</span>
</mat-button-toggle> </mat-button-toggle>
<mat-button-toggle value="card" [disabled]="currentView === 'card'"> <mat-button-toggle value="card" [disabled]="currentView === 'card'">
<mat-icon>grid_view</mat-icon> <span class="type-view-text">{{ 'Vista Tarjeta' | translate }}</span> <mat-icon>grid_view</mat-icon> <span class="type-view-text">{{ 'vistatarjeta' | translate }}</span>
</mat-button-toggle> </mat-button-toggle>
</mat-button-toggle-group> </mat-button-toggle-group>
</div> </div>
@ -231,9 +316,49 @@
<app-loading [isLoading]="isLoadingClients"></app-loading> <app-loading [isLoading]="isLoadingClients"></app-loading>
<!-- CLIENTS VIEWS--> <!-- CLIENTS VIEWS-->
<div class="clients-view" *ngIf="!isLoadingClients"> <div class="clients-view" *ngIf="!isLoadingClients" joyrideStep="clientsViewStep"
text="{{ 'clientsViewStepText' | translate }}">
<div *ngIf="hasClients; else noClientsTemplate"> <div *ngIf="hasClients; else noClientsTemplate">
<div class="stats-container" *ngIf="currentView === 'list'">
<div class="stat-card">
<div class="stat-icon">
<mat-icon>computer</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ totalStats.total }}</div>
<div class="stat-label">{{ 'totalClients' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon offline">
<mat-icon>wifi_off</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('off') }}</div>
<div class="stat-label">{{ 'offline' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon online">
<mat-icon>wifi</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('og-live') + getStatusCount('linux') + getStatusCount('windows') }}</div>
<div class="stat-label">{{ 'online' | translate }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon busy">
<mat-icon>hourglass_empty</mat-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ getStatusCount('busy') }}</div>
<div class="stat-label">{{ 'busy' | translate }}</div>
</div>
</div>
</div>
<!-- Cards view --> <!-- Cards view -->
<div *ngIf="currentView === 'card'"> <div *ngIf="currentView === 'card'">
<section class="cards-view"> <section class="cards-view">
@ -242,22 +367,24 @@
[indeterminate]="selection.hasValue() && !isAllSelected()"> [indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox> </mat-checkbox>
<div class="clients-grid"> <div class="clients-grid">
<div *ngFor="let client of arrayClients" class="client-item"> <div *ngFor="let client of arrayClients" class="client-item" [ngClass]="'status-' + client.status">
<div class="client-card"> <div class="client-card">
<mat-checkbox (click)="$event.stopPropagation()" (change)="toggleRow(client)" <mat-checkbox (click)="$event.stopPropagation()" (change)="toggleRow(client)"
[checked]="selection.isSelected(client)" [disabled]="client.status === 'busy' || client.status === 'off' || client.status === 'disconnected'"> [checked]="selection.isSelected(client)">
</mat-checkbox> </mat-checkbox>
<img style="margin-top: 0.5em;" [src]="'assets/images/computer_' + client.status + '.svg'" <img style="margin-top: 0.5em;" [src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon" class="client-image" /> alt="Client Icon" class="client-image" />
<div class="client-details"> <div class="client-details">
<span class="client-name">{{ client.name }}</span> <span class="client-name truncate-cell-wide" [matTooltip]="client.name">{{ client.name }}</span>
<span class="client-ip">{{ client.ip }}</span> <span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span> <span class="client-mac">{{ client.mac }}</span>
<div class="action-icons"> <div class="action-icons">
<app-execute-command [clientData]="[client]" [buttonType]="'icon'" [icon]="'terminal'" <app-execute-command [clientState]="client.status" [clientData]="[client]"
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"></app-execute-command> [buttonType]="'icon'" [icon]="'terminal'"
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
[runScriptContext]="getRunScriptContext([client])"></app-execute-command>
<button <button
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))" [disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
@ -270,7 +397,7 @@
</span> </span>
<mat-menu #clientMenu="matMenu"> <mat-menu #clientMenu="matMenu">
<button mat-menu-item (click)="onEditClick($event, client.type, client.uuid)"> <button mat-menu-item (click)="onEditClick($event, client.type, client.uuid)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span>{{ 'edit' | translate }}</span> <span>{{ 'edit' | translate }}</span>
</button> </button>
@ -283,7 +410,19 @@
<mat-icon>sync</mat-icon> <mat-icon>sync</mat-icon>
<span>{{ 'sync' | translate }}</span> <span>{{ 'sync' | translate }}</span>
</button> </button>
<button mat-menu-item (click)="onDeleteClick($event, client)"> <button mat-menu-item (click)="openClientTaskLogs($event, client)">
<mat-icon>list_alt</mat-icon>
<span>{{ 'procedimientosCliente' | translate }}</span>
</button>
<button mat-menu-item (click)="openClientPendingTasks($event, client)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<button mat-menu-item *ngIf="client.status === 'og-live'" (click)="openClientLogsInNewTab($event, client)">
<mat-icon>article</mat-icon>
<span>Logs en tiempo real</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span> <span>{{ 'delete' | translate }}</span>
</button> </button>
@ -301,10 +440,24 @@
</div> </div>
</div> </div>
<!-- List view --> <!-- List view mejorada -->
<div *ngIf="currentView === 'list'" class="list-view"> <div *ngIf="currentView === 'list'" class="list-view">
<div class="table-header">
<div class="table-info">
<span>{{ 'showingResults' | translate: { from: getPaginationFrom(), to: getPaginationTo(), total: getPaginationTotal() } }}</span>
</div>
<div class="table-actions">
<button mat-icon-button (click)="refreshClientData()" matTooltip="{{ 'refresh' | translate }}">
<mat-icon>refresh</mat-icon>
</button>
<button mat-icon-button (click)="exportToCSV()" matTooltip="{{ 'exportCSV' | translate }}">
<mat-icon>download</mat-icon>
</button>
</div>
</div>
<section class="clients-table" tabindex="0"> <section class="clients-table" tabindex="0">
<table mat-table matSort [dataSource]="selectedClients"> <table mat-table [dataSource]="selectedClients" class="mat-elevation-z8">
<ng-container matColumnDef="select"> <ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef> <th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null" <mat-checkbox (change)="$event ? toggleAllRows() : null"
@ -314,12 +467,19 @@
</th> </th>
<td mat-cell *matCellDef="let row"> <td mat-cell *matCellDef="let row">
<mat-checkbox (click)="$event.stopPropagation()" (change)="toggleRow(row)" <mat-checkbox (click)="$event.stopPropagation()" (change)="toggleRow(row)"
[checked]="selection.isSelected(row)" [disabled]="row.status === 'busy' || row.status === 'off' || row.status === 'disconnected'"> [checked]="selection.isSelected(row)">
</mat-checkbox> </mat-checkbox>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="status"> <ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'status' | translate }} </th> <th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'status' | translate }}</span>
<button mat-icon-button (click)="sortColumn('status')" class="sort-button">
<mat-icon>{{ getSortIcon('status') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}" <td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500"> matTooltipPosition="left" matTooltipShowDelay="500">
<div class="client-status-container"> <div class="client-status-container">
@ -333,55 +493,103 @@
</ng-container> </ng-container>
<ng-container matColumnDef="name"> <ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'name' | translate }} </th> <th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'name' | translate }}</span>
<button mat-icon-button (click)="sortColumn('name')" class="sort-button">
<mat-icon>{{ getSortIcon('name') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}" <td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500"> matTooltipPosition="left" matTooltipShowDelay="500">
<p>{{ client.name }}</p> <div class="client-cell">
<span class="client-name truncate-cell-wide" [matTooltip]="client.name">{{ client.name }}</span>
</div>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="ip"> <ng-container matColumnDef="ip">
<th mat-header-cell *matHeaderCellDef mat-sort-header>IP </th> <th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>IP</span>
<button mat-icon-button (click)="sortColumn('ip')" class="sort-button">
<mat-icon>{{ getSortIcon('ip') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}" <td mat-cell *matCellDef="let client" matTooltip="{{ getClientPath(client) }}"
matTooltipPosition="left" matTooltipShowDelay="500"> matTooltipPosition="left" matTooltipShowDelay="500">
{{ client.ip }} <div class="client-cell">
<span class="client-ip">{{ client.ip }}</span>
<span class="client-mac">{{ client.mac }}</span>
</div>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="firmwareType">
<th mat-header-cell *matHeaderCellDef>
<div class="column-header">
<span>{{ 'firmwareType' | translate }}</span>
<button mat-icon-button (click)="sortColumn('firmwareType')" class="sort-button">
<mat-icon>{{ getSortIcon('firmwareType') }}</mat-icon>
</button>
</div>
</th>
<td mat-cell *matCellDef="let client">
<mat-chip *ngIf="client.firmwareType" class="firmware-chip">
{{ client.firmwareType }}
</mat-chip>
</td>
</ng-container>
<ng-container matColumnDef="oglive"> <ng-container matColumnDef="oglive">
<th mat-header-cell *matHeaderCellDef mat-sort-header> OG Live </th> <th mat-header-cell *matHeaderCellDef> OG Live </th>
<td mat-cell *matCellDef="let client"> {{ client.ogLive?.date | date }} </td> <td mat-cell *matCellDef="let client">
<div class="oglive-cell">
<span class="oglive-kernel">{{ client.ogLive?.kernel }}</span>
<span class="oglive-date">{{ client.ogLive?.date | date }}</span>
</div>
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="maintenace"> <ng-container matColumnDef="maintenace">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'maintenance' | translate }} </th> <th mat-header-cell *matHeaderCellDef> {{ 'maintenance' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.maintenance }} </td> <td mat-cell *matCellDef="let client"> {{ client.maintenance }} </td>
</ng-container> </ng-container>
<ng-container matColumnDef="subnet"> <ng-container matColumnDef="subnet">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'subnet' | translate }} </th> <th mat-header-cell *matHeaderCellDef> {{ 'subnet' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.subnet }} </td> <td mat-cell *matCellDef="let client"> {{ client.subnet }} </td>
</ng-container> </ng-container>
<ng-container matColumnDef="pxeTemplate"> <ng-container matColumnDef="pxeTemplate">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'pxeTemplate' | translate }} </th> <th mat-header-cell *matHeaderCellDef> {{ 'pxeTemplate' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.template?.name }} </td> <td mat-cell *matCellDef="let client" class="truncate-cell-medium" [matTooltip]="client.pxeTemplate?.name"> {{ client.pxeTemplate?.name }} </td>
</ng-container> </ng-container>
<ng-container matColumnDef="parentName"> <ng-container matColumnDef="parentName">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'parent' | translate }} </th> <th mat-header-cell *matHeaderCellDef> {{ 'parent' | translate }} </th>
<td mat-cell *matCellDef="let client"> {{ client.parentName }} </td> <td mat-cell *matCellDef="let client" class="truncate-cell-medium" [matTooltip]="client.parentName"> {{ client.parentName }} </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef mat-sort-header> {{ 'actions' | translate }} </th> <th mat-header-cell *matHeaderCellDef> {{ 'actions' | translate }} </th>
<td mat-cell *matCellDef="let client"> <td mat-cell *matCellDef="let client">
<button <div class="action-buttons">
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))" <button
mat-icon-button [matMenuTriggerFor]="clientMenu" color="primary"> [disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
<mat-icon>more_vert</mat-icon> mat-icon-button [matMenuTriggerFor]="clientMenu" color="primary" matTooltip="{{ 'moreActions' | translate }}">
</button> <mat-icon>more_vert</mat-icon>
<app-execute-command [clientData]="[client]" [buttonType]="'icon'" [icon]="'terminal'" </button>
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"> <app-execute-command [clientState]="client.status" [clientData]="[client]" [buttonType]="'icon'"
</app-execute-command> [icon]="'terminal'"
[disabled]="selection.selected.length > 1 || (selection.selected.length === 1 && !selection.isSelected(client))"
[runScriptContext]="getRunScriptContext([client])" matTooltip="{{ 'executeCommand' | translate }}">
</app-execute-command>
<button mat-icon-button color="primary" (click)="onShowClientDetail($event, client)" matTooltip="{{ 'viewDetails' | translate }}">
<mat-icon>visibility</mat-icon>
</button>
</div>
<mat-menu #clientMenu="matMenu"> <mat-menu #clientMenu="matMenu">
<button mat-menu-item (click)="onEditClick($event, client.type, client.uuid)"> <button mat-menu-item (click)="onEditClick($event, client.type, client.uuid)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>edit</mat-icon> <mat-icon>edit</mat-icon>
<span>{{ 'edit' | translate }}</span> <span>{{ 'edit' | translate }}</span>
</button> </button>
@ -393,16 +601,30 @@
<mat-icon>sync</mat-icon> <mat-icon>sync</mat-icon>
<span>{{ 'sync' | translate }}</span> <span>{{ 'sync' | translate }}</span>
</button> </button>
<button mat-menu-item (click)="onDeleteClick($event, client)"> <button mat-menu-item (click)="openClientTaskLogs($event, client)">
<mat-icon>list_alt</mat-icon>
<span>{{ 'procedimientosCliente' | translate }}</span>
</button>
<button mat-menu-item (click)="openClientPendingTasks($event, client)">
<mat-icon>pending_actions</mat-icon>
<span>{{ 'colaAcciones' | translate }}</span>
</button>
<button mat-menu-item *ngIf="client.status === 'og-live'" (click)="openClientLogsInNewTab($event, client)">
<mat-icon>article</mat-icon>
<span>Logs en tiempo real</span>
</button>
<button mat-menu-item (click)="onDeleteClick($event, client)" *ngIf="auth.userCategory !== 'ou-minimal'">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
<span>{{ 'delete' | translate }}</span> <span>{{ 'delete' | translate }}</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
</ng-container> </ng-container>
<tr mat-header-row style="background-color: #f3f3f3;" <tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
*matHeaderRowDef="displayedColumns; sticky: true"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns;" class="mat-row"
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> [ngClass]="'status-' + row.status"
[class.selected-row]="selectedClient?.uuid === row.uuid"
(click)="selectClient(row)"></tr>
</table> </table>
</section> </section>
<mat-paginator class="list-paginator" [length]="length" [pageSize]="itemsPerPage" [pageIndex]="page" <mat-paginator class="list-paginator" [length]="length" [pageSize]="itemsPerPage" [pageIndex]="page"

View File

@ -27,6 +27,7 @@ import { TreeNode } from './model/model';
import { LoadingComponent } from '../../shared/loading/loading.component'; import { LoadingComponent } from '../../shared/loading/loading.component';
import { ExecuteCommandComponent } from '../commands/main-commands/execute-command/execute-command.component'; import { ExecuteCommandComponent } from '../commands/main-commands/execute-command/execute-command.component';
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import { ModalOverlayComponent } from '../../shared/modal-overlay/modal-overlay.component';
describe('GroupsComponent', () => { describe('GroupsComponent', () => {
let component: GroupsComponent; let component: GroupsComponent;
@ -39,7 +40,7 @@ describe('GroupsComponent', () => {
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
declarations: [GroupsComponent, ExecuteCommandComponent, LoadingComponent], declarations: [GroupsComponent, ExecuteCommandComponent, LoadingComponent, ModalOverlayComponent],
imports: [ imports: [
HttpClientTestingModule, HttpClientTestingModule,
ToastrModule.forRoot(), ToastrModule.forRoot(),

View File

@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ViewChild, QueryList, ViewChildren } from '@angular/core'; import { Component, OnInit, OnDestroy, ViewChild, QueryList, ViewChildren, ChangeDetectorRef } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@ -15,7 +15,6 @@ import { ShowOrganizationalUnitComponent } from './shared/organizational-units/s
import { LegendComponent } from './shared/legend/legend.component'; import { LegendComponent } from './shared/legend/legend.component';
import { DeleteModalComponent } from '../../shared/delete_modal/delete-modal/delete-modal.component'; import { DeleteModalComponent } from '../../shared/delete_modal/delete-modal/delete-modal.component';
import { ClassroomViewDialogComponent } from './shared/classroom-view/classroom-view-modal'; import { ClassroomViewDialogComponent } from './shared/classroom-view/classroom-view-modal';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { PageEvent } from '@angular/material/paginator'; import { PageEvent } from '@angular/material/paginator';
import { CreateMultipleClientComponent } from "./shared/clients/create-multiple-client/create-multiple-client.component"; import { CreateMultipleClientComponent } from "./shared/clients/create-multiple-client/create-multiple-client.component";
@ -26,6 +25,12 @@ import { Subject } from 'rxjs';
import { ConfigService } from '@services/config.service'; import { ConfigService } from '@services/config.service';
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
import { MatMenuTrigger } from '@angular/material/menu'; import { MatMenuTrigger } from '@angular/material/menu';
import { ClientDetailsComponent } from './shared/client-details/client-details.component';
import { PartitionTypeOrganizatorComponent } from './shared/partition-type-organizator/partition-type-organizator.component';
import { ClientTaskLogsComponent } from '../task-logs/client-task-logs/client-task-logs.component';
import {ChangeParentComponent} from "./shared/change-parent/change-parent.component";
import { AuthService } from '@services/auth.service';
import { ClientPendingTasksComponent } from '../task-logs/client-pending-tasks/client-pending-tasks.component';
enum NodeType { enum NodeType {
OrganizationalUnit = 'organizational-unit', OrganizationalUnit = 'organizational-unit',
@ -74,6 +79,29 @@ export class GroupsComponent implements OnInit, OnDestroy {
arrayClients: any[] = []; arrayClients: any[] = [];
filters: { [key: string]: string } = {}; filters: { [key: string]: string } = {};
private clientFilterSubject = new Subject<string>(); private clientFilterSubject = new Subject<string>();
loading = false;
// Nuevas propiedades para funcionalidades mejoradas
selectedClient: any = null;
sortBy: string = 'name';
sortDirection: 'asc' | 'desc' = 'asc';
currentSortColumn: string = 'name';
// Estadísticas totales
totalStats: {
total: number;
off: number;
online: number;
busy: number;
} = {
total: 0,
off: 0,
online: 0,
busy: 0
};
// Tipos de firmware disponibles
firmwareTypes: string[] = [];
protected status = [ protected status = [
{ value: 'off', name: 'Apagado' }, { value: 'off', name: 'Apagado' },
@ -85,19 +113,10 @@ export class GroupsComponent implements OnInit, OnDestroy {
{ value: 'windows-session', name: 'Windows Session' }, { value: 'windows-session', name: 'Windows Session' },
{ value: 'busy', name: 'Ocupado' }, { value: 'busy', name: 'Ocupado' },
{ value: 'mac', name: 'Mac' }, { value: 'mac', name: 'Mac' },
{ value: 'disconnected', name: 'Desconectado' }
]; ];
displayedColumns: string[] = ['select', 'status', 'ip', 'name', 'oglive', 'subnet', 'pxeTemplate', 'actions']; displayedColumns: string[] = ['select', 'status', 'ip', 'firmwareType', 'name', 'oglive', 'subnet', 'pxeTemplate', 'actions'];
private _sort!: MatSort;
@ViewChild(MatSort)
set matSort(ms: MatSort) {
this._sort = ms;
if (this.selectedClients) {
this.selectedClients.sort = this._sort;
}
}
private subscriptions: Subscription = new Subscription(); private subscriptions: Subscription = new Subscription();
@ -110,7 +129,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
private joyrideService: JoyrideService, private joyrideService: JoyrideService,
private breakpointObserver: BreakpointObserver, private breakpointObserver: BreakpointObserver,
private toastr: ToastrService, private toastr: ToastrService,
private configService: ConfigService public auth: AuthService,
private configService: ConfigService,
private cd: ChangeDetectorRef,
) { ) {
this.baseUrl = this.configService.apiUrl; this.baseUrl = this.configService.apiUrl;
this.mercureUrl = this.configService.mercureUrl; this.mercureUrl = this.configService.mercureUrl;
@ -127,7 +148,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
); );
this.treeDataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); this.treeDataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener);
this.currentView = localStorage.getItem('groupsView') || 'list'; this.currentView = this.auth.groupsView || 'list';
} }
@ -175,23 +196,35 @@ export class GroupsComponent implements OnInit, OnDestroy {
}) })
} }
private updateClientStatus(clientUuid: string, newStatus: string): void { private updateClientStatus(clientUuid: string, status: string): void {
const clientIndex = this.selectedClients.data.findIndex(client => client['@id'] === clientUuid); let updated = false;
if (clientIndex !== -1) { const index = this.arrayClients.findIndex(client => client['@id'] === clientUuid);
if (index !== -1) {
const updatedClient = { ...this.arrayClients[index], status };
this.arrayClients = [
...this.arrayClients.slice(0, index),
updatedClient,
...this.arrayClients.slice(index + 1)
];
updated = true;
}
const tableIndex = this.selectedClients.data.findIndex(client => client['@id'] === clientUuid);
if (tableIndex !== -1) {
const updatedClients = [...this.selectedClients.data]; const updatedClients = [...this.selectedClients.data];
updatedClients[clientIndex] = { updatedClients[tableIndex] = {
...updatedClients[clientIndex], ...updatedClients[tableIndex],
status: newStatus status: status
}; };
this.selectedClients.data = updatedClients; this.selectedClients.data = updatedClients;
this.arrayClients = updatedClients; }
console.log(`Estado actualizado para el cliente ${clientUuid}: ${newStatus}`); if (updated) {
} else { this.cd.detectChanges();
console.warn(`Cliente con UUID ${clientUuid} no encontrado en la lista.`);
} }
} }
@ -369,23 +402,34 @@ export class GroupsComponent implements OnInit, OnDestroy {
onNodeClick(event: MouseEvent, node: TreeNode): void { onNodeClick(event: MouseEvent, node: TreeNode): void {
event.stopPropagation(); event.stopPropagation();
this.selectedNode = node; this.selectedNode = node;
this.fetchClientsForNode(node); const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
this.fetchClientsForNode(node, selectedClientsBeforeEdit);
} }
onMenuClick(event: Event, node: any): void { onMenuClick(event: Event, node: any): void {
event.stopPropagation(); event.stopPropagation();
this.selectedNode = node; this.selectedNode = node;
this.fetchClientsForNode(node); const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
this.fetchClientsForNode(node, selectedClientsBeforeEdit);
} }
public fetchClientsForNode(node: any, selectedClientsBeforeEdit: string[] = []): void { public fetchClientsForNode(node: any, selectedClientsBeforeEdit: string[] = []): void {
const params = new HttpParams({ fromObject: this.filters }); const params = new HttpParams({ fromObject: this.filters });
// Agregar parámetros de ordenamiento al backend
let backendParams = { ...this.filters };
if (this.sortBy) {
backendParams['order[' + this.sortBy + ']'] = this.sortDirection;
}
this.isLoadingClients = true; this.isLoadingClients = true;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params }).subscribe({ this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params: backendParams }).subscribe({
next: (response: any) => { next: (response: any) => {
this.selectedClients.data = response['hydra:member']; this.selectedClients.data = response['hydra:member'];
if (this.selectedNode) {
this.selectedNode.clients = response['hydra:member'];
}
this.length = response['hydra:totalItems']; this.length = response['hydra:totalItems'];
this.arrayClients = this.selectedClients.data; this.arrayClients = this.selectedClients.data;
this.hasClients = this.selectedClients.data.length > 0; this.hasClients = this.selectedClients.data.length > 0;
@ -398,6 +442,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selection.select(client); this.selection.select(client);
} }
}); });
// Calcular estadísticas después de cargar los clientes
this.calculateLocalStats();
}, },
error: () => { error: () => {
this.isLoadingClients = false; this.isLoadingClients = false;
@ -413,25 +460,35 @@ export class GroupsComponent implements OnInit, OnDestroy {
} }
addOU(event: MouseEvent, parent: TreeNode | null = null): void { addOU(event: MouseEvent, parent: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation(); event.stopPropagation();
const dialogRef = this.dialog.open(ManageOrganizationalUnitComponent, { const dialogRef = this.dialog.open(ManageOrganizationalUnitComponent, {
data: { parent }, data: { parent },
width: '900px', width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
}); });
dialogRef.afterClosed().subscribe((newUnit) => { dialogRef.afterClosed().subscribe((newUnit) => {
if (newUnit?.uuid) { if (newUnit) {
this.refreshData(newUnit.uuid); this.refreshData(newUnit.uuid);
} }
this.loading = false;
}); });
} }
addClient(event: MouseEvent, organizationalUnit: TreeNode | null = null): void { addClient(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation(); event.stopPropagation();
const targetNode = organizationalUnit || this.selectedNode; const targetNode = organizationalUnit || this.selectedNode;
const dialogRef = this.dialog.open(ManageClientComponent, { const dialogRef = this.dialog.open(ManageClientComponent, {
data: { organizationalUnit: targetNode }, data: { organizationalUnit: targetNode },
width: '900px', width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
}); });
dialogRef.afterClosed().subscribe((result) => { dialogRef.afterClosed().subscribe((result) => {
@ -444,17 +501,22 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.refreshData(parentNode.uuid); this.refreshData(parentNode.uuid);
} }
} }
this.loading = false;
}); });
} }
addMultipleClients(event: MouseEvent, organizationalUnit: TreeNode | null = null): void { addMultipleClients(event: MouseEvent, organizationalUnit: TreeNode | null = null): void {
this.loading = true;
event.stopPropagation(); event.stopPropagation();
const targetNode = organizationalUnit || this.selectedNode; const targetNode = organizationalUnit || this.selectedNode;
const dialogRef = this.dialog.open(CreateMultipleClientComponent, { const dialogRef = this.dialog.open(CreateMultipleClientComponent, {
data: { organizationalUnit: targetNode }, data: { organizationalUnit: targetNode },
width: '900px', width: '900px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
}); });
dialogRef.afterClosed().subscribe((result) => { dialogRef.afterClosed().subscribe((result) => {
if (result?.success) { if (result?.success) {
@ -469,29 +531,33 @@ export class GroupsComponent implements OnInit, OnDestroy {
console.error('No se encontró el nodo padre después de la creación masiva.'); console.error('No se encontró el nodo padre después de la creación masiva.');
} }
} }
this.loading = false;
}); });
} }
onEditNode(event: MouseEvent, node: TreeNode | null): void { onEditNode(event: MouseEvent, node: TreeNode | null): void {
event.stopPropagation(); event.stopPropagation();
this.loading = true;
const uuid = node ? this.extractUuid(node['@id']) : null; const uuid = node ? this.extractUuid(node['@id']) : null;
if (!uuid) return; if (!uuid) return;
const dialogRef = node?.type !== NodeType.Client const dialogRef = node?.type !== NodeType.Client
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px' }) ? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px' }); : this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
dialogRef.afterClosed().subscribe((result) => { dialogRef.afterClosed().subscribe((result) => {
if (result?.success) { if (result?.success) {
this.refreshData(node?.id); this.refreshData(node?.id);
} }
this.menuTriggers.forEach(trigger => trigger.closeMenu()); this.menuTriggers.forEach(trigger => trigger.closeMenu());
this.loading = false;
}); });
} }
onDeleteClick(event: MouseEvent, entity: TreeNode | Client | null): void { onDeleteClick(event: MouseEvent, entity: TreeNode | Client | null): void {
event.stopPropagation(); event.stopPropagation();
this.loading = true;
if (!entity) return; if (!entity) return;
const uuid = entity['@id'] ? this.extractUuid(entity['@id']) : null; const uuid = entity['@id'] ? this.extractUuid(entity['@id']) : null;
@ -508,6 +574,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
if (result === true) { if (result === true) {
this.deleteEntityorClient(uuid, type); this.deleteEntityorClient(uuid, type);
} }
this.loading = false;
}); });
} }
@ -545,16 +612,18 @@ export class GroupsComponent implements OnInit, OnDestroy {
onEditClick(event: MouseEvent, type: string, uuid: string): void { onEditClick(event: MouseEvent, type: string, uuid: string): void {
event.stopPropagation(); event.stopPropagation();
this.loading = true;
const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid); const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
const dialogRef = type !== NodeType.Client const dialogRef = type !== NodeType.Client
? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px' }) ? this.dialog.open(ManageOrganizationalUnitComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' })
: this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px' }); : this.dialog.open(ManageClientComponent, { data: { uuid }, width: '900px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
dialogRef.afterClosed().subscribe((result) => { dialogRef.afterClosed().subscribe((result) => {
if (result?.success) { if (result?.success) {
this.refreshData(this.selectedNode?.id, selectedClientsBeforeEdit); this.refreshData(this.selectedNode?.id, selectedClientsBeforeEdit);
} }
this.menuTriggers.forEach(trigger => trigger.closeMenu()); this.menuTriggers.forEach(trigger => trigger.closeMenu());
this.loading = false;
}); });
} }
@ -562,11 +631,14 @@ export class GroupsComponent implements OnInit, OnDestroy {
onRoomMap(room: TreeNode | null): void { onRoomMap(room: TreeNode | null): void {
if (!room || !room['@id']) return; if (!room || !room['@id']) return;
this.subscriptions.add( this.subscriptions.add(
this.http.get<{ clients: Client[] }>(`${this.baseUrl}${room['@id']}`).subscribe( this.http.get<{ clients: Client[] }>(`${this.baseUrl}/clients?organizationalUnit.id=${room.id}`).subscribe(
(response) => { (response: any) => {
this.dialog.open(ClassroomViewDialogComponent, { this.dialog.open(ClassroomViewDialogComponent, {
width: '90vw', width: '90vw',
data: { clients: response.clients }, data: { clients: response['hydra:member'] },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
}); });
}, },
(error) => { (error) => {
@ -578,31 +650,46 @@ export class GroupsComponent implements OnInit, OnDestroy {
executeCommand(command: Command, selectedNode: TreeNode | null): void { executeCommand(command: Command, selectedNode: TreeNode | null): void {
this.loading = true;
if (!selectedNode) { if (!selectedNode) {
this.toastr.error('No hay un nodo seleccionado.'); this.toastr.error('No hay un nodo seleccionado.');
return; return;
} else { } else {
this.toastr.success(`Ejecutando comando: ${command.name} en ${selectedNode.name}`); this.toastr.success(`Ejecutando comando: ${command.name} en ${selectedNode.name}`);
} }
this.loading = false;
} }
onShowClientDetail(event: MouseEvent, client: Client): void { onShowClientDetail(event: MouseEvent, client: Client): void {
event.stopPropagation(); event.stopPropagation();
this.router.navigate(['clients', client.uuid], { state: { clientData: client } }); this.loading = true;
const dialogRef = this.dialog.open(ClientDetailsComponent, {
width: '70vw',
height: '90vh',
data: { clientData: client },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
})
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
} }
onShowDetailsClick(event: MouseEvent, data: TreeNode | null): void { onShowDetailsClick(event: MouseEvent, data: TreeNode | null): void {
event.stopPropagation(); event.stopPropagation();
this.loading = true;
if (data && data.type !== NodeType.Client) { if (data && data.type !== NodeType.Client) {
this.dialog.open(ShowOrganizationalUnitComponent, { data: { data }, width: '800px' }); this.dialog.open(ShowOrganizationalUnitComponent, { data: { data }, width: '800px', disableClose: true, hasBackdrop: true, backdropClass: 'non-clickable-backdrop' });
} else { } else {
if (data) { if (data) {
this.router.navigate(['clients', this.extractUuid(data['@id'])], { state: { clientData: data } }); this.router.navigate(['clients', this.extractUuid(data['@id'])], { state: { clientData: data } });
} }
} }
this.loading = false;
} }
@ -611,9 +698,9 @@ export class GroupsComponent implements OnInit, OnDestroy {
} }
iniciarTour(): void { initTour(): void {
this.joyrideService.startTour({ this.joyrideService.startTour({
steps: ['groupsTitleStepText', 'filtersPanelStep', 'addStep', 'keyStep', 'tabsStep'], steps: ['groupsTitleStep', 'filtersPanelStep', 'treePanelStep', 'addStep', 'keyStep', 'executeCommandStep', 'tabsStep', 'clientsViewStep'],
showPrevButton: true, showPrevButton: true,
themeColor: '#3f51b5', themeColor: '#3f51b5',
}); });
@ -721,8 +808,8 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.syncingClientId = null; this.syncingClientId = null;
this.refreshData(parentNodeId) this.refreshData(parentNodeId)
}, },
() => { (error) => {
this.toastr.error('Error de conexión con el cliente'); this.toastr.error(error.error['hydra:description'] || 'Error al actualizar el cliente');
this.syncStatus = false; this.syncStatus = false;
this.syncingClientId = null; this.syncingClientId = null;
this.refreshData(parentNodeId) this.refreshData(parentNodeId)
@ -805,4 +892,339 @@ export class GroupsComponent implements OnInit, OnDestroy {
clientSearchStatusInput.value = null; clientSearchStatusInput.value = null;
this.fetchClientsForNode(this.selectedNode); this.fetchClientsForNode(this.selectedNode);
} }
getRunScriptContext(clientData: any[]): any {
const selectedClientNames = clientData.map(client => client.name);
if (clientData.length === 1) {
return clientData[0]; // devuelve el objeto cliente completo
} else if (
clientData.length === this.selectedClients.data.length &&
selectedClientNames.every(name => this.selectedClients.data.some(c => c.name === name))
) {
return this.selectedNode || null; // devuelve el nodo completo
} else if (clientData.length > 1) {
return clientData; // devuelve array de objetos cliente
} else if (this.selectedNode && clientData.length === 0) {
return this.selectedNode;
}
return null;
}
openPartitionTypeModal(event: MouseEvent, node: TreeNode | null = null): void {
event.stopPropagation();
const simplifiedClientsData = node?.clients?.map((client: any) => ({
name: client.name,
partitions: client.partitions
}));
this.dialog.open(PartitionTypeOrganizatorComponent, {
width: '1200px',
data: simplifiedClientsData
});
}
changeParent(event: MouseEvent, ): void {
event.stopPropagation();
const dialogRef = this.dialog.open(ChangeParentComponent, {
data: { clients: this.selection.selected },
width: '700px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.refreshData();
}
});
}
openClientTaskLogs(event: MouseEvent, client: Client): void {
this.loading = true;
event.stopPropagation();
const dialogRef = this.dialog.open(ClientTaskLogsComponent, {
width: '1200px',
data: { client },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
})
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
}
openClientPendingTasks(event: MouseEvent, client: Client): void {
event.stopPropagation();
const dialogRef = this.dialog.open(ClientPendingTasksComponent, {
width: '90vw',
height: '80vh',
data: {
client: client,
parentNode: this.selectedNode
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.refreshClientData();
}
});
}
openClientLogsInNewTab(event: MouseEvent, client: Client): void {
event.stopPropagation();
if (client.ip) {
const logsUrl = `${this.baseUrl}/pcclients/${client.ip}/cgi-bin/httpd-log.sh`;
const windowName = `logs_${client.ip.replace(/\./g, '_')}`;
const newWindow = window.open(logsUrl, windowName);
if (newWindow) {
newWindow.document.write(`
<title>Logs - ${client.ip}</title>
<iframe src="${logsUrl}" width="100%" height="100%" style="border:none;"></iframe>
`);
}
} else {
this.toastr.error('No se puede acceder a los logs: IP del cliente no disponible', 'Error');
}
}
openOUPendingTasks(event: MouseEvent, node: any): void {
event.stopPropagation();
this.loading = true;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=1&itemsPerPage=10000`).subscribe({
next: (response) => {
const allClients = response['hydra:member'] || [];
if (allClients.length === 0) {
this.toastr.warning('Esta unidad organizativa no tiene clientes');
return;
}
const ouClientData = {
name: node.name,
id: node.id,
uuid: node.uuid,
type: 'organizational-unit',
clients: allClients
};
const dialogRef = this.dialog.open(ClientPendingTasksComponent, {
width: '1200px',
data: { client: ouClientData, isOrganizationalUnit: true },
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop',
});
dialogRef.afterClosed().subscribe((result) => {
this.loading = false;
});
},
error: (error) => {
console.error('Error al obtener los clientes de la unidad organizativa:', error);
this.toastr.error('Error al cargar los clientes de la unidad organizativa');
this.loading = false;
}
});
}
// Métodos para paginación
getPaginationFrom(): number {
return (this.page * this.itemsPerPage) + 1;
}
getPaginationTo(): number {
return Math.min((this.page + 1) * this.itemsPerPage, this.length);
}
getPaginationTotal(): number {
return this.length;
}
refreshClientData(): void {
this.fetchClientsForNode(this.selectedNode);
this.toastr.success('Datos actualizados', 'Éxito');
}
exportToCSV(): void {
const headers = ['Nombre', 'IP', 'MAC', 'Estado', 'Firmware', 'Subnet', 'Parent'];
const csvData = this.arrayClients.map(client => [
client.name,
client.ip || '',
client.mac || '',
client.status || '',
client.firmwareType || '',
client.subnet || '',
client.parentName || ''
]);
const csvContent = [headers, ...csvData]
.map(row => row.map(cell => `"${cell}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `clients_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.toastr.success('Archivo CSV exportado correctamente', 'Éxito');
}
private calculateLocalStats(): void {
const clients = this.arrayClients;
this.totalStats = {
total: clients.length,
off: clients.filter(client => client.status === 'off').length,
online: clients.filter(client => ['og-live', 'linux', 'windows', 'linux-session', 'windows-session'].includes(client.status)).length,
busy: clients.filter(client => client.status === 'busy').length
};
// Actualizar tipos de firmware disponibles
this.firmwareTypes = [...new Set(clients.map(client => client.firmwareType).filter(Boolean))];
}
// Métodos para funcionalidades mejoradas
selectClient(client: any): void {
this.selectedClient = client;
}
sortColumn(columnDef: string): void {
if (this.currentSortColumn === columnDef) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.currentSortColumn = columnDef;
this.sortDirection = 'asc';
}
this.sortBy = columnDef;
this.onSortChange();
}
getSortIcon(columnDef: string): string {
if (this.currentSortColumn !== columnDef) {
return 'unfold_more';
}
return this.sortDirection === 'asc' ? 'expand_less' : 'expand_more';
}
onSortChange(): void {
// Hacer nueva llamada al backend con el ordenamiento actualizado
this.fetchClientsForNode(this.selectedNode);
}
getStatusCount(status: string): number {
switch(status) {
case 'off':
return this.totalStats.off;
case 'online':
return this.totalStats.online;
case 'busy':
return this.totalStats.busy;
default:
return this.arrayClients.filter(client => client.status === status).length;
}
}
// Métodos para el árbol mejorado
expandAll(): void {
this.treeControl.expandAll();
}
collapseAll(): void {
this.treeControl.collapseAll();
}
getNodeTypeTooltip(nodeType: string): string {
switch (nodeType) {
case 'organizational-unit':
return 'Unidad Organizacional - Estructura principal de la organización';
case 'classrooms-group':
return 'Grupo de Aulas - Conjunto de aulas relacionadas';
case 'classroom':
return 'Aula - Espacio físico con equipos informáticos';
case 'clients-group':
return 'Grupo de Equipos - Conjunto de equipos informáticos';
case 'client':
return 'Equipo Informático - Computadora o dispositivo individual';
case 'group':
return 'Grupo - Agrupación lógica de elementos';
default:
return 'Elemento del árbol organizacional';
}
}
getNodeCountLabel(count: number): string {
if (count === 1) return 'elemento';
return 'elementos';
}
getStatusLabel(status: string): string {
const statusLabels: { [key: string]: string } = {
'off': 'Apagado',
'og-live': 'OG Live',
'linux': 'Linux',
'linux-session': 'Linux Session',
'windows': 'Windows',
'windows-session': 'Windows Session',
'busy': 'Ocupado',
'mac': 'Mac',
'disconnected': 'Desconectado',
'initializing': 'Inicializando'
};
return statusLabels[status] || status;
}
// Funciones para el dashboard de estadísticas
getTotalOrganizationalUnits(): number {
let total = 0;
const countOrganizationalUnits = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.type === 'organizational-unit') {
total += 1;
}
if (node.children) {
countOrganizationalUnits(node.children);
}
});
};
countOrganizationalUnits(this.originalTreeData);
return total;
}
getTotalClassrooms(): number {
let total = 0;
const countClassrooms = (nodes: TreeNode[]) => {
nodes.forEach(node => {
if (node.type === 'classroom') {
total += 1;
}
if (node.children) {
countClassrooms(node.children);
}
});
};
countClassrooms(this.originalTreeData);
return total;
}
// Función para actualizar estadísticas cuando cambian los datos
private updateDashboardStats(): void {
// Las estadísticas de equipos ya se calculan en calculateLocalStats()
// Solo necesitamos asegurar que se actualicen cuando cambian los datos
this.calculateLocalStats();
}
} }

View File

@ -0,0 +1,39 @@
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100px;
}
mat-form-field {
width: 100%;
}
mat-dialog-actions {
display: flex;
justify-content: flex-end;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.selected-list ul {
list-style: none;
padding: 0;
}
.selected-item {
display: flex;
justify-content: space-between; /* Alinea texto a la izquierda y botón a la derecha */
align-items: center; /* Centra verticalmente */
padding: 8px;
border-bottom: 1px solid #ccc;
}
.selected-item button {
margin-left: 10px;
}

View File

@ -0,0 +1,15 @@
<h2 mat-dialog-title>Mover clientes a:</h2>
<mat-dialog-content>
<mat-form-field appearance="fill" class="full-width">
<mat-label>Seleccione aula</mat-label>
<mat-select [(ngModel)]="newOU">
<mat-option *ngFor="let unit of units" [value]="unit">{{ unit.name }}</mat-option>
</mat-select>
</mat-form-field>
</mat-dialog-content>
<div class="action-container">
<button class="ordinary-button" (click)="close()">Cancelar</button>
<button class="submit-button" (click)="save()" [disabled]="!newOU">Continuar</button>
</div>

View File

@ -0,0 +1,46 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeParentComponent } from './change-parent.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { ToastrService } from 'ngx-toastr';
import { ConfigService } from '@services/config.service';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { FormsModule } from '@angular/forms';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe('ChangeParentComponent', () => {
let component: ChangeParentComponent;
let fixture: ComponentFixture<ChangeParentComponent>;
beforeEach(async () => {
const mockConfigService = { apiUrl: 'http://mock-api-url' };
await TestBed.configureTestingModule({
declarations: [ChangeParentComponent],
imports: [
HttpClientTestingModule,
MatDialogModule,
MatFormFieldModule,
MatSelectModule,
FormsModule,
BrowserAnimationsModule
],
providers: [
{ provide: MatDialogRef, useValue: {} },
{ provide: MAT_DIALOG_DATA, useValue: { clients: [] } },
{ provide: ToastrService, useValue: { success: () => { }, error: () => { } } },
{ provide: ConfigService, useValue: mockConfigService }
]
})
.compileComponents();
fixture = TestBed.createComponent(ChangeParentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,63 @@
import {Component, Inject, OnInit} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ToastrService} from "ngx-toastr";
import {Router} from "@angular/router";
import {ConfigService} from "@services/config.service";
@Component({
selector: 'app-change-parent',
templateUrl: './change-parent.component.html',
styleUrl: './change-parent.component.css'
})
export class ChangeParentComponent implements OnInit {
baseUrl: string;
loading: boolean = true;
units: any[] = [];
newOU: any;
constructor(
private http: HttpClient,
public dialogRef: MatDialogRef<ChangeParentComponent>,
private toastService: ToastrService,
private router: Router,
private configService: ConfigService,
@Inject(MAT_DIALOG_DATA) public data: { clients: any}
) {
this.baseUrl = this.configService.apiUrl;
}
ngOnInit(): void {
this.loading = true;
this.loadUnits();
}
loadUnits() {
this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=500`).subscribe(
response => {
this.units = response['hydra:member'];
this.loading = false;
},
error => console.error('Error fetching organizational units:', error)
);
}
save() {
this.http.post<any>(`${this.baseUrl}/clients/change-organizational-unit`, {
clients: this.data.clients.map((client: any) => client['@id']),
organizationalUnit: this.newOU['@id']
}).subscribe({
next: (response) => {
this.toastService.success('Parent changed successfully');
this.dialogRef.close(true);
},
error: error => {
this.toastService.error(error.error['hydra:description']);
}
})
}
close() {
this.dialogRef.close();
}
}

View File

@ -21,21 +21,24 @@ mat-card {
} }
.client-image { .client-image {
width: 100%; display: flex;
justify-content: center;
width: 70%;
height: auto; height: auto;
margin: 0 auto;
} }
.proyector-image { .proyector-image {
width: auto; width: auto;
height: 100px; height: 80px;
} }
.client-info { .client-info {
display: grid;
text-align: center; text-align: center;
margin-top: 5px;
font-size: medium;
color: gray; color: gray;
align-items: center; align-items: center;
font-size: small;
} }
.client-name { .client-name {

View File

@ -12,7 +12,9 @@
<img mat-card-image src="assets/images/client.png" alt="{{ 'clientAlt' | translate }}" class="client-image"/> <img mat-card-image src="assets/images/client.png" alt="{{ 'clientAlt' | translate }}" class="client-image"/>
</div> </div>
<div class="client-info"> <div class="client-info">
<span>{{ client.name }}</span> <span><strong>{{ client.name }}</strong></span>
<span>{{ client.ip }}</span>
<span>{{ client.mac }}</span>
</div> </div>
</mat-card> </mat-card>
</div> </div>

View File

@ -72,7 +72,6 @@ export class ClassroomViewComponent implements OnInit, OnChanges {
} }
handleClientClick(client: any): void { handleClientClick(client: any): void {
console.log('Client clicked:', client);
this.dialog.open(ClientViewComponent, { data: { client }, width: '800px', height: '700px' }); this.dialog.open(ClientViewComponent, { data: { client }, width: '800px', height: '700px' });
} }

Some files were not shown because too many files have changed in this diff Show More