Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
<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 ? 'Installing…' : 'Activate' }}</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 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
Reference in New Issue
Block a user