Compare commits

...

152 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
86 changed files with 30002 additions and 8764 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 \
.

237
README.md
View File

@@ -1,204 +1,117 @@
# SPORE UI
# SPORE UI Frontend
Zero-configuration 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 with auto-discovery
- **🌐 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
## Screenshots
### Cluster
![UI](./assets/cluster.png)
![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)
## 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
│ ├── framework.js # Enhanced component framework with state preservation
│ ├── components.js # UI components with partial update support
│ ├── view-models.js # Data models with UI state management
│ ├── app.js # Main application logic
│ └── test-state-preservation.html # Test interface for state preservation
├── docs/
│ └── STATE_PRESERVATION.md # Detailed documentation of state preservation system
└── README.md # This file
```
## Getting Started
### Prerequisites
1. **Install dependencies**: `npm install`
2. **Start the server**: `npm start`
3. **Open in browser**: `http://localhost:3001`
4. **Test state preservation**: `http://localhost:3001/test-state-preservation.html`
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
- **Framework**: Custom component-based architecture with state preservation
- **API**: SPORE Embedded System API
- **Framework**: Custom component-based architecture
- **API**: SPORE Embedded System API via spore-gateway
- **Design**: Glassmorphism, CSS Grid, Flexbox
## UDP Auto Discovery
## Development
The backend now includes automatic UDP discovery for SPORE nodes on the network. This eliminates the need for hardcoded IP addresses and provides a self-healing, scalable solution for managing SPORE clusters.
### 🚀 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
5. **Health Monitoring**: Continuous monitoring of node availability with automatic failover
### 📡 Discovery Protocol
- **Port**: 4210 (configurable via `UDP_PORT` constant)
- **Message**: `CLUSTER_DISCOVERY` (configurable via `DISCOVERY_MESSAGE` constant)
- **Broadcast**: `255.255.255.255:4210`
- **Protocol**: UDP broadcast listening
- **Auto-binding**: Automatically binds to the specified port on startup
### 🔧 Setup Instructions
#### Backend Setup
```bash
# Start the backend server
npm start
# The server will automatically:
# - Start HTTP server on port 3001
# - Start UDP discovery server on port 4210
# - Wait for CLUSTER_DISCOVERY messages
### 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 Configuration
SPORE nodes should send discovery messages periodically:
### 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
## Troubleshooting
### Common Issues
**Frontend not connecting to gateway**
```bash
# Recommended: Send every 30-60 seconds
# Message format: "CLUSTER_DISCOVERY"
# Target: 255.255.255.255:4210
```
### 🌐 Discovery Endpoints
#### Discovery Management
- `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
- `POST /api/discovery/random-primary` - Randomly select a new primary node
#### Health Monitoring
- `GET /api/health` - Comprehensive health check including discovery status
### 🧪 Testing & Development
#### Test Scripts
```bash
# Send discovery messages to test the system
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
# Test random primary node selection
npm run test-random-selection
# Monitor discovery in real-time
npm run demo-discovery
```
#### Manual Testing
```bash
# Check discovery status
curl http://localhost:3001/api/discovery/nodes
# Check health
# Check if spore-gateway is running
curl http://localhost:3001/api/health
# Manual refresh
curl -X POST http://localhost:3001/api/discovery/refresh
# Random primary selection
curl -X POST http://localhost:3001/api/discovery/random-primary
# Set specific primary
curl -X POST http://localhost:3001/api/discovery/primary/192.168.1.100
# Verify gateway health
# Should return gateway health status
```
### 🔍 Troubleshooting
#### Common Issues
**No Nodes Discovered**
**WebSocket connection issues**
```bash
# Check if backend is running
curl http://localhost:3001/api/health
# Check WebSocket endpoint
curl http://localhost:3001/api/test/websocket
# Verify UDP port is open
netstat -tulpn | grep 4210
# Send test discovery message
npm run test-discovery broadcast
# Verify gateway WebSocket server is running
```
**UDP Port Already in Use**
**No cluster data**
```bash
# Check for conflicting processes
netstat -tulpn | grep 4210
# Kill conflicting processes or change port in code
# Restart backend server
```
**Client Not Initialized**
```bash
# Check discovery status
# Check gateway discovery status
curl http://localhost:3001/api/discovery/nodes
# Verify nodes are sending discovery messages
# Check network connectivity
# Verify SPORE nodes are sending heartbeat messages
```
#### Debug Commands
```bash
# Check discovery status
curl http://localhost:3001/api/discovery/nodes
## Architecture Benefits
# Check health
curl http://localhost:3001/api/health
# Manual refresh
curl -X POST http://localhost:3001/api/discovery/refresh
# Set primary node
curl -X POST http://localhost:3001/api/discovery/primary/192.168.1.100
```
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

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

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.

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

@@ -1,266 +0,0 @@
# SPORE UI State Preservation System
## Overview
The SPORE UI framework now includes an advanced state preservation system that prevents UI state loss during data refreshes. This system ensures that user interactions like expanded cards, active tabs, and other UI state are maintained when data is updated from the server.
## Key Features
### 1. **UI State Persistence**
- **Expanded Cards**: When cluster member cards are expanded, their state is preserved across data refreshes
- **Active Tabs**: Active tab selections within node detail views are maintained
- **User Interactions**: All user-initiated UI changes are stored and restored automatically
### 2. **Smart Data Updates**
- **Change Detection**: The system detects when data has actually changed and only updates what's necessary
- **Partial Updates**: Components can update specific data without re-rendering the entire UI
- **State Preservation**: UI state is automatically preserved during all data operations
### 3. **Efficient Rendering**
- **No Full Re-renders**: Components avoid unnecessary full re-renders when only data changes
- **Granular Updates**: Only changed properties trigger UI updates
- **Performance Optimization**: Reduced DOM manipulation and improved user experience
## Architecture
### Enhanced ViewModel Class
The base `ViewModel` class now includes:
```javascript
class ViewModel {
// UI State Management
setUIState(key, value) // Store UI state
getUIState(key) // Retrieve UI state
getAllUIState() // Get all stored UI state
clearUIState(key) // Clear specific or all UI state
// Change Detection
hasChanged(property) // Check if property changed
getPrevious(property) // Get previous value
// Batch Updates
batchUpdate(updates, options) // Update multiple properties with state preservation
}
```
### Enhanced Component Class
The base `Component` class now includes:
```javascript
class Component {
// UI State Management
setUIState(key, value) // Store local UI state
getUIState(key) // Get local or view model state
getAllUIState() // Get merged state
restoreUIState() // Restore state from view model
// Partial Updates
updatePartial(property, newValue, previousValue) // Handle partial updates
}
```
## Implementation Examples
### 1. **Cluster Members Component**
The `ClusterMembersComponent` demonstrates state preservation:
```javascript
class ClusterMembersComponent extends Component {
setupViewModelListeners() {
// Listen with change detection
this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
}
handleMembersUpdate(newMembers, previousMembers) {
if (this.shouldPreserveState(newMembers, previousMembers)) {
// Partial update preserves UI state
this.updateMembersPartially(newMembers, previousMembers);
} else {
// Full re-render only when necessary
this.render();
}
}
shouldPreserveState(newMembers, previousMembers) {
// Check if member structure allows state preservation
if (newMembers.length !== previousMembers.length) return false;
const newIps = new Set(newMembers.map(m => m.ip));
const prevIps = new Set(previousMembers.map(m => m.ip));
return newIps.size === prevIps.size &&
[...newIps].every(ip => prevIps.has(ip));
}
}
```
### 2. **Node Details Component**
The `NodeDetailsComponent` preserves active tab state:
```javascript
class NodeDetailsComponent extends Component {
setupViewModelListeners() {
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
}
handleActiveTabUpdate(newTab, previousTab) {
// Update tab UI without full re-render
this.updateActiveTab(newTab, previousTab);
}
updateActiveTab(newTab) {
// Update only the tab UI, preserving other state
const tabButtons = this.findAllElements('.tab-button');
const tabContents = this.findAllElements('.tab-content');
tabButtons.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
const activeButton = this.findElement(`[data-tab="${newTab}"]`);
const activeContent = this.findElement(`#${newTab}-tab`);
if (activeButton) activeButton.classList.add('active');
if (activeContent) activeContent.classList.add('active');
}
}
```
## Usage Patterns
### 1. **Storing UI State**
```javascript
// In a component
this.setUIState('expandedCard', memberIp);
this.setUIState('activeTab', 'firmware');
// In a view model
this.setUIState('userPreferences', { theme: 'dark', layout: 'compact' });
```
### 2. **Retrieving UI State**
```javascript
// Get specific state
const expandedCard = this.getUIState('expandedCard');
const activeTab = this.getUIState('activeTab');
// Get all state
const allState = this.getAllUIState();
```
### 3. **Batch Updates with State Preservation**
```javascript
// Update data while preserving UI state
this.viewModel.batchUpdate({
members: newMembers,
lastUpdateTime: new Date().toISOString()
}, { preserveUIState: true });
```
### 4. **Smart Updates**
```javascript
// Use smart update to preserve state
await this.viewModel.smartUpdate();
```
## Benefits
### 1. **Improved User Experience**
- Users don't lose their place in the interface
- Expanded cards remain expanded
- Active tabs stay selected
- No jarring UI resets
### 2. **Better Performance**
- Reduced unnecessary DOM manipulation
- Efficient partial updates
- Optimized rendering cycles
### 3. **Maintainable Code**
- Clear separation of concerns
- Consistent state management patterns
- Easy to extend and modify
## Testing
Use the `test-state-preservation.html` file to test the state preservation system:
1. **Expand cluster member cards**
2. **Change active tabs in node details**
3. **Trigger data refresh**
4. **Verify state is preserved**
## Migration Guide
### From Old System
If you're upgrading from the old system:
1. **Update ViewModel Listeners**: Change from `this.render.bind(this)` to specific update handlers
2. **Add State Management**: Use `setUIState()` and `getUIState()` for UI state
3. **Implement Partial Updates**: Override `updatePartial()` method for efficient updates
4. **Use Smart Updates**: Replace direct data updates with `smartUpdate()` calls
### Example Migration
**Old Code:**
```javascript
this.subscribeToProperty('members', this.render.bind(this));
async handleRefresh() {
await this.viewModel.updateClusterMembers();
}
```
**New Code:**
```javascript
this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
async handleRefresh() {
await this.viewModel.smartUpdate();
}
handleMembersUpdate(newMembers, previousMembers) {
if (this.shouldPreserveState(newMembers, previousMembers)) {
this.updateMembersPartially(newMembers, previousMembers);
} else {
this.render();
}
}
```
## Best Practices
1. **Always Store UI State**: Use `setUIState()` for any user interaction
2. **Implement Partial Updates**: Override `updatePartial()` for efficient updates
3. **Use Change Detection**: Leverage `hasChanged()` to avoid unnecessary updates
4. **Batch Related Updates**: Use `batchUpdate()` for multiple property changes
5. **Test State Preservation**: Verify that UI state is maintained during data refreshes
## Troubleshooting
### Common Issues
1. **State Not Preserved**: Ensure you're using `setUIState()` and `getUIState()`
2. **Full Re-renders**: Check if `shouldPreserveState()` logic is correct
3. **Performance Issues**: Verify you're using partial updates instead of full renders
### Debug Tips
1. **Enable Console Logging**: Check browser console for state preservation logs
2. **Use State Indicators**: Monitor state changes in the test interface
3. **Verify Change Detection**: Ensure `hasChanged()` is working correctly
## Future Enhancements
- **State Synchronization**: Real-time state sync across multiple browser tabs
- **Advanced Change Detection**: Deep object comparison for complex data structures
- **State Persistence**: Save UI state to localStorage for session persistence
- **State Rollback**: Ability to revert to previous UI states

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

View File

@@ -1,223 +0,0 @@
# View Switching Fixes for Member Card Issues
## Problem Description
When switching between the cluster and firmware views, member cards were experiencing:
- **Wrong UI state**: Expanded cards, active tabs, and other UI state was being lost
- **Flickering**: Visual glitches and rapid re-rendering during view switches
- **Broken functionality**: Member cards not working properly after view switches
- **Inefficient rendering**: Components were completely unmounted and remounted on every view switch
- **Incorrect state restoration**: UI state was incorrectly restored on first load (all cards expanded, wrong tabs active)
## Root Causes Identified
1. **Aggressive DOM Manipulation**: Complete component unmounting/remounting on every view switch
2. **Race Conditions**: Multiple async operations and timeouts interfering with each other
3. **State Loss**: UI state not properly preserved across view switches
4. **Rapid Navigation**: Multiple rapid clicks could cause navigation conflicts
5. **CSS Transition Conflicts**: Multiple transitions causing visual flickering
6. **No Component Caching**: Every view switch created new component instances
7. **Complex State Restoration**: Attempting to restore UI state caused incorrect behavior on first load
## Fixes Implemented
### 1. **Component Caching System** (`framework.js`)
- **Component Cache**: Components are created once and cached, never re-created
- **Pause/Resume Pattern**: Components are paused (not unmounted) when switching away
- **Pre-initialization**: Components are created during route registration for better performance
- **Simple Show/Hide**: Components are just shown/hidden without touching UI state
### 2. **Enhanced Navigation System** (`framework.js`)
- **Debounced Navigation**: Added 300ms cooldown between navigation requests
- **Navigation Queue**: Queues navigation requests when one is already in progress
- **Smooth Transitions**: Added opacity transitions to prevent abrupt view changes
- **No Component Destruction**: Components are kept alive and just paused/resumed
### 3. **Simplified State Management** (`view-models.js`)
- **No UI State Persistence**: Removed complex localStorage state restoration
- **Clean State on Load**: Components start with default state (collapsed cards, status tab)
- **No State Corruption**: Eliminates incorrect state restoration on first load
### 4. **Enhanced Component Lifecycle** (`components.js`)
- **Pause/Resume Methods**: Components can be paused and resumed without losing state
- **Default State**: Member cards always start collapsed, tabs start on 'status'
- **No State Restoration**: Components maintain their current state without external interference
- **Render Guards**: Prevents multiple simultaneous render operations
- **View Switch Detection**: Skips rendering during view transitions
- **Improved Unmounting**: Better cleanup of timeouts and event listeners
- **State Tracking**: Tracks if data has already been loaded to prevent unnecessary reloads
### 5. **CSS Improvements** (`styles.css`)
- **Smooth Transitions**: Added fade-in/fade-out animations for view switching
- **Reduced Transition Times**: Shortened member card transitions from 0.3s to 0.2s
- **Better Animations**: Improved expand/collapse animations for member cards
- **Loading States**: Added fade-in animations for loading, error, and empty states
### 6. **View Model Enhancements**
- **Smart Updates**: Only updates changed data to minimize re-renders
- **Change Detection**: Compares data before triggering updates
- **Clean Initialization**: No complex state restoration logic
## Technical Details
### Component Caching Flow
1. **Route Registration**: Components are created and cached during app initialization
2. **Navigation**: When switching views, current component is paused (not unmounted)
3. **State Preservation**: All component state, DOM, and event listeners remain intact
4. **Resume**: When returning to a view, component is resumed from paused state
5. **No Re-rendering**: Components maintain their exact state and appearance
6. **Simple Show/Hide**: No complex state restoration, just show/hide components
### Pause/Resume Pattern
```javascript
// Component is paused instead of unmounted
onPause() {
// Clear timers, pause operations
// Component state and DOM remain intact
}
onResume() {
// Restore timers, resume operations
// No re-rendering needed
}
```
### Navigation Flow
1. **Cooldown Check**: 300ms minimum between navigation requests
2. **Queue Management**: Multiple requests queued and processed sequentially
3. **Pause Current**: Current component paused (opacity: 0)
4. **Show New View**: New view becomes visible with fade-in animation
5. **Resume Component**: Cached component resumed from paused state
6. **No Unmounting**: Components are never destroyed during view switches
7. **No State Touch**: UI state is not modified during view switches
### State Management
- **Default State**: Member cards start collapsed, tabs start on 'status'
- **No Persistence**: No localStorage state restoration
- **Clean Initialization**: Components always start with predictable state
- **No State Corruption**: Eliminates incorrect state restoration issues
### Render Optimization
- **No Re-rendering**: Components maintain their exact state across view switches
- **Pause/Resume**: Components are paused instead of unmounted
- **State Persistence**: All UI state preserved in memory (not localStorage)
- **Change Detection**: Only updates changed data when resuming
- **Default Behavior**: Always starts with clean, predictable state
## Testing
Use the test page `test-view-switching.html` to verify fixes:
1. **Rapid Switching Test**: Clicks navigation tabs rapidly to test cooldown
2. **State Preservation Test**: Expands cards, switches views, verifies state restoration
3. **Component Caching Test**: Verify components are not re-created on view switches
4. **Default State Test**: Verify components start with correct default state
5. **Console Monitoring**: Check console for detailed operation logs
## Expected Results
After implementing these fixes:
-**No More Re-rendering**: Components are cached and never re-created
-**No More Flickering**: Smooth transitions between views
-**Correct Default State**: Member cards start collapsed, tabs start on 'status'
-**No State Corruption**: No incorrect state restoration on first load
-**Stable Navigation**: No more broken member cards after view switches
-**Better Performance**: No unnecessary component creation/destruction
-**Improved UX**: Smoother, more professional feel
-**Memory Efficiency**: Components reused instead of recreated
-**Predictable Behavior**: Components always start with clean state
## Configuration
### Navigation Cooldown
```javascript
this.navigationCooldown = 300; // 300ms between navigation requests
```
### Component Caching
```javascript
// Components are automatically cached during route registration
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
```
### Transition Timing
```css
.view-content {
transition: opacity 0.2s ease-in-out;
}
```
### Member Card Transitions
```css
.member-card {
transition: all 0.2s ease;
}
```
## Architecture Benefits
### 1. **Performance**
- No component recreation on view switches
- Faster view transitions
- Reduced memory allocation/deallocation
### 2. **State Management**
- Clean, predictable default state
- No state corruption on first load
- Consistent user experience
### 3. **Maintainability**
- Cleaner component lifecycle
- No complex state restoration logic
- Easier debugging and testing
- More predictable behavior
### 4. **User Experience**
- No flickering or visual glitches
- Instant view switching
- Maintained user context
- Predictable component behavior
## Key Changes Made
### Removed Complex State Restoration
-`preserveUIState()` method
-`restoreUIState()` method
- ❌ localStorage state persistence
- ❌ Complex tab state restoration
- ❌ Expanded card state restoration
### Simplified Component Behavior
- ✅ Components start with default state
- ✅ Member cards always start collapsed
- ✅ Tabs always start on 'status'
- ✅ No external state interference
- ✅ Clean, predictable initialization
### Maintained Performance Benefits
- ✅ Component caching still works
- ✅ No re-rendering on view switches
- ✅ Smooth transitions
- ✅ Better memory efficiency
## Future Improvements
1. **Virtual Scrolling**: For large numbers of member cards
2. **Animation Preferences**: User-configurable transition speeds
3. **State Sync**: Real-time state synchronization across multiple tabs
4. **Performance Metrics**: Track and optimize render performance
5. **Lazy Loading**: Load components only when first accessed
6. **Memory Management**: Intelligent cache cleanup for unused components
7. **User Preferences**: Allow users to set default states if desired

1184
index-standalone.js Normal file

File diff suppressed because it is too large Load Diff

605
index.js
View File

@@ -1,584 +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;
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// File upload middleware
const fileUpload = require('express-fileupload');
app.use(fileUpload({
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit
abortOnLimit: true,
responseOnLimit: 'File size limit has been reached',
debug: false
}));
// 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;
}
// Function to randomly select a primary node
function selectRandomPrimaryNode() {
if (discoveredNodes.size === 0) {
return null;
}
// Convert discovered nodes to array and filter out current primary
const availableNodes = Array.from(discoveredNodes.keys()).filter(ip => ip !== primaryNodeIp);
if (availableNodes.length === 0) {
// If no other nodes available, keep current primary
return primaryNodeIp;
}
// Randomly select from available nodes
const randomIndex = Math.floor(Math.random() * availableNodes.length);
const randomNode = availableNodes[randomIndex];
// Update primary node
primaryNodeIp = randomNode;
console.log(`Randomly selected new primary node: ${randomNode}`);
return randomNode;
}
// 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 randomly select a new primary node
app.post('/api/discovery/random-primary', (req, res) => {
try {
if (discoveredNodes.size === 0) {
return res.status(404).json({
error: 'No nodes available',
message: 'No SPORE nodes have been discovered yet'
});
}
// Randomly select a new primary node
const randomNode = selectRandomPrimaryNode();
if (!randomNode) {
return res.status(500).json({
error: 'Selection failed',
message: 'Failed to select a random primary node'
});
}
// Update the client with the new primary node
updateSporeClient();
// Get current timestamp for the response
const timestamp = req.body && req.body.timestamp ? req.body.timestamp : new Date().toISOString();
res.json({
success: true,
message: `Randomly selected new primary node: ${randomNode}`,
primaryNode: primaryNodeIp,
totalNodes: discoveredNodes.size,
clientInitialized: !!sporeClient,
timestamp: timestamp
});
} catch (error) {
console.error('Error selecting random primary node:', error);
res.status(500).json({
error: 'Random selection 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 {
const { ip } = req.query;
if (ip) {
try {
const nodeClient = new SporeApiClient(`http://${ip}`);
const taskStatus = await nodeClient.getTaskStatus();
return res.json(taskStatus);
} catch (innerError) {
console.error('Error fetching task status from specific node:', innerError);
return res.status(500).json({
error: 'Failed to fetch task status from node',
message: innerError.message
});
}
}
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', 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'
});
}
// Check if we have a file in the request
if (!req.files || !req.files.file) {
console.log('File upload request received but no file found:', {
hasFiles: !!req.files,
fileKeys: req.files ? Object.keys(req.files) : [],
contentType: req.headers['content-type']
});
return res.status(400).json({
error: 'No file data received',
message: 'Please select a firmware file to upload'
});
}
const uploadedFile = req.files.file;
console.log(`File upload received:`, {
nodeIp: nodeIp,
filename: uploadedFile.name,
fileSize: uploadedFile.data.length,
mimetype: uploadedFile.mimetype,
encoding: uploadedFile.encoding
});
// Create a temporary client for the specific node
const nodeClient = new SporeApiClient(`http://${nodeIp}`);
console.log(`Created SPORE client for node ${nodeIp}`);
// Send the firmware data to the node
console.log(`Starting firmware upload to SPORE device ${nodeIp}...`);
try {
const updateResult = await nodeClient.updateFirmware(uploadedFile.data, uploadedFile.name);
console.log(`Firmware upload to SPORE device ${nodeIp} completed successfully:`, updateResult);
res.json({
success: true,
message: 'Firmware uploaded successfully',
nodeIp: nodeIp,
fileSize: uploadedFile.data.length,
filename: uploadedFile.name,
result: updateResult
});
} catch (uploadError) {
console.error(`Firmware upload to SPORE device ${nodeIp} failed:`, uploadError);
throw new Error(`SPORE device upload failed: ${uploadError.message}`);
}
} 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);
});
// 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`);
});

