All checks were successful
Build Archipelago ISO (dev) / build-iso (push) Successful in 34m25s
Re-adds the TCP transport (`0.0.0.0:8443`) to the rendered fips.yaml alongside UDP. Upstream factory default enables both; we had inadvertently narrowed to UDP-only when the yaml rewriter was last touched, which left nodes unable to reach fips.v0l.io (the public anchor only answers on TCP right now) or talk across networks that block UDP. Backend startup now compares the installed yaml against the current rendered schema and restarts whichever fips unit is active when they differ — so OTA-upgrading nodes pick up the new transport without anyone having to click Reconnect. Dropped the earlier plan to auto-add federated peers as seed anchors: invites don't carry a FIPS-reachable IP:port, and once TCP reconnects the public mesh, federated peers become npub-routable without needing a seed entry. Seed Anchors modal cleanup: replaced malformed header icon with a three-arc broadcast glyph, and the close button now matches the What's New modal (embedded in the card header, same icon + hover style) instead of the earlier floating off-design placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
259 lines
11 KiB
Vue
259 lines
11 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">FIPS 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 ? 'Installing…' : 'Activate' }}</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, 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 installed and activated')
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
flash(`Install 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
|
|
}
|
|
}
|
|
|
|
onMounted(loadStatus)
|
|
</script>
|