Major feature release implementing comprehensive state management, component registry, and admin control center with full UI integration. ## Backend Features (secubox-core v0.9.0-1) State Management System: - ✅ State database (state-db.json) with 15 states across 4 categories - ✅ State machine with transition matrix validation - ✅ secubox-state CLI (8 commands: get, set, history, list, validate, sync, freeze, clear-error) - ✅ state-machine.sh with atomic transitions using flock - ✅ State history tracking with timestamps and reasons - ✅ Error state handling with detailed error info - ✅ Frozen state support for system-critical components Component Registry System: - ✅ Component registry database (component-registry.json) - ✅ secubox-component CLI (7 commands: list, get, register, unregister, tree, affected, set-setting) - ✅ Component types: app, module, widget, service, composite - ✅ Dependency tracking (required/optional) - ✅ Recursive dependency tree resolution - ✅ Reverse dependency tracking - ✅ Component settings management - ✅ Profile tagging and filtering Auto-Sync System: - ✅ secubox-sync-registry CLI for catalog synchronization - ✅ Auto-populate from catalog.json - ✅ Plugin catalog directory scanning - ✅ Installed package detection - ✅ Automatic state initialization RPC Backend (luci.secubox): - ✅ 6 state management RPC methods - ✅ 5 component registry RPC methods - ✅ Bulk operations support - ✅ State validation endpoints ## Frontend Features (luci-app-secubox-admin v1.0.0-16) UI Components: - ✅ state-utils.js: 20+ utility functions, state config, transition validation - ✅ StateIndicator.js: 5 rendering modes (badge, compact, pill, dot, statistics) - ✅ StateTimeline.js: 4 visualization modes (vertical, horizontal, compact, transition diagram) - ✅ state-management.css: 600+ lines with animations, responsive design, accessibility Admin Control Center Dashboard: - ✅ System overview panel with health metrics - ✅ Component state summary with statistics - ✅ Recent state transitions timeline - ✅ Alerts panel for warnings and errors - ✅ Quick actions panel - ✅ Real-time updates (5-second polling) - ✅ Metric cards with hover effects - ✅ State distribution by category API Integration (api.js): - ✅ 11 RPC method declarations - ✅ Enhanced methods: getComponentWithState(), getAllComponentsWithStates() - ✅ Bulk operations: bulkSetComponentState() - ✅ State statistics: getStateStatistics() - ✅ Retry logic with exponential backoff - ✅ Promise-based async operations ## Documentation Comprehensive Documentation: - ✅ API-REFERENCE.md (1,200+ lines): Complete API docs for RPC, CLI, JS - ✅ EXAMPLES.md (800+ lines): 30+ usage examples, shell scripts, integration patterns - ✅ State definitions table (15 states) - ✅ State transition matrix - ✅ Component metadata schemas - ✅ Error codes reference - ✅ Testing examples ## State Definitions 15 States Across 4 Categories: - Persistent: available, installed, active, disabled, frozen - Transient: installing, configuring, activating, starting, stopping, uninstalling - Runtime: running, stopped - Error: error (with subtypes) State Transition Flow: available → installing → installed → configuring → configured → activating → active → starting → running → stopping → stopped ## Technical Details Files Created (10 backend + 8 frontend): Backend: - /usr/sbin/secubox-state (12KB, 8 commands) - /usr/sbin/secubox-component (12KB, 7 commands) - /usr/sbin/secubox-sync-registry (8.4KB) - /usr/share/secubox/state-machine.sh (5.2KB) - /var/lib/secubox/state-db.json (schema) - /var/lib/secubox/component-registry.json (schema) Frontend: - resources/secubox-admin/state-utils.js (~400 lines) - resources/secubox-admin/components/StateIndicator.js (~350 lines) - resources/secubox-admin/components/StateTimeline.js (~450 lines) - resources/secubox-admin/state-management.css (~600 lines) - resources/view/secubox-admin/control-center.js (~550 lines) - resources/secubox-admin/api.js (+145 lines) Documentation: - docs/admin-control-center/API-REFERENCE.md (1,200+ lines) - docs/admin-control-center/EXAMPLES.md (800+ lines) Files Modified (3): - package/secubox/secubox-core/Makefile (v0.8.0 → v0.9.0-1) - package/secubox/luci-app-secubox-admin/Makefile (release 15 → 16) - package/secubox/secubox-core/root/usr/libexec/rpcd/luci.secubox (+157 lines) ## Installation & Migration Makefile Updates: - Added 3 new CLI tools to install section - Added state-machine.sh to scripts - Updated package description - Enhanced postinst to initialize databases - Auto-sync registry on first install Postinst Features: - Automatic state-db.json initialization - Automatic component-registry.json initialization - Catalog sync on install - Version announcement with new features ## Performance & Security Performance: - File locking (flock) for atomic state transitions - State history limited to 100 entries per component - RPC retry logic with exponential backoff - Bulk operations use Promise.all for parallel execution - Component list caching (30 seconds) Security: - Frozen state prevents unauthorized modifications - All state changes logged with timestamp and reason - System-critical components have additional safeguards - Proper authentication required for state transitions ## Testing & Validation Features: - State transition validation - Component dependency resolution - Circular dependency detection - State consistency checker - Integration test scripts included in docs ## Breaking Changes None - Backward Compatible: - Existing RPC methods remain functional - State-aware methods are additive - Components without state default to 'available' - Migration is automatic on install ## Statistics Total Implementation: - Lines of Code: ~4,000 - Backend: ~1,800 (Bash + JSON) - Frontend: ~2,200 (JavaScript + CSS) - Documentation: ~2,000 (Markdown) - Functions/Commands: 40+ - RPC Methods: 11 - CLI Commands: 22 - UI Components: 5 - Documentation Pages: 2 ## Next Phase Remaining from Plan: - Phase 4: System Hub integration - Phase 5: Migration script (secubox-migrate-state) - Phase 6: Additional documentation (ARCHITECTURE.md, STATE-MANAGEMENT.md, etc.) - Phase 7: Additional UI views (components.js, state-manager.js, debug-panel.js) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
455 lines
13 KiB
JavaScript
455 lines
13 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
'require secubox-admin.state-utils as stateUtils';
|
|
'require secubox-admin.components.StateIndicator as StateIndicator';
|
|
|
|
/**
|
|
* StateTimeline Component
|
|
* Visualizes state history as a timeline with transitions and events
|
|
*/
|
|
|
|
return baseclass.extend({
|
|
/**
|
|
* Render a state history timeline
|
|
* @param {Array<Object>} history - Array of history entries
|
|
* @param {Object} options - Display options
|
|
* @returns {Element} DOM element
|
|
*/
|
|
render: function(history, options) {
|
|
options = options || {};
|
|
|
|
if (!history || history.length === 0) {
|
|
return E('div', {
|
|
'class': 'state-timeline-empty',
|
|
'style': 'padding: 2rem; text-align: center; color: #6b7280;'
|
|
}, 'No state history available');
|
|
}
|
|
|
|
var container = E('div', {
|
|
'class': 'state-timeline',
|
|
'style': 'position: relative; padding-left: 2rem;'
|
|
});
|
|
|
|
// Vertical timeline line
|
|
var timelineLine = E('div', {
|
|
'class': 'timeline-line',
|
|
'style': 'position: absolute; left: 0.5rem; top: 0; bottom: 0; width: 2px; background-color: #e5e7eb;'
|
|
});
|
|
container.appendChild(timelineLine);
|
|
|
|
// Render history entries
|
|
var sortedHistory = this._sortHistory(history, options.sortOrder || 'desc');
|
|
var limit = options.limit || sortedHistory.length;
|
|
|
|
for (var i = 0; i < Math.min(limit, sortedHistory.length); i++) {
|
|
var entry = sortedHistory[i];
|
|
var timelineEntry = this._renderTimelineEntry(entry, i, options);
|
|
container.appendChild(timelineEntry);
|
|
}
|
|
|
|
// "Show more" button if limited
|
|
if (limit < sortedHistory.length && options.onShowMore) {
|
|
var showMoreBtn = E('button', {
|
|
'class': 'btn btn-sm timeline-show-more',
|
|
'style': 'margin-left: 2rem; margin-top: 1rem;'
|
|
}, 'Show ' + (sortedHistory.length - limit) + ' more...');
|
|
|
|
showMoreBtn.addEventListener('click', function() {
|
|
options.onShowMore();
|
|
});
|
|
|
|
container.appendChild(showMoreBtn);
|
|
}
|
|
|
|
return container;
|
|
},
|
|
|
|
/**
|
|
* Render a compact timeline (inline)
|
|
* @param {Array<Object>} history - Array of history entries
|
|
* @param {Object} options - Display options
|
|
* @returns {Element} DOM element
|
|
*/
|
|
renderCompact: function(history, options) {
|
|
options = options || {};
|
|
|
|
if (!history || history.length === 0) {
|
|
return E('span', { 'style': 'color: #6b7280; font-size: 0.875rem;' }, 'No history');
|
|
}
|
|
|
|
var container = E('div', {
|
|
'class': 'state-timeline-compact',
|
|
'style': 'display: flex; align-items: center; gap: 0.25rem;'
|
|
});
|
|
|
|
var sortedHistory = this._sortHistory(history, 'desc');
|
|
var limit = options.limit || 5;
|
|
|
|
for (var i = 0; i < Math.min(limit, sortedHistory.length); i++) {
|
|
var entry = sortedHistory[i];
|
|
var stateIndicator = StateIndicator.renderDot(entry.state, {
|
|
size: '0.625rem',
|
|
customTooltip: stateUtils.formatHistoryEntry(entry)
|
|
});
|
|
container.appendChild(stateIndicator);
|
|
}
|
|
|
|
// More indicator
|
|
if (sortedHistory.length > limit) {
|
|
var moreIndicator = E('span', {
|
|
'style': 'font-size: 0.75rem; color: #6b7280; margin-left: 0.25rem;'
|
|
}, '+' + (sortedHistory.length - limit));
|
|
container.appendChild(moreIndicator);
|
|
}
|
|
|
|
return container;
|
|
},
|
|
|
|
/**
|
|
* Render a horizontal timeline (for state transitions)
|
|
* @param {Array<Object>} history - Array of history entries
|
|
* @param {Object} options - Display options
|
|
* @returns {Element} DOM element
|
|
*/
|
|
renderHorizontal: function(history, options) {
|
|
options = options || {};
|
|
|
|
if (!history || history.length === 0) {
|
|
return E('div', {
|
|
'class': 'state-timeline-empty',
|
|
'style': 'padding: 1rem; text-align: center; color: #6b7280;'
|
|
}, 'No transitions');
|
|
}
|
|
|
|
var container = E('div', {
|
|
'class': 'state-timeline-horizontal',
|
|
'style': 'display: flex; align-items: center; gap: 0.5rem; overflow-x: auto; padding: 1rem 0;'
|
|
});
|
|
|
|
var sortedHistory = this._sortHistory(history, 'asc');
|
|
var limit = options.limit || sortedHistory.length;
|
|
|
|
for (var i = 0; i < Math.min(limit, sortedHistory.length); i++) {
|
|
var entry = sortedHistory[i];
|
|
var config = stateUtils.getStateConfig(entry.state);
|
|
|
|
// State node
|
|
var node = E('div', {
|
|
'class': 'timeline-node',
|
|
'style': 'display: flex; flex-direction: column; align-items: center; min-width: 80px;'
|
|
});
|
|
|
|
// Icon
|
|
var icon = StateIndicator.renderCompact(entry.state);
|
|
node.appendChild(icon);
|
|
|
|
// Label
|
|
var label = E('div', {
|
|
'style': 'font-size: 0.75rem; margin-top: 0.25rem; color: ' + config.color + '; font-weight: 600; text-align: center;'
|
|
}, config.label);
|
|
node.appendChild(label);
|
|
|
|
// Time
|
|
if (entry.timestamp) {
|
|
var time = E('div', {
|
|
'style': 'font-size: 0.625rem; color: #6b7280; margin-top: 0.125rem; text-align: center;'
|
|
}, stateUtils.getTimeAgo(entry.timestamp));
|
|
node.appendChild(time);
|
|
}
|
|
|
|
container.appendChild(node);
|
|
|
|
// Arrow connector (except for last)
|
|
if (i < Math.min(limit, sortedHistory.length) - 1) {
|
|
var arrow = E('div', {
|
|
'class': 'timeline-arrow',
|
|
'style': 'font-size: 1.25rem; color: #9ca3af; margin: 0 0.5rem;'
|
|
}, '→');
|
|
container.appendChild(arrow);
|
|
}
|
|
}
|
|
|
|
return container;
|
|
},
|
|
|
|
/**
|
|
* Render timeline entry (for vertical timeline)
|
|
* @private
|
|
*/
|
|
_renderTimelineEntry: function(entry, index, options) {
|
|
var config = stateUtils.getStateConfig(entry.state);
|
|
var isError = stateUtils.isError(entry.state);
|
|
var isTransient = stateUtils.isTransient(entry.state);
|
|
|
|
var entryContainer = E('div', {
|
|
'class': 'timeline-entry' + (isError ? ' timeline-entry-error' : '') + (isTransient ? ' timeline-entry-transient' : ''),
|
|
'style': 'position: relative; margin-bottom: 1.5rem;'
|
|
});
|
|
|
|
// Timeline dot
|
|
var dot = E('div', {
|
|
'class': 'timeline-dot',
|
|
'style': 'position: absolute; left: -1.75rem; top: 0.25rem; width: 1rem; height: 1rem; border-radius: 50%; background-color: ' + config.color + '; border: 3px solid #ffffff; z-index: 10;'
|
|
});
|
|
|
|
if (isTransient) {
|
|
dot.style.animation = 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite';
|
|
}
|
|
|
|
entryContainer.appendChild(dot);
|
|
|
|
// Entry content
|
|
var content = E('div', {
|
|
'class': 'timeline-content',
|
|
'style': 'padding: 0.75rem; border-radius: 0.5rem; background-color: ' + config.color + '10; border-left: 3px solid ' + config.color + ';'
|
|
});
|
|
|
|
// Header (state + timestamp)
|
|
var header = E('div', {
|
|
'class': 'timeline-header',
|
|
'style': 'display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;'
|
|
});
|
|
|
|
var stateInfo = E('div', { 'style': 'display: flex; align-items: center; gap: 0.5rem;' });
|
|
var stateBadge = StateIndicator.render(entry.state, { showIcon: true, showLabel: true, animate: false });
|
|
stateInfo.appendChild(stateBadge);
|
|
|
|
// Category badge
|
|
if (options.showCategory !== false) {
|
|
var categoryBadge = E('span', {
|
|
'class': 'badge badge-secondary',
|
|
'style': 'font-size: 0.625rem; padding: 0.125rem 0.375rem; background-color: #f3f4f6; color: #6b7280;'
|
|
}, config.category);
|
|
stateInfo.appendChild(categoryBadge);
|
|
}
|
|
|
|
header.appendChild(stateInfo);
|
|
|
|
// Timestamp
|
|
if (entry.timestamp) {
|
|
var timestamp = E('div', {
|
|
'class': 'timeline-timestamp',
|
|
'style': 'font-size: 0.75rem; color: #6b7280;'
|
|
});
|
|
|
|
if (options.showRelativeTime !== false) {
|
|
timestamp.textContent = stateUtils.getTimeAgo(entry.timestamp);
|
|
timestamp.setAttribute('title', stateUtils.formatTimestamp(entry.timestamp));
|
|
} else {
|
|
timestamp.textContent = stateUtils.formatTimestamp(entry.timestamp);
|
|
}
|
|
|
|
header.appendChild(timestamp);
|
|
}
|
|
|
|
content.appendChild(header);
|
|
|
|
// Reason
|
|
if (entry.reason) {
|
|
var reason = E('div', {
|
|
'class': 'timeline-reason',
|
|
'style': 'font-size: 0.875rem; color: #4b5563; margin-bottom: 0.25rem;'
|
|
}, 'Reason: ' + this._formatReason(entry.reason));
|
|
content.appendChild(reason);
|
|
}
|
|
|
|
// Error details
|
|
if (isError && entry.error_details) {
|
|
var errorDetails = E('div', {
|
|
'class': 'timeline-error-details',
|
|
'style': 'margin-top: 0.5rem; padding: 0.5rem; background-color: #fee2e2; border-radius: 0.375rem; border-left: 3px solid #ef4444;'
|
|
});
|
|
|
|
if (entry.error_details.type) {
|
|
var errorType = E('div', {
|
|
'style': 'font-size: 0.75rem; font-weight: 600; color: #dc2626; margin-bottom: 0.25rem;'
|
|
}, 'Error Type: ' + entry.error_details.type);
|
|
errorDetails.appendChild(errorType);
|
|
}
|
|
|
|
if (entry.error_details.message) {
|
|
var errorMsg = E('div', {
|
|
'style': 'font-size: 0.75rem; color: #991b1b;'
|
|
}, entry.error_details.message);
|
|
errorDetails.appendChild(errorMsg);
|
|
}
|
|
|
|
if (entry.error_details.code) {
|
|
var errorCode = E('div', {
|
|
'style': 'font-size: 0.625rem; color: #7f1d1d; margin-top: 0.25rem; font-family: monospace;'
|
|
}, 'Code: ' + entry.error_details.code);
|
|
errorDetails.appendChild(errorCode);
|
|
}
|
|
|
|
content.appendChild(errorDetails);
|
|
}
|
|
|
|
// Metadata
|
|
if (options.showMetadata !== false && entry.metadata) {
|
|
var metadata = E('div', {
|
|
'class': 'timeline-metadata',
|
|
'style': 'margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid ' + config.color + '30; font-size: 0.75rem; color: #6b7280;'
|
|
});
|
|
|
|
var metadataEntries = Object.entries(entry.metadata);
|
|
for (var i = 0; i < metadataEntries.length; i++) {
|
|
var key = metadataEntries[i][0];
|
|
var value = metadataEntries[i][1];
|
|
var metaItem = E('div', {}, key + ': ' + value);
|
|
metadata.appendChild(metaItem);
|
|
}
|
|
|
|
content.appendChild(metadata);
|
|
}
|
|
|
|
// Actions
|
|
if (options.onEntryClick) {
|
|
content.style.cursor = 'pointer';
|
|
content.addEventListener('click', function() {
|
|
options.onEntryClick(entry, index);
|
|
});
|
|
}
|
|
|
|
entryContainer.appendChild(content);
|
|
|
|
return entryContainer;
|
|
},
|
|
|
|
/**
|
|
* Sort history entries
|
|
* @private
|
|
*/
|
|
_sortHistory: function(history, order) {
|
|
var sorted = history.slice();
|
|
|
|
sorted.sort(function(a, b) {
|
|
var timeA = new Date(a.timestamp || 0).getTime();
|
|
var timeB = new Date(b.timestamp || 0).getTime();
|
|
|
|
return order === 'asc' ? timeA - timeB : timeB - timeA;
|
|
});
|
|
|
|
return sorted;
|
|
},
|
|
|
|
/**
|
|
* Format reason string
|
|
* @private
|
|
*/
|
|
_formatReason: function(reason) {
|
|
if (!reason) {
|
|
return 'Unknown';
|
|
}
|
|
|
|
// Convert snake_case to Title Case
|
|
return reason
|
|
.split('_')
|
|
.map(function(word) {
|
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
|
})
|
|
.join(' ');
|
|
},
|
|
|
|
/**
|
|
* Render a state transition diagram
|
|
* @param {string} currentState - Current state
|
|
* @param {Object} options - Display options
|
|
* @returns {Element} DOM element
|
|
*/
|
|
renderTransitionDiagram: function(currentState, options) {
|
|
options = options || {};
|
|
|
|
var container = E('div', {
|
|
'class': 'state-transition-diagram',
|
|
'style': 'padding: 1rem;'
|
|
});
|
|
|
|
// Current state (center)
|
|
var currentStateEl = E('div', {
|
|
'style': 'text-align: center; margin-bottom: 1.5rem;'
|
|
});
|
|
|
|
var currentLabel = E('div', {
|
|
'style': 'font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem;'
|
|
}, 'Current State:');
|
|
currentStateEl.appendChild(currentLabel);
|
|
|
|
var currentBadge = StateIndicator.renderPill(currentState, {}, { showDescription: true });
|
|
currentStateEl.appendChild(currentBadge);
|
|
|
|
container.appendChild(currentStateEl);
|
|
|
|
// Possible transitions
|
|
var nextStates = stateUtils.getNextStates(currentState);
|
|
|
|
if (nextStates.length > 0) {
|
|
var transitionsLabel = E('div', {
|
|
'style': 'font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem; text-align: center;'
|
|
}, 'Possible Transitions:');
|
|
container.appendChild(transitionsLabel);
|
|
|
|
var transitionsGrid = E('div', {
|
|
'style': 'display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75rem;'
|
|
});
|
|
|
|
for (var i = 0; i < nextStates.length; i++) {
|
|
var nextState = nextStates[i];
|
|
var nextConfig = stateUtils.getStateConfig(nextState);
|
|
|
|
var transitionCard = E('div', {
|
|
'class': 'transition-card',
|
|
'style': 'padding: 0.75rem; border-radius: 0.5rem; border: 2px solid ' + nextConfig.color + '40; background-color: ' + nextConfig.color + '10; cursor: pointer; transition: all 0.2s;'
|
|
});
|
|
|
|
transitionCard.addEventListener('mouseenter', function() {
|
|
this.style.borderColor = nextConfig.color;
|
|
this.style.transform = 'translateY(-2px)';
|
|
});
|
|
|
|
transitionCard.addEventListener('mouseleave', function() {
|
|
this.style.borderColor = nextConfig.color + '40';
|
|
this.style.transform = 'translateY(0)';
|
|
});
|
|
|
|
// Arrow
|
|
var arrow = E('div', {
|
|
'style': 'text-align: center; font-size: 1.5rem; color: ' + nextConfig.color + '; margin-bottom: 0.5rem;'
|
|
}, '↓');
|
|
transitionCard.appendChild(arrow);
|
|
|
|
// State badge
|
|
var badge = StateIndicator.render(nextState, { showIcon: true, showLabel: true });
|
|
badge.style.display = 'flex';
|
|
badge.style.justifyContent = 'center';
|
|
transitionCard.appendChild(badge);
|
|
|
|
// Description
|
|
var desc = E('div', {
|
|
'style': 'font-size: 0.75rem; color: #6b7280; margin-top: 0.5rem; text-align: center;'
|
|
}, nextConfig.description);
|
|
transitionCard.appendChild(desc);
|
|
|
|
// Click handler
|
|
if (options.onTransitionClick) {
|
|
(function(state) {
|
|
transitionCard.addEventListener('click', function() {
|
|
options.onTransitionClick(currentState, state);
|
|
});
|
|
})(nextState);
|
|
}
|
|
|
|
transitionsGrid.appendChild(transitionCard);
|
|
}
|
|
|
|
container.appendChild(transitionsGrid);
|
|
} else {
|
|
var noTransitions = E('div', {
|
|
'style': 'text-align: center; padding: 1rem; color: #6b7280; font-size: 0.875rem;'
|
|
}, 'No transitions available from this state');
|
|
container.appendChild(noTransitions);
|
|
}
|
|
|
|
return container;
|
|
}
|
|
});
|