285 lines
14 KiB
Vue
285 lines
14 KiB
Vue
<template>
|
|
<!-- Decentralized Web Node (DWN) -->
|
|
<div class="glass-card p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
|
|
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-white">{{ t('web5.decentralizedWebNode') }}</h2>
|
|
<p class="text-xs text-white/60">{{ t('web5.dwnDescription') }}</p>
|
|
</div>
|
|
</div>
|
|
<router-link v-if="dwnInstalled && dwnRunning" to="/apps/dwn" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
|
{{ t('web5.manageDwn') }}
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- DWN not installed or not running -->
|
|
<div v-if="!dwnInstalled || !dwnRunning" class="py-6 text-center">
|
|
<p class="text-white/60 text-sm mb-4">
|
|
{{ !dwnInstalled ? 'The DWN container is not installed.' : 'The DWN container is not running.' }}
|
|
{{ !dwnInstalled ? 'Install it from the App Store to enable decentralized data storage and sync.' : 'Start it from the App Store to enable decentralized data storage and sync.' }}
|
|
</p>
|
|
<router-link to="/dashboard/marketplace" class="glass-button px-4 py-2 rounded-lg text-sm font-medium inline-flex items-center gap-2 no-underline">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
|
|
</svg>
|
|
Open App Store
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- Status (only shown when DWN is installed and running) -->
|
|
<template v-if="dwnInstalled && dwnRunning">
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
<div class="text-xs text-white/50 mb-1">{{ t('common.status') }}</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-2 h-2 rounded-full" :class="dwnStatus?.running ? 'bg-green-400' : 'bg-red-400'"></div>
|
|
<span class="text-sm text-white font-medium">{{ dwnStatus?.running ? t('common.running') : t('common.stopped') }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
<div class="text-xs text-white/50 mb-1">Sync</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="w-2 h-2 rounded-full" :class="{
|
|
'bg-green-400': dwnSyncStatus === 'synced',
|
|
'bg-yellow-400 animate-pulse': dwnSyncStatus === 'syncing',
|
|
'bg-red-400': dwnSyncStatus === 'error',
|
|
'bg-white/30': dwnSyncStatus === 'idle'
|
|
}"></div>
|
|
<span class="text-sm text-white font-medium capitalize">{{ dwnSyncStatus }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
<div class="text-xs text-white/50 mb-1">Storage</div>
|
|
<span class="text-sm text-white font-medium">{{ formatDwnStorage }}</span>
|
|
</div>
|
|
<div class="bg-white/5 rounded-lg p-3">
|
|
<div class="text-xs text-white/50 mb-1">Messages</div>
|
|
<span class="text-sm text-white font-medium">{{ dwnStatus?.message_count ?? 0 }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Protocols -->
|
|
<div class="mb-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="text-xs text-white/50">Registered Protocols ({{ dwnProtocols.length }})</div>
|
|
<button @click="showRegisterProtocol = !showRegisterProtocol" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
|
|
{{ showRegisterProtocol ? 'Cancel' : '+ Register' }}
|
|
</button>
|
|
</div>
|
|
<div v-if="showRegisterProtocol" class="bg-white/5 rounded-lg p-3 mb-3">
|
|
<div class="flex gap-2 items-end">
|
|
<div class="flex-1">
|
|
<label class="text-xs text-white/50 block mb-1">Protocol URI</label>
|
|
<input v-model="newProtocolUri" type="text" placeholder="https://example.com/protocol" class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50" />
|
|
</div>
|
|
<label class="flex items-center gap-1.5 text-xs text-white/60 cursor-pointer whitespace-nowrap pb-1.5">
|
|
<input v-model="newProtocolPublished" type="checkbox" class="rounded bg-black/30 border-white/20" />
|
|
Published
|
|
</label>
|
|
<button @click="registerDwnProtocol" :disabled="registeringProtocol || !newProtocolUri.trim()" class="glass-button glass-button-sm px-3 rounded-lg text-xs font-medium disabled:opacity-50 whitespace-nowrap">
|
|
{{ registeringProtocol ? 'Registering...' : 'Register' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="dwnProtocols.length" class="flex flex-wrap gap-2">
|
|
<div v-for="proto in dwnProtocols" :key="proto.protocol" class="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/15 border border-blue-500/20 text-xs text-blue-300 group">
|
|
<span>{{ proto.protocol }}</span>
|
|
<span v-if="proto.published" class="text-green-400/60" title="Published">•</span>
|
|
<button @click="removeDwnProtocol(proto.protocol)" :disabled="removingProtocol === proto.protocol" class="opacity-0 group-hover:opacity-100 text-red-400/60 hover:text-red-400 transition-all ml-1" title="Remove">
|
|
×
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-xs text-white/30 italic">No protocols registered</div>
|
|
</div>
|
|
|
|
<!-- Sync Targets -->
|
|
<div v-if="dwnStatus?.peer_sync_targets?.length" class="mb-4">
|
|
<div class="text-xs text-white/50 mb-2">Peer Sync Targets</div>
|
|
<div class="space-y-1">
|
|
<div v-for="target in dwnStatus.peer_sync_targets" :key="target" class="flex items-center gap-2 text-xs text-white/70 bg-white/5 rounded-lg px-3 py-2">
|
|
<svg class="w-3 h-3 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" /></svg>
|
|
<span class="truncate font-mono">{{ target }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Messages Browser -->
|
|
<div class="mb-4">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="text-xs text-white/50">Messages</div>
|
|
<button @click="toggleDwnMessages" class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
|
|
{{ showDwnMessages ? 'Hide' : 'Browse' }}
|
|
</button>
|
|
</div>
|
|
<div v-if="showDwnMessages">
|
|
<div v-if="loadingDwnMessages && dwnMessages.length === 0" class="text-xs text-white/40 py-4 text-center">Loading messages...</div>
|
|
<div v-else-if="dwnMessages.length === 0" class="text-xs text-white/30 italic py-2">No messages stored</div>
|
|
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
|
<div v-if="loadingDwnMessages" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
|
|
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
Refreshing messages...
|
|
</div>
|
|
<div v-for="msg in dwnMessages" :key="msg.record_id" class="bg-white/5 rounded-lg p-3">
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-xs font-mono text-white/50 truncate max-w-[200px]" :title="msg.record_id">{{ (msg.record_id || '').slice(0, 8) }}...</span>
|
|
<span class="text-xs text-white/40">{{ new Date(msg.date_created).toLocaleString() }}</span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2 text-xs">
|
|
<span class="text-white/70">{{ msg.author }}</span>
|
|
<span v-if="msg.descriptor.protocol" class="text-blue-300/80">{{ msg.descriptor.protocol }}</span>
|
|
<span v-if="msg.descriptor.schema" class="text-purple-300/80">{{ msg.descriptor.schema }}</span>
|
|
</div>
|
|
<div v-if="msg.data" class="mt-1 text-xs text-white/40 font-mono truncate">{{ JSON.stringify(msg.data).slice(0, 120) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Last Sync & Actions -->
|
|
<div class="flex items-center justify-between pt-3 border-t border-white/10">
|
|
<div class="text-xs text-white/40">
|
|
{{ dwnStatus?.last_sync ? `Last sync: ${new Date(dwnStatus.last_sync).toLocaleString()}` : 'Never synced' }}
|
|
</div>
|
|
<button @click="syncDWNs" :disabled="syncingDWNs || !dwnStatus?.running" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2 disabled:opacity-50">
|
|
<svg class="w-4 h-4" :class="{ 'animate-spin': syncingDWNs }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
{{ syncingDWNs ? t('web5.syncing') : t('web5.syncNow') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import { useAppStore } from '@/stores/app'
|
|
import { PackageState } from '@/types/api'
|
|
import type { DwnStatusData, DwnProtocol, DwnMessageEntry } from './types'
|
|
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
|
|
const dwnStatus = ref<DwnStatusData | null>(null)
|
|
const dwnSyncStatus = ref<'synced' | 'syncing' | 'error' | 'idle'>('idle')
|
|
const dwnInstalled = computed(() => !!appStore.packages['dwn'])
|
|
const dwnRunning = computed(() => appStore.packages['dwn']?.state === PackageState.Running)
|
|
const syncingDWNs = ref(false)
|
|
const dwnProtocols = ref<DwnProtocol[]>([])
|
|
const dwnMessages = ref<DwnMessageEntry[]>([])
|
|
const showDwnMessages = ref(false)
|
|
const loadingDwnMessages = ref(false)
|
|
const showRegisterProtocol = ref(false)
|
|
const newProtocolUri = ref('')
|
|
const newProtocolPublished = ref(false)
|
|
const registeringProtocol = ref(false)
|
|
const removingProtocol = ref<string | null>(null)
|
|
|
|
const formatDwnStorage = computed(() => {
|
|
if (!dwnStatus.value) return '0 B'
|
|
const bytes = dwnStatus.value.storage_bytes
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
})
|
|
|
|
async function loadDwnStatus() {
|
|
try {
|
|
const res = await rpcClient.call<DwnStatusData>({ method: 'dwn.status' })
|
|
dwnStatus.value = res
|
|
dwnSyncStatus.value = (res.sync_status as 'synced' | 'syncing' | 'error' | 'idle') || 'idle'
|
|
} catch {
|
|
dwnStatus.value = null
|
|
dwnSyncStatus.value = 'idle'
|
|
}
|
|
}
|
|
|
|
async function syncDWNs() {
|
|
syncingDWNs.value = true
|
|
dwnSyncStatus.value = 'syncing'
|
|
try {
|
|
const res = await rpcClient.call<{ sync_status: string; last_sync: string; messages_synced: number }>({ method: 'dwn.sync' })
|
|
dwnSyncStatus.value = (res.sync_status as 'synced' | 'syncing' | 'error' | 'idle') || 'synced'
|
|
await loadDwnStatus()
|
|
} catch {
|
|
dwnSyncStatus.value = 'error'
|
|
} finally {
|
|
syncingDWNs.value = false
|
|
}
|
|
}
|
|
|
|
async function loadDwnProtocols() {
|
|
try {
|
|
const res = await rpcClient.call<{ protocols: DwnProtocol[] }>({ method: 'dwn.list-protocols' })
|
|
dwnProtocols.value = res.protocols || []
|
|
} catch {
|
|
dwnProtocols.value = []
|
|
}
|
|
}
|
|
|
|
async function registerDwnProtocol() {
|
|
if (registeringProtocol.value || !newProtocolUri.value.trim()) return
|
|
registeringProtocol.value = true
|
|
try {
|
|
await rpcClient.call({ method: 'dwn.register-protocol', params: { protocol: newProtocolUri.value.trim(), published: newProtocolPublished.value } })
|
|
newProtocolUri.value = ''
|
|
newProtocolPublished.value = false
|
|
showRegisterProtocol.value = false
|
|
await loadDwnProtocols()
|
|
await loadDwnStatus()
|
|
} catch {
|
|
if (import.meta.env.DEV) console.error('Failed to register protocol')
|
|
} finally {
|
|
registeringProtocol.value = false
|
|
}
|
|
}
|
|
|
|
async function removeDwnProtocol(protocol: string) {
|
|
removingProtocol.value = protocol
|
|
try {
|
|
await rpcClient.call({ method: 'dwn.remove-protocol', params: { protocol } })
|
|
await loadDwnProtocols()
|
|
await loadDwnStatus()
|
|
} catch {
|
|
if (import.meta.env.DEV) console.error('Failed to remove protocol')
|
|
} finally {
|
|
removingProtocol.value = null
|
|
}
|
|
}
|
|
|
|
async function toggleDwnMessages() {
|
|
showDwnMessages.value = !showDwnMessages.value
|
|
if (showDwnMessages.value) {
|
|
await loadDwnMessages()
|
|
}
|
|
}
|
|
|
|
async function loadDwnMessages() {
|
|
const hadMessages = dwnMessages.value.length > 0
|
|
loadingDwnMessages.value = true
|
|
try {
|
|
const res = await rpcClient.call<{ messages: DwnMessageEntry[]; count: number }>({ method: 'dwn.query-messages', params: { limit: 50 } })
|
|
dwnMessages.value = res.messages || []
|
|
} catch {
|
|
if (!hadMessages) dwnMessages.value = []
|
|
} finally {
|
|
loadingDwnMessages.value = false
|
|
}
|
|
}
|
|
|
|
defineExpose({ loadDwnStatus, loadDwnProtocols, loadDwnMessages, dwnMessages, showDwnMessages })
|
|
</script>
|