forked from snxraven/p2ns
reorg
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
# Domain Consensus Plugin
|
||||
|
||||
A comprehensive interface for visualizing and analyzing consensus within the P2NS network. This plugin provides real-time insights into domain consensus states, voting patterns, claims, and quorum information.
|
||||
|
||||
## Overview
|
||||
|
||||
The Domain Consensus plugin offers an advanced dashboard for monitoring and understanding how domains achieve consensus in the P2NS network. It provides detailed views of:
|
||||
|
||||
- Domain consensus statuses (resolved, insufficient quorum, ties, etc.)
|
||||
- Vote counts and distributions
|
||||
- Claims and claimants
|
||||
- Quorum progress and requirements
|
||||
- Real-time consensus metrics
|
||||
|
||||
## Features
|
||||
|
||||
### Overview Dashboard
|
||||
|
||||
- **Aggregate Statistics**: High-level metrics showing total domains, resolved domains, domains with insufficient quorum, tied domains, and active peers
|
||||
- **Consensus Metrics**: Total resolutions, quorum failures, total votes cast, and average votes per domain
|
||||
- **Visual Charts**:
|
||||
- Pie chart showing consensus status distribution
|
||||
- Bar chart displaying consensus metrics (resolutions, quorum failures, ties, validation failures)
|
||||
|
||||
### Domain List View
|
||||
|
||||
- **Comprehensive Domain Table**: Lists all domains with their consensus information
|
||||
- **Search Functionality**: Search domains by name
|
||||
- **Status Filtering**: Filter domains by consensus status (resolved, insufficient quorum, tie, no claims, error)
|
||||
- **Sortable Columns**: Sort by domain name, status, resolved claimant, votes, quorum, or active peers
|
||||
- **Quick Actions**: View detailed information for any domain with a single click
|
||||
|
||||
### Domain Detail View
|
||||
|
||||
- **Detailed Consensus Information**: Complete consensus state for a specific domain
|
||||
- **Claims Display**: All claims with claimant IDs, hashes, timestamps, and vote counts
|
||||
- **Votes Display**: All votes showing which voters voted for which claimants
|
||||
- **Quorum Progress**: Visual progress indicator showing quorum status
|
||||
- **Vote Distribution Chart**: Bar chart showing vote distribution across claimants
|
||||
- **Resolved Information**: Displays resolved claimant and hash when consensus is reached
|
||||
|
||||
### Real-Time Updates
|
||||
|
||||
- **WebSocket Integration**: Real-time updates as consensus changes occur
|
||||
- **Live Metrics**: Automatically updates statistics and charts without page refresh
|
||||
- **Connection Status**: Visual indicator showing WebSocket connection status
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing the Plugin
|
||||
|
||||
Once P2NS is running and the plugin is loaded, access the Domain Consensus interface at:
|
||||
|
||||
```
|
||||
https://domain.consensus
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
- **Overview**: Click "Overview" in the sidebar to view the main dashboard with aggregate metrics
|
||||
- **Domain List**: Click "Domain List" to see all domains and their consensus statuses
|
||||
- **Domain Details**: Click "View Details" on any domain in the domain list to see detailed information
|
||||
|
||||
### Understanding Consensus Statuses
|
||||
|
||||
- **Resolved** (Green): Domain has reached consensus with a resolved claimant and hash
|
||||
- **Insufficient Quorum** (Yellow): Domain has claims and votes but hasn't met the minimum quorum requirement
|
||||
- **Tie** (Orange): Multiple claimants have the same number of votes
|
||||
- **No Claims** (Gray): Domain has no claims registered
|
||||
- **Error** (Red): An error occurred while determining consensus
|
||||
|
||||
### Domain List Features
|
||||
|
||||
1. **Search**: Type in the search box to filter domains by name
|
||||
2. **Filter**: Use the status dropdown to filter by consensus status
|
||||
3. **Sort**: Click column headers to sort the table
|
||||
4. **View Details**: Click "View Details" button to see comprehensive domain information
|
||||
|
||||
### Domain Detail Features
|
||||
|
||||
1. **Overview Cards**: Quick view of total votes, minimum required, quorum status, and active peers
|
||||
2. **Quorum Progress**: Visual progress bar showing quorum percentage
|
||||
3. **Resolved Information**: When resolved, shows the resolved claimant and hash with copy buttons
|
||||
4. **Claims Table**: All claims with their vote counts
|
||||
5. **Votes Table**: Complete list of all votes
|
||||
6. **Vote Distribution Chart**: Visual representation of vote distribution
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend API Endpoints
|
||||
|
||||
The plugin exposes the following API endpoints:
|
||||
|
||||
- `GET /api/overview` - Returns aggregate consensus metrics and statistics
|
||||
- `GET /api/domains` - Returns list of all domains with consensus status
|
||||
- `GET /api/domain/:domain` - Returns detailed consensus state for a specific domain
|
||||
- `GET /api/metrics` - Returns consensus metrics
|
||||
- `GET /api/peers` - Returns active peer count and quorum information
|
||||
|
||||
### Frontend Components
|
||||
|
||||
- **app.js**: Main application logic, view management, and WebSocket handling
|
||||
- **views/overview.js**: Overview dashboard with charts and statistics
|
||||
- **views/domain-list.js**: Domain list table with search, filter, and sort
|
||||
- **views/domain-detail.js**: Detailed domain view with claims, votes, and charts
|
||||
- **api.js**: API client with caching
|
||||
- **websocket.js**: WebSocket client for real-time updates
|
||||
- **utils.js**: Utility functions for formatting and display
|
||||
|
||||
### Real-Time Updates
|
||||
|
||||
The plugin uses WebSocket connections to receive real-time updates:
|
||||
|
||||
- **Update Events**: Periodic updates every 5 seconds with current system state
|
||||
- **Domain Events**: Immediate notifications when domains are added or removed
|
||||
- **Chart Updates**: Charts automatically update without full page re-render
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Consensus State Structure
|
||||
|
||||
Each domain's consensus state includes:
|
||||
|
||||
```javascript
|
||||
{
|
||||
status: 'resolved' | 'insufficient_quorum' | 'tie' | 'no_claims' | 'error',
|
||||
hash: string | null,
|
||||
resolvedClaimant: string | null,
|
||||
voteCounts: { [claimant: string]: number },
|
||||
activePeers: number,
|
||||
quorumMet: boolean,
|
||||
minVotes: number,
|
||||
totalVotes: number,
|
||||
lastResolution: number | null
|
||||
}
|
||||
```
|
||||
|
||||
### Quorum Calculation
|
||||
|
||||
Quorum is calculated based on:
|
||||
- **Active Peers**: Number of connected peers in the network
|
||||
- **Quorum Threshold**: Configurable threshold (default: 50% of active peers)
|
||||
- **Minimum Votes**: Maximum of configured minimum votes and calculated threshold
|
||||
|
||||
Quorum is met when `totalVotes >= minVotes`.
|
||||
|
||||
### Data Caching
|
||||
|
||||
- API responses are cached for 30 seconds to reduce server load
|
||||
- Cache is automatically invalidated on WebSocket updates
|
||||
- Manual cache invalidation available through API calls
|
||||
|
||||
## Requirements
|
||||
|
||||
- P2NS system running with DNS service initialized
|
||||
- WebSocket support enabled
|
||||
- Modern browser with JavaScript enabled
|
||||
- Chart.js library (loaded via CDN)
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- Chrome/Edge (latest)
|
||||
- Firefox (latest)
|
||||
- Safari (latest)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Charts Not Displaying
|
||||
|
||||
- Ensure the view container is visible before charts render
|
||||
- Check browser console for JavaScript errors
|
||||
- Verify Chart.js library is loaded
|
||||
|
||||
### WebSocket Connection Issues
|
||||
|
||||
- Check connection status indicator in header
|
||||
- Verify P2NS is running and WebSocket server is active
|
||||
- Check browser console for WebSocket errors
|
||||
|
||||
### Domain List Not Showing
|
||||
|
||||
- Verify DNS service is initialized
|
||||
- Check that domains exist in the network
|
||||
- Look for errors in browser console
|
||||
|
||||
### Real-Time Updates Not Working
|
||||
|
||||
- Check WebSocket connection status
|
||||
- Verify WebSocket server is running
|
||||
- Check browser console for errors
|
||||
|
||||
## Development
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
domain.consensus/
|
||||
├── config.json # Plugin configuration
|
||||
├── index.js # Backend handler and API endpoints
|
||||
├── README.md # This file
|
||||
└── www/ # Frontend files
|
||||
├── index.html # Main HTML structure
|
||||
├── css/
|
||||
│ ├── style.css # Custom styles
|
||||
│ └── tailwind.css # Tailwind CSS framework
|
||||
└── js/
|
||||
├── app.js # Main application logic
|
||||
├── api.js # API client
|
||||
├── websocket.js # WebSocket client
|
||||
├── utils.js # Utility functions
|
||||
└── views/
|
||||
├── overview.js # Overview dashboard
|
||||
├── domain-list.js # Domain list view
|
||||
└── domain-detail.js # Domain detail view
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
The plugin uses Tailwind CSS. To rebuild the CSS:
|
||||
|
||||
```bash
|
||||
npm run build:css
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
1. Start P2NS
|
||||
2. Navigate to `https://domain.consensus`
|
||||
3. Verify all views load correctly
|
||||
4. Test search, filter, and sort functionality
|
||||
5. Verify WebSocket updates work
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Same as P2NS project
|
||||
|
||||
## Author
|
||||
|
||||
P2NS Team
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [P2NS Plugin System Documentation](../../docs/PLUGINS.md)
|
||||
- [Plugin SDK Reference](../../docs/PLUGIN_SDK.md)
|
||||
- [Consensus Mechanism](../../README.md#dns-conflict-selector)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Domain Consensus",
|
||||
"version": "1.0.0",
|
||||
"domain": "domain.consensus",
|
||||
"enabled": true,
|
||||
"description": "Advanced interface for viewing and analyzing consensus within the P2NS network",
|
||||
"author": "P2NS",
|
||||
"homepage": "https://github.com/p2ns/p2ns",
|
||||
"license": "MIT",
|
||||
"icon": "balance-scale",
|
||||
"dependencies": {},
|
||||
"www": "www"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* Domain Consensus Plugin
|
||||
*
|
||||
* Provides an advanced interface to visualize and analyze consensus within the P2NS network.
|
||||
* Features:
|
||||
* - Overview dashboard with aggregate consensus metrics
|
||||
* - Domain list with consensus status
|
||||
* - Detailed domain views showing claims, votes, and quorum information
|
||||
* - Real-time updates via WebSocket
|
||||
*/
|
||||
|
||||
const sdk = require('../../includes/plugins/sdk');
|
||||
|
||||
// Shared state
|
||||
let updateInterval = null;
|
||||
let lastConsensusStates = new Map(); // Track last known consensus states for change detection
|
||||
let debounceTimer = null;
|
||||
|
||||
/**
|
||||
* Enrich peer IDs with profile data
|
||||
* Fetches profiles for all peer IDs in parallel and returns a map
|
||||
* @param {Array<string>} peerIds - Array of peer IDs to fetch profiles for
|
||||
* @returns {Promise<Map<string, object>>} Map of peerId -> profile data (or null if no profile)
|
||||
*/
|
||||
async function enrichWithProfiles(peerIds) {
|
||||
const profileMap = new Map();
|
||||
|
||||
if (!peerIds || peerIds.length === 0) {
|
||||
return profileMap;
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
const uniquePeerIds = [...new Set(peerIds)];
|
||||
|
||||
// Fetch all profiles in parallel
|
||||
const profilePromises = uniquePeerIds.map(async (peerId) => {
|
||||
try {
|
||||
const profile = await sdk.profiles.getProfile(peerId);
|
||||
return { peerId, profile };
|
||||
} catch (err) {
|
||||
// Profile doesn't exist or error fetching - return null
|
||||
sdk.log.debug('domain.consensus', `No profile found for peer ${peerId.slice(0, 16)}...`);
|
||||
return { peerId, profile: null };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(profilePromises);
|
||||
|
||||
// Build the map
|
||||
for (const { peerId, profile } of results) {
|
||||
profileMap.set(peerId, profile);
|
||||
}
|
||||
|
||||
return profileMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin Handler
|
||||
*
|
||||
* Handles all HTTP requests for domain.consensus
|
||||
*/
|
||||
async function handler(req, res) {
|
||||
try {
|
||||
const { path, query, method } = sdk.router.parseRequest(req);
|
||||
|
||||
// Handle root path - serve index.html
|
||||
if (path === '' || path === '/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// API: Get overview data
|
||||
if (path === 'api/overview' && method === 'GET') {
|
||||
if (!sdk.utils.isDNSReady()) {
|
||||
return sdk.router.json(res, {
|
||||
stats: {
|
||||
totalDomains: 0,
|
||||
resolved: 0,
|
||||
insufficientQuorum: 0,
|
||||
tie: 0,
|
||||
noClaims: 0,
|
||||
error: 0
|
||||
},
|
||||
metrics: {
|
||||
resolutions: 0,
|
||||
quorumFailures: 0,
|
||||
ties: 0,
|
||||
validationFailures: 0,
|
||||
totalVotes: 0,
|
||||
avgVotesPerDomain: 0
|
||||
},
|
||||
activePeers: 0,
|
||||
timestamp: Date.now(),
|
||||
error: 'DNS service not ready'
|
||||
});
|
||||
}
|
||||
|
||||
const domains = await sdk.domains.listDomains();
|
||||
const metrics = sdk.dns.getConsensusMetrics();
|
||||
const connectedPeers = sdk.state.connectedPeers;
|
||||
|
||||
// Calculate aggregate statistics
|
||||
const stats = {
|
||||
totalDomains: domains.length,
|
||||
resolved: 0,
|
||||
insufficientQuorum: 0,
|
||||
tie: 0,
|
||||
noClaims: 0,
|
||||
error: 0
|
||||
};
|
||||
|
||||
for (const domain of domains) {
|
||||
if (domain.consensus) {
|
||||
switch (domain.consensus.status) {
|
||||
case 'resolved':
|
||||
stats.resolved++;
|
||||
break;
|
||||
case 'insufficient_quorum':
|
||||
stats.insufficientQuorum++;
|
||||
break;
|
||||
case 'tie':
|
||||
stats.tie++;
|
||||
break;
|
||||
case 'no_claims':
|
||||
stats.noClaims++;
|
||||
break;
|
||||
case 'error':
|
||||
stats.error++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sdk.router.json(res, {
|
||||
stats,
|
||||
metrics: {
|
||||
resolutions: metrics.resolutions || 0,
|
||||
quorumFailures: metrics.quorumFailures || 0,
|
||||
ties: metrics.ties || 0,
|
||||
validationFailures: metrics.validationFailures || 0,
|
||||
totalVotes: metrics.totalVotes || 0,
|
||||
avgVotesPerDomain: metrics.avgVotesPerDomain || 0
|
||||
},
|
||||
activePeers: connectedPeers,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// API: Get all domains with consensus status
|
||||
if (path === 'api/domains' && method === 'GET') {
|
||||
if (!sdk.utils.isDNSReady()) {
|
||||
return sdk.router.json(res, {
|
||||
domains: [],
|
||||
timestamp: Date.now(),
|
||||
error: 'DNS service not ready'
|
||||
});
|
||||
}
|
||||
|
||||
const domains = await sdk.domains.listDomains();
|
||||
|
||||
// Enrich domains with consensus information
|
||||
const enrichedDomains = await Promise.all(domains.map(async (domain) => {
|
||||
const consensusState = await sdk.dns.getConsensusState(domain.domain);
|
||||
return {
|
||||
domain: domain.domain,
|
||||
hash: domain.hash,
|
||||
ip: domain.ip,
|
||||
consensus: consensusState,
|
||||
isLocal: domain.isLocal || false
|
||||
};
|
||||
}));
|
||||
|
||||
return sdk.router.json(res, {
|
||||
domains: enrichedDomains,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// API: Get detailed consensus state for a specific domain
|
||||
const domainMatch = path.match(/^api\/domain\/(.+)$/);
|
||||
if (domainMatch && method === 'GET') {
|
||||
const domain = decodeURIComponent(domainMatch[1]);
|
||||
|
||||
try {
|
||||
const consensusState = await sdk.dns.getConsensusState(domain);
|
||||
const domainInfo = await sdk.domains.getDomainInfo(domain);
|
||||
|
||||
// Get all entries to extract claims and votes
|
||||
const allEntries = await sdk.dns.getAllEntries(true);
|
||||
const claims = [];
|
||||
const votes = [];
|
||||
const peerIds = new Set();
|
||||
|
||||
for (const entry of allEntries) {
|
||||
// Extract claims
|
||||
if (entry.key.startsWith(`claim:${domain}:`)) {
|
||||
const claimant = entry.key.slice(`claim:${domain}:`.length);
|
||||
peerIds.add(claimant);
|
||||
let claimValue;
|
||||
try {
|
||||
claimValue = JSON.parse(entry.value);
|
||||
} catch (e) {
|
||||
claimValue = { hash: entry.value };
|
||||
}
|
||||
|
||||
claims.push({
|
||||
claimant,
|
||||
hash: claimValue.hash || entry.value,
|
||||
timestamp: claimValue.timestamp || null,
|
||||
ssl: claimValue.ssl || false,
|
||||
clients: claimValue.clients || []
|
||||
});
|
||||
}
|
||||
|
||||
// Extract votes
|
||||
if (entry.key.startsWith(`vote:${domain}:`)) {
|
||||
const parts = entry.key.split(':');
|
||||
if (parts.length === 4) {
|
||||
const claimant = parts[2];
|
||||
const voter = parts[3];
|
||||
peerIds.add(claimant);
|
||||
peerIds.add(voter);
|
||||
votes.push({
|
||||
claimant,
|
||||
voter,
|
||||
hash: entry.value
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add resolved claimant if exists
|
||||
if (consensusState.resolvedClaimant) {
|
||||
peerIds.add(consensusState.resolvedClaimant);
|
||||
}
|
||||
|
||||
// Fetch profiles for all peer IDs
|
||||
const profiles = await enrichWithProfiles(Array.from(peerIds));
|
||||
|
||||
// Enrich claims with profile data
|
||||
const enrichedClaims = claims.map(claim => ({
|
||||
...claim,
|
||||
profile: profiles.get(claim.claimant) || null
|
||||
}));
|
||||
|
||||
// Enrich votes with profile data
|
||||
const enrichedVotes = votes.map(vote => ({
|
||||
...vote,
|
||||
voterProfile: profiles.get(vote.voter) || null,
|
||||
claimantProfile: profiles.get(vote.claimant) || null
|
||||
}));
|
||||
|
||||
return sdk.router.json(res, {
|
||||
domain,
|
||||
consensus: consensusState,
|
||||
domainInfo,
|
||||
claims: enrichedClaims,
|
||||
votes: enrichedVotes,
|
||||
profiles: Object.fromEntries(profiles), // Include all profiles for easy lookup
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} catch (err) {
|
||||
sdk.log.error('domain.consensus', `Error getting domain details: ${err.message}`);
|
||||
return sdk.router.error(res, `Failed to get domain details: ${err.message}`, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// API: Get consensus metrics
|
||||
if (path === 'api/metrics' && method === 'GET') {
|
||||
const metrics = sdk.dns.getConsensusMetrics();
|
||||
return sdk.router.json(res, {
|
||||
metrics,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// API: Get peer information
|
||||
if (path === 'api/peers' && method === 'GET') {
|
||||
const connectedPeers = sdk.state.connectedPeers;
|
||||
const localPeerId = sdk.state.localPeerId;
|
||||
|
||||
return sdk.router.json(res, {
|
||||
activePeers: connectedPeers,
|
||||
localPeerId,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// Return false for all other routes to allow static file serving
|
||||
return false;
|
||||
} catch (err) {
|
||||
sdk.log.error('domain.consensus', `Error handling request: ${err.message}`);
|
||||
return sdk.router.error(res, 'Internal Server Error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast update to all WebSocket clients
|
||||
*/
|
||||
function broadcastUpdate(data) {
|
||||
sdk.websocket.broadcast(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for consensus changes and broadcast updates
|
||||
* Debounced to avoid excessive updates
|
||||
*/
|
||||
async function checkAndBroadcastConsensusChanges() {
|
||||
if (sdk.websocket.getClientCount() === 0) {
|
||||
return; // No clients connected, skip
|
||||
}
|
||||
|
||||
try {
|
||||
const currentState = await getSystemState();
|
||||
const changedDomains = [];
|
||||
let hasChanges = false;
|
||||
|
||||
// Check for domain changes
|
||||
const currentDomainMap = new Map(
|
||||
currentState.domains.map(d => [d.domain, d.consensus])
|
||||
);
|
||||
|
||||
// Check for new domains or changed consensus
|
||||
for (const domain of currentState.domains) {
|
||||
const lastState = lastConsensusStates.get(domain.domain);
|
||||
const currentConsensus = domain.consensus;
|
||||
|
||||
if (!lastState || JSON.stringify(lastState) !== JSON.stringify(currentConsensus)) {
|
||||
changedDomains.push(domain.domain);
|
||||
hasChanges = true;
|
||||
lastConsensusStates.set(domain.domain, JSON.parse(JSON.stringify(currentConsensus)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removed domains
|
||||
for (const [domain, _] of lastConsensusStates) {
|
||||
if (!currentDomainMap.has(domain)) {
|
||||
lastConsensusStates.delete(domain);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast update if there are changes
|
||||
if (hasChanges) {
|
||||
broadcastUpdate({
|
||||
type: 'consensus-update',
|
||||
data: currentState,
|
||||
changedDomains
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
sdk.log.error('domain.consensus', `Error checking consensus changes: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced consensus check
|
||||
*/
|
||||
function debouncedConsensusCheck() {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
checkAndBroadcastConsensusChanges();
|
||||
}, 100); // 100ms debounce
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket handlers using SDK
|
||||
*/
|
||||
function setupWebSocketHandlers() {
|
||||
// Explicitly initialize WebSocket server to ensure it's ready
|
||||
if (!sdk.websocket.initialize()) {
|
||||
sdk.log.error('domain.consensus', 'Failed to initialize WebSocket server');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle new connections
|
||||
sdk.websocket.on('connection', (ws) => {
|
||||
// Send initial state
|
||||
getSystemState().then(state => {
|
||||
// Initialize last known states
|
||||
for (const domain of state.domains) {
|
||||
lastConsensusStates.set(domain.domain, JSON.parse(JSON.stringify(domain.consensus)));
|
||||
}
|
||||
|
||||
sdk.websocket.send(ws, {
|
||||
type: 'init',
|
||||
data: state
|
||||
});
|
||||
}).catch(err => {
|
||||
sdk.log.error('domain.consensus', `Error sending initial state: ${err.message}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Setup periodic updates as fallback (reduced frequency since we have event-driven updates)
|
||||
updateInterval = setInterval(async () => {
|
||||
if (sdk.websocket.getClientCount() > 0) {
|
||||
const state = await getSystemState();
|
||||
broadcastUpdate({
|
||||
type: 'update',
|
||||
data: state
|
||||
});
|
||||
}
|
||||
}, 30000); // Update every 30 seconds as fallback (event-driven updates handle most changes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current system state for WebSocket clients
|
||||
*/
|
||||
async function getSystemState() {
|
||||
try {
|
||||
const domains = await sdk.domains.listDomains();
|
||||
const metrics = sdk.dns.getConsensusMetrics();
|
||||
const connectedPeers = sdk.state.connectedPeers;
|
||||
|
||||
// Get consensus states for all domains
|
||||
const consensusStates = await Promise.all(
|
||||
domains.map(domain => sdk.dns.getConsensusState(domain.domain))
|
||||
);
|
||||
|
||||
return {
|
||||
domains: domains.map((domain, idx) => ({
|
||||
domain: domain.domain,
|
||||
consensus: consensusStates[idx]
|
||||
})),
|
||||
metrics,
|
||||
activePeers: connectedPeers,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
} catch (err) {
|
||||
sdk.log.error('domain.consensus', `Error getting system state: ${err.message}`);
|
||||
return {
|
||||
domains: [],
|
||||
metrics: {},
|
||||
activePeers: 0,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin Initialization Hook
|
||||
*/
|
||||
async function onInit() {
|
||||
sdk.log.info('domain.consensus', 'Plugin initialized');
|
||||
|
||||
// Check if DNS is ready (don't wait, as this blocks startup)
|
||||
if (sdk.utils.isDNSReady()) {
|
||||
sdk.log.info('domain.consensus', 'DNS service is ready');
|
||||
} else {
|
||||
sdk.log.warn('domain.consensus', 'DNS service is not ready yet - plugin will work once DNS is ready');
|
||||
}
|
||||
|
||||
// Setup WebSocket handlers (server is automatically created and registered)
|
||||
setupWebSocketHandlers();
|
||||
|
||||
// Subscribe to consensus-related events (non-blocking)
|
||||
sdk.events.on('domain-added', async (data) => {
|
||||
try {
|
||||
sdk.log.debug('domain.consensus', `Domain added: ${data.domain}`);
|
||||
// Trigger consensus check after a short delay to allow DNS to update
|
||||
setTimeout(() => {
|
||||
debouncedConsensusCheck();
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
sdk.log.error('domain.consensus', `Error handling domain-added event: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
sdk.events.on('domain-removed', async (data) => {
|
||||
try {
|
||||
sdk.log.debug('domain.consensus', `Domain removed: ${data.domain}`);
|
||||
lastConsensusStates.delete(data.domain);
|
||||
// Trigger consensus check
|
||||
setTimeout(() => {
|
||||
debouncedConsensusCheck();
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
sdk.log.error('domain.consensus', `Error handling domain-removed event: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to DNS pass updates for real-time consensus changes
|
||||
// This is the key to making it fully live - we detect when DNS entries change
|
||||
// Wait for DNS to be ready before setting up listeners
|
||||
const setupDNSListeners = () => {
|
||||
const dnsPass = sdk.state.dnsPass;
|
||||
const core = sdk.state.core;
|
||||
|
||||
if (dnsPass) {
|
||||
// Remove existing listener if any
|
||||
dnsPass.removeAllListeners('update');
|
||||
dnsPass.on('update', () => {
|
||||
sdk.log.debug('domain.consensus', 'DNS pass update detected - checking consensus changes');
|
||||
debouncedConsensusCheck();
|
||||
});
|
||||
sdk.log.info('domain.consensus', 'DNS pass update listener registered');
|
||||
}
|
||||
|
||||
if (core) {
|
||||
// Remove existing listener if any
|
||||
core.removeAllListeners('append');
|
||||
core.on('append', () => {
|
||||
sdk.log.debug('domain.consensus', 'Core append detected - checking consensus changes');
|
||||
debouncedConsensusCheck();
|
||||
});
|
||||
sdk.log.info('domain.consensus', 'Core append listener registered');
|
||||
}
|
||||
};
|
||||
|
||||
// Setup listeners immediately if DNS is ready, otherwise wait
|
||||
if (sdk.utils.isDNSReady()) {
|
||||
setupDNSListeners();
|
||||
} else {
|
||||
// Wait for DNS to be ready and then setup listeners
|
||||
sdk.utils.waitForDNSReady(30000).then((ready) => {
|
||||
if (ready) {
|
||||
setupDNSListeners();
|
||||
} else {
|
||||
sdk.log.warn('domain.consensus', 'DNS not ready after 30s, will retry on next check');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen to peer connection/disconnection events
|
||||
sdk.events.on('peer-connected', async (data) => {
|
||||
try {
|
||||
sdk.log.debug('domain.consensus', `Peer connected: ${data.peerId}`);
|
||||
// Peer count changed, update state
|
||||
debouncedConsensusCheck();
|
||||
} catch (err) {
|
||||
sdk.log.error('domain.consensus', `Error handling peer-connected event: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
sdk.events.on('peer-disconnected', async (data) => {
|
||||
try {
|
||||
sdk.log.debug('domain.consensus', `Peer disconnected: ${data.peerId}`);
|
||||
// Peer count changed, update state
|
||||
debouncedConsensusCheck();
|
||||
} catch (err) {
|
||||
sdk.log.error('domain.consensus', `Error handling peer-disconnected event: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
sdk.log.info('domain.consensus', 'WebSocket server initialized with real-time event listeners');
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin Shutdown Hook
|
||||
*/
|
||||
async function onShutdown() {
|
||||
sdk.log.info('domain.consensus', 'Plugin shutting down');
|
||||
|
||||
// Clear update interval
|
||||
if (updateInterval) {
|
||||
clearInterval(updateInterval);
|
||||
updateInterval = null;
|
||||
}
|
||||
|
||||
// Clear debounce timer
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
|
||||
// Remove event listeners (safely)
|
||||
try {
|
||||
const dnsPass = sdk.state.dnsPass;
|
||||
if (dnsPass && typeof dnsPass.removeAllListeners === 'function') {
|
||||
dnsPass.removeAllListeners('update');
|
||||
}
|
||||
} catch (err) {
|
||||
sdk.log.debug('domain.consensus', `Error removing DNS pass listeners: ${err.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const core = sdk.state.core;
|
||||
if (core && typeof core.removeAllListeners === 'function') {
|
||||
core.removeAllListeners('append');
|
||||
}
|
||||
} catch (err) {
|
||||
sdk.log.debug('domain.consensus', `Error removing core listeners: ${err.message}`);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
lastConsensusStates.clear();
|
||||
|
||||
// Close all WebSocket connections (SDK handles cleanup automatically)
|
||||
sdk.websocket.close();
|
||||
}
|
||||
|
||||
// Export the plugin interface
|
||||
module.exports = {
|
||||
handler,
|
||||
onInit,
|
||||
onShutdown
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="640" height="512">
|
||||
<path fill="#3b82f6" d="M384 176c0 70.7-57.3 128-128 128s-128-57.3-128-128S185.3 48 256 48s128 57.3 128 128zM9.8 214.5c-4.2 13.6 .6 28.3 11.5 37.4l72 56 15.2-19.5c-1.2-3.1-2.1-6.3-2.8-9.6l-1.1-7.9c-1.2-8.2 1.2-16.5 6.8-22.8l21.6-25.1L81 201.1c-9.1-9.1-23.8-15.7-37.4-11.5L9.8 214.5zM630.2 214.5c4.2 13.6-.6 28.3-11.5 37.4l-72 56-15.2-19.5c1.2-3.1 2.1-6.3 2.8-9.6l1.1-7.9c1.2-8.2-1.2-16.5-6.8-22.8l-21.6-25.1L559 201.1c9.1-9.1 23.8-15.7 37.4-11.5l23.8 24.9zM320 384c-35.3 0-64-28.7-64-64s28.7-64 64-64 64 28.7 64 64-28.7 64-64 64zm-192-32c-17.7 0-32 14.3-32 32s14.3 32 32 32H320c17.7 0 32-14.3 32-32s-14.3-32-32-32H128zm384 32c-17.7 0-32 14.3-32 32s14.3 32 32 32h64c17.7 0 32-14.3 32-32s-14.3-32-32-32H512z"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 807 B |
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<title>Domain Consensus - P2NS</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="stylesheet" href="/css/tailwind.css">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="https://global.profile/css/profile-modal.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<h1>Domain Consensus</h1>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<div class="status-indicator" id="statusIndicator" aria-label="Connection status"></div>
|
||||
<span id="statusText">Connecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<!-- View Tabs -->
|
||||
<div class="sidebar-section">
|
||||
<h2>Views</h2>
|
||||
<div>
|
||||
<a href="#overview" class="view-tab active" data-view="overview">Overview</a>
|
||||
<a href="#domains" class="view-tab" data-view="domain-list">Domain List</a>
|
||||
<a href="#domain-detail" class="view-tab hidden" data-view="domain-detail" id="domainDetailTab">Domain Details</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="sidebar-section">
|
||||
<h2>Quick Stats</h2>
|
||||
<div class="stats-list">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Domains:</span>
|
||||
<span id="statTotalDomains" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Resolved:</span>
|
||||
<span id="statResolved" class="stat-value success">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Active Peers:</span>
|
||||
<span id="statActivePeers" class="stat-value primary">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Visualization Area -->
|
||||
<main class="main-content">
|
||||
<!-- View Container -->
|
||||
<div id="viewContainer" class="view-container">
|
||||
<!-- Overview View -->
|
||||
<div id="overview-view" class="view-content active">
|
||||
<div class="view-content-inner">
|
||||
<div id="overviewContent">
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner"></div>
|
||||
<p class="mt-4 text-tertiary">Loading overview...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain List View -->
|
||||
<div id="domain-list-view" class="view-content">
|
||||
<div class="view-content-inner">
|
||||
<div id="domainListContent">
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner"></div>
|
||||
<p class="mt-4 text-tertiary">Loading domains...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain Detail View -->
|
||||
<div id="domain-detail-view" class="view-content">
|
||||
<div class="view-content-inner">
|
||||
<div id="domainDetailContent">
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner"></div>
|
||||
<p class="mt-4 text-tertiary">Loading domain details...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Modal Component -->
|
||||
<div id="profile-modal" class="profile-modal" style="display: none;">
|
||||
<div class="profile-modal-backdrop"></div>
|
||||
<div class="profile-modal-content">
|
||||
<button class="profile-modal-close" aria-label="Close" onclick="if(window.ProfileModal){window.ProfileModal.close();}">×</button>
|
||||
|
||||
<div class="profile-modal-loading" id="profile-modal-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading profile...</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-error" id="profile-modal-error" style="display: none;">
|
||||
<p>Failed to load profile</p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-body" id="profile-modal-body" style="display: none;">
|
||||
<div class="profile-modal-header">
|
||||
<div class="profile-modal-avatar-container">
|
||||
<img id="profile-modal-avatar" class="profile-modal-avatar" alt="Avatar" onerror="this.style.display='none'; document.getElementById('profile-modal-avatar-placeholder').style.display='flex';">
|
||||
<div id="profile-modal-avatar-placeholder" class="profile-modal-avatar-placeholder" style="display: none;">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-modal-header-info">
|
||||
<h2 id="profile-modal-name" class="profile-modal-name">Loading...</h2>
|
||||
<p id="profile-modal-peerid" class="profile-modal-peerid">...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-bio" id="profile-modal-bio-container" style="display: none;">
|
||||
<p id="profile-modal-bio" class="profile-modal-bio-text"></p>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-details" id="profile-modal-details">
|
||||
<div class="profile-modal-detail-item" id="profile-modal-email-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">📧</span>
|
||||
<a id="profile-modal-email" href="#" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-website-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">🌐</span>
|
||||
<a id="profile-modal-website" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
<div class="profile-modal-detail-item" id="profile-modal-x-item" style="display: none;">
|
||||
<span class="profile-modal-detail-icon">🐦</span>
|
||||
<a id="profile-modal-x" href="#" target="_blank" rel="noopener noreferrer" class="profile-modal-detail-link"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-actions" id="profile-modal-actions" style="display: none;">
|
||||
<button id="profile-modal-block-btn" class="profile-modal-action-btn profile-modal-action-btn-block" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
|
||||
</svg>
|
||||
Block
|
||||
</button>
|
||||
<button id="profile-modal-unblock-btn" class="profile-modal-action-btn profile-modal-action-btn-unblock" style="display: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
Unblock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/js/utils.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/websocket.js"></script>
|
||||
<script src="https://global.profile/js/profile-modal.js"></script>
|
||||
<script src="/js/views/overview.js"></script>
|
||||
<script src="/js/views/domain-list.js"></script>
|
||||
<script src="/js/views/domain-detail.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* API client for domain consensus plugin
|
||||
*/
|
||||
|
||||
class APIClient {
|
||||
constructor() {
|
||||
this.baseUrl = window.location.origin;
|
||||
this.cache = new Map();
|
||||
this.cacheTimeout = 30000; // 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API request
|
||||
*/
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseUrl}/${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`API request failed: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overview data
|
||||
*/
|
||||
async getOverview() {
|
||||
const cacheKey = 'overview';
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.request('api/overview');
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Return cached data if available, even if expired
|
||||
if (cached) {
|
||||
return cached.data;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all domains with consensus status
|
||||
*/
|
||||
async getDomains() {
|
||||
const cacheKey = 'domains';
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.request('api/domains');
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
return cached.data;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed consensus state for a specific domain
|
||||
*/
|
||||
async getDomainDetail(domain) {
|
||||
const cacheKey = `domain:${domain}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedDomain = encodeURIComponent(domain);
|
||||
const data = await this.request(`api/domain/${encodedDomain}`);
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
return cached.data;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consensus metrics
|
||||
*/
|
||||
async getMetrics() {
|
||||
const cacheKey = 'metrics';
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.request('api/metrics');
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
return cached.data;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get peer information
|
||||
*/
|
||||
async getPeers() {
|
||||
const cacheKey = 'peers';
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await this.request('api/peers');
|
||||
this.cache.set(cacheKey, { data, timestamp: Date.now() });
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
return cached.data;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific key or all cache
|
||||
*/
|
||||
invalidateCache(key = null) {
|
||||
if (key) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
window.apiClient = new APIClient();
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* Main application logic for domain consensus plugin
|
||||
*/
|
||||
|
||||
// Global state
|
||||
let currentView = 'overview';
|
||||
let currentDomain = null;
|
||||
|
||||
/**
|
||||
* Initialize application
|
||||
*/
|
||||
function init() {
|
||||
// Initialize ProfileModal if available
|
||||
if (window.ProfileModal && typeof window.ProfileModal.init === 'function') {
|
||||
window.ProfileModal.init();
|
||||
}
|
||||
|
||||
// Setup hash-based view switching
|
||||
setupHashNavigation();
|
||||
|
||||
// Setup WebSocket listeners
|
||||
setupWebSocketListeners();
|
||||
|
||||
// Connect WebSocket
|
||||
window.wsClient.connect();
|
||||
|
||||
// Update sidebar stats on initial load
|
||||
if (window.utils && window.utils.updateSidebarStats) {
|
||||
window.utils.updateSidebarStats();
|
||||
}
|
||||
|
||||
// Load initial view
|
||||
if (!window.location.hash || window.location.hash === '#') {
|
||||
window.location.hash = '#overview';
|
||||
}
|
||||
|
||||
// Always handle hash change on init to set up the view
|
||||
handleHashChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup hash-based navigation
|
||||
*/
|
||||
function setupHashNavigation() {
|
||||
// Listen for hash changes
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
|
||||
// Also listen for popstate (back/forward buttons)
|
||||
window.addEventListener('popstate', handleHashChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hash change
|
||||
*/
|
||||
function handleHashChange() {
|
||||
const hash = window.location.hash.slice(1) || 'overview';
|
||||
|
||||
// Handle domain-detail hash format
|
||||
const domainDetailMatch = hash.match(/^domain-detail:(.+)$/);
|
||||
if (domainDetailMatch) {
|
||||
const domain = decodeURIComponent(domainDetailMatch[1]);
|
||||
showView('domain-detail', domain);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map hash to view name
|
||||
const viewMap = {
|
||||
'overview': 'overview',
|
||||
'domains': 'domain-list',
|
||||
'domain-detail': 'domain-detail'
|
||||
};
|
||||
|
||||
const view = viewMap[hash] || 'overview';
|
||||
showView(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different view
|
||||
*/
|
||||
function showView(view, domain = null) {
|
||||
// Check if we're already on this view (and for domain-detail, same domain)
|
||||
// Do this BEFORE updating currentView
|
||||
const isDomainDetail = view === 'domain-detail';
|
||||
const isSameView = currentView === view && (!isDomainDetail || currentDomain === domain);
|
||||
|
||||
// If we're already on this view, just ensure hash is correct and return (without changing currentView)
|
||||
if (isSameView) {
|
||||
// Map view name to hash
|
||||
const hashMap = {
|
||||
'overview': 'overview',
|
||||
'domain-list': 'domains',
|
||||
'domain-detail': 'domain-detail'
|
||||
};
|
||||
const expectedHash = isDomainDetail && domain
|
||||
? `#domain-detail:${encodeURIComponent(domain)}`
|
||||
: `#${hashMap[view] || view}`;
|
||||
|
||||
// Only update hash if it's different
|
||||
if (window.location.hash !== expectedHash) {
|
||||
window.location.hash = expectedHash;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Update current view AFTER checking
|
||||
currentView = view;
|
||||
currentDomain = domain;
|
||||
|
||||
// Hide all views and clean up charts
|
||||
document.querySelectorAll('.view-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
content.style.display = 'none';
|
||||
content.style.visibility = 'hidden';
|
||||
|
||||
// Clean up charts when hiding overview view
|
||||
if (content.id === 'overview-view' && window.overviewView) {
|
||||
window.overviewView.destroy();
|
||||
}
|
||||
// Clean up charts when hiding domain detail view
|
||||
if (content.id === 'domain-detail-view' && window.domainDetailView) {
|
||||
window.domainDetailView.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Update active tab
|
||||
document.querySelectorAll('.view-tab').forEach(tab => {
|
||||
const tabView = tab.dataset.view;
|
||||
if (tabView === view) {
|
||||
tab.classList.add('active');
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Show domain detail tab if viewing domain detail
|
||||
const domainDetailTab = document.getElementById('domainDetailTab');
|
||||
|
||||
// Map view name to hash
|
||||
const hashMap = {
|
||||
'overview': 'overview',
|
||||
'domain-list': 'domains',
|
||||
'domain-detail': 'domain-detail'
|
||||
};
|
||||
const expectedHash = view === 'domain-detail' && domain
|
||||
? `#domain-detail:${encodeURIComponent(domain)}`
|
||||
: `#${hashMap[view] || view}`;
|
||||
|
||||
// Only update hash if it's different to avoid recursive hash changes
|
||||
if (window.location.hash !== expectedHash) {
|
||||
window.location.hash = expectedHash;
|
||||
}
|
||||
|
||||
if (view === 'domain-detail') {
|
||||
if (domainDetailTab) {
|
||||
domainDetailTab.classList.remove('hidden');
|
||||
domainDetailTab.classList.add('active');
|
||||
}
|
||||
} else {
|
||||
if (domainDetailTab) {
|
||||
domainDetailTab.classList.add('hidden');
|
||||
domainDetailTab.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Show and render the selected view
|
||||
let viewElement;
|
||||
let renderFunction;
|
||||
|
||||
switch (view) {
|
||||
case 'overview':
|
||||
viewElement = document.getElementById('overview-view');
|
||||
renderFunction = () => window.overviewView.render();
|
||||
break;
|
||||
case 'domain-list':
|
||||
viewElement = document.getElementById('domain-list-view');
|
||||
renderFunction = () => window.domainListView.render();
|
||||
break;
|
||||
case 'domain-detail':
|
||||
viewElement = document.getElementById('domain-detail-view');
|
||||
if (domain) {
|
||||
renderFunction = () => window.domainDetailView.render(domain);
|
||||
} else {
|
||||
// Try to extract domain from hash
|
||||
const hashMatch = window.location.hash.match(/^#domain-detail:(.+)$/);
|
||||
if (hashMatch) {
|
||||
const hashDomain = decodeURIComponent(hashMatch[1]);
|
||||
renderFunction = () => window.domainDetailView.render(hashDomain);
|
||||
} else {
|
||||
// Fallback to domain list
|
||||
showView('domain-list');
|
||||
return;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
viewElement = document.getElementById('overview-view');
|
||||
renderFunction = () => window.overviewView.render();
|
||||
}
|
||||
|
||||
if (viewElement) {
|
||||
viewElement.classList.add('active');
|
||||
viewElement.style.display = 'flex';
|
||||
viewElement.style.visibility = 'visible';
|
||||
viewElement.style.flexDirection = 'column';
|
||||
|
||||
// Render the view after ensuring it's visible
|
||||
// Use a small timeout to ensure DOM is ready
|
||||
if (renderFunction) {
|
||||
setTimeout(() => {
|
||||
renderFunction();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show domain detail view
|
||||
*/
|
||||
function showDomainDetail(domain) {
|
||||
showView('domain-detail', domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket listeners
|
||||
*/
|
||||
function setupWebSocketListeners() {
|
||||
const ws = window.wsClient;
|
||||
|
||||
ws.on('connected', () => {
|
||||
console.log('WebSocket connected');
|
||||
});
|
||||
|
||||
ws.on('disconnected', () => {
|
||||
console.log('WebSocket disconnected');
|
||||
});
|
||||
|
||||
ws.on('init', (data) => {
|
||||
console.log('WebSocket init:', data);
|
||||
// Update sidebar stats on init
|
||||
if (window.utils && window.utils.updateSidebarStats) {
|
||||
window.utils.updateSidebarStats();
|
||||
}
|
||||
// Handle initial data if needed
|
||||
handleWebSocketUpdate(data.data || data);
|
||||
});
|
||||
|
||||
ws.on('update', (data) => {
|
||||
// Handle periodic update (fallback)
|
||||
handleWebSocketUpdate(data);
|
||||
});
|
||||
|
||||
ws.on('consensus-update', (data) => {
|
||||
// Handle real-time consensus change
|
||||
console.log('Consensus update detected:', data.changedDomains || 'all domains');
|
||||
handleWebSocketUpdate(data);
|
||||
});
|
||||
|
||||
ws.on('domain-added', (data) => {
|
||||
console.log('Domain added:', data);
|
||||
if (data.state) {
|
||||
handleWebSocketUpdate(data.state);
|
||||
} else {
|
||||
handleWebSocketUpdate(data);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('domain-removed', (data) => {
|
||||
console.log('Domain removed:', data);
|
||||
if (data.state) {
|
||||
handleWebSocketUpdate(data.state);
|
||||
} else {
|
||||
handleWebSocketUpdate(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket update
|
||||
*/
|
||||
function handleWebSocketUpdate(data) {
|
||||
// Show brief visual indicator for real-time updates (only for consensus-update type)
|
||||
if (data.changedDomains && data.changedDomains.length > 0) {
|
||||
showUpdateIndicator();
|
||||
}
|
||||
|
||||
// Always update sidebar stats on any update
|
||||
if (window.utils && window.utils.updateSidebarStats) {
|
||||
window.utils.updateSidebarStats();
|
||||
}
|
||||
|
||||
// Notify current view of update (only if view is active)
|
||||
const activeView = document.querySelector('.view-content.active');
|
||||
if (activeView) {
|
||||
switch (currentView) {
|
||||
case 'overview':
|
||||
if (window.overviewView) {
|
||||
window.overviewView.handleUpdate(data);
|
||||
}
|
||||
break;
|
||||
case 'domain-list':
|
||||
if (window.domainListView) {
|
||||
window.domainListView.handleUpdate(data);
|
||||
}
|
||||
break;
|
||||
case 'domain-detail':
|
||||
if (window.domainDetailView) {
|
||||
window.domainDetailView.handleUpdate(data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show brief visual indicator for real-time updates
|
||||
*/
|
||||
function showUpdateIndicator() {
|
||||
const statusText = document.getElementById('statusText');
|
||||
if (statusText) {
|
||||
const originalText = statusText.textContent;
|
||||
statusText.textContent = 'Updating...';
|
||||
statusText.style.opacity = '0.7';
|
||||
|
||||
setTimeout(() => {
|
||||
statusText.textContent = originalText;
|
||||
statusText.style.opacity = '1';
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Export app functions
|
||||
window.app = {
|
||||
showView,
|
||||
showDomainDetail
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Utility functions for domain consensus plugin
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a timestamp to human-readable string
|
||||
*/
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
|
||||
if (diff < 60000) {
|
||||
return `${Math.floor(diff / 1000)}s ago`;
|
||||
} else if (diff < 3600000) {
|
||||
return `${Math.floor(diff / 60000)}m ago`;
|
||||
} else if (diff < 86400000) {
|
||||
return `${Math.floor(diff / 3600000)}h ago`;
|
||||
} else {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a peer ID to shortened version
|
||||
*/
|
||||
function formatPeerId(peerId) {
|
||||
if (!peerId) return 'N/A';
|
||||
if (peerId.length <= 16) return peerId;
|
||||
return `${peerId.slice(0, 8)}...${peerId.slice(-8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a hash to shortened version
|
||||
*/
|
||||
function formatHash(hash) {
|
||||
if (!hash) return 'N/A';
|
||||
if (hash.length <= 20) return hash;
|
||||
return `${hash.slice(0, 10)}...${hash.slice(-10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge class based on consensus status
|
||||
*/
|
||||
function getStatusBadgeClass(status) {
|
||||
switch (status) {
|
||||
case 'resolved':
|
||||
return 'status-badge resolved';
|
||||
case 'insufficient_quorum':
|
||||
return 'status-badge insufficient_quorum';
|
||||
case 'tie':
|
||||
return 'status-badge tie';
|
||||
case 'no_claims':
|
||||
return 'status-badge no_claims';
|
||||
case 'error':
|
||||
return 'status-badge error';
|
||||
default:
|
||||
return 'status-badge no_claims';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status text for consensus status
|
||||
*/
|
||||
function getStatusText(status) {
|
||||
switch (status) {
|
||||
case 'resolved':
|
||||
return 'Resolved';
|
||||
case 'insufficient_quorum':
|
||||
return 'Insufficient Quorum';
|
||||
case 'tie':
|
||||
return 'Tie';
|
||||
case 'no_claims':
|
||||
return 'No Claims';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color for consensus status
|
||||
*/
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'resolved':
|
||||
return '#10b981'; // green-500
|
||||
case 'insufficient_quorum':
|
||||
return '#eab308'; // yellow-500
|
||||
case 'tie':
|
||||
return '#f97316'; // orange-500
|
||||
case 'no_claims':
|
||||
return '#6b7280'; // gray-500
|
||||
case 'error':
|
||||
return '#ef4444'; // red-500
|
||||
default:
|
||||
return '#6b7280'; // gray-500
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate quorum percentage
|
||||
*/
|
||||
function calculateQuorumPercentage(totalVotes, minVotes) {
|
||||
if (minVotes === 0) return 0;
|
||||
return Math.min(100, Math.round((totalVotes / minVotes) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
return true;
|
||||
} catch (err) {
|
||||
document.body.removeChild(textArea);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
function formatNumber(num) {
|
||||
if (num === null || num === undefined) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a progress bar element
|
||||
*/
|
||||
function createProgressBar(percentage, color = '#3b82f6') {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'w-full progress-bar-container';
|
||||
bar.innerHTML = `
|
||||
<div class="progress-bar" style="width: ${percentage}%; background-color: ${color};"></div>
|
||||
`;
|
||||
return bar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sidebar stats (can be called from any view)
|
||||
*/
|
||||
async function updateSidebarStats() {
|
||||
try {
|
||||
// Fetch overview and peers data to get stats
|
||||
const [overviewData, peersData] = await Promise.all([
|
||||
window.apiClient.getOverview(),
|
||||
window.apiClient.getPeers()
|
||||
]);
|
||||
|
||||
const stats = overviewData.stats || {};
|
||||
const activePeers = peersData.activePeers || 0;
|
||||
|
||||
const totalDomainsEl = document.getElementById('statTotalDomains');
|
||||
const resolvedEl = document.getElementById('statResolved');
|
||||
const activePeersEl = document.getElementById('statActivePeers');
|
||||
|
||||
if (totalDomainsEl) totalDomainsEl.textContent = formatNumber(stats.totalDomains || 0);
|
||||
if (resolvedEl) resolvedEl.textContent = formatNumber(stats.resolved || 0);
|
||||
if (activePeersEl) activePeersEl.textContent = formatNumber(activePeers);
|
||||
} catch (error) {
|
||||
console.error('Error updating sidebar stats:', error);
|
||||
// Set to 0 on error
|
||||
const totalDomainsEl = document.getElementById('statTotalDomains');
|
||||
const resolvedEl = document.getElementById('statResolved');
|
||||
const activePeersEl = document.getElementById('statActivePeers');
|
||||
if (totalDomainsEl) totalDomainsEl.textContent = '0';
|
||||
if (resolvedEl) resolvedEl.textContent = '0';
|
||||
if (activePeersEl) activePeersEl.textContent = '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export functions
|
||||
*/
|
||||
window.utils = {
|
||||
formatTimestamp,
|
||||
formatPeerId,
|
||||
formatHash,
|
||||
getStatusBadgeClass,
|
||||
getStatusText,
|
||||
getStatusColor,
|
||||
calculateQuorumPercentage,
|
||||
escapeHtml,
|
||||
copyToClipboard,
|
||||
debounce,
|
||||
formatNumber,
|
||||
createProgressBar,
|
||||
updateSidebarStats
|
||||
};
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* Domain detail view
|
||||
*/
|
||||
|
||||
class DomainDetailView {
|
||||
constructor() {
|
||||
this.currentDomain = null;
|
||||
this.data = null;
|
||||
this.chart = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render peer with profile (avatar + display name or peer ID)
|
||||
* @param {string} peerId - Peer ID
|
||||
* @param {object|null} profile - Profile data or null
|
||||
* @param {number} avatarSize - Avatar size in pixels (default: 32)
|
||||
* @returns {string} HTML string for peer display
|
||||
*/
|
||||
renderPeerWithProfile(peerId, profile, avatarSize = 32) {
|
||||
const displayName = profile?.displayName || null;
|
||||
const avatarHash = profile?.avatarHash || null;
|
||||
const avatarUrl = avatarHash
|
||||
? `https://global.profile/api/profile/avatar/${peerId}/${avatarSize}`
|
||||
: null;
|
||||
const peerIdShort = window.utils.formatPeerId(peerId);
|
||||
|
||||
const avatarHtml = avatarUrl
|
||||
? `<img src="${avatarUrl}" alt="${displayName || peerIdShort}" class="inline-block rounded-full mr-2" style="width: ${avatarSize}px; height: ${avatarSize}px; vertical-align: middle;" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="inline-flex items-center justify-center rounded-full mr-2" style="width: ${avatarSize}px; height: ${avatarSize}px; vertical-align: middle; display: none; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem; font-weight: 600;">
|
||||
${(displayName || peerIdShort).charAt(0).toUpperCase()}
|
||||
</div>`
|
||||
: `<div class="inline-flex items-center justify-center rounded-full mr-2" style="width: ${avatarSize}px; height: ${avatarSize}px; vertical-align: middle; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.75rem; font-weight: 600;">
|
||||
${(displayName || peerIdShort).charAt(0).toUpperCase()}
|
||||
</div>`;
|
||||
|
||||
const nameHtml = displayName
|
||||
? `<span class="font-medium">${window.utils.escapeHtml(displayName)}</span><span class="text-tertiary text-xs ml-2 font-mono">${peerIdShort}</span>`
|
||||
: `<span class="font-mono text-sm">${peerIdShort}</span>`;
|
||||
|
||||
// Create a unique ID for the avatar container
|
||||
const avatarId = `peer-avatar-${peerId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
|
||||
return `
|
||||
<div class="flex items-center" id="${avatarId}">
|
||||
${avatarHtml}
|
||||
<span>${nameHtml}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the domain detail view
|
||||
*/
|
||||
async render(domain) {
|
||||
const container = document.getElementById('domainDetailContent');
|
||||
if (!container) return;
|
||||
|
||||
this.currentDomain = domain;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner"></div>
|
||||
<p class="mt-4 text-tertiary">Loading domain details...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fetch data
|
||||
const data = await window.apiClient.getDomainDetail(domain);
|
||||
this.data = data;
|
||||
|
||||
// Update sidebar stats
|
||||
await window.utils.updateSidebarStats();
|
||||
|
||||
// Render the view
|
||||
this.renderContent(container);
|
||||
} catch (error) {
|
||||
console.error('Error loading domain details:', error);
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<p class="text-red-400">Error loading domain details: ${error.message}</p>
|
||||
<button onclick="window.app.showView('domain-list')" class="btn btn-primary mt-4">
|
||||
Back to Domain List
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the content
|
||||
*/
|
||||
renderContent(container) {
|
||||
const { domain, consensus, claims, votes } = this.data;
|
||||
const status = consensus.status || 'unknown';
|
||||
const statusText = window.utils.getStatusText(status);
|
||||
const statusClass = window.utils.getStatusBadgeClass(status);
|
||||
const statusColor = window.utils.getStatusColor(status);
|
||||
|
||||
container.innerHTML = `
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<button
|
||||
onclick="window.app.showView('domain-list')"
|
||||
class="btn btn-secondary mb-4"
|
||||
>
|
||||
← Back to Domain List
|
||||
</button>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<span class="font-mono">${window.utils.escapeHtml(domain)}</span>
|
||||
</h2>
|
||||
<span class="status-badge ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Total Votes</h3>
|
||||
<p class="text-2xl font-bold text-blue-400" data-votes-total>${consensus.totalVotes || 0}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Minimum Required</h3>
|
||||
<p class="text-2xl font-bold text-indigo-400" data-votes-min>${consensus.minVotes || 0}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Quorum Status</h3>
|
||||
<p class="text-2xl font-bold ${consensus.quorumMet ? 'text-green-400' : 'text-yellow-400'}" data-quorum-status>
|
||||
${consensus.quorumMet ? 'Met' : 'Not Met'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Active Peers</h3>
|
||||
<p class="text-2xl font-bold text-purple-400" data-active-peers>${consensus.activePeers || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quorum Progress -->
|
||||
<div class="glass-card mb-6">
|
||||
<h3 class="text-sm font-semibold text-secondary mb-3">Quorum Progress</h3>
|
||||
<div class="mb-2 flex justify-between text-sm text-tertiary">
|
||||
<span data-quorum-votes>${consensus.totalVotes || 0} / ${consensus.minVotes || 0} votes</span>
|
||||
<span data-quorum-percentage>${window.utils.calculateQuorumPercentage(consensus.totalVotes || 0, consensus.minVotes || 1)}%</span>
|
||||
</div>
|
||||
<div class="w-full progress-bar-container" style="height: 1rem;">
|
||||
<div
|
||||
class="progress-bar ${consensus.quorumMet ? 'success' : 'warning'} quorum-progress-bar"
|
||||
style="width: ${Math.min(100, window.utils.calculateQuorumPercentage(consensus.totalVotes || 0, consensus.minVotes || 1))}%; height: 100%;"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolved Information -->
|
||||
${consensus.status === 'resolved' ? `
|
||||
<div class="glass-card mb-6" id="resolved-info-section">
|
||||
<h3 class="text-sm font-semibold text-secondary mb-3">Resolved Information</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: var(--spacing-sm);">
|
||||
<div>
|
||||
<span class="text-tertiary">Resolved Claimant:</span>
|
||||
<div class="ml-2 inline-block" data-resolved-claimant>${this.renderPeerWithProfile(consensus.resolvedClaimant, this.data.profiles?.[consensus.resolvedClaimant] || null, 32)}</div>
|
||||
<button
|
||||
onclick="window.utils.copyToClipboard('${consensus.resolvedClaimant}').then(() => alert('Copied!'))"
|
||||
class="btn btn-secondary ml-2 text-xs"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-tertiary">Resolved Hash:</span>
|
||||
<span class="ml-2 font-mono" data-resolved-hash>${window.utils.formatHash(consensus.hash)}</span>
|
||||
<button
|
||||
onclick="window.utils.copyToClipboard('${consensus.hash}').then(() => alert('Copied!'))"
|
||||
class="btn btn-secondary ml-2 text-xs"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Vote Distribution Chart -->
|
||||
${this.getVoteCounts().length > 0 ? `
|
||||
<div class="glass-card mb-6">
|
||||
<h3 class="text-sm font-semibold text-secondary mb-4">Vote Distribution</h3>
|
||||
<div style="height: 300px; position: relative;">
|
||||
<canvas id="voteChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Claims Section -->
|
||||
<div class="glass-card mb-6">
|
||||
<h3 class="text-sm font-semibold text-secondary mb-4">Claims (${claims.length})</h3>
|
||||
${claims.length > 0 ? `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Claimant</th>
|
||||
<th>Hash</th>
|
||||
<th>Votes</th>
|
||||
<th>Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${claims.map(claim => {
|
||||
const voteCount = this.getVoteCountForClaimant(claim.claimant);
|
||||
const isResolved = consensus.resolvedClaimant === claim.claimant;
|
||||
const profile = claim.profile || this.data.profiles?.[claim.claimant] || null;
|
||||
return `
|
||||
<tr class="${isResolved ? 'bg-green-900/20' : ''}">
|
||||
<td>
|
||||
${this.renderPeerWithProfile(claim.claimant, profile, 32)}
|
||||
${isResolved ? '<span class="text-xs text-green-400 ml-2">(resolved)</span>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-mono text-xs">${window.utils.formatHash(claim.hash)}</div>
|
||||
</td>
|
||||
<td>${voteCount}</td>
|
||||
<td class="text-sm">${window.utils.formatTimestamp(claim.timestamp)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : `
|
||||
<p class="text-tertiary text-center py-4">No claims found</p>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Votes Section -->
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm font-semibold text-secondary mb-4">Votes (${votes.length})</h3>
|
||||
${votes.length > 0 ? `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Voter</th>
|
||||
<th>Voted For</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${votes.map(vote => {
|
||||
const isResolved = consensus.resolvedClaimant === vote.claimant;
|
||||
const voterProfile = vote.voterProfile || this.data.profiles?.[vote.voter] || null;
|
||||
const claimantProfile = vote.claimantProfile || this.data.profiles?.[vote.claimant] || null;
|
||||
return `
|
||||
<tr class="${isResolved ? 'bg-green-900/20' : ''}">
|
||||
<td>${this.renderPeerWithProfile(vote.voter, voterProfile, 32)}</td>
|
||||
<td>
|
||||
${this.renderPeerWithProfile(vote.claimant, claimantProfile, 32)}
|
||||
${isResolved ? '<span class="text-xs text-green-400 ml-2">(resolved claimant)</span>' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
` : `
|
||||
<p class="text-tertiary text-center py-4">No votes found</p>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Render chart after a brief delay
|
||||
if (this.getVoteCounts().length > 0) {
|
||||
setTimeout(() => {
|
||||
this.renderVoteChart();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Make all peer avatars clickable after rendering
|
||||
setTimeout(() => {
|
||||
this.makeAvatarsClickable();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make all peer avatars clickable to open profile modal
|
||||
*/
|
||||
makeAvatarsClickable() {
|
||||
if (!window.ProfileModal) {
|
||||
console.warn('ProfileModal not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('domainDetailContent');
|
||||
if (!container) return;
|
||||
|
||||
// Get all peer IDs from the data
|
||||
const peerIds = new Set();
|
||||
|
||||
// Add resolved claimant
|
||||
if (this.data.consensus?.resolvedClaimant) {
|
||||
peerIds.add(this.data.consensus.resolvedClaimant);
|
||||
}
|
||||
|
||||
// Add all claimants
|
||||
if (this.data.claims) {
|
||||
this.data.claims.forEach(claim => {
|
||||
if (claim.claimant) peerIds.add(claim.claimant);
|
||||
});
|
||||
}
|
||||
|
||||
// Add all voters and voted-for claimants
|
||||
if (this.data.votes) {
|
||||
this.data.votes.forEach(vote => {
|
||||
if (vote.voter) peerIds.add(vote.voter);
|
||||
if (vote.claimant) peerIds.add(vote.claimant);
|
||||
});
|
||||
}
|
||||
|
||||
// Make each peer avatar clickable
|
||||
peerIds.forEach(peerId => {
|
||||
const avatarId = `peer-avatar-${peerId.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
||||
const avatarContainer = container.querySelector(`#${avatarId}`);
|
||||
if (avatarContainer) {
|
||||
// Find the img or placeholder div
|
||||
const avatarImg = avatarContainer.querySelector('img');
|
||||
const avatarPlaceholder = avatarContainer.querySelector('div[style*="rounded-full"]');
|
||||
const clickableElement = avatarImg || avatarPlaceholder || avatarContainer;
|
||||
|
||||
if (clickableElement) {
|
||||
clickableElement.style.cursor = 'pointer';
|
||||
clickableElement.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
window.ProfileModal.open(peerId);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vote counts per claimant
|
||||
*/
|
||||
getVoteCounts() {
|
||||
if (!this.data || !this.data.consensus) return [];
|
||||
|
||||
const voteCounts = this.data.consensus.voteCounts || {};
|
||||
return Object.entries(voteCounts).map(([claimant, count]) => ({
|
||||
claimant,
|
||||
count
|
||||
})).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get vote count for a specific claimant
|
||||
*/
|
||||
getVoteCountForClaimant(claimant) {
|
||||
const voteCounts = this.data?.consensus?.voteCounts || {};
|
||||
return voteCounts[claimant] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render vote distribution chart
|
||||
*/
|
||||
renderVoteChart() {
|
||||
const voteCounts = this.getVoteCounts();
|
||||
if (voteCounts.length === 0) return;
|
||||
|
||||
const ctx = document.getElementById('voteChart');
|
||||
if (!ctx) return;
|
||||
|
||||
// Destroy existing chart
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
this.chart = null;
|
||||
}
|
||||
|
||||
const isResolved = this.data.consensus.status === 'resolved';
|
||||
const resolvedClaimant = this.data.consensus.resolvedClaimant;
|
||||
|
||||
this.chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: voteCounts.map(v => window.utils.formatPeerId(v.claimant)),
|
||||
datasets: [{
|
||||
label: 'Votes',
|
||||
data: voteCounts.map(v => v.count),
|
||||
backgroundColor: voteCounts.map(v =>
|
||||
isResolved && v.claimant === resolvedClaimant ? '#10b981' : '#3b82f6'
|
||||
)
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
color: '#d1d5db'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#d1d5db'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update vote distribution chart with new data
|
||||
*/
|
||||
updateChart() {
|
||||
if (!this.chart || !this.data) return;
|
||||
|
||||
const voteCounts = this.getVoteCounts();
|
||||
if (voteCounts.length === 0) return;
|
||||
|
||||
const isResolved = this.data.consensus.status === 'resolved';
|
||||
const resolvedClaimant = this.data.consensus.resolvedClaimant;
|
||||
|
||||
// Update chart data
|
||||
this.chart.data.labels = voteCounts.map(v => window.utils.formatPeerId(v.claimant));
|
||||
this.chart.data.datasets[0].data = voteCounts.map(v => v.count);
|
||||
this.chart.data.datasets[0].backgroundColor = voteCounts.map(v =>
|
||||
isResolved && v.claimant === resolvedClaimant ? '#10b981' : '#3b82f6'
|
||||
);
|
||||
|
||||
// Update chart smoothly without animation flash
|
||||
this.chart.update('none'); // 'none' means no animation for smoother updates
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content elements without full re-render
|
||||
*/
|
||||
updateContent() {
|
||||
if (!this.data) return;
|
||||
|
||||
const { consensus } = this.data;
|
||||
const quorumPercentage = window.utils.calculateQuorumPercentage(
|
||||
consensus.totalVotes || 0,
|
||||
consensus.minVotes || 1
|
||||
);
|
||||
|
||||
// Update vote count displays (scope to domain detail content to avoid conflicts)
|
||||
const container = document.getElementById('domainDetailContent');
|
||||
if (!container) return;
|
||||
|
||||
const totalVotesEl = container.querySelector('[data-votes-total]');
|
||||
const minVotesEl = container.querySelector('[data-votes-min]');
|
||||
const quorumStatusEl = container.querySelector('[data-quorum-status]');
|
||||
const quorumPercentageEl = container.querySelector('[data-quorum-percentage]');
|
||||
const quorumVotesEl = container.querySelector('[data-quorum-votes]');
|
||||
const activePeersEl = container.querySelector('[data-active-peers]');
|
||||
|
||||
if (totalVotesEl) totalVotesEl.textContent = consensus.totalVotes || 0;
|
||||
if (minVotesEl) minVotesEl.textContent = consensus.minVotes || 0;
|
||||
if (quorumStatusEl) {
|
||||
quorumStatusEl.textContent = consensus.quorumMet ? 'Met' : 'Not Met';
|
||||
quorumStatusEl.className = `text-2xl font-bold ${consensus.quorumMet ? 'text-green-400' : 'text-yellow-400'}`;
|
||||
}
|
||||
if (quorumPercentageEl) quorumPercentageEl.textContent = `${quorumPercentage}%`;
|
||||
if (quorumVotesEl) quorumVotesEl.textContent = `${consensus.totalVotes || 0} / ${consensus.minVotes || 0} votes`;
|
||||
if (activePeersEl) activePeersEl.textContent = consensus.activePeers || 0;
|
||||
|
||||
// Update quorum progress bar
|
||||
const progressBar = container.querySelector('.quorum-progress-bar');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${Math.min(100, quorumPercentage)}%`;
|
||||
progressBar.className = `h-4 rounded-full transition-all quorum-progress-bar ${
|
||||
consensus.quorumMet ? 'bg-green-500' : 'bg-yellow-500'
|
||||
}`;
|
||||
}
|
||||
|
||||
// Update resolved information if status changed to resolved
|
||||
if (consensus.status === 'resolved') {
|
||||
const resolvedSection = container.querySelector('#resolved-info-section');
|
||||
const resolvedClaimantEl = container.querySelector('[data-resolved-claimant]');
|
||||
const resolvedHashEl = container.querySelector('[data-resolved-hash]');
|
||||
|
||||
// Show resolved section if it was hidden
|
||||
if (resolvedSection && resolvedSection.style.display === 'none') {
|
||||
resolvedSection.style.display = 'block';
|
||||
}
|
||||
|
||||
if (resolvedClaimantEl && consensus.resolvedClaimant) {
|
||||
// Update with profile if available
|
||||
const profile = this.data.profiles?.[consensus.resolvedClaimant] || null;
|
||||
resolvedClaimantEl.innerHTML = this.renderPeerWithProfile(consensus.resolvedClaimant, profile, 32);
|
||||
// Make avatar clickable
|
||||
setTimeout(() => {
|
||||
this.makeAvatarsClickable();
|
||||
}, 100);
|
||||
}
|
||||
if (resolvedHashEl && consensus.hash) {
|
||||
resolvedHashEl.textContent = window.utils.formatHash(consensus.hash);
|
||||
}
|
||||
} else {
|
||||
// Hide resolved section if status is no longer resolved
|
||||
const resolvedSection = container.querySelector('#resolved-info-section');
|
||||
if (resolvedSection) {
|
||||
resolvedSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle updates from WebSocket
|
||||
*/
|
||||
async handleUpdate(data) {
|
||||
if (!this.currentDomain) return;
|
||||
|
||||
// Check if this domain is in the changed domains list or if we have full update
|
||||
const changedDomains = data.changedDomains;
|
||||
const shouldUpdate = !changedDomains || changedDomains.includes(this.currentDomain) || data.domains || data.consensus;
|
||||
|
||||
if (shouldUpdate) {
|
||||
try {
|
||||
// Update sidebar stats
|
||||
await window.utils.updateSidebarStats();
|
||||
|
||||
// Fetch fresh data (invalidate cache first)
|
||||
window.apiClient.invalidateCache(`domain:${this.currentDomain}`);
|
||||
const freshData = await window.apiClient.getDomainDetail(this.currentDomain);
|
||||
|
||||
// Check if consensus status changed (important for UI updates)
|
||||
const oldStatus = this.data?.consensus?.status;
|
||||
const newStatus = freshData.consensus?.status;
|
||||
const statusChanged = oldStatus !== newStatus;
|
||||
|
||||
// Update the data
|
||||
this.data = freshData;
|
||||
|
||||
// If status changed significantly, we might need a full re-render
|
||||
// Otherwise, just update the content
|
||||
if (statusChanged && (oldStatus === 'resolved' || newStatus === 'resolved')) {
|
||||
// Status changed to/from resolved - full re-render for better UX
|
||||
this.renderContent(document.getElementById('domainDetailContent'));
|
||||
} else {
|
||||
// Update chart if it exists, otherwise render it
|
||||
if (this.chart) {
|
||||
this.updateChart();
|
||||
} else {
|
||||
// If chart doesn't exist, render it after a short delay
|
||||
setTimeout(() => {
|
||||
const ctx = document.getElementById('voteChart');
|
||||
if (ctx) {
|
||||
this.renderVoteChart();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Update the content without full re-render
|
||||
this.updateContent();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating domain detail:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy chart
|
||||
*/
|
||||
destroy() {
|
||||
if (this.chart) {
|
||||
this.chart.destroy();
|
||||
this.chart = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export
|
||||
window.domainDetailView = new DomainDetailView();
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Domain list view
|
||||
*/
|
||||
|
||||
class DomainListView {
|
||||
constructor() {
|
||||
this.domains = [];
|
||||
this.filteredDomains = [];
|
||||
this.sortColumn = null;
|
||||
this.sortDirection = 'asc';
|
||||
this.filterStatus = 'all';
|
||||
this.searchQuery = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the domain list view
|
||||
*/
|
||||
async render() {
|
||||
const container = document.getElementById('domainListContent');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner"></div>
|
||||
<p class="mt-4 text-tertiary">Loading domains...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fetch data
|
||||
const data = await window.apiClient.getDomains();
|
||||
this.domains = data.domains || [];
|
||||
this.filteredDomains = [...this.domains];
|
||||
|
||||
// Update sidebar stats
|
||||
await window.utils.updateSidebarStats();
|
||||
|
||||
// Render the view
|
||||
this.renderContent(container);
|
||||
} catch (error) {
|
||||
console.error('Error loading domains:', error);
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<p class="text-red-400">Error loading domains: ${error.message}</p>
|
||||
<button onclick="location.reload()" class="btn btn-primary mt-4">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the content
|
||||
*/
|
||||
renderContent(container) {
|
||||
container.innerHTML = `
|
||||
<!-- Search and Filter Bar -->
|
||||
<div class="mb-4 flex gap-4" style="width: 100%;">
|
||||
<div class="flex-1" style="min-width: 0;">
|
||||
<input
|
||||
type="text"
|
||||
id="domainSearchInput"
|
||||
placeholder="Search domains..."
|
||||
class="input"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div style="width: 12rem; flex-shrink: 0;">
|
||||
<select
|
||||
id="domainStatusFilter"
|
||||
class="select"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="insufficient_quorum">Insufficient Quorum</option>
|
||||
<option value="tie">Tie</option>
|
||||
<option value="no_claims">No Claims</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domain Table -->
|
||||
<div class="table-container">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-column="domain">
|
||||
Domain
|
||||
<span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th data-column="status">
|
||||
Status
|
||||
<span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th data-column="claimant">
|
||||
Resolved Claimant
|
||||
<span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th data-column="votes">
|
||||
Votes
|
||||
<span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th data-column="quorum">
|
||||
Quorum
|
||||
<span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th data-column="peers">
|
||||
Active Peers
|
||||
<span class="sort-indicator"></span>
|
||||
</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domainTableBody">
|
||||
${this.renderTableRows()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${this.filteredDomains.length === 0 ? `
|
||||
<div class="p-6 text-center text-tertiary">
|
||||
No domains found
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render table rows
|
||||
*/
|
||||
renderTableRows() {
|
||||
if (this.filteredDomains.length === 0) {
|
||||
return '<tr><td colspan="7" class="px-4 py-8 text-center text-tertiary">No domains found</td></tr>';
|
||||
}
|
||||
|
||||
return this.filteredDomains.map(domain => {
|
||||
const consensus = domain.consensus || {};
|
||||
const status = consensus.status || 'unknown';
|
||||
const statusText = window.utils.getStatusText(status);
|
||||
const statusClass = window.utils.getStatusBadgeClass(status);
|
||||
const totalVotes = consensus.totalVotes || 0;
|
||||
const minVotes = consensus.minVotes || 0;
|
||||
const quorumMet = consensus.quorumMet || false;
|
||||
const quorumPercentage = window.utils.calculateQuorumPercentage(totalVotes, minVotes);
|
||||
const claimant = domain.consensus?.resolvedClaimant ? window.utils.formatPeerId(domain.consensus.resolvedClaimant) : 'N/A';
|
||||
const activePeers = domain.consensus?.activePeers || 0;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="font-medium">
|
||||
<span class="font-mono">${window.utils.escapeHtml(domain.domain)}</span>
|
||||
${domain.isLocal ? '<span class="ml-2 text-xs text-blue-400">(local)</span>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge ${statusClass}">${statusText}</span>
|
||||
</td>
|
||||
<td class="font-mono text-sm">${claimant}</td>
|
||||
<td>
|
||||
${totalVotes} / ${minVotes}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 progress-bar-container">
|
||||
<div class="progress-bar ${quorumMet ? 'success' : 'warning'}" style="width: ${Math.min(100, quorumPercentage)}%"></div>
|
||||
</div>
|
||||
<span class="text-xs text-tertiary">${quorumPercentage}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${activePeers}</td>
|
||||
<td class="text-right">
|
||||
<button
|
||||
onclick="window.domainListView.viewDomain('${window.utils.escapeHtml(domain.domain)}')"
|
||||
class="btn btn-primary text-sm"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Search input
|
||||
const searchInput = document.getElementById('domainSearchInput');
|
||||
if (searchInput) {
|
||||
const debouncedSearch = window.utils.debounce(() => {
|
||||
this.searchQuery = searchInput.value.toLowerCase();
|
||||
this.applyFilters();
|
||||
}, 300);
|
||||
searchInput.addEventListener('input', debouncedSearch);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
const statusFilter = document.getElementById('domainStatusFilter');
|
||||
if (statusFilter) {
|
||||
statusFilter.addEventListener('change', (e) => {
|
||||
this.filterStatus = e.target.value;
|
||||
this.applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
// Sortable columns
|
||||
document.querySelectorAll('[data-column]').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const column = header.dataset.column;
|
||||
if (this.sortColumn === column) {
|
||||
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
this.sortColumn = column;
|
||||
this.sortDirection = 'asc';
|
||||
}
|
||||
this.applySort();
|
||||
this.updateSortIndicators();
|
||||
const tbody = document.getElementById('domainTableBody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = this.renderTableRows();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters
|
||||
*/
|
||||
applyFilters() {
|
||||
this.filteredDomains = this.domains.filter(domain => {
|
||||
// Search filter
|
||||
if (this.searchQuery && !domain.domain.toLowerCase().includes(this.searchQuery)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (this.filterStatus !== 'all') {
|
||||
const status = domain.consensus?.status || 'unknown';
|
||||
if (status !== this.filterStatus) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Apply sort
|
||||
this.applySort();
|
||||
|
||||
// Re-render table
|
||||
const tbody = document.getElementById('domainTableBody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = this.renderTableRows();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply sort
|
||||
*/
|
||||
applySort() {
|
||||
if (!this.sortColumn) return;
|
||||
|
||||
this.filteredDomains.sort((a, b) => {
|
||||
let aVal, bVal;
|
||||
|
||||
switch (this.sortColumn) {
|
||||
case 'domain':
|
||||
aVal = a.domain.toLowerCase();
|
||||
bVal = b.domain.toLowerCase();
|
||||
break;
|
||||
case 'status':
|
||||
aVal = a.consensus?.status || 'unknown';
|
||||
bVal = b.consensus?.status || 'unknown';
|
||||
break;
|
||||
case 'claimant':
|
||||
aVal = a.consensus?.resolvedClaimant || '';
|
||||
bVal = b.consensus?.resolvedClaimant || '';
|
||||
break;
|
||||
case 'votes':
|
||||
aVal = a.consensus?.totalVotes || 0;
|
||||
bVal = b.consensus?.totalVotes || 0;
|
||||
break;
|
||||
case 'quorum':
|
||||
const aQuorum = window.utils.calculateQuorumPercentage(a.consensus?.totalVotes || 0, a.consensus?.minVotes || 1);
|
||||
const bQuorum = window.utils.calculateQuorumPercentage(b.consensus?.totalVotes || 0, b.consensus?.minVotes || 1);
|
||||
aVal = aQuorum;
|
||||
bVal = bQuorum;
|
||||
break;
|
||||
case 'peers':
|
||||
aVal = a.consensus?.activePeers || 0;
|
||||
bVal = b.consensus?.activePeers || 0;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aVal < bVal) return this.sortDirection === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return this.sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sort indicators
|
||||
*/
|
||||
updateSortIndicators() {
|
||||
document.querySelectorAll('[data-column] .sort-indicator').forEach(indicator => {
|
||||
indicator.textContent = '';
|
||||
});
|
||||
|
||||
if (this.sortColumn) {
|
||||
const header = document.querySelector(`[data-column="${this.sortColumn}"]`);
|
||||
if (header) {
|
||||
const indicator = header.querySelector('.sort-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = this.sortDirection === 'asc' ? ' ▲' : ' ▼';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View domain details
|
||||
*/
|
||||
viewDomain(domain) {
|
||||
// Switch to domain detail view
|
||||
window.app.showDomainDetail(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle updates from WebSocket
|
||||
*/
|
||||
async handleUpdate(data) {
|
||||
// Always update on any consensus-related change
|
||||
if (data.domains || data.consensus || data.changedDomains) {
|
||||
try {
|
||||
// Fetch fresh data
|
||||
window.apiClient.invalidateCache('domains');
|
||||
const response = await window.apiClient.getDomains();
|
||||
this.domains = response.domains || [];
|
||||
|
||||
// Update sidebar stats
|
||||
await window.utils.updateSidebarStats();
|
||||
|
||||
// Apply current filters and sort (this already updates the table body)
|
||||
this.applyFilters();
|
||||
} catch (error) {
|
||||
console.error('Error updating domain list:', error);
|
||||
// Fall back to full render on error
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export
|
||||
window.domainListView = new DomainListView();
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Overview dashboard view
|
||||
*/
|
||||
|
||||
class OverviewView {
|
||||
constructor() {
|
||||
this.charts = {};
|
||||
this.data = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the overview view
|
||||
*/
|
||||
async render() {
|
||||
const container = document.getElementById('overviewContent');
|
||||
if (!container) return;
|
||||
|
||||
// Destroy existing charts before re-rendering
|
||||
this.destroy();
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<div class="spinner"></div>
|
||||
<p class="mt-4 text-tertiary">Loading overview...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fetch data
|
||||
const [overviewData, metricsData, peersData] = await Promise.all([
|
||||
window.apiClient.getOverview(),
|
||||
window.apiClient.getMetrics(),
|
||||
window.apiClient.getPeers()
|
||||
]);
|
||||
|
||||
this.data = { overview: overviewData, metrics: metricsData, peers: peersData };
|
||||
|
||||
// Render the view
|
||||
this.renderContent(container);
|
||||
|
||||
// Update stats in sidebar
|
||||
this.updateSidebarStats();
|
||||
} catch (error) {
|
||||
console.error('Error loading overview:', error);
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-12">
|
||||
<p class="text-red-400">Error loading overview: ${error.message}</p>
|
||||
<button onclick="location.reload()" class="btn btn-primary mt-4">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the content
|
||||
*/
|
||||
renderContent(container) {
|
||||
const { overview, metrics, peers } = this.data;
|
||||
const stats = overview.stats;
|
||||
|
||||
container.innerHTML = `
|
||||
<!-- Key Statistics Cards -->
|
||||
<div class="mb-6">
|
||||
<div class="grid grid-cols-5 gap-4">
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Total Domains</h3>
|
||||
<p class="text-2xl font-bold text-indigo-400">${window.utils.formatNumber(stats.totalDomains)}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Resolved</h3>
|
||||
<p class="text-2xl font-bold text-green-400">${window.utils.formatNumber(stats.resolved)}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Insufficient Quorum</h3>
|
||||
<p class="text-2xl font-bold text-yellow-400">${window.utils.formatNumber(stats.insufficientQuorum)}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Ties</h3>
|
||||
<p class="text-2xl font-bold text-orange-400">${window.utils.formatNumber(stats.tie)}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Active Peers</h3>
|
||||
<p class="text-2xl font-bold text-blue-400">${window.utils.formatNumber(peers.activePeers)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Cards -->
|
||||
<div class="mb-6 grid grid-cols-4 gap-4">
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Total Resolutions</h3>
|
||||
<p class="text-2xl font-bold text-purple-400">${window.utils.formatNumber(metrics.metrics?.resolutions || 0)}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Quorum Failures</h3>
|
||||
<p class="text-2xl font-bold text-yellow-400">${window.utils.formatNumber(metrics.metrics?.quorumFailures || 0)}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Total Votes</h3>
|
||||
<p class="text-2xl font-bold text-blue-400">${window.utils.formatNumber(metrics.metrics?.totalVotes || 0)}</p>
|
||||
</div>
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm text-secondary mb-1">Avg Votes/Domain</h3>
|
||||
<p class="text-2xl font-bold text-indigo-400">${(metrics.metrics?.avgVotesPerDomain || 0).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Consensus Status Pie Chart -->
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm font-semibold text-secondary mb-4">Consensus Status Distribution</h3>
|
||||
<div style="height: 300px; position: relative;">
|
||||
<canvas id="statusChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Consensus Metrics Bar Chart -->
|
||||
<div class="glass-card">
|
||||
<h3 class="text-sm font-semibold text-secondary mb-4">Consensus Metrics</h3>
|
||||
<div style="height: 300px; position: relative;">
|
||||
<canvas id="metricsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Render charts after a brief delay to ensure DOM is ready and container is visible
|
||||
setTimeout(() => {
|
||||
const container = document.getElementById('overviewContent');
|
||||
const statusChart = document.getElementById('statusChart');
|
||||
const metricsChart = document.getElementById('metricsChart');
|
||||
|
||||
// Only render charts if the container is visible and canvas elements exist
|
||||
if (container && container.offsetParent !== null && statusChart && metricsChart) {
|
||||
this.renderCharts();
|
||||
} else {
|
||||
// Retry after a longer delay if container isn't visible yet
|
||||
setTimeout(() => {
|
||||
const retryStatusChart = document.getElementById('statusChart');
|
||||
const retryMetricsChart = document.getElementById('metricsChart');
|
||||
if (retryStatusChart && retryMetricsChart) {
|
||||
this.renderCharts();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing charts with new data
|
||||
*/
|
||||
updateCharts() {
|
||||
if (!this.data) return;
|
||||
|
||||
const { overview, metrics } = this.data;
|
||||
const stats = overview.stats;
|
||||
|
||||
// Update Status Pie Chart
|
||||
if (this.charts.status) {
|
||||
this.charts.status.data.datasets[0].data = [
|
||||
stats.resolved,
|
||||
stats.insufficientQuorum,
|
||||
stats.tie,
|
||||
stats.noClaims,
|
||||
stats.error
|
||||
];
|
||||
this.charts.status.update('none'); // 'none' means no animation for smoother updates
|
||||
}
|
||||
|
||||
// Update Metrics Bar Chart
|
||||
if (this.charts.metrics) {
|
||||
const metricsData = metrics.metrics || {};
|
||||
this.charts.metrics.data.datasets[0].data = [
|
||||
metricsData.resolutions || 0,
|
||||
metricsData.quorumFailures || 0,
|
||||
metricsData.ties || 0,
|
||||
metricsData.validationFailures || 0
|
||||
];
|
||||
this.charts.metrics.update('none');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render charts
|
||||
*/
|
||||
renderCharts() {
|
||||
// Destroy existing charts first to prevent duplicates
|
||||
if (this.charts.status) {
|
||||
this.charts.status.destroy();
|
||||
this.charts.status = null;
|
||||
}
|
||||
if (this.charts.metrics) {
|
||||
this.charts.metrics.destroy();
|
||||
this.charts.metrics = null;
|
||||
}
|
||||
|
||||
const { overview, metrics } = this.data;
|
||||
const stats = overview.stats;
|
||||
|
||||
// Status Pie Chart
|
||||
const statusCtx = document.getElementById('statusChart');
|
||||
if (statusCtx && !this.charts.status) {
|
||||
this.charts.status = new Chart(statusCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: ['Resolved', 'Insufficient Quorum', 'Tie', 'No Claims', 'Error'],
|
||||
datasets: [{
|
||||
data: [
|
||||
stats.resolved,
|
||||
stats.insufficientQuorum,
|
||||
stats.tie,
|
||||
stats.noClaims,
|
||||
stats.error
|
||||
],
|
||||
backgroundColor: [
|
||||
'#10b981', // green
|
||||
'#eab308', // yellow
|
||||
'#f97316', // orange
|
||||
'#6b7280', // gray
|
||||
'#ef4444' // red
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#d1d5db'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Metrics Bar Chart
|
||||
const metricsCtx = document.getElementById('metricsChart');
|
||||
if (metricsCtx && !this.charts.metrics) {
|
||||
const metricsData = metrics.metrics || {};
|
||||
this.charts.metrics = new Chart(metricsCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['Resolutions', 'Quorum Failures', 'Ties', 'Validation Failures'],
|
||||
datasets: [{
|
||||
label: 'Count',
|
||||
data: [
|
||||
metricsData.resolutions || 0,
|
||||
metricsData.quorumFailures || 0,
|
||||
metricsData.ties || 0,
|
||||
metricsData.validationFailures || 0
|
||||
],
|
||||
backgroundColor: [
|
||||
'#10b981',
|
||||
'#eab308',
|
||||
'#f97316',
|
||||
'#ef4444'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#d1d5db'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#d1d5db'
|
||||
},
|
||||
grid: {
|
||||
color: '#374151'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sidebar stats
|
||||
*/
|
||||
updateSidebarStats() {
|
||||
if (this.data && this.data.overview && this.data.peers) {
|
||||
const { overview, peers } = this.data;
|
||||
const stats = overview.stats;
|
||||
|
||||
const totalDomainsEl = document.getElementById('statTotalDomains');
|
||||
const resolvedEl = document.getElementById('statResolved');
|
||||
const activePeersEl = document.getElementById('statActivePeers');
|
||||
|
||||
if (totalDomainsEl) totalDomainsEl.textContent = window.utils.formatNumber(stats.totalDomains);
|
||||
if (resolvedEl) resolvedEl.textContent = window.utils.formatNumber(stats.resolved);
|
||||
if (activePeersEl) activePeersEl.textContent = window.utils.formatNumber(peers.activePeers);
|
||||
} else {
|
||||
// Fallback to shared function if data not available
|
||||
window.utils.updateSidebarStats();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle updates from WebSocket
|
||||
*/
|
||||
async handleUpdate(data) {
|
||||
// Always update on any consensus-related change
|
||||
if (data.overview || data.metrics || data.peers || data.domains || data.changedDomains) {
|
||||
// If charts already exist, just update the data instead of re-rendering
|
||||
if (this.charts.status || this.charts.metrics) {
|
||||
try {
|
||||
// Fetch fresh data
|
||||
const [overviewData, metricsData, peersData] = await Promise.all([
|
||||
window.apiClient.getOverview(),
|
||||
window.apiClient.getMetrics(),
|
||||
window.apiClient.getPeers()
|
||||
]);
|
||||
|
||||
this.data = { overview: overviewData, metrics: metricsData, peers: peersData };
|
||||
|
||||
// Update sidebar stats
|
||||
this.updateSidebarStats();
|
||||
|
||||
// Update charts without destroying them
|
||||
this.updateCharts();
|
||||
} catch (error) {
|
||||
console.error('Error updating overview:', error);
|
||||
}
|
||||
} else {
|
||||
// If charts don't exist yet, do a full render
|
||||
window.apiClient.invalidateCache();
|
||||
await this.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy charts
|
||||
*/
|
||||
destroy() {
|
||||
Object.values(this.charts).forEach(chart => {
|
||||
if (chart && chart.destroy) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
this.charts = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Export
|
||||
window.overviewView = new OverviewView();
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* WebSocket client for real-time consensus updates
|
||||
*/
|
||||
|
||||
class ConsensusWebSocket {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectDelay = 1000;
|
||||
this.listeners = new Map();
|
||||
this.isConnected = false;
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected');
|
||||
this.updateStatus(true);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
this.handleMessage(message);
|
||||
} catch (err) {
|
||||
console.error('Error parsing WebSocket message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.emit('error', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.isConnected = false;
|
||||
this.updateStatus(false);
|
||||
this.emit('disconnected');
|
||||
this.attemptReconnect();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error connecting to WebSocket:', err);
|
||||
this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
*/
|
||||
handleMessage(message) {
|
||||
const { type, data } = message;
|
||||
|
||||
switch (type) {
|
||||
case 'init':
|
||||
this.emit('init', data);
|
||||
break;
|
||||
case 'update':
|
||||
this.emit('update', data);
|
||||
break;
|
||||
case 'consensus-update':
|
||||
// Real-time consensus change detected
|
||||
this.emit('consensus-update', data);
|
||||
break;
|
||||
case 'domain-added':
|
||||
this.emit('domain-added', data);
|
||||
break;
|
||||
case 'domain-removed':
|
||||
this.emit('domain-removed', data);
|
||||
break;
|
||||
default:
|
||||
// Unknown message type
|
||||
console.debug('Unknown WebSocket message type:', type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to server
|
||||
*/
|
||||
send(type, data = {}) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type, ...data }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect
|
||||
*/
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
|
||||
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status indicator
|
||||
*/
|
||||
updateStatus(connected) {
|
||||
const indicator = document.getElementById('statusIndicator');
|
||||
const statusText = document.getElementById('statusText');
|
||||
|
||||
if (indicator && statusText) {
|
||||
if (connected) {
|
||||
indicator.classList.remove('disconnected');
|
||||
statusText.textContent = 'Connected';
|
||||
} else {
|
||||
indicator.classList.add('disconnected');
|
||||
statusText.textContent = 'Disconnected - Reconnecting...';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add event listener
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener
|
||||
*/
|
||||
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 to listeners
|
||||
*/
|
||||
emit(event, data) {
|
||||
if (this.listeners.has(event)) {
|
||||
this.listeners.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (err) {
|
||||
console.error(`Error in WebSocket event listener for ${event}:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.isConnected = false;
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
window.wsClient = new ConsensusWebSocket();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Domain Consensus",
|
||||
"short_name": "Consensus",
|
||||
"description": "Advanced interface for viewing and analyzing consensus within the P2NS network",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "developer"],
|
||||
"lang": "en"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user