import { Component, OnInit, OnDestroy, inject, signal, Input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTabsModule } from '@angular/material/tabs'; import { MatChipsModule } from '@angular/material/chips'; import { MatDialogModule, MatDialog } from '@angular/material/dialog'; import { RouterModule } from '@angular/router'; import { Clipboard } from '@angular/cdk/clipboard'; import { interval, Subscription } from 'rxjs'; import { ApiService } from '../../../core/services/api.service'; import { BlockDto, BlockchainTransactionDto } from '../../../api/models'; import { BlockDetailDialogComponent } from './block-detail-dialog.component'; import { TransactionDetailDialogComponent } from './transaction-detail-dialog.component'; @Component({ selector: 'app-blockchain-explorer-mini', standalone: true, imports: [ CommonModule, MatCardModule, MatIconModule, MatButtonModule, MatTooltipModule, MatProgressSpinnerModule, MatTabsModule, MatChipsModule, MatDialogModule, RouterModule, ], template: `
link

Blockchain Explorer

Real-time network activity

{{ isLive() ? 'Live' : 'Paused' }}
{{ latestBlock() | number }}
Latest Block
{{ totalTransactions() | number }}
Total Txns
{{ pendingTransactions() }}
Pending
{{ getNetworkIcon() }} {{ networkStatus() }}
Network
view_module Blocks
@if (loading()) {
} @else if (blocks().length === 0) {
view_module

No blocks yet

} @else {
@for (block of blocks(); track block.blockNumber) {
view_module
#{{ block.blockNumber | number }} {{ getRelativeTime(block.timestamp) }}
receipt_long {{ block.transactionCount }} txns {{ truncateHash(block.hash) }} content_copy
}
}
receipt_long Transactions
@if (loading()) {
} @else if (transactions().length === 0) {
receipt_long

No transactions yet

} @else {
@for (tx of transactions(); track tx.id) {
@switch (tx.status) { @case ('CONFIRMED') { check_circle } @case ('PENDING') { schedule } @case ('FAILED') { error } }
{{ truncateHash(tx.txHash) }} content_copy {{ tx.status }}
{{ getTxTypeIcon(tx.type) }} {{ formatTxType(tx.type) }} {{ getRelativeTime(tx.timestamp) }}
}
}
@if (showViewAll) { }
`, styles: [` .explorer-card { border-radius: 16px !important; overflow: hidden; } .card-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69) 0%, var(--dbim-blue-mid, #2563EB) 100%); color: white; } .header-left { display: flex; align-items: center; gap: 16px; } .header-icon { width: 48px; height: 48px; border-radius: 12px; background: rgba(255, 255, 255, 0.15); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(10px); mat-icon { font-size: 28px; width: 28px; height: 28px; } } .header-text h3 { margin: 0; font-size: 1.125rem; font-weight: 600; color: white !important; } .header-text .subtitle { margin: 4px 0 0; font-size: 0.8rem; color: rgba(255, 255, 255, 0.9) !important; } .header-right { display: flex; align-items: center; gap: 8px; button { color: white; } } .live-indicator { display: flex; align-items: center; gap: 6px; padding: 6px 12px; background: rgba(255, 255, 255, 0.15); border-radius: 20px; font-size: 0.75rem; font-weight: 500; .pulse { width: 8px; height: 8px; border-radius: 50%; background: #fff; opacity: 0.5; } &.active .pulse { background: #4ade80; opacity: 1; animation: pulse 1.5s ease-in-out infinite; } } @keyframes pulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.5); opacity: 0.5; } } .stats-row { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: var(--dbim-linen, #EBEAEA); border-bottom: 1px solid rgba(0, 0, 0, 0.08); } .stat-item { text-align: center; flex: 1; } .stat-value { font-size: 1.25rem; font-weight: 700; color: var(--dbim-brown, #150202); &.status { display: flex; align-items: center; justify-content: center; gap: 4px; font-size: 0.85rem; mat-icon { font-size: 16px; width: 16px; height: 16px; } &.healthy { color: var(--dbim-success, #198754); } &.degraded { color: var(--dbim-warning, #FFC107); } &.down { color: var(--dbim-error, #DC3545); } } } .stat-label { font-size: 0.7rem; color: var(--dbim-grey-2, #8E8E8E); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; } .stat-divider { width: 1px; height: 32px; background: rgba(0, 0, 0, 0.12); } .explorer-tabs { ::ng-deep { .mat-mdc-tab-labels { background: #fafafa; border-bottom: 1px solid rgba(0, 0, 0, 0.08); } .mat-mdc-tab { min-width: 120px; .mdc-tab__content { gap: 8px; } mat-icon { font-size: 18px; width: 18px; height: 18px; } } } } .tab-content { min-height: 240px; max-height: 320px; overflow-y: auto; } .loading-state, .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; color: var(--dbim-grey-2, #8E8E8E); mat-icon { font-size: 48px; width: 48px; height: 48px; margin-bottom: 12px; opacity: 0.5; } p { margin: 0; font-size: 0.9rem; } } .list-container { padding: 8px 0; } .list-item { display: flex; align-items: center; gap: 12px; padding: 12px 24px; cursor: pointer; transition: background-color 0.2s; &:hover { background: rgba(0, 0, 0, 0.03); } &:not(:last-child) { border-bottom: 1px solid rgba(0, 0, 0, 0.05); } } .item-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; mat-icon { font-size: 20px; width: 20px; height: 20px; } &.block-icon { background: linear-gradient(135deg, var(--dbim-blue-dark, #1D0A69), var(--dbim-blue-mid, #2563EB)); color: white; } &.status-confirmed { background: rgba(25, 135, 84, 0.1); color: var(--dbim-success, #198754); } &.status-pending { background: rgba(37, 99, 235, 0.1); color: var(--dbim-blue-mid, #2563EB); } &.status-failed { background: rgba(220, 53, 69, 0.1); color: var(--dbim-error, #DC3545); } } .item-content { flex: 1; min-width: 0; } .item-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 4px; } .block-number { font-weight: 600; color: var(--dbim-blue-mid, #2563EB); font-size: 0.95rem; } .tx-hash { font-family: 'Monaco', 'Consolas', monospace; font-size: 0.8rem; color: var(--dbim-blue-dark, #1D0A69); display: flex; align-items: center; gap: 4px; .copy-icon { font-size: 14px; width: 14px; height: 14px; opacity: 0; transition: opacity 0.2s; } &:hover .copy-icon { opacity: 0.6; } } .item-time { font-size: 0.75rem; color: var(--dbim-grey-2, #8E8E8E); white-space: nowrap; } .item-details { display: flex; align-items: center; gap: 16px; font-size: 0.8rem; color: var(--dbim-grey-2, #8E8E8E); } .tx-count, .tx-type { display: flex; align-items: center; gap: 4px; mat-icon { font-size: 14px; width: 14px; height: 14px; } } .hash { font-family: 'Monaco', 'Consolas', monospace; font-size: 0.7rem; padding: 2px 6px; background: rgba(0, 0, 0, 0.04); border-radius: 4px; display: flex; align-items: center; gap: 4px; cursor: pointer; .copy-icon { font-size: 12px; width: 12px; height: 12px; opacity: 0; transition: opacity 0.2s; } &:hover { background: rgba(0, 0, 0, 0.08); .copy-icon { opacity: 0.6; } } } .status-chip { font-size: 0.65rem; min-height: 20px; padding: 0 8px; &.confirmed { background: rgba(25, 135, 84, 0.1) !important; color: var(--dbim-success, #198754) !important; } &.pending { background: rgba(37, 99, 235, 0.1) !important; color: var(--dbim-blue-mid, #2563EB) !important; } &.failed { background: rgba(220, 53, 69, 0.1) !important; color: var(--dbim-error, #DC3545) !important; } } .card-footer { display: flex; justify-content: center; padding: 12px; border-top: 1px solid rgba(0, 0, 0, 0.08); background: #fafafa; a { mat-icon { margin-left: 4px; font-size: 18px; width: 18px; height: 18px; } } } `], }) export class BlockchainExplorerMiniComponent implements OnInit, OnDestroy { private readonly api = inject(ApiService); private readonly clipboard = inject(Clipboard); private readonly dialog = inject(MatDialog); private refreshSubscription?: Subscription; @Input() showViewAll = true; @Input() refreshInterval = 10000; // 10 seconds readonly loading = signal(true); readonly isLive = signal(true); readonly latestBlock = signal(0); readonly totalTransactions = signal(0); readonly pendingTransactions = signal(0); readonly networkStatus = signal<'HEALTHY' | 'DEGRADED' | 'DOWN'>('HEALTHY'); readonly blocks = signal([]); readonly transactions = signal([]); ngOnInit(): void { this.loadData(); this.startAutoRefresh(); } ngOnDestroy(): void { this.stopAutoRefresh(); } private startAutoRefresh(): void { if (this.refreshSubscription) return; this.refreshSubscription = interval(this.refreshInterval).subscribe(() => { if (this.isLive()) { this.loadData(false); } }); } private stopAutoRefresh(): void { this.refreshSubscription?.unsubscribe(); this.refreshSubscription = undefined; } toggleLive(): void { this.isLive.update(v => !v); } refresh(): void { this.loadData(); } async loadData(showLoading = true): Promise { if (showLoading) { this.loading.set(true); } try { // Fetch blocks const blocksResponse = await this.api.get<{ data: BlockDto[] }>( '/admin/blockchain/blocks', { limit: 5 } ).toPromise(); if (blocksResponse?.data && blocksResponse.data.length > 0) { this.blocks.set(blocksResponse.data); this.latestBlock.set(blocksResponse.data[0].blockNumber); } else { // No real blocks - use mock data for demo this.loadMockBlocks(); } // Fetch transactions const txResponse = await this.api.get<{ data: BlockchainTransactionDto[]; total: number; }>('/admin/blockchain/transactions', { limit: 5 }).toPromise(); if (txResponse) { this.transactions.set(txResponse.data); this.totalTransactions.set(txResponse.total); this.pendingTransactions.set( txResponse.data.filter(tx => tx.status === 'PENDING').length ); } this.networkStatus.set('HEALTHY'); } catch (error) { console.error('Failed to load blockchain data:', error); this.networkStatus.set('DOWN'); // Use mock data for demo this.loadMockData(); } finally { this.loading.set(false); } } private loadMockBlocks(): void { const mockBlocks: BlockDto[] = Array.from({ length: 5 }, (_, i) => ({ blockNumber: 1628260 - i, hash: `0x${this.generateRandomHash()}`, parentHash: `0x${this.generateRandomHash()}`, timestamp: new Date(Date.now() - i * 15000).toISOString(), transactionCount: Math.floor(Math.random() * 5) + 1, gasUsed: Math.floor(Math.random() * 500000) + 100000, gasLimit: 15000000, size: Math.floor(Math.random() * 50000) + 10000, })); this.blocks.set(mockBlocks); this.latestBlock.set(mockBlocks[0].blockNumber); } private loadMockData(): void { // Mock blocks this.loadMockBlocks(); const mockTx: BlockchainTransactionDto[] = [ { id: '1', txHash: `0x${this.generateRandomHash()}`, type: 'LICENSE_MINT', status: 'CONFIRMED', blockNumber: 12345, timestamp: new Date(Date.now() - 30000).toISOString(), data: {}, }, { id: '2', txHash: `0x${this.generateRandomHash()}`, type: 'DOCUMENT_HASH', status: 'CONFIRMED', blockNumber: 12344, timestamp: new Date(Date.now() - 60000).toISOString(), data: {}, }, { id: '3', txHash: `0x${this.generateRandomHash()}`, type: 'APPROVAL_RECORD', status: 'PENDING', timestamp: new Date(Date.now() - 90000).toISOString(), data: {}, }, { id: '4', txHash: `0x${this.generateRandomHash()}`, type: 'LICENSE_TRANSFER', status: 'CONFIRMED', blockNumber: 12343, timestamp: new Date(Date.now() - 120000).toISOString(), data: {}, }, { id: '5', txHash: `0x${this.generateRandomHash()}`, type: 'REVOCATION', status: 'FAILED', timestamp: new Date(Date.now() - 180000).toISOString(), data: {}, }, ]; this.transactions.set(mockTx); this.totalTransactions.set(1234); this.pendingTransactions.set(3); this.networkStatus.set('HEALTHY'); } private generateRandomHash(): string { return Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16) ).join(''); } truncateHash(hash: string): string { if (!hash || hash.length <= 18) return hash || ''; return `${hash.substring(0, 10)}...${hash.substring(hash.length - 6)}`; } getRelativeTime(timestamp: string): string { if (!timestamp) return ''; const now = new Date(); const time = new Date(timestamp); const diffSeconds = Math.floor((now.getTime() - time.getTime()) / 1000); if (diffSeconds < 60) return `${diffSeconds}s ago`; if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`; if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`; return `${Math.floor(diffSeconds / 86400)}d ago`; } getNetworkIcon(): string { switch (this.networkStatus()) { case 'HEALTHY': return 'check_circle'; case 'DEGRADED': return 'warning'; case 'DOWN': return 'error'; } } getTxTypeIcon(type: string): string { if (!type) return 'receipt_long'; const icons: Record = { LICENSE_MINT: 'verified', DOCUMENT_HASH: 'fingerprint', APPROVAL_RECORD: 'approval', LICENSE_TRANSFER: 'swap_horiz', REVOCATION: 'block', }; return icons[type] || 'receipt_long'; } formatTxType(type: string): string { if (!type) return 'Unknown'; return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); } copyHash(hash: string, event: Event): void { event.stopPropagation(); this.clipboard.copy(hash); } viewBlock(block: BlockDto): void { this.dialog.open(BlockDetailDialogComponent, { data: block, width: '600px', maxHeight: '90vh', panelClass: 'blockchain-detail-dialog', }); } viewTransaction(tx: BlockchainTransactionDto): void { this.dialog.open(TransactionDetailDialogComponent, { data: tx, width: '600px', maxHeight: '90vh', panelClass: 'blockchain-detail-dialog', }); } }