Frontend: - Add runtime configuration service for deployment-time API URL injection - Create docker-entrypoint.sh to generate config.json from environment variables - Update ApiService, ApprovalService, and DocumentViewer to use RuntimeConfigService - Add APP_INITIALIZER to load runtime config before app starts Backend: - Fix init-blockchain.js to properly quote mnemonic phrases in .env file - Improve docker-entrypoint.sh with health checks and better error handling Docker: - Add API_BASE_URL environment variable to frontend container - Update docker-compose.yml with clear documentation for remote deployment - Reorganize .env.example with clear categories (REQUIRED FOR REMOTE, PRODUCTION, AUTO-GENERATED) Workflow fixes: - Fix DepartmentApproval interface to match backend schema - Fix stage transformation for 0-indexed stageOrder - Fix workflow list to show correct stage count from definition.stages Cleanup: - Move development artifacts to .trash directory - Remove root-level package.json (was only for utility scripts) - Add .trash/ to .gitignore
837 lines
22 KiB
TypeScript
837 lines
22 KiB
TypeScript
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: `
|
|
<mat-card class="explorer-card">
|
|
<!-- Header -->
|
|
<div class="card-header">
|
|
<div class="header-left">
|
|
<div class="header-icon">
|
|
<mat-icon>link</mat-icon>
|
|
</div>
|
|
<div class="header-text">
|
|
<h3>Blockchain Explorer</h3>
|
|
<p class="subtitle">Real-time network activity</p>
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<div class="live-indicator" [class.active]="isLive()">
|
|
<span class="pulse"></span>
|
|
<span class="label">{{ isLive() ? 'Live' : 'Paused' }}</span>
|
|
</div>
|
|
<button
|
|
mat-icon-button
|
|
(click)="toggleLive()"
|
|
[matTooltip]="isLive() ? 'Pause updates' : 'Resume updates'"
|
|
>
|
|
<mat-icon>{{ isLive() ? 'pause' : 'play_arrow' }}</mat-icon>
|
|
</button>
|
|
<button mat-icon-button (click)="refresh()" [disabled]="loading()" matTooltip="Refresh">
|
|
<mat-icon>refresh</mat-icon>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Row -->
|
|
<div class="stats-row">
|
|
<div class="stat-item">
|
|
<div class="stat-value">{{ latestBlock() | number }}</div>
|
|
<div class="stat-label">Latest Block</div>
|
|
</div>
|
|
<div class="stat-divider"></div>
|
|
<div class="stat-item">
|
|
<div class="stat-value">{{ totalTransactions() | number }}</div>
|
|
<div class="stat-label">Total Txns</div>
|
|
</div>
|
|
<div class="stat-divider"></div>
|
|
<div class="stat-item">
|
|
<div class="stat-value">{{ pendingTransactions() }}</div>
|
|
<div class="stat-label">Pending</div>
|
|
</div>
|
|
<div class="stat-divider"></div>
|
|
<div class="stat-item">
|
|
<div class="stat-value status" [class]="networkStatus().toLowerCase()">
|
|
<mat-icon>{{ getNetworkIcon() }}</mat-icon>
|
|
{{ networkStatus() }}
|
|
</div>
|
|
<div class="stat-label">Network</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<mat-tab-group class="explorer-tabs" animationDuration="200ms">
|
|
<!-- Blocks Tab -->
|
|
<mat-tab>
|
|
<ng-template mat-tab-label>
|
|
<mat-icon>view_module</mat-icon>
|
|
Blocks
|
|
</ng-template>
|
|
<div class="tab-content">
|
|
@if (loading()) {
|
|
<div class="loading-state">
|
|
<mat-spinner diameter="32"></mat-spinner>
|
|
</div>
|
|
} @else if (blocks().length === 0) {
|
|
<div class="empty-state">
|
|
<mat-icon>view_module</mat-icon>
|
|
<p>No blocks yet</p>
|
|
</div>
|
|
} @else {
|
|
<div class="list-container">
|
|
@for (block of blocks(); track block.blockNumber) {
|
|
<div class="list-item block-item" (click)="viewBlock(block)">
|
|
<div class="item-icon block-icon">
|
|
<mat-icon>view_module</mat-icon>
|
|
</div>
|
|
<div class="item-content">
|
|
<div class="item-header">
|
|
<span class="block-number">#{{ block.blockNumber | number }}</span>
|
|
<span class="item-time">{{ getRelativeTime(block.timestamp) }}</span>
|
|
</div>
|
|
<div class="item-details">
|
|
<span class="tx-count">
|
|
<mat-icon>receipt_long</mat-icon>
|
|
{{ block.transactionCount }} txns
|
|
</span>
|
|
<span class="hash" (click)="copyHash(block.hash, $event)">
|
|
{{ truncateHash(block.hash) }}
|
|
<mat-icon class="copy-icon">content_copy</mat-icon>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</mat-tab>
|
|
|
|
<!-- Transactions Tab -->
|
|
<mat-tab>
|
|
<ng-template mat-tab-label>
|
|
<mat-icon>receipt_long</mat-icon>
|
|
Transactions
|
|
</ng-template>
|
|
<div class="tab-content">
|
|
@if (loading()) {
|
|
<div class="loading-state">
|
|
<mat-spinner diameter="32"></mat-spinner>
|
|
</div>
|
|
} @else if (transactions().length === 0) {
|
|
<div class="empty-state">
|
|
<mat-icon>receipt_long</mat-icon>
|
|
<p>No transactions yet</p>
|
|
</div>
|
|
} @else {
|
|
<div class="list-container">
|
|
@for (tx of transactions(); track tx.id) {
|
|
<div class="list-item tx-item" (click)="viewTransaction(tx)">
|
|
<div class="item-icon" [class]="'status-' + tx.status.toLowerCase()">
|
|
@switch (tx.status) {
|
|
@case ('CONFIRMED') {
|
|
<mat-icon>check_circle</mat-icon>
|
|
}
|
|
@case ('PENDING') {
|
|
<mat-icon>schedule</mat-icon>
|
|
}
|
|
@case ('FAILED') {
|
|
<mat-icon>error</mat-icon>
|
|
}
|
|
}
|
|
</div>
|
|
<div class="item-content">
|
|
<div class="item-header">
|
|
<span class="tx-hash" (click)="copyHash(tx.txHash, $event)">
|
|
{{ truncateHash(tx.txHash) }}
|
|
<mat-icon class="copy-icon">content_copy</mat-icon>
|
|
</span>
|
|
<mat-chip class="status-chip" [class]="tx.status.toLowerCase()">
|
|
{{ tx.status }}
|
|
</mat-chip>
|
|
</div>
|
|
<div class="item-details">
|
|
<span class="tx-type">
|
|
<mat-icon>{{ getTxTypeIcon(tx.type) }}</mat-icon>
|
|
{{ formatTxType(tx.type) }}
|
|
</span>
|
|
<span class="item-time">{{ getRelativeTime(tx.timestamp) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</mat-tab>
|
|
</mat-tab-group>
|
|
|
|
<!-- Footer -->
|
|
@if (showViewAll) {
|
|
<div class="card-footer">
|
|
<a mat-button color="primary" routerLink="/admin/transactions">
|
|
View All Transactions
|
|
<mat-icon>arrow_forward</mat-icon>
|
|
</a>
|
|
</div>
|
|
}
|
|
</mat-card>
|
|
`,
|
|
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<BlockDto[]>([]);
|
|
readonly transactions = signal<BlockchainTransactionDto[]>([]);
|
|
|
|
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<void> {
|
|
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<string, string> = {
|
|
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',
|
|
});
|
|
}
|
|
}
|