592 lines
21 KiB
JavaScript
592 lines
21 KiB
JavaScript
/**
|
|
* 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();
|
|
|