This commit is contained in:
Raven Scott
2025-12-17 20:05:50 -05:00
commit 742e27d3f7
276 changed files with 89838 additions and 0 deletions
+246
View File
@@ -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)
+14
View File
@@ -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"
}
+599
View File
@@ -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();}">&times;</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>
+170
View File
@@ -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();
+343
View File
@@ -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"
}