47
package-lock.json generated
View File

@@ -9,8 +9,10 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"express-fileupload": "^1.4.3"
"express-fileupload": "^1.4.3",
"ws": "^8.18.3"
}
},
"node_modules/accepts": {
@@ -134,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",
@@ -529,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",
@@ -852,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

@@ -11,13 +11,27 @@
"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": {
"cors": "^2.8.5",
"express": "^5.1.0",
"express-fileupload": "^1.4.3"
"express-fileupload": "^1.4.3",
"ws": "^8.18.3"
}
}

View File

@@ -1,133 +0,0 @@
// API Client for communicating with the backend
class ApiClient {
constructor() {
this.baseUrl = 'http://localhost:3001'; // Backend server URL
}
async getClusterMembers() {
try {
const response = await fetch(`${this.baseUrl}/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 getDiscoveryInfo() {
try {
const response = await fetch(`${this.baseUrl}/api/discovery/nodes`, {
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 selectRandomPrimaryNode() {
try {
const response = await fetch(`${this.baseUrl}/api/discovery/random-primary`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
timestamp: new Date().toISOString()
})
});
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 {
const response = await fetch(`${this.baseUrl}/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(ip) {
try {
const url = ip
? `${this.baseUrl}/api/tasks/status?ip=${encodeURIComponent(ip)}`
: `${this.baseUrl}/api/tasks/status`;
const response = await fetch(url, {
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 uploadFirmware(file, nodeIp) {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${this.baseUrl}/api/node/update?ip=${encodeURIComponent(nodeIp)}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Upload failed: ${error.message}`);
}
}
}
// Global API client instance
window.apiClient = new ApiClient();

View File

@@ -1,199 +0,0 @@
// Main SPORE UI Application
// Initialize the application when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('=== SPORE UI Application Initialization ===');
// Initialize the framework (but don't navigate yet)
console.log('App: Creating framework instance...');
const app = window.app;
// Create view models
console.log('App: Creating view models...');
const clusterViewModel = new ClusterViewModel();
const firmwareViewModel = new FirmwareViewModel();
console.log('App: View models created:', { clusterViewModel, firmwareViewModel });
// Connect firmware view model to cluster data
clusterViewModel.subscribe('members', (members) => {
console.log('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
}));
firmwareViewModel.updateAvailableNodes(nodes);
console.log('App: Updated firmware view model with nodes:', nodes);
} else {
firmwareViewModel.updateAvailableNodes([]);
console.log('App: Cleared firmware view model nodes');
}
});
// Register routes with their view models
console.log('App: Registering routes...');
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
console.log('App: Routes registered and components pre-initialized');
// Initialize cluster status component for header badge AFTER main components
// DISABLED - causes interference with main cluster functionality
/*
console.log('App: Initializing cluster status component...');
const clusterStatusComponent = new ClusterStatusComponent(
document.querySelector('.cluster-status'),
clusterViewModel,
app.eventBus
);
clusterStatusComponent.initialize();
console.log('App: Cluster status component initialized');
*/
// Set up navigation event listeners
console.log('App: Setting up navigation...');
app.setupNavigation();
// Set up cluster status updates (simple approach without component interference)
setupClusterStatusUpdates(clusterViewModel);
// Set up periodic updates for cluster view with state preservation
// setupPeriodicUpdates(); // Disabled automatic refresh
// Now navigate to the default route
console.log('App: Navigating to default route...');
app.navigateTo('cluster');
console.log('=== SPORE UI Application initialization completed ===');
});
// Set up periodic updates with state preservation
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') {
console.log('App: Performing smart update to preserve UI state...');
viewModel.smartUpdate();
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
console.log('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);
}
// Set up cluster status updates (simple approach without component interference)
function setupClusterStatusUpdates(clusterViewModel) {
// Set initial "discovering" state immediately
updateClusterStatusBadge(undefined, undefined, undefined);
// Force a fresh fetch and keep showing "discovering" until we get real data
let hasReceivedRealData = false;
// Subscribe to view model changes to update cluster status
clusterViewModel.subscribe('totalNodes', (totalNodes) => {
if (hasReceivedRealData) {
updateClusterStatusBadge(totalNodes, clusterViewModel.get('clientInitialized'), clusterViewModel.get('error'));
}
});
clusterViewModel.subscribe('clientInitialized', (clientInitialized) => {
if (hasReceivedRealData) {
updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clientInitialized, clusterViewModel.get('error'));
}
});
clusterViewModel.subscribe('error', (error) => {
if (hasReceivedRealData) {
updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clusterViewModel.get('clientInitialized'), error);
}
});
// Force a fresh fetch and only update status after we get real data
setTimeout(async () => {
try {
console.log('Cluster Status: Forcing fresh fetch from backend...');
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
console.log('Cluster Status: Got fresh data:', discoveryInfo);
// Now we have real data, mark it and update the status
hasReceivedRealData = true;
updateClusterStatusBadge(discoveryInfo.totalNodes, discoveryInfo.clientInitialized, null);
} catch (error) {
console.error('Cluster Status: Failed to fetch fresh data:', error);
hasReceivedRealData = true;
updateClusterStatusBadge(0, false, error.message);
}
}, 100); // Small delay to ensure view model is ready
}
function updateClusterStatusBadge(totalNodes, clientInitialized, error) {
const clusterStatusBadge = document.querySelector('.cluster-status');
if (!clusterStatusBadge) return;
let statusText, statusIcon, statusClass;
// Check if we're still in initial state (no real data yet)
const hasRealData = totalNodes !== undefined && clientInitialized !== undefined;
if (!hasRealData) {
statusText = 'Cluster Discovering...';
statusIcon = '🔍';
statusClass = 'cluster-status-discovering';
} else if (error || totalNodes === 0) {
// Show "Cluster Offline" for both errors and when no nodes are discovered
statusText = 'Cluster Offline';
statusIcon = '🔴';
statusClass = 'cluster-status-offline';
} else if (clientInitialized) {
statusText = 'Cluster Online';
statusIcon = '🟢';
statusClass = 'cluster-status-online';
} else {
statusText = 'Cluster Connecting';
statusIcon = '🟡';
statusClass = 'cluster-status-connecting';
}
// Update the badge
clusterStatusBadge.innerHTML = `${statusIcon} ${statusText}`;
// Remove all existing status classes
clusterStatusBadge.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error', 'cluster-status-discovering');
// Add the appropriate status class
clusterStatusBadge.classList.add(statusClass);
}
// Global error handler
window.addEventListener('error', function(event) {
console.error('Global error:', event.error);
});
// Global unhandled promise rejection handler
window.addEventListener('unhandledrejection', function(event) {
console.error('Unhandled promise rejection:', event.reason);
});
// Clean up on page unload
window.addEventListener('beforeunload', function() {
if (window.app) {
console.log('App: Cleaning up cached components...');
window.app.cleanup();
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,190 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Cluster Load</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.debug-panel { background: #f0f0f0; padding: 15px; margin: 10px 0; border-radius: 5px; }
.debug-button { padding: 8px 16px; margin: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer; }
.debug-button:hover { background: #0056b3; }
.log { background: #000; color: #0f0; padding: 10px; margin: 10px 0; border-radius: 3px; font-family: monospace; max-height: 300px; overflow-y: auto; }
.cluster-container { border: 1px solid #ccc; padding: 15px; margin: 10px 0; border-radius: 5px; }
</style>
</head>
<body>
<h1>🔍 Debug Cluster Load</h1>
<div class="debug-panel">
<h3>Debug Controls</h3>
<button class="debug-button" onclick="testContainerFind()">🔍 Test Container Find</button>
<button class="debug-button" onclick="testViewModel()">📊 Test ViewModel</button>
<button class="debug-button" onclick="testComponent()">🧩 Test Component</button>
<button class="debug-button" onclick="testAPICall()">📡 Test API Call</button>
<button class="debug-button" onclick="clearLog()">🧹 Clear Log</button>
</div>
<div class="debug-panel">
<h3>Container Elements</h3>
<div id="cluster-view" class="cluster-container">
<div class="primary-node-info">
<h4>Primary Node</h4>
<div id="primary-node-ip">🔍 Discovering...</div>
<button class="primary-node-refresh">🔄 Refresh</button>
</div>
<div id="cluster-members-container">
<h4>Cluster Members</h4>
<div class="loading">Loading cluster members...</div>
</div>
</div>
</div>
<div class="debug-panel">
<h3>Debug Log</h3>
<div id="debug-log" class="log"></div>
</div>
<!-- Include SPORE UI framework and components -->
<script src="framework.js"></script>
<script src="view-models.js"></script>
<script src="components.js"></script>
<script src="api-client.js"></script>
<script>
let debugLog = [];
function log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const logEntry = `[${timestamp}] ${message}`;
const logContainer = document.getElementById('debug-log');
logContainer.innerHTML += logEntry + '\n';
logContainer.scrollTop = logContainer.scrollHeight;
debugLog.push({ timestamp, message, type });
console.log(logEntry);
}
function clearLog() {
document.getElementById('debug-log').innerHTML = '';
debugLog = [];
}
// Test container finding
function testContainerFind() {
log('🔍 Testing container finding...');
const clusterView = document.getElementById('cluster-view');
const primaryNodeInfo = document.querySelector('.primary-node-info');
const clusterMembersContainer = document.getElementById('cluster-members-container');
log(`Cluster view found: ${!!clusterView} (ID: ${clusterView?.id})`);
log(`Primary node info found: ${!!primaryNodeInfo}`);
log(`Cluster members container found: ${!!clusterMembersContainer} (ID: ${clusterMembersContainer?.id})`);
log(`Cluster members container innerHTML: ${clusterMembersContainer?.innerHTML?.substring(0, 100)}...`);
}
// Test view model
function testViewModel() {
log('📊 Testing ViewModel...');
try {
const viewModel = new ClusterViewModel();
log('✅ ClusterViewModel created successfully');
log(`Initial members: ${viewModel.get('members')?.length || 0}`);
log(`Initial loading: ${viewModel.get('isLoading')}`);
log(`Initial error: ${viewModel.get('error')}`);
return viewModel;
} catch (error) {
log(`❌ ViewModel creation failed: ${error.message}`, 'error');
return null;
}
}
// Test component
function testComponent() {
log('🧩 Testing Component...');
try {
const viewModel = new ClusterViewModel();
const eventBus = new EventBus();
const container = document.getElementById('cluster-members-container');
log('✅ Dependencies created, creating ClusterMembersComponent...');
const component = new ClusterMembersComponent(container, viewModel, eventBus);
log('✅ ClusterMembersComponent created successfully');
log('Mounting component...');
component.mount();
log('✅ Component mounted');
return { component, viewModel, eventBus };
} catch (error) {
log(`❌ Component test failed: ${error.message}`, 'error');
return null;
}
}
// Test API call
async function testAPICall() {
log('📡 Testing API call...');
try {
if (!window.apiClient) {
log('❌ API client not available');
return;
}
log('Calling getClusterMembers...');
const response = await window.apiClient.getClusterMembers();
log(`✅ API call successful: ${response.members?.length || 0} members`);
if (response.members && response.members.length > 0) {
response.members.forEach(member => {
log(`📱 Member: ${member.hostname || member.ip} (${member.status})`);
});
}
} catch (error) {
log(`❌ API call failed: ${error.message}`, 'error');
}
}
// Initialize debug interface
document.addEventListener('DOMContentLoaded', function() {
log('🚀 Debug interface initialized');
log('💡 Use the debug controls above to test different aspects of the cluster loading');
});
// Mock API client if not available
if (!window.apiClient) {
log('⚠️ Creating mock API client for testing');
window.apiClient = {
getClusterMembers: async () => {
log('📡 Mock API: getClusterMembers called');
return {
members: [
{ ip: '192.168.1.100', hostname: 'Node-1', status: 'active', latency: 15 },
{ ip: '192.168.1.101', hostname: 'Node-2', status: 'active', latency: 22 },
{ ip: '192.168.1.102', hostname: 'Node-3', status: 'offline', latency: null }
]
};
},
getDiscoveryInfo: async () => {
log('📡 Mock API: getDiscoveryInfo called');
return {
primaryNode: '192.168.1.100',
clientInitialized: true,
totalNodes: 3
};
}
};
}
</script>
</body>
</html>

View File

@@ -1,208 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Cluster</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }
.debug-section { margin: 20px 0; padding: 20px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.status { padding: 10px; margin: 10px 0; border-radius: 4px; }
.status.success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status.error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.status.info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
button { padding: 10px 20px; margin: 5px; border: none; border-radius: 4px; background: #007bff; color: white; cursor: pointer; }
button:hover { background: #0056b3; }
.log { background: #f8f9fa; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; max-height: 300px; overflow-y: auto; }
</style>
</head>
<body>
<h1>🐛 Debug Cluster Functionality</h1>
<div class="debug-section">
<h3>1. API Client Test</h3>
<button onclick="testApiClient()">Test API Client</button>
<div id="api-client-result"></div>
</div>
<div class="debug-section">
<h3>2. View Model Test</h3>
<button onclick="testViewModel()">Test View Model</button>
<div id="viewmodel-result"></div>
</div>
<div class="debug-section">
<h3>3. Component Test</h3>
<button onclick="testComponents()">Test Components</button>
<div id="component-result"></div>
</div>
<div class="debug-section">
<h3>4. Console Log</h3>
<div id="console-log" class="log"></div>
<button onclick="clearLog()">Clear Log</button>
</div>
<script src="framework.js"></script>
<script src="api-client.js"></script>
<script src="view-models.js"></script>
<script src="components.js"></script>
<script>
// Capture console logs
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
function addToLog(level, ...args) {
const logDiv = document.getElementById('console-log');
const timestamp = new Date().toLocaleTimeString();
const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)).join(' ');
logDiv.innerHTML += `[${timestamp}] ${level}: ${message}\n`;
logDiv.scrollTop = logDiv.scrollHeight;
}
console.log = function(...args) {
originalLog.apply(console, args);
addToLog('LOG', ...args);
};
console.error = function(...args) {
originalError.apply(console, args);
addToLog('ERROR', ...args);
};
console.warn = function(...args) {
originalWarn.apply(console, args);
addToLog('WARN', ...args);
};
function clearLog() {
document.getElementById('console-log').innerHTML = '';
}
async function testApiClient() {
const resultDiv = document.getElementById('api-client-result');
resultDiv.innerHTML = '<div class="status info">Testing API Client...</div>';
try {
console.log('Testing API Client...');
// Test discovery info
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
console.log('Discovery Info:', discoveryInfo);
// Test cluster members
const clusterMembers = await window.apiClient.getClusterMembers();
console.log('Cluster Members:', clusterMembers);
resultDiv.innerHTML = `
<div class="status success">
<strong>API Client Test Passed!</strong><br>
Discovery: ${discoveryInfo.totalNodes} nodes, Primary: ${discoveryInfo.primaryNode || 'None'}<br>
Members: ${clusterMembers.members ? clusterMembers.members.length : 0} members
</div>
`;
} catch (error) {
console.error('API Client Test Failed:', error);
resultDiv.innerHTML = `<div class="status error">API Client Test Failed: ${error.message}</div>`;
}
}
async function testViewModel() {
const resultDiv = document.getElementById('viewmodel-result');
resultDiv.innerHTML = '<div class="status info">Testing View Model...</div>';
try {
console.log('Testing View Model...');
const clusterViewModel = new ClusterViewModel();
console.log('ClusterViewModel created:', clusterViewModel);
// Wait for initial data
await new Promise(resolve => setTimeout(resolve, 200));
const totalNodes = clusterViewModel.get('totalNodes');
const primaryNode = clusterViewModel.get('primaryNode');
const clientInitialized = clusterViewModel.get('clientInitialized');
console.log('ViewModel data:', { totalNodes, primaryNode, clientInitialized });
resultDiv.innerHTML = `
<div class="status success">
<strong>View Model Test Passed!</strong><br>
Total Nodes: ${totalNodes}<br>
Primary Node: ${primaryNode || 'None'}<br>
Client Initialized: ${clientInitialized}
</div>
`;
} catch (error) {
console.error('View Model Test Failed:', error);
resultDiv.innerHTML = `<div class="status error">View Model Test Failed: ${error.message}</div>`;
}
}
async function testComponents() {
const resultDiv = document.getElementById('component-result');
resultDiv.innerHTML = '<div class="status info">Testing Components...</div>';
try {
console.log('Testing Components...');
const eventBus = new EventBus();
const clusterViewModel = new ClusterViewModel();
// Test cluster status component
const statusContainer = document.createElement('div');
statusContainer.className = 'cluster-status';
statusContainer.innerHTML = '🚀 Cluster Online';
document.body.appendChild(statusContainer);
const clusterStatusComponent = new ClusterStatusComponent(
statusContainer,
clusterViewModel,
eventBus
);
clusterStatusComponent.initialize();
console.log('Cluster Status Component initialized');
// Wait for data
await new Promise(resolve => setTimeout(resolve, 300));
const statusText = statusContainer.innerHTML;
const statusClasses = Array.from(statusContainer.classList);
console.log('Status Component Result:', { statusText, statusClasses });
resultDiv.innerHTML = `
<div class="status success">
<strong>Component Test Passed!</strong><br>
Status Text: ${statusText}<br>
Status Classes: ${statusClasses.join(', ')}
</div>
`;
// Clean up
document.body.removeChild(statusContainer);
} catch (error) {
console.error('Component Test Failed:', error);
resultDiv.innerHTML = `<div class="status error">Component Test Failed: ${error.message}</div>`;
}
}
// Auto-run tests on page load
window.addEventListener('load', () => {
setTimeout(() => {
console.log('Page loaded, starting auto-tests...');
testApiClient();
setTimeout(testViewModel, 1000);
setTimeout(testComponents, 2000);
}, 500);
});
</script>
</body>
</html>

View File

@@ -1,124 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debug Framework</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.debug-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
.log { background: #f5f5f5; padding: 10px; margin: 10px 0; font-family: monospace; }
</style>
</head>
<body>
<h1>Framework Debug</h1>
<div class="debug-section">
<h2>Console Log</h2>
<div id="console-log" class="log"></div>
<button onclick="clearLog()">Clear Log</button>
</div>
<div class="debug-section">
<h2>Test Cluster View</h2>
<div id="cluster-view">
<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" 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>
<button class="refresh-btn">Refresh</button>
</div>
<div id="cluster-members-container">
<div class="loading">Loading cluster members...</div>
</div>
</div>
</div>
<button onclick="testClusterView()">Test Cluster View</button>
</div>
<script src="framework.js"></script>
<script src="api-client.js"></script>
<script src="view-models.js"></script>
<script src="components.js"></script>
<script>
// Override console.log to capture output
const originalLog = console.log;
const originalError = console.error;
const logElement = document.getElementById('console-log');
function addToLog(message, type = 'log') {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.style.color = type === 'error' ? 'red' : 'black';
logEntry.textContent = `[${timestamp}] ${message}`;
logElement.appendChild(logEntry);
logElement.scrollTop = logElement.scrollHeight;
}
console.log = function(...args) {
originalLog.apply(console, args);
addToLog(args.join(' '));
};
console.error = function(...args) {
originalError.apply(console, args);
addToLog(args.join(' '), 'error');
};
function clearLog() {
logElement.innerHTML = '';
}
// Test cluster view
function testClusterView() {
try {
console.log('Testing cluster view...');
// Create view model
const clusterVM = new ClusterViewModel();
console.log('ClusterViewModel created:', clusterVM);
// Create component
const container = document.getElementById('cluster-view');
const clusterComponent = new ClusterViewComponent(container, clusterVM, null);
console.log('ClusterViewComponent created:', clusterComponent);
// Mount component
clusterComponent.mount();
console.log('Component mounted');
// Test data loading
console.log('Testing data loading...');
clusterVM.updateClusterMembers();
} catch (error) {
console.error('Error testing cluster view:', error);
}
}
// Initialize framework
document.addEventListener('DOMContentLoaded', function() {
console.log('DOM loaded, initializing framework...');
if (window.app) {
console.log('Framework app found:', window.app);
window.app.init();
console.log('Framework initialized');
} else {
console.error('Framework app not found');
}
});
</script>
</body>
</html>

View File

@@ -1,344 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deploy Button Test - Isolated</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #1a202c;
color: white;
}
.test-section {
background: rgba(255, 255, 255, 0.05);
padding: 20px;
margin: 20px 0;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.firmware-actions {
background: rgba(0, 0, 0, 0.3);
padding: 20px;
border-radius: 8px;
}
.target-options {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.target-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.file-input-wrapper {
margin: 20px 0;
}
.deploy-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
}
.deploy-btn:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.4);
cursor: not-allowed;
}
.node-select {
background: #2d3748;
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 5px 10px;
border-radius: 4px;
margin-left: 10px;
}
.cluster-members {
background: rgba(0, 0, 0, 0.2);
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.member-card {
background: rgba(255, 255, 255, 0.05);
padding: 10px;
margin: 10px 0;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.debug-info {
background: rgba(0, 0, 0, 0.5);
padding: 15px;
border-radius: 6px;
margin: 20px 0;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<h1>🚀 Deploy Button Test - Isolated</h1>
<div class="test-section">
<h2>Test Scenario: Deploy Button State</h2>
<p>This test isolates the deploy button functionality to debug the issue.</p>
</div>
<div class="firmware-actions">
<h3>🚀 Firmware Update</h3>
<div class="target-options">
<label class="target-option">
<input type="radio" name="target-type" value="all" checked>
<span>All Nodes</span>
</label>
<label class="target-option">
<input type="radio" name="target-type" value="specific">
<span>Specific Node</span>
<select id="specific-node-select" class="node-select" style="visibility: hidden; opacity: 0;">
<option value="">Select a node...</option>
</select>
</label>
</div>
<div class="file-input-wrapper">
<input type="file" id="global-firmware-file" accept=".bin,.hex" style="display: none;">
<button onclick="document.getElementById('global-firmware-file').click()">
📁 Choose File
</button>
<span id="file-info">No file selected</span>
</div>
<button class="deploy-btn" id="deploy-btn" disabled>
🚀 Deploy Firmware
</button>
</div>
<div class="cluster-members">
<h3>Cluster Members</h3>
<div id="cluster-members-container">
<div class="loading">Loading cluster members...</div>
</div>
<button onclick="addTestNode()">Add Test Node</button>
<button onclick="removeAllNodes()">Remove All Nodes</button>
</div>
<div class="debug-info">
<h3>Debug Information</h3>
<div id="debug-output">Waiting for actions...</div>
</div>
<script>
// Simulate the cluster members functionality
let testNodes = [];
function addTestNode() {
const nodeCount = testNodes.length + 1;
const newNode = {
ip: `192.168.1.${100 + nodeCount}`,
hostname: `TestNode${nodeCount}`,
status: 'active',
latency: Math.floor(Math.random() * 50) + 10
};
testNodes.push(newNode);
displayClusterMembers();
populateNodeSelect();
updateDeployButton();
updateDebugInfo();
}
function removeAllNodes() {
testNodes = [];
displayClusterMembers();
populateNodeSelect();
updateDeployButton();
updateDebugInfo();
}
function displayClusterMembers() {
const container = document.getElementById('cluster-members-container');
if (testNodes.length === 0) {
container.innerHTML = '<div class="loading">No cluster members found</div>';
return;
}
const membersHTML = testNodes.map(node => {
const statusClass = node.status === 'active' ? 'status-online' : 'status-offline';
const statusText = node.status === 'active' ? 'Online' : 'Offline';
const statusIcon = node.status === 'active' ? '🟢' : '🔴';
return `
<div class="member-card" data-member-ip="${node.ip}">
<div class="member-name">${node.hostname}</div>
<div class="member-ip">${node.ip}</div>
<div class="member-status ${statusClass}">
${statusIcon} ${statusText}
</div>
<div class="member-latency">Latency: ${node.latency}ms</div>
</div>
`;
}).join('');
container.innerHTML = membersHTML;
}
function populateNodeSelect() {
const select = document.getElementById('specific-node-select');
if (!select) return;
select.innerHTML = '<option value="">Select a node...</option>';
if (testNodes.length === 0) {
const option = document.createElement('option');
option.value = "";
option.textContent = "No nodes available";
option.disabled = true;
select.appendChild(option);
return;
}
testNodes.forEach(node => {
const option = document.createElement('option');
option.value = node.ip;
option.textContent = `${node.hostname} (${node.ip})`;
select.appendChild(option);
});
}
function updateDeployButton() {
const deployBtn = document.getElementById('deploy-btn');
const fileInput = document.getElementById('global-firmware-file');
const targetType = document.querySelector('input[name="target-type"]:checked');
const specificNodeSelect = document.getElementById('specific-node-select');
if (!deployBtn || !fileInput) return;
const hasFile = fileInput.files && fileInput.files.length > 0;
const hasAvailableNodes = testNodes.length > 0;
let isValidTarget = false;
if (targetType.value === 'all') {
isValidTarget = hasAvailableNodes;
} else if (targetType.value === 'specific') {
isValidTarget = hasAvailableNodes && specificNodeSelect.value && specificNodeSelect.value !== "";
}
// Debug logging
const debugInfo = {
hasFile,
targetType: targetType?.value,
hasAvailableNodes,
specificNodeValue: specificNodeSelect?.value,
isValidTarget,
memberCardsCount: testNodes.length
};
console.log('updateDeployButton debug:', debugInfo);
deployBtn.disabled = !hasFile || !isValidTarget;
// Update button text to provide better feedback
if (!hasAvailableNodes) {
deployBtn.textContent = '🚀 Deploy (No nodes available)';
deployBtn.title = 'No cluster nodes are currently available for deployment';
} else if (!hasFile) {
deployBtn.textContent = '🚀 Deploy Firmware';
deployBtn.title = 'Please select a firmware file to deploy';
} else if (!isValidTarget) {
deployBtn.textContent = '🚀 Deploy Firmware';
deployBtn.title = 'Please select a valid target for deployment';
} else {
deployBtn.textContent = '🚀 Deploy Firmware';
deployBtn.title = 'Ready to deploy firmware';
}
updateDebugInfo();
}
function updateDebugInfo() {
const debugOutput = document.getElementById('debug-output');
const deployBtn = document.getElementById('deploy-btn');
const fileInput = document.getElementById('global-firmware-file');
const targetType = document.querySelector('input[name="target-type"]:checked');
const specificNodeSelect = document.getElementById('specific-node-select');
const debugInfo = {
hasFile: fileInput.files && fileInput.files.length > 0,
targetType: targetType?.value,
hasAvailableNodes: testNodes.length > 0,
specificNodeValue: specificNodeSelect?.value,
deployButtonDisabled: deployBtn.disabled,
deployButtonText: deployBtn.textContent,
testNodesCount: testNodes.length
};
debugOutput.innerHTML = `<pre>${JSON.stringify(debugInfo, null, 2)}</pre>`;
}
// Setup event listeners
document.addEventListener('DOMContentLoaded', function() {
// Setup target selection
const targetRadios = document.querySelectorAll('input[name="target-type"]');
const specificNodeSelect = document.getElementById('specific-node-select');
targetRadios.forEach(radio => {
radio.addEventListener('change', () => {
console.log('Target radio changed to:', radio.value);
if (radio.value === 'specific') {
specificNodeSelect.style.visibility = 'visible';
specificNodeSelect.style.opacity = '1';
populateNodeSelect();
} else {
specificNodeSelect.style.visibility = 'hidden';
specificNodeSelect.style.opacity = '0';
}
console.log('Calling updateDeployButton after target change');
updateDeployButton();
});
});
// Setup specific node select change handler
if (specificNodeSelect) {
specificNodeSelect.addEventListener('change', (event) => {
console.log('Specific node select changed to:', event.target.value);
updateDeployButton();
});
}
// Setup file input change handler
const fileInput = document.getElementById('global-firmware-file');
if (fileInput) {
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
const fileInfo = document.getElementById('file-info');
if (file) {
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
} else {
fileInfo.textContent = 'No file selected';
}
updateDeployButton();
});
}
// Initial setup
displayClusterMembers();
populateNodeSelect();
updateDeployButton();
});
</script>
</body>
</html>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 B

View File

@@ -1,47 +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 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>
<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 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>
@@ -49,72 +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="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>
<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 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>
</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 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="framework.js"></script>
<script src="api-client.js"></script>
<script src="view-models.js"></script>
<script src="components.js"></script>
<script src="app.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

@@ -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 };
})();

View File

@@ -1,5 +1,16 @@
// 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() {
@@ -77,7 +88,7 @@ class ViewModel {
// Set data property and notify listeners
set(property, value) {
console.log(`ViewModel: Setting property '${property}' to:`, value);
logger.debug(`ViewModel: Setting property '${property}' to:`, value);
// Check if the value has actually changed
const hasChanged = this._data[property] !== value;
@@ -89,38 +100,39 @@ class ViewModel {
// Update the data
this._data[property] = value;
console.log(`ViewModel: Property '${property}' changed, notifying listeners...`);
logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`);
this._notifyListeners(property, value, this._previousData[property]);
} else {
console.log(`ViewModel: Property '${property}' unchanged, skipping notification`);
logger.debug(`ViewModel: Property '${property}' unchanged, skipping notification`);
}
}
// Set multiple properties at once with change detection
setMultiple(properties) {
const changedProperties = {};
const unchangedProperties = {};
// Determine changes and update previousData snapshot per key
Object.keys(properties).forEach(key => {
if (this._data[key] !== properties[key]) {
changedProperties[key] = properties[key];
} else {
unchangedProperties[key] = properties[key];
const newValue = properties[key];
const oldValue = this._data[key];
if (oldValue !== newValue) {
this._previousData[key] = oldValue;
changedProperties[key] = newValue;
}
});
// Set all properties
// Apply all properties
Object.keys(properties).forEach(key => {
this._data[key] = properties[key];
});
// Notify listeners only for changed properties
// Notify listeners only for changed properties with accurate previous values
Object.keys(changedProperties).forEach(key => {
this._notifyListeners(key, changedProperties[key], this._previousData[key]);
this._notifyListeners(key, this._data[key], this._previousData[key]);
});
if (Object.keys(changedProperties).length > 0) {
console.log(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
logger.debug(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
}
}
@@ -145,20 +157,20 @@ class ViewModel {
// Notify listeners of property changes
_notifyListeners(property, value, previousValue) {
console.log(`ViewModel: _notifyListeners called for property '${property}'`);
logger.debug(`ViewModel: _notifyListeners called for property '${property}'`);
if (this._listeners.has(property)) {
const callbacks = this._listeners.get(property);
console.log(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
callbacks.forEach((callback, index) => {
try {
console.log(`ViewModel: Calling listener ${index} for property '${property}'`);
logger.debug(`ViewModel: Calling listener ${index} for property '${property}'`);
callback(value, previousValue);
} catch (error) {
console.error(`Error in property listener for ${property}:`, error);
}
});
} else {
console.log(`ViewModel: No listeners found for property '${property}'`);
logger.debug(`ViewModel: No listeners found for property '${property}'`);
}
}
@@ -213,36 +225,33 @@ class ViewModel {
// Batch update with change detection
batchUpdate(updates, options = {}) {
const { preserveUIState = true, notifyChanges = true } = options;
const { notifyChanges = true } = options;
if (preserveUIState) {
// Store current UI state
const currentUIState = new Map(this._uiState);
// Apply updates
Object.keys(updates).forEach(key => {
this._data[key] = updates[key];
});
// Restore UI state
this._uiState = currentUIState;
} else {
// Apply updates normally
Object.keys(updates).forEach(key => {
this._data[key] = updates[key];
});
}
// 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 if requested
// Notify listeners for changed keys
if (notifyChanges) {
Object.keys(updates).forEach(key => {
this._notifyListeners(key, updates[key], this._previousData[key]);
changedKeys.forEach(key => {
this._notifyListeners(key, this._data[key], this._previousData[key]);
});
}
}
}
// Base Component class with enhanced state preservation
// Base Component class
class Component {
constructor(container, viewModel, eventBus) {
this.container = container;
@@ -268,13 +277,13 @@ class Component {
mount() {
if (this.isMounted) return;
console.log(`${this.constructor.name}: Starting mount...`);
logger.debug(`${this.constructor.name}: Starting mount...`);
this.isMounted = true;
this.setupEventListeners();
this.setupViewModelListeners();
this.render();
console.log(`${this.constructor.name}: Mounted successfully`);
logger.debug(`${this.constructor.name}: Mounted successfully`);
}
// Unmount the component
@@ -285,14 +294,14 @@ class Component {
this.cleanupEventListeners();
this.cleanupViewModelListeners();
console.log(`${this.constructor.name} unmounted`);
logger.debug(`${this.constructor.name} unmounted`);
}
// Pause the component (keep alive but pause activity)
pause() {
if (!this.isMounted) return;
console.log(`${this.constructor.name}: Pausing component`);
logger.debug(`${this.constructor.name}: Pausing component`);
// Pause any active timers or animations
if (this.updateInterval) {
@@ -311,7 +320,7 @@ class Component {
resume() {
if (!this.isMounted || !this.isPaused) return;
console.log(`${this.constructor.name}: Resuming component`);
logger.debug(`${this.constructor.name}: Resuming component`);
this.isPaused = false;
@@ -368,7 +377,7 @@ class Component {
// Partial update method for efficient data updates
updatePartial(property, newValue, previousValue) {
// Override in subclasses to implement partial updates
console.log(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
logger.debug(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
}
// UI State Management Methods
@@ -457,22 +466,22 @@ class Component {
// Helper method to set innerHTML safely
setHTML(selector, html) {
console.log(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
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;
console.log(`${this.constructor.name}: Using component container for empty selector`);
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) {
console.log(`${this.constructor.name}: Element found, setting innerHTML`);
logger.debug(`${this.constructor.name}: Element found, setting innerHTML`);
element.innerHTML = html;
console.log(`${this.constructor.name}: innerHTML set successfully`);
logger.debug(`${this.constructor.name}: innerHTML set successfully`);
} else {
console.error(`${this.constructor.name}: Element not found for selector '${selector}'`);
}
@@ -521,6 +530,80 @@ class Component {
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
@@ -533,7 +616,7 @@ class App {
this.navigationInProgress = false;
this.navigationQueue = [];
this.lastNavigationTime = 0;
this.navigationCooldown = 300; // 300ms cooldown between navigations
this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300; // cooldown between navigations
// Component cache to keep components alive
this.componentCache = new Map();
@@ -544,8 +627,8 @@ class App {
registerRoute(name, componentClass, containerId, viewModel = null) {
this.routes.set(name, { componentClass, containerId, viewModel });
// Pre-initialize component in cache for better performance
this.preInitializeComponent(name, componentClass, containerId, viewModel);
// Defer instantiation until navigation to reduce startup work
// this.preInitializeComponent(name, componentClass, containerId, viewModel);
}
// Pre-initialize component in cache
@@ -560,21 +643,21 @@ class App {
// Store in cache
this.componentCache.set(name, component);
console.log(`App: Pre-initialized component for route '${name}'`);
logger.debug(`App: Pre-initialized component for route '${name}'`);
}
// Navigate to a route
navigateTo(routeName) {
navigateTo(routeName, updateUrl = true) {
// Check cooldown period
const now = Date.now();
if (now - this.lastNavigationTime < this.navigationCooldown) {
console.log(`App: Navigation cooldown active, skipping route '${routeName}'`);
logger.debug(`App: Navigation cooldown active, skipping route '${routeName}'`);
return;
}
// If navigation is already in progress, queue this request
if (this.navigationInProgress) {
console.log(`App: Navigation in progress, queuing route '${routeName}'`);
logger.debug(`App: Navigation in progress, queuing route '${routeName}'`);
if (!this.navigationQueue.includes(routeName)) {
this.navigationQueue.push(routeName);
}
@@ -583,32 +666,67 @@ class App {
// If trying to navigate to the same route, do nothing
if (this.currentView && this.currentView.routeName === routeName) {
console.log(`App: Already on route '${routeName}', skipping navigation`);
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 {
console.log(`App: Navigating to route '${routeName}'`);
logger.debug(`App: Navigating to route '${routeName}'`);
const route = this.routes.get(routeName);
if (!route) {
console.error(`Route '${routeName}' not found`);
return;
}
console.log(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
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) {
console.log(`App: Component not in cache, creating new instance for '${routeName}'`);
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`);
@@ -623,12 +741,12 @@ class App {
// Hide current view smoothly
if (this.currentView) {
console.log('App: Hiding current view');
logger.debug('App: Hiding current view');
await this.hideCurrentView();
}
// Show new view
console.log(`App: Showing new view '${routeName}'`);
logger.debug(`App: Showing new view '${routeName}'`);
await this.showView(routeName, component);
// Update navigation state
@@ -640,7 +758,7 @@ class App {
// Mark view as cached for future use
this.cachedViews.add(routeName);
console.log(`App: Navigation to '${routeName}' completed`);
logger.debug(`App: Navigation to '${routeName}' completed`);
} catch (error) {
console.error('App: Navigation failed:', error);
@@ -650,7 +768,7 @@ class App {
// Process any queued navigation requests
if (this.navigationQueue.length > 0) {
const nextRoute = this.navigationQueue.shift();
console.log(`App: Processing queued navigation to '${nextRoute}'`);
logger.debug(`App: Processing queued navigation to '${nextRoute}'`);
setTimeout(() => this.navigateTo(nextRoute), 100);
}
}
@@ -662,39 +780,47 @@ class App {
// If component is mounted, pause it instead of unmounting
if (this.currentView.isMounted) {
console.log('App: Pausing current view instead of unmounting');
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 0.15s ease-out';
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, 150));
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)
// 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) {
console.log(`App: Mounting component for '${routeName}'`);
logger.debug(`App: Mounting component for '${routeName}'`);
component.mount();
} else {
console.log(`App: Resuming component for '${routeName}'`);
logger.debug(`App: Resuming component for '${routeName}'`);
component.resume();
}
// Fade in the container
container.style.opacity = '0';
container.style.transition = 'opacity 0.2s ease-in';
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, 50));
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50));
// Fade in
container.style.opacity = '1';
@@ -703,7 +829,7 @@ class App {
// Update navigation state
updateNavigation(activeRoute) {
// Remove active class from all nav tabs
document.querySelectorAll('.nav-tab').forEach(tab => {
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
tab.classList.remove('active');
});
@@ -714,7 +840,7 @@ class App {
}
// Hide all view contents with smooth transition
document.querySelectorAll('.view-content').forEach(view => {
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';
@@ -749,7 +875,7 @@ class App {
// Initialize the application
init() {
console.log('SPORE UI Framework initialized');
logger.debug('SPORE UI Framework initialized');
// Note: Navigation is now handled by the app initialization
// to ensure routes are registered before navigation
@@ -757,21 +883,36 @@ class App {
// Setup navigation
setupNavigation() {
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', () => {
// 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() {
console.log('App: Cleaning up cached components...');
logger.debug('App: Cleaning up cached components...');
this.componentCache.forEach((component, routeName) => {
if (component.isMounted) {
console.log(`App: Unmounting cached component '${routeName}'`);
logger.debug(`App: Unmounting cached component '${routeName}'`);
component.unmount();
}
});

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

View File

@@ -1,104 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Simple Framework Test</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
.success { color: green; }
.error { color: red; }
button { margin: 5px; padding: 10px; }
</style>
</head>
<body>
<h1>Simple Framework Test</h1>
<div class="test-section">
<h2>API Test</h2>
<button onclick="testAPI()">Test API Connection</button>
<div id="api-result"></div>
</div>
<div class="test-section">
<h2>Framework Test</h2>
<button onclick="testFramework()">Test Framework</button>
<div id="framework-result"></div>
</div>
<script src="framework.js"></script>
<script src="api-client.js"></script>
<script src="view-models.js"></script>
<script>
async function testAPI() {
const resultDiv = document.getElementById('api-result');
resultDiv.innerHTML = 'Testing...';
try {
// Test cluster members API
const members = await window.apiClient.getClusterMembers();
console.log('Members:', members);
// Test discovery API
const discovery = await window.apiClient.getDiscoveryInfo();
console.log('Discovery:', discovery);
resultDiv.innerHTML = `
<div class="success">
✅ API Test Successful!<br>
Cluster Members: ${members.members?.length || 0}<br>
Primary Node: ${discovery.primaryNode || 'None'}<br>
Total Nodes: ${discovery.totalNodes || 0}
</div>
`;
} catch (error) {
console.error('API test failed:', error);
resultDiv.innerHTML = `<div class="error">❌ API Test Failed: ${error.message}</div>`;
}
}
function testFramework() {
const resultDiv = document.getElementById('framework-result');
resultDiv.innerHTML = 'Testing...';
try {
// Test framework classes
if (typeof EventBus !== 'undefined' &&
typeof ViewModel !== 'undefined' &&
typeof Component !== 'undefined') {
// Create a simple view model
const vm = new ViewModel();
vm.set('test', 'Hello World');
if (vm.get('test') === 'Hello World') {
resultDiv.innerHTML = `
<div class="success">
✅ Framework Test Successful!<br>
EventBus: ${typeof EventBus}<br>
ViewModel: ${typeof ViewModel}<br>
Component: ${typeof Component}<br>
ViewModel test: ${vm.get('test')}
</div>
`;
} else {
throw new Error('ViewModel get/set not working');
}
} else {
throw new Error('Framework classes not found');
}
} catch (error) {
console.error('Framework test failed:', error);
resultDiv.innerHTML = `<div class="error">❌ Framework Test Failed: ${error.message}</div>`;
}
}
// Initialize when page loads
document.addEventListener('DOMContentLoaded', function() {
console.log('Page loaded, framework ready');
});
</script>
</body>
</html>

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

View File

@@ -1,351 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI - Component Caching Test</title>
<link rel="stylesheet" href="styles.css">
<style>
.test-info {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.test-button {
background: rgba(74, 222, 128, 0.2);
border: 1px solid rgba(74, 222, 128, 0.3);
color: #4ade80;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
margin: 0.25rem;
transition: all 0.2s ease;
}
.test-button:hover {
background: rgba(74, 222, 128, 0.3);
border-color: rgba(74, 222, 128, 0.5);
}
.test-button:active {
transform: scale(0.95);
}
.test-results {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: monospace;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<div class="test-info">
<h3>🧪 Component Caching System Test</h3>
<p>This page tests the new component caching system to verify that components are not re-rendered on view switches.</p>
<p><strong>Note:</strong> Components now start with clean default state (collapsed cards, status tab) and don't restore previous UI state.</p>
<div>
<button class="test-button" onclick="testComponentCaching()">Test Component Caching</button>
<button class="test-button" onclick="testDefaultState()">Test Default State</button>
<button class="test-button" onclick="testPerformance()">Test Performance</button>
<button class="test-button" onclick="clearTestData()">Clear Test Data</button>
</div>
</div>
<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>
<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>
<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 id="cluster-members-container">
<div class="loading">
<div>Loading cluster members...</div>
</div>
</div>
</div>
</div>
<div id="firmware-view" class="view-content">
<div class="firmware-section">
<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>
</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 class="test-results" id="test-results">
<h4>Test Results:</h4>
<div id="test-output">Run a test to see results...</div>
</div>
</div>
<script src="framework.js"></script>
<script src="api-client.js"></script>
<script src="view-models.js"></script>
<script src="components.js"></script>
<script src="app.js"></script>
<script>
// Test tracking variables
let componentCreationCount = 0;
let componentMountCount = 0;
let componentUnmountCount = 0;
let componentPauseCount = 0;
let componentResumeCount = 0;
let testStartTime = 0;
// Override console.log to track component operations
const originalLog = console.log;
console.log = function(...args) {
const message = args.join(' ');
// Track component operations
if (message.includes('Constructor called')) {
componentCreationCount++;
} else if (message.includes('Mounting...')) {
componentMountCount++;
} else if (message.includes('Unmounting...')) {
componentUnmountCount++;
} else if (message.includes('Pausing...')) {
componentPauseCount++;
} else if (message.includes('Resuming...')) {
componentResumeCount++;
}
// Call original console.log
originalLog.apply(console, args);
};
// Test functions
function testComponentCaching() {
console.log('🧪 Testing component caching system...');
resetTestCounts();
const results = document.getElementById('test-output');
results.innerHTML = 'Testing component caching...<br>';
// Test rapid view switching
const clusterTab = document.querySelector('[data-view="cluster"]');
const firmwareTab = document.querySelector('[data-view="firmware"]');
let switchCount = 0;
const maxSwitches = 10;
const rapidSwitch = setInterval(() => {
if (switchCount >= maxSwitches) {
clearInterval(rapidSwitch);
analyzeResults();
return;
}
if (switchCount % 2 === 0) {
firmwareTab.click();
results.innerHTML += `Switch ${switchCount + 1}: Cluster → Firmware<br>`;
} else {
clusterTab.click();
results.innerHTML += `Switch ${switchCount + 1}: Firmware → Cluster<br>`;
}
switchCount++;
}, 200);
}
function testDefaultState() {
console.log('🧪 Testing default state...');
resetTestCounts();
const results = document.getElementById('test-output');
results.innerHTML = 'Testing default state...<br>';
// Switch to cluster view
const clusterTab = document.querySelector('[data-view="cluster"]');
clusterTab.click();
results.innerHTML += 'Switched to Cluster View.<br>';
// Check if default state is applied (collapsed cards, status tab)
setTimeout(() => {
const memberCards = document.querySelectorAll('.member-card');
const statusTab = document.querySelector('.nav-tab.active[data-view="status"]');
if (memberCards.length > 0) {
results.innerHTML += 'Checking default state:<br>';
results.innerHTML += `- Member cards are collapsed: ${memberCards.every(card => !card.classList.contains('expanded'))}<br>`;
results.innerHTML += `- Status tab is active: ${statusTab && statusTab.classList.contains('active')}<br>`;
analyzeResults();
} else {
results.innerHTML += 'No member cards found to check default state<br>';
analyzeResults();
}
}, 1000);
}
function testPerformance() {
console.log('🧪 Testing performance...');
resetTestCounts();
testStartTime = performance.now();
const results = document.getElementById('test-output');
results.innerHTML = 'Testing performance with rapid switching...<br>';
// Perform rapid view switching
const clusterTab = document.querySelector('[data-view="cluster"]');
const firmwareTab = document.querySelector('[data-view="firmware"]');
let switchCount = 0;
const maxSwitches = 20;
const performanceTest = setInterval(() => {
if (switchCount >= maxSwitches) {
clearInterval(performanceTest);
const totalTime = performance.now() - testStartTime;
results.innerHTML += `Performance test completed in ${totalTime.toFixed(2)}ms<br>`;
analyzeResults();
return;
}
if (switchCount % 2 === 0) {
firmwareTab.click();
} else {
clusterTab.click();
}
switchCount++;
}, 100);
}
function resetTestCounts() {
componentCreationCount = 0;
componentMountCount = 0;
componentUnmountCount = 0;
componentPauseCount = 0;
componentResumeCount = 0;
}
function analyzeResults() {
const results = document.getElementById('test-output');
results.innerHTML += '<br><strong>Test Analysis:</strong><br>';
results.innerHTML += `Component Creations: ${componentCreationCount}<br>`;
results.innerHTML += `Component Mounts: ${componentMountCount}<br>`;
results.innerHTML += `Component Unmounts: ${componentUnmountCount}<br>`;
results.innerHTML += `Component Pauses: ${componentPauseCount}<br>`;
results.innerHTML += `Component Resumes: ${componentResumeCount}<br><br>`;
// Analyze results
if (componentCreationCount <= 2) {
results.innerHTML += '✅ <strong>PASS:</strong> Components are properly cached (not re-created)<br>';
} else {
results.innerHTML += '❌ <strong>FAIL:</strong> Components are being re-created on view switches<br>';
}
if (componentUnmountCount === 0) {
results.innerHTML += '✅ <strong>PASS:</strong> Components are never unmounted during view switches<br>';
} else {
results.innerHTML += '❌ <strong>FAIL:</strong> Components are being unmounted during view switches<br>';
}
if (componentPauseCount > 0 && componentResumeCount > 0) {
results.innerHTML += '✅ <strong>PASS:</strong> Pause/Resume pattern is working correctly<br>';
} else {
results.innerHTML += '❌ <strong>FAIL:</strong> Pause/Resume pattern is not working<br>';
}
// New test for default state behavior
if (componentCreationCount <= 2 && componentUnmountCount === 0) {
results.innerHTML += '✅ <strong>PASS:</strong> Component caching system is working correctly<br>';
results.innerHTML += '✅ <strong>PASS:</strong> Components start with clean default state<br>';
results.innerHTML += '✅ <strong>PASS:</strong> No complex state restoration causing issues<br>';
}
}
function clearTestData() {
console.log('🧪 Clearing test data...');
localStorage.removeItem('spore_cluster_expanded_cards');
localStorage.removeItem('spore_cluster_active_tabs');
console.log('🧪 Test data cleared');
const results = document.getElementById('test-output');
results.innerHTML = 'Test data cleared. Run a test to see results...';
}
// Add test info to console
console.log('🧪 SPORE UI Component Caching Test Page Loaded');
console.log('🧪 Use the test buttons above to verify the caching system works');
console.log('🧪 Expected: Components should be created once and cached, never re-created');
console.log('🧪 Expected: Components start with clean default state (collapsed cards, status tab)');
console.log('🧪 Expected: No complex state restoration causing incorrect behavior');
</script>
</body>
</html>

View File

@@ -1,351 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deploy Button Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background: #1a202c;
color: white;
}
.test-section {
background: rgba(255, 255, 255, 0.05);
padding: 20px;
margin: 20px 0;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.firmware-actions {
background: rgba(0, 0, 0, 0.3);
padding: 20px;
border-radius: 8px;
}
.target-options {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.target-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.file-input-wrapper {
margin: 20px 0;
}
.deploy-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
color: white;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
}
.deploy-btn:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.4);
cursor: not-allowed;
}
.node-select {
background: #2d3748;
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 5px 10px;
border-radius: 4px;
margin-left: 10px;
}
.no-nodes-message {
color: #fbbf24;
font-size: 0.8rem;
margin-top: 0.25rem;
font-style: italic;
text-align: center;
padding: 0.25rem;
border-radius: 4px;
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
}
.cluster-members {
background: rgba(0, 0, 0, 0.2);
padding: 15px;
border-radius: 6px;
margin: 20px 0;
}
.member-card {
background: rgba(255, 255, 255, 0.05);
padding: 10px;
margin: 10px 0;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-online {
color: #4ade80;
}
.status-offline {
color: #f87171;
}
</style>
</head>
<body>
<h1>🚀 Deploy Button Test</h1>
<div class="test-section">
<h2>Test Scenario: Deploy Button State</h2>
<p>This test demonstrates the deploy button behavior when:</p>
<ul>
<li>No file is selected</li>
<li>No nodes are available</li>
<li>File is selected but no target is chosen</li>
<li>File is selected and target is chosen</li>
</ul>
</div>
<div class="firmware-actions">
<h3>🚀 Firmware Update</h3>
<div class="target-options">
<label class="target-option">
<input type="radio" name="target-type" value="all" checked>
<span>All Nodes</span>
</label>
<label class="target-option">
<input type="radio" name="target-type" value="specific">
<span>Specific Node</span>
<select id="specific-node-select" class="node-select" style="visibility: hidden; opacity: 0;">
<option value="">Select a node...</option>
</select>
</label>
</div>
<div class="file-input-wrapper">
<input type="file" id="global-firmware-file" accept=".bin,.hex" style="display: none;">
<button onclick="document.getElementById('global-firmware-file').click()">
📁 Choose File
</button>
<span id="file-info">No file selected</span>
</div>
<button class="deploy-btn" id="deploy-btn" disabled>
🚀 Deploy Firmware
</button>
</div>
<div class="cluster-members">
<h3>Cluster Members</h3>
<div id="cluster-members-container">
<div class="loading">Loading cluster members...</div>
</div>
<button onclick="addTestNode()">Add Test Node</button>
<button onclick="removeAllNodes()">Remove All Nodes</button>
</div>
<div class="test-section">
<h2>Test Instructions</h2>
<ol>
<li>Select "Specific Node" radio button - notice the deploy button remains disabled</li>
<li>Click "Add Test Node" to simulate cluster discovery</li>
<li>Select "Specific Node" again - now you should see nodes in the dropdown</li>
<li>Select a file - deploy button should remain disabled until you select a node</li>
<li>Select a specific node - deploy button should now be enabled</li>
<li>Click "Remove All Nodes" to test the "no nodes available" state</li>
</ol>
</div>
<script>
// Simulate the cluster members functionality
let testNodes = [];
function addTestNode() {
const nodeCount = testNodes.length + 1;
const newNode = {
ip: `192.168.1.${100 + nodeCount}`,
hostname: `TestNode${nodeCount}`,
status: 'active',
latency: Math.floor(Math.random() * 50) + 10
};
testNodes.push(newNode);
displayClusterMembers();
populateNodeSelect();
updateDeployButton();
}
function removeAllNodes() {
testNodes = [];
displayClusterMembers();
populateNodeSelect();
updateDeployButton();
}
function displayClusterMembers() {
const container = document.getElementById('cluster-members-container');
if (testNodes.length === 0) {
container.innerHTML = '<div class="loading">No cluster members found</div>';
return;
}
const membersHTML = testNodes.map(node => {
const statusClass = node.status === 'active' ? 'status-online' : 'status-offline';
const statusText = node.status === 'active' ? 'Online' : 'Offline';
const statusIcon = node.status === 'active' ? '🟢' : '🔴';
return `
<div class="member-card" data-member-ip="${node.ip}">
<div class="member-name">${node.hostname}</div>
<div class="member-ip">${node.ip}</div>
<div class="member-status ${statusClass}">
${statusIcon} ${statusText}
</div>
<div class="member-latency">Latency: ${node.latency}ms</div>
</div>
`;
}).join('');
container.innerHTML = membersHTML;
}
function populateNodeSelect() {
const select = document.getElementById('specific-node-select');
if (!select) return;
select.innerHTML = '<option value="">Select a node...</option>';
if (testNodes.length === 0) {
const option = document.createElement('option');
option.value = "";
option.textContent = "No nodes available";
option.disabled = true;
select.appendChild(option);
return;
}
testNodes.forEach(node => {
const option = document.createElement('option');
option.value = node.ip;
option.textContent = `${node.hostname} (${node.ip})`;
select.appendChild(option);
});
}
function updateDeployButton() {
const deployBtn = document.getElementById('deploy-btn');
const fileInput = document.getElementById('global-firmware-file');
const targetType = document.querySelector('input[name="target-type"]:checked');
const specificNodeSelect = document.getElementById('specific-node-select');
if (!deployBtn || !fileInput) return;
const hasFile = fileInput.files && fileInput.files.length > 0;
const hasAvailableNodes = testNodes.length > 0;
let isValidTarget = false;
if (targetType.value === 'all') {
isValidTarget = hasAvailableNodes;
} else if (targetType.value === 'specific') {
isValidTarget = hasAvailableNodes && specificNodeSelect.value && specificNodeSelect.value !== "";
}
deployBtn.disabled = !hasFile || !isValidTarget;
// Update button text to provide better feedback
if (!hasAvailableNodes) {
deployBtn.textContent = '🚀 Deploy (No nodes available)';
deployBtn.title = 'No cluster nodes are currently available for deployment';
} else if (!hasFile) {
deployBtn.textContent = '🚀 Deploy Firmware';
deployBtn.title = 'Please select a firmware file to deploy';
} else if (!isValidTarget) {
deployBtn.textContent = '🚀 Deploy Firmware';
deployBtn.title = 'Please select a valid target for deployment';
} else {
deployBtn.textContent = '🚀 Deploy Firmware';
deployBtn.title = 'Ready to deploy firmware';
}
}
// Setup event listeners
document.addEventListener('DOMContentLoaded', function() {
// 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.visibility = 'visible';
specificNodeSelect.style.opacity = '1';
populateNodeSelect();
// Check if there are any nodes available and show appropriate message
if (testNodes.length === 0) {
// Show a message that no nodes are available
const noNodesMsg = document.createElement('div');
noNodesMsg.className = 'no-nodes-message';
noNodesMsg.textContent = 'No cluster nodes are currently available';
// Remove any existing message
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
if (existingMsg) {
existingMsg.remove();
}
specificNodeSelect.parentNode.appendChild(noNodesMsg);
} else {
// Remove any existing no-nodes message
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
if (existingMsg) {
existingMsg.remove();
}
}
} else {
specificNodeSelect.style.visibility = 'hidden';
specificNodeSelect.style.opacity = '0';
// Remove any no-nodes message when hiding
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
if (existingMsg) {
existingMsg.remove();
}
}
updateDeployButton();
});
});
// Setup specific node select change handler
if (specificNodeSelect) {
specificNodeSelect.addEventListener('change', updateDeployButton);
}
// Setup file input change handler
const fileInput = document.getElementById('global-firmware-file');
if (fileInput) {
fileInput.addEventListener('change', (event) => {
const file = event.target.files[0];
const fileInfo = document.getElementById('file-info');
if (file) {
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
} else {
fileInfo.textContent = 'No file selected';
}
updateDeployButton();
});
}
// Initial setup
displayClusterMembers();
populateNodeSelect();
updateDeployButton();
});
</script>
</body>
</html>

View File

@@ -1,131 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Framework Test</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
.success { color: green; }
.error { color: red; }
button { margin: 5px; padding: 10px; }
input { margin: 5px; padding: 5px; }
</style>
</head>
<body>
<h1>SPORE UI Framework Test</h1>
<div class="test-section">
<h2>Framework Initialization Test</h2>
<div id="framework-status">Checking...</div>
</div>
<div class="test-section">
<h2>Event Bus Test</h2>
<button id="publish-btn">Publish Test Event</button>
<div id="event-log"></div>
</div>
<div class="test-section">
<h2>View Model Test</h2>
<input type="text" id="name-input" placeholder="Enter name">
<button id="update-btn">Update Name</button>
<div id="name-display">Name: (not set)</div>
</div>
<div class="test-section">
<h2>Component Test</h2>
<div id="test-component">
<div class="loading">Loading...</div>
</div>
<button id="refresh-btn">Refresh Component</button>
</div>
<script src="framework.js"></script>
<script>
// Test framework initialization
document.addEventListener('DOMContentLoaded', function() {
console.log('Testing framework...');
// Test 1: Framework initialization
if (window.app && window.app.eventBus) {
document.getElementById('framework-status').innerHTML =
'<span class="success">✅ Framework initialized successfully</span>';
} else {
document.getElementById('framework-status').innerHTML =
'<span class="error">❌ Framework failed to initialize</span>';
}
// Test 2: Event Bus
const eventLog = document.getElementById('event-log');
const unsubscribe = window.app.eventBus.subscribe('test-event', (data) => {
eventLog.innerHTML += `<div>📡 Event received: ${JSON.stringify(data)}</div>`;
});
document.getElementById('publish-btn').addEventListener('click', () => {
window.app.eventBus.publish('test-event', {
message: 'Hello from test!',
timestamp: new Date().toISOString()
});
});
// Test 3: View Model
const testVM = new ViewModel();
testVM.setEventBus(window.app.eventBus);
testVM.subscribe('name', (value) => {
document.getElementById('name-display').textContent = `Name: ${value || '(not set)'}`;
});
document.getElementById('update-btn').addEventListener('click', () => {
const name = document.getElementById('name-input').value;
testVM.set('name', name);
});
// Test 4: Component
class TestComponent extends Component {
constructor(container, viewModel, eventBus) {
super(container, viewModel, eventBus);
}
setupEventListeners() {
const refreshBtn = document.getElementById('refresh-btn');
if (refreshBtn) {
this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this));
}
}
render() {
const data = this.viewModel ? this.viewModel.get('data') : null;
if (data) {
this.setHTML('', `<div class="success">✅ Component data: ${data}</div>`);
} else {
this.setHTML('', `<div class="loading">Loading component data...</div>`);
}
}
handleRefresh() {
if (this.viewModel) {
this.viewModel.set('data', `Refreshed at ${new Date().toLocaleTimeString()}`);
}
}
}
const testComponentVM = new ViewModel();
testComponentVM.setEventBus(window.app.eventBus);
testComponentVM.set('data', 'Initial component data');
const testComponent = new TestComponent(
document.getElementById('test-component'),
testComponentVM,
window.app.eventBus
);
testComponent.mount();
console.log('Framework test completed');
});
</script>
</body>
</html>

View File

@@ -1,192 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Refresh Button</title>
<link rel="stylesheet" href="styles.css">
<style>
body { font-family: Arial, sans-serif; margin: 20px; background: #1a1a1a; color: white; }
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #333; border-radius: 8px; }
.log { background: #2a2a2a; padding: 10px; margin: 10px 0; font-family: monospace; border-radius: 4px; max-height: 300px; overflow-y: auto; }
.test-button { background: #4a90e2; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin: 5px; }
.test-button:hover { background: #357abd; }
</style>
</head>
<body>
<h1>🔍 Test Refresh Button Functionality</h1>
<div class="test-section">
<h3>Test Controls</h3>
<button class="test-button" onclick="testRefreshButton()">🧪 Test Refresh Button</button>
<button class="test-button" onclick="testAPICall()">📡 Test API Call</button>
<button class="test-button" onclick="testComponent()">🧩 Test Component</button>
<button class="test-button" onclick="clearLog()">🧹 Clear Log</button>
</div>
<div class="test-section">
<h3>Cluster View (Simplified)</h3>
<div id="cluster-view" class="cluster-container">
<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>
</div>
</div>
<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 id="cluster-members-container">
<div class="loading">Loading cluster members...</div>
</div>
</div>
</div>
<div class="test-section">
<h3>Debug Log</h3>
<div id="debug-log" class="log"></div>
</div>
<!-- Include SPORE UI framework and components -->
<script src="framework.js"></script>
<script src="view-models.js"></script>
<script src="components.js"></script>
<script src="api-client.js"></script>
<script>
let debugLog = [];
function log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const logEntry = `[${timestamp}] ${type.toUpperCase()}: ${message}`;
debugLog.push(logEntry);
const logElement = document.getElementById('debug-log');
if (logElement) {
logElement.innerHTML = debugLog.map(entry => `<div>${entry}</div>`).join('');
logElement.scrollTop = logElement.scrollHeight;
}
console.log(logEntry);
}
function clearLog() {
debugLog = [];
document.getElementById('debug-log').innerHTML = '';
}
function testRefreshButton() {
log('Testing refresh button functionality...');
const refreshBtn = document.getElementById('refresh-cluster-btn');
if (refreshBtn) {
log('Found refresh button, testing click event...');
// Test if the button is clickable
refreshBtn.click();
log('Refresh button clicked');
// Check if the button state changed
setTimeout(() => {
if (refreshBtn.disabled) {
log('Button was disabled (good sign)', 'success');
} else {
log('Button was not disabled (potential issue)', 'warning');
}
// Check button text
if (refreshBtn.innerHTML.includes('Refreshing')) {
log('Button text changed to "Refreshing" (good sign)', 'success');
} else {
log('Button text did not change (potential issue)', 'warning');
}
}, 100);
} else {
log('Refresh button not found!', 'error');
}
}
function testAPICall() {
log('Testing API call to cluster members endpoint...');
fetch('http://localhost:3001/api/cluster/members')
.then(response => {
log(`API response status: ${response.status}`, 'info');
return response.json();
})
.then(data => {
log(`API response data: ${JSON.stringify(data, null, 2)}`, 'success');
})
.catch(error => {
log(`API call failed: ${error.message}`, 'error');
});
}
function testComponent() {
log('Testing component initialization...');
try {
// Create a simple test component
const container = document.getElementById('cluster-view');
const viewModel = new ClusterViewModel();
const component = new ClusterMembersComponent(container, viewModel, new EventBus());
log('Component created successfully', 'success');
log(`Component container: ${!!component.container}`, 'info');
log(`Component viewModel: ${!!component.viewModel}`, 'info');
// Test mounting
component.mount();
log('Component mounted successfully', 'success');
// Test finding elements
const refreshBtn = component.findElement('.refresh-btn');
log(`Found refresh button: ${!!refreshBtn}`, 'info');
// Test event listener setup
component.setupEventListeners();
log('Event listeners set up successfully', 'success');
// Clean up
component.unmount();
log('Component unmounted successfully', 'success');
} catch (error) {
log(`Component test failed: ${error.message}`, 'error');
console.error('Component test error:', error);
}
}
// Initialize when page loads
document.addEventListener('DOMContentLoaded', function() {
log('Page loaded, ready for testing');
// Test if the refresh button exists
const refreshBtn = document.getElementById('refresh-cluster-btn');
if (refreshBtn) {
log('Refresh button found on page load', 'success');
} else {
log('Refresh button NOT found on page load', 'error');
}
});
// Global error handler
window.addEventListener('error', function(event) {
log(`Global error: ${event.error}`, 'error');
});
// Global unhandled promise rejection handler
window.addEventListener('unhandledrejection', function(event) {
log(`Unhandled promise rejection: ${event.reason}`, 'error');
});
</script>
</body>
</html>

View File

@@ -1,419 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPORE UI - State Preservation Test</title>
<link rel="stylesheet" href="styles.css">
<style>
.test-panel {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.test-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.test-button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
font-size: 14px;
}
.test-button:hover {
background: #0056b3;
}
.test-button.danger {
background: #dc3545;
}
.test-button.danger:hover {
background: #c82333;
}
.test-button.success {
background: #28a745;
}
.test-button.success:hover {
background: #218838;
}
.test-info {
background: #e9ecef;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 15px;
margin: 15px 0;
}
.test-info h4 {
margin-top: 0;
color: #495057;
}
.state-indicator {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
margin-left: 10px;
}
.state-preserved {
background: #d4edda;
color: #155724;
}
.state-lost {
background: #f8d7da;
color: #721c24;
}
.log-container {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
margin-top: 20px;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-radius: 3px;
}
.log-info { background: #d1ecf1; color: #0c5460; }
.log-success { background: #d4edda; color: #155724; }
.log-warning { background: #fff3cd; color: #856404; }
.log-error { background: #f8d7da; color: #721c24; }
</style>
</head>
<body>
<div class="container">
<h1>🧪 SPORE UI State Preservation Test</h1>
<div class="test-panel">
<h3>Test Controls</h3>
<div class="test-controls">
<button class="test-button" onclick="testStatePreservation()">
🔄 Test Data Refresh (Preserve State)
</button>
<button class="test-button danger" onclick="testFullRerender()">
🗑️ Test Full Re-render (Lose State)
</button>
<button class="test-button success" onclick="expandAllCards()">
📖 Expand All Cards
</button>
<button class="test-button" onclick="changeAllTabs()">
🏷️ Change All Tabs
</button>
<button class="test-button" onclick="testManualDataLoad()">
📡 Test Manual Data Load
</button>
<button class="test-button" onclick="debugComponentState()">
🐛 Debug Component State
</button>
<button class="test-button" onclick="testManualRefresh()">
🔧 Test Manual Refresh
</button>
<button class="test-button" onclick="clearLog()">
🧹 Clear Log
</button>
</div>
<div class="test-info">
<h4>What This Test Demonstrates:</h4>
<ul>
<li><strong>State Preservation:</strong> When data is refreshed, expanded cards and active tabs are maintained</li>
<li><strong>Partial Updates:</strong> Only changed data is updated, not entire components</li>
<li><strong>UI State Persistence:</strong> User interactions (expanded cards, active tabs) are preserved across refreshes</li>
<li><strong>Smart Updates:</strong> The system detects when data has actually changed and only updates what's necessary</li>
</ul>
</div>
</div>
<div class="test-panel">
<h3>Current State Indicators</h3>
<div>
<strong>Expanded Cards:</strong>
<span class="state-indicator" id="expanded-count">0</span>
</div>
<div>
<strong>Active Tabs:</strong>
<span class="state-indicator" id="active-tabs-count">0</span>
</div>
<div>
<strong>Last Update:</strong>
<span class="state-indicator" id="last-update">Never</span>
</div>
</div>
<div class="test-panel">
<h3>Test Log</h3>
<div class="log-container" id="test-log">
<div class="log-entry log-info">Test log initialized. Use the test controls above to test state preservation.</div>
</div>
</div>
<!-- Include the actual SPORE UI components for testing -->
<div id="cluster-view" class="view-content active">
<div class="primary-node-info">
<h3>Primary Node</h3>
<div id="primary-node-ip">🔍 Discovering...</div>
<button class="primary-node-refresh">🔄 Refresh</button>
</div>
<div id="cluster-members-container">
<h3>Cluster Members</h3>
<button class="refresh-btn">🔄 Refresh Members</button>
<div id="members-list">
<!-- Members will be populated here -->
</div>
</div>
</div>
</div>
<!-- Include SPORE UI framework and components -->
<script src="framework.js"></script>
<script src="view-models.js"></script>
<script src="components.js"></script>
<script src="api-client.js"></script>
<script>
// Test state preservation functionality
let testLog = [];
let expandedCardsCount = 0;
let activeTabsCount = 0;
function log(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = `log-entry log-${type}`;
logEntry.textContent = `[${timestamp}] ${message}`;
const logContainer = document.getElementById('test-log');
logContainer.appendChild(logEntry);
logContainer.scrollTop = logContainer.scrollHeight;
testLog.push({ timestamp, message, type });
}
function updateStateIndicators() {
document.getElementById('expanded-count').textContent = expandedCardsCount;
document.getElementById('active-tabs-count').textContent = activeTabsCount;
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
}
function testStatePreservation() {
log('🧪 Testing state preservation during data refresh...', 'info');
// Simulate a data refresh that preserves state
setTimeout(() => {
log('✅ Data refresh completed with state preservation', 'success');
log('📊 Expanded cards maintained: ' + expandedCardsCount, 'info');
log('🏷️ Active tabs maintained: ' + activeTabsCount, 'info');
updateStateIndicators();
}, 1000);
}
function testFullRerender() {
log('🗑️ Testing full re-render (this would lose state in old system)...', 'warning');
// Simulate what would happen in the old system
setTimeout(() => {
log('❌ Full re-render completed - state would be lost in old system', 'error');
log('💡 In new system, this preserves state automatically', 'info');
updateStateIndicators();
}, 1000);
}
function expandAllCards() {
log('📖 Expanding all cluster member cards...', 'info');
expandedCardsCount = 3; // Simulate 3 expanded cards
updateStateIndicators();
log('✅ All cards expanded. State will be preserved during refreshes.', 'success');
}
function changeAllTabs() {
log('🏷️ Changing all active tabs to different values...', 'info');
activeTabsCount = 3; // Simulate 3 active tabs
updateStateIndicators();
log('✅ All tabs changed. Active tab states will be preserved during refreshes.', 'success');
}
function clearLog() {
document.getElementById('test-log').innerHTML = '';
testLog = [];
log('🧹 Test log cleared', 'info');
}
// Test manual data loading
async function testManualDataLoad() {
log('📡 Testing manual data load...', 'info');
try {
// Test if we can manually trigger the cluster view model
if (window.app && window.app.currentView && window.app.currentView.viewModel) {
const viewModel = window.app.currentView.viewModel;
log('✅ Found cluster view model, attempting to load data...', 'info');
if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
await viewModel.updateClusterMembers();
log('✅ Manual data load completed', 'success');
} else {
log('❌ updateClusterMembers method not found on view model', 'error');
}
} else {
log('❌ No cluster view model found', 'error');
}
} catch (error) {
log(`❌ Manual data load failed: ${error.message}`, 'error');
}
}
// Debug component state
function debugComponentState() {
log('🐛 Debugging component state...', 'info');
try {
if (window.app && window.app.currentView && window.app.currentView.clusterMembersComponent) {
const component = window.app.currentView.clusterMembersComponent;
log('✅ Found cluster members component, checking state...', 'info');
if (component.debugState && typeof component.debugState === 'function') {
const state = component.debugState();
log('📊 Component state:', 'info');
log(` - Members: ${state.members?.length || 0}`, 'info');
log(` - Loading: ${state.isLoading}`, 'info');
log(` - Error: ${state.error || 'none'}`, 'info');
log(` - Expanded cards: ${state.expandedCards?.size || 0}`, 'info');
log(` - Active tabs: ${state.activeTabs?.size || 0}`, 'info');
} else {
log('❌ debugState method not found on component', 'error');
}
} else {
log('❌ No cluster members component found', 'error');
}
} catch (error) {
log(`❌ Debug failed: ${error.message}`, 'error');
}
}
// Test manual refresh
async function testManualRefresh() {
log('🔧 Testing manual refresh...', 'info');
try {
if (window.app && window.app.currentView && window.app.currentView.clusterMembersComponent) {
const component = window.app.currentView.clusterMembersComponent;
log('✅ Found cluster members component, testing manual refresh...', 'info');
if (component.manualRefresh && typeof component.manualRefresh === 'function') {
await component.manualRefresh();
log('✅ Manual refresh completed', 'success');
} else {
log('❌ manualRefresh method not found on component', 'error');
}
} else {
log('❌ No cluster members component found', 'error');
}
} catch (error) {
log(`❌ Manual refresh failed: ${error.message}`, 'error');
}
}
// Initialize test
document.addEventListener('DOMContentLoaded', function() {
log('🚀 SPORE UI State Preservation Test initialized', 'success');
log('💡 This demonstrates how the new system preserves UI state during data refreshes', 'info');
updateStateIndicators();
// Test API client functionality
testAPIClient();
});
// Test API client functionality
async function testAPIClient() {
try {
log('🧪 Testing API client functionality...', 'info');
const response = await window.apiClient.getClusterMembers();
log(`✅ API client test successful. Found ${response.members?.length || 0} cluster members`, 'success');
if (response.members && response.members.length > 0) {
response.members.forEach(member => {
log(`📱 Member: ${member.hostname || member.ip} (${member.status})`, 'info');
});
}
// Test discovery info
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
log(`🔍 Discovery info: Primary node ${discoveryInfo.primaryNode || 'none'}, Total nodes: ${discoveryInfo.totalNodes}`, 'info');
} catch (error) {
log(`❌ API client test failed: ${error.message}`, 'error');
}
}
// Mock API client for testing
if (!window.apiClient) {
window.apiClient = {
getClusterMembers: async () => {
return {
members: [
{ ip: '192.168.1.100', hostname: 'Node-1', status: 'active', latency: 15 },
{ ip: '192.168.1.101', hostname: 'Node-2', status: 'active', latency: 22 },
{ ip: '192.168.1.102', hostname: 'Node-3', status: 'offline', latency: null }
]
};
},
getDiscoveryInfo: async () => {
return {
primaryNode: '192.168.1.100',
clientInitialized: true,
totalNodes: 3
};
},
getNodeStatus: async (ip) => {
return {
freeHeap: 102400,
chipId: 'ESP32-' + ip.split('.').pop(),
sdkVersion: 'v4.4.2',
cpuFreqMHz: 240,
flashChipSize: 4194304,
api: [
{ method: 1, uri: '/status' },
{ method: 2, uri: '/config' }
]
};
},
getTasksStatus: async () => {
return [
{ name: 'Heartbeat', running: true, interval: 5000, enabled: true },
{ name: 'DataSync', running: false, interval: 30000, enabled: true }
];
}
};
}
</script>
</body>
</html>

View File

@@ -1,72 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tab Test</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h1>Tab Active State Test</h1>
<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">
<h3>Status Tab</h3>
<p>This is the status tab content.</p>
</div>
<div class="tab-content" id="endpoints-tab">
<h3>Endpoints Tab</h3>
<p>This is the endpoints tab content.</p>
</div>
<div class="tab-content" id="tasks-tab">
<h3>Tasks Tab</h3>
<p>This is the tasks tab content.</p>
</div>
<div class="tab-content" id="firmware-tab">
<h3>Firmware Tab</h3>
<p>This is the firmware tab content.</p>
</div>
</div>
</div>
<script>
// Simple tab functionality test
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('.tab-button');
const tabContents = document.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const targetTab = this.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
this.classList.add('active');
const targetContent = document.querySelector(`#${targetTab}-tab`);
if (targetContent) {
targetContent.classList.add('active');
}
console.log('Tab switched to:', targetTab);
});
});
});
</script>
</body>
</html>

View File

@@ -1 +0,0 @@

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

File diff suppressed because one or more lines are too long

View File

@@ -1,393 +0,0 @@
// View Models for SPORE UI Components
// Cluster View Model with enhanced state preservation
class ClusterViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
members: [],
primaryNode: null,
totalNodes: 0,
clientInitialized: false,
isLoading: false,
error: null,
expandedCards: new Map(),
activeTabs: new Map(), // Store active tab for each node
lastUpdateTime: null
});
// Initialize cluster status after a short delay to allow components to subscribe
setTimeout(() => {
this.updatePrimaryNodeDisplay();
}, 100);
}
// Update cluster members with state preservation
async updateClusterMembers() {
try {
console.log('ClusterViewModel: updateClusterMembers called');
// Store current UI state before update
const currentUIState = this.getAllUIState();
const currentExpandedCards = this.get('expandedCards');
const currentActiveTabs = this.get('activeTabs');
this.set('isLoading', true);
this.set('error', null);
console.log('ClusterViewModel: Fetching cluster members...');
const response = await window.apiClient.getClusterMembers();
console.log('ClusterViewModel: Got response:', response);
// Use batch update to preserve UI state
this.batchUpdate({
members: response.members || [],
lastUpdateTime: new Date().toISOString()
}, { preserveUIState: true });
// Restore expanded cards and active tabs
this.set('expandedCards', currentExpandedCards);
this.set('activeTabs', currentActiveTabs);
// Update primary node display
console.log('ClusterViewModel: Updating primary node display...');
await this.updatePrimaryNodeDisplay();
} catch (error) {
console.error('ClusterViewModel: Failed to fetch cluster members:', error);
this.set('error', error.message);
} finally {
this.set('isLoading', false);
console.log('ClusterViewModel: updateClusterMembers completed');
}
}
// Update primary node display with state preservation
async updatePrimaryNodeDisplay() {
try {
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
// Use batch update to preserve UI state
const updates = {};
if (discoveryInfo.primaryNode) {
updates.primaryNode = discoveryInfo.primaryNode;
updates.clientInitialized = discoveryInfo.clientInitialized;
updates.totalNodes = discoveryInfo.totalNodes;
} else if (discoveryInfo.totalNodes > 0) {
updates.primaryNode = discoveryInfo.nodes[0]?.ip;
updates.clientInitialized = false;
updates.totalNodes = discoveryInfo.totalNodes;
} else {
updates.primaryNode = null;
updates.clientInitialized = false;
updates.totalNodes = 0;
}
this.batchUpdate(updates, { preserveUIState: true });
} catch (error) {
console.error('Failed to fetch discovery info:', error);
this.set('error', error.message);
}
}
// Select random primary node
async selectRandomPrimaryNode() {
try {
const result = await window.apiClient.selectRandomPrimaryNode();
if (result.success) {
// Update the display after a short delay
setTimeout(() => {
this.updatePrimaryNodeDisplay();
}, 1500);
return result;
} else {
throw new Error(result.message || 'Random selection failed');
}
} catch (error) {
console.error('Failed to select random primary node:', error);
throw error;
}
}
// Store expanded card state
storeExpandedCard(memberIp, content) {
const expandedCards = this.get('expandedCards');
expandedCards.set(memberIp, content);
this.set('expandedCards', expandedCards);
// Also store in UI state for persistence
this.setUIState(`expanded_${memberIp}`, content);
}
// Get expanded card state
getExpandedCard(memberIp) {
const expandedCards = this.get('expandedCards');
return expandedCards.get(memberIp);
}
// Clear expanded card state
clearExpandedCard(memberIp) {
const expandedCards = this.get('expandedCards');
expandedCards.delete(memberIp);
this.set('expandedCards', expandedCards);
// Also clear from UI state
this.clearUIState(`expanded_${memberIp}`);
}
// Store active tab for a specific node
storeActiveTab(memberIp, tabName) {
const activeTabs = this.get('activeTabs');
activeTabs.set(memberIp, tabName);
this.set('activeTabs', activeTabs);
// Also store in UI state for persistence
this.setUIState(`activeTab_${memberIp}`, tabName);
}
// Get active tab for a specific node
getActiveTab(memberIp) {
const activeTabs = this.get('activeTabs');
return activeTabs.get(memberIp) || 'status'; // Default to 'status' tab
}
// Check if data has actually changed to avoid unnecessary updates
hasDataChanged(newData, dataType) {
const currentData = this.get(dataType);
if (Array.isArray(newData) && Array.isArray(currentData)) {
if (newData.length !== currentData.length) return true;
// Compare each member's key properties
return newData.some((newMember, index) => {
const currentMember = currentData[index];
return !currentMember ||
newMember.ip !== currentMember.ip ||
newMember.status !== currentMember.status ||
newMember.latency !== currentMember.latency;
});
}
return newData !== currentData;
}
// Smart update that only updates changed data
async smartUpdate() {
try {
console.log('ClusterViewModel: Performing smart update...');
// Fetch new data
const response = await window.apiClient.getClusterMembers();
const newMembers = response.members || [];
// Check if members data has actually changed
if (this.hasDataChanged(newMembers, 'members')) {
console.log('ClusterViewModel: Members data changed, updating...');
await this.updateClusterMembers();
} else {
console.log('ClusterViewModel: Members data unchanged, skipping update');
// Still update primary node display as it might have changed
await this.updatePrimaryNodeDisplay();
}
} catch (error) {
console.error('ClusterViewModel: Smart update failed:', error);
this.set('error', error.message);
}
}
}
// Node Details View Model with enhanced state preservation
class NodeDetailsViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
nodeStatus: null,
tasks: [],
isLoading: false,
error: null,
activeTab: 'status',
nodeIp: null
});
}
// Load node details with state preservation
async loadNodeDetails(ip) {
try {
// Store current UI state
const currentActiveTab = this.get('activeTab');
this.set('isLoading', true);
this.set('error', null);
this.set('nodeIp', ip);
const nodeStatus = await window.apiClient.getNodeStatus(ip);
// Use batch update to preserve UI state
this.batchUpdate({
nodeStatus: nodeStatus
}, { preserveUIState: true });
// Restore active tab
this.set('activeTab', currentActiveTab);
// Load tasks data
await this.loadTasksData();
} catch (error) {
console.error('Failed to load node details:', error);
this.set('error', error.message);
} finally {
this.set('isLoading', false);
}
}
// Load tasks data with state preservation
async loadTasksData() {
try {
const ip = this.get('nodeIp');
const response = await window.apiClient.getTasksStatus(ip);
this.set('tasks', response || []);
} catch (error) {
console.error('Failed to load tasks:', error);
this.set('tasks', []);
}
}
// Set active tab with state persistence
setActiveTab(tabName) {
console.log('NodeDetailsViewModel: Setting activeTab to:', tabName);
this.set('activeTab', tabName);
// Store in UI state for persistence
this.setUIState('activeTab', tabName);
}
// Upload firmware
async uploadFirmware(file, nodeIp) {
try {
const result = await window.apiClient.uploadFirmware(file, nodeIp);
return result;
} catch (error) {
console.error('Firmware upload failed:', error);
throw error;
}
}
}
// Firmware View Model
class FirmwareViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
selectedFile: null,
targetType: 'all',
specificNode: null,
availableNodes: [],
uploadProgress: null,
uploadResults: [],
isUploading: false
});
}
// Set selected file
setSelectedFile(file) {
this.set('selectedFile', file);
}
// Set target type
setTargetType(type) {
this.set('targetType', type);
}
// Set specific node
setSpecificNode(nodeIp) {
this.set('specificNode', nodeIp);
}
// Update available nodes
updateAvailableNodes(nodes) {
this.set('availableNodes', nodes);
}
// Start upload
startUpload() {
this.set('isUploading', true);
this.set('uploadProgress', {
current: 0,
total: 0,
status: 'Preparing...'
});
this.set('uploadResults', []);
}
// Update upload progress
updateUploadProgress(current, total, status) {
this.set('uploadProgress', {
current,
total,
status
});
}
// Add upload result
addUploadResult(result) {
const results = this.get('uploadResults');
results.push(result);
this.set('uploadResults', results);
}
// Complete upload
completeUpload() {
this.set('isUploading', false);
}
// Reset upload state
resetUploadState() {
this.set('selectedFile', null);
this.set('uploadProgress', null);
this.set('uploadResults', []);
this.set('isUploading', false);
}
// Check if deploy button should be enabled
isDeployEnabled() {
const hasFile = this.get('selectedFile') !== null;
const availableNodes = this.get('availableNodes');
const hasAvailableNodes = availableNodes && availableNodes.length > 0;
let isValidTarget = false;
if (this.get('targetType') === 'all') {
isValidTarget = hasAvailableNodes;
} else if (this.get('targetType') === 'specific') {
isValidTarget = hasAvailableNodes && this.get('specificNode');
}
return hasFile && isValidTarget && !this.get('isUploading');
}
}
// Navigation View Model
class NavigationViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
activeView: 'cluster',
views: ['cluster', 'firmware']
});
}
// Set active view
setActiveView(viewName) {
this.set('activeView', viewName);
}
// Get active view
getActiveView() {
return this.get('activeView');
}
}

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

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);

View File

@@ -1,102 +0,0 @@
#!/usr/bin/env node
/**
* Demo script for Frontend Discovery Integration
* Shows how the frontend displays primary node information
*/
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 showFrontendIntegration() {
console.log('🚀 Frontend Discovery Integration Demo');
console.log('=====================================');
console.log('This demo shows how the frontend displays primary node information.');
console.log('Open http://localhost:3001 in your browser to see the UI.');
console.log('');
try {
// Check if backend is running
const healthResponse = await makeRequest('/api/health');
console.log('✅ Backend is running');
// Get discovery information
const discoveryResponse = await makeRequest('/api/discovery/nodes');
console.log('\n📡 Discovery Status:');
console.log(` Primary Node: ${discoveryResponse.data.primaryNode || 'None'}`);
console.log(` Total Nodes: ${discoveryResponse.data.totalNodes}`);
console.log(` Client Initialized: ${discoveryResponse.data.clientInitialized}`);
if (discoveryResponse.data.nodes.length > 0) {
console.log('\n🌐 Discovered Nodes:');
discoveryResponse.data.nodes.forEach((node, index) => {
console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`);
console.log(` Last Seen: ${node.lastSeen}`);
});
}
console.log('\n🎯 Frontend Display:');
console.log(' The frontend will show:');
if (discoveryResponse.data.primaryNode) {
const status = discoveryResponse.data.clientInitialized ? '✅' : '⚠️';
const nodeCount = discoveryResponse.data.totalNodes > 1 ? ` (${discoveryResponse.data.totalNodes} nodes)` : '';
console.log(` ${status} ${discoveryResponse.data.primaryNode}${nodeCount}`);
} else if (discoveryResponse.data.totalNodes > 0) {
const firstNode = discoveryResponse.data.nodes[0];
console.log(` ⚠️ ${firstNode.ip} (No Primary)`);
} else {
console.log(' 🔍 No Nodes Found');
}
console.log('\n💡 To test the frontend:');
console.log(' 1. Open http://localhost:3001 in your browser');
console.log(' 2. Look at the cluster header for primary node info');
console.log(' 3. Send discovery messages: npm run test-discovery broadcast');
console.log(' 4. Watch the primary node display update in real-time');
} catch (error) {
console.error('❌ Error:', error.message);
console.log('\n💡 Make sure the backend is running: npm start');
}
}
// Run the demo
showFrontendIntegration().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);

View File

@@ -1,137 +0,0 @@
#!/usr/bin/env node
/**
* Test script for Random Primary Node Selection
* Demonstrates how the random selection works
*/
const http = require('http');
const BASE_URL = 'http://localhost:3001';
function makeRequest(path, method = 'POST', body = null) {
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);
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
async function testRandomSelection() {
console.log('🎲 Testing Random Primary Node Selection');
console.log('======================================');
console.log('');
try {
// First, check current discovery status
console.log('1. Checking current discovery status...');
const discoveryResponse = await makeRequest('/api/discovery/nodes', 'GET');
if (discoveryResponse.status !== 200) {
console.log('❌ Failed to get discovery status');
return;
}
const discovery = discoveryResponse.data;
console.log(` Current Primary: ${discovery.primaryNode || 'None'}`);
console.log(` Total Nodes: ${discovery.totalNodes}`);
console.log(` Client Initialized: ${discovery.clientInitialized}`);
if (discovery.nodes.length === 0) {
console.log('\n💡 No nodes discovered yet. Send some discovery messages first:');
console.log(' npm run test-discovery broadcast');
return;
}
console.log('\n2. Testing random primary node selection...');
// Store current primary for comparison
const currentPrimary = discovery.primaryNode;
const availableNodes = discovery.nodes.map(n => n.ip);
console.log(` Available nodes: ${availableNodes.join(', ')}`);
console.log(` Current primary: ${currentPrimary}`);
// Perform random selection
const randomResponse = await makeRequest('/api/discovery/random-primary', 'POST', {
timestamp: new Date().toISOString()
});
if (randomResponse.status === 200) {
const result = randomResponse.data;
console.log('\n✅ Random selection successful!');
console.log(` New Primary: ${result.primaryNode}`);
console.log(` Previous Primary: ${currentPrimary}`);
console.log(` Message: ${result.message}`);
console.log(` Total Nodes: ${result.totalNodes}`);
console.log(` Client Initialized: ${result.clientInitialized}`);
// Verify the change
if (result.primaryNode !== currentPrimary) {
console.log('\n🎯 Primary node successfully changed!');
} else {
console.log('\n⚠ Primary node remained the same (only one node available)');
}
} else {
console.log('\n❌ Random selection failed:');
console.log(` Status: ${randomResponse.status}`);
console.log(` Error: ${randomResponse.data.error || 'Unknown error'}`);
}
// Show updated status
console.log('\n3. Checking updated discovery status...');
const updatedResponse = await makeRequest('/api/discovery/nodes', 'GET');
if (updatedResponse.status === 200) {
const updated = updatedResponse.data;
console.log(` Current Primary: ${updated.primaryNode}`);
console.log(` Client Base URL: ${updated.clientBaseUrl}`);
}
console.log('\n💡 To test in the frontend:');
console.log(' 1. Open http://localhost:3001 in your browser');
console.log(' 2. Look at the cluster header for primary node info');
console.log(' 3. Click the 🎲 button to randomly select a new primary node');
console.log(' 4. Watch the display change in real-time');
} catch (error) {
console.error('\n❌ Test failed:', error.message);
console.log('\n💡 Make sure the backend is running: npm start');
}
}
// Run the test
testRandomSelection().catch(console.error);