Compare commits

...

163 Commits

Author SHA1 Message Date
09052cbfc2 feat: multiarch build 2025-10-27 21:37:26 +01:00
2a7d170824 feat: live message viewer 2025-10-27 20:44:57 +01:00
cbe13f1d84 feat: add Docker build 2025-10-27 15:19:10 +01:00
65b491640b Merge pull request 'feat: events visualization' (#26) from feature/event-view into main
Reviewed-on: #26
2025-10-26 18:59:59 +01:00
f6dc4e8bf3 feat: events visualization 2025-10-26 18:58:53 +01:00
359d681c72 Merge pull request 'feat: update to new cluster protocol' (#25) from feature/cluster-protocol-update into main
Reviewed-on: #25
2025-10-26 12:51:48 +01:00
62badbc692 feat: update to new cluster protocol 2025-10-26 12:46:45 +01:00
698a150162 Merge pull request 'feat: live topology view through websocket updates' (#24) from feature/live-topology into main
Reviewed-on: #24
2025-10-26 11:57:04 +01:00
741cc48ce5 feat: toggle between mesh and star topology 2025-10-26 11:56:44 +01:00
74473cbc26 feat: node_discovered indicator in graph 2025-10-25 11:13:35 +02:00
b4bd459d27 feat: primary node switching in topology graph 2025-10-24 22:44:44 +02:00
fa6777a042 feat: live topology view through websocket updates 2025-10-24 21:54:00 +02:00
ce836e7636 Merge pull request 'feature/ui-harmonization' (#23) from feature/ui-harmonization into main
Reviewed-on: #23
2025-10-23 20:25:22 +02:00
21f1029805 feat: harmonize add-label 2025-10-23 20:24:00 +02:00
ad268a7c13 feat: harmonize labels 2025-10-23 13:41:14 +02:00
61f8c8aa2a feat: expand firmware sections on search 2025-10-23 11:48:43 +02:00
531ddbee85 feat: introduce routing system 2025-10-23 11:38:03 +02:00
c6949c36c1 feat: harmonize styling 2025-10-23 11:25:44 +02:00
cabc08de29 fix: error styling and buttons 2025-10-23 08:08:50 +02:00
4fa8d96656 Merge pull request 'feature/registry' (#22) from feature/registry into main
Reviewed-on: #22
2025-10-22 21:51:19 +02:00
cdb42c459a feat: rollout 2025-10-22 21:45:06 +02:00
7def7bce81 feat: firmware registry view 2025-10-21 20:17:18 +02:00
aa7467e1ca Merge pull request 'feature/gateway' (#21) from feature/gateway into main
Reviewed-on: #21
2025-10-21 13:50:55 +02:00
30d88d6884 feat: remove borders and shadows on the theme-switcher 2025-10-21 13:14:36 +02:00
6ed42f9c90 feat: update firmware upload status through websocket 2025-10-21 13:00:02 +02:00
85802c68db fix: configureNodeWiFi method to properly check the response structure 2025-10-19 22:42:39 +02:00
a7018f53f3 feat: externalize cluster integration and API 2025-10-19 21:52:55 +02:00
8de77e225d Merge pull request 'feature/improved-cluster-forming' (#20) from feature/improved-cluster-forming into main
Reviewed-on: #20
2025-10-19 17:46:54 +02:00
56e54e0b31 docs: update 2025-10-19 14:05:00 +02:00
d166b0b634 feat: new cluster forming protocoll 2025-10-19 12:51:31 +02:00
b6b55c0a6f Merge pull request 'feat: introduce global config dialog' (#19) from feature/global-config into main
Reviewed-on: #19
2025-10-18 13:12:14 +02:00
f73dd4d0e9 feat: introduce global config dialog 2025-10-18 10:42:58 +02:00
07be307035 fix: spinner animation 2025-10-16 22:19:24 +02:00
3b2b596014 feat: logging system and reduced logs 2025-10-16 22:08:56 +02:00
75bb974a27 Merge pull request 'feature/firmware-drawer' (#18) from feature/firmware-drawer into main
Reviewed-on: #18
2025-10-16 22:07:06 +02:00
79a28bae22 feat: introduce overlay dialog component 2025-10-16 22:00:19 +02:00
478d23b805 feat: firmware upload on the cluster view 2025-10-16 21:47:33 +02:00
f3a61131db Merge pull request 'feat: filter cluster members by multiple labels' (#17) from feature/multi-label-filter into main
Reviewed-on: #17
2025-10-16 20:47:24 +02:00
3314f7e10a feat: filter cluster members by multiple labels 2025-10-16 20:47:07 +02:00
e431d3b551 Merge pull request 'feat: label editor' (#16) from feature/labels-editor into main
Reviewed-on: #16
2025-10-16 19:07:56 +02:00
bf19071cc4 feat: label editor 2025-10-16 16:51:29 +02:00
55cc8c8d8c Merge pull request 'feature/filters' (#15) from feature/filters into main
Reviewed-on: #15
2025-10-14 23:08:00 +02:00
39eae6562c fix: cluster view header styling 2025-10-14 23:04:55 +02:00
2cc62d1ee2 feat: memberlist filter 2025-10-14 22:19:04 +02:00
e58604d726 Merge pull request 'feat: live updates' (#14) from feature/live-ui into main
Reviewed-on: #14
2025-10-14 22:06:43 +02:00
25911a183c feat: live updates 2025-10-14 21:41:15 +02:00
6db56e470c fix: monitoring node-cards grid 2025-10-14 18:17:08 +02:00
f77973a876 feeat: improve icons, update screenshots 2025-10-14 10:38:47 +02:00
fa6d72ea62 feat: replace all emojis with SVG icons 2025-10-14 10:17:38 +02:00
55bc38577c feat: improve terminal and monitoring styling 2025-10-14 10:00:55 +02:00
fcff402c75 fix: don't log raw messages 2025-10-03 21:21:58 +02:00
489fdafa1c feat(terminal): styling improvements and shortcut 2025-10-02 20:26:34 +02:00
7cee2ff94f feat: change drawer behavior 2025-09-30 22:26:33 +02:00
675d51bc66 Merge pull request 'feature/terminaling' (#13) from feature/terminaling into main
Reviewed-on: #13
2025-09-30 22:16:41 +02:00
96e0641819 feat: minimize terminal 2025-09-30 22:16:10 +02:00
a26ef3949a feat: add member terminal trigger and align terminal panel bottom-center 2025-09-30 21:32:30 +02:00
75dc122898 fix(terminal): stop escaping JSON in terminal; pretty-print JSON messages
Decode HTML entities for incoming text and pretty-print valid JSON payloads before appending to the log.
2025-09-30 20:38:14 +02:00
9be4af1c09 Merge pull request 'docs(readme): document new Terminal panel and usage' (#12) from feature/terminal into main
Reviewed-on: #12
2025-09-29 22:49:00 +02:00
c238105ce7 Merge branch 'main' into feature/terminal 2025-09-29 22:48:54 +02:00
d56e7f3ab6 docs(readme): document new Terminal panel and usage
Add feature bullet and section detailing bottom-up terminal panel, /ws WebSocket connection, usage, and requirements.
2025-09-29 22:48:16 +02:00
8b17e18d52 Merge pull request 'feat(terminal): add Terminal panel to Node Details with WebSocket' (#11) from feature/terminal into main
Reviewed-on: #11
2025-09-29 22:40:33 +02:00
620792178e docs: update screenshot 2025-09-29 22:40:05 +02:00
9e3ab73a73 feat(terminal): add Terminal panel to Node Details with WebSocket
- Add 'Terminal' button to drawer header

- Implement TerminalPanel (bottom-up fade-in, ~1/3 viewport, left of drawer)

- Connect to ws(s)://{nodeIp}/ws; display incoming messages; input sends raw

- Wire Drawer to pass node IP and close terminal on drawer close

- Add styles/z-index and include script in index.html
2025-09-29 21:44:16 +02:00
602a3d6215 feat: boolean param in dynamic form 2025-09-28 13:41:44 +02:00
85505586ac feat: always sort nodes by hostname in monitoring view 2025-09-20 22:41:40 +02:00
9d4b68e7fc feat: add labels to nodes in monitoring 2025-09-20 22:13:39 +02:00
491ddb86b8 fix:_missing error arrow on from dropdowns 2025-09-20 15:09:13 +02:00
d2438eab82 chore: remove outdated project structure 2025-09-20 13:49:41 +02:00
5203b480b1 fix: outdated info in readme 2025-09-20 13:40:55 +02:00
66b5537330 Merge pull request 'feature/monitoring-overview' (#10) from feature/monitoring-overview into main
Reviewed-on: #10
2025-09-20 13:39:33 +02:00
cde3861c84 feat: update mock 2025-09-20 13:38:51 +02:00
22adf7d65f feat: improve styling, add more infos to node cards 2025-09-20 13:28:10 +02:00
e4cfb77a67 feat: health bar coloring 2025-09-20 13:04:27 +02:00
b4aeb9d388 feat: improve styling 2025-09-20 12:58:33 +02:00
c13d544e54 feat: monitoring view 2025-09-20 12:49:44 +02:00
e0e86f88a9 docs: update readme 2025-09-20 12:22:17 +02:00
5d350a3fcf Merge pull request 'fix: mobile layout issues' (#9) from fix/mobile-ui into main
Reviewed-on: #9
2025-09-20 12:19:53 +02:00
4538853ec7 fix: mobile layout issues 2025-09-20 12:19:21 +02:00
03e4c50766 Merge pull request 'feat: number range slider' (#8) from feature/numberrange into main
Reviewed-on: #8
2025-09-19 21:59:54 +02:00
da80228eb4 feat: number range slider 2025-09-19 21:58:05 +02:00
5cd187e674 Merge pull request 'feat: color picker' (#7) from feature/color-picker into main
Reviewed-on: #7
2025-09-19 21:32:28 +02:00
eb50048016 feat: color picker 2025-09-19 21:22:55 +02:00
3e1c6eaef0 Merge pull request 'feat: remove state preservation' (#6) from feature/remove-state-preservation-wuerg into main
Reviewed-on: #6
2025-09-19 21:18:12 +02:00
0aca182de9 feat: remove state preservation 2025-09-19 21:15:17 +02:00
262b03413a Merge pull request 'feature/desktop-view' (#5) from feature/desktop-view into main
Reviewed-on: #5
2025-09-19 21:06:22 +02:00
1062691e7b feat: add mock mode 2025-09-17 22:22:11 +02:00
bfe973afe6 feat: update screenshots 2025-09-17 21:40:37 +02:00
fd1c8e5a8c feat: add node resource infos 2025-09-16 20:46:17 +02:00
13f771837b feat: highlight selected node 2025-09-16 14:44:55 +02:00
27f93959ff feat: DrawerComonent 2025-09-16 14:35:09 +02:00
f9dc811239 feat: add IP to title 2025-09-16 14:30:37 +02:00
d870219136 feat: fresh tabs 2025-09-15 22:35:20 +02:00
52436f8b93 feat: improve topology view 2025-09-15 22:23:04 +02:00
41be660d94 feat: add labels 2025-09-15 21:36:23 +02:00
2dbba87098 fix: firmware upload failure 2025-09-15 21:12:00 +02:00
d01f094edd feat: increase drawer width 2025-09-15 21:02:27 +02:00
6f1e194545 feat: add initial desktop view implementation 2025-09-15 20:57:30 +02:00
8476c76637 Merge pull request 'feature/light-theme' (#4) from feature/light-theme into main
Reviewed-on: #4
2025-09-13 19:30:01 +02:00
d0557a56a2 refactor: remove capabilities in favor of endpoints 2025-09-13 19:15:38 +02:00
3055c2cb0e fix: graph styling 2025-09-13 13:50:46 +02:00
0b341ad6dd fix: capabilities endpoint 2025-09-13 13:46:30 +02:00
103aeabea7 Fix mobile navigation light theme
- Replace hardcoded dark colors in main.css with CSS variables
- Add comprehensive light theme overrides for mobile navigation
- Implement cool glass-morphism styling for mobile expanded state
- Ensure proper contrast and readability on all screen sizes
- Add cache-busting parameter to theme.css for browser refresh

Mobile navigation now properly displays light theme when expanded.
2025-09-06 14:02:48 +02:00
2cf3fb2852 feat: remove theme switcher text 2025-09-04 21:19:37 +02:00
7898a1d461 Implement cool & minimal light theme
- Replace warm & cozy theme with cool & minimal aesthetic
- Use clean slate grays (#f8fafc, #f1f5f9, #e2e8f0) for backgrounds
- Apply sophisticated blue accents (#3b82f6, #1d4ed8) for interactive elements
- Maintain high contrast text colors for excellent readability
- Preserve glass-morphism effects with cool-tinted shadows
- Create professional, corporate-friendly color scheme
- Ensure minimal visual noise with subtle borders and clean typography
2025-09-04 21:15:44 +02:00
2cf50486a7 refactor: Simplify navigation and background gradient
🎨 Background Gradient:
- Reduced from 5 to 3 Nord Frost colors
- Cleaner, more minimal gradient: #8fbcbb → #88c0d0 → #81a1c1
- Less visual noise in background

🧭 Navigation Improvements:
- Only active tab has background and border
- Inactive tabs are completely transparent
- Subtle hover effects for better UX
- Cleaner visual hierarchy

 Benefits:
- More minimal and professional appearance
- Better focus on active content
- Reduced visual complexity
- Consistent with modern design principles

Files modified:
- public/styles/theme.css - Simplified gradient and navigation styling
2025-09-04 21:08:24 +02:00
4498da72fa feat: Implement Nord Frost glass-morphism theme
🎨 Color Palette:
- Nord Frost gradient: #8fbcbb → #88c0d0 → #81a1c1 → #5e81ac
- Cool, sophisticated color scheme
- Nord text colors for optimal readability

 Glass-Morphism Effects:
- Maintained beautiful transparency effects
- Enhanced backdrop blur (20-25px)
- Nord-tinted shadows throughout
- Layered depth with opacity variations

🔧 UI Improvements:
- Removed member card hover effects for cleaner look
- Only active navigation tabs have borders
- Complete member overlay styling
- Consistent glass effects across all components

🎯 Visual Benefits:
- Cool, minimal aesthetic
- Professional appearance
- Easy on the eyes
- Modern glass-morphism design

Files modified:
- public/styles/theme.css - Complete Nord Frost theme implementation
2025-09-04 21:02:44 +02:00
1564816dc6 chore: Remove temporary test file 2025-09-04 20:48:32 +02:00
4b1011ce5e feat: Add comprehensive light theme and theme switcher
 Features:
- Complete light theme with improved color palette
- Theme switcher in header with sun/moon icons
- Theme persistence using localStorage
- Smooth transitions between themes

🎨 Theme Improvements:
- Softer, easier-on-eyes light theme colors
- Fixed text contrast issues across all components
- Enhanced member card and tab text readability
- Fixed hover effects that made text disappear
- Improved member overlay header and body styling

🔧 Technical Implementation:
- CSS variables system for easy theme management
- JavaScript ThemeManager class for theme switching
- Responsive design for mobile devices
- Comprehensive hover state fixes
- Z-index solutions for text visibility

📱 Components Fixed:
- Member cards (hostname, IP, latency, details)
- Navigation tabs and content
- Task summaries and progress items
- Capability forms and API endpoints
- Member overlay popup
- Form inputs and dropdowns
- Status indicators and label chips

🎯 Accessibility:
- WCAG AA contrast compliance
- Proper color hierarchy
- Clear visual feedback
- Mobile-responsive design

Files added:
- public/styles/theme.css - Theme system and variables
- public/scripts/theme-manager.js - Theme management logic
- THEME_README.md - Comprehensive documentation
- THEME_IMPROVEMENTS.md - Improvement details

Files modified:
- public/index.html - Added theme switcher and script includes
- public/styles/main.css - Updated to use CSS variables
2025-09-04 20:48:29 +02:00
6ff9f8dce9 feat: default values in dynamic form 2025-09-02 14:12:24 +02:00
29855a45a1 Merge pull request 'feature/refactoring' (#3) from feature/refactoring into main
Reviewed-on: #3
2025-09-02 13:26:12 +02:00
9986b4acac UI: Improve cluster view error styling
Add scoped styles for #cluster-members-container .error: better contrast, spacing, icon; override global centering for left-aligned layout.
2025-09-02 13:25:08 +02:00
d49a586eb0 chore: remove obsolete components file 2025-08-31 18:20:45 +02:00
ab20128008 UI (mobile): prevent firmware view flicker on touch by disabling hover effects and tap highlight 2025-08-31 17:51:04 +02:00
a4736948f5 UI (mobile): fix member tile flicker on touch; compact primary node header; unify to single page scroll on mobile 2025-08-31 17:46:48 +02:00
ef40bf1ee2 fix: flicker on mobile member card 2025-08-31 17:23:24 +02:00
2f271f4b29 style(mobile): reduce horizontal padding and tighten spacing to maximize usable width on small screens 2025-08-31 16:42:29 +02:00
ac6c2fbb80 perf(startup): remove blocking components loader wait; defer component instantiation until navigation; trigger initial cluster load immediately 2025-08-31 14:27:33 +02:00
8b0267ea2a fix(components): correct JS operators in FirmwareComponent; reorder script tags to ensure FirmwareComponent loads before FirmwareViewComponent 2025-08-31 14:14:11 +02:00
cc7fa0fa00 refactor(components): split components.js into separate files and add loader; app waits for components before init 2025-08-31 14:00:33 +02:00
83d252f3cc chore: remove obsolete docs 2025-08-31 12:20:58 +02:00
948a8a1fab refactor(logging): replace remaining console.* with logger.* in components.js 2025-08-31 12:20:11 +02:00
9dab498aa2 refactor(logging): replace remaining console.* with logger.debug/error across app, view-models, api-client, and framework 2025-08-31 12:18:15 +02:00
4ee209ef78 refactor(logging): downgrade noisy component console.log to logger.debug across ClusterMembers, NodeDetails, Firmware, and Topology components 2025-08-31 12:06:06 +02:00
ab03cd772d refactor(logging): downgrade info logs to logger.debug in ViewModel, Component lifecycle, and App navigation 2025-08-31 11:58:20 +02:00
1bdaed9a2c refactor(rendering): restore NodeDetails active tab; keyed partial updates by IP; add escapeHtml in base Component and use in members; simplify ApiClient methods by removing redundant try/catch 2025-08-31 11:24:39 +02:00
b757cb68da refactor(constants): introduce constants.js and wire timing/selector constants into framework transitions and navigation 2025-08-31 11:22:22 +02:00
f18907d9e4 refactor(tabs): centralize tab wiring in base Component.setupTabs with onChange hook; persist and restore NodeDetails active tab; reuse base tabs in ClusterMembersComponent 2025-08-31 11:06:39 +02:00
c0aef5b8d5 refactor(app): mount ClusterStatusComponent and remove duplicate cluster status logic from app.js 2025-08-31 11:04:47 +02:00
d91eb4d5b6 chore: format index.html 2025-08-31 11:02:19 +02:00
17d68c45e1 chore: restructure public files 2025-08-31 09:38:24 +02:00
fc9e415860 fix: font and size of stuff in member card header 2025-08-30 21:34:09 +02:00
91ff24c162 feat: optimize topology member overlay 2025-08-30 21:28:36 +02:00
bebf979860 feat: compact member cards 2025-08-30 21:23:29 +02:00
389bb733c0 fix: scrolling issue 2025-08-30 20:51:51 +02:00
583a72f3f6 feat: nice task info 2025-08-30 20:47:55 +02:00
c05a2b6c30 fix: topology container height 2025-08-30 20:41:00 +02:00
9fba644a18 fix: improve mobile layout 2025-08-30 16:22:06 +02:00
aaf19b74ae feat: update screenshots 2025-08-30 16:02:51 +02:00
dc46fc6ca2 feat: improve styling 2025-08-30 15:57:25 +02:00
7bac42c58e feat: add member details overlay to topology 2025-08-30 15:18:50 +02:00
f28b4f8797 feat: add topology view 2025-08-30 13:26:46 +02:00
c1b92b3fef fix: mobile burger menu 2025-08-29 19:48:36 +02:00
ae061bbbc9 fix: primary node failover 2025-08-29 19:06:12 +02:00
d7c70cf636 fix: firmware page styling 2025-08-29 16:08:54 +02:00
63fa57e666 feat: add labels 2025-08-29 13:25:31 +02:00
d2ba3ed7d2 feat: cool tabs 2025-08-29 11:15:33 +02:00
e5c4a7cedc feat: improve hover styles 2025-08-28 21:36:28 +02:00
4ded40b85c feat: improve capability dropdown 2025-08-28 21:14:22 +02:00
c15654ef5a feat: frontend optimization, refactoring 2025-08-28 20:46:53 +02:00
9486594199 feat: capability selection 2025-08-28 13:06:17 +02:00
de131f955a docs: update screenshots 2025-08-28 12:48:37 +02:00
bb46e5d412 feature/capabilities (#2)
Reviewed-on: #2
2025-08-28 11:17:37 +02:00
6c58e479af feature/framework (#1)
Reviewed-on: #1
2025-08-28 10:21:14 +02:00
e23b40e0cb feat: improve firmware upload section 2025-08-26 17:34:05 +02:00
0e7f241c90 docs: add screenshots 2025-08-26 16:00:39 +02:00
27b07d2aba feat: harmonize styling 2025-08-26 13:40:54 +02:00
b23025a6f8 feat: make upload stuff more compact 2025-08-26 13:19:46 +02:00
423d052ccf feat: remove redundant upload results 2025-08-26 12:56:21 +02:00
e0dedc1c23 fix: upload progress bars 2025-08-26 12:50:44 +02:00
d712206377 feat: implement firmware page functionality to update specific or all nodes 2025-08-26 12:30:23 +02:00
ff43eddd27 fix: OTA 2025-08-26 12:20:17 +02:00
be3cd771fc docs: update readme 2025-08-25 12:15:55 +02:00
5977a37d6c feat: auto-discovery 2025-08-25 12:05:51 +02:00
69 changed files with 30989 additions and 2607 deletions

View File

@@ -0,0 +1,56 @@
---
description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality.
globs:
alwaysApply: true
---
# Clean Code Guidelines
## Constants Over Magic Numbers
- Replace hard-coded values with named constants
- Use descriptive constant names that explain the value's purpose
- Keep constants at the top of the file or in a dedicated constants file
## Meaningful Names
- Variables, functions, and classes should reveal their purpose
- Names should explain why something exists and how it's used
- Avoid abbreviations unless they're universally understood
## Smart Comments
- Don't comment on what the code does - make the code self-documenting
- Use comments to explain why something is done a certain way
- Document APIs, complex algorithms, and non-obvious side effects
## Single Responsibility
- Each function should do exactly one thing
- Functions should be small and focused
- If a function needs a comment to explain what it does, it should be split
## DRY (Don't Repeat Yourself)
- Extract repeated code into reusable functions
- Share common logic through proper abstraction
- Maintain single sources of truth
## Clean Structure
- Keep related code together
- Organize code in a logical hierarchy
- Use consistent file and folder naming conventions
## Encapsulation
- Hide implementation details
- Expose clear interfaces
- Move nested conditionals into well-named functions
## Code Quality Maintenance
- Refactor continuously
- Fix technical debt early
- Leave code cleaner than you found it
## Testing
- Write tests before fixing bugs
- Keep tests readable and maintainable
- Test edge cases and error conditions
## Version Control
- Write clear commit messages
- Make small, focused commits
- Use meaningful branch names

111
.cursor/rules/gitflow.mdc Normal file
View File

@@ -0,0 +1,111 @@
---
description: Gitflow Workflow Rules. These rules should be applied when performing git operations.
---
# Gitflow Workflow Rules
## Main Branches
### main (or master)
- Contains production-ready code
- Never commit directly to main
- Only accepts merges from:
- hotfix/* branches
- release/* branches
- Must be tagged with version number after each merge
### develop
- Main development branch
- Contains latest delivered development changes
- Source branch for feature branches
- Never commit directly to develop
## Supporting Branches
### feature/*
- Branch from: develop
- Merge back into: develop
- Naming convention: feature/[issue-id]-descriptive-name
- Example: feature/123-user-authentication
- Must be up-to-date with develop before creating PR
- Delete after merge
### release/*
- Branch from: develop
- Merge back into:
- main
- develop
- Naming convention: release/vX.Y.Z
- Example: release/v1.2.0
- Only bug fixes, documentation, and release-oriented tasks
- No new features
- Delete after merge
### hotfix/*
- Branch from: main
- Merge back into:
- main
- develop
- Naming convention: hotfix/vX.Y.Z
- Example: hotfix/v1.2.1
- Only for urgent production fixes
- Delete after merge
## Commit Messages
- Format: `type(scope): description`
- Types:
- feat: New feature
- fix: Bug fix
- docs: Documentation changes
- style: Formatting, missing semicolons, etc.
- refactor: Code refactoring
- test: Adding tests
- chore: Maintenance tasks
## Version Control
### Semantic Versioning
- MAJOR version for incompatible API changes
- MINOR version for backwards-compatible functionality
- PATCH version for backwards-compatible bug fixes
## Pull Request Rules
1. All changes must go through Pull Requests
2. Required approvals: minimum 1
3. CI checks must pass
4. No direct commits to protected branches (main, develop)
5. Branch must be up to date before merging
6. Delete branch after merge
## Branch Protection Rules
### main & develop
- Require pull request reviews
- Require status checks to pass
- Require branches to be up to date
- Include administrators in restrictions
- No force pushes
- No deletions
## Release Process
1. Create release branch from develop
2. Bump version numbers
3. Fix any release-specific issues
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete release branch
## Hotfix Process
1. Create hotfix branch from main
2. Fix the issue
3. Bump patch version
4. Create PR to main
5. After merge to main:
- Tag release
- Merge back to develop
- Delete hotfix branch

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.gitignore
.cursor
*.md
node_modules
README.md
docs
test
openapitools.json
*.backup

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Runtime stage
FROM node:20-alpine
# Install wget for health checks
RUN apk --no-cache add wget
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY index.js index-standalone.js ./
COPY public ./public
COPY src ./src
# Create non-root user (let Alpine assign available GID/UID)
RUN addgroup spore && \
adduser -D -s /bin/sh -G spore spore && \
chown -R spore:spore /app
USER spore
# Expose port
EXPOSE 3000
# Run the application
CMD ["node", "index.js"]

54
Makefile Normal file
View File

@@ -0,0 +1,54 @@
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
# Install dependencies
install:
npm install
# Build the application (if needed)
build: install
# Run the application
run:
node index.js
# Start in development mode
dev:
node index.js
# Clean build artifacts
clean:
rm -rf node_modules
rm -f package-lock.json
# Docker variables
DOCKER_REGISTRY ?=
IMAGE_NAME = wirelos/spore-ui
IMAGE_TAG ?= latest
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
# Build Docker image
docker-build:
docker build -t $(FULL_IMAGE_NAME) .
# Run Docker container
docker-run:
docker run -p 3000:3000 --rm $(FULL_IMAGE_NAME)
# Push Docker image
docker-push:
docker push $(FULL_IMAGE_NAME)
# Build multiarch Docker image
docker-build-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.
# Push multiarch Docker image (if not pushed during build)
docker-push-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.

172
README.md
View File

@@ -1,103 +1,117 @@
# SPORE UI
# SPORE UI Frontend
A modern web interface for monitoring and managing SPORE embedded systems.
Frontend web interface for monitoring and managing SPORE embedded systems. Now works in conjunction with the SPORE Gateway backend service.
## Architecture
This frontend server works together with the **SPORE Gateway** (spore-gateway) backend service:
- **spore-ui**: Serves the static frontend files and provides the user interface
- **spore-gateway**: Handles UDP node discovery, API endpoints, and WebSocket connections
## Features
- **🌐 Cluster Monitoring**: Real-time view of all cluster members
- **📊 Node Details**: Expandable cards with detailed system information
- **🚀 Modern UI**: Beautiful glassmorphism design with smooth animations
- **🌐 Cluster Monitoring**: Real-time view of all cluster members via spore-gateway
- **📊 Node Details**: Detailed system information including running tasks and available endpoints
- **🚀 OTA**: Clusterwide over-the-air firmware updates
- **📱 Responsive**: Works on all devices and screen sizes
- **🖥️ Terminal**: Terminal for interacting with a node's WebSocket
- **🔗 Gateway Integration**: Seamlessly connects to spore-gateway for all backend functionality
![UI](./assets/ui.png)
## Project Structure
```
spore-ui/
├── index.js # Express.js backend server
├── api/
│ └── openapi.yaml # API specification
├── src/
│ └── client/ # SPORE API client library
│ ├── index.js # Main client class
│ ├── package.json # Client package info
│ ├── README.md # Client documentation
│ └── example.js # Usage examples
├── public/ # Frontend files
│ ├── index.html # Main HTML page
│ ├── styles.css # All CSS styles
│ ├── script.js # All JavaScript functionality
│ └── README.md # Frontend documentation
└── README.md # This file
```
## Screenshots
### Cluster
![UI](./assets/cluster.png)
### Topology
![UI](./assets/topology.png)
### Events
![UI](./assets/events.png)
![UI](./assets/events-messages.png)
### Monitoring
![UI](./assets/monitoring.png)
### Firmware
![UI](./assets/firmware.png)
## Getting Started
### Prerequisites
1. **Install dependencies**: `npm install`
2. **Start the server**: `npm start`
3. **Open in browser**: `http://localhost:3001`
2. **Start spore-gateway**: `./spore-gateway` (in the spore-gateway directory)
3. **Start frontend server**: `npm start`
## API Endpoints
### Access
- **Frontend UI**: `http://localhost:3000`
- **API Backend**: spore-gateway runs on port 3001
- **WebSocket**: Connects to spore-gateway on port 3001
- **`/`** - Main UI page
- **`/api/cluster/members`** - Get cluster member information
- **`/api/tasks/status`** - Get task status
- **`/api/node/status`** - Get system status
- **`/api/node/status/:ip`** - Get status from specific node
## API Integration
The frontend automatically connects to the spore-gateway for:
- **Cluster Discovery**: `/api/discovery/*` endpoints
- **Node Management**: `/api/node/*` endpoints
- **Task Monitoring**: `/api/tasks/*` endpoints
- **Real-time Updates**: WebSocket connections via `/ws`
## Technologies Used
- **Backend**: Express.js, Node.js
- **Backend Integration**: Express.js server connecting to spore-gateway
- **Frontend**: Vanilla JavaScript, CSS3, HTML5
- **API**: SPORE Embedded System API
- **Framework**: Custom component-based architecture
- **API**: SPORE Embedded System API via spore-gateway
- **Design**: Glassmorphism, CSS Grid, Flexbox
## TODO
## Development
## UDP Auto Discovery
The backend now includes automatic UDP discovery for SPORE nodes on the network. This eliminates the need for hardcoded IP addresses.
### How It Works
1. **UDP Server**: The backend listens on port 4210 for UDP messages
2. **Discovery Message**: Nodes send `CLUSTER_DISCOVERY` messages to broadcast address `255.255.255.255:4210`
3. **Auto Configuration**: When a discovery message is received, the source IP is automatically used to configure the SporeApiClient
4. **Dynamic Updates**: The system automatically switches to the most recently seen node as the primary connection
### Discovery Endpoints
- `GET /api/discovery/nodes` - View all discovered nodes and current status
- `POST /api/discovery/refresh` - Manually trigger discovery refresh
- `POST /api/discovery/primary/:ip` - Manually set a specific node as primary
- `GET /api/health` - Health check including discovery status
### Testing Discovery
Use the included test script to send discovery messages:
```bash
# Send to broadcast address
npm run test-discovery broadcast
# Send to specific IP
npm run test-discovery 192.168.1.100
# Send multiple messages
npm run test-discovery broadcast 5
### File Structure
```
spore-ui/
├── public/ # Static frontend files
│ ├── index.html # Main HTML page
│ ├── scripts/ # JavaScript components
│ └── styles/ # CSS stylesheets
├── index.js # Simple static file server
└── package.json # Node.js dependencies
```
### Node Behavior
### Key Changes
- **Simplified Backend**: Now only serves static files
- **Gateway Integration**: All API calls go through spore-gateway
- **WebSocket Proxy**: Real-time updates via spore-gateway
- **UDP Discovery**: Handled by spore-gateway service
- Nodes should send `CLUSTER_DISCOVERY` messages periodically (recommended: every 30-60 seconds)
- The backend automatically cleans up stale nodes (not seen for 5+ minutes)
- The most recently seen node becomes the primary connection
- All API endpoints automatically use the discovered node IPs
## Troubleshooting
### Configuration
### Common Issues
- UDP Discovery Port: 4210 (configurable via `UDP_PORT` constant)
- Discovery Message: `CLUSTER_DISCOVERY` (configurable via `DISCOVERY_MESSAGE` constant)
- Stale Node Timeout: 5 minutes (configurable in `cleanupStaleNodes()` function)
- Health Check Interval: 5 seconds (configurable in `setInterval`)
**Frontend not connecting to gateway**
```bash
# Check if spore-gateway is running
curl http://localhost:3001/api/health
# Verify gateway health
# Should return gateway health status
```
**WebSocket connection issues**
```bash
# Check WebSocket endpoint
curl http://localhost:3001/api/test/websocket
# Verify gateway WebSocket server is running
```
**No cluster data**
```bash
# Check gateway discovery status
curl http://localhost:3001/api/discovery/nodes
# Verify SPORE nodes are sending heartbeat messages
```
## Architecture Benefits
1. **Separation of Concerns**: Frontend handles UI, gateway handles backend logic
2. **Scalability**: Gateway can handle multiple frontend instances
3. **Maintainability**: Clear separation between presentation and business logic
4. **Performance**: Gateway can optimize API calls and caching
5. **Reliability**: Gateway provides failover and health monitoring

BIN
assets/cluster.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

BIN
assets/events-messages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

BIN
assets/events.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

BIN
assets/firmware.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

BIN
assets/monitoring.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

BIN
assets/topology.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

View File

@@ -8,12 +8,12 @@ The backend has been successfully updated to implement UDP auto discovery, elimi
### 1. UDP Discovery Server
- **Port**: 4210 (configurable via `UDP_PORT` constant)
- **Message**: `CLUSTER_DISCOVERY` (configurable via `DISCOVERY_MESSAGE` constant)
- **Message**: `CLUSTER_HEARTBEAT` (configurable via `HEARTBEAT_MESSAGE` constant)
- **Protocol**: UDP broadcast listening
- **Auto-binding**: Automatically binds to the specified port on startup
### 2. Dynamic Node Management
- **Automatic Discovery**: Nodes are discovered when they send `CLUSTER_DISCOVERY` messages
- **Automatic Discovery**: Nodes are discovered when they send `CLUSTER_HEARTBEAT` messages
- **Primary Node Selection**: The most recently seen node becomes the primary connection
- **Stale Node Cleanup**: Nodes not seen for 5+ minutes are automatically removed
- **Health Monitoring**: Continuous monitoring of node availability
@@ -45,14 +45,14 @@ The backend has been successfully updated to implement UDP auto discovery, elimi
```
1. Backend starts and binds UDP server to port 4210
2. HTTP server starts on port 3001
3. System waits for CLUSTER_DISCOVERY messages
3. System waits for CLUSTER_HEARTBEAT messages
4. When messages arrive, nodes are automatically discovered
5. SporeApiClient is configured with the first discovered node
```
### 2. Discovery Process
```
1. Node sends "CLUSTER_DISCOVERY" to 255.255.255.255:4210
1. Node sends "CLUSTER_HEARTBEAT:hostname" to 255.255.255.255:4210
2. Backend receives message and extracts source IP
3. Node is added to discovered nodes list
4. If no primary node exists, this becomes the primary
@@ -71,11 +71,11 @@ The backend has been successfully updated to implement UDP auto discovery, elimi
### Environment Variables
- `PORT`: HTTP server port (default: 3001)
- `UDP_PORT`: UDP discovery port (default: 4210)
- `UDP_PORT`: UDP heartbeat port (default: 4210)
### Constants (in index.js)
- `UDP_PORT`: Discovery port (currently 4210)
- `DISCOVERY_MESSAGE`: Expected message (currently "CLUSTER_DISCOVERY")
- `UDP_PORT`: Heartbeat port (currently 4210)
- `HEARTBEAT_MESSAGE`: Expected message (currently "CLUSTER_HEARTBEAT")
- Stale timeout: 5 minutes (configurable in `cleanupStaleNodes()`)
- Health check interval: 5 seconds (configurable in `setInterval`)

518
docs/Events.md Normal file
View File

@@ -0,0 +1,518 @@
# Events Feature
## Overview
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
![Events View](./assets/events.png)
## Features
### Real-Time Event Visualization
- **Interactive Graph**: Events are visualized as a force-directed graph using D3.js
- **Topic Chain Visualization**: Multi-part topics (e.g., `cluster/event/api/neopattern`) are broken down into chains showing hierarchical relationships
- **Event Counting**: Each event type is tracked with occurrence counts displayed on connections
- **Animated Transitions**: New events trigger visual animations showing data flow through the graph
### User Interactions
#### Drag to Reposition Nodes
- **Center Node**: Dragging the center (blue) node moves the entire graph together
- **Topic Nodes**: Individual topic nodes (green) can be repositioned independently
- **Fixed Positioning**: Once dragged, nodes maintain their positions to prevent layout disruption
#### Zoom Controls
- **Mouse Wheel**: Zoom in and out using the mouse wheel
- **Scale Range**: Zoom level constrained between 0.5x and 5x
- **Pan**: Click and drag the canvas to pan around the graph
#### Rearrange Layout
- **Rearrange Button**: Click the button in the top-left corner to reset node positions
- **Automatic Layout**: Clears fixed positions and lets the force simulation reposition nodes
### Visual Elements
#### Node Types
- **Center Node** (Blue):
- Represents the central event hub
- Always visible in the graph
- Acts as the root node for all event chains
- Larger size (18px radius)
- **Topic Nodes** (Green):
- Represent individual topic segments
- Examples: `cluster`, `event`, `api`, `neopattern`
- Smaller size (14px radius)
- Positioned dynamically based on event frequency
#### Link Types
- **Center Links** (Blue):
- Connect the center node to root topic nodes
- Thicker lines (2.5px)
- Examples: center → `cluster`, center → `event`
- **Topic Links** (Green):
- Connect adjacent topics in event chains
- Line thickness proportional to event frequency
- Examples: `cluster``event`, `api``neopattern`
#### Hover Interactions
- **Link Hover**: Links become thicker and more opaque when hovered
- **Event Frequency**: Line thickness dynamically adjusts based on event occurrence counts
## Architecture
### Component Structure
```12:37:spore-ui/public/scripts/components/EventComponent.js
// Events Component - Visualizes websocket events as a graph
class EventComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.svg = null;
this.simulation = null;
this.zoom = null;
this.width = 0;
this.height = 0;
this.isInitialized = false;
// Center node data - will be initialized with proper coordinates later
this.centerNode = { id: 'center', type: 'center', label: '', x: 0, y: 0 };
// Track nodes for D3
this.graphNodes = [];
this.graphLinks = [];
// Track recent events to trigger animations
this.lastSeenEvents = new Set();
}
```
### ViewModel
The `EventViewModel` manages the state and WebSocket connections:
```1241:1253:spore-ui/public/scripts/view-models.js
// Events View Model for websocket event visualization
class EventViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
isLoading: false,
error: null,
lastUpdateTime: null
});
// Set up WebSocket listeners for real-time updates
this.setupWebSocketListeners();
}
```
### Event Tracking
Events are tracked with the following metadata:
- **Topic**: The full event topic path (e.g., `cluster/event/api/neopattern`)
- **Parts**: Array of topic segments split by `/`
- **Count**: Total occurrences of this event
- **First Seen**: ISO timestamp of first occurrence
- **Last Seen**: ISO timestamp of most recent occurrence
- **Last Data**: The most recent event payload
### Event Addition Logic
The view model handles nested events specially:
```1283:1329:spore-ui/public/scripts/view-models.js
// Add a topic (parsed by "/" separator)
addTopic(topic, data = null) {
// Get current events as a new Map to ensure change detection
const events = new Map(this.get('events'));
// Handle nested events from cluster/event
let fullTopic = topic;
if (topic === 'cluster/event' && data && data.data) {
try {
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
if (parsedData && parsedData.event) {
// Create nested topic chain: cluster/event/api/neopattern
fullTopic = `${topic}/${parsedData.event}`;
}
} catch (e) {
// If parsing fails, just use the original topic
}
}
const parts = fullTopic.split('/').filter(p => p);
if (events.has(fullTopic)) {
// Update existing event - create new object to ensure change detection
const existing = events.get(fullTopic);
events.set(fullTopic, {
topic: existing.topic,
parts: existing.parts,
count: existing.count + 1,
firstSeen: existing.firstSeen,
lastSeen: new Date().toISOString(),
lastData: data
});
} else {
// Create new event entry
events.set(fullTopic, {
topic: fullTopic,
parts: parts,
count: 1,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
lastData: data
});
}
// Use set to trigger change notification
this.set('events', events);
this.set('lastUpdateTime', new Date().toISOString());
}
```
## Graph Construction
### Node Creation
The graph construction process follows two passes:
1. **First Pass - Node Creation**:
- Creates nodes for each unique topic segment
- Preserves existing node positions if they've been dragged
- New nodes are positioned near their parent in the hierarchy
2. **Second Pass - Event Counting**:
- Counts occurrences of each topic segment
- Updates node counts to reflect event frequency
### Link Construction
Links are created as chains representing complete topic paths:
```283:342:spore-ui/public/scripts/components/EventComponent.js
// Build links as chains for each topic
// For "cluster/update", create: center -> cluster -> update
this.graphLinks = [];
const linkSet = new Set(); // Track links to avoid duplicates
if (events && events.size > 0) {
for (const [topic, data] of events) {
const parts = data.parts;
if (parts.length === 0) continue;
// Connect center to first part
const firstPart = parts[0];
const centerToFirst = `center-${firstPart}`;
if (!linkSet.has(centerToFirst)) {
this.graphLinks.push({
source: 'center',
target: firstPart,
type: 'center-link',
count: data.count,
topic: topic
});
linkSet.add(centerToFirst);
} else {
// Update count for existing link
const existingLink = this.graphLinks.find(l =>
`${l.source}-${l.target}` === centerToFirst
);
if (existingLink) {
existingLink.count += data.count;
}
}
// Connect each part to the next (creating a chain)
for (let i = 0; i < parts.length - 1; i++) {
const source = parts[i];
const target = parts[i + 1];
const linkKey = `${source}-${target}`;
if (!linkSet.has(linkKey)) {
this.graphLinks.push({
source: source,
target: target,
type: 'topic-link',
topic: topic,
count: data.count
});
linkSet.add(linkKey);
} else {
// Update count for existing link
const existingLink = this.graphLinks.find(l =>
`${l.source}-${l.target}` === linkKey
);
if (existingLink) {
existingLink.count += data.count;
}
}
}
}
}
```
## Force Simulation
The D3.js force simulation provides the physics-based layout:
```103:111:spore-ui/public/scripts/components/EventComponent.js
// Initialize simulation
this.simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
.force('collision', d3.forceCollide().radius(35))
.alphaDecay(0.0228) // Slower decay to allow simulation to run longer
.velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement
```
### Force Parameters
- **Link Force**: Maintains 100px distance between connected nodes
- **Charge Force**: -300 strength creates repulsion between nodes
- **Center Force**: Keeps the graph centered in the viewport
- **Collision Detection**: Prevents nodes from overlapping (35px collision radius)
- **Decay Rate**: Slow decay (0.0228) keeps the simulation running smoothly
- **Velocity Decay**: High decay (0.4) prevents excessive movement
## Animation System
### Event Arrival Animation
When a new event arrives, a golden animation dot travels along the event chain:
```725:809:spore-ui/public/scripts/components/EventComponent.js
animateEventMessage(topic, data) {
const g = this.svg.select('g');
if (g.empty()) return;
const parts = data.parts || topic.split('/').filter(p => p);
if (parts.length === 0) return;
// Wait for the next tick to ensure nodes exist
setTimeout(() => {
// Build the chain: center -> first -> second -> ... -> last
const chain = ['center', ...parts];
// Animate along each segment of the chain
let delay = 0;
for (let i = 0; i < chain.length - 1; i++) {
const sourceId = chain[i];
const targetId = chain[i + 1];
const sourceNode = this.graphNodes.find(n => n.id === sourceId);
const targetNode = this.graphNodes.find(n => n.id === targetId);
if (!sourceNode || !targetNode) continue;
setTimeout(() => {
this.animateDotAlongLink(sourceNode, targetNode, g);
}, delay);
// Add delay between segments (staggered animation)
delay += 400; // Duration (300ms) + small gap (100ms)
}
}, 100);
}
animateDotAlongLink(sourceNode, targetNode, g) {
if (!sourceNode || !targetNode || !g) return;
// Calculate positions
const dx = targetNode.x - sourceNode.x;
const dy = targetNode.y - sourceNode.y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length === 0) return;
// Normalize direction
const nx = dx / length;
const ny = dy / length;
// Calculate node radii
const sourceRadius = sourceNode.type === 'center' ? 18 : 14;
const targetRadius = targetNode.type === 'center' ? 18 : 14;
// Start from edge of source node
const startX = sourceNode.x + nx * sourceRadius;
const startY = sourceNode.y + ny * sourceRadius;
// End at edge of target node
const endX = targetNode.x - nx * targetRadius;
const endY = targetNode.y - ny * targetRadius;
// Create animation dot
const animationGroup = g.append('g').attr('class', 'animation-group');
const dot = animationGroup.append('circle')
.attr('r', 4)
.attr('fill', '#FFD700')
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('cx', startX)
.attr('cy', startY);
// Animate the dot along the path
dot.transition()
.duration(300)
.ease(d3.easeLinear)
.attr('cx', endX)
.attr('cy', endY)
.on('end', function() {
// Fade out after reaching destination
dot.transition()
.duration(100)
.style('opacity', 0)
.remove();
animationGroup.remove();
});
}
```
### Animation Characteristics
- **Golden Dot**: Visual indicator traveling along event chains
- **Staggered Timing**: Each segment of a multi-part topic animates sequentially
- **Duration**: 300ms per segment with 100ms delay between segments
- **Smooth Motion**: Linear easing for consistent speed
## WebSocket Integration
The Events view subscribes to all WebSocket messages through the gateway:
```1256:1280:spore-ui/public/scripts/view-models.js
// Set up WebSocket event listeners
setupWebSocketListeners() {
if (!window.wsClient) {
// Retry after a short delay to allow wsClient to initialize
setTimeout(() => this.setupWebSocketListeners(), 1000);
return;
}
// Listen for all websocket messages
window.wsClient.on('message', (data) => {
const topic = data.topic || data.type;
if (topic) {
this.addTopic(topic, data);
}
});
// Listen for connection status changes
window.wsClient.on('connected', () => {
logger.info('EventViewModel: WebSocket connected');
});
window.wsClient.on('disconnected', () => {
logger.debug('EventViewModel: WebSocket disconnected');
});
}
```
### Event Types Tracked
All WebSocket messages are captured and displayed, including:
- **cluster/update**: Node discovery and cluster membership changes
- **cluster/event**: Arbitrary events from cluster members
- **api/tasks**: Task execution notifications
- **api/config**: Configuration updates
- **node/***: Node-specific events
- Any custom topics sent by SPORE nodes
## Empty State
When no events have been received, the view displays a helpful message:
```604:632:spore-ui/public/scripts/components/EventComponent.js
showEmptyState() {
if (!this.isInitialized || !this.svg) return;
const g = this.svg.select('g');
if (g.empty()) return;
// Remove any existing message
g.selectAll('.empty-state').remove();
// Account for the initial zoom transform (scale(1.4) translate(-200, -150))
// We need to place the text in the center of the transformed coordinate space
const transformX = -200;
const transformY = -150;
const scale = 1.4;
// Calculate centered position in transformed space
const x = ((this.width / 2) - transformX) / scale;
const y = ((this.height / 2) - transformY) / scale;
const emptyMsg = g.append('text')
.attr('class', 'empty-state')
.attr('x', x)
.attr('y', y)
.attr('text-anchor', 'middle')
.attr('font-size', '18px')
.attr('fill', 'var(--text-secondary)')
.attr('font-weight', '500')
.text('Waiting for websocket events...');
}
```
## Best Practices
### Performance Considerations
1. **Event Accumulation**: Events accumulate over time; consider clearing the view for long-running sessions
2. **Many Events**: With hundreds of unique topics, the graph may become cluttered
3. **Animation**: Rapid event bursts may cause overlapping animations
### Usage Tips
1. **Rearrange Regularly**: Use the rearrange button to clean up the layout after many events
2. **Drag Center Node**: Move the entire graph to see different areas of the visualization
3. **Zoom Out**: Zoom out to see the overall event pattern across the cluster
4. **Interactive Exploration**: Hover over links to see event frequencies
## Troubleshooting
### No Events Showing
- **Check WebSocket Connection**: Verify the gateway WebSocket is connected
- **Check Node Activity**: Ensure SPORE nodes are actively sending events
- **Browser Console**: Check for any JavaScript errors
### Graph Not Updating
- **Force Refresh**: Reload the page to reinitialize the WebSocket connection
- **Check Gateway**: Verify the SPORE Gateway is running and accessible
### Performance Issues
- **Clear Events**: Reload the page to clear accumulated events
- **Reduce Events**: Limit the event rate in your SPORE nodes
- **Browser Settings**: Ensure hardware acceleration is enabled
## Technical Details
### Dependencies
- **D3.js v7**: Force-directed graph simulation and SVG manipulation
- **SPORE Gateway**: WebSocket connection to cluster event stream
- **Component Framework**: Built on the SPORE UI component architecture
### Browser Compatibility
- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest versions)
- **SVG Support**: Requires modern browser with full SVG support
- **WebSocket Support**: Native WebSocket API required
### View Structure
```254:262:spore-ui/public/index.html
<div id="events-view" class="view-content">
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
<div id="events-graph-container" style="flex: 1; min-height: 0;">
<div class="loading">
<div>Waiting for websocket events...</div>
</div>
</div>
</div>
</div>
```
The Events view provides a unique perspective on cluster activity, making it easy to observe the flow of events through the SPORE system in real-time.

View File

@@ -0,0 +1,169 @@
# Firmware Registry Integration
This document describes the integration of the SPORE Registry into the SPORE UI, replacing the previous firmware upload functionality with a comprehensive CRUD interface for managing firmware in the registry.
## Overview
The firmware view has been completely redesigned to provide:
- **Registry Management**: Full CRUD operations for firmware in the SPORE Registry
- **Search & Filter**: Search firmware by name, version, or labels
- **Drawer Forms**: Add/edit forms displayed in the existing drawer component
- **Real-time Status**: Registry connection status indicator
- **Download Support**: Direct download of firmware binaries
## Architecture
### Components
1. **FirmwareComponent** (`FirmwareComponent.js`)
- Main component for the firmware registry interface
- Handles CRUD operations and UI interactions
- Manages registry connection status
2. **FirmwareFormComponent** (`FirmwareFormComponent.js`)
- Form component for add/edit operations
- Used within the drawer component
- Handles metadata and file uploads
3. **API Client Extensions** (`api-client.js`)
- New registry API methods added to existing ApiClient
- Auto-detection of registry server URL
- Support for multipart form data uploads
### API Integration
The integration uses the SPORE Registry API endpoints:
- `GET /health` - Health check
- `GET /firmware` - List firmware with optional filtering
- `POST /firmware` - Upload firmware with metadata
- `GET /firmware/{name}/{version}` - Download firmware binary
### Registry Server Configuration
The registry server is expected to run on:
- **Localhost**: `http://localhost:8080`
- **Remote**: `http://{hostname}:8080`
The UI automatically detects the appropriate URL based on the current hostname.
## Features
### Firmware Management
- **Add Firmware**: Upload new firmware with metadata and labels
- **Edit Firmware**: Modify existing firmware (requires new file upload)
- **Download Firmware**: Direct download of firmware binaries
- **Delete Firmware**: Remove firmware from registry (not yet implemented in API)
### Search & Filtering
- **Text Search**: Search by firmware name, version, or label values
- **Real-time Filtering**: Results update as you type
- **Label Display**: Visual display of firmware labels with color coding
### User Interface
- **Card Layout**: Clean card-based layout for firmware entries
- **Action Buttons**: Edit, download, and delete actions for each firmware
- **Status Indicators**: Registry connection status with visual feedback
- **Loading States**: Proper loading indicators during operations
- **Error Handling**: User-friendly error messages and notifications
### Form Interface
- **Drawer Integration**: Forms open in the existing drawer component
- **Metadata Fields**: Name, version, and custom labels
- **File Upload**: Drag-and-drop or click-to-upload file selection
- **Label Management**: Add/remove key-value label pairs
- **Validation**: Client-side validation with helpful error messages
## Usage
### Adding Firmware
1. Click the "Add Firmware" button in the header
2. Fill in the firmware name and version
3. Select a firmware file (.bin or .hex)
4. Add optional labels (key-value pairs)
5. Click "Upload Firmware"
### Editing Firmware
1. Click the edit button on any firmware card
2. Modify the metadata (name and version are read-only)
3. Upload a new firmware file
4. Update labels as needed
5. Click "Update Firmware"
### Downloading Firmware
1. Click the download button on any firmware card
2. The firmware binary will be downloaded automatically
### Searching Firmware
1. Use the search box to filter firmware
2. Search by name, version, or label values
3. Results update in real-time
## Testing
A test suite is provided to verify the registry integration:
```bash
cd spore-ui/test
node registry-integration-test.js
```
The test suite verifies:
- Registry health check
- List firmware functionality
- Upload firmware functionality
- Download firmware functionality
## Configuration
### Registry Server
Ensure the SPORE Registry server is running on port 8080:
```bash
cd spore-registry
go run main.go
```
### UI Configuration
The UI automatically detects the registry server URL. No additional configuration is required.
## Error Handling
The integration includes comprehensive error handling:
- **Connection Errors**: Clear indication when registry is unavailable
- **Upload Errors**: Detailed error messages for upload failures
- **Validation Errors**: Client-side validation with helpful messages
- **Network Errors**: Graceful handling of network timeouts and failures
## Future Enhancements
Planned improvements include:
- **Delete Functionality**: Complete delete operation when API supports it
- **Bulk Operations**: Select multiple firmware for bulk operations
- **Version History**: View and manage firmware version history
- **Deployment Integration**: Deploy firmware directly to nodes from registry
- **Advanced Filtering**: Filter by date, size, or other metadata
## Migration Notes
The previous firmware upload functionality has been completely replaced. The new interface provides:
- Better organization with the registry
- Improved user experience with search and filtering
- Consistent UI patterns with the rest of the application
- Better error handling and user feedback
All existing firmware functionality is now handled through the registry interface.

203
docs/FRAMEWORK_README.md Normal file
View File

@@ -0,0 +1,203 @@
# SPORE UI Framework
A clean, component-based frontend framework with pub/sub communication and view models.
## Architecture Overview
The framework follows a clean architecture pattern with the following layers:
1. **Components** - Handle UI rendering and user interactions
2. **View Models** - Hold data and business logic
3. **API Client** - Communicate with the backend
4. **Event Bus** - Pub/sub communication between components
5. **Framework Core** - Base classes and application management
## Key Features
- **Component-based architecture** - Reusable, self-contained UI components
- **View Models** - Data flows from backend → view model → UI rendering
- **Pub/Sub system** - Components communicate through events
- **Automatic cleanup** - Event listeners and subscriptions are automatically cleaned up
- **Type-safe property access** - View models provide get/set methods with change notifications
- **Routing** - Built-in navigation between views
## File Structure
```
public/
├── framework.js # Core framework classes
├── api-client.js # Backend API communication
├── view-models.js # View models for each component
├── components.js # UI components
├── app.js # Main application setup
├── index.html # HTML template
└── styles.css # Styling
```
## Usage
### 1. Creating a View Model
```javascript
class MyViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
data: [],
isLoading: false,
error: null
});
}
async loadData() {
try {
this.set('isLoading', true);
const data = await window.apiClient.getData();
this.set('data', data);
} catch (error) {
this.set('error', error.message);
} finally {
this.set('isLoading', false);
}
}
}
```
### 2. Creating a Component
```javascript
class MyComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupEventListeners() {
const button = this.findElement('.my-button');
if (button) {
this.addEventListener(button, 'click', this.handleClick.bind(this));
}
}
setupViewModelListeners() {
this.subscribeToProperty('data', this.render.bind(this));
this.subscribeToProperty('isLoading', this.render.bind(this));
this.subscribeToProperty('error', this.render.bind(this));
}
render() {
const data = this.viewModel.get('data');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
if (isLoading) {
this.setHTML('', '<div>Loading...</div>');
return;
}
if (error) {
this.setHTML('', `<div class="error">${error}</div>`);
return;
}
this.setHTML('', `<div>${this.renderData(data)}</div>`);
}
renderData(data) {
return data.map(item => `<div>${item.name}</div>`).join('');
}
handleClick() {
// Handle user interaction
this.viewModel.loadData();
}
}
```
### 3. Using the Event Bus
```javascript
// Subscribe to events
this.subscribeToEvent('user-logged-in', (userData) => {
console.log('User logged in:', userData);
});
// Publish events
this.viewModel.publish('data-updated', { timestamp: Date.now() });
```
### 4. Component Helper Methods
The framework provides several helper methods for common DOM operations:
```javascript
// Find elements
const element = this.findElement('.my-class');
const elements = this.findAllElements('.my-class');
// Update content
this.setHTML('.my-container', '<div>New content</div>');
this.setText('.my-text', 'New text');
// Manage classes
this.setClass('.my-element', 'active', true);
this.setClass('.my-element', 'hidden', false);
// Show/hide elements
this.setVisible('.my-element', false);
this.setEnabled('.my-button', false);
// Manage styles
this.setStyle('.my-element', 'color', 'red');
```
### 5. Registering Routes
```javascript
// In app.js
window.app.registerRoute('my-view', MyComponent, 'my-view-container');
```
### 6. Navigation
```javascript
// Navigate to a route
window.app.navigateTo('my-view');
```
## Data Flow
1. **User Interaction** → Component handles event
2. **Component** → Calls view model method
3. **View Model** → Makes API call or processes data
4. **View Model** → Updates properties (triggers change notifications)
5. **Component** → Receives change notification and re-renders
6. **UI** → Updates to reflect new data
## Best Practices
1. **Keep components focused** - Each component should have a single responsibility
2. **Use view models for business logic** - Don't put API calls or data processing in components
3. **Subscribe to specific properties** - Only listen to properties your component needs
4. **Use the event bus sparingly** - Prefer direct view model communication for related components
5. **Clean up resources** - The framework handles most cleanup automatically, but be mindful of custom event listeners
6. **Error handling** - Always handle errors in async operations and update the view model accordingly
## Migration from Old Code
The old monolithic `script.js` has been broken down into:
- **API calls** → `api-client.js`
- **Data management** → `view-models.js`
- **UI logic** → `components.js`
- **Application setup** → `app.js`
Each piece is now more focused, testable, and maintainable.
## Benefits
- **Maintainability** - Smaller, focused files are easier to understand and modify
- **Testability** - View models can be tested independently of UI
- **Reusability** - Components can be reused across different views
- **Scalability** - Easy to add new features without affecting existing code
- **Debugging** - Clear separation of concerns makes issues easier to track down
- **Performance** - Components only re-render when their specific data changes

69
docs/LOGGING.md Normal file
View File

@@ -0,0 +1,69 @@
# SPORE UI Backend Logging
The SPORE UI backend now includes a configurable logging system to reduce log noise while maintaining important information.
## Log Levels
The logging system supports different levels:
- **INFO**: Important operational messages (default)
- **DEBUG**: Detailed debugging information (only shown when enabled)
- **WARN**: Warning messages
- **ERROR**: Error messages
## Controlling Log Levels
### Environment Variables
Set the `LOG_LEVEL` environment variable to control logging:
```bash
# Show only INFO, WARN, and ERROR messages (default)
LOG_LEVEL=info
# Show all messages including DEBUG
LOG_LEVEL=debug
```
### Development Mode
In development mode (`NODE_ENV=development`), DEBUG messages are automatically enabled:
```bash
NODE_ENV=development npm start
```
## What Was Changed
The following verbose logging has been moved to DEBUG level:
1. **Heartbeat Messages**: Regular heartbeat logs from nodes
2. **WebSocket Broadcasts**: Routine cluster update broadcasts
3. **Proxy Calls**: Individual API proxy request details
4. **Cluster Updates**: Member list change notifications
5. **Discovery Events**: Routine node discovery messages
## Important Messages Still Shown
These messages remain at INFO level for operational visibility:
- Node discovery (new nodes)
- Node status changes (inactive/stale)
- Failover events
- Server startup/shutdown
- Error conditions
## Example Usage
```bash
# Production with minimal logging
LOG_LEVEL=info npm start
# Development with full debugging
LOG_LEVEL=debug npm start
# Or use development mode
NODE_ENV=development npm start
```
This reduces log noise significantly while preserving important operational information.

78
docs/README.md Normal file
View File

@@ -0,0 +1,78 @@
# SPORE UI Documentation
This directory contains detailed documentation for the SPORE UI frontend application.
## Documentation Index
### Core Documentation
- **[Framework](./FRAMEWORK_README.md)**: Component-based architecture, View Models, Event Bus, and framework conventions
- **[Discovery](./DISCOVERY.md)**: Node discovery system and cluster management
- **[Topology WebSocket Update](./TOPOLOGY_WEBSOCKET_UPDATE.md)**: Real-time topology visualization updates
- **[Logging](./LOGGING.md)**: Logging system and debugging utilities
### Feature Documentation
- **[Events](./Events.md)**: Real-time WebSocket event visualization with interactive force-directed graph
- **[Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md)**: Integration with the firmware registry system
## Feature Overview
### Events Feature
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
**Key Capabilities:**
- Real-time event tracking and visualization
- Interactive force-directed graph layout
- Event counting and frequency visualization
- Animated event flow visualization
- Topic hierarchy display
See [Events.md](./Events.md) for complete documentation.
### Framework Architecture
The SPORE UI uses a component-based architecture with:
- View Models for state management
- Components for UI rendering
- Event Bus for pub/sub communication
- API Client for backend integration
See [FRAMEWORK_README.md](./FRAMEWORK_README.md) for architecture details.
### Discovery System
The discovery system enables automatic detection of SPORE nodes on the network, maintaining a real-time view of cluster members.
See [DISCOVERY.md](./DISCOVERY.md) for implementation details.
### Topology Visualization
The topology view provides an interactive network graph showing relationships between cluster nodes in real-time.
See [TOPOLOGY_WEBSOCKET_UPDATE.md](./TOPOLOGY_WEBSOCKET_UPDATE.md) for detailed documentation.
## Quick Reference
### Most Popular Pages
1. [Framework Architecture](./FRAMEWORK_README.md) - Start here for understanding the codebase
2. [Events Feature](./Events.md) - Real-time event visualization
3. [Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md) - Firmware management
### Developer Resources
- Component development: See [FRAMEWORK_README.md](./FRAMEWORK_README.md)
- Debugging: See [LOGGING.md](./LOGGING.md)
- Testing: See individual component documentation
## Contributing
When adding new features or documentation:
1. Follow the existing documentation structure
2. Include code examples and diagrams where helpful
3. Update this README when adding new documentation files
4. Maintain consistency with existing documentation style
## Related Documentation
For information about other SPORE components:
- **SPORE Gateway**: See `spore-gateway/README.md`
- **SPORE Registry**: See `spore-registry/README.md`
- **SPORE Embedded**: See `spore/README.md`

View File

@@ -0,0 +1,248 @@
# Topology Component WebSocket Integration
## Summary
Enhanced the topology graph component to support real-time node additions and removals via WebSocket connections. The topology view now automatically updates when nodes join or leave the cluster without requiring manual refresh. Existing nodes update their properties (status, labels) smoothly in place without being removed and re-added.
## Changes Made
### 1. TopologyViewModel (`spore-ui/public/scripts/view-models.js`)
Added `setupWebSocketListeners()` method to the TopologyViewModel class:
- **Listens to `clusterUpdate` events**: When cluster membership changes, the topology graph is automatically rebuilt with the new node data
- **Listens to `nodeDiscovery` events**: When a new node is discovered or becomes stale, triggers a topology update
- **Listens to connection status**: Automatically refreshes topology when WebSocket reconnects
- **Async graph updates**: Rebuilds graph data asynchronously from WebSocket data to avoid blocking the UI
Enhanced `buildEnhancedGraphData()` method to preserve node state:
- **Position preservation**: Existing nodes retain their x, y coordinates across updates
- **Velocity preservation**: D3 simulation velocity (vx, vy) is maintained for smooth physics
- **Fixed position preservation**: Manually dragged nodes (fx, fy) stay in place
- **New nodes only**: Only newly discovered nodes get random initial positions
- **Result**: Nodes no longer "jump" or get removed/re-added when their properties update
### 2. TopologyGraphComponent (`spore-ui/public/scripts/components/TopologyGraphComponent.js`)
#### Added WebSocket Setup
- Added `setupWebSocketListeners()` method that calls the view model's WebSocket setup during component initialization
- Integrated into the `initialize()` lifecycle method
#### Improved Dynamic Updates (D3.js Enter/Exit Pattern)
Refactored the graph rendering to use D3's data binding patterns for smooth transitions:
- **`updateLinks()`**: Uses enter/exit pattern to add/remove links with fade transitions
- **`updateNodes()`**: Uses enter/exit pattern to add/remove nodes with fade transitions
- New nodes fade in (300ms transition)
- Removed nodes fade out (300ms transition)
- Existing nodes smoothly update their properties
- **`updateLinkLabels()`**: Dynamically updates link latency labels
- **`updateSimulation()`**: Handles D3 force simulation updates
- Creates new simulation on first render
- Updates existing simulation with new node/link data on subsequent renders
- Maintains smooth physics-based layout
- **`addLegend()`**: Fixed to prevent duplicate legend creation
#### Key Improvements
- **Incremental updates**: Instead of recreating the entire graph, only modified nodes/links are added or removed
- **Smooth animations**: 300ms fade transitions for adding/removing elements
- **In-place updates**: Existing nodes update their properties without being removed/re-added
- **Preserved interactions**: Click, hover, and drag interactions work seamlessly with dynamic updates
- **Efficient rendering**: D3's data binding with key functions ensures optimal DOM updates
- **Intelligent simulation**: Uses different alpha values (0.1 for updates, 0.3 for additions/removals) to minimize disruption
- **Drag-aware updates**: WebSocket updates are deferred while dragging and applied after drag completes
- **Uninterrupted dragging**: Drag operations are never interrupted by incoming updates
- **Rearrange button**: Convenient UI control to reset node layout and clear manual positioning
## How It Works
### Data Flow
```
WebSocket Server (spore-ui backend)
↓ (cluster_update / node_discovery events)
WebSocketClient (api-client.js)
↓ (emits clusterUpdate / nodeDiscovery events)
TopologyViewModel.setupWebSocketListeners()
↓ (builds graph data, updates state)
TopologyGraphComponent subscriptions
↓ (renderGraph() called automatically)
├─ If dragging: queue update in pendingUpdate
└─ If not dragging: apply update immediately
D3.js enter/exit pattern
↓ (smooth visual updates)
Updated Topology Graph
```
### Simplified Update Architecture
**Core Principle**: The D3 simulation is the single source of truth for positions.
#### How It Works:
1. **Drag Deferral**:
- `isDragging` flag blocks updates during drag
- Updates queued in `pendingUpdate` and applied after drag ends
- Dragged positions saved in `draggedNodePositions` Map for persistence
2. **Position Merging** (in `updateNodes()`):
- When simulation exists: copy live positions from simulation nodes to new data
- This preserves ongoing animations and velocities
- Then apply dragged positions (if any) as overrides
- Result: Always use most current position state
3. **Smart Simulation Updates** (in `updateSimulation()`):
- **Structural changes** (nodes added/removed): restart with alpha=0.3
- **Property changes** (status, labels): DON'T restart - just update data
- Simulation continues naturally for property-only changes
- No unnecessary disruptions to ongoing animations
This ensures:
- ✅ Simulation is authoritative for positions
- ✅ No position jumping during animations
- ✅ Property updates don't disrupt node movement
- ✅ Dragged positions always respected
- ✅ Simple, clean logic with one source of truth
### WebSocket Events Handled
1. **`clusterUpdate`** (from `cluster_update` message type)
- Payload: `{ members: [...], primaryNode: string, totalNodes: number, timestamp: string }`
- Action: Rebuilds graph with current cluster state
2. **`nodeDiscovery`** (from `node_discovery` message type)
- Payload: `{ action: 'discovered' | 'stale', nodeIp: string, timestamp: string }`
- Action: Triggers topology refresh after 500ms delay
3. **`connected`** (WebSocket connection established)
- Action: Triggers topology refresh after 1000ms delay
4. **`disconnected`** (WebSocket connection lost)
- Action: Logs disconnection (no action taken)
## Benefits
1. **Real-time Updates**: Topology reflects cluster state changes immediately
2. **Smooth Transitions**: Nodes and links fade in/out gracefully
3. **Better UX**: No manual refresh needed
4. **Efficient**: Only updates changed elements, not entire graph
5. **Resilient**: Automatically refreshes on reconnection
6. **Consistent**: Uses same WebSocket infrastructure as ClusterStatusComponent
## Testing
To test the WebSocket integration:
1. **Start the application**:
```bash
cd spore-ui
node index-standalone.js
```
2. **Open the UI** and navigate to the Topology view
3. **Add a node**: Start a new SPORE device on the network
- Watch it appear in the topology graph within seconds
- Node should fade in smoothly
4. **Remove a node**: Stop a SPORE device
- Watch it fade out from the topology graph
- Connected links should also disappear
5. **Status changes**: Change node status (active → inactive → dead)
- Node colors should update automatically
- Status indicators should change
6. **Drag during updates**:
- Start dragging a node
- While dragging, trigger a cluster update (add/remove/change another node)
- Drag should continue smoothly without interruption
- After releasing, the update should be applied immediately
- **Important**: The dragged node should stay at its final position, not revert
7. **Position persistence after drag**:
- Drag a node to a new position and release
- Trigger multiple WebSocket updates (status changes, new nodes, etc.)
- The dragged node should remain in its new position through all updates
- Only when the node is removed should its position be forgotten
8. **Update during animation**:
- Let the graph settle (simulation running, nodes animating to stable positions)
- While nodes are still moving, trigger a WebSocket update (status change)
- **Expected**: Nodes should continue their smooth animation without jumping
- **No flickering**: Positions should not snap back and forth
- Animation should feel continuous and natural
9. **Single node scenario**:
- Start with multiple nodes in the topology
- Remove nodes one by one until only one remains
- **Expected**: Single node stays visible, no "loading" message
- Graph should render correctly with just one node
- Remove the last node
- **Expected**: "No cluster members found" message appears
10. **Rearrange nodes**:
- Drag nodes to custom positions manually
- Click the "Rearrange" button in the top-left corner
- **Expected**: All nodes reset to physics-based positions
- Dragged positions cleared, simulation restarts
- Nodes animate to a clean, evenly distributed layout
11. **WebSocket reconnection**:
- Disconnect from network briefly
- Reconnect
- Topology should refresh automatically
## Technical Notes
### Architecture
- **Single Source of Truth**: D3 simulation manages all position state
- **Key Functions**: D3 data binding uses node IPs as keys to track identity
- **Transition Duration**: 300ms for fade in/out animations
### Position Management (Simplified!)
- **updateNodes()**: Copies live positions from simulation to new data before binding
- **No complex syncing**: Simulation state flows naturally to new data
- **Dragged positions**: Override via `draggedNodePositions` Map (always respected)
### Simulation Behavior
- **Structural changes** (add/remove nodes): Restart with alpha=0.3
- **Property changes** (status, labels): No restart - data updated in-place
- **Drag operations**: Simulation updates blocked entirely
- **Result**: Smooth animations for property updates, controlled restart for structure changes
### Drag Management
- **isDragging flag**: Blocks all updates during drag
- **pendingUpdate**: Queues one update, applied 50ms after drag ends
- **draggedNodePositions Map**: Persists manual positions across all updates
- **Cleanup**: Map entries removed when nodes deleted
### Performance
- **No unnecessary restarts**: Property-only updates don't disrupt simulation
- **Efficient merging**: Position data copied via Map lookup (O(n))
- **Memory efficient**: Only active nodes tracked, old entries cleaned up
- **Smooth animations**: Velocity and momentum preserved across updates
### Edge Cases Handled
- **Single node**: Graph renders correctly with just one node
- **Transient states**: Loading/no-data states don't clear existing SVG
- **Update races**: SVG preserved even if loading state triggered during render
- **Empty to non-empty**: Smooth transition from loading to first node
## Future Enhancements
Possible improvements for future iterations:
1. **Diff-based updates**: Only rebuild graph when node/link structure actually changes
2. **Visual indicators**: Show "new node" or "leaving node" badges temporarily
3. **Connection health**: Real-time latency updates on links without full rebuild
4. **Throttling**: Debounce rapid successive updates
5. **Persistent layout**: Save and restore user-arranged topology layouts
6. **Zoom to node**: Auto-zoom to newly added nodes with animation
## Related Files
- `spore-ui/public/scripts/view-models.js` - TopologyViewModel class
- `spore-ui/public/scripts/components/TopologyGraphComponent.js` - Topology visualization component
- `spore-ui/public/scripts/api-client.js` - WebSocketClient class
- `spore-ui/index-standalone.js` - WebSocket server implementation

1184
index-standalone.js Normal file

File diff suppressed because it is too large Load Diff

545
index.js
View File

@@ -1,524 +1,45 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const dgram = require('dgram');
const SporeApiClient = require('./src/client');
// Simple logging utility with level control
const logger = {
debug: (...args) => {
if (process.env.LOG_LEVEL === 'debug' || process.env.NODE_ENV === 'development') {
console.log('[DEBUG]', ...args);
}
},
info: (...args) => console.log('[INFO]', ...args),
warn: (...args) => console.warn('[WARN]', ...args),
error: (...args) => console.error('[ERROR]', ...args)
};
const app = express();
const PORT = process.env.PORT || 3001;
// UDP discovery configuration
const UDP_PORT = 4210;
const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY';
// Initialize UDP server for auto discovery
const udpServer = dgram.createSocket('udp4');
// Store discovered nodes and their IPs
const discoveredNodes = new Map();
let primaryNodeIp = null;
// UDP server event handlers
udpServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`UDP port ${UDP_PORT} is already in use. Please check if another instance is running.`);
} else {
console.error('UDP Server error:', err);
}
udpServer.close();
});
udpServer.on('message', (msg, rinfo) => {
try {
const message = msg.toString().trim();
const sourceIp = rinfo.address;
const sourcePort = rinfo.port;
//console.log(`UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
if (message === DISCOVERY_MESSAGE) {
//console.log(`Received CLUSTER_DISCOVERY from ${sourceIp}:${sourcePort}`);
// Store the discovered node
const nodeInfo = {
ip: sourceIp,
port: sourcePort,
discoveredAt: new Date(),
lastSeen: new Date()
};
discoveredNodes.set(sourceIp, nodeInfo);
// Set as primary node if this is the first one or if we don't have one
if (!primaryNodeIp) {
primaryNodeIp = sourceIp;
console.log(`Set primary node to ${sourceIp}`);
// Immediately try to initialize the client
updateSporeClient();
}
// Update last seen timestamp
discoveredNodes.get(sourceIp).lastSeen = new Date();
//console.log(`Node ${sourceIp} added/updated. Total discovered nodes: ${discoveredNodes.size}`);
} else {
console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`);
}
} catch (error) {
console.error('Error processing UDP message:', error);
}
});
udpServer.on('listening', () => {
const address = udpServer.address();
console.log(`UDP discovery server listening on ${address.address}:${address.port}`);
});
// Bind UDP server to listen for discovery messages
udpServer.bind(UDP_PORT, () => {
console.log(`UDP discovery server bound to port ${UDP_PORT}`);
});
// Initialize the SPORE API client with dynamic IP
let sporeClient = null;
// Function to initialize or update the SporeApiClient
function initializeSporeClient(nodeIp) {
if (!nodeIp) {
console.warn('No node IP available for SporeApiClient initialization');
return null;
}
try {
const client = new SporeApiClient(`http://${nodeIp}`);
console.log(`Initialized SporeApiClient with node IP: ${nodeIp}`);
return client;
} catch (error) {
console.error(`Failed to initialize SporeApiClient with IP ${nodeIp}:`, error);
return null;
}
}
// Function to clean up stale discovered nodes (nodes not seen in the last 5 minutes)
function cleanupStaleNodes() {
const now = new Date();
const staleThreshold = 5 * 60 * 1000; // 5 minutes in milliseconds
for (const [ip, node] of discoveredNodes.entries()) {
if (now - node.lastSeen > staleThreshold) {
console.log(`Removing stale node: ${ip} (last seen: ${node.lastSeen.toISOString()})`);
discoveredNodes.delete(ip);
// If this was our primary node, clear it
if (primaryNodeIp === ip) {
primaryNodeIp = null;
console.log('Primary node became stale, clearing primary node selection');
}
}
}
}
// Function to select the best primary node
function selectBestPrimaryNode() {
if (discoveredNodes.size === 0) {
return null;
}
// If we already have a valid primary node, keep it
if (primaryNodeIp && discoveredNodes.has(primaryNodeIp)) {
return primaryNodeIp;
}
// Select the most recently seen node as primary
let bestNode = null;
let mostRecent = new Date(0);
for (const [ip, node] of discoveredNodes.entries()) {
if (node.lastSeen > mostRecent) {
mostRecent = node.lastSeen;
bestNode = ip;
}
}
if (bestNode && bestNode !== primaryNodeIp) {
primaryNodeIp = bestNode;
console.log(`Selected new primary node: ${bestNode}`);
}
return bestNode;
}
// Initialize client when a node is discovered
function updateSporeClient() {
const nodeIp = selectBestPrimaryNode();
if (nodeIp) {
sporeClient = initializeSporeClient(nodeIp);
}
}
// Set up periodic tasks
setInterval(() => {
cleanupStaleNodes();
if (!sporeClient || !primaryNodeIp || !discoveredNodes.has(primaryNodeIp)) {
updateSporeClient();
}
}, 5000); // Check every 5 seconds
const PORT = process.env.PORT || 3000;
// Serve static files from public directory
app.use(express.static(path.join(__dirname, 'public')));
// Serve the main HTML page
app.get('/', (req, res) => {
// Health check endpoint (before catch-all route)
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
service: 'spore-ui-frontend',
timestamp: new Date().toISOString(),
note: 'Frontend server - API calls are handled by spore-gateway on port 3001'
});
});
// SPA catch-all route - serves index.html for all routes
// This allows client-side routing to work properly
// Using regex pattern for Express 5 compatibility
app.get(/^\/(?!health$).*/, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// API endpoint to get discovered nodes
app.get('/api/discovery/nodes', (req, res) => {
const nodes = Array.from(discoveredNodes.values()).map(node => ({
...node,
discoveredAt: node.discoveredAt.toISOString(),
lastSeen: node.lastSeen.toISOString(),
isPrimary: node.ip === primaryNodeIp
}));
res.json({
primaryNode: primaryNodeIp,
totalNodes: discoveredNodes.size,
nodes: nodes,
clientInitialized: !!sporeClient,
clientBaseUrl: sporeClient ? sporeClient.baseUrl : null,
discoveryStatus: {
udpPort: UDP_PORT,
discoveryMessage: DISCOVERY_MESSAGE,
serverRunning: udpServer.listening
}
});
});
// API endpoint to manually trigger discovery refresh
app.post('/api/discovery/refresh', (req, res) => {
try {
// Clean up stale nodes
cleanupStaleNodes();
// Try to update the client
updateSporeClient();
res.json({
success: true,
message: 'Discovery refresh completed',
primaryNode: primaryNodeIp,
totalNodes: discoveredNodes.size,
clientInitialized: !!sporeClient
});
} catch (error) {
console.error('Error during discovery refresh:', error);
res.status(500).json({
error: 'Discovery refresh failed',
message: error.message
});
}
});
// API endpoint to manually set primary node
app.post('/api/discovery/primary/:ip', (req, res) => {
try {
const requestedIp = req.params.ip;
if (!discoveredNodes.has(requestedIp)) {
return res.status(404).json({
error: 'Node not found',
message: `Node with IP ${requestedIp} has not been discovered`
});
}
primaryNodeIp = requestedIp;
updateSporeClient();
res.json({
success: true,
message: `Primary node set to ${requestedIp}`,
primaryNode: primaryNodeIp,
clientInitialized: !!sporeClient
});
} catch (error) {
console.error('Error setting primary node:', error);
res.status(500).json({
error: 'Failed to set primary node',
message: error.message
});
}
});
// API endpoint to get cluster members
app.get('/api/cluster/members', async (req, res) => {
try {
if (!sporeClient) {
return res.status(503).json({
error: 'Service unavailable',
message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_DISCOVERY messages...',
discoveredNodes: Array.from(discoveredNodes.keys())
});
}
const members = await sporeClient.getClusterStatus();
res.json(members);
} catch (error) {
console.error('Error fetching cluster members:', error);
res.status(500).json({
error: 'Failed to fetch cluster members',
message: error.message
});
}
});
// API endpoint to get task status
app.get('/api/tasks/status', async (req, res) => {
try {
if (!sporeClient) {
return res.status(503).json({
error: 'Service unavailable',
message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_DISCOVERY messages...',
discoveredNodes: Array.from(discoveredNodes.keys())
});
}
const taskStatus = await sporeClient.getTaskStatus();
res.json(taskStatus);
} catch (error) {
console.error('Error fetching task status:', error);
res.status(500).json({
error: 'Failed to fetch task status',
message: error.message
});
}
});
// API endpoint to get system status
app.get('/api/node/status', async (req, res) => {
try {
if (!sporeClient) {
return res.status(503).json({
error: 'Service unavailable',
message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_DISCOVERY messages...',
discoveredNodes: Array.from(discoveredNodes.keys())
});
}
const systemStatus = await sporeClient.getSystemStatus();
res.json(systemStatus);
} catch (error) {
console.error('Error fetching system status:', error);
res.status(500).json({
error: 'Failed to fetch system status',
message: error.message
});
}
});
// Proxy endpoint to get status from a specific node
app.get('/api/node/status/:ip', async (req, res) => {
try {
const nodeIp = req.params.ip;
// Create a temporary client for the specific node
const nodeClient = new SporeApiClient(`http://${nodeIp}`);
const nodeStatus = await nodeClient.getSystemStatus();
res.json(nodeStatus);
} catch (error) {
console.error(`Error fetching status from node ${req.params.ip}:`, error);
res.status(500).json({
error: `Failed to fetch status from node ${req.params.ip}`,
message: error.message
});
}
});
// File upload endpoint for firmware updates
app.post('/api/node/update', express.raw({ type: 'multipart/form-data', limit: '50mb' }), async (req, res) => {
try {
const nodeIp = req.query.ip || req.headers['x-node-ip'];
if (!nodeIp) {
return res.status(400).json({
error: 'Node IP address is required',
message: 'Please provide the target node IP address'
});
}
// Parse multipart form data manually
const boundary = req.headers['content-type']?.split('boundary=')[1];
if (!boundary) {
return res.status(400).json({
error: 'Invalid content type',
message: 'Expected multipart/form-data with boundary'
});
}
// Parse the multipart data to extract the file
const fileData = parseMultipartData(req.body, boundary);
if (!fileData.file) {
return res.status(400).json({
error: 'No file data received',
message: 'Please select a firmware file to upload'
});
}
console.log(`Uploading firmware to node ${nodeIp}, file size: ${fileData.file.data.length} bytes, filename: ${fileData.file.filename}`);
// Create a temporary client for the specific node
const nodeClient = new SporeApiClient(`http://${nodeIp}`);
// Send the firmware data to the node using multipart form data
const updateResult = await nodeClient.updateFirmware(fileData.file.data, fileData.file.filename);
res.json({
success: true,
message: 'Firmware uploaded successfully',
nodeIp: nodeIp,
fileSize: fileData.file.data.length,
filename: fileData.file.filename,
result: updateResult
});
} catch (error) {
console.error('Error uploading firmware:', error);
res.status(500).json({
error: 'Failed to upload firmware',
message: error.message
});
}
});
// Health check endpoint
app.get('/api/health', (req, res) => {
const health = {
status: 'healthy',
timestamp: new Date().toISOString(),
services: {
http: true,
udp: udpServer.listening,
sporeClient: !!sporeClient
},
discovery: {
totalNodes: discoveredNodes.size,
primaryNode: primaryNodeIp,
udpPort: UDP_PORT,
serverRunning: udpServer.listening
}
};
// If no nodes discovered, mark as degraded
if (discoveredNodes.size === 0) {
health.status = 'degraded';
health.message = 'No SPORE nodes discovered yet';
}
// If no client initialized, mark as degraded
if (!sporeClient) {
health.status = 'degraded';
health.message = health.message ?
`${health.message}; SPORE client not initialized` :
'SPORE client not initialized';
}
const statusCode = health.status === 'healthy' ? 200 : 503;
res.status(statusCode).json(health);
});
// Helper function to parse multipart form data
function parseMultipartData(buffer, boundary) {
const data = {};
const boundaryBuffer = Buffer.from(`--${boundary}`);
const endBoundaryBuffer = Buffer.from(`--${boundary}--`);
let start = 0;
let end = 0;
while (true) {
// Find the start of the next part
start = buffer.indexOf(boundaryBuffer, end);
if (start === -1) break;
// Find the end of this part
end = buffer.indexOf(boundaryBuffer, start + boundaryBuffer.length);
if (end === -1) {
end = buffer.indexOf(endBoundaryBuffer, start + boundaryBuffer.length);
if (end === -1) break;
}
// Extract the part content
const partBuffer = buffer.slice(start + boundaryBuffer.length + 2, end);
// Parse headers and content
const headerEnd = partBuffer.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = partBuffer.slice(0, headerEnd).toString();
const content = partBuffer.slice(headerEnd + 4);
// Parse the Content-Disposition header to get field name and filename
const nameMatch = headers.match(/name="([^"]+)"/);
const filenameMatch = headers.match(/filename="([^"]+)"/);
if (nameMatch) {
const fieldName = nameMatch[1];
const filename = filenameMatch ? filenameMatch[1] : null;
data[fieldName] = {
data: content,
filename: filename
};
}
}
return data;
}
// Start the server
const server = app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
console.log(`UDP discovery server listening on port ${UDP_PORT}`);
console.log('Waiting for CLUSTER_DISCOVERY messages from SPORE nodes...');
});
// Graceful shutdown handling
process.on('SIGINT', () => {
console.log('\nReceived SIGINT. Shutting down gracefully...');
udpServer.close(() => {
console.log('UDP server closed.');
});
server.close(() => {
console.log('HTTP server closed.');
process.exit(0);
});
});
process.on('SIGTERM', () => {
console.log('\nReceived SIGTERM. Shutting down gracefully...');
udpServer.close(() => {
console.log('UDP server closed.');
});
server.close(() => {
console.log('HTTP server closed.');
process.exit(0);
});
});
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
udpServer.close();
server.close();
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
udpServer.close();
server.close();
process.exit(1);
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`SPORE UI Frontend Server is running on http://0.0.0.0:${PORT}`);
console.log(`Accessible from: http://YOUR_COMPUTER_IP:${PORT}`);
console.log(`Frontend connects to spore-gateway for API and WebSocket functionality`);
console.log(`Make sure spore-gateway is running on port 3001`);
});

79
package-lock.json generated
View File

@@ -9,7 +9,10 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"express": "^5.1.0"
"cors": "^2.8.5",
"express": "^5.1.0",
"express-fileupload": "^1.4.3",
"ws": "^8.18.3"
}
},
"node_modules/accepts": {
@@ -45,6 +48,17 @@
"node": ">=18"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -122,6 +136,19 @@
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -264,6 +291,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-fileupload": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.5.2.tgz",
"integrity": "sha512-wxUJn2vTHvj/kZCVmc5/bJO15C7aSMyHeuXYY3geKpeKibaAoQGcEv5+sM6nHS2T7VF+QHS4hTWPiY2mKofEdg==",
"license": "MIT",
"dependencies": {
"busboy": "^1.6.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@@ -505,6 +544,15 @@
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -774,6 +822,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -820,6 +876,27 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -1,20 +1,37 @@
{
"name": "spore-ui",
"version": "1.0.0",
"description": "## TODO",
"description": "SPORE Cluster Management UI",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js",
"client-example": "node src/client/example.js",
"test-discovery": "node test-discovery.js",
"demo-discovery": "node demo-discovery.js",
"test-discovery": "node test/test-discovery.js",
"demo-discovery": "node test/demo-discovery.js",
"demo-frontend": "node test/demo-frontend.js",
"test-random-selection": "node test/test-random-selection.js",
"mock": "node test/mock-cli.js",
"mock:start": "node test/mock-cli.js start",
"mock:list": "node test/mock-cli.js list",
"mock:info": "node test/mock-cli.js info",
"mock:healthy": "node test/mock-cli.js start healthy",
"mock:degraded": "node test/mock-cli.js start degraded",
"mock:large": "node test/mock-cli.js start large",
"mock:unstable": "node test/mock-cli.js start unstable",
"mock:single": "node test/mock-cli.js start single",
"mock:empty": "node test/mock-cli.js start empty",
"mock:test": "node test/mock-test.js",
"mock:integration": "node test/test-mock-integration.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.1.0"
"cors": "^2.8.5",
"express": "^5.1.0",
"express-fileupload": "^1.4.3",
"ws": "^8.18.3"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

View File

@@ -1,36 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="styles/main.css">
<link rel="stylesheet" href="styles/theme.css?v=1757159926">
</head>
<body>
<div class="container">
<div class="main-navigation">
<div class="nav-left">
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
</div>
<div class="nav-right">
<div class="cluster-status">🚀 Cluster Online</div>
</div>
<div class="container">
<div class="main-navigation">
<button class="burger-btn" id="burger-btn" aria-label="Menu" title="Menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
<div class="nav-left">
<a href="/cluster" class="nav-tab active" data-view="cluster">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<circle cx="12" cy="12" r="9"/>
<circle cx="8" cy="10" r="1.5"/>
<circle cx="16" cy="8" r="1.5"/>
<circle cx="14" cy="15" r="1.5"/>
<path d="M9 11l3 3M9 11l6-3"/>
</svg>
Cluster
</a>
<a href="/topology" class="nav-tab" data-view="topology">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<circle cx="12" cy="4" r="1.6"/>
<circle cx="19" cy="9" r="1.6"/>
<circle cx="16" cy="18" r="1.6"/>
<circle cx="8" cy="18" r="1.6"/>
<circle cx="5" cy="9" r="1.6"/>
<path d="M12 4L16 18M16 18L5 9M5 9L19 9M19 9L8 18M8 18L12 4"/>
</svg>
Topology
</a>
<a href="/events" class="nav-tab" data-view="events">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<circle cx="5" cy="12" r="1.5" fill="currentColor"/>
<circle cx="18" cy="6" r="1.5" fill="currentColor"/>
<circle cx="18" cy="12" r="1.5" fill="currentColor"/>
<circle cx="18" cy="18" r="1.5" fill="currentColor"/>
<line x1="6.5" y1="12" x2="16.5" y2="6"/>
<line x1="6.5" y1="12" x2="16.5" y2="12"/>
<line x1="6.5" y1="12" x2="16.5" y2="18"/>
</svg>
Events
</a>
<a href="/monitoring" class="nav-tab" data-view="monitoring">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>
</svg>
Monitoring
</a>
<a href="/firmware" class="nav-tab" data-view="firmware">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/>
<path d="M12 8v8"/>
</svg>
Firmware
</a>
</div>
<div id="cluster-view" class="view-content active">
<div class="cluster-section">
<div class="cluster-header">
<div class="cluster-header-left"></div>
<button class="refresh-btn" onclick="refreshClusterMembers()">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
Refresh
</button>
</div>
<div class="nav-right">
<div class="random-primary-switcher">
<button class="random-primary-toggle" id="random-primary-toggle" title="Select random primary node">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
</button>
</div>
<div class="theme-switcher">
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</button>
</div>
<div class="cluster-status">Cluster</div>
</div>
</div>
<div id="cluster-view" class="view-content active">
<div class="cluster-section">
<div class="cluster-header">
<div class="cluster-header-left">
<div class="primary-node-info">
<span class="primary-node-label">API:</span>
<span class="primary-node-ip" id="primary-node-ip">Discovering...</span>
<button class="primary-node-refresh" id="select-random-primary-btn"
title="Select random primary node">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14"
height="14">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
</button>
</div>
<div class="cluster-filters">
<div class="filter-group">
<label for="label-key-filter" class="filter-label">Filter by Label:</label>
<select id="label-key-filter" class="filter-select">
<option value="">All Labels</option>
</select>
<select id="label-value-filter" class="filter-select">
<option value="">All Values</option>
</select>
<div class="filter-pills-container" id="filter-pills-container">
<!-- Active filter pills will be dynamically added here -->
</div>
<button id="clear-filters-btn" class="clear-filters-btn" title="Clear all filters">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</div>
<div class="cluster-header-right">
<button class="config-btn" id="config-wifi-btn" title="Configure WiFi settings for visible nodes">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
Config
</button>
<button class="deploy-btn" id="deploy-firmware-btn" title="Deploy firmware to visible nodes">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<path d="M12 16V4"/>
<path d="M8 8l4-4 4 4"/>
<path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2"/>
</svg>
Deploy
</button>
<button class="refresh-btn" id="refresh-cluster-btn">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
Refresh
</button>
</div>
</div>
<div id="cluster-members-container">
<div class="loading">
<div>Loading cluster members...</div>
@@ -38,74 +156,140 @@
</div>
</div>
</div>
<div id="firmware-view" class="view-content">
<div class="firmware-section">
<div class="firmware-header">
<div class="firmware-header-left"></div>
<button class="refresh-btn" onclick="refreshFirmwareView()">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
Refresh
</button>
</div>
<div id="firmware-container">
<div class="firmware-overview">
<div class="firmware-stats">
<div class="stat-card">
<div class="stat-title">Total Nodes</div>
<div class="stat-value" id="total-nodes">-</div>
</div>
<div class="stat-card">
<div class="stat-title">Available Updates</div>
<div class="stat-value" id="available-updates">-</div>
</div>
<div class="stat-card">
<div class="stat-title">Last Update</div>
<div class="stat-value" id="last-update">-</div>
</div>
<div id="topology-view" class="view-content">
<div id="topology-graph-container">
<div class="loading">
<div>Loading network topology...</div>
</div>
</div>
</div>
<div id="firmware-view" class="view-content">
<div class="firmware-section">
<div class="firmware-header">
<div class="firmware-search">
<div class="search-input-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" class="search-icon">
<circle cx="11" cy="11" r="8"/>
<path d="M21 21l-4.35-4.35"/>
</svg>
<input type="text" id="firmware-search" placeholder="Search firmware by name, version, or labels (e.g., '1.0.0 base')...">
</div>
<div class="firmware-actions">
<div class="action-group">
<h3>📁 Upload New Firmware</h3>
<div class="upload-area-large">
<input type="file" id="global-firmware-file" accept=".bin,.hex" style="display: none;">
<button class="upload-btn-large" onclick="document.getElementById('global-firmware-file').click()">
📁 Choose Firmware File
</button>
<div class="upload-info-large">Select a .bin or .hex file to upload to all nodes</div>
</div>
</div>
<div class="action-group">
<h3>🎯 Target Selection</h3>
<div class="target-selection">
<label>
<input type="radio" name="target-type" value="all" checked> All Nodes
</label>
<label>
<input type="radio" name="target-type" value="specific"> Specific Node
</label>
<select id="specific-node-select" style="display: none;">
<option value="">Select a node...</option>
</select>
</div>
</div>
</div>
<div class="header-actions">
<div id="registry-status" class="registry-status">
<span class="status-indicator disconnected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
Registry Disconnected
</span>
</div>
<button class="refresh-btn" id="refresh-firmware-btn" title="Refresh firmware list">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
<button class="add-btn" id="add-firmware-btn" title="Add new firmware">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add Firmware
</button>
</div>
</div>
<div class="firmware-content">
<div id="firmware-list-container" class="firmware-list-container">
<div class="loading-state">
<div class="loading-spinner"></div>
<div class="loading-text">Loading firmware...</div>
</div>
</div>
</div>
</div>
</div>
<div id="monitoring-view" class="view-content">
<div class="monitoring-view-section">
<div class="monitoring-header">
<h2>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18" style="margin-right:8px; vertical-align: -2px;">
<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>
</svg>
Monitoring
</h2>
<button class="refresh-btn" id="refresh-monitoring-btn">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
Refresh
</button>
</div>
<div class="monitoring-content">
<div class="cluster-summary" id="cluster-summary">
<div class="loading">
<div>Loading cluster resource summary...</div>
</div>
</div>
<div class="firmware-nodes-list" id="firmware-nodes-list">
<!-- Nodes will be populated here -->
<div class="nodes-monitoring" id="nodes-monitoring">
<div class="loading">
<div>Loading node resource data...</div>
</div>
</div>
</div>
</div>
</div>
<div id="events-view" class="view-content">
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
<div id="events-graph-container" style="flex: 1; min-height: 0;">
<div class="loading">
<div>Waiting for websocket events...</div>
</div>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
<script src="./vendor/d3.v7.min.js"></script>
<script src="./scripts/constants.js"></script>
<script src="./scripts/icons.js"></script>
<script src="./scripts/framework.js"></script>
<script src="./scripts/api-client.js"></script>
<script src="./scripts/view-models.js"></script>
<!-- Base/leaf components first -->
<script src="./scripts/components/DrawerComponent.js"></script>
<script src="./scripts/components/TerminalPanelComponent.js"></script>
<script src="./scripts/components/PrimaryNodeComponent.js"></script>
<script src="./scripts/components/NodeDetailsComponent.js"></script>
<script src="./scripts/components/ClusterMembersComponent.js"></script>
<script src="./scripts/components/OverlayDialogComponent.js"></script>
<script src="./scripts/components/FirmwareComponent.js"></script>
<script src="./scripts/components/FirmwareFormComponent.js"></script>
<script src="./scripts/components/FirmwareUploadComponent.js"></script>
<script src="./scripts/components/RolloutComponent.js"></script>
<script src="./scripts/components/WiFiConfigComponent.js"></script>
<!-- Container/view components after their deps -->
<script src="./scripts/components/FirmwareViewComponent.js"></script>
<script src="./scripts/components/ClusterViewComponent.js"></script>
<script src="./scripts/components/ClusterStatusComponent.js"></script>
<script src="./scripts/components/TopologyGraphComponent.js"></script>
<script src="./scripts/components/MonitoringViewComponent.js"></script>
<script src="./scripts/components/EventComponent.js"></script>
<script src="./scripts/components/ComponentsLoader.js"></script>
<script src="./scripts/theme-manager.js"></script>
<script src="./scripts/app.js"></script>
</body>
</html>
</html>

View File

@@ -1,689 +0,0 @@
// Frontend API client - calls our Express backend
class FrontendApiClient {
constructor() {
this.baseUrl = ''; // Same origin as the current page
}
async getClusterMembers() {
try {
const response = await fetch('/api/cluster/members', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getNodeStatus(ip) {
try {
// Create a proxy endpoint that forwards the request to the specific node
const response = await fetch(`/api/node/status/${encodeURIComponent(ip)}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getTasksStatus() {
try {
const response = await fetch('/api/tasks/status', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
}
// Global client instance
const client = new FrontendApiClient();
// Function to refresh cluster members
async function refreshClusterMembers() {
const container = document.getElementById('cluster-members-container');
// Store the currently expanded cards BEFORE showing loading state
const expandedCards = new Map();
const existingCards = container.querySelectorAll('.member-card');
existingCards.forEach(card => {
if (card.classList.contains('expanded')) {
const memberIp = card.dataset.memberIp;
const memberDetails = card.querySelector('.member-details');
if (memberDetails) {
expandedCards.set(memberIp, memberDetails.innerHTML);
console.log(`Storing expanded state for ${memberIp}`);
}
}
});
console.log(`Stored ${expandedCards.size} expanded cards for restoration`);
// Show loading state
container.innerHTML = `
<div class="loading">
<div>Loading cluster members...</div>
</div>
`;
try {
const response = await client.getClusterMembers();
console.log(response);
displayClusterMembers(response.members, expandedCards);
} catch (error) {
console.error('Failed to fetch cluster members:', error);
container.innerHTML = `
<div class="error">
<strong>Error loading cluster members:</strong><br>
${error.message}
</div>
`;
}
}
// Function to load detailed node information
async function loadNodeDetails(card, memberIp) {
console.log('Loading node details for IP:', memberIp);
const memberDetails = card.querySelector('.member-details');
console.log('Member details element:', memberDetails);
try {
console.log('Fetching node status...');
const nodeStatus = await client.getNodeStatus(memberIp);
console.log('Node status received:', nodeStatus);
displayNodeDetails(memberDetails, nodeStatus);
} catch (error) {
console.error('Failed to load node details:', error);
memberDetails.innerHTML = `
<div class="error">
<strong>Error loading node details:</strong><br>
${error.message}
</div>
`;
}
}
// Function to display node details
function displayNodeDetails(container, nodeStatus) {
console.log('Displaying node details in container:', container);
console.log('Node status data:', nodeStatus);
container.innerHTML = `
<div class="tabs-container">
<div class="tabs-header">
<button class="tab-button active" data-tab="status">Status</button>
<button class="tab-button" data-tab="endpoints">Endpoints</button>
<button class="tab-button" data-tab="tasks">Tasks</button>
<button class="tab-button" data-tab="firmware">Firmware</button>
</div>
<div class="tab-content active" id="status-tab">
<div class="detail-row">
<span class="detail-label">Free Heap:</span>
<span class="detail-value">${Math.round(nodeStatus.freeHeap / 1024)}KB</span>
</div>
<div class="detail-row">
<span class="detail-label">Chip ID:</span>
<span class="detail-value">${nodeStatus.chipId}</span>
</div>
<div class="detail-row">
<span class="detail-label">SDK Version:</span>
<span class="detail-value">${nodeStatus.sdkVersion}</span>
</div>
<div class="detail-row">
<span class="detail-label">CPU Frequency:</span>
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
</div>
<div class="detail-row">
<span class="detail-label">Flash Size:</span>
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
</div>
</div>
<div class="tab-content" id="endpoints-tab">
<h4>Available API Endpoints:</h4>
${nodeStatus.api ? nodeStatus.api.map(endpoint =>
`<div class="endpoint-item">${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}</div>`
).join('') : '<div class="endpoint-item">No API endpoints available</div>'}
</div>
<div class="tab-content" id="tasks-tab">
<div class="loading-tasks">Loading tasks...</div>
</div>
<div class="tab-content" id="firmware-tab">
<div class="firmware-upload">
<h4>Firmware Update</h4>
<div class="upload-area">
<input type="file" id="firmware-file" accept=".bin,.hex" style="display: none;">
<button class="upload-btn" data-action="select-file">
📁 Choose Firmware File
</button>
<div class="upload-info">Select a .bin or .hex file to upload</div>
<div id="upload-status" style="display: none;"></div>
</div>
</div>
</div>
</div>
`;
// Set up tab switching
setupTabs(container);
// Load tasks data for the tasks tab
loadTasksData(container, nodeStatus);
console.log('Node details HTML set successfully');
}
// Function to set up tab switching
function setupTabs(container) {
const tabButtons = container.querySelectorAll('.tab-button');
const tabContents = container.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', (e) => {
// Prevent the click event from bubbling up to the card
e.stopPropagation();
const targetTab = button.dataset.tab;
// Remove active class from all buttons and contents
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// Add active class to clicked button and corresponding content
button.classList.add('active');
const targetContent = container.querySelector(`#${targetTab}-tab`);
if (targetContent) {
targetContent.classList.add('active');
}
});
});
// Also prevent event propagation on tab content areas
tabContents.forEach(content => {
content.addEventListener('click', (e) => {
e.stopPropagation();
});
});
// Set up firmware upload button
const uploadBtn = container.querySelector('.upload-btn[data-action="select-file"]');
if (uploadBtn) {
uploadBtn.addEventListener('click', (e) => {
e.stopPropagation();
const fileInput = container.querySelector('#firmware-file');
if (fileInput) {
fileInput.click();
}
});
// Set up file input change handler
const fileInput = container.querySelector('#firmware-file');
if (fileInput) {
fileInput.addEventListener('change', async (e) => {
e.stopPropagation();
const file = e.target.files[0];
if (file) {
await uploadFirmware(file, container);
}
});
}
}
}
// Function to load tasks data
async function loadTasksData(container, nodeStatus) {
const tasksTab = container.querySelector('#tasks-tab');
if (!tasksTab) return;
try {
const response = await client.getTasksStatus();
console.log('Tasks data received:', response);
if (response && response.length > 0) {
const tasksHTML = response.map(task => `
<div class="task-item">
<div class="task-header">
<span class="task-name">${task.name || 'Unknown Task'}</span>
<span class="task-status ${task.running ? 'running' : 'stopped'}">
${task.running ? '🟢 Running' : '🔴 Stopped'}
</span>
</div>
<div class="task-details">
<span class="task-interval">Interval: ${task.interval}ms</span>
<span class="task-enabled">${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}</span>
</div>
</div>
`).join('');
tasksTab.innerHTML = `
<h4>Active Tasks</h4>
${tasksHTML}
`;
} else {
tasksTab.innerHTML = `
<div class="no-tasks">
<div>📋 No active tasks found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
This node has no running tasks
</div>
</div>
`;
}
} catch (error) {
console.error('Failed to load tasks:', error);
tasksTab.innerHTML = `
<div class="error">
<strong>Error loading tasks:</strong><br>
${error.message}
</div>
`;
}
}
// Function to upload firmware
async function uploadFirmware(file, container) {
const uploadStatus = container.querySelector('#upload-status');
const uploadBtn = container.querySelector('.upload-btn');
const originalText = uploadBtn.textContent;
try {
// Show upload status
uploadStatus.style.display = 'block';
uploadStatus.innerHTML = `
<div class="upload-progress">
<div>📤 Uploading ${file.name}...</div>
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
// Disable upload button
uploadBtn.disabled = true;
uploadBtn.textContent = '⏳ Uploading...';
// Get the member IP from the card
const memberCard = container.closest('.member-card');
const memberIp = memberCard.dataset.memberIp;
if (!memberIp) {
throw new Error('Could not determine target node IP address');
}
// Create FormData for multipart upload
const formData = new FormData();
formData.append('file', file);
// Upload to backend
const response = await fetch('/api/node/update?ip=' + encodeURIComponent(memberIp), {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
// Show success
uploadStatus.innerHTML = `
<div class="upload-success">
<div>✅ Firmware uploaded successfully!</div>
<div style="font-size: 0.8rem; opacity: 0.7;">Node: ${memberIp}</div>
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
</div>
`;
console.log('Firmware upload successful:', result);
} catch (error) {
console.error('Firmware upload failed:', error);
// Show error
uploadStatus.innerHTML = `
<div class="upload-error">
<div>❌ Upload failed: ${error.message}</div>
</div>
`;
} finally {
// Re-enable upload button
uploadBtn.disabled = false;
uploadBtn.textContent = originalText;
// Clear file input
const fileInput = container.querySelector('#firmware-file');
if (fileInput) {
fileInput.value = '';
}
}
}
// Function to display cluster members
function displayClusterMembers(members, expandedCards = new Map()) {
const container = document.getElementById('cluster-members-container');
if (!members || members.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🌐</div>
<div>No cluster members found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
The cluster might be empty or not yet discovered
</div>
</div>
`;
return;
}
const membersHTML = members.map(member => {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' : 'Offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
return `
<div class="member-card" data-member-ip="${member.ip}">
<div class="member-header">
<div class="member-info">
<div class="member-name">${member.hostname || 'Unknown Device'}</div>
<div class="member-ip">${member.ip || 'No IP'}</div>
<div class="member-status ${statusClass}">
${statusIcon} ${statusText}
</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
</div>
<div class="expand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
`;
}).join('');
container.innerHTML = membersHTML;
// Add event listeners for expand/collapse
console.log('Setting up event listeners for', members.length, 'member cards');
// Small delay to ensure DOM is ready
setTimeout(() => {
document.querySelectorAll('.member-card').forEach((card, index) => {
const expandIcon = card.querySelector('.expand-icon');
const memberDetails = card.querySelector('.member-details');
const memberIp = card.dataset.memberIp;
console.log(`Setting up card ${index} with IP: ${memberIp}`);
// Restore expanded state if this card was expanded before refresh
if (expandedCards.has(memberIp)) {
console.log(`Restoring expanded state for ${memberIp}`);
const restoredContent = expandedCards.get(memberIp);
console.log(`Restored content length: ${restoredContent.length} characters`);
memberDetails.innerHTML = restoredContent;
card.classList.add('expanded');
expandIcon.classList.add('expanded');
// Re-setup tabs for restored content
setupTabs(memberDetails);
console.log(`Successfully restored expanded state for ${memberIp}`);
} else {
console.log(`No expanded state to restore for ${memberIp}`);
}
// Make the entire card clickable
card.addEventListener('click', async (e) => {
// Don't trigger if clicking on the expand icon (to avoid double-triggering)
if (e.target === expandIcon) {
return;
}
console.log('Card clicked for IP:', memberIp);
const isExpanding = !card.classList.contains('expanded');
console.log('Is expanding:', isExpanding);
if (isExpanding) {
// Expanding - fetch detailed status
console.log('Starting to expand...');
await loadNodeDetails(card, memberIp);
card.classList.add('expanded');
expandIcon.classList.add('expanded');
console.log('Card expanded successfully');
} else {
// Collapsing
console.log('Collapsing...');
card.classList.remove('expanded');
expandIcon.classList.remove('expanded');
console.log('Card collapsed successfully');
}
});
// Keep the expand icon click handler for visual feedback
if (expandIcon) {
expandIcon.addEventListener('click', async (e) => {
e.stopPropagation();
console.log('Expand icon clicked for IP:', memberIp);
const isExpanding = !card.classList.contains('expanded');
console.log('Is expanding:', isExpanding);
if (isExpanding) {
// Expanding - fetch detailed status
console.log('Starting to expand...');
await loadNodeDetails(card, memberIp);
card.classList.add('expanded');
expandIcon.classList.add('expanded');
console.log('Card expanded successfully');
} else {
// Collapsing
console.log('Collapsing...');
card.classList.remove('expanded');
expandIcon.classList.remove('expanded');
console.log('Card collapsed successfully');
}
});
console.log(`Event listener added for expand icon on card ${index}`);
} else {
console.error(`No expand icon found for card ${index}`);
}
console.log(`Event listener added for card ${index}`);
});
}, 100);
}
// Load cluster members when page loads
document.addEventListener('DOMContentLoaded', function() {
refreshClusterMembers();
setupNavigation();
setupFirmwareView();
});
// Auto-refresh every 30 seconds
// FIXME not working properly: scroll position is not preserved, if there is an upload happening, this mus also be handled
//setInterval(refreshClusterMembers, 30000);
// Setup navigation menu
function setupNavigation() {
const navTabs = document.querySelectorAll('.nav-tab');
const viewContents = document.querySelectorAll('.view-content');
navTabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetView = tab.dataset.view;
// Update active tab
navTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Update active view
viewContents.forEach(view => view.classList.remove('active'));
const targetViewElement = document.getElementById(`${targetView}-view`);
if (targetViewElement) {
targetViewElement.classList.add('active');
}
// Refresh the active view
if (targetView === 'cluster') {
refreshClusterMembers();
} else if (targetView === 'firmware') {
refreshFirmwareView();
}
});
});
}
// Setup firmware view
function setupFirmwareView() {
// Setup global firmware file input
const globalFirmwareFile = document.getElementById('global-firmware-file');
if (globalFirmwareFile) {
globalFirmwareFile.addEventListener('change', handleGlobalFirmwareUpload);
}
// Setup target selection
const targetRadios = document.querySelectorAll('input[name="target-type"]');
const specificNodeSelect = document.getElementById('specific-node-select');
targetRadios.forEach(radio => {
radio.addEventListener('change', () => {
if (radio.value === 'specific') {
specificNodeSelect.style.display = 'block';
populateNodeSelect();
} else {
specificNodeSelect.style.display = 'none';
}
});
});
}
// Handle global firmware upload
async function handleGlobalFirmwareUpload(event) {
const file = event.target.files[0];
if (!file) return;
const targetType = document.querySelector('input[name="target-type"]:checked').value;
const specificNode = document.getElementById('specific-node-select').value;
if (targetType === 'specific' && !specificNode) {
alert('Please select a specific node to update.');
return;
}
try {
if (targetType === 'all') {
await uploadFirmwareToAllNodes(file);
} else {
await uploadFirmwareToSpecificNode(file, specificNode);
}
} catch (error) {
console.error('Global firmware upload failed:', error);
alert(`Upload failed: ${error.message}`);
}
// Clear file input
event.target.value = '';
}
// Upload firmware to all nodes
async function uploadFirmwareToAllNodes(file) {
const response = await client.getClusterMembers();
const nodes = response.members || [];
if (nodes.length === 0) {
alert('No nodes available for firmware update.');
return;
}
const confirmed = confirm(`Upload firmware to all ${nodes.length} nodes? This will update: ${nodes.map(n => n.hostname || n.ip).join(', ')}`);
if (!confirmed) return;
// TODO: Implement batch upload logic
alert(`Firmware upload to all ${nodes.length} nodes initiated. This feature is coming soon!`);
}
// Upload firmware to specific node
async function uploadFirmwareToSpecificNode(file, nodeIp) {
const confirmed = confirm(`Upload firmware to node ${nodeIp}?`);
if (!confirmed) return;
// TODO: Implement single node upload logic
alert(`Firmware upload to node ${nodeIp} initiated. This feature is coming soon!`);
}
// Populate node select dropdown
function populateNodeSelect() {
const select = document.getElementById('specific-node-select');
if (!select) return;
// Clear existing options
select.innerHTML = '<option value="">Select a node...</option>';
// Get current cluster members and populate
const container = document.getElementById('cluster-members-container');
const memberCards = container.querySelectorAll('.member-card');
memberCards.forEach(card => {
const memberIp = card.dataset.memberIp;
const hostname = card.querySelector('.member-name')?.textContent || memberIp;
const option = document.createElement('option');
option.value = memberIp;
option.textContent = `${hostname} (${memberIp})`;
select.appendChild(option);
});
}
// Refresh firmware view
function refreshFirmwareView() {
updateFirmwareStats();
populateNodeSelect();
}
// Update firmware statistics
function updateFirmwareStats() {
const container = document.getElementById('cluster-members-container');
const memberCards = container.querySelectorAll('.member-card');
document.getElementById('total-nodes').textContent = memberCards.length;
document.getElementById('available-updates').textContent = '0'; // TODO: Implement update checking
document.getElementById('last-update').textContent = 'Never'; // TODO: Implement last update tracking
}

View File

@@ -0,0 +1,381 @@
// API Client for communicating with the backend
class ApiClient {
constructor() {
// Auto-detect server URL based on current location
const currentHost = window.location.hostname;
const currentPort = window.location.port;
// If accessing from localhost, use localhost:3001
// If accessing from another device, use the same hostname but port 3001
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
this.baseUrl = 'http://localhost:3001';
} else {
// Use the same hostname but port 3001
this.baseUrl = `http://${currentHost}:3001`;
}
logger.debug('API Client initialized with base URL:', this.baseUrl);
}
async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) {
const url = new URL(`${this.baseUrl}${path}`);
if (query && typeof query === 'object') {
Object.entries(query).forEach(([k, v]) => {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
});
}
const finalHeaders = { 'Accept': 'application/json', ...headers };
const options = { method, headers: finalHeaders };
if (body !== undefined) {
if (isForm) {
options.body = body;
} else {
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
options.body = typeof body === 'string' ? body : JSON.stringify(body);
}
}
const response = await fetch(url.toString(), options);
let data;
const text = await response.text();
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text; // Non-JSON payload
}
if (!response.ok) {
const message = (data && data.message) || `HTTP ${response.status}: ${response.statusText}`;
throw new Error(message);
}
return data;
}
async getClusterMembers() {
return this.request('/api/cluster/members', { method: 'GET' });
}
async getClusterMembersFromNode(ip) {
return this.request(`/api/cluster/members`, {
method: 'GET',
query: { ip: ip }
});
}
async getDiscoveryInfo() {
return this.request('/api/discovery/nodes', { method: 'GET' });
}
async selectRandomPrimaryNode() {
return this.request('/api/discovery/random-primary', {
method: 'POST',
body: { timestamp: new Date().toISOString() }
});
}
async setPrimaryNode(ip) {
return this.request(`/api/discovery/primary/${encodeURIComponent(ip)}`, {
method: 'POST',
body: { timestamp: new Date().toISOString() }
});
}
async getNodeStatus(ip) {
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
}
async getTasksStatus(ip) {
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
}
async getEndpoints(ip) {
return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined });
}
async callEndpoint({ ip, method, uri, params }) {
return this.request('/api/proxy-call', {
method: 'POST',
body: { ip, method, uri, params }
});
}
async uploadFirmware(file, nodeIp) {
const formData = new FormData();
formData.append('file', file);
const data = await this.request(`/api/node/update`, {
method: 'POST',
query: { ip: nodeIp },
body: formData,
isForm: true,
headers: {},
});
// Some endpoints may return HTTP 200 with success=false on logical failure
if (data && data.success === false) {
const message = data.message || 'Firmware upload failed';
throw new Error(message);
}
return data;
}
async getMonitoringResources(ip) {
return this.request('/api/proxy-call', {
method: 'POST',
body: {
ip: ip,
method: 'GET',
uri: '/api/monitoring/resources',
params: []
}
});
}
async getNodeLabels(ip) {
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
}
async setNodeLabels(ip, labels) {
return this.request('/api/proxy-call', {
method: 'POST',
body: {
ip: ip,
method: 'POST',
uri: '/api/node/config',
params: [{ name: 'labels', value: JSON.stringify(labels) }]
}
});
}
// Registry API methods - now proxied through gateway
async getRegistryHealth() {
return this.request('/api/registry/health', { method: 'GET' });
}
async uploadFirmwareToRegistry(metadata, firmwareFile) {
const formData = new FormData();
formData.append('metadata', JSON.stringify(metadata));
formData.append('firmware', firmwareFile);
return this.request('/api/registry/firmware', {
method: 'POST',
body: formData,
isForm: true,
headers: {}
});
}
async updateFirmwareMetadata(name, version, metadata) {
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
method: 'PUT',
body: metadata
});
}
async listFirmwareFromRegistry(name = null, version = null) {
const query = {};
if (name) query.name = name;
if (version) query.version = version;
const queryString = Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : '';
return this.request(`/api/registry/firmware${queryString}`, { method: 'GET' });
}
async downloadFirmwareFromRegistry(name, version) {
const response = await fetch(`${this.baseURL}/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Registry download failed: ${errorText}`);
}
return response.blob();
}
async deleteFirmwareFromRegistry(name, version) {
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
method: 'DELETE'
});
}
// Rollout API methods
async getClusterNodeVersions() {
return this.request('/api/cluster/node/versions', { method: 'GET' });
}
async startRollout(rolloutData) {
return this.request('/api/rollout', {
method: 'POST',
body: rolloutData
});
}
}
// Global API client instance
window.apiClient = new ApiClient();
// WebSocket Client for real-time updates
class WebSocketClient {
constructor() {
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000; // Start with 1 second
this.listeners = new Map();
this.isConnected = false;
// Auto-detect WebSocket URL based on current location
const currentHost = window.location.hostname;
const currentPort = window.location.port;
// Use ws:// for HTTP and wss:// for HTTPS
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
this.wsUrl = `${wsProtocol}//localhost:3001/ws`;
} else {
this.wsUrl = `${wsProtocol}//${currentHost}:3001/ws`;
}
logger.debug('WebSocket Client initialized with URL:', this.wsUrl);
this.connect();
}
connect() {
try {
this.ws = new WebSocket(this.wsUrl);
this.setupEventListeners();
} catch (error) {
logger.error('Failed to create WebSocket connection:', error);
this.scheduleReconnect();
}
}
setupEventListeners() {
this.ws.onopen = () => {
logger.debug('WebSocket connected');
this.isConnected = true;
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
// Notify listeners of connection
this.emit('connected');
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
logger.debug('WebSocket message received:', data);
const messageTopic = data.topic || data.type;
logger.debug('WebSocket message topic:', messageTopic);
this.emit('message', data);
this.handleMessage(data);
} catch (error) {
logger.error('Failed to parse WebSocket message:', error);
}
};
this.ws.onclose = (event) => {
logger.debug('WebSocket disconnected:', event.code, event.reason);
this.isConnected = false;
this.emit('disconnected');
if (event.code !== 1000) { // Not a normal closure
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
logger.error('WebSocket error:', error);
this.emit('error', error);
};
}
handleMessage(data) {
const messageTopic = data.topic || data.type;
// Handler map for different WebSocket message types
const handlers = {
'cluster/update': (data) => this.emit('clusterUpdate', data),
'node/discovery': (data) => this.emit('nodeDiscovery', data),
'firmware/upload/status': (data) => this.emit('firmwareUploadStatus', data),
'rollout/progress': (data) => this.emit('rolloutProgress', data)
};
const handler = handlers[messageTopic];
if (handler) {
handler(data);
} else {
logger.debug('Unknown WebSocket message topic:', messageTopic);
}
}
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
logger.error('Max reconnection attempts reached');
this.emit('maxReconnectAttemptsReached');
return;
}
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff
logger.debug(`Scheduling WebSocket reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
setTimeout(() => {
this.connect();
}, delay);
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
}
off(event, callback) {
if (this.listeners.has(event)) {
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
emit(event, ...args) {
if (this.listeners.has(event)) {
this.listeners.get(event).forEach(callback => {
try {
callback(...args);
} catch (error) {
logger.error('Error in WebSocket event listener:', error);
}
});
}
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
logger.warn('WebSocket not connected, cannot send data');
}
}
disconnect() {
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
}
}
getConnectionStatus() {
return {
connected: this.isConnected,
reconnectAttempts: this.reconnectAttempts,
maxReconnectAttempts: this.maxReconnectAttempts,
url: this.wsUrl
};
}
}
// Global WebSocket client instance
window.wsClient = new WebSocketClient();

224
public/scripts/app.js Normal file
View File

@@ -0,0 +1,224 @@
// Main SPORE UI Application
// Initialize the application when DOM is loaded
document.addEventListener('DOMContentLoaded', async function() {
logger.debug('=== SPORE UI Application Initialization ===');
// Initialize the framework (but don't navigate yet)
logger.debug('App: Creating framework instance...');
const app = window.app;
// Components are loaded via script tags in order; no blocking wait required
// Create view models
logger.debug('App: Creating view models...');
const clusterViewModel = new ClusterViewModel();
const firmwareViewModel = new FirmwareViewModel();
const clusterFirmwareViewModel = new ClusterFirmwareViewModel();
const topologyViewModel = new TopologyViewModel();
const monitoringViewModel = new MonitoringViewModel();
const eventsViewModel = new EventViewModel();
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel, eventsViewModel });
// Connect firmware view model to cluster data
clusterViewModel.subscribe('members', (members) => {
logger.debug('App: Members subscription triggered:', members);
if (members && members.length > 0) {
// Extract node information for firmware view
const nodes = members.map(member => ({
ip: member.ip,
hostname: member.hostname || member.ip,
labels: member.labels || {}
}));
firmwareViewModel.updateAvailableNodes(nodes);
logger.debug('App: Updated firmware view model with nodes:', nodes);
} else {
firmwareViewModel.updateAvailableNodes([]);
logger.debug('App: Cleared firmware view model nodes');
}
});
// Connect cluster firmware view model to cluster data
// Note: This subscription is disabled because target nodes should be set explicitly
// when opening the firmware deploy drawer, not automatically updated
/*
clusterViewModel.subscribe('members', (members) => {
logger.debug('App: Members subscription triggered for cluster firmware:', members);
if (members && members.length > 0) {
// Extract node information for cluster firmware view
const nodes = members.map(member => ({
ip: member.ip,
hostname: member.hostname || member.ip,
labels: member.labels || {}
}));
clusterFirmwareViewModel.setTargetNodes(nodes);
logger.debug('App: Updated cluster firmware view model with nodes:', nodes);
} else {
clusterFirmwareViewModel.setTargetNodes([]);
logger.debug('App: Cleared cluster firmware view model nodes');
}
});
*/
// Register routes with their view models
logger.debug('App: Registering routes...');
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel);
app.registerRoute('events', EventComponent, 'events-view', eventsViewModel);
logger.debug('App: Routes registered and components pre-initialized');
// Initialize cluster status component for header badge
logger.debug('App: Initializing cluster status component...');
const clusterStatusComponent = new ClusterStatusComponent(
document.querySelector('.cluster-status'),
clusterViewModel,
app.eventBus
);
clusterStatusComponent.mount();
logger.debug('App: Cluster status component initialized');
// Set up random primary node button
logger.debug('App: Setting up random primary node button...');
const randomPrimaryBtn = document.getElementById('random-primary-toggle');
if (randomPrimaryBtn) {
randomPrimaryBtn.addEventListener('click', async function() {
try {
// Add spinning animation
randomPrimaryBtn.classList.add('spinning');
randomPrimaryBtn.disabled = true;
logger.debug('App: Selecting random primary node...');
await clusterViewModel.selectRandomPrimaryNode();
// Show success state briefly
logger.info('App: Random primary node selected successfully');
// Refresh topology to show new primary node connections
// Wait a bit for the backend to update, then refresh topology
setTimeout(async () => {
logger.debug('App: Refreshing topology after primary node change...');
try {
await topologyViewModel.updateNetworkTopology();
logger.debug('App: Topology refreshed successfully');
} catch (error) {
logger.error('App: Failed to refresh topology:', error);
}
}, 1000);
// Also refresh cluster view to update member list with new primary
setTimeout(async () => {
logger.debug('App: Refreshing cluster view after primary node change...');
try {
if (clusterViewModel.updateClusterMembers) {
await clusterViewModel.updateClusterMembers();
}
logger.debug('App: Cluster view refreshed successfully');
} catch (error) {
logger.error('App: Failed to refresh cluster view:', error);
}
}, 1000);
// Remove spinning animation after delay
setTimeout(() => {
randomPrimaryBtn.classList.remove('spinning');
randomPrimaryBtn.disabled = false;
}, 1500);
} catch (error) {
logger.error('App: Failed to select random primary node:', error);
randomPrimaryBtn.classList.remove('spinning');
randomPrimaryBtn.disabled = false;
// Show error notification (could be enhanced with a toast notification)
alert('Failed to select random primary node: ' + error.message);
}
});
logger.debug('App: Random primary node button configured');
}
// Set up navigation event listeners
logger.debug('App: Setting up navigation...');
app.setupNavigation();
// Now navigate to the default route
logger.debug('App: Navigating to default route...');
app.navigateTo('cluster');
logger.debug('=== SPORE UI Application initialization completed ===');
});
// Burger menu toggle for mobile
(function setupBurgerMenu(){
document.addEventListener('DOMContentLoaded', function(){
const nav = document.querySelector('.main-navigation');
const burger = document.getElementById('burger-btn');
const navLeft = nav ? nav.querySelector('.nav-left') : null;
if (!nav || !burger || !navLeft) return;
burger.addEventListener('click', function(e){
e.preventDefault();
nav.classList.toggle('mobile-open');
});
// Close menu when a nav tab is clicked
navLeft.addEventListener('click', function(e){
const btn = e.target.closest('.nav-tab');
if (btn && nav.classList.contains('mobile-open')) {
nav.classList.remove('mobile-open');
}
});
// Close menu on outside click
document.addEventListener('click', function(e){
if (!nav.contains(e.target) && nav.classList.contains('mobile-open')) {
nav.classList.remove('mobile-open');
}
});
});
})();
// Set up periodic updates
function setupPeriodicUpdates() {
// Auto-refresh cluster members every 30 seconds using smart update
setInterval(() => {
if (window.app.currentView && window.app.currentView.viewModel) {
const viewModel = window.app.currentView.viewModel;
// Use smart update if available, otherwise fall back to regular update
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
logger.debug('App: Performing smart update...');
viewModel.smartUpdate();
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
logger.debug('App: Performing regular update...');
viewModel.updateClusterMembers();
}
}
}, 30000);
// Update primary node display every 10 seconds (this is lightweight and doesn't affect UI state)
setInterval(() => {
if (window.app.currentView && window.app.currentView.viewModel) {
const viewModel = window.app.currentView.viewModel;
if (viewModel.updatePrimaryNodeDisplay && typeof viewModel.updatePrimaryNodeDisplay === 'function') {
viewModel.updatePrimaryNodeDisplay();
}
}
}, 10000);
}
// Global error handler
window.addEventListener('error', function(event) {
logger.error('Global error:', event.error);
});
// Global unhandled promise rejection handler
window.addEventListener('unhandledrejection', function(event) {
logger.error('Unhandled promise rejection:', event.reason);
});
// Clean up on page unload
window.addEventListener('beforeunload', function() {
if (window.app) {
logger.debug('App: Cleaning up cached components...');
window.app.cleanup();
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
// Cluster Status Component for header badge
class ClusterStatusComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.wsConnected = false;
this.wsReconnectAttempts = 0;
}
setupViewModelListeners() {
// Subscribe to properties that affect cluster status
this.subscribeToProperty('totalNodes', this.render.bind(this));
this.subscribeToProperty('clientInitialized', this.render.bind(this));
this.subscribeToProperty('error', this.render.bind(this));
// Set up WebSocket status listeners
this.setupWebSocketListeners();
}
setupWebSocketListeners() {
if (!window.wsClient) return;
window.wsClient.on('connected', () => {
this.wsConnected = true;
this.wsReconnectAttempts = 0;
this.render();
});
window.wsClient.on('disconnected', () => {
this.wsConnected = false;
this.render();
});
window.wsClient.on('maxReconnectAttemptsReached', () => {
this.wsConnected = false;
this.wsReconnectAttempts = window.wsClient ? window.wsClient.reconnectAttempts : 0;
this.render();
});
// Initialize current WebSocket status
if (window.wsClient) {
const status = window.wsClient.getConnectionStatus();
this.wsConnected = status.connected;
this.wsReconnectAttempts = status.reconnectAttempts;
}
}
render() {
const totalNodes = this.viewModel.get('totalNodes');
const clientInitialized = this.viewModel.get('clientInitialized');
const error = this.viewModel.get('error');
let statusText, statusIcon, statusClass;
let wsStatusText = '';
let wsStatusIcon = '';
// Determine WebSocket status
if (this.wsConnected) {
wsStatusIcon = window.icon('dotGreen', { width: 10, height: 10 });
wsStatusText = 'WS';
} else if (this.wsReconnectAttempts > 0) {
wsStatusIcon = window.icon('dotYellow', { width: 10, height: 10 });
wsStatusText = 'WS Reconnecting';
} else {
wsStatusIcon = window.icon('dotRed', { width: 10, height: 10 });
wsStatusText = 'WS Offline';
}
if (error) {
statusText = 'Cluster Error';
statusIcon = window.icon('error', { width: 12, height: 12 });
statusClass = 'cluster-status-error';
} else if (totalNodes === 0) {
statusText = 'Cluster Offline';
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
statusClass = 'cluster-status-offline';
} else if (clientInitialized) {
statusText = 'Cluster';
statusIcon = window.icon('dotGreen', { width: 12, height: 12 });
statusClass = 'cluster-status-online';
} else {
statusText = 'Cluster Connecting';
statusIcon = window.icon('dotYellow', { width: 12, height: 12 });
statusClass = 'cluster-status-connecting';
}
// Update the cluster status badge using the container passed to this component
if (this.container) {
// Create HTML with both cluster and WebSocket status on a single compact line
this.container.innerHTML = `
<div class="cluster-status-compact">
<span class="cluster-status-main">${statusIcon} ${statusText}</span>
<span class="websocket-status" title="WebSocket Connection: ${wsStatusText}">${wsStatusIcon} ${wsStatusText}</span>
</div>
`;
// Remove all existing status classes
this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error');
// Add the appropriate status class
this.container.classList.add(statusClass);
// Add WebSocket connection class
this.container.classList.remove('ws-connected', 'ws-disconnected', 'ws-reconnecting');
if (this.wsConnected) {
this.container.classList.add('ws-connected');
} else if (this.wsReconnectAttempts > 0) {
this.container.classList.add('ws-reconnecting');
} else {
this.container.classList.add('ws-disconnected');
}
}
}
}

View File

@@ -0,0 +1,495 @@
// Cluster View Component
class ClusterViewComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('ClusterViewComponent: Constructor called');
logger.debug('ClusterViewComponent: Container:', container);
logger.debug('ClusterViewComponent: Container ID:', container?.id);
// Find elements for sub-components
const primaryNodeContainer = this.findElement('.primary-node-info');
const clusterMembersContainer = this.findElement('#cluster-members-container');
logger.debug('ClusterViewComponent: Primary node container:', primaryNodeContainer);
logger.debug('ClusterViewComponent: Cluster members container:', clusterMembersContainer);
logger.debug('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id);
logger.debug('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML);
// Create sub-components
this.primaryNodeComponent = new PrimaryNodeComponent(
primaryNodeContainer,
viewModel,
eventBus
);
this.clusterMembersComponent = new ClusterMembersComponent(
clusterMembersContainer,
viewModel,
eventBus
);
logger.debug('ClusterViewComponent: Sub-components created');
// Track if we've already loaded data to prevent unnecessary reloads
this.dataLoaded = false;
// Initialize overlay dialog
this.overlayDialog = null;
}
mount() {
logger.debug('ClusterViewComponent: Mounting...');
super.mount();
logger.debug('ClusterViewComponent: Mounting sub-components...');
// Mount sub-components
this.primaryNodeComponent.mount();
this.clusterMembersComponent.mount();
// Set up refresh button event listener (since it's in the cluster header, not in the members container)
this.setupRefreshButton();
// Set up deploy button event listener
this.setupDeployButton();
// Set up config button event listener
this.setupConfigButton();
// Initialize overlay dialog
this.initializeOverlayDialog();
// Only load data if we haven't already or if the view model is empty
const members = this.viewModel.get('members');
const shouldLoadData = true; // always perform initial refresh quickly
if (shouldLoadData) {
logger.debug('ClusterViewComponent: Starting initial data load...');
// Initial data load - ensure it happens after mounting
// Trigger immediately to reduce perceived startup latency
this.viewModel.updateClusterMembers().then(() => {
this.dataLoaded = true;
}).catch(error => {
logger.error('ClusterViewComponent: Failed to load initial data:', error);
});
} else {
logger.debug('ClusterViewComponent: Data already loaded, skipping initial load');
}
// Set up periodic updates
// this.setupPeriodicUpdates(); // Disabled automatic refresh
logger.debug('ClusterViewComponent: Mounted successfully');
}
setupRefreshButton() {
logger.debug('ClusterViewComponent: Setting up refresh button...');
const refreshBtn = this.findElement('.refresh-btn');
logger.debug('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn);
if (refreshBtn) {
logger.debug('ClusterViewComponent: Adding click event listener to refresh button');
this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this));
logger.debug('ClusterViewComponent: Event listener added successfully');
} else {
logger.error('ClusterViewComponent: Refresh button not found!');
logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML);
logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
}
}
setupDeployButton() {
logger.debug('ClusterViewComponent: Setting up deploy button...');
const deployBtn = this.findElement('#deploy-firmware-btn');
logger.debug('ClusterViewComponent: Found deploy button:', !!deployBtn, deployBtn);
if (deployBtn) {
logger.debug('ClusterViewComponent: Adding click event listener to deploy button');
this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this));
logger.debug('ClusterViewComponent: Event listener added successfully');
} else {
logger.error('ClusterViewComponent: Deploy button not found!');
logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML);
logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
}
}
setupConfigButton() {
logger.debug('ClusterViewComponent: Setting up config button...');
const configBtn = this.findElement('#config-wifi-btn');
logger.debug('ClusterViewComponent: Found config button:', !!configBtn, configBtn);
if (configBtn) {
logger.debug('ClusterViewComponent: Adding click event listener to config button');
this.addEventListener(configBtn, 'click', this.handleConfig.bind(this));
logger.debug('ClusterViewComponent: Event listener added successfully');
} else {
logger.error('ClusterViewComponent: Config button not found!');
logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML);
logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
}
}
initializeOverlayDialog() {
// Create overlay container if it doesn't exist
let overlayContainer = document.getElementById('cluster-overlay-dialog');
if (!overlayContainer) {
overlayContainer = document.createElement('div');
overlayContainer.id = 'cluster-overlay-dialog';
overlayContainer.className = 'overlay-dialog';
document.body.appendChild(overlayContainer);
}
// Create and initialize the overlay dialog component
if (!this.overlayDialog) {
const overlayVM = new ViewModel();
this.overlayDialog = new OverlayDialogComponent(overlayContainer, overlayVM, this.eventBus);
this.overlayDialog.mount();
}
}
showConfirmationDialog(options) {
if (!this.overlayDialog) {
this.initializeOverlayDialog();
}
this.overlayDialog.show(options);
}
async handleDeploy() {
logger.debug('ClusterViewComponent: Deploy button clicked, opening firmware upload drawer...');
// Get current filtered members from cluster members component
const filteredMembers = this.clusterMembersComponent ? this.clusterMembersComponent.getFilteredMembers() : [];
if (!filteredMembers || filteredMembers.length === 0) {
this.showConfirmationDialog({
title: 'No Nodes Available',
message: 'No nodes available for firmware deployment. Please ensure cluster members are loaded and visible.',
confirmText: 'OK',
cancelText: null,
onConfirm: () => {},
onCancel: null
});
return;
}
// Open drawer with firmware upload interface
this.openFirmwareUploadDrawer(filteredMembers);
}
async handleConfig() {
logger.debug('ClusterViewComponent: Config button clicked, opening WiFi config drawer...');
// Get current filtered members from cluster members component
const filteredMembers = this.clusterMembersComponent ? this.clusterMembersComponent.getFilteredMembers() : [];
if (!filteredMembers || filteredMembers.length === 0) {
this.showConfirmationDialog({
title: 'No Nodes Available',
message: 'No nodes available for WiFi configuration. Please ensure cluster members are loaded and visible.',
confirmText: 'OK',
cancelText: null,
onConfirm: () => {},
onCancel: null
});
return;
}
// Open drawer with WiFi configuration interface
this.openWiFiConfigDrawer(filteredMembers);
}
openFirmwareUploadDrawer(targetNodes) {
logger.debug('ClusterViewComponent: Opening firmware upload drawer for', targetNodes.length, 'nodes');
// Get display name for drawer title
const nodeCount = targetNodes.length;
const displayName = `Firmware Deployment - ${nodeCount} node${nodeCount !== 1 ? 's' : ''}`;
// Open drawer with content callback (hide terminal button for firmware upload)
this.clusterMembersComponent.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
// Create firmware upload view model and component
const firmwareUploadVM = new ClusterFirmwareViewModel();
firmwareUploadVM.setTargetNodes(targetNodes);
// Create HTML for firmware upload interface
contentContainer.innerHTML = `
<div class="firmware-upload-drawer">
<div class="firmware-upload-section">
<!--div class="firmware-upload-header">
<h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/>
<path d="M12 8v8"/>
</svg>
Firmware Upload
</h3>
</div-->
<div class="firmware-upload-controls">
<div class="file-input-wrapper">
<div class="file-input-left">
<input type="file" id="firmware-file" accept=".bin,.hex" style="display: none;">
<button class="upload-btn-compact" onclick="document.getElementById('firmware-file').click()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:6px; vertical-align: -2px;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
</svg>
Choose File
</button>
<span class="file-info" id="file-info">No file selected</span>
</div>
<button class="deploy-btn-compact" id="deploy-btn" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:6px; vertical-align: -2px;">
<path d="M12 16V4"/>
<path d="M8 8l4-4 4 4"/>
<path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2"/>
</svg>
Deploy
</button>
</div>
</div>
<div class="target-nodes-section">
<h3>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Target Nodes (${targetNodes.length})
</h3>
<div class="target-nodes-list">
${targetNodes.map(node => `
<div class="target-node-item" data-node-ip="${node.ip}">
<div class="node-info">
<span class="node-name">${node.hostname || node.ip}</span>
<span class="node-ip">${node.ip}</span>
</div>
<div class="node-status">
<span class="status-indicator ready">Ready</span>
</div>
</div>
`).join('')}
</div>
</div>
<div id="firmware-progress-container">
<!-- Progress will be shown here during upload -->
</div>
</div>
</div>
`;
// Create and mount firmware upload component
const firmwareUploadComponent = new FirmwareUploadComponent(contentContainer, firmwareUploadVM, this.eventBus);
setActiveComponent(firmwareUploadComponent);
firmwareUploadComponent.mount();
}, null, () => {
// Close callback - clear any upload state
logger.debug('ClusterViewComponent: Firmware upload drawer closed');
}, true); // Hide terminal button for firmware upload
}
openWiFiConfigDrawer(targetNodes) {
logger.debug('ClusterViewComponent: Opening WiFi config drawer for', targetNodes.length, 'nodes');
// Get display name for drawer title
const nodeCount = targetNodes.length;
const displayName = `Configuration - ${nodeCount} node${nodeCount !== 1 ? 's' : ''}`;
// Open drawer with content callback (hide terminal button for WiFi config)
this.clusterMembersComponent.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
// Create WiFi config view model and component
const wifiConfigVM = new WiFiConfigViewModel();
wifiConfigVM.setTargetNodes(targetNodes);
// Create HTML for WiFi configuration interface
contentContainer.innerHTML = `
<div class="wifi-config-drawer">
<div class="tabs-container">
<div class="tabs-header">
<button class="tab-button active" data-tab="wifi">WiFi</button>
</div>
<div class="tab-content active" id="wifi-tab">
<div class="wifi-config-section">
<div class="wifi-form">
<div class="form-group">
<label for="wifi-ssid">SSID (Network Name)</label>
<input type="text" id="wifi-ssid" placeholder="Enter WiFi network name" required>
</div>
<div class="form-group">
<label for="wifi-password">Password</label>
<input type="password" id="wifi-password" placeholder="Enter WiFi password" required>
</div>
<div class="wifi-divider"></div>
<div class="affected-nodes-info">
<div class="nodes-count">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
Affected Nodes: <span id="affected-nodes-count">${targetNodes.length}</span>
</div>
</div>
<div class="wifi-actions">
<button class="config-btn" id="apply-wifi-config" disabled>
Apply
</button>
</div>
</div>
<div id="wifi-progress-container">
<!-- Progress will be shown here during configuration -->
</div>
</div>
</div>
</div>
</div>
`;
// Create and mount WiFi config component
const wifiConfigComponent = new WiFiConfigComponent(contentContainer, wifiConfigVM, this.eventBus);
setActiveComponent(wifiConfigComponent);
wifiConfigComponent.mount();
}, null, () => {
// Close callback - clear any config state
logger.debug('ClusterViewComponent: WiFi config drawer closed');
}, true); // Hide terminal button for WiFi config
}
async handleRefresh() {
logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...');
// Get the refresh button and show loading state
const refreshBtn = this.findElement('.refresh-btn');
logger.debug('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn);
if (refreshBtn) {
const originalText = refreshBtn.innerHTML;
logger.debug('ClusterViewComponent: Original button text:', originalText);
refreshBtn.innerHTML = `
<svg class="refresh-icon spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
</svg>
Refreshing...
`;
refreshBtn.disabled = true;
try {
logger.debug('ClusterViewComponent: Starting cluster members update...');
// Always perform a full refresh when user clicks refresh button
await this.viewModel.updateClusterMembers();
logger.debug('ClusterViewComponent: Cluster members update completed successfully');
} catch (error) {
logger.error('ClusterViewComponent: Error during refresh:', error);
// Show error state
if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
}
} finally {
logger.debug('ClusterViewComponent: Restoring button state...');
// Restore button state
refreshBtn.innerHTML = originalText;
refreshBtn.disabled = false;
}
} else {
logger.warn('ClusterViewComponent: Refresh button not found, using fallback refresh');
// Fallback if button not found
try {
await this.viewModel.updateClusterMembers();
} catch (error) {
logger.error('ClusterViewComponent: Fallback refresh failed:', error);
if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
}
}
}
}
unmount() {
logger.debug('ClusterViewComponent: Unmounting...');
// Unmount sub-components
if (this.primaryNodeComponent) {
this.primaryNodeComponent.unmount();
}
if (this.clusterMembersComponent) {
this.clusterMembersComponent.unmount();
}
// Clear intervals
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
super.unmount();
logger.debug('ClusterViewComponent: Unmounted');
}
// Override pause method to handle sub-components
onPause() {
logger.debug('ClusterViewComponent: Pausing...');
// Pause sub-components
if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) {
this.primaryNodeComponent.pause();
}
if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) {
this.clusterMembersComponent.pause();
}
// Clear any active intervals
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
}
// Override resume method to handle sub-components
onResume() {
logger.debug('ClusterViewComponent: Resuming...');
// Resume sub-components
if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) {
this.primaryNodeComponent.resume();
}
if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) {
this.clusterMembersComponent.resume();
}
// Restart periodic updates if needed
// this.setupPeriodicUpdates(); // Disabled automatic refresh
}
// Override to determine if re-render is needed on resume
shouldRenderOnResume() {
// Don't re-render on resume - the component should maintain its state
return false;
}
setupPeriodicUpdates() {
// Update primary node display every 10 seconds
this.updateInterval = setInterval(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 10000);
}
}
window.ClusterViewComponent = ClusterViewComponent;

View File

@@ -0,0 +1,16 @@
(function(){
// Simple readiness flag once all component constructors are present
function allReady(){
return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.FirmwareFormComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent && window.DrawerComponent && window.WiFiConfigComponent);
}
window.waitForComponentsReady = function(timeoutMs = 5000){
return new Promise((resolve, reject) => {
const start = Date.now();
(function check(){
if (allReady()) return resolve(true);
if (Date.now() - start > timeoutMs) return reject(new Error('Components did not load in time'));
setTimeout(check, 25);
})();
});
};
})();

View File

@@ -0,0 +1,177 @@
// Reusable Drawer Component for desktop slide-in panels
class DrawerComponent {
constructor() {
if (window.__sharedDrawerInstance) {
return window.__sharedDrawerInstance;
}
this.detailsDrawer = null;
this.detailsDrawerContent = null;
this.activeDrawerComponent = null;
this.onCloseCallback = null;
window.__sharedDrawerInstance = this;
}
// Determine if we should use desktop drawer behavior
isDesktop() {
try {
return window && window.innerWidth >= 1024; // desktop threshold
} catch (_) {
return false;
}
}
ensureDrawer() {
if (this.detailsDrawer) return;
// Create drawer
this.detailsDrawer = document.createElement('div');
this.detailsDrawer.className = 'details-drawer';
// Header with actions and close button
const header = document.createElement('div');
header.className = 'details-drawer-header';
header.innerHTML = `
<div class="drawer-title">Node Details</div>
<div class="drawer-actions">
<button class="drawer-terminal-btn" title="Open Terminal" aria-label="Open Terminal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 17l6-6-6-6"></path>
<path d="M12 19h8"></path>
</svg>
</button>
<button class="drawer-close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
`;
this.detailsDrawer.appendChild(header);
// Content container
this.detailsDrawerContent = document.createElement('div');
this.detailsDrawerContent.className = 'details-drawer-content';
this.detailsDrawer.appendChild(this.detailsDrawerContent);
// Terminal panel container (positioned left of details drawer)
this.terminalPanelContainer = document.createElement('div');
this.terminalPanelContainer.className = 'terminal-panel-container';
document.body.appendChild(this.terminalPanelContainer);
document.body.appendChild(this.detailsDrawer);
// Close handlers
const close = () => this.closeDrawer();
header.querySelector('.drawer-close').addEventListener('click', close);
const terminalBtn = header.querySelector('.drawer-terminal-btn');
if (terminalBtn) {
terminalBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const nodeIp = this.activeDrawerComponent && this.activeDrawerComponent.viewModel && this.activeDrawerComponent.viewModel.get('nodeIp');
if (!window.TerminalPanel) return;
const panel = window.TerminalPanel;
const wasMinimized = panel.isMinimized;
panel.open(this.terminalPanelContainer, nodeIp);
if (nodeIp && panel._updateTitle) {
panel._updateTitle(nodeIp);
}
if (wasMinimized && panel.restore) {
panel.restore();
}
} catch (err) {
console.error('Failed to open terminal:', err);
}
});
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
});
}
openDrawer(title, contentCallback, errorCallback, onCloseCallback, hideTerminalButton = false) {
this.ensureDrawer();
this.onCloseCallback = onCloseCallback;
// Set drawer title
const titleEl = this.detailsDrawer.querySelector('.drawer-title');
if (titleEl) {
titleEl.textContent = title;
}
// Show/hide terminal button based on parameter
const terminalBtn = this.detailsDrawer.querySelector('.drawer-terminal-btn');
if (terminalBtn) {
terminalBtn.style.display = hideTerminalButton ? 'none' : 'block';
}
// Clear previous component if any
if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') {
try {
this.activeDrawerComponent.unmount();
} catch (_) {}
}
this.detailsDrawerContent.innerHTML = '<div class="loading-details">Loading detailed information...</div>';
// Execute content callback
try {
contentCallback(this.detailsDrawerContent, (component) => {
this.activeDrawerComponent = component;
});
} catch (error) {
logger.error('Failed to load drawer content:', error);
if (errorCallback) {
errorCallback(error);
} else {
this.detailsDrawerContent.innerHTML = `
<div class="error">
<strong>Error loading content:</strong><br>
${this.escapeHtml ? this.escapeHtml(error.message) : error.message}
</div>
`;
}
}
// Open drawer
this.detailsDrawer.classList.add('open');
// Inform terminal container that the drawer is open for alignment
if (this.terminalPanelContainer) {
this.terminalPanelContainer.classList.add('drawer-open');
}
}
closeDrawer() {
if (this.detailsDrawer) this.detailsDrawer.classList.remove('open');
if (this.terminalPanelContainer) {
this.terminalPanelContainer.classList.remove('drawer-open');
}
// Call close callback if provided
if (this.onCloseCallback) {
this.onCloseCallback();
this.onCloseCallback = null;
}
}
// Clean up drawer elements
destroy() {
if (this.detailsDrawer && this.detailsDrawer.parentNode) {
this.detailsDrawer.parentNode.removeChild(this.detailsDrawer);
}
this.detailsDrawer = null;
this.detailsDrawerContent = null;
this.activeDrawerComponent = null;
}
// Helper method for HTML escaping (can be overridden)
escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
window.DrawerComponent = DrawerComponent;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,711 @@
// Registry Firmware Component - CRUD interface for firmware registry
class FirmwareComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('FirmwareComponent: Constructor called');
logger.debug('FirmwareComponent: Container:', container);
// Initialize drawer component
this.drawer = new DrawerComponent();
// Registry connection status
this.registryConnected = false;
this.registryError = null;
}
setupEventListeners() {
// Setup refresh button
const refreshBtn = this.findElement('#refresh-firmware-btn');
if (refreshBtn) {
this.addEventListener(refreshBtn, 'click', this.refreshFirmwareList.bind(this));
}
// Setup add firmware button
const addBtn = this.findElement('#add-firmware-btn');
if (addBtn) {
this.addEventListener(addBtn, 'click', this.showAddFirmwareForm.bind(this));
}
// Setup search input
const searchInput = this.findElement('#firmware-search');
if (searchInput) {
this.addEventListener(searchInput, 'input', this.handleSearch.bind(this));
}
}
setupViewModelListeners() {
this.subscribeToProperty('firmwareList', this.renderFirmwareList.bind(this));
this.subscribeToProperty('isLoading', this.updateLoadingState.bind(this));
this.subscribeToProperty('searchQuery', this.updateSearchResults.bind(this));
this.subscribeToProperty('registryConnected', this.updateRegistryStatus.bind(this));
}
mount() {
super.mount();
logger.debug('FirmwareComponent: Mounting...');
// Check registry connection and load firmware list
this.checkRegistryConnection();
this.loadFirmwareList();
logger.debug('FirmwareComponent: Mounted successfully');
}
unmount() {
this.cleanupDynamicListeners();
super.unmount();
}
async checkRegistryConnection() {
try {
await window.apiClient.getRegistryHealth();
this.registryConnected = true;
this.registryError = null;
this.viewModel.set('registryConnected', true);
} catch (error) {
logger.error('Registry connection failed:', error);
this.registryConnected = false;
this.registryError = error.message;
this.viewModel.set('registryConnected', false);
}
}
async loadFirmwareList() {
try {
this.viewModel.set('isLoading', true);
const firmwareList = await window.apiClient.listFirmwareFromRegistry();
this.viewModel.set('firmwareList', firmwareList);
} catch (error) {
logger.error('Failed to load firmware list:', error);
this.viewModel.set('firmwareList', []);
this.showError('Failed to load firmware list: ' + error.message);
} finally {
this.viewModel.set('isLoading', false);
}
}
async refreshFirmwareList() {
await this.checkRegistryConnection();
await this.loadFirmwareList();
}
renderFirmwareList() {
const container = this.findElement('#firmware-list-container');
if (!container) return;
const groupedFirmware = this.viewModel.get('firmwareList') || [];
const searchQuery = this.viewModel.get('searchQuery') || '';
// Filter grouped firmware based on search query
const filteredGroups = groupedFirmware.map(group => {
if (!searchQuery) return group;
// Split search query into individual terms
const searchTerms = searchQuery.toLowerCase().split(/\s+/).filter(term => term.length > 0);
// Filter firmware versions within the group
const filteredFirmware = group.firmware.filter(firmware => {
// All search terms must match somewhere in the firmware data
return searchTerms.every(term => {
// Check group name
if (group.name.toLowerCase().includes(term)) {
return true;
}
// Check version
if (firmware.version.toLowerCase().includes(term)) {
return true;
}
// Check labels
if (Object.values(firmware.labels || {}).some(label =>
label.toLowerCase().includes(term)
)) {
return true;
}
return false;
});
});
// Return group with filtered firmware, or null if no firmware matches
return filteredFirmware.length > 0 ? {
...group,
firmware: filteredFirmware
} : null;
}).filter(group => group !== null);
if (filteredGroups.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="48" height="48">
<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/>
<path d="M12 8v8"/>
</svg>
</div>
<div class="empty-title">${searchQuery ? 'No firmware found' : 'No firmware available'}</div>
<div class="empty-description">
${searchQuery ? 'Try adjusting your search terms' : 'Upload your first firmware to get started'}
</div>
</div>
`;
return;
}
// Auto-expand groups when search is active to show results
const autoExpand = searchQuery.trim().length > 0;
const firmwareHTML = filteredGroups.map(group => this.renderFirmwareGroup(group, autoExpand)).join('');
container.innerHTML = `
<div class="firmware-groups">
${firmwareHTML}
</div>
`;
// Setup event listeners for firmware items
this.setupFirmwareItemListeners();
}
renderFirmwareGroup(group, autoExpand = false) {
const versionsHTML = group.firmware.map(firmware => this.renderFirmwareVersion(firmware)).join('');
// Add 'expanded' class if autoExpand is true (e.g., when search results are shown)
const expandedClass = autoExpand ? 'expanded' : '';
return `
<div class="firmware-group ${expandedClass}">
<div class="firmware-group-header">
<div class="firmware-group-header-content">
<h3 class="firmware-group-name">${this.escapeHtml(group.name)}</h3>
<span class="firmware-group-count">${group.firmware.length} version${group.firmware.length !== 1 ? 's' : ''}</span>
</div>
<svg class="firmware-group-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div class="firmware-versions">
${versionsHTML}
</div>
</div>
`;
}
renderFirmwareVersion(firmware) {
const labels = firmware.labels || {};
const labelsHTML = Object.entries(labels).map(([key, value]) =>
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
).join('');
const sizeKB = Math.round(firmware.size / 1024);
return `
<div class="firmware-version-item clickable" data-name="${firmware.name}" data-version="${firmware.version}" title="Click to edit firmware">
<div class="firmware-version-main">
<div class="firmware-version-info">
<div class="firmware-version-number">v${this.escapeHtml(firmware.version)}</div>
<div class="firmware-size">${sizeKB} KB</div>
</div>
<div class="firmware-version-labels">
${labelsHTML}
</div>
</div>
<div class="firmware-version-actions">
<button class="action-btn rollout-btn" title="Rollout firmware" data-name="${firmware.name}" data-version="${firmware.version}" data-labels='${JSON.stringify(firmware.labels)}'>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</button>
<button class="action-btn download-btn" title="Download firmware" data-name="${firmware.name}" data-version="${firmware.version}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7,10 12,15 17,10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button class="action-btn delete-btn" title="Delete firmware" data-name="${firmware.name}" data-version="${firmware.version}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="3,6 5,6 21,6"/>
<path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div>
</div>
`;
}
renderFirmwareItem(firmware) {
const labels = firmware.labels || {};
const labelsHTML = Object.entries(labels).map(([key, value]) =>
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
).join('');
const sizeKB = Math.round(firmware.size / 1024);
return `
<div class="firmware-list-item" data-name="${firmware.name}" data-version="${firmware.version}">
<div class="firmware-item-main">
<div class="firmware-item-info">
<div class="firmware-name">${this.escapeHtml(firmware.name)}</div>
<div class="firmware-version">v${this.escapeHtml(firmware.version)}</div>
<div class="firmware-size">${sizeKB} KB</div>
</div>
<div class="firmware-item-labels">
${labelsHTML}
</div>
</div>
<div class="firmware-item-actions">
<button class="action-btn edit-btn" title="Edit firmware" data-name="${firmware.name}" data-version="${firmware.version}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="action-btn download-btn" title="Download firmware" data-name="${firmware.name}" data-version="${firmware.version}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7,10 12,15 17,10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button class="action-btn delete-btn" title="Delete firmware" data-name="${firmware.name}" data-version="${firmware.version}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<polyline points="3,6 5,6 21,6"/>
<path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div>
</div>
`;
}
setupFirmwareItemListeners() {
// First, clean up existing listeners for dynamically created content
this.cleanupDynamicListeners();
this.dynamicUnsubscribers = [];
// Firmware group header clicks (for expand/collapse)
const groupHeaders = this.findAllElements('.firmware-group-header');
groupHeaders.forEach(header => {
const handler = (e) => {
const group = header.closest('.firmware-group');
group.classList.toggle('expanded');
};
header.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => header.removeEventListener('click', handler));
});
// Version item clicks (for editing)
const versionItems = this.findAllElements('.firmware-version-item.clickable');
versionItems.forEach(item => {
const handler = (e) => {
// Don't trigger if clicking on action buttons
if (e.target.closest('.firmware-version-actions')) {
return;
}
const name = item.getAttribute('data-name');
const version = item.getAttribute('data-version');
this.showEditFirmwareForm(name, version);
};
item.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => item.removeEventListener('click', handler));
});
// Rollout buttons
const rolloutBtns = this.findAllElements('.rollout-btn');
rolloutBtns.forEach(btn => {
const handler = (e) => {
e.stopPropagation();
const name = btn.getAttribute('data-name');
const version = btn.getAttribute('data-version');
const labels = JSON.parse(btn.getAttribute('data-labels') || '{}');
this.showRolloutPanel(name, version, labels);
};
btn.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
});
// Download buttons
const downloadBtns = this.findAllElements('.download-btn');
downloadBtns.forEach(btn => {
const handler = (e) => {
e.stopPropagation();
const name = btn.getAttribute('data-name');
const version = btn.getAttribute('data-version');
this.downloadFirmware(name, version);
};
btn.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
});
// Delete buttons
const deleteBtns = this.findAllElements('.delete-btn');
logger.debug('Found delete buttons:', deleteBtns.length);
deleteBtns.forEach(btn => {
const handler = (e) => {
e.stopPropagation();
const name = btn.getAttribute('data-name');
const version = btn.getAttribute('data-version');
logger.debug('Delete button clicked:', name, version);
this.showDeleteConfirmation(name, version);
};
btn.addEventListener('click', handler);
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
});
}
cleanupDynamicListeners() {
if (this.dynamicUnsubscribers) {
this.dynamicUnsubscribers.forEach(unsub => unsub());
this.dynamicUnsubscribers = [];
}
}
showAddFirmwareForm() {
this.openFirmwareForm('Add Firmware', null, null);
}
showEditFirmwareForm(name, version) {
const groupedFirmware = this.viewModel.get('firmwareList') || [];
// Find the firmware in the grouped data
let firmware = null;
for (const group of groupedFirmware) {
if (group.name === name) {
firmware = group.firmware.find(f => f.version === version);
if (firmware) break;
}
}
if (firmware) {
this.openFirmwareForm('Edit Firmware', firmware, null);
}
}
openFirmwareForm(title, firmwareData, onCloseCallback) {
this.drawer.openDrawer(title, (contentContainer, setActiveComponent) => {
const formComponent = new FirmwareFormComponent(contentContainer, this.viewModel, this.eventBus);
setActiveComponent(formComponent);
formComponent.setFirmwareData(firmwareData);
formComponent.setOnSaveCallback(() => {
this.loadFirmwareList();
this.drawer.closeDrawer();
});
formComponent.setOnCancelCallback(() => {
this.drawer.closeDrawer();
});
formComponent.mount();
}, null, onCloseCallback, true); // Hide terminal button
}
async downloadFirmware(name, version) {
try {
const blob = await window.apiClient.downloadFirmwareFromRegistry(name, version);
// Create download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}-${version}.bin`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
this.showSuccess(`Firmware ${name} v${version} downloaded successfully`);
} catch (error) {
logger.error('Download failed:', error);
this.showError('Download failed: ' + error.message);
}
}
showDeleteConfirmation(name, version) {
OverlayDialogComponent.danger({
title: 'Delete Firmware',
message: `Are you sure you want to delete firmware "${name}" version "${version}"?<br><br>This action cannot be undone.`,
confirmText: 'Delete',
cancelText: 'Cancel',
onConfirm: () => this.deleteFirmware(name, version)
});
}
async showRolloutPanel(name, version, labels) {
try {
// Get cluster node versions to show which nodes will be affected
const nodeVersions = await window.apiClient.getClusterNodeVersions();
// Filter nodes that match the firmware labels
const matchingNodes = nodeVersions.nodes.filter(node => {
return this.nodeMatchesLabels(node.labels, labels);
});
this.openRolloutDrawer(name, version, labels, matchingNodes);
} catch (error) {
logger.error('Failed to get cluster node versions:', error);
this.showError('Failed to get cluster information: ' + error.message);
}
}
nodeMatchesLabels(nodeLabels, firmwareLabels) {
for (const [key, value] of Object.entries(firmwareLabels)) {
if (!nodeLabels[key] || nodeLabels[key] !== value) {
return false;
}
}
return true;
}
openRolloutDrawer(name, version, labels, matchingNodes) {
this.drawer.openDrawer('Rollout Firmware', (contentContainer, setActiveComponent) => {
const rolloutComponent = new RolloutComponent(contentContainer, this.viewModel, this.eventBus);
setActiveComponent(rolloutComponent);
rolloutComponent.setRolloutData(name, version, labels, matchingNodes);
rolloutComponent.setOnRolloutCallback((rolloutData) => {
this.startRollout(rolloutData);
});
rolloutComponent.setOnCancelCallback(() => {
this.drawer.closeDrawer();
});
// Store reference for status updates
this.currentRolloutComponent = rolloutComponent;
rolloutComponent.mount();
}, null, null, true); // Hide terminal button
}
async startRollout(rolloutData) {
try {
// Start rollout in the panel (no backdrop)
if (this.currentRolloutComponent) {
this.currentRolloutComponent.startRollout();
}
const response = await window.apiClient.startRollout(rolloutData);
logger.info('Rollout started:', response);
this.showSuccess(`Rollout started for ${response.totalNodes} nodes`);
// Set up WebSocket listener for rollout progress
this.setupRolloutProgressListener(response.rolloutId);
} catch (error) {
logger.error('Rollout failed:', error);
this.showError('Rollout failed: ' + error.message);
// Reset rollout state on error
if (this.currentRolloutComponent) {
this.currentRolloutComponent.resetRolloutState();
}
}
}
showRolloutProgress() {
// Create backdrop and progress overlay
const backdrop = document.createElement('div');
backdrop.className = 'rollout-backdrop';
backdrop.id = 'rollout-backdrop';
const progressOverlay = document.createElement('div');
progressOverlay.className = 'rollout-progress-overlay';
progressOverlay.innerHTML = `
<div class="rollout-progress-content">
<div class="rollout-progress-header">
<h3>Rolling Out Firmware</h3>
</div>
<div class="rollout-progress-body">
<div class="rollout-progress-info">
<p>Firmware rollout in progress...</p>
<p class="rollout-progress-text">Preparing rollout...</p>
</div>
<div class="rollout-progress-bar">
<div class="rollout-progress-fill" id="rollout-progress-fill"></div>
</div>
<div class="rollout-progress-details" id="rollout-progress-details">
<div class="rollout-node-list" id="rollout-node-list"></div>
</div>
</div>
</div>
`;
backdrop.appendChild(progressOverlay);
document.body.appendChild(backdrop);
// Block UI interactions
document.body.style.overflow = 'hidden';
}
hideRolloutProgress() {
const backdrop = document.getElementById('rollout-backdrop');
if (backdrop) {
document.body.removeChild(backdrop);
}
document.body.style.overflow = '';
}
setupRolloutProgressListener(rolloutId) {
// Track completed nodes for parallel processing
this.completedNodes = new Set();
this.totalNodes = 0;
const progressListener = (data) => {
if (data.rolloutId === rolloutId) {
// Set total nodes from first update
if (this.totalNodes === 0) {
this.totalNodes = data.total;
}
this.updateRolloutProgress(data);
}
};
window.wsClient.on('rolloutProgress', progressListener);
// Store listener for cleanup
this.currentRolloutListener = progressListener;
}
updateRolloutProgress(data) {
// Update status in the rollout panel
if (this.currentRolloutComponent) {
this.currentRolloutComponent.updateNodeStatus(data.nodeIp, data.status);
}
// Track completed nodes for parallel processing
if (data.status === 'completed') {
this.completedNodes.add(data.nodeIp);
} else if (data.status === 'failed') {
// Also count failed nodes as "processed" for completion check
this.completedNodes.add(data.nodeIp);
}
// Check if rollout is complete (all nodes processed)
if (this.completedNodes.size >= this.totalNodes) {
setTimeout(() => {
this.showSuccess('Rollout completed successfully');
// Clean up WebSocket listener
if (this.currentRolloutListener) {
window.wsClient.off('rolloutProgress', this.currentRolloutListener);
this.currentRolloutListener = null;
}
}, 2000);
}
}
async deleteFirmware(name, version) {
try {
await window.apiClient.deleteFirmwareFromRegistry(name, version);
this.showSuccess(`Firmware ${name} v${version} deleted successfully`);
await this.loadFirmwareList();
} catch (error) {
logger.error('Delete failed:', error);
this.showError('Delete failed: ' + error.message);
}
}
handleSearch(event) {
const query = event.target.value;
this.viewModel.set('searchQuery', query);
}
updateSearchResults() {
// This method is called when searchQuery property changes
// The actual filtering is handled in renderFirmwareList
this.renderFirmwareList();
}
updateLoadingState() {
const isLoading = this.viewModel.get('isLoading');
const container = this.findElement('#firmware-list-container');
if (isLoading && container) {
container.innerHTML = `
<div class="loading-state">
<div class="loading-spinner"></div>
<div class="loading-text">Loading firmware...</div>
</div>
`;
}
}
updateRegistryStatus() {
const isConnected = this.viewModel.get('registryConnected');
const statusElement = this.findElement('#registry-status');
if (statusElement) {
if (isConnected) {
statusElement.innerHTML = `
<span class="status-indicator connected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22,4 12,14.01 9,11.01"/>
</svg>
Registry Connected
</span>
`;
} else {
statusElement.innerHTML = `
<span class="status-indicator disconnected">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
Registry Disconnected
</span>
`;
}
}
}
showSuccess(message) {
this.showNotification(message, 'success');
}
showError(message) {
this.showNotification(message, 'error');
}
showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 100);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 300);
}, 3000);
}
escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
window.FirmwareComponent = FirmwareComponent;

View File

@@ -0,0 +1,354 @@
// Firmware Form Component for add/edit operations in drawer
class FirmwareFormComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.firmwareData = null;
this.onSaveCallback = null;
this.onCancelCallback = null;
this.isEditMode = false;
}
setFirmwareData(firmwareData) {
this.firmwareData = firmwareData;
this.isEditMode = !!firmwareData;
}
setOnSaveCallback(callback) {
this.onSaveCallback = callback;
}
setOnCancelCallback(callback) {
this.onCancelCallback = callback;
}
setupEventListeners() {
// Submit button
const submitBtn = this.findElement('button[type="submit"]');
if (submitBtn) {
this.addEventListener(submitBtn, 'click', this.handleSubmit.bind(this));
}
// Cancel button
const cancelBtn = this.findElement('#cancel-btn');
if (cancelBtn) {
this.addEventListener(cancelBtn, 'click', this.handleCancel.bind(this));
}
// File input
const fileInput = this.findElement('#firmware-file');
if (fileInput) {
this.addEventListener(fileInput, 'change', this.handleFileSelect.bind(this));
}
// Labels management
this.setupLabelsManagement();
}
setupLabelsManagement() {
// Add label button
const addLabelBtn = this.findElement('#add-label-btn');
if (addLabelBtn) {
this.addEventListener(addLabelBtn, 'click', this.addLabel.bind(this));
}
// Remove label buttons (delegated event handling)
const labelsContainer = this.findElement('#labels-container');
if (labelsContainer) {
this.addEventListener(labelsContainer, 'click', (e) => {
const removeBtn = e.target.closest('.remove-label-btn');
if (removeBtn) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const key = removeBtn.getAttribute('data-label-key');
if (key) {
this.removeLabel(key);
}
}
});
}
}
mount() {
super.mount();
this.render();
this.setupEventListeners();
}
render() {
const container = this.container;
if (!container) return;
const labels = this.firmwareData?.labels || {};
const labelsHTML = Object.entries(labels).map(([key, value]) =>
`<div class="label-item" data-key="${this.escapeHtml(key)}">
<div class="label-content">
<span class="label-key">${this.escapeHtml(key)}</span>
<span class="label-separator">=</span>
<span class="label-value">${this.escapeHtml(value)}</span>
</div>
<button type="button" class="remove-label-btn" data-label-key="${this.escapeHtml(key)}" title="Remove label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>`
).join('');
container.innerHTML = `
<div class="firmware-form">
<div class="form-group">
<label for="firmware-name">Name *</label>
<input
type="text"
id="firmware-name"
name="name"
value="${this.firmwareData?.name || ''}"
placeholder="e.g., base, neopattern, relay"
${this.isEditMode ? 'readonly' : ''}
>
<small class="form-help">${this.isEditMode ? 'Name cannot be changed after creation' : 'Unique identifier for the firmware'}</small>
</div>
<div class="form-group">
<label for="firmware-version">Version *</label>
<input
type="text"
id="firmware-version"
name="version"
value="${this.firmwareData?.version || ''}"
placeholder="e.g., 1.0.0, 2.1.3"
${this.isEditMode ? 'readonly' : ''}
>
<small class="form-help">${this.isEditMode ? 'Version cannot be changed after creation' : 'Semantic version (e.g., 1.0.0)'}</small>
</div>
<div class="form-group">
<label for="firmware-file">Firmware File *</label>
<div class="file-input-wrapper">
<input
type="file"
id="firmware-file"
name="firmware"
accept=".bin,.hex"
>
<label for="firmware-file" class="file-input-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<path d="M14 2v6h6"/>
</svg>
<span id="file-name">${this.isEditMode ? 'Current file: ' + (this.firmwareData?.name || 'unknown') + '.bin' : 'Choose firmware file...'}</span>
</label>
</div>
<small class="form-help">${this.isEditMode ? 'Select a new firmware file to update, or leave empty to update metadata only' : 'Binary firmware file (.bin or .hex)'}</small>
</div>
<div class="form-group">
<label>Labels</label>
<div class="labels-section">
<div class="add-label-controls">
<input type="text" id="label-key" placeholder="Key" class="label-key-input">
<span class="label-separator">=</span>
<input type="text" id="label-value" placeholder="Value" class="label-value-input">
<button type="button" id="add-label-btn" class="add-label-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add Label
</button>
</div>
<div id="labels-container" class="labels-container">
${labelsHTML}
</div>
</div>
<small class="form-help">Key-value pairs for categorizing firmware (e.g., platform: esp32, app: base)</small>
</div>
<div class="firmware-actions">
<button type="button" id="cancel-btn" class="config-btn">Cancel</button>
<button type="submit" class="config-btn">
${this.isEditMode ? 'Update Firmware' : 'Upload Firmware'}
</button>
</div>
</div>
`;
}
handleFileSelect(event) {
const file = event.target.files[0];
const fileNameSpan = this.findElement('#file-name');
if (file) {
fileNameSpan.textContent = file.name;
} else {
fileNameSpan.textContent = this.isEditMode ?
'Current file: ' + (this.firmwareData?.name || 'unknown') + '.bin' :
'Choose firmware file...';
}
}
addLabel() {
const keyInput = this.findElement('#label-key');
const valueInput = this.findElement('#label-value');
const labelsContainer = this.findElement('#labels-container');
const key = keyInput.value.trim();
const value = valueInput.value.trim();
if (!key || !value) {
this.showError('Please enter both key and value for the label');
return;
}
// Check if key already exists
const existingLabel = labelsContainer.querySelector(`[data-key="${this.escapeHtml(key)}"]`);
if (existingLabel) {
this.showError('A label with this key already exists');
return;
}
// Add the label
const labelHTML = `
<div class="label-item" data-key="${this.escapeHtml(key)}">
<div class="label-content">
<span class="label-key">${this.escapeHtml(key)}</span>
<span class="label-separator">=</span>
<span class="label-value">${this.escapeHtml(value)}</span>
</div>
<button type="button" class="remove-label-btn" data-label-key="${this.escapeHtml(key)}" title="Remove label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
`;
labelsContainer.insertAdjacentHTML('beforeend', labelHTML);
// Clear inputs
keyInput.value = '';
valueInput.value = '';
}
removeLabel(key) {
const removeBtn = this.findElement(`.remove-label-btn[data-label-key="${this.escapeHtml(key)}"]`);
if (removeBtn) {
const labelItem = removeBtn.closest('.label-item');
if (labelItem) {
labelItem.remove();
}
}
}
async handleSubmit(event) {
event.preventDefault();
try {
const nameInput = this.findElement('#firmware-name');
const versionInput = this.findElement('#firmware-version');
const firmwareFile = this.findElement('#firmware-file').files[0];
const name = nameInput.value.trim();
const version = versionInput.value.trim();
if (!name || !version) {
this.showError('Name and version are required');
return;
}
// Only require file for new uploads, not for edit mode when keeping existing file
if (!this.isEditMode && (!firmwareFile || firmwareFile.size === 0)) {
this.showError('Please select a firmware file');
return;
}
// Collect labels
const labels = {};
const labelItems = this.findAllElements('.label-item');
labelItems.forEach(item => {
const key = item.querySelector('.label-key').textContent;
const value = item.querySelector('.label-value').textContent;
labels[key] = value;
});
// Prepare metadata
const metadata = {
name,
version,
labels
};
// Handle upload vs metadata-only update
if (this.isEditMode && (!firmwareFile || firmwareFile.size === 0)) {
// Metadata-only update
await window.apiClient.updateFirmwareMetadata(name, version, metadata);
} else {
// Full upload (new firmware or edit with new file)
await window.apiClient.uploadFirmwareToRegistry(metadata, firmwareFile);
}
this.showSuccess(this.isEditMode ? 'Firmware updated successfully' : 'Firmware uploaded successfully');
if (this.onSaveCallback) {
this.onSaveCallback();
}
} catch (error) {
logger.error('Firmware upload failed:', error);
this.showError('Upload failed: ' + error.message);
}
}
handleCancel() {
if (this.onCancelCallback) {
this.onCancelCallback();
}
}
showError(message) {
this.showNotification(message, 'error');
}
showSuccess(message) {
this.showNotification(message, 'success');
}
showNotification(message, type = 'info') {
// Remove any existing notifications
const existing = this.findElement('.form-notification');
if (existing) {
existing.remove();
}
const notification = document.createElement('div');
notification.className = `form-notification notification-${type}`;
notification.textContent = message;
this.container.insertBefore(notification, this.container.firstChild);
setTimeout(() => {
notification.classList.add('show');
}, 100);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 300);
}, 3000);
}
escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
window.FirmwareFormComponent = FirmwareFormComponent;

View File

@@ -0,0 +1,811 @@
// Reusable Firmware Upload Component
class FirmwareUploadComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('FirmwareUploadComponent: Constructor called');
logger.debug('FirmwareUploadComponent: Container:', container);
logger.debug('FirmwareUploadComponent: Container ID:', container?.id);
// Initialize overlay dialog
this.overlayDialog = null;
}
setupEventListeners() {
// Setup firmware file input
const firmwareFile = this.findElement('#firmware-file');
if (firmwareFile) {
this.addEventListener(firmwareFile, 'change', this.handleFileSelect.bind(this));
}
// Setup deploy button
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this));
}
// Setup WebSocket listener for real-time firmware upload status
this.setupWebSocketListeners();
}
setupViewModelListeners() {
this.subscribeToProperty('selectedFile', () => {
this.updateFileInfo();
this.updateDeployButton();
});
this.subscribeToProperty('isUploading', this.updateUploadState.bind(this));
this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this));
this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this));
}
setupWebSocketListeners() {
// Listen for real-time firmware upload status updates
window.wsClient.on('firmwareUploadStatus', (data) => {
this.handleFirmwareUploadStatus(data);
});
}
handleFirmwareUploadStatus(data) {
const { nodeIp, status, filename, fileSize, timestamp } = data;
logger.debug('FirmwareUploadComponent: Firmware upload status received:', { nodeIp, status, filename });
// Check if there's currently an upload in progress
const isUploading = this.viewModel.get('isUploading');
if (!isUploading) {
logger.debug('FirmwareUploadComponent: No active upload, ignoring status update');
return;
}
// Find the target node item for this node
const targetNodeItem = this.findElement(`[data-node-ip="${nodeIp}"]`);
if (!targetNodeItem) {
logger.debug('FirmwareUploadComponent: No target node item found for node:', nodeIp);
return;
}
// Update the status display based on the received status
const statusElement = targetNodeItem.querySelector('.status-indicator');
if (statusElement) {
let displayStatus = status;
let statusClass = '';
logger.debug(`FirmwareUploadComponent: Updating status for node ${nodeIp}: ${status} -> ${displayStatus}`);
switch (status) {
case 'uploading':
displayStatus = 'Uploading...';
statusClass = 'uploading';
break;
case 'completed':
displayStatus = 'Completed';
statusClass = 'success';
logger.debug(`FirmwareUploadComponent: Node ${nodeIp} marked as completed`);
break;
case 'failed':
displayStatus = 'Failed';
statusClass = 'error';
break;
default:
displayStatus = status;
break;
}
statusElement.textContent = displayStatus;
statusElement.className = `status-indicator ${statusClass}`;
}
// Update overall progress if we have multiple nodes
this.updateOverallProgressFromStatus();
// Check if all uploads are complete and finalize results
this.checkAndFinalizeUploadResults();
}
updateOverallProgressFromStatus() {
const targetNodeItems = Array.from(this.findAllElements('.target-node-item'));
if (targetNodeItems.length <= 1) {
return; // Only update for multi-node uploads
}
let completedCount = 0;
let failedCount = 0;
let uploadingCount = 0;
targetNodeItems.forEach(item => {
const statusElement = item.querySelector('.status-indicator');
if (statusElement) {
const status = statusElement.textContent;
if (status === 'Completed') {
completedCount++;
} else if (status === 'Failed') {
failedCount++;
} else if (status === 'Uploading...') {
uploadingCount++;
}
}
});
const totalNodes = targetNodeItems.length;
const successfulUploads = completedCount;
const successPercentage = Math.round((successfulUploads / totalNodes) * 100);
// Update overall progress bar
const progressBar = this.findElement('#overall-progress-bar');
const progressText = this.findElement('.progress-text');
if (progressBar && progressText) {
progressBar.style.width = `${successPercentage}%`;
// Update progress bar color based on completion
if (successPercentage === 100) {
progressBar.style.backgroundColor = '#4ade80';
} else if (successPercentage > 50) {
progressBar.style.backgroundColor = '#60a5fa';
} else {
progressBar.style.backgroundColor = '#fbbf24';
}
progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`;
}
// Update progress summary
const progressSummary = this.findElement('#progress-summary');
if (progressSummary) {
if (failedCount > 0) {
progressSummary.innerHTML = `<span>${window.icon('warning', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading, ${failedCount} failed)</span>`;
} else if (uploadingCount > 0) {
progressSummary.innerHTML = `<span>${window.icon('info', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading)</span>`;
} else if (completedCount === totalNodes) {
progressSummary.innerHTML = `<span>${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
}
}
}
checkAndFinalizeUploadResults() {
const targetNodeItems = Array.from(this.findAllElements('.target-node-item'));
if (targetNodeItems.length === 0) return;
// Check if all uploads are complete (either completed or failed)
let allComplete = true;
let hasAnyCompleted = false;
let hasAnyFailed = false;
let uploadingCount = 0;
const statuses = [];
targetNodeItems.forEach(item => {
const statusElement = item.querySelector('.status-indicator');
if (statusElement) {
const status = statusElement.textContent;
statuses.push(status);
if (status !== 'Completed' && status !== 'Failed') {
allComplete = false;
if (status === 'Uploading...') {
uploadingCount++;
}
}
if (status === 'Completed') {
hasAnyCompleted = true;
}
if (status === 'Failed') {
hasAnyFailed = true;
}
}
});
logger.debug('FirmwareUploadComponent: Upload status check:', {
totalItems: targetNodeItems.length,
allComplete,
uploadingCount,
hasAnyCompleted,
hasAnyFailed,
statuses
});
// If all uploads are complete, finalize the results
if (allComplete) {
logger.debug('FirmwareUploadComponent: All firmware uploads complete, finalizing results');
// Generate results based on current status
const results = targetNodeItems.map(item => {
const nodeIp = item.getAttribute('data-node-ip');
const nodeName = item.querySelector('.node-name')?.textContent || nodeIp;
const statusElement = item.querySelector('.status-indicator');
const status = statusElement?.textContent || 'Unknown';
return {
nodeIp: nodeIp,
hostname: nodeName,
success: status === 'Completed',
error: status === 'Failed' ? 'Upload failed' : undefined,
timestamp: new Date().toISOString()
};
});
// Update the header and summary to show final results
this.displayUploadResults(results);
// Hide the progress overlay since upload is complete
this.hideProgressOverlay();
// Now that all uploads are truly complete (confirmed via websocket), mark upload as complete
this.viewModel.completeUpload();
// Reset upload state after a short delay to allow user to see results and re-enable deploy button
setTimeout(() => {
this.viewModel.resetUploadState();
logger.debug('FirmwareUploadComponent: Upload state reset, deploy button should be re-enabled');
}, 5000);
} else if (uploadingCount > 0) {
logger.debug(`FirmwareUploadComponent: ${uploadingCount} uploads still in progress, not finalizing yet`);
} else {
logger.debug('FirmwareUploadComponent: Some uploads may have unknown status, but not finalizing yet');
}
}
mount() {
super.mount();
logger.debug('FirmwareUploadComponent: Mounting...');
// Initialize overlay dialog
this.initializeOverlayDialog();
// Initialize UI state
this.updateFileInfo();
this.updateDeployButton();
logger.debug('FirmwareUploadComponent: Mounted successfully');
}
render() {
// Initial render is handled by the HTML template
this.updateDeployButton();
}
initializeOverlayDialog() {
// Create overlay container if it doesn't exist
let overlayContainer = document.getElementById('firmware-upload-overlay-dialog');
if (!overlayContainer) {
overlayContainer = document.createElement('div');
overlayContainer.id = 'firmware-upload-overlay-dialog';
overlayContainer.className = 'overlay-dialog';
document.body.appendChild(overlayContainer);
}
// Create and initialize the overlay dialog component
if (!this.overlayDialog) {
const overlayVM = new ViewModel();
this.overlayDialog = new OverlayDialogComponent(overlayContainer, overlayVM, this.eventBus);
this.overlayDialog.mount();
}
}
showConfirmationDialog(options) {
if (!this.overlayDialog) {
this.initializeOverlayDialog();
}
this.overlayDialog.show(options);
}
handleFileSelect(event) {
const file = event.target.files[0];
this.viewModel.setSelectedFile(file);
}
async handleDeploy() {
const file = this.viewModel.get('selectedFile');
const targetNodes = this.viewModel.get('targetNodes');
if (!file) {
this.showConfirmationDialog({
title: 'No File Selected',
message: 'Please select a firmware file first.',
confirmText: 'OK',
cancelText: null,
onConfirm: () => {},
onCancel: null
});
return;
}
if (!targetNodes || targetNodes.length === 0) {
this.showConfirmationDialog({
title: 'No Target Nodes',
message: 'No target nodes available for firmware update.',
confirmText: 'OK',
cancelText: null,
onConfirm: () => {},
onCancel: null
});
return;
}
// Show confirmation dialog for deployment
this.showDeploymentConfirmation(file, targetNodes);
}
showDeploymentConfirmation(file, targetNodes) {
const title = 'Deploy Firmware';
const message = `Upload firmware "${file.name}" to ${targetNodes.length} node(s)?<br><br>Target nodes:<br>${targetNodes.map(n => `${n.hostname || n.ip} (${n.ip})`).join('<br>')}<br><br>This will update the firmware on all selected nodes.`;
this.showConfirmationDialog({
title: title,
message: message,
confirmText: 'Deploy',
cancelText: 'Cancel',
onConfirm: () => this.performDeployment(file, targetNodes),
onCancel: () => {}
});
}
async performDeployment(file, targetNodes) {
try {
this.viewModel.startUpload();
// Show progress overlay to block UI interactions
this.showProgressOverlay();
// Show upload progress area
this.showUploadProgress(file, targetNodes);
// Start batch upload
const results = await this.performBatchUpload(file, targetNodes);
// NOTE: Don't display results or reset state here!
// The upload state should remain active until websocket confirms completion
// Status updates and finalization happen via websocket messages in checkAndFinalizeUploadResults()
logger.debug('FirmwareUploadComponent: Firmware upload HTTP requests completed, waiting for websocket status updates');
} catch (error) {
logger.error('FirmwareUploadComponent: Firmware deployment failed:', error);
this.showConfirmationDialog({
title: 'Deployment Failed',
message: `Deployment failed: ${error.message}`,
confirmText: 'OK',
cancelText: null,
onConfirm: () => {},
onCancel: null
});
// Only complete upload on error
this.viewModel.completeUpload();
this.hideProgressOverlay();
}
}
async performBatchUpload(file, nodes) {
const results = [];
const totalNodes = nodes.length;
let successfulUploads = 0;
// Initialize all nodes as uploading first
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const nodeIp = node.ip;
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...');
}
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const nodeIp = node.ip;
try {
// Upload to this node (HTTP call just initiates the upload)
const result = await this.performSingleUpload(file, nodeIp);
// Don't immediately mark as completed - wait for websocket status
logger.debug(`FirmwareUploadComponent: Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`);
results.push(result);
} catch (error) {
logger.error(`FirmwareUploadComponent: Failed to upload to node ${nodeIp}:`, error);
const errorResult = {
nodeIp: nodeIp,
hostname: node.hostname || nodeIp,
success: false,
error: error.message,
timestamp: new Date().toISOString()
};
results.push(errorResult);
// For HTTP errors, we can immediately mark as failed since the upload didn't start
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed');
}
// Small delay between uploads
if (i < nodes.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
async performSingleUpload(file, nodeIp) {
try {
const result = await window.apiClient.uploadFirmware(file, nodeIp);
// IMPORTANT: This HTTP response is just an acknowledgment that the gateway received the file
// The actual firmware processing happens asynchronously on the device
// Status updates will come via WebSocket messages, NOT from this HTTP response
logger.debug(`FirmwareUploadComponent: HTTP acknowledgment received for ${nodeIp}:`, result);
logger.debug(`FirmwareUploadComponent: This does NOT mean upload is complete - waiting for WebSocket status updates`);
return {
nodeIp: nodeIp,
hostname: nodeIp,
httpAcknowledged: true, // Changed from 'success' to make it clear this is just HTTP ack
result: result,
timestamp: new Date().toISOString()
};
} catch (error) {
throw new Error(`Upload to ${nodeIp} failed: ${error.message}`);
}
}
showUploadProgress(file, nodes) {
// Update the target nodes section header to show upload progress
const targetNodesSection = this.findElement('.target-nodes-section');
if (targetNodesSection) {
const h3 = targetNodesSection.querySelector('h3');
if (h3) {
h3.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
<path d="M12 16V4"/>
<path d="M8 8l4-4 4 4"/>
<path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2"/>
</svg>
Firmware Upload Progress (${nodes.length} nodes)
`;
}
}
// Add progress info to the firmware-progress-container
const container = this.findElement('#firmware-progress-container');
if (container) {
const progressHTML = `
<div class="upload-progress-info">
<div class="overall-progress">
<div class="progress-bar-container">
<div class="progress-bar" id="overall-progress-bar" style="width: 0%; background-color: #fbbf24;"></div>
</div>
<span class="progress-text">0/${nodes.length} Successful (0%)</span>
</div>
<div class="progress-summary" id="progress-summary">
<span>Status: Upload in progress...</span>
</div>
</div>
`;
container.innerHTML = progressHTML;
}
// Update existing target nodes to show upload status
this.updateTargetNodesForUpload(nodes);
}
updateTargetNodesForUpload(nodes) {
const targetNodesList = this.findElement('.target-nodes-list');
if (!targetNodesList) return;
// Update each target node item to show upload status
targetNodesList.innerHTML = nodes.map(node => `
<div class="target-node-item" data-node-ip="${node.ip}">
<div class="node-info">
<span class="node-name">${node.hostname || node.ip}</span>
<span class="node-ip">${node.ip}</span>
</div>
<div class="node-status">
<span class="status-indicator uploading">Uploading...</span>
</div>
</div>
`).join('');
}
updateNodeProgress(current, total, nodeIp, status) {
const targetNodeItem = this.findElement(`[data-node-ip="${nodeIp}"]`);
if (targetNodeItem) {
const statusElement = targetNodeItem.querySelector('.status-indicator');
if (statusElement) {
statusElement.textContent = status;
// Update status-specific styling
statusElement.className = 'status-indicator';
if (status === 'Completed') {
statusElement.classList.add('success');
} else if (status === 'Failed') {
statusElement.classList.add('error');
} else if (status === 'Uploading...') {
statusElement.classList.add('uploading');
} else if (status === 'Pending...') {
statusElement.classList.add('pending');
}
}
}
}
updateOverallProgress(successfulUploads, totalNodes) {
const progressBar = this.findElement('#overall-progress-bar');
const progressText = this.findElement('.progress-text');
if (progressBar && progressText) {
const successPercentage = Math.round((successfulUploads / totalNodes) * 100);
progressBar.style.width = `${successPercentage}%`;
progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`;
// Update progress bar color based on completion
if (successPercentage === 100) {
progressBar.style.backgroundColor = '#4ade80';
} else if (successPercentage > 50) {
progressBar.style.backgroundColor = '#60a5fa';
} else {
progressBar.style.backgroundColor = '#fbbf24';
}
// NOTE: Don't update progress summary here for single-node uploads
// The summary should only be updated via websocket status updates
// This prevents premature "completed successfully" messages
}
}
displayUploadResults(results) {
const progressHeader = this.findElement('.progress-header h3');
const progressSummary = this.findElement('#progress-summary');
if (progressHeader && progressSummary) {
const successCount = results.filter(r => r.success).length;
const totalCount = results.length;
const successRate = Math.round((successCount / totalCount) * 100);
if (totalCount === 1) {
// Single node upload
if (successCount === 1) {
progressHeader.textContent = `Firmware Upload Complete`;
progressSummary.innerHTML = `<span>${window.icon('success', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
progressHeader.textContent = `Firmware Upload Failed`;
progressSummary.innerHTML = `<span>${window.icon('error', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}</span>`;
}
} else if (successCount === totalCount) {
// Multi-node upload - all successful
progressHeader.textContent = `Firmware Upload Complete (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
} else {
// Multi-node upload - some failed
progressHeader.textContent = `Firmware Upload Results (${successCount}/${totalCount} Successful)`;
progressSummary.innerHTML = `<span>${window.icon('warning', { width: 14, height: 14 })} Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}</span>`;
}
}
}
updateFileInfo() {
const file = this.viewModel.get('selectedFile');
const fileInfo = this.findElement('#file-info');
if (file) {
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
fileInfo.classList.add('has-file');
} else {
fileInfo.textContent = 'No file selected';
fileInfo.classList.remove('has-file');
}
this.updateDeployButton();
}
updateDeployButton() {
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
const file = this.viewModel.get('selectedFile');
const targetNodes = this.viewModel.get('targetNodes');
const isUploading = this.viewModel.get('isUploading');
deployBtn.disabled = !file || !targetNodes || targetNodes.length === 0 || isUploading;
}
}
updateUploadState() {
const isUploading = this.viewModel.get('isUploading');
const deployBtn = this.findElement('#deploy-btn');
if (deployBtn) {
deployBtn.disabled = isUploading;
if (isUploading) {
deployBtn.classList.add('loading');
// Update button text while keeping the SVG icon
const iconSvg = deployBtn.querySelector('svg');
deployBtn.innerHTML = '';
if (iconSvg) {
deployBtn.appendChild(iconSvg);
}
deployBtn.appendChild(document.createTextNode(' Deploying...'));
} else {
deployBtn.classList.remove('loading');
// Restore original button content with SVG icon
deployBtn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:6px; vertical-align: -2px;">
<path d="M12 16V4"/>
<path d="M8 8l4-4 4 4"/>
<path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2"/>
</svg>
Deploy
`;
}
}
}
updateUploadProgress() {
// This will be implemented when we add upload progress tracking
}
updateUploadResults() {
// This will be implemented when we add upload results display
}
showProgressOverlay() {
// Create overlay element that only covers the left side (main content area)
const overlay = document.createElement('div');
overlay.id = 'firmware-upload-overlay';
overlay.className = 'firmware-upload-overlay';
overlay.innerHTML = `
<div class="overlay-content">
<div class="overlay-spinner">
<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
</svg>
</div>
<div class="overlay-text">Firmware upload in progress...</div>
<div class="overlay-subtext">Check the drawer for detailed progress</div>
</div>
`;
// Add to body
document.body.appendChild(overlay);
// Check if drawer is open and adjust overlay accordingly
const drawer = document.querySelector('.details-drawer');
if (drawer && drawer.classList.contains('open')) {
overlay.classList.add('drawer-open');
}
// Block ESC key during upload
this.blockEscapeKey();
// Block drawer close button during upload
this.blockDrawerCloseButton();
// Block choose file button during upload
this.blockChooseFileButton();
// Store reference for cleanup
this.progressOverlay = overlay;
}
blockDrawerCloseButton() {
// Find the drawer close button
const closeButton = document.querySelector('.drawer-close');
if (closeButton) {
// Store original state
this.originalCloseButtonDisabled = closeButton.disabled;
this.originalCloseButtonStyle = closeButton.style.cssText;
// Disable the close button
closeButton.disabled = true;
closeButton.style.opacity = '0.5';
closeButton.style.cursor = 'not-allowed';
closeButton.style.pointerEvents = 'none';
// Add visual indicator that it's disabled
closeButton.title = 'Cannot close during firmware upload';
}
}
unblockDrawerCloseButton() {
// Restore the drawer close button
const closeButton = document.querySelector('.drawer-close');
if (closeButton) {
// Restore original state
closeButton.disabled = this.originalCloseButtonDisabled || false;
closeButton.style.cssText = this.originalCloseButtonStyle || '';
closeButton.title = 'Close';
}
}
blockChooseFileButton() {
// Find the choose file button
const chooseFileButton = document.querySelector('.upload-btn-compact');
if (chooseFileButton) {
// Store original state
this.originalChooseFileButtonDisabled = chooseFileButton.disabled;
this.originalChooseFileButtonStyle = chooseFileButton.style.cssText;
// Disable the choose file button
chooseFileButton.disabled = true;
chooseFileButton.style.opacity = '0.5';
chooseFileButton.style.cursor = 'not-allowed';
chooseFileButton.style.pointerEvents = 'none';
// Add visual indicator that it's disabled
chooseFileButton.title = 'Cannot change file during upload';
}
}
unblockChooseFileButton() {
// Restore the choose file button
const chooseFileButton = document.querySelector('.upload-btn-compact');
if (chooseFileButton) {
// Restore original state
chooseFileButton.disabled = this.originalChooseFileButtonDisabled || false;
chooseFileButton.style.cssText = this.originalChooseFileButtonStyle || '';
chooseFileButton.title = 'Choose File';
}
}
hideTargetNodesSection() {
// Find the target nodes section
const targetNodesSection = document.querySelector('.target-nodes-section');
if (targetNodesSection) {
// Store original state
this.originalTargetNodesSectionDisplay = targetNodesSection.style.display;
// Hide the target nodes section
targetNodesSection.style.display = 'none';
}
}
showTargetNodesSection() {
// Restore the target nodes section
const targetNodesSection = document.querySelector('.target-nodes-section');
if (targetNodesSection) {
// Restore original state
targetNodesSection.style.display = this.originalTargetNodesSectionDisplay || '';
}
}
blockEscapeKey() {
// Create a keydown event listener that prevents ESC from closing the drawer
this.escapeKeyHandler = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
return false;
}
};
// Add the event listener with capture=true to intercept before drawer's handler
document.addEventListener('keydown', this.escapeKeyHandler, true);
}
unblockEscapeKey() {
// Remove the ESC key blocker
if (this.escapeKeyHandler) {
document.removeEventListener('keydown', this.escapeKeyHandler, true);
this.escapeKeyHandler = null;
}
}
hideProgressOverlay() {
if (this.progressOverlay) {
this.progressOverlay.remove();
this.progressOverlay = null;
}
// Unblock ESC key
this.unblockEscapeKey();
// Unblock drawer close button
this.unblockDrawerCloseButton();
// Unblock choose file button
this.unblockChooseFileButton();
}
}
window.FirmwareUploadComponent = FirmwareUploadComponent;

View File

@@ -0,0 +1,67 @@
// Firmware View Component
class FirmwareViewComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('FirmwareViewComponent: Constructor called');
logger.debug('FirmwareViewComponent: Container:', container);
// Pass the entire firmware view container to the FirmwareComponent
logger.debug('FirmwareViewComponent: Using entire container for FirmwareComponent');
this.firmwareComponent = new FirmwareComponent(
container,
viewModel,
eventBus
);
logger.debug('FirmwareViewComponent: FirmwareComponent created');
}
mount() {
super.mount();
logger.debug('FirmwareViewComponent: Mounting...');
// Mount sub-component
this.firmwareComponent.mount();
logger.debug('FirmwareViewComponent: Mounted successfully');
}
unmount() {
// Unmount sub-component
if (this.firmwareComponent) {
this.firmwareComponent.unmount();
}
super.unmount();
}
// Override pause method to handle sub-components
onPause() {
logger.debug('FirmwareViewComponent: Pausing...');
// Pause sub-component
if (this.firmwareComponent && this.firmwareComponent.isMounted) {
this.firmwareComponent.pause();
}
}
// Override resume method to handle sub-components
onResume() {
logger.debug('FirmwareViewComponent: Resuming...');
// Resume sub-component
if (this.firmwareComponent && this.firmwareComponent.isMounted) {
this.firmwareComponent.resume();
}
}
// Override to determine if re-render is needed on resume
shouldRenderOnResume() {
// Don't re-render on resume - maintain current state
return false;
}
}

View File

@@ -0,0 +1,551 @@
// Monitoring View Component
class MonitoringViewComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('MonitoringViewComponent: Constructor called');
logger.debug('MonitoringViewComponent: Container:', container);
logger.debug('MonitoringViewComponent: Container ID:', container?.id);
// Track if we've already loaded data to prevent unnecessary reloads
this.dataLoaded = false;
// Drawer state for desktop (shared singleton)
this.drawer = new DrawerComponent();
}
mount() {
logger.debug('MonitoringViewComponent: Mounting...');
super.mount();
// Set up refresh button event listener
this.setupRefreshButton();
// Only load data if we haven't already or if the view model is empty
const clusterMembers = this.viewModel.get('clusterMembers');
if (!this.dataLoaded || !clusterMembers || clusterMembers.length === 0) {
this.loadData();
}
// Subscribe to view model changes
this.setupSubscriptions();
}
setupRefreshButton() {
const refreshBtn = this.findElement('#refresh-monitoring-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
this.refreshData();
});
}
}
setupSubscriptions() {
// Subscribe to cluster members changes
this.viewModel.subscribe('clusterMembers', () => {
this.render();
});
// Subscribe to node resources changes
this.viewModel.subscribe('nodeResources', () => {
this.render();
});
// Subscribe to cluster summary changes
this.viewModel.subscribe('clusterSummary', () => {
this.render();
});
// Subscribe to loading state changes
this.viewModel.subscribe('isLoading', () => {
this.render();
});
// Subscribe to error changes
this.viewModel.subscribe('error', () => {
this.render();
});
}
async loadData() {
logger.debug('MonitoringViewComponent: Loading data...');
this.dataLoaded = true;
await this.viewModel.loadClusterData();
}
async refreshData() {
logger.debug('MonitoringViewComponent: Refreshing data...');
await this.viewModel.refresh();
}
// Determine if we should use desktop drawer behavior
isDesktop() {
return this.drawer.isDesktop();
}
// Open drawer for a specific node
openDrawerForNode(nodeData) {
const { ip, hostname } = nodeData;
// Get display name for drawer title
let displayName = ip;
if (hostname && ip) {
displayName = `${hostname} - ${ip}`;
} else if (hostname) {
displayName = hostname;
} else if (ip) {
displayName = ip;
}
// Open drawer with content callback
this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
// Load and mount NodeDetails into drawer
const nodeDetailsVM = new NodeDetailsViewModel();
const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
setActiveComponent(nodeDetailsComponent);
nodeDetailsVM.loadNodeDetails(ip).then(() => {
nodeDetailsComponent.mount();
}).catch((error) => {
logger.error('Failed to load node details for drawer:', error);
contentContainer.innerHTML = `
<div class="error">
<strong>Error loading node details:</strong><br>
${this.escapeHtml(error.message)}
</div>
`;
});
});
}
closeDrawer() {
this.drawer.closeDrawer();
}
// Get color class based on utilization percentage (same logic as gauges)
getUtilizationColorClass(percentage) {
const numPercentage = parseFloat(percentage);
if (numPercentage === 0 || isNaN(numPercentage)) return 'utilization-empty';
if (numPercentage < 50) return 'utilization-green';
if (numPercentage < 80) return 'utilization-yellow';
return 'utilization-red';
}
// Format lastSeen timestamp to human readable format
formatLastSeen(lastSeen) {
if (!lastSeen) return 'Unknown';
// lastSeen appears to be in milliseconds
const now = Date.now();
const diff = now - lastSeen;
if (diff < 60000) { // Less than 1 minute
return 'Just now';
} else if (diff < 3600000) { // Less than 1 hour
const minutes = Math.floor(diff / 60000);
return `${minutes}m ago`;
} else if (diff < 86400000) { // Less than 1 day
const hours = Math.floor(diff / 3600000);
const minutes = Math.floor((diff % 3600000) / 60000);
return `${hours}h ${minutes}m ago`;
} else { // More than 1 day
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff % 86400000) / 3600000);
return `${days}d ${hours}h ago`;
}
}
// Format flash size in human readable format
formatFlashSize(bytes) {
if (!bytes || bytes === 0) return 'Unknown';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
// Clean up resources
cleanup() {
if (this.drawer) {
this.drawer.cleanup();
}
super.cleanup();
}
// Setup click event listeners for node cards
setupNodeCardClickListeners(container) {
const nodeCards = container.querySelectorAll('.node-card');
nodeCards.forEach(card => {
const nodeIp = card.dataset.nodeIp;
if (nodeIp) {
// Find the node data
const nodeResources = this.viewModel.get('nodeResources');
const nodeData = nodeResources.get(nodeIp);
if (nodeData) {
this.addEventListener(card, 'click', (e) => {
e.preventDefault();
e.stopPropagation();
this.openDrawerForNode(nodeData);
});
// Add hover cursor style
card.style.cursor = 'pointer';
}
}
});
}
render() {
logger.debug('MonitoringViewComponent: Rendering...');
const isLoading = this.viewModel.get('isLoading');
const error = this.viewModel.get('error');
const clusterSummary = this.viewModel.get('clusterSummary');
const nodeResources = this.viewModel.get('nodeResources');
const lastUpdated = this.viewModel.get('lastUpdated');
// Render cluster summary
this.renderClusterSummary(isLoading, error, clusterSummary, lastUpdated);
// Render nodes monitoring
this.renderNodesMonitoring(isLoading, error, nodeResources);
}
renderClusterSummary(isLoading, error, clusterSummary, lastUpdated) {
const container = this.findElement('#cluster-summary');
if (!container) return;
if (isLoading) {
container.innerHTML = `
<div class="loading">
<div>Loading cluster resource summary...</div>
</div>
`;
return;
}
if (error) {
container.innerHTML = `
<div class="error">
<div>${window.icon('error', { width: 14, height: 14, class: 'icon' })} Error: ${this.escapeHtml(String(error))}</div>
</div>
`;
return;
}
const cpuUtilization = this.viewModel.getResourceUtilization('Cpu');
const memoryUtilization = this.viewModel.getResourceUtilization('Memory');
const storageUtilization = this.viewModel.getResourceUtilization('Storage');
const lastUpdatedText = lastUpdated ?
`Last updated: ${lastUpdated.toLocaleTimeString()}` :
'Never updated';
container.innerHTML = `
<div class="cluster-summary-content">
<div class="summary-header">
<h3>Summary</h3>
<div class="last-updated">${lastUpdatedText}</div>
</div>
<div class="summary-stats">
<div class="stat-card">
<div class="stat-icon">${window.icon('computer', { width: 18, height: 18 })}</div>
<div class="stat-content">
<div class="stat-label">Total Nodes</div>
<div class="stat-value">${clusterSummary.totalNodes}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">${window.icon('cpu', { width: 18, height: 18 })}</div>
<div class="stat-content">
<div class="stat-label">CPU</div>
<div class="stat-value">${Math.round(clusterSummary.totalCpu - clusterSummary.availableCpu)}MHz / ${Math.round(clusterSummary.totalCpu)}MHz</div>
<div class="stat-utilization">
<div class="utilization-bar">
<div class="utilization-fill ${this.getUtilizationColorClass(cpuUtilization)}" style="width: ${cpuUtilization}%"></div>
</div>
<span class="utilization-text">${cpuUtilization}% used</span>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">${window.icon('memory', { width: 18, height: 18 })}</div>
<div class="stat-content">
<div class="stat-label">Memory</div>
<div class="stat-value">${this.viewModel.formatResourceValue(clusterSummary.totalMemory - clusterSummary.availableMemory, 'memory')} / ${this.viewModel.formatResourceValue(clusterSummary.totalMemory, 'memory')}</div>
<div class="stat-utilization">
<div class="utilization-bar">
<div class="utilization-fill ${this.getUtilizationColorClass(memoryUtilization)}" style="width: ${memoryUtilization}%"></div>
</div>
<span class="utilization-text">${memoryUtilization}% used</span>
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">${window.icon('storage', { width: 18, height: 18 })}</div>
<div class="stat-content">
<div class="stat-label">Storage</div>
<div class="stat-value">${this.viewModel.formatResourceValue(clusterSummary.totalStorage - clusterSummary.availableStorage, 'storage')} / ${this.viewModel.formatResourceValue(clusterSummary.totalStorage, 'storage')}</div>
<div class="stat-utilization">
<div class="utilization-bar">
<div class="utilization-fill ${this.getUtilizationColorClass(storageUtilization)}" style="width: ${storageUtilization}%"></div>
</div>
<span class="utilization-text">${storageUtilization}% used</span>
</div>
</div>
</div>
</div>
</div>
`;
}
renderNodesMonitoring(isLoading, error, nodeResources) {
const container = this.findElement('#nodes-monitoring');
if (!container) return;
if (isLoading) {
container.innerHTML = `
<div class="loading">
<div>Loading node resource data...</div>
</div>
`;
return;
}
if (error) {
container.innerHTML = `
<div class="error">
<div>${window.icon('error', { width: 14, height: 14, class: 'icon' })} Error: ${this.escapeHtml(String(error))}</div>
</div>
`;
return;
}
if (!nodeResources || nodeResources.size === 0) {
container.innerHTML = `
<div class="no-data">
<div>No node resource data available</div>
</div>
`;
return;
}
const nodesHtml = Array.from(nodeResources.values())
.sort((a, b) => {
// Sort by hostname, fallback to IP if hostname is not available
const hostnameA = a.hostname || a.ip || '';
const hostnameB = b.hostname || b.ip || '';
return hostnameA.localeCompare(hostnameB);
})
.map(nodeData => {
return this.renderNodeCard(nodeData);
}).join('');
const nodeCount = nodeResources.size;
container.innerHTML = `
<div class="nodes-monitoring-content">
<h3>${window.icon('computer', { width: 16, height: 16, class: 'icon', strokeWidth: 2 })} Node Resource Details</h3>
<div class="nodes-grid" data-item-count="${nodeCount}">
${nodesHtml}
</div>
</div>
`;
// Add click event listeners to node cards
this.setupNodeCardClickListeners(container);
}
renderNodeCard(nodeData) {
const { ip, hostname, resources, hasResources, error, resourceSource, labels } = nodeData;
if (!hasResources) {
return `
<div class="node-card error" data-node-ip="${ip}">
<div class="node-header">
<div class="status-hostname-group">
<div class="node-status-indicator status-offline">
${window.icon('dotRed', { width: 12, height: 12 })}
</div>
<div class="node-title">${hostname || ip}</div>
</div>
<div class="node-ip">${ip}</div>
</div>
${labels && Object.keys(labels).length > 0 ? `
<div class="node-labels">
<div class="labels-container">
${Object.entries(labels).map(([key, value]) =>
`<span class="label-chip">${key}: ${value}</span>`
).join('')}
</div>
<div class="labels-divider"></div>
</div>
` : ''}
<div class="node-error">
<div class="error-label">${window.icon('warning', { width: 14, height: 14 })} Error</div>
<div class="error-message">${error || 'Monitoring endpoint not available'}</div>
</div>
${nodeData.lastSeen ? `
<div class="node-uptime">
<div class="uptime-label">${window.icon('timer', { width: 14, height: 14 })} Last Seen</div>
<div class="uptime-value">${this.formatLastSeen(nodeData.lastSeen)}</div>
</div>
` : ''}
</div>
`;
}
// Extract resource data (handle both monitoring API and basic data formats)
const cpu = resources?.cpu || {};
const memory = resources?.memory || {};
const storage = resources?.filesystem || resources?.storage || {};
const system = resources?.system || {};
let cpuTotal, cpuAvailable, cpuUsed, cpuUtilization;
let memoryTotal, memoryAvailable, memoryUsed, memoryUtilization;
let storageTotal, storageAvailable, storageUsed, storageUtilization;
if (resourceSource === 'monitoring') {
// Real monitoring API format
const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80; // Get CPU frequency from basic resources
cpuTotal = cpuFreqMHz; // Total CPU frequency in MHz
cpuAvailable = cpuFreqMHz * (100 - (cpu.average_usage || 0)) / 100; // Available frequency
cpuUsed = cpuFreqMHz * (cpu.average_usage || 0) / 100; // Used frequency
cpuUtilization = Math.round(cpu.average_usage || 0);
memoryTotal = memory.total_heap || 0;
memoryAvailable = memory.free_heap || 0;
memoryUsed = memoryTotal - memoryAvailable;
memoryUtilization = memoryTotal > 0 ? Math.round((memoryUsed / memoryTotal) * 100) : 0;
storageTotal = storage.total_bytes || 0;
storageAvailable = storage.free_bytes || 0;
storageUsed = storageTotal - storageAvailable;
storageUtilization = storageTotal > 0 ? Math.round((storageUsed / storageTotal) * 100) : 0;
} else {
// Basic data format - use CPU frequency from basic resources
const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80;
cpuTotal = cpuFreqMHz; // Total CPU frequency in MHz
cpuAvailable = cpuFreqMHz * (cpu.available || 0.8); // Available frequency
cpuUsed = cpuFreqMHz * (cpu.used || 0.2); // Used frequency
cpuUtilization = cpuTotal > 0 ? Math.round((cpuUsed / cpuTotal) * 100) : 0;
memoryTotal = memory.total || 0;
memoryAvailable = memory.available || memory.free || 0;
memoryUsed = memoryTotal - memoryAvailable;
memoryUtilization = memoryTotal > 0 ? Math.round((memoryUsed / memoryTotal) * 100) : 0;
storageTotal = storage.total || 0;
storageAvailable = storage.available || storage.free || 0;
storageUsed = storageTotal - storageAvailable;
storageUtilization = storageTotal > 0 ? Math.round((storageUsed / storageTotal) * 100) : 0;
}
// Determine status indicator based on resource source
const statusIcon = resourceSource === 'monitoring' ? window.icon('dotGreen', { width: 12, height: 12 }) :
resourceSource === 'basic' ? window.icon('dotYellow', { width: 12, height: 12 }) : window.icon('dotRed', { width: 12, height: 12 });
const statusClass = resourceSource === 'monitoring' ? 'status-online' :
resourceSource === 'basic' ? 'status-warning' : 'status-offline';
return `
<div class="node-card" data-node-ip="${ip}">
<div class="node-header">
<div class="status-hostname-group">
<div class="node-status-indicator ${statusClass}">
${statusIcon}
</div>
<div class="node-title">${hostname || ip}</div>
</div>
<div class="node-ip">${ip}</div>
</div>
${labels && Object.keys(labels).length > 0 ? `
<div class="node-labels">
<div class="labels-container">
${Object.entries(labels).map(([key, value]) =>
`<span class="label-chip">${key}: ${value}</span>`
).join('')}
</div>
<div class="labels-divider"></div>
</div>
` : ''}
${system.uptime_formatted ? `
<div class="node-uptime">
<div class="uptime-label">${window.icon('timer', { width: 14, height: 14 })} Uptime</div>
<div class="uptime-value">${system.uptime_formatted}</div>
</div>
` : ''}
<div class="node-latency">
<div class="latency-label">${window.icon('latency', { width: 14, height: 14 })} Latency</div>
<div class="latency-value">${nodeData.latency ? `${nodeData.latency}ms` : 'N/A'}</div>
</div>
<div class="latency-divider"></div>
${(nodeData.basic?.flashChipSize || nodeData.resources?.flashChipSize) ? `
<div class="node-flash">
<div class="flash-label">${window.icon('storage', { width: 14, height: 14 })} Flash</div>
<div class="flash-value">${this.formatFlashSize(nodeData.basic?.flashChipSize || nodeData.resources?.flashChipSize)}</div>
</div>
` : ''}
<div class="node-resources">
<div class="resource-item">
<div class="resource-label">${window.icon('cpu', { width: 14, height: 14 })} CPU</div>
<div class="resource-value">
<span class="value-label">Used:</span> ${Math.round(cpuUsed)}MHz / <span class="value-label">Total:</span> ${Math.round(cpuTotal)}MHz
</div>
<div class="resource-utilization">
<div class="utilization-bar">
<div class="utilization-fill ${this.getUtilizationColorClass(cpuUtilization)}" style="width: ${cpuUtilization}%"></div>
</div>
<span class="utilization-text">${cpuUtilization}% used</span>
</div>
</div>
<div class="resource-item">
<div class="resource-label">${window.icon('memory', { width: 14, height: 14 })} Memory</div>
<div class="resource-value">
<span class="value-label">Used:</span> ${this.viewModel.formatResourceValue(memoryUsed, 'memory')} / <span class="value-label">Total:</span> ${this.viewModel.formatResourceValue(memoryTotal, 'memory')}
</div>
<div class="resource-utilization">
<div class="utilization-bar">
<div class="utilization-fill ${this.getUtilizationColorClass(memoryUtilization)}" style="width: ${memoryUtilization}%"></div>
</div>
<span class="utilization-text">${memoryUtilization}% used</span>
</div>
</div>
<div class="resource-item">
<div class="resource-label">${window.icon('storage', { width: 14, height: 14 })} Storage</div>
<div class="resource-value">
<span class="value-label">Used:</span> ${this.viewModel.formatResourceValue(storageUsed, 'storage')} / <span class="value-label">Total:</span> ${this.viewModel.formatResourceValue(storageTotal, 'storage')}
</div>
<div class="resource-utilization">
<div class="utilization-bar">
<div class="utilization-fill ${this.getUtilizationColorClass(storageUtilization)}" style="width: ${storageUtilization}%"></div>
</div>
<span class="utilization-text">${storageUtilization}% used</span>
</div>
</div>
</div>
</div>
`;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
// Overlay Dialog Component - Reusable confirmation dialog
class OverlayDialogComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
this.isVisible = false;
this.onConfirm = null;
this.onCancel = null;
this.title = '';
this.message = '';
this.confirmText = 'Yes';
this.cancelText = 'No';
this.confirmClass = 'overlay-dialog-btn-confirm';
this.showCloseButton = true;
}
mount() {
super.mount();
this.setupEventListeners();
}
setupEventListeners() {
// Close overlay when clicking outside or pressing escape
this.addEventListener(this.container, 'click', (e) => {
if (!this.isVisible) return;
if (e.target === this.container) {
this.hide();
}
});
this.addEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape' && this.isVisible) {
this.hide();
}
});
}
show(options = {}) {
const {
title = 'Confirm Action',
message = 'Are you sure you want to proceed?',
confirmText = 'Yes',
cancelText = 'No',
confirmClass = 'overlay-dialog-btn-confirm',
showCloseButton = true,
onConfirm = null,
onCancel = null
} = options;
this.title = title;
this.message = message;
this.confirmText = confirmText;
this.cancelText = cancelText;
this.confirmClass = confirmClass;
this.showCloseButton = showCloseButton;
this.onConfirm = onConfirm;
this.onCancel = onCancel;
this.render();
// Add visible class with small delay for animation
setTimeout(() => {
this.container.classList.add('visible');
}, 10);
this.isVisible = true;
}
hide() {
this.container.classList.remove('visible');
setTimeout(() => {
this.isVisible = false;
// Call cancel callback if provided
if (this.onCancel) {
this.onCancel();
this.onCancel = null;
}
}, 300);
}
handleConfirm() {
this.container.classList.remove('visible');
setTimeout(() => {
this.isVisible = false;
// Call confirm callback if provided
if (this.onConfirm) {
this.onConfirm();
this.onConfirm = null;
}
}, 300);
}
render() {
this.container.innerHTML = `
<div class="overlay-dialog-content">
<div class="overlay-dialog-header">
<h3 class="overlay-dialog-title">${this.escapeHtml(this.title)}</h3>
${this.showCloseButton ? `
<button class="overlay-dialog-close" type="button" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
` : ''}
</div>
<div class="overlay-dialog-body">
<p class="overlay-dialog-message">${this.message}</p>
</div>
<div class="overlay-dialog-footer">
${this.cancelText ? `
<button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button">
${this.escapeHtml(this.cancelText)}
</button>
` : ''}
<button class="overlay-dialog-btn ${this.confirmClass}" type="button">
${this.escapeHtml(this.confirmText)}
</button>
</div>
</div>
`;
// Add event listeners to buttons
const closeBtn = this.container.querySelector('.overlay-dialog-close');
const cancelBtn = this.container.querySelector('.overlay-dialog-btn-cancel');
const confirmBtn = this.container.querySelector(`.${this.confirmClass}`);
if (closeBtn) {
this.addEventListener(closeBtn, 'click', () => this.hide());
}
if (cancelBtn) {
this.addEventListener(cancelBtn, 'click', () => this.hide());
}
if (confirmBtn) {
this.addEventListener(confirmBtn, 'click', () => this.handleConfirm());
}
}
escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
unmount() {
// Clean up event listeners
this.removeAllEventListeners();
// Call parent unmount
super.unmount();
}
}
// Static utility methods for easy usage without mounting
OverlayDialogComponent.show = function(options) {
// Create a temporary container
const container = document.createElement('div');
container.className = 'overlay-dialog';
document.body.appendChild(container);
// Create component instance
const dialog = new OverlayDialogComponent(container, null, null);
// Override hide to clean up container
const originalHide = dialog.hide.bind(dialog);
dialog.hide = function() {
originalHide();
setTimeout(() => {
if (container.parentNode) {
document.body.removeChild(container);
}
}, 350);
};
// Override handleConfirm to clean up container
const originalHandleConfirm = dialog.handleConfirm.bind(dialog);
dialog.handleConfirm = function() {
originalHandleConfirm();
setTimeout(() => {
if (container.parentNode) {
document.body.removeChild(container);
}
}, 350);
};
dialog.mount();
dialog.show(options);
return dialog;
};
// Convenience method for confirmation dialogs
OverlayDialogComponent.confirm = function(options) {
return OverlayDialogComponent.show({
...options,
confirmClass: options.confirmClass || 'overlay-dialog-btn-confirm'
});
};
// Convenience method for danger/delete confirmations
OverlayDialogComponent.danger = function(options) {
return OverlayDialogComponent.show({
...options,
confirmClass: 'overlay-dialog-btn-danger'
});
};
// Convenience method for alerts
OverlayDialogComponent.alert = function(message, title = 'Notice') {
return OverlayDialogComponent.show({
title,
message,
confirmText: 'OK',
cancelText: null,
showCloseButton: false
});
};

View File

@@ -0,0 +1,90 @@
// Primary Node Component
class PrimaryNodeComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupEventListeners() {
const refreshBtn = this.findElement('.primary-node-refresh');
if (refreshBtn) {
this.addEventListener(refreshBtn, 'click', this.handleRandomSelection.bind(this));
}
}
setupViewModelListeners() {
// Listen to primary node changes
this.subscribeToProperty('primaryNode', this.render.bind(this));
this.subscribeToProperty('clientInitialized', this.render.bind(this));
this.subscribeToProperty('totalNodes', this.render.bind(this));
this.subscribeToProperty('onlineNodes', this.render.bind(this));
this.subscribeToProperty('error', this.render.bind(this));
}
render() {
const primaryNode = this.viewModel.get('primaryNode');
const clientInitialized = this.viewModel.get('clientInitialized');
const totalNodes = this.viewModel.get('totalNodes');
const onlineNodes = this.viewModel.get('onlineNodes');
const error = this.viewModel.get('error');
if (error) {
this.setText('#primary-node-ip', 'Discovery Failed');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
return;
}
if (!primaryNode) {
this.setText('#primary-node-ip', 'No Nodes Found');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
return;
}
const status = clientInitialized ? '' : '';
const nodeCount = (onlineNodes && onlineNodes > 0)
? ` (${onlineNodes}/${totalNodes} online)`
: (totalNodes > 1 ? ` (${totalNodes} nodes)` : '');
this.setText('#primary-node-ip', `${primaryNode}${nodeCount}`);
this.setClass('#primary-node-ip', 'error', false);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'selecting', false);
}
async handleRandomSelection() {
try {
// Show selecting state
this.setText('#primary-node-ip', 'Selecting...');
this.setClass('#primary-node-ip', 'selecting', true);
this.setClass('#primary-node-ip', 'discovering', false);
this.setClass('#primary-node-ip', 'error', false);
await this.viewModel.selectRandomPrimaryNode();
// Show success briefly
this.setText('#primary-node-ip', 'Selection Complete');
// Update display after delay
setTimeout(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 1500);
} catch (error) {
logger.error('Failed to select random primary node:', error);
this.setText('#primary-node-ip', 'Selection Failed');
this.setClass('#primary-node-ip', 'error', true);
this.setClass('#primary-node-ip', 'selecting', false);
this.setClass('#primary-node-ip', 'discovering', false);
// Revert to normal display after error
setTimeout(() => {
this.viewModel.updatePrimaryNodeDisplay();
}, 2000);
}
}
}
window.PrimaryNodeComponent = PrimaryNodeComponent;

View File

@@ -0,0 +1,271 @@
// Rollout Component - Shows rollout panel with matching nodes and starts rollout
class RolloutComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('RolloutComponent: Constructor called');
this.rolloutData = null;
this.matchingNodes = [];
this.onRolloutCallback = null;
this.onCancelCallback = null;
}
setupEventListeners() {
// Rollout button
const rolloutBtn = this.findElement('#rollout-confirm-btn');
if (rolloutBtn) {
this.addEventListener(rolloutBtn, 'click', this.handleRollout.bind(this));
}
// Cancel button
const cancelBtn = this.findElement('#rollout-cancel-btn');
if (cancelBtn) {
this.addEventListener(cancelBtn, 'click', this.handleCancel.bind(this));
}
}
// Start rollout - hide labels and show status indicators
startRollout() {
const nodeItems = this.container.querySelectorAll('.rollout-node-item');
nodeItems.forEach(item => {
const labelsDiv = item.querySelector('.rollout-node-labels');
const statusDiv = item.querySelector('.status-indicator');
if (labelsDiv && statusDiv) {
labelsDiv.style.display = 'none';
statusDiv.style.display = 'block';
statusDiv.textContent = 'Ready';
statusDiv.className = 'status-indicator ready';
}
});
// Disable the confirm button
const confirmBtn = this.findElement('#rollout-confirm-btn');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Rollout in Progress...';
}
}
// Update status for a specific node
updateNodeStatus(nodeIp, status) {
const nodeItem = this.container.querySelector(`[data-node-ip="${nodeIp}"]`);
if (!nodeItem) return;
const statusDiv = nodeItem.querySelector('.status-indicator');
if (!statusDiv) return;
let displayStatus = status;
let statusClass = '';
switch (status) {
case 'updating_labels':
displayStatus = 'Updating Labels...';
statusClass = 'uploading';
break;
case 'uploading':
displayStatus = 'Uploading...';
statusClass = 'uploading';
break;
case 'completed':
displayStatus = 'Completed';
statusClass = 'success';
break;
case 'failed':
displayStatus = 'Failed';
statusClass = 'error';
break;
default:
displayStatus = status;
statusClass = 'pending';
}
statusDiv.textContent = displayStatus;
statusDiv.className = `status-indicator ${statusClass}`;
}
// Check if rollout is complete
isRolloutComplete() {
const statusIndicators = this.container.querySelectorAll('.status-indicator');
for (const indicator of statusIndicators) {
const status = indicator.textContent.toLowerCase();
if (status !== 'completed' && status !== 'failed') {
return false;
}
}
return true;
}
// Reset to initial state (show labels, hide status indicators)
resetRolloutState() {
const nodeItems = this.container.querySelectorAll('.rollout-node-item');
nodeItems.forEach(item => {
const labelsDiv = item.querySelector('.rollout-node-labels');
const statusDiv = item.querySelector('.status-indicator');
if (labelsDiv && statusDiv) {
labelsDiv.style.display = 'block';
statusDiv.style.display = 'none';
}
});
// Re-enable the confirm button
const confirmBtn = this.findElement('#rollout-confirm-btn');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = `Rollout to ${this.matchingNodes.length} Node${this.matchingNodes.length !== 1 ? 's' : ''}`;
}
}
mount() {
super.mount();
logger.debug('RolloutComponent: Mounting...');
this.render();
logger.debug('RolloutComponent: Mounted successfully');
}
setRolloutData(name, version, labels, matchingNodes) {
this.rolloutData = { name, version, labels };
this.matchingNodes = matchingNodes;
}
setOnRolloutCallback(callback) {
this.onRolloutCallback = callback;
}
setOnCancelCallback(callback) {
this.onCancelCallback = callback;
}
render() {
if (!this.rolloutData) {
this.container.innerHTML = '<div class="error">No rollout data available</div>';
return;
}
const { name, version, labels } = this.rolloutData;
// Render labels as chips
const labelsHTML = Object.entries(labels).map(([key, value]) =>
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
).join('');
// Render matching nodes
const nodesHTML = this.matchingNodes.map(node => {
const nodeLabelsHTML = Object.entries(node.labels || {}).map(([key, value]) =>
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
).join('');
return `
<div class="rollout-node-item" data-node-ip="${node.ip}">
<div class="rollout-node-info">
<div class="rollout-node-ip">${this.escapeHtml(node.ip)}</div>
<div class="rollout-node-version">Version: ${this.escapeHtml(node.version)}</div>
</div>
<div class="rollout-node-status">
<div class="rollout-node-labels">
${nodeLabelsHTML}
</div>
<div class="status-indicator ready" style="display: none;">Ready</div>
</div>
</div>
`;
}).join('');
this.container.innerHTML = `
<div class="rollout-panel">
<div class="rollout-header">
<p>Deploy firmware to matching cluster nodes</p>
</div>
<div class="rollout-firmware-info">
<div class="rollout-firmware-name">${this.escapeHtml(name)}</div>
<div class="rollout-firmware-version">Version: ${this.escapeHtml(version)}</div>
<div class="rollout-firmware-labels">
${labelsHTML}
</div>
</div>
<div class="rollout-matching-nodes">
<h4>Matching Nodes (${this.matchingNodes.length})</h4>
<div class="rollout-nodes-list">
${nodesHTML}
</div>
</div>
<div class="rollout-warning">
<div class="warning-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<div class="warning-text">
<strong>Warning:</strong> This will update firmware on ${this.matchingNodes.length} node${this.matchingNodes.length !== 1 ? 's' : ''}.
The rollout process cannot be cancelled once started.
</div>
</div>
<div class="rollout-actions">
<button id="rollout-cancel-btn" class="refresh-btn">Cancel</button>
<button id="rollout-confirm-btn" class="deploy-btn" ${this.matchingNodes.length === 0 ? 'disabled' : ''}>
Rollout to ${this.matchingNodes.length} Node${this.matchingNodes.length !== 1 ? 's' : ''}
</button>
</div>
</div>
`;
this.setupEventListeners();
}
handleRollout() {
if (!this.onRolloutCallback || this.matchingNodes.length === 0) {
return;
}
const nodeCount = this.matchingNodes.length;
const nodePlural = nodeCount !== 1 ? 's' : '';
const { name, version } = this.rolloutData;
// Show confirmation dialog
OverlayDialogComponent.confirm({
title: 'Confirm Firmware Rollout',
message: `Are you sure you want to deploy firmware <strong>${this.escapeHtml(name)}</strong> version <strong>${this.escapeHtml(version)}</strong> to <strong>${nodeCount} node${nodePlural}</strong>?<br><br>The rollout process cannot be cancelled once started. All nodes will be updated and rebooted.`,
confirmText: `Rollout to ${nodeCount} Node${nodePlural}`,
cancelText: 'Cancel',
onConfirm: () => {
// Send the firmware info and matching nodes directly
const rolloutData = {
firmware: {
name: this.rolloutData.name,
version: this.rolloutData.version,
labels: this.rolloutData.labels
},
nodes: this.matchingNodes
};
this.onRolloutCallback(rolloutData);
}
});
}
handleCancel() {
if (this.onCancelCallback) {
this.onCancelCallback();
}
}
escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
window.RolloutComponent = RolloutComponent;

View File

@@ -0,0 +1,391 @@
// Lightweight Terminal Panel singleton
(() => {
class TerminalPanelImpl {
constructor() {
this.container = null; // external container passed by DrawerComponent
this.panelEl = null;
this.logEl = null;
this.inputEl = null;
this.socket = null;
this.connectedIp = null;
this.isOpen = false;
this.resizeHandler = null;
this.isMinimized = false;
this.dockEl = null;
this.dockBtnEl = null;
this.lastNodeIp = null;
this.fallbackContainer = null;
try {
this._onKeydown = this._onKeydown.bind(this);
document.addEventListener('keydown', this._onKeydown);
} catch (_) {
// ignore if document not available
}
}
open(container, nodeIp) {
try {
this.container = container;
if (!this.container) return;
if (!this.panelEl) {
this._buildPanel();
}
this.lastNodeIp = nodeIp || this.lastNodeIp;
// Reset any leftover inline positioning so CSS centering applies
if (this.container) {
this.container.style.right = '';
this.container.style.left = '';
this.container.style.top = '';
this.container.style.bottom = '';
this.container.style.width = '';
}
if (!this.isMinimized) {
if (this.panelEl) {
this.panelEl.classList.remove('minimized');
this.panelEl.classList.remove('visible');
requestAnimationFrame(() => {
this.panelEl.classList.add('visible');
this.inputEl && this.inputEl.focus();
});
}
this._hideDock();
} else {
if (this.panelEl) {
this.panelEl.classList.remove('visible');
this.panelEl.classList.add('minimized');
}
this._showDock();
}
this.isOpen = true;
// Connect websocket
if (nodeIp) {
this._connect(nodeIp);
} else if (this.lastNodeIp && !this.socket) {
this._connect(this.lastNodeIp);
} else if (!this.socket && !this.lastNodeIp) {
this._updateTitle(null);
}
} catch (err) {
console.error('TerminalPanel.open error:', err);
}
}
close() {
try {
if (!this.isOpen) return;
this.panelEl && this.panelEl.classList.remove('visible');
this.isOpen = false;
this.isMinimized = false;
if (this.panelEl) this.panelEl.classList.remove('minimized');
this._hideDock();
if (this.socket) {
try { this.socket.close(); } catch (_) {}
this.socket = null;
}
} catch (err) {
console.error('TerminalPanel.close error:', err);
}
}
_buildPanel() {
// Ensure container baseline positioning
this.container.classList.add('terminal-panel-container');
// Create panel DOM
this.panelEl = document.createElement('div');
this.panelEl.className = 'terminal-panel';
this.panelEl.innerHTML = `
<div class="terminal-header">
<div class="terminal-title">Terminal</div>
<div class="terminal-actions">
<button class="terminal-minimize-btn" title="Minimize" aria-label="Minimize">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M5 19h14"/>
</svg>
</button>
<button class="terminal-close-btn" title="Close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="terminal-body">
<pre class="terminal-log"></pre>
</div>
<div class="terminal-input-row">
<input type="text" class="terminal-input" placeholder="Type and press Enter to send" />
<button class="terminal-clear-btn" title="Clear">Clear</button>
<button class="terminal-send-btn">Send</button>
</div>
`;
this.container.appendChild(this.panelEl);
this.logEl = this.panelEl.querySelector('.terminal-log');
this.inputEl = this.panelEl.querySelector('.terminal-input');
const sendBtn = this.panelEl.querySelector('.terminal-send-btn');
const closeBtn = this.panelEl.querySelector('.terminal-close-btn');
const clearBtn = this.panelEl.querySelector('.terminal-clear-btn');
const minimizeBtn = this.panelEl.querySelector('.terminal-minimize-btn');
const sendHandler = () => {
const value = (this.inputEl && this.inputEl.value) || '';
if (!value) return;
this._send(value);
this.inputEl.value = '';
};
if (sendBtn) sendBtn.addEventListener('click', (e) => { e.stopPropagation(); sendHandler(); });
if (this.inputEl) this.inputEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
sendHandler();
}
});
if (closeBtn) closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.close(); });
if (clearBtn) clearBtn.addEventListener('click', (e) => { e.stopPropagation(); this._clear(); });
if (minimizeBtn) minimizeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.minimize(); });
}
_connect(nodeIp) {
try {
this._updateTitle(nodeIp);
// Close previous socket if switching node
if (this.socket) {
try { this.socket.close(); } catch (_) {}
this.socket = null;
}
const protocol = (window.location && window.location.protocol === 'https:') ? 'wss' : 'ws';
const url = `${protocol}://${nodeIp}/ws`;
this.connectedIp = nodeIp;
this._appendLine(`[connecting] ${url}`);
const ws = new WebSocket(url);
this.socket = ws;
ws.addEventListener('open', () => {
this._appendLine('[open] WebSocket connection established');
});
ws.addEventListener('message', (evt) => {
let dataStr = typeof evt.data === 'string' ? evt.data : '[binary]';
// Decode any HTML entities so JSON isn't shown as &quot; etc.
dataStr = this._decodeHtmlEntities(dataStr);
// Try to pretty-print JSON if applicable
const trimmed = dataStr.trim();
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
const obj = JSON.parse(trimmed);
const pretty = JSON.stringify(obj, null, 2);
this._appendLine(pretty);
return;
} catch (_) {
// fall through if not valid JSON
}
}
this._appendLine(dataStr);
});
ws.addEventListener('error', (evt) => {
this._appendLine('[error] WebSocket error');
});
ws.addEventListener('close', () => {
this._appendLine('[close] WebSocket connection closed');
});
} catch (err) {
console.error('TerminalPanel._connect error:', err);
this._appendLine(`[error] ${err.message || err}`);
}
}
minimize() {
try {
if (!this.panelEl || this.isMinimized) return;
this.panelEl.classList.remove('visible');
this.panelEl.classList.add('minimized');
this.isMinimized = true;
this.isOpen = true;
this._showDock();
} catch (err) {
console.error('TerminalPanel.minimize error:', err);
}
}
restore() {
try {
if (!this.panelEl || !this.isMinimized) return;
this.panelEl.classList.remove('minimized');
requestAnimationFrame(() => {
this.panelEl.classList.add('visible');
this.inputEl && this.inputEl.focus();
});
this.isMinimized = false;
this.isOpen = true;
this._hideDock();
} catch (err) {
console.error('TerminalPanel.restore error:', err);
}
}
_ensureDock() {
if (this.dockEl) return this.dockEl;
const dock = document.createElement('div');
dock.className = 'terminal-dock';
const button = document.createElement('button');
button.type = 'button';
button.className = 'terminal-dock-btn';
button.title = 'Show Terminal';
button.setAttribute('aria-label', 'Show Terminal');
button.innerHTML = `
<span class="terminal-dock-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 17l6-6-6-6"></path>
<path d="M12 19h8"></path>
</svg>
</span>
<span class="terminal-dock-label">Terminal</span>
`;
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.restore();
});
dock.appendChild(button);
document.body.appendChild(dock);
this.dockEl = dock;
this.dockBtnEl = button;
return dock;
}
_showDock() {
const dock = this._ensureDock();
if (dock) dock.classList.add('visible');
}
_hideDock() {
if (this.dockEl) this.dockEl.classList.remove('visible');
}
_resolveContainer() {
if (this.container && document.body && document.body.contains(this.container)) {
return this.container;
}
const sharedDrawer = window.__sharedDrawerInstance;
if (sharedDrawer && sharedDrawer.terminalPanelContainer) {
return sharedDrawer.terminalPanelContainer;
}
if (this.fallbackContainer && document.body && document.body.contains(this.fallbackContainer)) {
return this.fallbackContainer;
}
if (typeof document !== 'undefined') {
const fallback = document.createElement('div');
fallback.className = 'terminal-panel-container';
document.body.appendChild(fallback);
this.fallbackContainer = fallback;
return fallback;
}
return null;
}
_onKeydown(event) {
try {
if (!event || event.defaultPrevented) return;
if (event.key !== 't' && event.key !== 'T') return;
if (event.repeat) return;
if (event.metaKey || event.ctrlKey || event.altKey) return;
const activeEl = document.activeElement;
if (activeEl) {
const tagName = activeEl.tagName;
const isEditable = activeEl.isContentEditable;
if (isEditable || tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
return;
}
}
event.preventDefault();
this.toggleVisibility();
} catch (_) {
// swallow errors from key handler to avoid breaking global listeners
}
}
toggleVisibility() {
try {
if (this.isOpen && !this.isMinimized) {
this.minimize();
return;
}
if (this.isOpen && this.isMinimized) {
this.restore();
return;
}
const targetContainer = this._resolveContainer();
if (!targetContainer) return;
const targetIp = this.lastNodeIp || this.connectedIp || null;
this.open(targetContainer, targetIp);
} catch (err) {
console.error('TerminalPanel.toggleVisibility error:', err);
}
}
_send(text) {
try {
if (!this.socket || this.socket.readyState !== 1) {
this._appendLine('[warn] Socket not open');
return;
}
this.socket.send(text);
this._appendLine(`> ${text}`);
} catch (err) {
this._appendLine(`[error] ${err.message || err}`);
}
}
_updateTitle(nodeIp) {
if (!this.panelEl) return;
const titleEl = this.panelEl.querySelector('.terminal-title');
if (!titleEl) return;
titleEl.textContent = nodeIp ? `Terminal — ${nodeIp}` : 'Terminal';
}
_clear() {
if (this.logEl) this.logEl.textContent = '';
}
_appendLine(line) {
if (!this.logEl) return;
const timestamp = new Date().toLocaleTimeString();
this.logEl.textContent += `[${timestamp}] ${line}\n`;
// Auto-scroll to bottom
const bodyEl = this.panelEl.querySelector('.terminal-body');
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
}
_decodeHtmlEntities(text) {
try {
const div = document.createElement('div');
div.innerHTML = text;
return div.textContent || div.innerText || '';
} catch (_) {
return text;
}
}
}
// Expose singleton API
window.TerminalPanel = window.TerminalPanel || new TerminalPanelImpl();
})();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,315 @@
// WiFi Configuration Component
class WiFiConfigComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
logger.debug('WiFiConfigComponent: Constructor called');
logger.debug('WiFiConfigComponent: Container:', container);
// Track form state
this.formValid = false;
}
mount() {
logger.debug('WiFiConfigComponent: Mounting...');
super.mount();
this.setupFormValidation();
this.setupApplyButton();
this.setupProgressDisplay();
// Initial validation to ensure button starts disabled
this.validateForm();
logger.debug('WiFiConfigComponent: Mounted successfully');
}
setupFormValidation() {
logger.debug('WiFiConfigComponent: Setting up form validation...');
const ssidInput = this.findElement('#wifi-ssid');
const passwordInput = this.findElement('#wifi-password');
const applyBtn = this.findElement('#apply-wifi-config');
if (!ssidInput || !passwordInput || !applyBtn) {
logger.error('WiFiConfigComponent: Required form elements not found');
return;
}
// Add input event listeners
this.addEventListener(ssidInput, 'input', this.validateForm.bind(this));
this.addEventListener(passwordInput, 'input', this.validateForm.bind(this));
// Initial validation
this.validateForm();
}
setupApplyButton() {
logger.debug('WiFiConfigComponent: Setting up apply button...');
const applyBtn = this.findElement('#apply-wifi-config');
if (applyBtn) {
this.addEventListener(applyBtn, 'click', this.handleApply.bind(this));
logger.debug('WiFiConfigComponent: Apply button event listener added');
} else {
logger.error('WiFiConfigComponent: Apply button not found');
}
}
setupProgressDisplay() {
logger.debug('WiFiConfigComponent: Setting up progress display...');
// Subscribe to view model changes
this.viewModel.subscribe('isConfiguring', (isConfiguring) => {
this.updateApplyButton(isConfiguring);
});
this.viewModel.subscribe('configProgress', (progress) => {
this.updateProgressDisplay(progress);
});
this.viewModel.subscribe('configResults', (results) => {
this.updateResultsDisplay(results);
});
}
validateForm() {
logger.debug('WiFiConfigComponent: Validating form...');
const ssidInput = this.findElement('#wifi-ssid');
const passwordInput = this.findElement('#wifi-password');
const applyBtn = this.findElement('#apply-wifi-config');
if (!ssidInput || !passwordInput || !applyBtn) {
return;
}
const ssid = ssidInput.value.trim();
const password = passwordInput.value.trim();
this.formValid = ssid.length > 0 && password.length > 0;
// Update apply button state
applyBtn.disabled = !this.formValid;
// Update view model
this.viewModel.setCredentials(ssid, password);
logger.debug('WiFiConfigComponent: Form validation complete. Valid:', this.formValid);
}
updateApplyButton(isConfiguring) {
logger.debug('WiFiConfigComponent: Updating apply button. Configuring:', isConfiguring);
const applyBtn = this.findElement('#apply-wifi-config');
if (!applyBtn) {
return;
}
if (isConfiguring) {
applyBtn.disabled = true;
applyBtn.classList.add('loading');
applyBtn.innerHTML = `Apply`;
} else {
applyBtn.disabled = !this.formValid;
applyBtn.classList.remove('loading');
applyBtn.innerHTML = `Apply`;
}
}
updateProgressDisplay(progress) {
logger.debug('WiFiConfigComponent: Updating progress display:', progress);
const progressContainer = this.findElement('#wifi-progress-container');
if (!progressContainer || !progress) {
return;
}
const { current, total, status } = progress;
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
progressContainer.innerHTML = `
<div class="upload-progress-info">
<div class="overall-progress">
<div class="progress-bar-container">
<div class="progress-bar" id="wifi-progress-bar" style="width: ${percentage}%; background-color: #60a5fa;"></div>
</div>
<span class="progress-text">${current}/${total} Configured (${percentage}%)</span>
</div>
<div class="progress-summary" id="wifi-progress-summary">
<span>Status: ${status}</span>
</div>
</div>
`;
}
showProgressBar(totalNodes) {
logger.debug('WiFiConfigComponent: Showing initial progress bar for', totalNodes, 'nodes');
const progressContainer = this.findElement('#wifi-progress-container');
if (!progressContainer) {
return;
}
progressContainer.innerHTML = `
<div class="upload-progress-info">
<div class="overall-progress">
<div class="progress-bar-container">
<div class="progress-bar" id="wifi-progress-bar" style="width: 0%; background-color: #60a5fa;"></div>
</div>
<span class="progress-text">0/${totalNodes} Configured (0%)</span>
</div>
<div class="progress-summary" id="wifi-progress-summary">
<span>Status: Preparing configuration...</span>
</div>
</div>
`;
}
updateResultsDisplay(results) {
logger.debug('WiFiConfigComponent: Updating results display:', results);
const progressContainer = this.findElement('#wifi-progress-container');
if (!progressContainer || !results || results.length === 0) {
return;
}
const resultsHtml = results.map(result => `
<div class="result-item ${result.success ? 'success' : 'error'}">
<div class="result-node">
<span class="node-name">${result.node.hostname || result.node.ip}</span>
<span class="node-ip">${result.node.ip}</span>
</div>
<div class="result-status">
<span class="status-indicator ${result.success ? 'success' : 'error'}">
${result.success ? 'Success' : 'Failed'}
</span>
</div>
${result.error ? `<div class="result-error">${this.escapeHtml(result.error)}</div>` : ''}
</div>
`).join('');
// Append results to existing progress container
const existingProgress = progressContainer.querySelector('.upload-progress-info');
if (existingProgress) {
existingProgress.innerHTML += `
<div class="results-section">
<div class="results-list">
${resultsHtml}
</div>
</div>
`;
}
}
async handleApply() {
logger.debug('WiFiConfigComponent: Apply button clicked');
if (!this.formValid) {
logger.warn('WiFiConfigComponent: Form is not valid, cannot apply');
return;
}
const ssid = this.findElement('#wifi-ssid').value.trim();
const password = this.findElement('#wifi-password').value.trim();
const targetNodes = this.viewModel.get('targetNodes');
logger.debug('WiFiConfigComponent: Applying WiFi config to', targetNodes.length, 'nodes');
logger.debug('WiFiConfigComponent: SSID:', ssid);
// Start configuration
this.viewModel.startConfiguration();
// Show initial progress bar
this.showProgressBar(targetNodes.length);
try {
// Update progress
this.viewModel.updateConfigProgress(0, targetNodes.length, 'Starting configuration...');
// Apply configuration to each node
for (let i = 0; i < targetNodes.length; i++) {
const node = targetNodes[i];
try {
logger.debug('WiFiConfigComponent: Configuring node:', node.ip);
// Update progress
this.viewModel.updateConfigProgress(i + 1, targetNodes.length, `Configuring ${node.hostname || node.ip}...`);
// Make API call to configure WiFi
const result = await this.configureNodeWiFi(node, ssid, password);
// Add successful result
this.viewModel.addConfigResult({
node,
success: true,
error: null
});
logger.debug('WiFiConfigComponent: Successfully configured node:', node.ip);
} catch (error) {
logger.error('WiFiConfigComponent: Failed to configure node:', node.ip, error);
// Add failed result
this.viewModel.addConfigResult({
node,
success: false,
error: error.message || 'Configuration failed'
});
}
// Small delay between requests to avoid overwhelming the nodes
if (i < targetNodes.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
// Complete configuration
this.viewModel.updateConfigProgress(targetNodes.length, targetNodes.length, 'Configuration complete');
this.viewModel.completeConfiguration();
logger.debug('WiFiConfigComponent: WiFi configuration completed');
} catch (error) {
logger.error('WiFiConfigComponent: WiFi configuration failed:', error);
this.viewModel.completeConfiguration();
}
}
async configureNodeWiFi(node, ssid, password) {
logger.debug('WiFiConfigComponent: Configuring WiFi for node:', node.ip);
const response = await window.apiClient.callEndpoint({
ip: node.ip,
method: 'POST',
uri: '/api/network/wifi/config',
params: [
{ name: 'ssid', value: ssid, location: 'body' },
{ name: 'password', value: password, location: 'body' }
]
});
// Check if the API call was successful based on the response structure
if (!response || response.status !== 200) {
const errorMessage = response?.data?.message || response?.error || 'Failed to configure WiFi';
throw new Error(errorMessage);
}
return response;
}
unmount() {
logger.debug('WiFiConfigComponent: Unmounting...');
super.unmount();
logger.debug('WiFiConfigComponent: Unmounted');
}
}
window.WiFiConfigComponent = WiFiConfigComponent;

View File

@@ -0,0 +1,27 @@
(function(){
const TIMING = {
NAV_COOLDOWN_MS: 300,
VIEW_FADE_OUT_MS: 150,
VIEW_FADE_IN_MS: 200,
VIEW_FADE_DELAY_MS: 50,
AUTO_REFRESH_MS: 30000,
PRIMARY_NODE_REFRESH_MS: 10000,
LOAD_GUARD_MS: 10000
};
const SELECTORS = {
NAV_TAB: '.nav-tab',
VIEW_CONTENT: '.view-content',
CLUSTER_STATUS: '.cluster-status'
};
const CLASSES = {
CLUSTER_STATUS_ONLINE: 'cluster-status-online',
CLUSTER_STATUS_OFFLINE: 'cluster-status-offline',
CLUSTER_STATUS_CONNECTING: 'cluster-status-connecting',
CLUSTER_STATUS_ERROR: 'cluster-status-error',
CLUSTER_STATUS_DISCOVERING: 'cluster-status-discovering'
};
window.CONSTANTS = window.CONSTANTS || { TIMING, SELECTORS, CLASSES };
})();

926
public/scripts/framework.js Normal file
View File

@@ -0,0 +1,926 @@
// SPORE UI Framework - Component-based architecture with pub/sub system
// Lightweight logger with level gating
const logger = {
debug: (...args) => { try { if (window && window.DEBUG) { console.debug(...args); } } catch (_) { /* no-op */ } },
info: (...args) => console.info(...args),
warn: (...args) => console.warn(...args),
error: (...args) => console.error(...args),
};
if (typeof window !== 'undefined') {
window.logger = window.logger || logger;
}
// Event Bus for pub/sub communication
class EventBus {
constructor() {
this.events = new Map();
}
// Subscribe to an event
subscribe(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(callback);
// Return unsubscribe function
return () => {
const callbacks = this.events.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
};
}
// Publish an event
publish(event, data) {
if (this.events.has(event)) {
this.events.get(event).forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`Error in event callback for ${event}:`, error);
}
});
}
}
// Unsubscribe from an event
unsubscribe(event, callback) {
if (this.events.has(event)) {
const callbacks = this.events.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
// Clear all events
clear() {
this.events.clear();
}
}
// Base ViewModel class with enhanced state management
class ViewModel {
constructor() {
this._data = {};
this._listeners = new Map();
this._eventBus = null;
this._uiState = new Map(); // Store UI state like active tabs, expanded cards, etc.
this._previousData = {}; // Store previous data for change detection
}
// Set the event bus for this view model
setEventBus(eventBus) {
this._eventBus = eventBus;
}
// Get data property
get(property) {
return this._data[property];
}
// Set data property and notify listeners
set(property, value) {
logger.debug(`ViewModel: Setting property '${property}' to:`, value);
// Check if the value has actually changed
const hasChanged = this._data[property] !== value;
if (hasChanged) {
// Store previous value for change detection
this._previousData[property] = this._data[property];
// Update the data
this._data[property] = value;
logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`);
this._notifyListeners(property, value, this._previousData[property]);
} else {
logger.debug(`ViewModel: Property '${property}' unchanged, skipping notification`);
}
}
// Set multiple properties at once with change detection
setMultiple(properties) {
const changedProperties = {};
// Determine changes and update previousData snapshot per key
Object.keys(properties).forEach(key => {
const newValue = properties[key];
const oldValue = this._data[key];
if (oldValue !== newValue) {
this._previousData[key] = oldValue;
changedProperties[key] = newValue;
}
});
// Apply all properties
Object.keys(properties).forEach(key => {
this._data[key] = properties[key];
});
// Notify listeners only for changed properties with accurate previous values
Object.keys(changedProperties).forEach(key => {
this._notifyListeners(key, this._data[key], this._previousData[key]);
});
if (Object.keys(changedProperties).length > 0) {
logger.debug(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
}
}
// Subscribe to property changes
subscribe(property, callback) {
if (!this._listeners.has(property)) {
this._listeners.set(property, []);
}
this._listeners.get(property).push(callback);
// Return unsubscribe function
return () => {
const callbacks = this._listeners.get(property);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
};
}
// Notify listeners of property changes
_notifyListeners(property, value, previousValue) {
logger.debug(`ViewModel: _notifyListeners called for property '${property}'`);
if (this._listeners.has(property)) {
const callbacks = this._listeners.get(property);
logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
callbacks.forEach((callback, index) => {
try {
logger.debug(`ViewModel: Calling listener ${index} for property '${property}'`);
callback(value, previousValue);
} catch (error) {
console.error(`Error in property listener for ${property}:`, error);
}
});
} else {
logger.debug(`ViewModel: No listeners found for property '${property}'`);
}
}
// Publish event to event bus
publish(event, data) {
if (this._eventBus) {
this._eventBus.publish(event, data);
}
}
// Get all data
getAll() {
return { ...this._data };
}
// Clear all data
clear() {
this._data = {};
this._listeners.clear();
}
// UI State Management Methods
setUIState(key, value) {
this._uiState.set(key, value);
}
getUIState(key) {
return this._uiState.get(key);
}
getAllUIState() {
return new Map(this._uiState);
}
clearUIState(key) {
if (key) {
this._uiState.delete(key);
} else {
this._uiState.clear();
}
}
// Check if a property has changed
hasChanged(property) {
return this._data[property] !== this._previousData[property];
}
// Get previous value of a property
getPrevious(property) {
return this._previousData[property];
}
// Batch update with change detection
batchUpdate(updates, options = {}) {
const { notifyChanges = true } = options;
// Track which keys actually change and what the previous values were
const changedKeys = [];
Object.keys(updates).forEach(key => {
const newValue = updates[key];
const oldValue = this._data[key];
if (oldValue !== newValue) {
this._previousData[key] = oldValue;
this._data[key] = newValue;
changedKeys.push(key);
} else {
// Still apply to ensure consistency if needed
this._data[key] = newValue;
}
});
// Notify listeners for changed keys
if (notifyChanges) {
changedKeys.forEach(key => {
this._notifyListeners(key, this._data[key], this._previousData[key]);
});
}
}
}
// Base Component class
class Component {
constructor(container, viewModel, eventBus) {
this.container = container;
this.viewModel = viewModel;
this.eventBus = eventBus;
this.isMounted = false;
this.unsubscribers = [];
this.uiState = new Map(); // Local UI state for this component
// Set event bus on view model
if (this.viewModel) {
this.viewModel.setEventBus(eventBus);
}
// Bind methods
this.render = this.render.bind(this);
this.mount = this.mount.bind(this);
this.unmount = this.unmount.bind(this);
this.updatePartial = this.updatePartial.bind(this);
}
// Mount the component
mount() {
if (this.isMounted) return;
logger.debug(`${this.constructor.name}: Starting mount...`);
this.isMounted = true;
this.setupEventListeners();
this.setupViewModelListeners();
this.render();
logger.debug(`${this.constructor.name}: Mounted successfully`);
}
// Unmount the component
unmount() {
if (!this.isMounted) return;
this.isMounted = false;
this.cleanupEventListeners();
this.cleanupViewModelListeners();
logger.debug(`${this.constructor.name} unmounted`);
}
// Pause the component (keep alive but pause activity)
pause() {
if (!this.isMounted) return;
logger.debug(`${this.constructor.name}: Pausing component`);
// Pause any active timers or animations
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
// Pause any ongoing operations
this.isPaused = true;
// Override in subclasses to pause specific functionality
this.onPause();
}
// Resume the component
resume() {
if (!this.isMounted || !this.isPaused) return;
logger.debug(`${this.constructor.name}: Resuming component`);
this.isPaused = false;
// Restart any necessary timers or operations
this.onResume();
// Re-render if needed
if (this.shouldRenderOnResume()) {
this.render();
}
}
// Override in subclasses to handle pause-specific logic
onPause() {
// Default implementation does nothing
}
// Override in subclasses to handle resume-specific logic
onResume() {
// Default implementation does nothing
}
// Override in subclasses to determine if re-render is needed on resume
shouldRenderOnResume() {
// Default: don't re-render on resume
return false;
}
// Setup event listeners (override in subclasses)
setupEventListeners() {
// Override in subclasses
}
// Setup view model listeners (override in subclasses)
setupViewModelListeners() {
// Override in subclasses
}
// Cleanup event listeners (override in subclasses)
cleanupEventListeners() {
// Override in subclasses
}
// Cleanup view model listeners (override in subclasses)
cleanupViewModelListeners() {
// Override in subclasses
}
// Render the component (override in subclasses)
render() {
// Override in subclasses
}
// Partial update method for efficient data updates
updatePartial(property, newValue, previousValue) {
// Override in subclasses to implement partial updates
logger.debug(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
}
// UI State Management Methods
setUIState(key, value) {
this.uiState.set(key, value);
// Also store in view model for persistence across refreshes
if (this.viewModel) {
this.viewModel.setUIState(key, value);
}
}
getUIState(key) {
// First try local state, then view model state
return this.uiState.get(key) || (this.viewModel ? this.viewModel.getUIState(key) : null);
}
getAllUIState() {
const localState = new Map(this.uiState);
const viewModelState = this.viewModel ? this.viewModel.getAllUIState() : new Map();
// Merge states, with local state taking precedence
const mergedState = new Map(viewModelState);
localState.forEach((value, key) => mergedState.set(key, value));
return mergedState;
}
clearUIState(key) {
if (key) {
this.uiState.delete(key);
if (this.viewModel) {
this.viewModel.clearUIState(key);
}
} else {
this.uiState.clear();
if (this.viewModel) {
this.viewModel.clearUIState();
}
}
}
// Restore UI state from view model
restoreUIState() {
if (this.viewModel) {
const viewModelState = this.viewModel.getAllUIState();
viewModelState.forEach((value, key) => {
this.uiState.set(key, value);
});
}
}
// Helper method to add event listener and track for cleanup
addEventListener(element, event, handler) {
element.addEventListener(event, handler);
this.unsubscribers.push(() => {
element.removeEventListener(event, handler);
});
}
// Helper method to subscribe to event bus and track for cleanup
subscribeToEvent(event, handler) {
const unsubscribe = this.eventBus.subscribe(event, handler);
this.unsubscribers.push(unsubscribe);
}
// Helper method to subscribe to view model property and track for cleanup
subscribeToProperty(property, handler) {
if (this.viewModel) {
const unsubscribe = this.viewModel.subscribe(property, (newValue, previousValue) => {
// Call handler with both new and previous values for change detection
handler(newValue, previousValue);
});
this.unsubscribers.push(unsubscribe);
}
}
// Helper method to find element within component container
findElement(selector) {
return this.container.querySelector(selector);
}
// Helper method to find all elements within component container
findAllElements(selector) {
return this.container.querySelectorAll(selector);
}
// Helper method to set innerHTML safely
setHTML(selector, html) {
logger.debug(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
let element;
if (selector === '') {
// Empty selector means set HTML on the component's container itself
element = this.container;
logger.debug(`${this.constructor.name}: Using component container for empty selector`);
} else {
// Find element within the component's container
element = this.findElement(selector);
}
if (element) {
logger.debug(`${this.constructor.name}: Element found, setting innerHTML`);
element.innerHTML = html;
logger.debug(`${this.constructor.name}: innerHTML set successfully`);
} else {
console.error(`${this.constructor.name}: Element not found for selector '${selector}'`);
}
}
// Helper method to set text content safely
setText(selector, text) {
const element = this.findElement(selector);
if (element) {
element.textContent = text;
}
}
// Helper method to add/remove CSS classes
setClass(selector, className, add = true) {
const element = this.findElement(selector);
if (element) {
if (add) {
element.classList.add(className);
} else {
element.classList.remove(className);
}
}
}
// Helper method to set CSS styles
setStyle(selector, property, value) {
const element = this.findElement(selector);
if (element) {
element.style[property] = value;
}
}
// Helper method to show/hide elements
setVisible(selector, visible) {
const element = this.findElement(selector);
if (element) {
element.style.display = visible ? '' : 'none';
}
}
// Helper method to enable/disable elements
setEnabled(selector, enabled) {
const element = this.findElement(selector);
if (element) {
element.disabled = !enabled;
}
}
// Reusable render helpers
renderLoading(customHtml) {
const html = customHtml || `
<div class="loading">
<div>Loading...</div>
</div>
`;
this.setHTML('', html);
}
renderError(message) {
const safe = this.escapeHtml(String(message || 'An error occurred'));
const html = `
<div class="error">
<strong>Error:</strong><br>
${safe}
</div>
`;
this.setHTML('', html);
}
renderEmpty(customHtml) {
const html = customHtml || `
<div class="empty-state">
<div>No data</div>
</div>
`;
this.setHTML('', html);
}
// Basic HTML escaping for dynamic values
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Tab helpers
setupTabs(container = this.container, options = {}) {
const { onChange } = options;
const tabButtons = container.querySelectorAll('.tab-button');
const tabContents = container.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
this.addEventListener(button, 'click', (e) => {
e.stopPropagation();
const targetTab = button.dataset.tab;
this.setActiveTab(targetTab, container);
if (typeof onChange === 'function') {
try { onChange(targetTab); } catch (_) {}
}
});
});
tabContents.forEach(content => {
this.addEventListener(content, 'click', (e) => {
e.stopPropagation();
});
});
}
setActiveTab(tabName, container = this.container) {
const tabButtons = container.querySelectorAll('.tab-button');
const tabContents = container.querySelectorAll('.tab-content');
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
const activeButton = container.querySelector(`[data-tab="${tabName}"]`);
const activeContent = container.querySelector(`#${tabName}-tab`);
if (activeButton) activeButton.classList.add('active');
if (activeContent) activeContent.classList.add('active');
logger.debug(`${this.constructor.name}: Active tab set to '${tabName}'`);
}
}
// Application class to manage components and routing
class App {
constructor() {
this.eventBus = new EventBus();
this.components = new Map();
this.currentView = null;
this.routes = new Map();
this.navigationInProgress = false;
this.navigationQueue = [];
this.lastNavigationTime = 0;
this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300; // cooldown between navigations
// Component cache to keep components alive
this.componentCache = new Map();
this.cachedViews = new Set();
}
// Register a route
registerRoute(name, componentClass, containerId, viewModel = null) {
this.routes.set(name, { componentClass, containerId, viewModel });
// Defer instantiation until navigation to reduce startup work
// this.preInitializeComponent(name, componentClass, containerId, viewModel);
}
// Pre-initialize component in cache
preInitializeComponent(name, componentClass, containerId, viewModel) {
const container = document.getElementById(containerId);
if (!container) return;
// Create component instance but don't mount it yet
const component = new componentClass(container, viewModel, this.eventBus);
component.routeName = name;
component.isCached = true;
// Store in cache
this.componentCache.set(name, component);
logger.debug(`App: Pre-initialized component for route '${name}'`);
}
// Navigate to a route
navigateTo(routeName, updateUrl = true) {
// Check cooldown period
const now = Date.now();
if (now - this.lastNavigationTime < this.navigationCooldown) {
logger.debug(`App: Navigation cooldown active, skipping route '${routeName}'`);
return;
}
// If navigation is already in progress, queue this request
if (this.navigationInProgress) {
logger.debug(`App: Navigation in progress, queuing route '${routeName}'`);
if (!this.navigationQueue.includes(routeName)) {
this.navigationQueue.push(routeName);
}
return;
}
// If trying to navigate to the same route, do nothing
if (this.currentView && this.currentView.routeName === routeName) {
logger.debug(`App: Already on route '${routeName}', skipping navigation`);
return;
}
// Update URL if requested
if (updateUrl) {
this.updateURL(routeName);
}
this.lastNavigationTime = now;
this.performNavigation(routeName);
}
// Update browser URL
updateURL(routeName) {
const url = `/${routeName}`;
if (window.location.pathname !== url) {
window.history.pushState({ route: routeName }, '', url);
logger.debug(`App: Updated URL to ${url}`);
}
}
// Get route from current URL
getRouteFromURL() {
const path = window.location.pathname;
// Remove leading slash and use as route name
const routeName = path.substring(1) || 'cluster'; // default to cluster
return routeName;
}
// Handle browser back/forward
handlePopState(event) {
if (event.state && event.state.route) {
logger.debug(`App: Handling popstate for route '${event.state.route}'`);
this.navigateTo(event.state.route, false); // Don't update URL again
} else {
// Fallback: parse URL
const routeName = this.getRouteFromURL();
logger.debug(`App: Handling popstate, navigating to '${routeName}'`);
this.navigateTo(routeName, false);
}
}
// Perform the actual navigation
async performNavigation(routeName) {
this.navigationInProgress = true;
try {
logger.debug(`App: Navigating to route '${routeName}'`);
const route = this.routes.get(routeName);
if (!route) {
console.error(`Route '${routeName}' not found`);
return;
}
logger.debug(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
// Get or create component from cache
let component = this.componentCache.get(routeName);
if (!component) {
logger.debug(`App: Component not in cache, creating new instance for '${routeName}'`);
const container = document.getElementById(route.containerId);
if (!container) {
console.error(`Container '${route.containerId}' not found`);
return;
}
component = new route.componentClass(container, route.viewModel, this.eventBus);
component.routeName = routeName;
component.isCached = true;
this.componentCache.set(routeName, component);
}
// Hide current view smoothly
if (this.currentView) {
logger.debug('App: Hiding current view');
await this.hideCurrentView();
}
// Show new view
logger.debug(`App: Showing new view '${routeName}'`);
await this.showView(routeName, component);
// Update navigation state
this.updateNavigation(routeName);
// Set as current view
this.currentView = component;
// Mark view as cached for future use
this.cachedViews.add(routeName);
logger.debug(`App: Navigation to '${routeName}' completed`);
} catch (error) {
console.error('App: Navigation failed:', error);
} finally {
this.navigationInProgress = false;
// Process any queued navigation requests
if (this.navigationQueue.length > 0) {
const nextRoute = this.navigationQueue.shift();
logger.debug(`App: Processing queued navigation to '${nextRoute}'`);
setTimeout(() => this.navigateTo(nextRoute), 100);
}
}
}
// Hide current view smoothly
async hideCurrentView() {
if (!this.currentView) return;
// If component is mounted, pause it instead of unmounting
if (this.currentView.isMounted) {
logger.debug('App: Pausing current view instead of unmounting');
this.currentView.pause();
}
// Fade out the container
if (this.currentView.container) {
this.currentView.container.style.opacity = '0';
this.currentView.container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150}ms ease-out`;
}
// Wait for fade out to complete
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150));
}
// Show view smoothly
async showView(routeName, component) {
const container = component.container;
// Ensure component is mounted (but not necessarily active); lazy-create now if needed
if (!component) {
const route = this.routes.get(routeName);
const container = document.getElementById(route.containerId);
component = new route.componentClass(container, route.viewModel, this.eventBus);
component.routeName = routeName;
component.isCached = true;
this.componentCache.set(routeName, component);
}
if (!component.isMounted) {
logger.debug(`App: Mounting component for '${routeName}'`);
component.mount();
} else {
logger.debug(`App: Resuming component for '${routeName}'`);
component.resume();
}
// Fade in the container
container.style.opacity = '0';
container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_IN_MS) || 200}ms ease-in`;
// Small delay to ensure smooth transition
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50));
// Fade in
container.style.opacity = '1';
}
// Update navigation state
updateNavigation(activeRoute) {
// Remove active class from all nav tabs
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
tab.classList.remove('active');
});
// Add active class to current route tab
const activeTab = document.querySelector(`[data-view="${activeRoute}"]`);
if (activeTab) {
activeTab.classList.add('active');
}
// Hide all view contents with smooth transition
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.VIEW_CONTENT) || '.view-content').forEach(view => {
view.classList.remove('active');
view.style.opacity = '0';
view.style.transition = 'opacity 0.15s ease-out';
});
// Show current view content with smooth transition
const activeView = document.getElementById(`${activeRoute}-view`);
if (activeView) {
activeView.classList.add('active');
// Small delay to ensure smooth transition
setTimeout(() => {
activeView.style.opacity = '1';
activeView.style.transition = 'opacity 0.2s ease-in';
}, 50);
}
}
// Register a component
registerComponent(name, component) {
this.components.set(name, component);
}
// Get a component by name
getComponent(name) {
return this.components.get(name);
}
// Get the event bus
getEventBus() {
return this.eventBus;
}
// Initialize the application
init() {
logger.debug('SPORE UI Framework initialized');
// Note: Navigation is now handled by the app initialization
// to ensure routes are registered before navigation
}
// Setup navigation
setupNavigation() {
// Intercept navigation link clicks
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault(); // Prevent default link behavior
const routeName = tab.dataset.view;
this.navigateTo(routeName);
});
});
// Handle browser back/forward buttons
window.addEventListener('popstate', (e) => this.handlePopState(e));
// Navigate to route based on current URL on initial load
const initialRoute = this.getRouteFromURL();
logger.debug(`App: Initial route from URL: '${initialRoute}'`);
// Set initial history state
window.history.replaceState({ route: initialRoute }, '', `/${initialRoute}`);
// Navigate to initial route
this.navigateTo(initialRoute, false); // Don't update URL since we just set it
}
// Clean up cached components (call when app is shutting down)
cleanup() {
logger.debug('App: Cleaning up cached components...');
this.componentCache.forEach((component, routeName) => {
if (component.isMounted) {
logger.debug(`App: Unmounting cached component '${routeName}'`);
component.unmount();
}
});
this.componentCache.clear();
this.cachedViews.clear();
}
}
// Global app instance
window.app = new App();

70
public/scripts/icons.js Normal file
View File

@@ -0,0 +1,70 @@
// Centralized SVG Icons for SPORE UI
// Usage: window.icon('cluster', {class: 'foo', width: 16, height: 16}) -> returns inline SVG string
(function(){
const toAttrs = (opts) => {
if (!opts) return '';
const attrs = [];
if (opts.class) attrs.push(`class="${opts.class}"`);
if (opts.width) attrs.push(`width="${opts.width}"`);
if (opts.height) attrs.push(`height="${opts.height}"`);
if (opts.strokeWidth) attrs.push(`stroke-width="${opts.strokeWidth}"`);
return attrs.join(' ');
};
const withSvg = (inner, opts) => {
const attr = toAttrs(opts);
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ${attr}>${inner}</svg>`;
};
const Icons = {
// Navigation / sections
cluster: (o) => withSvg(`<circle cx="12" cy="12" r="9"/><circle cx="8" cy="10" r="1.5"/><circle cx="16" cy="8" r="1.5"/><circle cx="14" cy="15" r="1.5"/><path d="M9 11l3 3M9 11l6-3"/>`, o),
topology: (o) => withSvg(`
<g transform="rotate(-60 12 12)">
<circle cx="12" cy="4" r="1.6"/>
<circle cx="19" cy="9" r="1.6"/>
<circle cx="16" cy="18" r="1.6"/>
<circle cx="8" cy="18" r="1.6"/>
<circle cx="5" cy="9" r="1.6"/>
<path d="M12 4L16 18M16 18L5 9M5 9L19 9M19 9L8 18M8 18L12 4"/>
</g>
`, o),
monitoring: (o) => withSvg(`<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>`, o),
firmware: (o) => withSvg(`<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/><path d="M12 8v8"/>`, o),
// Status / feedback
success: (o) => withSvg(`<path d="M20 6L9 17l-5-5"/>`, o),
warning: (o) => withSvg(`<path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L14.71 3.86a2 2 0 0 0-3.42 0z"/>`, o),
error: (o) => withSvg(`<path d="M18 6L6 18M6 6l12 12"/>`, o),
offlineDot: (o) => withSvg(`<circle cx="12" cy="12" r="6" fill="currentColor"/>`, o),
dotGreen: (o) => withSvg(`<circle cx="12" cy="12" r="6" fill="#10b981"/>`, o),
dotYellow: (o) => withSvg(`<circle cx="12" cy="12" r="6" fill="#f59e0b"/>`, o),
dotRed: (o) => withSvg(`<circle cx="12" cy="12" r="6" fill="#ef4444"/>`, o),
// Actions
refresh: (o) => withSvg(`<path d="M3 3v6h6"/><path d="M21 21v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L3 9m18 6-2.64 2.36A9 9 0 0 1 3.51 15"/>`, o),
terminal: (o) => withSvg(`<path d="M4 17l6-6-6-6"></path><path d="M12 19h8"></path>`, o),
chevronDown: (o) => withSvg(`<path d="M6 9l6 6 6-6"/>`, o),
upload: (o) => withSvg(`<path d="M12 16V4"/><path d="M8 8l4-4 4 4"/><path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2"/>`, o),
file: (o) => withSvg(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>`, o),
timer: (o) => withSvg(`<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>`, o),
cpu: (o) => withSvg(`<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>`, o),
memory: (o) => withSvg(`<rect x="4" y="8" width="16" height="8" rx="2"/><path d="M7 8v8M12 8v8M17 8v8"/>`, o),
storage: (o) => withSvg(`<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M7 10h10"/>`, o),
computer: (o) => withSvg(`<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/>`, o),
latency: (o) => withSvg(`<path d="M3 12h4l2 5 4-10 2 7 2-4h4"/>`, o)
};
function icon(name, opts){
const fn = Icons[name];
if (!fn) return '';
return fn(Object.assign({ width: 16, height: 16, strokeWidth: 2 }, opts || {}));
}
if (typeof window !== 'undefined') {
window.Icons = Icons;
window.icon = icon;
}
})();

1
public/scripts/index.js Normal file
View File

@@ -0,0 +1 @@
// intentionally empty placeholder

View File

@@ -0,0 +1,120 @@
// Theme Manager - Handles theme switching and persistence
class ThemeManager {
constructor() {
this.currentTheme = this.getStoredTheme() || 'dark';
this.themeToggle = document.getElementById('theme-toggle');
this.init();
}
init() {
// Apply stored theme on page load
this.applyTheme(this.currentTheme);
// Set up event listener for theme toggle
if (this.themeToggle) {
this.themeToggle.addEventListener('click', () => this.toggleTheme());
}
// Listen for system theme changes
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addListener((e) => {
if (this.getStoredTheme() === 'system') {
this.applyTheme(e.matches ? 'dark' : 'light');
}
});
}
}
getStoredTheme() {
try {
return localStorage.getItem('spore-ui-theme');
} catch (e) {
console.warn('Could not access localStorage for theme preference');
return 'dark';
}
}
setStoredTheme(theme) {
try {
localStorage.setItem('spore-ui-theme', theme);
} catch (e) {
console.warn('Could not save theme preference to localStorage');
}
}
applyTheme(theme) {
// Update data attribute on html element
document.documentElement.setAttribute('data-theme', theme);
// Update theme toggle icon
this.updateThemeIcon(theme);
// Store the theme preference
this.setStoredTheme(theme);
this.currentTheme = theme;
// Dispatch custom event for other components
window.dispatchEvent(new CustomEvent('themeChanged', {
detail: { theme: theme }
}));
}
updateThemeIcon(theme) {
if (!this.themeToggle) return;
const svg = this.themeToggle.querySelector('svg');
if (!svg) return;
// Update the SVG content based on theme
if (theme === 'light') {
// Sun icon for light theme
svg.innerHTML = `
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
`;
} else {
// Moon icon for dark theme
svg.innerHTML = `
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
`;
}
}
toggleTheme() {
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
this.applyTheme(newTheme);
// Add a subtle animation to the toggle button
if (this.themeToggle) {
this.themeToggle.style.transform = 'scale(0.9)';
setTimeout(() => {
this.themeToggle.style.transform = 'scale(1)';
}, 150);
}
}
// Method to get current theme (useful for other components)
getCurrentTheme() {
return this.currentTheme;
}
// Method to set theme programmatically
setTheme(theme) {
if (['dark', 'light'].includes(theme)) {
this.applyTheme(theme);
}
}
}
// Initialize theme manager when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
window.themeManager = new ThemeManager();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = ThemeManager;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

7550
public/styles/main.css Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1341
public/styles/theme.css Normal file

File diff suppressed because it is too large Load Diff

2
public/vendor/d3.v7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -23,8 +23,8 @@ async function main() {
await runExamples(client);
} else {
console.log('❌ No nodes discovered yet.');
console.log('💡 Start the backend server and send CLUSTER_DISCOVERY messages');
console.log('💡 Use: npm run test-discovery broadcast');
console.log('💡 Start the backend server and send CLUSTER_HEARTBEAT messages');
console.log('💡 Use: npm run test-heartbeat broadcast');
return;
}
} catch (error) {

View File

@@ -84,6 +84,14 @@ class SporeApiClient {
return this.request('GET', '/api/node/status');
}
/**
* Get node endpoints
* @returns {Promise<Object>} endpoints response
*/
async getCapabilities() {
return this.request('GET', '/api/node/endpoints');
}
/**
* Get cluster discovery information
* @returns {Promise<Object>} Cluster discovery response
@@ -199,15 +207,32 @@ class SporeApiClient {
/**
* Update firmware on the device
* @param {Buffer|Uint8Array} firmwareData - Firmware binary data
* @param {string} filename - Name of the firmware file
* @returns {Promise<Object>} Update response
*/
async updateFirmware(firmwareData, filename) {
// Send the raw firmware data directly to the SPORE device
// The SPORE device expects the file data, not re-encoded multipart
// Create multipart form data manually for Node.js compatibility
const boundary = '----WebKitFormBoundary' + Math.random().toString(16).substr(2, 8);
let body = '';
// Add the firmware file part
body += `--${boundary}\r\n`;
body += `Content-Disposition: form-data; name="firmware"; filename="${filename}"\r\n`;
body += 'Content-Type: application/octet-stream\r\n\r\n';
// Convert the body to Buffer and append the firmware data
const headerBuffer = Buffer.from(body, 'utf8');
const endBuffer = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
// Combine all parts
const finalBody = Buffer.concat([headerBuffer, firmwareData, endBuffer]);
// Send the multipart form data to the SPORE device
return this.request('POST', '/api/node/update', {
body: firmwareData,
body: finalBody,
headers: {
'Content-Type': 'application/octet-stream'
'Content-Type': `multipart/form-data; boundary=${boundary}`
}
});
}

View File

@@ -1,124 +0,0 @@
#!/usr/bin/env node
/**
* Demo script for UDP discovery functionality
* Monitors the discovery endpoints to show how nodes are discovered
*/
const http = require('http');
const BASE_URL = 'http://localhost:3001';
function makeRequest(path, method = 'GET') {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 3001,
path: path,
method: method,
headers: {
'Content-Type': 'application/json'
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const jsonData = JSON.parse(data);
resolve({ status: res.statusCode, data: jsonData });
} catch (error) {
resolve({ status: res.statusCode, data: data });
}
});
});
req.on('error', (error) => {
reject(error);
});
req.end();
});
}
async function checkHealth() {
try {
const response = await makeRequest('/api/health');
console.log('\n=== Health Check ===');
console.log(`Status: ${response.data.status}`);
console.log(`HTTP Service: ${response.data.services.http}`);
console.log(`UDP Service: ${response.data.services.udp}`);
console.log(`SPORE Client: ${response.data.services.sporeClient}`);
console.log(`Total Nodes: ${response.data.discovery.totalNodes}`);
console.log(`Primary Node: ${response.data.discovery.primaryNode || 'None'}`);
if (response.data.message) {
console.log(`Message: ${response.data.message}`);
}
} catch (error) {
console.error('Health check failed:', error.message);
}
}
async function checkDiscovery() {
try {
const response = await makeRequest('/api/discovery/nodes');
console.log('\n=== Discovery Status ===');
console.log(`Primary Node: ${response.data.primaryNode || 'None'}`);
console.log(`Total Nodes: ${response.data.totalNodes}`);
console.log(`Client Initialized: ${response.data.clientInitialized}`);
if (response.data.clientBaseUrl) {
console.log(`Client Base URL: ${response.data.clientBaseUrl}`);
}
if (response.data.nodes.length > 0) {
console.log('\nDiscovered Nodes:');
response.data.nodes.forEach((node, index) => {
console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`);
console.log(` Discovered: ${node.discoveredAt}`);
console.log(` Last Seen: ${node.lastSeen}`);
});
} else {
console.log('No nodes discovered yet.');
}
} catch (error) {
console.error('Discovery check failed:', error.message);
}
}
async function runDemo() {
console.log('🚀 SPORE UDP Discovery Demo');
console.log('============================');
console.log('This demo monitors the discovery endpoints to show how nodes are discovered.');
console.log('Start the backend server with: npm start');
console.log('Send discovery messages with: npm run test-discovery broadcast');
console.log('');
// Initial check
await checkHealth();
await checkDiscovery();
// Set up periodic monitoring
console.log('\n📡 Monitoring discovery endpoints every 5 seconds...');
console.log('Press Ctrl+C to stop\n');
setInterval(async () => {
await checkHealth();
await checkDiscovery();
}, 5000);
}
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n👋 Demo stopped. Goodbye!');
process.exit(0);
});
// Run the demo
runDemo().catch(console.error);

132
test/mock-api-client.js Normal file
View File

@@ -0,0 +1,132 @@
// Mock API Client for communicating with the mock server
// This replaces the original API client to use port 3002
class MockApiClient {
constructor() {
// Use port 3002 for mock server
const currentHost = window.location.hostname;
this.baseUrl = `http://${currentHost}:3002`;
console.log('Mock API Client initialized with base URL:', this.baseUrl);
}
async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) {
const url = new URL(`${this.baseUrl}${path}`);
if (query && typeof query === 'object') {
Object.entries(query).forEach(([k, v]) => {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
});
}
const finalHeaders = { 'Accept': 'application/json', ...headers };
const options = { method, headers: finalHeaders };
if (body !== undefined) {
if (isForm) {
options.body = body;
} else {
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
options.body = typeof body === 'string' ? body : JSON.stringify(body);
}
}
const response = await fetch(url.toString(), options);
let data;
const text = await response.text();
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text; // Non-JSON payload
}
if (!response.ok) {
const message = (data && data.message) || `HTTP ${response.status}: ${response.statusText}`;
throw new Error(message);
}
return data;
}
async getClusterMembers() {
return this.request('/api/cluster/members', { method: 'GET' });
}
async getClusterMembersFromNode(ip) {
return this.request(`/api/cluster/members`, {
method: 'GET',
query: { ip: ip }
});
}
async getDiscoveryInfo() {
return this.request('/api/discovery/nodes', { method: 'GET' });
}
async selectRandomPrimaryNode() {
return this.request('/api/discovery/random-primary', {
method: 'POST',
body: { timestamp: new Date().toISOString() }
});
}
async getNodeStatus(ip) {
return this.request('/api/node/status', {
method: 'GET',
query: { ip: ip }
});
}
async getTasksStatus(ip) {
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
}
async getEndpoints(ip) {
return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined });
}
async callEndpoint({ ip, method, uri, params }) {
return this.request('/api/proxy-call', {
method: 'POST',
body: { ip, method, uri, params }
});
}
async uploadFirmware(file, nodeIp) {
const formData = new FormData();
formData.append('file', file);
const data = await this.request(`/api/node/update`, {
method: 'POST',
query: { ip: nodeIp },
body: formData,
isForm: true,
headers: {},
});
// Some endpoints may return HTTP 200 with success=false on logical failure
if (data && data.success === false) {
const message = data.message || 'Firmware upload failed';
throw new Error(message);
}
return data;
}
async getMonitoringResources(ip) {
return this.request('/api/proxy-call', {
method: 'POST',
body: {
ip: ip,
method: 'GET',
uri: '/api/monitoring/resources',
params: []
}
});
}
}
// Override the global API client
window.apiClient = new MockApiClient();
// Add debugging
console.log('Mock API Client loaded and initialized');
console.log('API Client base URL:', window.apiClient.baseUrl);
// Test API call
window.apiClient.getDiscoveryInfo().then(data => {
console.log('Mock API test successful:', data);
}).catch(error => {
console.error('Mock API test failed:', error);
});

232
test/mock-cli.js Normal file
View File

@@ -0,0 +1,232 @@
#!/usr/bin/env node
/**
* Mock Server CLI Tool
*
* Command-line interface for managing the SPORE UI mock server
* with different configurations and scenarios
*/
const { spawn } = require('child_process');
const path = require('path');
const { getMockConfig, listMockConfigs, createCustomConfig } = require('./mock-configs');
// Colors for console output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
};
function colorize(text, color) {
return `${colors[color]}${text}${colors.reset}`;
}
function printHeader() {
console.log(colorize('🚀 SPORE UI Mock Server CLI', 'cyan'));
console.log(colorize('=============================', 'cyan'));
console.log('');
}
function printHelp() {
console.log('Usage: node mock-cli.js <command> [options]');
console.log('');
console.log('Commands:');
console.log(' start [config] Start mock server with specified config');
console.log(' list List available configurations');
console.log(' info <config> Show detailed info about a configuration');
console.log(' help Show this help message');
console.log('');
console.log('Available Configurations:');
listMockConfigs().forEach(config => {
console.log(` ${colorize(config.name, 'green')} - ${config.description} (${config.nodeCount} nodes)`);
});
console.log('');
console.log('Examples:');
console.log(' node mock-cli.js start healthy');
console.log(' node mock-cli.js start degraded');
console.log(' node mock-cli.js list');
console.log(' node mock-cli.js info large');
}
function printConfigInfo(configName) {
const config = getMockConfig(configName);
console.log(colorize(`📋 Configuration: ${config.name}`, 'blue'));
console.log(colorize('='.repeat(50), 'blue'));
console.log(`Description: ${config.description}`);
console.log(`Nodes: ${config.nodes.length}`);
console.log('');
if (config.nodes.length > 0) {
console.log(colorize('🌐 Mock Nodes:', 'yellow'));
config.nodes.forEach((node, index) => {
const statusColor = node.status === 'ACTIVE' ? 'green' :
node.status === 'INACTIVE' ? 'yellow' : 'red';
console.log(` ${index + 1}. ${colorize(node.hostname, 'cyan')} (${node.ip}) - ${colorize(node.status, statusColor)}`);
});
console.log('');
}
console.log(colorize('⚙️ Simulation Settings:', 'yellow'));
console.log(` Time Progression: ${config.simulation.enableTimeProgression ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
console.log(` Random Failures: ${config.simulation.enableRandomFailures ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
if (config.simulation.enableRandomFailures) {
console.log(` Failure Rate: ${(config.simulation.failureRate * 100).toFixed(1)}%`);
}
console.log(` Update Interval: ${config.simulation.updateInterval}ms`);
console.log(` Primary Rotation: ${config.simulation.primaryNodeRotation ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
if (config.simulation.primaryNodeRotation) {
console.log(` Rotation Interval: ${config.simulation.rotationInterval}ms`);
}
console.log('');
}
function startMockServer(configName) {
const config = getMockConfig(configName);
console.log(colorize(`🚀 Starting mock server with '${config.name}' configuration...`, 'green'));
console.log('');
// Set environment variables for the mock server
const env = {
...process.env,
MOCK_CONFIG: configName,
MOCK_PORT: process.env.MOCK_PORT || '3002'
};
// Start the mock server
const mockServerPath = path.join(__dirname, 'mock-server.js');
const child = spawn('node', [mockServerPath], {
env: env,
stdio: 'inherit',
cwd: path.join(__dirname, '..')
});
// Handle process termination
process.on('SIGINT', () => {
console.log(colorize('\n\n🛑 Stopping mock server...', 'yellow'));
child.kill('SIGINT');
process.exit(0);
});
child.on('close', (code) => {
if (code !== 0) {
console.log(colorize(`\n❌ Mock server exited with code ${code}`, 'red'));
} else {
console.log(colorize('\n✅ Mock server stopped gracefully', 'green'));
}
});
child.on('error', (error) => {
console.error(colorize(`\n❌ Failed to start mock server: ${error.message}`, 'red'));
process.exit(1);
});
}
function listConfigurations() {
console.log(colorize('📋 Available Mock Configurations', 'blue'));
console.log(colorize('================================', 'blue'));
console.log('');
const configs = listMockConfigs();
configs.forEach(config => {
console.log(colorize(`🔧 ${config.displayName}`, 'green'));
console.log(` Key: ${colorize(config.name, 'cyan')}`);
console.log(` Description: ${config.description}`);
console.log(` Nodes: ${config.nodeCount}`);
console.log('');
});
console.log(colorize('💡 Usage:', 'yellow'));
console.log(' node mock-cli.js start <config-key>');
console.log(' node mock-cli.js info <config-key>');
console.log('');
}
// Main CLI logic
function main() {
const args = process.argv.slice(2);
const command = args[0];
const configName = args[1];
printHeader();
switch (command) {
case 'start':
if (!configName) {
console.log(colorize('❌ Error: Configuration name required', 'red'));
console.log('Usage: node mock-cli.js start <config-name>');
console.log('Run "node mock-cli.js list" to see available configurations');
process.exit(1);
}
const config = getMockConfig(configName);
if (!config) {
console.log(colorize(`❌ Error: Unknown configuration '${configName}'`, 'red'));
console.log('Run "node mock-cli.js list" to see available configurations');
process.exit(1);
}
printConfigInfo(configName);
startMockServer(configName);
break;
case 'list':
listConfigurations();
break;
case 'info':
if (!configName) {
console.log(colorize('❌ Error: Configuration name required', 'red'));
console.log('Usage: node mock-cli.js info <config-name>');
console.log('Run "node mock-cli.js list" to see available configurations');
process.exit(1);
}
const infoConfig = getMockConfig(configName);
if (!infoConfig) {
console.log(colorize(`❌ Error: Unknown configuration '${configName}'`, 'red'));
console.log('Run "node mock-cli.js list" to see available configurations');
process.exit(1);
}
printConfigInfo(configName);
break;
case 'help':
case '--help':
case '-h':
printHelp();
break;
default:
if (!command) {
console.log(colorize('❌ Error: Command required', 'red'));
console.log('');
printHelp();
} else {
console.log(colorize(`❌ Error: Unknown command '${command}'`, 'red'));
console.log('');
printHelp();
}
process.exit(1);
}
}
// Run the CLI
if (require.main === module) {
main();
}
module.exports = {
getMockConfig,
listMockConfigs,
printConfigInfo,
startMockServer
};

291
test/mock-configs.js Normal file
View File

@@ -0,0 +1,291 @@
/**
* Mock Configuration Presets
*
* Different scenarios for testing the SPORE UI with various conditions
*/
const mockConfigs = {
// Default healthy cluster
healthy: {
name: "Healthy Cluster",
description: "All nodes active and functioning normally",
nodes: [
{
ip: '192.168.1.100',
hostname: 'spore-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
},
{
ip: '192.168.1.101',
hostname: 'spore-node-2',
chipId: 87654321,
status: 'ACTIVE',
latency: 8
},
{
ip: '192.168.1.102',
hostname: 'spore-node-3',
chipId: 11223344,
status: 'ACTIVE',
latency: 12
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
},
// Single node scenario
single: {
name: "Single Node",
description: "Only one node in the cluster",
nodes: [
{
ip: '192.168.1.100',
hostname: 'spore-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
},
// Large cluster
large: {
name: "Large Cluster",
description: "Many nodes in the cluster",
nodes: [
{ ip: '192.168.1.100', hostname: 'spore-node-1', chipId: 12345678, status: 'ACTIVE', latency: 5 },
{ ip: '192.168.1.101', hostname: 'spore-node-2', chipId: 87654321, status: 'ACTIVE', latency: 8 },
{ ip: '192.168.1.102', hostname: 'spore-node-3', chipId: 11223344, status: 'ACTIVE', latency: 12 },
{ ip: '192.168.1.103', hostname: 'spore-node-4', chipId: 44332211, status: 'ACTIVE', latency: 15 },
{ ip: '192.168.1.104', hostname: 'spore-node-5', chipId: 55667788, status: 'ACTIVE', latency: 7 },
{ ip: '192.168.1.105', hostname: 'spore-node-6', chipId: 99887766, status: 'ACTIVE', latency: 20 },
{ ip: '192.168.1.106', hostname: 'spore-node-7', chipId: 11223355, status: 'ACTIVE', latency: 9 },
{ ip: '192.168.1.107', hostname: 'spore-node-8', chipId: 66778899, status: 'ACTIVE', latency: 11 }
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: true,
rotationInterval: 30000
}
},
// Degraded cluster with some failures
degraded: {
name: "Degraded Cluster",
description: "Some nodes are inactive or dead",
nodes: [
{
ip: '192.168.1.100',
hostname: 'spore-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
},
{
ip: '192.168.1.101',
hostname: 'spore-node-2',
chipId: 87654321,
status: 'INACTIVE',
latency: 8
},
{
ip: '192.168.1.102',
hostname: 'spore-node-3',
chipId: 11223344,
status: 'DEAD',
latency: 12
},
{
ip: '192.168.1.103',
hostname: 'spore-node-4',
chipId: 44332211,
status: 'ACTIVE',
latency: 15
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: true,
failureRate: 0.1,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
},
// High failure rate scenario
unstable: {
name: "Unstable Cluster",
description: "High failure rate with frequent node changes",
nodes: [
{
ip: '192.168.1.100',
hostname: 'spore-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
},
{
ip: '192.168.1.101',
hostname: 'spore-node-2',
chipId: 87654321,
status: 'ACTIVE',
latency: 8
},
{
ip: '192.168.1.102',
hostname: 'spore-node-3',
chipId: 11223344,
status: 'ACTIVE',
latency: 12
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: true,
failureRate: 0.3, // 30% chance of failures
updateInterval: 2000, // Update every 2 seconds
primaryNodeRotation: true,
rotationInterval: 15000 // Rotate every 15 seconds
}
},
// No nodes scenario
empty: {
name: "Empty Cluster",
description: "No nodes discovered",
nodes: [],
simulation: {
enableTimeProgression: false,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
},
// Development scenario with custom settings
development: {
name: "Development Mode",
description: "Custom settings for development and testing",
nodes: [
{
ip: '192.168.1.100',
hostname: 'dev-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
},
{
ip: '192.168.1.101',
hostname: 'dev-node-2',
chipId: 87654321,
status: 'ACTIVE',
latency: 8
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: true,
failureRate: 0.05, // 5% failure rate
updateInterval: 3000, // Update every 3 seconds
primaryNodeRotation: true,
rotationInterval: 20000 // Rotate every 20 seconds
}
}
};
/**
* Get a mock configuration by name
* @param {string} configName - Name of the configuration preset
* @returns {Object} Mock configuration object
*/
function getMockConfig(configName = 'healthy') {
const config = mockConfigs[configName];
if (!config) {
console.warn(`Unknown mock config: ${configName}. Using 'healthy' instead.`);
return mockConfigs.healthy;
}
return config;
}
/**
* List all available mock configurations
* @returns {Array} Array of configuration names and descriptions
*/
function listMockConfigs() {
return Object.keys(mockConfigs).map(key => ({
name: key,
displayName: mockConfigs[key].name,
description: mockConfigs[key].description,
nodeCount: mockConfigs[key].nodes.length
}));
}
/**
* Create a custom mock configuration
* @param {Object} options - Configuration options
* @returns {Object} Custom mock configuration
*/
function createCustomConfig(options = {}) {
const defaultConfig = {
name: "Custom Configuration",
description: "User-defined mock configuration",
nodes: [
{
ip: '192.168.1.100',
hostname: 'custom-node-1',
chipId: 12345678,
status: 'ACTIVE',
latency: 5
}
],
simulation: {
enableTimeProgression: true,
enableRandomFailures: false,
failureRate: 0.0,
updateInterval: 5000,
primaryNodeRotation: false,
rotationInterval: 30000
}
};
// Merge with provided options
return {
...defaultConfig,
...options,
nodes: options.nodes || defaultConfig.nodes,
simulation: {
...defaultConfig.simulation,
...options.simulation
}
};
}
module.exports = {
mockConfigs,
getMockConfig,
listMockConfigs,
createCustomConfig
};

846
test/mock-server.js Normal file
View File

@@ -0,0 +1,846 @@
#!/usr/bin/env node
/**
* Complete Mock Server for SPORE UI
*
* This mock server provides a complete simulation of the SPORE embedded system
* without requiring actual hardware or UDP port conflicts. It simulates:
* - Multiple SPORE nodes with different IPs
* - All API endpoints from the OpenAPI specification
* - Discovery system without UDP conflicts
* - Realistic data that changes over time
* - Different scenarios (healthy, degraded, error states)
*/
const express = require('express');
const cors = require('cors');
const path = require('path');
const { getMockConfig } = require('./mock-configs');
// Load mock configuration
const configName = process.env.MOCK_CONFIG || 'healthy';
const baseConfig = getMockConfig(configName);
// Mock server configuration
const MOCK_CONFIG = {
// Server settings
port: process.env.MOCK_PORT || 3002,
baseUrl: process.env.MOCK_BASE_URL || 'http://localhost:3002',
// Load configuration from preset
...baseConfig
};
// Initialize Express app
const app = express();
app.use(cors({
origin: true,
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Mock data generators
class MockDataGenerator {
constructor() {
this.startTime = Date.now();
this.nodeStates = new Map();
this.primaryNodeIndex = 0;
this.initializeNodeStates();
}
initializeNodeStates() {
MOCK_CONFIG.nodes.forEach((node, index) => {
this.nodeStates.set(node.ip, {
...node,
freeHeap: this.generateFreeHeap(),
uptime: 0,
lastSeen: Date.now(),
tasks: this.generateTasks(),
systemInfo: this.generateSystemInfo(node),
apiEndpoints: this.generateApiEndpoints()
});
});
}
generateFreeHeap() {
// Simulate realistic ESP8266 memory usage
const base = 30000;
const variation = 20000;
return Math.floor(base + Math.random() * variation);
}
generateSystemInfo(node) {
return {
freeHeap: this.generateFreeHeap(),
chipId: node.chipId,
sdkVersion: "3.1.2",
cpuFreqMHz: 80,
flashChipSize: 1048576
};
}
generateTasks() {
return [
{
name: "discovery_send",
interval: 1000,
enabled: true,
running: true,
autoStart: true
},
{
name: "heartbeat",
interval: 2000,
enabled: true,
running: true,
autoStart: true
},
{
name: "status_update",
interval: 1000,
enabled: true,
running: true,
autoStart: true
},
{
name: "wifi_monitor",
interval: 5000,
enabled: true,
running: Math.random() > 0.1, // 90% chance of running
autoStart: true
},
{
name: "ota_check",
interval: 30000,
enabled: true,
running: Math.random() > 0.2, // 80% chance of running
autoStart: true
},
{
name: "cluster_sync",
interval: 10000,
enabled: true,
running: Math.random() > 0.05, // 95% chance of running
autoStart: true
}
];
}
generateApiEndpoints() {
return [
{ uri: "/api/node/status", method: "GET" },
{ uri: "/api/tasks/status", method: "GET" },
{ uri: "/api/tasks/control", method: "POST" },
{ uri: "/api/cluster/members", method: "GET" },
{ uri: "/api/node/update", method: "POST" },
{ uri: "/api/node/restart", method: "POST" },
{
uri: "/api/led/brightness",
method: "POST",
params: [
{
name: "brightness",
type: "numberRange",
location: "body",
required: true,
value: 255,
default: 128
}
]
},
{
uri: "/api/led/color",
method: "POST",
params: [
{
name: "color",
type: "color",
location: "body",
required: true,
default: 16711680
}
]
},
{
uri: "/api/sensor/interval",
method: "POST",
params: [
{
name: "interval",
type: "numberRange",
location: "body",
required: true,
value: 10000,
default: 1000
}
]
},
{
uri: "/api/system/mode",
method: "POST",
params: [
{
name: "mode",
type: "string",
location: "body",
required: true,
values: ["normal", "debug", "maintenance"],
default: "normal"
}
]
}
];
}
updateNodeStates() {
if (!MOCK_CONFIG.simulation.enableTimeProgression) return;
this.nodeStates.forEach((nodeState, ip) => {
// Update uptime
nodeState.uptime = Date.now() - this.startTime;
// Update free heap (simulate memory usage changes)
const currentHeap = nodeState.freeHeap;
const change = Math.floor((Math.random() - 0.5) * 1000);
nodeState.freeHeap = Math.max(10000, currentHeap + change);
// Update last seen
nodeState.lastSeen = Date.now();
// Simulate random failures
if (MOCK_CONFIG.simulation.enableRandomFailures && Math.random() < MOCK_CONFIG.simulation.failureRate) {
nodeState.status = Math.random() > 0.5 ? 'INACTIVE' : 'DEAD';
} else {
nodeState.status = 'ACTIVE';
}
// Update task states
nodeState.tasks.forEach(task => {
if (task.enabled && Math.random() > 0.05) { // 95% chance of running when enabled
task.running = true;
} else {
task.running = false;
}
});
});
// Rotate primary node if enabled
if (MOCK_CONFIG.simulation.primaryNodeRotation) {
this.primaryNodeIndex = (this.primaryNodeIndex + 1) % MOCK_CONFIG.nodes.length;
}
}
getPrimaryNode() {
return MOCK_CONFIG.nodes[this.primaryNodeIndex];
}
getAllNodes() {
return Array.from(this.nodeStates.values());
}
getNodeByIp(ip) {
return this.nodeStates.get(ip);
}
}
// Initialize mock data generator
const mockData = new MockDataGenerator();
// Update data periodically
setInterval(() => {
mockData.updateNodeStates();
}, MOCK_CONFIG.simulation.updateInterval);
// API Routes
// Health check endpoint
app.get('/api/health', (req, res) => {
const primaryNode = mockData.getPrimaryNode();
const allNodes = mockData.getAllNodes();
const activeNodes = allNodes.filter(node => node.status === 'ACTIVE');
const health = {
status: activeNodes.length > 0 ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
services: {
http: true,
udp: false, // Mock server doesn't use UDP
sporeClient: true
},
discovery: {
totalNodes: allNodes.length,
primaryNode: primaryNode.ip,
udpPort: 4210,
serverRunning: false // Mock server doesn't use UDP
},
mock: {
enabled: true,
nodes: allNodes.length,
activeNodes: activeNodes.length,
simulationMode: MOCK_CONFIG.simulation.enableTimeProgression
}
};
if (activeNodes.length === 0) {
health.status = 'degraded';
health.message = 'No active nodes in mock simulation';
}
res.json(health);
});
// Discovery endpoints (simulated)
app.get('/api/discovery/nodes', (req, res) => {
const primaryNode = mockData.getPrimaryNode();
const allNodes = mockData.getAllNodes();
const response = {
primaryNode: primaryNode.ip,
totalNodes: allNodes.length,
clientInitialized: true,
clientBaseUrl: `http://${primaryNode.ip}`,
nodes: allNodes.map(node => ({
ip: node.ip,
port: 80,
discoveredAt: new Date(node.lastSeen - 60000).toISOString(), // 1 minute ago
lastSeen: new Date(node.lastSeen).toISOString(),
isPrimary: node.ip === primaryNode.ip,
hostname: node.hostname,
status: node.status
}))
};
res.json(response);
});
app.post('/api/discovery/refresh', (req, res) => {
// Simulate discovery refresh
mockData.updateNodeStates();
res.json({
success: true,
message: 'Discovery refresh completed',
timestamp: new Date().toISOString()
});
});
app.post('/api/discovery/primary/:ip', (req, res) => {
const { ip } = req.params;
const node = mockData.getNodeByIp(ip);
if (!node) {
return res.status(404).json({
success: false,
message: `Node ${ip} not found`
});
}
// Find and set as primary
const nodeIndex = MOCK_CONFIG.nodes.findIndex(n => n.ip === ip);
if (nodeIndex !== -1) {
mockData.primaryNodeIndex = nodeIndex;
}
res.json({
success: true,
message: `Primary node set to ${ip}`,
primaryNode: ip
});
});
app.post('/api/discovery/random-primary', (req, res) => {
const allNodes = mockData.getAllNodes();
const activeNodes = allNodes.filter(node => node.status === 'ACTIVE');
if (activeNodes.length === 0) {
return res.status(503).json({
success: false,
message: 'No active nodes available for selection'
});
}
// Randomly select a new primary
const randomIndex = Math.floor(Math.random() * activeNodes.length);
const newPrimary = activeNodes[randomIndex];
const nodeIndex = MOCK_CONFIG.nodes.findIndex(n => n.ip === newPrimary.ip);
if (nodeIndex !== -1) {
mockData.primaryNodeIndex = nodeIndex;
}
res.json({
success: true,
message: `Primary node randomly selected: ${newPrimary.ip}`,
primaryNode: newPrimary.ip,
totalNodes: allNodes.length,
clientInitialized: true
});
});
// Task management endpoints
app.get('/api/tasks/status', (req, res) => {
const { ip } = req.query;
let nodeData;
if (ip) {
nodeData = mockData.getNodeByIp(ip);
if (!nodeData) {
return res.status(404).json({
error: 'Node not found',
message: `Node ${ip} not found in mock simulation`
});
}
} else {
// Use primary node
const primaryNode = mockData.getPrimaryNode();
nodeData = mockData.getNodeByIp(primaryNode.ip);
}
const tasks = nodeData.tasks;
const activeTasks = tasks.filter(task => task.enabled && task.running).length;
const response = {
summary: {
totalTasks: tasks.length,
activeTasks: activeTasks
},
tasks: tasks,
system: {
freeHeap: nodeData.freeHeap,
uptime: nodeData.uptime
}
};
res.json(response);
});
app.post('/api/tasks/control', (req, res) => {
const { task, action } = req.body;
if (!task || !action) {
return res.status(400).json({
success: false,
message: 'Missing parameters. Required: task, action',
example: '{"task": "discovery_send", "action": "status"}'
});
}
const validActions = ['enable', 'disable', 'start', 'stop', 'status'];
if (!validActions.includes(action)) {
return res.status(400).json({
success: false,
message: 'Invalid action. Use: enable, disable, start, stop, or status',
task: task,
action: action
});
}
// Simulate task control
const primaryNode = mockData.getPrimaryNode();
const nodeData = mockData.getNodeByIp(primaryNode.ip);
const taskData = nodeData.tasks.find(t => t.name === task);
if (!taskData) {
return res.status(404).json({
success: false,
message: `Task ${task} not found`
});
}
// Apply action
switch (action) {
case 'enable':
taskData.enabled = true;
break;
case 'disable':
taskData.enabled = false;
taskData.running = false;
break;
case 'start':
if (taskData.enabled) {
taskData.running = true;
}
break;
case 'stop':
taskData.running = false;
break;
case 'status':
// Return detailed status
return res.json({
success: true,
message: 'Task status retrieved',
task: task,
action: action,
taskDetails: {
name: taskData.name,
enabled: taskData.enabled,
running: taskData.running,
interval: taskData.interval,
system: {
freeHeap: nodeData.freeHeap,
uptime: nodeData.uptime
}
}
});
}
res.json({
success: true,
message: `Task ${action}d`,
task: task,
action: action
});
});
// System status endpoint
app.get('/api/node/status', (req, res) => {
const { ip } = req.query;
let nodeData;
if (ip) {
nodeData = mockData.getNodeByIp(ip);
if (!nodeData) {
return res.status(404).json({
error: 'Node not found',
message: `Node ${ip} not found in mock simulation`
});
}
} else {
// Use primary node
const primaryNode = mockData.getPrimaryNode();
nodeData = mockData.getNodeByIp(primaryNode.ip);
}
const response = {
freeHeap: nodeData.freeHeap,
chipId: nodeData.chipId,
sdkVersion: nodeData.systemInfo.sdkVersion,
cpuFreqMHz: nodeData.systemInfo.cpuFreqMHz,
flashChipSize: nodeData.systemInfo.flashChipSize,
api: nodeData.apiEndpoints
};
res.json(response);
});
// Cluster members endpoint
app.get('/api/cluster/members', (req, res) => {
const allNodes = mockData.getAllNodes();
const members = allNodes.map(node => ({
hostname: node.hostname,
ip: node.ip,
lastSeen: Math.floor(node.lastSeen / 1000), // Convert to seconds
latency: node.latency,
status: node.status,
resources: {
freeHeap: node.freeHeap,
chipId: node.chipId,
sdkVersion: node.systemInfo.sdkVersion,
cpuFreqMHz: node.systemInfo.cpuFreqMHz,
flashChipSize: node.systemInfo.flashChipSize
},
api: node.apiEndpoints
}));
res.json({ members });
});
// Node endpoints endpoint
app.get('/api/node/endpoints', (req, res) => {
const { ip } = req.query;
let nodeData;
if (ip) {
nodeData = mockData.getNodeByIp(ip);
if (!nodeData) {
return res.status(404).json({
error: 'Node not found',
message: `Node ${ip} not found in mock simulation`
});
}
} else {
// Use primary node
const primaryNode = mockData.getPrimaryNode();
nodeData = mockData.getNodeByIp(primaryNode.ip);
}
res.json(nodeData.apiEndpoints);
});
// Generic proxy endpoint
app.post('/api/proxy-call', (req, res) => {
const { ip, method, uri, params } = req.body || {};
if (!ip || !method || !uri) {
return res.status(400).json({
error: 'Missing required fields',
message: 'Required: ip, method, uri'
});
}
// Simulate proxy call by routing to appropriate mock endpoint
const nodeData = mockData.getNodeByIp(ip);
if (!nodeData) {
return res.status(404).json({
error: 'Node not found',
message: `Node ${ip} not found in mock simulation`
});
}
// Simulate different responses based on URI
if (uri === '/api/node/status') {
return res.json({
freeHeap: nodeData.freeHeap,
chipId: nodeData.chipId,
sdkVersion: nodeData.systemInfo.sdkVersion,
cpuFreqMHz: nodeData.systemInfo.cpuFreqMHz,
flashChipSize: nodeData.systemInfo.flashChipSize,
api: nodeData.apiEndpoints
});
} else if (uri === '/api/tasks/status') {
const tasks = nodeData.tasks;
const activeTasks = tasks.filter(task => task.enabled && task.running).length;
return res.json({
summary: {
totalTasks: tasks.length,
activeTasks: activeTasks
},
tasks: tasks,
system: {
freeHeap: nodeData.freeHeap,
uptime: nodeData.uptime
}
});
} else if (uri === '/api/monitoring/resources') {
// Return realistic monitoring resources data
const totalHeap = nodeData.systemInfo.flashChipSize || 1048576; // 1MB default
const freeHeap = nodeData.freeHeap;
const usedHeap = totalHeap - freeHeap;
const heapUsagePercent = (usedHeap / totalHeap) * 100;
return res.json({
cpu: {
average_usage: Math.random() * 30 + 10, // 10-40% CPU usage
current_usage: Math.random() * 50 + 5, // 5-55% current usage
frequency_mhz: nodeData.systemInfo.cpuFreqMHz || 80
},
memory: {
total_heap: totalHeap,
free_heap: freeHeap,
used_heap: usedHeap,
heap_usage_percent: heapUsagePercent,
min_free_heap: Math.floor(freeHeap * 0.8), // 80% of current free heap
max_alloc_heap: Math.floor(totalHeap * 0.9) // 90% of total heap
},
filesystem: {
total_bytes: 3145728, // 3MB SPIFFS
used_bytes: Math.floor(3145728 * (0.3 + Math.random() * 0.4)), // 30-70% used
free_bytes: 0 // Will be calculated
},
network: {
wifi_rssi: -30 - Math.floor(Math.random() * 40), // -30 to -70 dBm
wifi_connected: true,
uptime_seconds: nodeData.uptime
},
timestamp: new Date().toISOString()
});
} else {
return res.json({
success: true,
message: `Mock response for ${method} ${uri}`,
node: ip,
timestamp: new Date().toISOString()
});
}
});
// Firmware update endpoint
app.post('/api/node/update', (req, res) => {
// Simulate firmware update
res.json({
status: 'updating',
message: 'Firmware update in progress (mock simulation)'
});
});
// System restart endpoint
app.post('/api/node/restart', (req, res) => {
// Simulate system restart
res.json({
status: 'restarting'
});
});
// Test route
app.get('/test', (req, res) => {
res.send('Mock server is working!');
});
// Serve the mock UI (main UI with modified API client)
app.get('/', (req, res) => {
const filePath = path.join(__dirname, 'mock-ui.html');
console.log('Serving mock UI from:', filePath);
res.sendFile(filePath);
});
// Serve the original mock frontend
app.get('/frontend', (req, res) => {
res.sendFile(path.join(__dirname, 'mock-frontend.html'));
});
// Serve the main UI with modified API client
app.get('/ui', (req, res) => {
res.sendFile(path.join(__dirname, '../public/index.html'));
});
// Serve static files from public directory (after custom routes)
// Only serve static files for specific paths, not the root
app.use('/static', express.static(path.join(__dirname, '../public')));
app.use('/styles', express.static(path.join(__dirname, '../public/styles')));
app.use('/scripts', express.static(path.join(__dirname, '../public/scripts')));
app.use('/vendor', express.static(path.join(__dirname, '../public/vendor')));
// Serve mock API client
app.get('/test/mock-api-client.js', (req, res) => {
res.sendFile(path.join(__dirname, 'mock-api-client.js'));
});
// Serve test page
app.get('/test-page', (req, res) => {
res.sendFile(path.join(__dirname, 'test-page.html'));
});
// Serve favicon to prevent 404 errors
app.get('/favicon.ico', (req, res) => {
res.status(204).end(); // No content
});
// Serve mock server info page
app.get('/info', (req, res) => {
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI - Mock Server Info</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; text-align: center; }
.status { background: #e8f5e8; padding: 15px; border-radius: 5px; margin: 20px 0; }
.info { background: #f0f8ff; padding: 15px; border-radius: 5px; margin: 20px 0; }
.endpoint { background: #f9f9f9; padding: 10px; margin: 5px 0; border-left: 4px solid #007acc; }
.mock-note { background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107; }
.btn { display: inline-block; padding: 10px 20px; background: #007acc; color: white; text-decoration: none; border-radius: 5px; margin: 5px; }
.btn:hover { background: #005a9e; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 SPORE UI Mock Server</h1>
<div class="mock-note">
<strong>Mock Mode Active:</strong> This is a complete simulation of the SPORE embedded system.
No real hardware or UDP ports are required.
</div>
<div class="status">
<h3>📊 Server Status</h3>
<p><strong>Status:</strong> Running</p>
<p><strong>Port:</strong> ${MOCK_CONFIG.port}</p>
<p><strong>Configuration:</strong> ${MOCK_CONFIG.name}</p>
<p><strong>Mock Nodes:</strong> ${MOCK_CONFIG.nodes.length}</p>
<p><strong>Primary Node:</strong> ${mockData.getPrimaryNode().ip}</p>
</div>
<div class="info">
<h3>🌐 Access Points</h3>
<p><a href="/" class="btn">Mock UI (Port 3002)</a> - Full UI with mock data</p>
<p><a href="/frontend" class="btn">Mock Frontend</a> - Custom mock frontend</p>
<p><a href="/ui" class="btn">Real UI (Port 3002)</a> - Real UI connected to mock server</p>
<p><a href="/api/health" class="btn">API Health</a> - Check server status</p>
</div>
<div class="info">
<h3>🔗 Available Endpoints</h3>
<div class="endpoint"><strong>GET</strong> /api/health - Health check</div>
<div class="endpoint"><strong>GET</strong> /api/discovery/nodes - Discovery status</div>
<div class="endpoint"><strong>GET</strong> /api/tasks/status - Task status</div>
<div class="endpoint"><strong>POST</strong> /api/tasks/control - Control tasks</div>
<div class="endpoint"><strong>GET</strong> /api/node/status - System status</div>
<div class="endpoint"><strong>GET</strong> /api/cluster/members - Cluster members</div>
<div class="endpoint"><strong>POST</strong> /api/proxy-call - Generic proxy</div>
</div>
<div class="info">
<h3>🎮 Mock Features</h3>
<ul>
<li>✅ Multiple simulated SPORE nodes</li>
<li>✅ Realistic data that changes over time</li>
<li>✅ No UDP port conflicts</li>
<li>✅ All API endpoints implemented</li>
<li>✅ Random failures simulation</li>
<li>✅ Primary node rotation</li>
</ul>
</div>
<div class="info">
<h3>🔧 Configuration</h3>
<p>Use npm scripts to change configuration:</p>
<ul>
<li><code>npm run mock:healthy</code> - Healthy cluster (3 nodes)</li>
<li><code>npm run mock:degraded</code> - Degraded cluster (some inactive)</li>
<li><code>npm run mock:large</code> - Large cluster (8 nodes)</li>
<li><code>npm run mock:unstable</code> - Unstable cluster (high failure rate)</li>
<li><code>npm run mock:single</code> - Single node</li>
<li><code>npm run mock:empty</code> - Empty cluster</li>
</ul>
</div>
</div>
</body>
</html>
`);
});
// Start the mock server
const server = app.listen(MOCK_CONFIG.port, () => {
console.log('🚀 SPORE UI Mock Server Started');
console.log('================================');
console.log(`Configuration: ${MOCK_CONFIG.name}`);
console.log(`Description: ${MOCK_CONFIG.description}`);
console.log(`Port: ${MOCK_CONFIG.port}`);
console.log(`URL: http://localhost:${MOCK_CONFIG.port}`);
console.log(`Mock Nodes: ${MOCK_CONFIG.nodes.length}`);
console.log(`Primary Node: ${mockData.getPrimaryNode().ip}`);
console.log('');
console.log('📡 Available Mock Nodes:');
MOCK_CONFIG.nodes.forEach((node, index) => {
console.log(` ${index + 1}. ${node.hostname} (${node.ip}) - ${node.status}`);
});
console.log('');
console.log('🎮 Mock Features:');
console.log(' ✅ No UDP port conflicts');
console.log(' ✅ Realistic data simulation');
console.log(' ✅ All API endpoints');
console.log(` ✅ Time-based data updates (${MOCK_CONFIG.simulation.updateInterval}ms)`);
console.log(` ✅ Random failure simulation (${MOCK_CONFIG.simulation.enableRandomFailures ? 'Enabled' : 'Disabled'})`);
console.log(` ✅ Primary node rotation (${MOCK_CONFIG.simulation.primaryNodeRotation ? 'Enabled' : 'Disabled'})`);
console.log('');
console.log('Press Ctrl+C to stop');
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n👋 Mock server stopped. Goodbye!');
server.close(() => {
process.exit(0);
});
});
module.exports = { app, mockData, MOCK_CONFIG };

285
test/mock-test.js Normal file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env node
/**
* Mock Server Integration Test
*
* Tests the mock server functionality to ensure all endpoints work correctly
*/
const http = require('http');
const MOCK_SERVER_URL = 'http://localhost:3002';
const TIMEOUT = 5000; // 5 seconds
function makeRequest(path, method = 'GET', body = null) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 3002,
path: path,
method: method,
headers: {
'Content-Type': 'application/json'
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const jsonData = JSON.parse(data);
resolve({ status: res.statusCode, data: jsonData });
} catch (error) {
resolve({ status: res.statusCode, data: data });
}
});
});
req.on('error', (error) => {
reject(error);
});
req.setTimeout(TIMEOUT, () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
async function testEndpoint(name, testFn) {
try {
console.log(`🧪 Testing ${name}...`);
const result = await testFn();
console.log(`${name}: PASS`);
return { name, status: 'PASS', result };
} catch (error) {
console.log(`${name}: FAIL - ${error.message}`);
return { name, status: 'FAIL', error: error.message };
}
}
async function runTests() {
console.log('🚀 SPORE UI Mock Server Integration Tests');
console.log('==========================================');
console.log('');
const results = [];
// Test 1: Health Check
results.push(await testEndpoint('Health Check', async () => {
const response = await makeRequest('/api/health');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.status) {
throw new Error('Missing status field');
}
if (!response.data.mock) {
throw new Error('Missing mock field');
}
return response.data;
}));
// Test 2: Discovery Nodes
results.push(await testEndpoint('Discovery Nodes', async () => {
const response = await makeRequest('/api/discovery/nodes');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.primaryNode) {
throw new Error('Missing primaryNode field');
}
if (!Array.isArray(response.data.nodes)) {
throw new Error('Nodes should be an array');
}
return response.data;
}));
// Test 3: Task Status
results.push(await testEndpoint('Task Status', async () => {
const response = await makeRequest('/api/tasks/status');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.summary) {
throw new Error('Missing summary field');
}
if (!Array.isArray(response.data.tasks)) {
throw new Error('Tasks should be an array');
}
return response.data;
}));
// Test 4: Task Control
results.push(await testEndpoint('Task Control', async () => {
const response = await makeRequest('/api/tasks/control', 'POST', {
task: 'heartbeat',
action: 'status'
});
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.success) {
throw new Error('Task control should succeed');
}
return response.data;
}));
// Test 5: System Status
results.push(await testEndpoint('System Status', async () => {
const response = await makeRequest('/api/node/status');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (typeof response.data.freeHeap !== 'number') {
throw new Error('freeHeap should be a number');
}
if (!response.data.chipId) {
throw new Error('Missing chipId field');
}
return response.data;
}));
// Test 6: Cluster Members
results.push(await testEndpoint('Cluster Members', async () => {
const response = await makeRequest('/api/cluster/members');
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!Array.isArray(response.data.members)) {
throw new Error('Members should be an array');
}
return response.data;
}));
// Test 7: Random Primary Selection
results.push(await testEndpoint('Random Primary Selection', async () => {
const response = await makeRequest('/api/discovery/random-primary', 'POST', {
timestamp: new Date().toISOString()
});
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
if (!response.data.success) {
throw new Error('Random selection should succeed');
}
return response.data;
}));
// Test 8: Proxy Call
results.push(await testEndpoint('Proxy Call', async () => {
const response = await makeRequest('/api/proxy-call', 'POST', {
ip: '192.168.1.100',
method: 'GET',
uri: '/api/node/status'
});
if (response.status !== 200) {
throw new Error(`Expected status 200, got ${response.status}`);
}
return response.data;
}));
// Test 9: Error Handling
results.push(await testEndpoint('Error Handling', async () => {
const response = await makeRequest('/api/tasks/control', 'POST', {
task: 'nonexistent',
action: 'status'
});
if (response.status !== 404) {
throw new Error(`Expected status 404, got ${response.status}`);
}
return response.data;
}));
// Test 10: Invalid Parameters
results.push(await testEndpoint('Invalid Parameters', async () => {
const response = await makeRequest('/api/tasks/control', 'POST', {
// Missing required fields
});
if (response.status !== 400) {
throw new Error(`Expected status 400, got ${response.status}`);
}
return response.data;
}));
// Print Results
console.log('');
console.log('📊 Test Results');
console.log('===============');
const passed = results.filter(r => r.status === 'PASS').length;
const failed = results.filter(r => r.status === 'FAIL').length;
const total = results.length;
results.forEach(result => {
const status = result.status === 'PASS' ? '✅' : '❌';
console.log(`${status} ${result.name}`);
if (result.status === 'FAIL') {
console.log(` Error: ${result.error}`);
}
});
console.log('');
console.log(`Total: ${total} | Passed: ${passed} | Failed: ${failed}`);
if (failed === 0) {
console.log('');
console.log('🎉 All tests passed! Mock server is working correctly.');
} else {
console.log('');
console.log('⚠️ Some tests failed. Check the mock server configuration.');
}
return failed === 0;
}
// Check if mock server is running
async function checkMockServer() {
try {
const response = await makeRequest('/api/health');
return response.status === 200;
} catch (error) {
return false;
}
}
async function main() {
console.log('🔍 Checking if mock server is running...');
const isRunning = await checkMockServer();
if (!isRunning) {
console.log('❌ Mock server is not running!');
console.log('');
console.log('Please start the mock server first:');
console.log(' npm run mock:healthy');
console.log('');
process.exit(1);
}
console.log('✅ Mock server is running');
console.log('');
const success = await runTests();
process.exit(success ? 0 : 1);
}
// Run tests
if (require.main === module) {
main().catch(error => {
console.error('❌ Test runner failed:', error.message);
process.exit(1);
});
}
module.exports = { runTests, checkMockServer };

273
test/mock-ui.html Normal file
View File

@@ -0,0 +1,273 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI - Mock Mode</title>
<!-- Include all the same styles as the main UI -->
<link rel="stylesheet" href="/styles/main.css">
<link rel="stylesheet" href="/styles/theme.css">
<!-- Include D3.js for topology visualization -->
<script src="/vendor/d3.v7.min.js"></script>
<!-- Include framework and components in correct order -->
<script src="/scripts/constants.js"></script>
<script src="/scripts/framework.js"></script>
<script src="/test/mock-api-client.js"></script>
<script src="/scripts/view-models.js"></script>
<script src="/scripts/components/DrawerComponent.js"></script>
<script src="/scripts/components/PrimaryNodeComponent.js"></script>
<script src="/scripts/components/NodeDetailsComponent.js"></script>
<script src="/scripts/components/ClusterMembersComponent.js"></script>
<script src="/scripts/components/FirmwareComponent.js"></script>
<script src="/scripts/components/FirmwareViewComponent.js"></script>
<script src="/scripts/components/ClusterViewComponent.js"></script>
<script src="/scripts/components/ClusterStatusComponent.js"></script>
<script src="/scripts/components/TopologyGraphComponent.js"></script>
<script src="/scripts/components/MonitoringViewComponent.js"></script>
<script src="/scripts/components/ComponentsLoader.js"></script>
<script src="/scripts/theme-manager.js"></script>
<script src="/scripts/app.js"></script>
</head>
<body>
<div class="container">
<div class="main-navigation">
<button class="burger-btn" id="burger-btn" aria-label="Menu" title="Menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
<div class="nav-left">
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
<button class="nav-tab" data-view="topology">🔗 Topology</button>
<button class="nav-tab" data-view="monitoring">📡 Monitoring</button>
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
</div>
<div class="nav-right">
<div class="theme-switcher">
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
</button>
</div>
<div class="cluster-status">🚀 Cluster Online</div>
</div>
</div>
<div id="cluster-view" class="view-content active">
<div class="cluster-section">
<div class="cluster-header">
<div class="cluster-header-left">
<div class="primary-node-info">
<span class="primary-node-label">Primary Node:</span>
<span class="primary-node-ip" id="primary-node-ip">Discovering...</span>
<button class="primary-node-refresh" id="select-random-primary-btn"
title="🎲 Select random primary node">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14"
height="14">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
</button>
</div>
</div>
<div class="cluster-header-right">
<button class="refresh-btn" id="refresh-cluster-btn" title="Refresh cluster data">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
height="16">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
</div>
</div>
<div class="cluster-members" id="cluster-members-container">
<!-- Cluster members will be rendered here -->
</div>
</div>
</div>
<div id="topology-view" class="view-content">
<div class="topology-section">
<div class="topology-graph" id="topology-graph-container">
<!-- Topology graph will be rendered here -->
</div>
</div>
</div>
<div id="firmware-view" class="view-content">
<div class="firmware-section">
<div class="firmware-header">
<h2>Firmware Management</h2>
<button class="refresh-btn" id="refresh-firmware-btn" title="Refresh firmware">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
height="16">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
</div>
<div class="firmware-content" id="firmware-container">
<div class="firmware-overview">
<div class="firmware-actions">
<div class="action-group">
<h3>🚀 Firmware Update</h3>
<div class="firmware-upload-compact">
<div class="compact-upload-row">
<div class="file-upload-area">
<div class="target-options">
<label class="target-option">
<input type="radio" name="target-type" value="all" checked>
<span class="radio-custom"></span>
<span class="target-label">All Nodes</span>
</label>
<label class="target-option specific-node-option">
<input type="radio" name="target-type" value="specific">
<span class="radio-custom"></span>
<span class="target-label">Specific Node</span>
<select id="specific-node-select" class="node-select">
<option value="">Select a node...</option>
</select>
</label>
<label class="target-option by-label-option">
<input type="radio" name="target-type" value="labels">
<span class="radio-custom"></span>
<span class="target-label">By Label</span>
<select id="label-select" class="label-select"
style="min-width: 220px; display: inline-block; vertical-align: middle;">
<option value="">Select a label...</option>
</select>
<div id="selected-labels-container" class="selected-labels"></div>
</label>
</div>
<div class="file-input-wrapper">
<input type="file" id="global-firmware-file" accept=".bin,.hex"
style="display: none;">
<button class="upload-btn-compact"
onclick="document.getElementById('global-firmware-file').click()">
📁 Choose File
</button>
<span class="file-info" id="file-info">No file selected</span>
</div>
</div>
<button class="deploy-btn" id="deploy-btn" disabled>🚀 Deploy</button>
</div>
</div>
</div>
</div>
</div>
<div class="firmware-nodes-list" id="firmware-nodes-list">
<!-- Nodes will be populated here -->
</div>
</div>
</div>
</div>
<div id="monitoring-view" class="view-content">
<div class="monitoring-view-section">
<div class="monitoring-header">
<h2>📡 Monitoring</h2>
<button class="refresh-btn" id="refresh-monitoring-btn">
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6M23 20v-6h-6" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
</svg>
Refresh
</button>
</div>
<div class="monitoring-content">
<div class="cluster-summary" id="cluster-summary">
<div class="loading">
<div>Loading cluster resource summary...</div>
</div>
</div>
<div class="nodes-monitoring" id="nodes-monitoring">
<div class="loading">
<div>Loading node resource data...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mock status indicator -->
<div class="mock-status" id="mock-status">
<div class="mock-status-content">
<span class="mock-status-icon">🎭</span>
<span class="mock-status-text">Mock Mode</span>
<button class="btn-sm" id="mock-info-btn">Info</button>
</div>
</div>
<script>
// Mock server status indicator
document.addEventListener('DOMContentLoaded', function() {
const mockStatus = document.getElementById('mock-status');
const mockInfoBtn = document.getElementById('mock-info-btn');
mockInfoBtn.addEventListener('click', function() {
alert('🎭 Mock\n\n' +
'This UI is connected to the mock server on port 3002.\n' +
'All data is simulated and updates automatically.\n\n' +
'To switch to real server:\n' +
'1. Start real server: npm start\n' +
'2. Open: http://localhost:3001\n\n' +
'To change mock configuration:\n' +
'npm run mock:degraded\n' +
'npm run mock:large\n' +
'npm run mock:unstable');
});
});
</script>
<style>
.mock-status {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(255, 193, 7, 0.9);
color: #000;
padding: 10px 15px;
border-radius: 25px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1000;
font-size: 14px;
font-weight: 500;
}
.mock-status-content {
display: flex;
align-items: center;
gap: 8px;
}
.mock-status-icon {
font-size: 16px;
}
.mock-status-text {
white-space: nowrap;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
border-radius: 12px;
}
</style>
</body>
</html>

212
test/registry-integration-test.js Executable file
View File

@@ -0,0 +1,212 @@
#!/usr/bin/env node
/**
* Registry Integration Test
*
* Tests the registry API integration to ensure the firmware registry functionality works correctly
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const REGISTRY_URL = 'http://localhost:8080';
const TIMEOUT = 10000; // 10 seconds
function makeRequest(path, method = 'GET', body = null, isFormData = false) {
return new Promise((resolve, reject) => {
const options = {
hostname: 'localhost',
port: 8080,
path: path,
method: method,
headers: {}
};
if (body && !isFormData) {
options.headers['Content-Type'] = 'application/json';
}
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const jsonData = JSON.parse(data);
resolve({ status: res.statusCode, data: jsonData });
} catch (error) {
resolve({ status: res.statusCode, data: data });
}
});
});
req.on('error', (error) => {
reject(error);
});
req.setTimeout(TIMEOUT, () => {
req.destroy();
reject(new Error('Request timeout'));
});
if (body) {
req.write(body);
}
req.end();
});
}
async function testRegistryHealth() {
console.log('Testing registry health endpoint...');
try {
const response = await makeRequest('/health');
if (response.status === 200 && response.data.status === 'healthy') {
console.log('✅ Registry health check passed');
return true;
} else {
console.log('❌ Registry health check failed:', response);
return false;
}
} catch (error) {
console.log('❌ Registry health check failed:', error.message);
return false;
}
}
async function testListFirmware() {
console.log('Testing list firmware endpoint...');
try {
const response = await makeRequest('/firmware');
if (response.status === 200 && Array.isArray(response.data)) {
console.log('✅ List firmware endpoint works, found', response.data.length, 'firmware entries');
return true;
} else {
console.log('❌ List firmware endpoint failed:', response);
return false;
}
} catch (error) {
console.log('❌ List firmware endpoint failed:', error.message);
return false;
}
}
async function testUploadFirmware() {
console.log('Testing upload firmware endpoint...');
// Create a small test firmware file
const testFirmwareContent = Buffer.from('test firmware content');
const metadata = {
name: 'test-firmware',
version: '1.0.0',
labels: {
platform: 'esp32',
app: 'test'
}
};
try {
// Create multipart form data
const boundary = '----formdata-test-boundary';
const formData = [
`--${boundary}`,
'Content-Disposition: form-data; name="metadata"',
'Content-Type: application/json',
'',
JSON.stringify(metadata),
`--${boundary}`,
'Content-Disposition: form-data; name="firmware"; filename="test.bin"',
'Content-Type: application/octet-stream',
'',
testFirmwareContent.toString(),
`--${boundary}--`
].join('\r\n');
const response = await makeRequest('/firmware', 'POST', formData, true);
if (response.status === 201 && response.data.success) {
console.log('✅ Upload firmware endpoint works');
return true;
} else {
console.log('❌ Upload firmware endpoint failed:', response);
return false;
}
} catch (error) {
console.log('❌ Upload firmware endpoint failed:', error.message);
return false;
}
}
async function testDownloadFirmware() {
console.log('Testing download firmware endpoint...');
try {
const response = await makeRequest('/firmware/test-firmware/1.0.0');
if (response.status === 200) {
console.log('✅ Download firmware endpoint works');
return true;
} else {
console.log('❌ Download firmware endpoint failed:', response);
return false;
}
} catch (error) {
console.log('❌ Download firmware endpoint failed:', error.message);
return false;
}
}
async function runTests() {
console.log('Starting Registry Integration Tests...\n');
const tests = [
{ name: 'Health Check', fn: testRegistryHealth },
{ name: 'List Firmware', fn: testListFirmware },
{ name: 'Upload Firmware', fn: testUploadFirmware },
{ name: 'Download Firmware', fn: testDownloadFirmware }
];
let passed = 0;
let total = tests.length;
for (const test of tests) {
console.log(`\n--- ${test.name} ---`);
try {
const result = await test.fn();
if (result) {
passed++;
}
} catch (error) {
console.log(`${test.name} failed with error:`, error.message);
}
}
console.log(`\n--- Test Results ---`);
console.log(`Passed: ${passed}/${total}`);
if (passed === total) {
console.log('🎉 All tests passed! Registry integration is working correctly.');
process.exit(0);
} else {
console.log('⚠️ Some tests failed. Please check the registry server.');
process.exit(1);
}
}
// Run tests if this script is executed directly
if (require.main === module) {
runTests().catch(error => {
console.error('Test runner failed:', error);
process.exit(1);
});
}
module.exports = {
testRegistryHealth,
testListFirmware,
testUploadFirmware,
testDownloadFirmware,
runTests
};

View File

@@ -1,77 +0,0 @@
#!/usr/bin/env node
/**
* Test script for UDP discovery
* Sends CLUSTER_DISCOVERY messages to test the backend discovery functionality
*/
const dgram = require('dgram');
const client = dgram.createSocket('udp4');
const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY';
const TARGET_PORT = 4210;
const BROADCAST_ADDRESS = '255.255.255.255';
// Enable broadcast
client.setBroadcast(true);
function sendDiscoveryMessage() {
const message = Buffer.from(DISCOVERY_MESSAGE);
client.send(message, 0, message.length, TARGET_PORT, BROADCAST_ADDRESS, (err) => {
if (err) {
console.error('Error sending discovery message:', err);
} else {
console.log(`Sent CLUSTER_DISCOVERY message to ${BROADCAST_ADDRESS}:${TARGET_PORT}`);
}
});
}
function sendDiscoveryToSpecificIP(ip) {
const message = Buffer.from(DISCOVERY_MESSAGE);
client.send(message, 0, message.length, TARGET_PORT, ip, (err) => {
if (err) {
console.error(`Error sending discovery message to ${ip}:`, err);
} else {
console.log(`Sent CLUSTER_DISCOVERY message to ${ip}:${TARGET_PORT}`);
}
});
}
// Main execution
const args = process.argv.slice(2);
if (args.length === 0) {
console.log('Usage: node test-discovery.js [broadcast|ip] [count]');
console.log(' broadcast: Send to broadcast address (default)');
console.log(' ip: Send to specific IP address');
console.log(' count: Number of messages to send (default: 1)');
process.exit(1);
}
const target = args[0];
const count = parseInt(args[1]) || 1;
console.log(`Sending ${count} discovery message(s) to ${target === 'broadcast' ? 'broadcast' : target}`);
if (target === 'broadcast') {
for (let i = 0; i < count; i++) {
setTimeout(() => {
sendDiscoveryMessage();
}, i * 1000); // Send one message per second
}
} else {
// Assume it's an IP address
for (let i = 0; i < count; i++) {
setTimeout(() => {
sendDiscoveryToSpecificIP(target);
}, i * 1000); // Send one message per second
}
}
// Close the client after sending all messages
setTimeout(() => {
client.close();
console.log('Test completed');
}, (count + 1) * 1000);