268 lines
12 KiB
Vue
268 lines
12 KiB
Vue
<template>
|
|
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
|
|
<div class="flex items-start gap-4 mb-4 shrink-0">
|
|
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
|
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<div class="flex-1">
|
|
<div class="flex items-start justify-between gap-4 mb-2">
|
|
<h2 class="text-xl font-semibold text-white">F*ck IPs Mesh</h2>
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex items-center gap-2" :title="statusLabel">
|
|
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
|
|
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
|
|
title="Seed anchors"
|
|
aria-label="Open FIPS seed anchors settings"
|
|
@click="showAnchorsModal = true"
|
|
>
|
|
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p class="text-white/70 text-sm mb-4">Fast Nostr-keyed mesh routing</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Seed anchors modal — operator-editable list of peers this node
|
|
dials to bootstrap the mesh. Tucked behind the gear so it
|
|
doesn't crowd the card but is still one click away. Close
|
|
button and layout mirror the What's New modal (and the rest
|
|
of the app) so it feels like a first-class modal. -->
|
|
<Teleport to="body">
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="showAnchorsModal"
|
|
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
|
|
@click="showAnchorsModal = false"
|
|
>
|
|
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
|
<div
|
|
class="relative z-10 max-w-xl w-full"
|
|
style="max-height: 90vh; overflow-y: auto"
|
|
@click.stop
|
|
>
|
|
<FipsSeedAnchorsCard closable @close="showAnchorsModal = false" />
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 shrink-0">
|
|
<div class="p-3 bg-white/5 rounded-lg">
|
|
<p class="text-xs text-white/60 mb-1">Daemon version</p>
|
|
<p class="text-sm font-medium text-white break-all">{{ status.version || '—' }}</p>
|
|
<p v-if="!status.installed" class="text-xs text-white/40 mt-1">Package not installed</p>
|
|
</div>
|
|
<div class="p-3 bg-white/5 rounded-lg">
|
|
<div class="flex items-center justify-between gap-2 mb-1">
|
|
<p class="text-xs text-white/60">FIPS npub</p>
|
|
<button
|
|
v-if="status.npub"
|
|
type="button"
|
|
class="text-xs text-white/60 hover:text-white transition-colors flex items-center gap-1"
|
|
:title="copied ? 'Copied!' : 'Copy full npub to clipboard'"
|
|
@click="copyNpub"
|
|
>
|
|
<svg v-if="!copied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
|
<svg v-else class="w-3.5 h-3.5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
|
|
<span :class="{ 'text-green-400': copied }">{{ copied ? 'Copied' : 'Copy' }}</span>
|
|
</button>
|
|
</div>
|
|
<p class="text-sm font-mono text-white break-all select-all">{{ npubDisplay }}</p>
|
|
<p v-if="!status.key_present && status.npub" class="text-xs text-white/40 mt-1">Upstream key (not seed-derived)</p>
|
|
<p v-else-if="!status.key_present" class="text-xs text-white/40 mt-1">Unlock seed to derive archipelago-managed key</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Anchor status: always full-width to keep desktop layout tidy -->
|
|
<div v-if="status.service_active" class="p-3 bg-white/5 rounded-lg mb-3 shrink-0">
|
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
|
<div class="flex items-center gap-2 text-xs">
|
|
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
|
|
<span class="text-white/70">Anchor:</span>
|
|
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
|
|
{{ status.anchor_connected ? 'connected' : 'not reached' }}
|
|
</span>
|
|
<span class="text-white/40">·</span>
|
|
<span class="text-white/60">{{ status.authenticated_peer_count ?? 0 }} peer{{ (status.authenticated_peer_count ?? 0) === 1 ? '' : 's' }}</span>
|
|
</div>
|
|
<button
|
|
v-if="!status.anchor_connected"
|
|
type="button"
|
|
class="text-xs px-3 py-1.5 rounded-md bg-orange-400/15 hover:bg-orange-400/25 text-orange-200 disabled:opacity-60 transition-colors"
|
|
:disabled="reconnecting"
|
|
@click="reconnectAnchor"
|
|
>
|
|
{{ reconnecting ? 'Reconnecting…' : 'Reconnect' }}
|
|
</button>
|
|
</div>
|
|
<p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug">
|
|
No known anchor is currently an authenticated peer. DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until one reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache. Add a cluster-local anchor in Seed Anchors if the public one is unreachable.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
|
|
|
|
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
|
|
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Starting…' : 'Start' }}</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import { safeClipboardWrite } from '@/views/web5/utils'
|
|
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
|
|
|
interface FipsStatus {
|
|
installed: boolean
|
|
version: string | null
|
|
service_state: string
|
|
upstream_service_state: string
|
|
service_active: boolean
|
|
key_present: boolean
|
|
npub: string | null
|
|
authenticated_peer_count?: number
|
|
anchor_connected?: boolean
|
|
}
|
|
|
|
const status = ref<FipsStatus>({
|
|
installed: false,
|
|
version: null,
|
|
service_state: 'unknown',
|
|
upstream_service_state: 'unknown',
|
|
service_active: false,
|
|
key_present: false,
|
|
npub: null,
|
|
authenticated_peer_count: 0,
|
|
anchor_connected: false,
|
|
})
|
|
const installing = ref(false)
|
|
const reconnecting = ref(false)
|
|
const statusMessage = ref('')
|
|
const statusIsError = ref(false)
|
|
const copied = ref(false)
|
|
const showAnchorsModal = ref(false)
|
|
|
|
async function copyNpub() {
|
|
if (!status.value.npub) return
|
|
try {
|
|
await safeClipboardWrite(status.value.npub)
|
|
copied.value = true
|
|
setTimeout(() => { copied.value = false }, 2000)
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
flash(`Copy failed: ${msg}`, true)
|
|
}
|
|
}
|
|
|
|
const statusLabel = computed(() => {
|
|
if (!status.value.installed) return 'not installed'
|
|
// Active takes precedence: the daemon may be running from its own upstream
|
|
// key on a legacy/dev node that doesn't have a seed-derived archipelago key.
|
|
if (status.value.service_active) return 'active'
|
|
if (!status.value.key_present) return 'awaiting seed'
|
|
return status.value.service_state
|
|
})
|
|
|
|
const statusDotColor = computed(() => {
|
|
if (status.value.service_active) return 'bg-green-400'
|
|
if (!status.value.installed || !status.value.key_present) return 'bg-white/30'
|
|
return 'bg-orange-400'
|
|
})
|
|
|
|
const statusTextColor = computed(() => {
|
|
if (status.value.service_active) return 'text-green-400'
|
|
if (!status.value.installed || !status.value.key_present) return 'text-white/50'
|
|
return 'text-orange-400'
|
|
})
|
|
|
|
const npubDisplay = computed(() => {
|
|
const n = status.value.npub
|
|
if (!n) return '—'
|
|
return n.length > 20 ? `${n.slice(0, 12)}…${n.slice(-6)}` : n
|
|
})
|
|
|
|
function flash(msg: string, isError = false) {
|
|
statusMessage.value = msg
|
|
statusIsError.value = isError
|
|
setTimeout(() => { statusMessage.value = '' }, 6000)
|
|
}
|
|
|
|
async function loadStatus() {
|
|
try {
|
|
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
|
|
} catch (e) {
|
|
if (import.meta.env.DEV) console.warn('fips.status failed', e)
|
|
}
|
|
}
|
|
|
|
async function installAndActivate() {
|
|
installing.value = true
|
|
try {
|
|
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
|
flash('FIPS started')
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
flash(`Start failed: ${msg}`, true)
|
|
} finally {
|
|
installing.value = false
|
|
}
|
|
}
|
|
|
|
// Restart the FIPS daemon and wait for the anchor bootstrap window.
|
|
// The backend runs a proper recovery sequence (stop → start → wait →
|
|
// classify) and returns a structured diagnostic we can show the user
|
|
// instead of a generic "still unreachable".
|
|
async function reconnectAnchor() {
|
|
reconnecting.value = true
|
|
try {
|
|
const res = await rpcClient.call<{
|
|
recovered: boolean
|
|
likely_cause: string
|
|
hint: string
|
|
after: FipsStatus
|
|
}>({ method: 'fips.reconnect', timeout: 60_000 })
|
|
// Update the card with the post-reconnect status returned by the
|
|
// backend — avoids an extra status fetch race.
|
|
status.value = { ...status.value, ...res.after }
|
|
if (res.recovered) {
|
|
flash('Anchor reconnected.')
|
|
} else if (res.likely_cause === 'connected') {
|
|
// Already connected, not a "recovery" per se.
|
|
flash('Anchor is reachable.')
|
|
} else {
|
|
// Surface the backend's diagnostic hint verbatim — it's been
|
|
// written for the fleet reader.
|
|
flash(res.hint || 'Reconnect finished but anchor is still unreachable.', true)
|
|
}
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
flash(`Reconnect failed: ${msg}`, true)
|
|
} finally {
|
|
reconnecting.value = false
|
|
}
|
|
}
|
|
|
|
// Poll instead of one-shot: a long-lived page (the kiosk especially) was
|
|
// stuck showing whatever anchor state existed at mount time forever.
|
|
let statusInterval: ReturnType<typeof setInterval> | null = null
|
|
onMounted(() => {
|
|
loadStatus()
|
|
statusInterval = setInterval(loadStatus, 15000)
|
|
})
|
|
onUnmounted(() => {
|
|
if (statusInterval) clearInterval(statusInterval)
|
|
})
|
|
</script>
|