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>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1 relative">
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="absolute top-4 right-4 p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors z-10"
|
||||
aria-label="Close"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-start gap-4 mb-4 shrink-0" :class="{ 'pr-10': closable }">
|
||||
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
|
||||
<!-- Radio/broadcast icon — three concentric arcs radiating from a
|
||||
dot. Reads as mesh, signal, anchor-reaching-peers. -->
|
||||
<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="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0" />
|
||||
<circle cx="12" cy="18" r="1.25" fill="currentColor" stroke="none" />
|
||||
</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 Seed Anchors</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors disabled:opacity-60"
|
||||
:disabled="applying"
|
||||
:title="applying ? 'Applying…' : 'Re-dial every anchor in the list'"
|
||||
@click="applyAll"
|
||||
>
|
||||
{{ applying ? 'Applying…' : 'Apply now' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm mb-4">
|
||||
Peers this node dials to bootstrap the FIPS mesh. A cluster with its own anchors doesn't depend on the global public anchor — if one is down, the next seeds the DHT instead.
|
||||
</p>
|
||||
</div>
|
||||
</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="anchors.length === 0" class="p-4 rounded-lg bg-white/5 text-sm text-white/60 mb-3">
|
||||
<p>No seed anchors configured. The daemon will fall back to whatever the upstream FIPS build dials on its own — usually the single public anchor, which is fine until it isn't.</p>
|
||||
<p class="mt-2 text-white/50">Add at least one known-reachable peer (e.g. your VPS or a home node with port-forwarded UDP 8668) to make this cluster self-anchoring.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2 mb-3">
|
||||
<li v-for="a in anchors" :key="a.npub" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">{{ a.label || 'Unlabeled anchor' }}</p>
|
||||
<p class="text-xs text-white/60 font-mono break-all">{{ a.npub.slice(0, 20) }}…{{ a.npub.slice(-8) }}</p>
|
||||
<p class="text-xs text-white/50 mt-0.5">{{ a.address }} · {{ a.transport }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
|
||||
:title="`Remove ${a.label || a.npub.slice(0, 12)}`"
|
||||
@click="removeAnchor(a.npub)"
|
||||
>Remove</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-auto pt-3 border-t border-white/10 shrink-0" @submit.prevent="addAnchor">
|
||||
<label class="flex flex-col gap-1 sm:col-span-2">
|
||||
<span class="text-xs text-white/60">Anchor npub</span>
|
||||
<input v-model="draft.npub" type="text" placeholder="npub1…" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-white/60">Address (host:port)</span>
|
||||
<input v-model="draft.address" type="text" placeholder="185.18.221.160:8443" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs text-white/60">Transport</span>
|
||||
<select v-model="draft.transport" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none">
|
||||
<option value="tcp">tcp (public anchor / over internet)</option>
|
||||
<option value="udp">udp (same LAN)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 sm:col-span-2">
|
||||
<span class="text-xs text-white/60">Label (optional)</span>
|
||||
<input v-model="draft.label" type="text" placeholder="Public anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
|
||||
</label>
|
||||
<button type="submit" class="sm:col-span-2 min-h-[44px] glass-button rounded-lg text-sm font-medium disabled:opacity-60" :disabled="adding || !draft.npub || !draft.address">{{ adding ? 'Adding…' : 'Add anchor' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
defineProps<{ closable?: boolean }>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
interface SeedAnchor {
|
||||
npub: string
|
||||
address: string
|
||||
transport: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ApplyResult {
|
||||
npub: string
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
const anchors = ref<SeedAnchor[]>([])
|
||||
const adding = ref(false)
|
||||
const applying = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const statusIsError = ref(false)
|
||||
|
||||
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'transport' | 'label'>>({
|
||||
npub: '',
|
||||
address: '',
|
||||
transport: 'tcp',
|
||||
label: '',
|
||||
})
|
||||
|
||||
function flash(msg: string, isError = false) {
|
||||
statusMessage.value = msg
|
||||
statusIsError.value = isError
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
|
||||
anchors.value = res.seed_anchors
|
||||
} catch (e: unknown) {
|
||||
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addAnchor() {
|
||||
if (!draft.npub.trim() || !draft.address.trim()) return
|
||||
adding.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[]; apply: ApplyResult[] }>({
|
||||
method: 'fips.add-seed-anchor',
|
||||
params: {
|
||||
npub: draft.npub.trim(),
|
||||
address: draft.address.trim(),
|
||||
transport: draft.transport,
|
||||
label: draft.label.trim(),
|
||||
},
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
draft.npub = ''
|
||||
draft.address = ''
|
||||
draft.label = ''
|
||||
const applied = res.apply.find(r => r.ok)
|
||||
flash(applied ? 'Anchor added and dialed.' : 'Anchor saved — dial failed, will retry on the next apply cycle.', !applied)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Add failed: ${msg}`, true)
|
||||
} finally {
|
||||
adding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAnchor(npub: string) {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
|
||||
method: 'fips.remove-seed-anchor',
|
||||
params: { npub },
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
flash('Anchor removed.')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Remove failed: ${msg}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAll() {
|
||||
applying.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ applied: number; results: ApplyResult[] }>({ method: 'fips.apply-seed-anchors' })
|
||||
const ok = res.results.filter(r => r.ok).length
|
||||
flash(`${ok} of ${res.applied} anchor${res.applied === 1 ? '' : 's'} dialed.`, ok === 0 && res.applied > 0)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
flash(`Apply failed: ${msg}`, true)
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,901 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
function goBack() {
|
||||
router.push({ name: 'server' })
|
||||
}
|
||||
|
||||
interface ReleaseInfo {
|
||||
openwrt_release?: string
|
||||
openwrt_version?: string
|
||||
openwrt_board_name?: string
|
||||
openwrt_arch?: string
|
||||
openwrt_target?: string
|
||||
}
|
||||
|
||||
interface WifiInterface {
|
||||
section: string
|
||||
ssid: string
|
||||
device: string
|
||||
encryption: string
|
||||
network: string
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
interface TollGateStatus {
|
||||
installed: boolean
|
||||
enabled?: boolean
|
||||
metric?: string
|
||||
step_size_ms?: number
|
||||
price_per_step?: number
|
||||
min_steps?: number
|
||||
currency?: string
|
||||
mint_url?: string
|
||||
}
|
||||
|
||||
interface WanStatus {
|
||||
configured: boolean
|
||||
ssid: string
|
||||
assoc_ssid: string
|
||||
encryption: string
|
||||
ip: string
|
||||
internet: boolean
|
||||
radio0_disabled: boolean
|
||||
sta_iface: string
|
||||
sta_state: string
|
||||
wifi_log: string
|
||||
lan_ip: string
|
||||
lan_netmask: string
|
||||
dhcp_start: string
|
||||
dhcp_limit: string
|
||||
masq: boolean
|
||||
}
|
||||
|
||||
interface RouterStatus {
|
||||
host: string
|
||||
hostname: string
|
||||
uptime_secs: number
|
||||
release: ReleaseInfo
|
||||
tollgate: TollGateStatus
|
||||
wifi_interfaces: WifiInterface[]
|
||||
wan: WanStatus
|
||||
}
|
||||
|
||||
interface ScannedNetwork {
|
||||
ssid: string
|
||||
bssid: string
|
||||
signal: number
|
||||
channel: number
|
||||
encryption: string
|
||||
}
|
||||
|
||||
const status = ref<RouterStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const host = ref('')
|
||||
const sshUser = ref('root')
|
||||
const sshPassword = ref('')
|
||||
const showConnectForm = ref(false)
|
||||
const connecting = ref(false)
|
||||
const connectedParams = ref<Record<string, string> | null>(null)
|
||||
|
||||
const detecting = ref(false)
|
||||
const detectError = ref('')
|
||||
const detectedCandidates = ref<string[]>([])
|
||||
|
||||
const provisioning = ref(false)
|
||||
const provisionError = ref('')
|
||||
const provisionSuccess = ref(false)
|
||||
|
||||
// TollGate reconfigure form (shown once installed)
|
||||
const editingTollgate = ref(false)
|
||||
const updatingTollgate = ref(false)
|
||||
const updateTollgateError = ref('')
|
||||
const editPriceSats = ref(10)
|
||||
const editStepSizeMin = ref(1)
|
||||
const editMinSteps = ref(1)
|
||||
const editMintUrl = ref('')
|
||||
const editEnabled = ref(true)
|
||||
|
||||
// WAN setup flow
|
||||
type WanStep = 'idle' | 'scan' | 'scanning' | 'list' | 'password' | 'dhcp' | 'connecting' | 'done'
|
||||
const wanStep = ref<WanStep>('idle')
|
||||
const scannedNetworks = ref<ScannedNetwork[]>([])
|
||||
const selectedNetwork = ref<ScannedNetwork | null>(null)
|
||||
const wanPassword = ref('')
|
||||
const wanError = ref('')
|
||||
|
||||
// DHCP/masq settings (step 3 of wizard)
|
||||
const dhcpStart = ref(100)
|
||||
const dhcpLimit = ref(150)
|
||||
const masqEnabled = ref(true)
|
||||
|
||||
async function load(params?: Record<string, string>) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
status.value = await rpcClient.call<RouterStatus>({
|
||||
method: 'openwrt.get-status',
|
||||
params: params ?? {},
|
||||
timeout: 30000,
|
||||
})
|
||||
showConnectForm.value = false
|
||||
if (params) connectedParams.value = params
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (msg.includes('No router configured')) {
|
||||
showConnectForm.value = true
|
||||
} else {
|
||||
error.value = msg
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!host.value.trim()) return
|
||||
connecting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await load({ host: host.value.trim(), ssh_user: sshUser.value, ssh_password: sshPassword.value })
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
interface WiredInterface { name: string; type: string; state: string; ipv4: string[] }
|
||||
|
||||
async function detectRouter() {
|
||||
detecting.value = true
|
||||
detectError.value = ''
|
||||
detectedCandidates.value = []
|
||||
try {
|
||||
const { interfaces } = await rpcClient.call<{ interfaces: WiredInterface[] }>({
|
||||
method: 'network.list-interfaces',
|
||||
timeout: 10000,
|
||||
})
|
||||
const wired = interfaces.find(i => i.type === 'ethernet' && i.state === 'up' && i.ipv4.length > 0)
|
||||
if (!wired) {
|
||||
detectError.value = 'No active wired ethernet connection found on this node.'
|
||||
return
|
||||
}
|
||||
const [ip, prefixStr] = wired.ipv4[0]!.split('/')
|
||||
const prefix = Number(prefixStr) || 24
|
||||
|
||||
const { routers } = await rpcClient.call<{ routers: string[] }>({
|
||||
method: 'openwrt.scan',
|
||||
params: { subnet: ip, prefix, ssh_user: sshUser.value, ssh_password: sshPassword.value },
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
if (routers.length === 0) {
|
||||
detectError.value = `No OpenWrt router found on ${wired.name}'s network (/${prefix}).`
|
||||
} else if (routers.length === 1) {
|
||||
host.value = routers[0]!
|
||||
} else {
|
||||
detectedCandidates.value = routers
|
||||
}
|
||||
} catch (e) {
|
||||
detectError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
detecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickDetectedRouter(ip: string) {
|
||||
host.value = ip
|
||||
detectedCandidates.value = []
|
||||
}
|
||||
|
||||
// The router config now persists server-side (see handle_openwrt_get_status),
|
||||
// so onMounted's no-args load() always reconnects automatically — without this,
|
||||
// there'd be no way back to the connect form (and its Detect button) at all.
|
||||
function disconnectRouter() {
|
||||
host.value = status.value?.host ?? host.value
|
||||
connectedParams.value = null
|
||||
detectError.value = ''
|
||||
detectedCandidates.value = []
|
||||
showConnectForm.value = true
|
||||
}
|
||||
|
||||
async function provisionTollgate() {
|
||||
provisioning.value = true
|
||||
provisionError.value = ''
|
||||
provisionSuccess.value = false
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
}
|
||||
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
|
||||
provisionSuccess.value = true
|
||||
await load(connectedParams.value ?? undefined)
|
||||
} catch (e) {
|
||||
provisionError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
provisioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEditTollgate() {
|
||||
const tg = status.value?.tollgate
|
||||
editPriceSats.value = tg?.price_per_step ?? 10
|
||||
editStepSizeMin.value = Math.max(1, Math.round((tg?.step_size_ms ?? 60000) / 60000))
|
||||
editMinSteps.value = tg?.min_steps ?? 1
|
||||
editMintUrl.value = tg?.mint_url ?? ''
|
||||
editEnabled.value = tg?.enabled ?? true
|
||||
updateTollgateError.value = ''
|
||||
editingTollgate.value = true
|
||||
}
|
||||
|
||||
async function saveTollgateConfig() {
|
||||
updatingTollgate.value = true
|
||||
updateTollgateError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
price_sats: editPriceSats.value,
|
||||
step_size_ms: editStepSizeMin.value * 60_000,
|
||||
min_steps: editMinSteps.value,
|
||||
mint_url: editMintUrl.value,
|
||||
enabled: editEnabled.value,
|
||||
}
|
||||
await rpcClient.call({ method: 'openwrt.provision-tollgate', params, timeout: 300000 })
|
||||
editingTollgate.value = false
|
||||
await load(connectedParams.value ?? undefined)
|
||||
} catch (e) {
|
||||
updateTollgateError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
updatingTollgate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startWanSetup() {
|
||||
wanStep.value = 'scan'
|
||||
wanError.value = ''
|
||||
scannedNetworks.value = []
|
||||
selectedNetwork.value = null
|
||||
wanPassword.value = ''
|
||||
dhcpStart.value = Number(status.value?.wan?.dhcp_start) || 100
|
||||
dhcpLimit.value = Number(status.value?.wan?.dhcp_limit) || 150
|
||||
masqEnabled.value = true
|
||||
}
|
||||
|
||||
async function scanWifi() {
|
||||
wanStep.value = 'scanning'
|
||||
wanError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
}
|
||||
const result = await rpcClient.call<{ networks: ScannedNetwork[] }>({
|
||||
method: 'openwrt.scan-wifi',
|
||||
params,
|
||||
timeout: 30000,
|
||||
})
|
||||
scannedNetworks.value = result.networks
|
||||
wanStep.value = 'list'
|
||||
} catch (e) {
|
||||
wanError.value = e instanceof Error ? e.message : String(e)
|
||||
wanStep.value = 'scan'
|
||||
}
|
||||
}
|
||||
|
||||
function selectNetwork(net: ScannedNetwork) {
|
||||
selectedNetwork.value = net
|
||||
wanPassword.value = ''
|
||||
wanError.value = ''
|
||||
wanStep.value = 'password'
|
||||
}
|
||||
|
||||
async function configureWan() {
|
||||
if (!selectedNetwork.value) return
|
||||
wanStep.value = 'connecting'
|
||||
wanError.value = ''
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
host: connectedParams.value?.host ?? status.value?.host,
|
||||
ssh_user: connectedParams.value?.ssh_user ?? sshUser.value,
|
||||
ssh_password: connectedParams.value?.ssh_password ?? sshPassword.value,
|
||||
ssid: selectedNetwork.value.ssid,
|
||||
password: wanPassword.value,
|
||||
encryption: selectedNetwork.value.encryption,
|
||||
dhcp_start: dhcpStart.value,
|
||||
dhcp_limit: dhcpLimit.value,
|
||||
masq: masqEnabled.value,
|
||||
}
|
||||
await rpcClient.call({ method: 'openwrt.configure-wan', params, timeout: 30000 })
|
||||
wanStep.value = 'done'
|
||||
// Give the router ~8s to associate before reloading status
|
||||
setTimeout(() => {
|
||||
wanStep.value = 'idle'
|
||||
load(connectedParams.value ?? undefined)
|
||||
}, 8000)
|
||||
} catch (e) {
|
||||
wanError.value = e instanceof Error ? e.message : String(e)
|
||||
wanStep.value = 'dhcp'
|
||||
}
|
||||
}
|
||||
|
||||
function signalBars(dbm: number): number {
|
||||
if (dbm >= -50) return 4
|
||||
if (dbm >= -65) return 3
|
||||
if (dbm >= -75) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function formatUptime(secs: number): string {
|
||||
const d = Math.floor(secs / 86400)
|
||||
const h = Math.floor((secs % 86400) / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const parts = []
|
||||
if (d) parts.push(`${d}d`)
|
||||
if (h) parts.push(`${h}h`)
|
||||
parts.push(`${m}m`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function formatStepSize(ms: number): string {
|
||||
if (ms >= 3_600_000) return `${ms / 3_600_000}h`
|
||||
if (ms >= 60_000) return `${ms / 60_000}m`
|
||||
if (ms >= 1_000) return `${ms / 1_000}s`
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
const openwrtVersion = computed(() => {
|
||||
if (!status.value) return ''
|
||||
const r = status.value.release
|
||||
return r.openwrt_release || r.openwrt_version || ''
|
||||
})
|
||||
|
||||
const boardName = computed(() => {
|
||||
if (!status.value) return ''
|
||||
return status.value.release.openwrt_board_name || ''
|
||||
})
|
||||
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- mb-0 overrides the pill's default mb-4, which pushed it above the
|
||||
title's centerline in this inline header row. -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<BackButton desktop-margin="mb-0" @click="goBack" />
|
||||
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
|
||||
</div>
|
||||
|
||||
<!-- Connect form -->
|
||||
<div v-if="showConnectForm" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">Connect to Router</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Router IP</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="host"
|
||||
type="text"
|
||||
placeholder="192.168.1.1"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
:disabled="detecting"
|
||||
class="glass-button px-4 py-3 text-sm font-medium whitespace-nowrap flex-shrink-0"
|
||||
:class="detecting ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="detectRouter"
|
||||
>
|
||||
{{ detecting ? 'Detecting…' : 'Detect' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="detectError" class="mt-2 text-xs text-red-400">{{ detectError }}</p>
|
||||
<div v-if="detectedCandidates.length > 0" class="mt-2 space-y-1">
|
||||
<p class="text-xs text-white/40">Multiple routers found — pick one:</p>
|
||||
<button
|
||||
v-for="ip in detectedCandidates"
|
||||
:key="ip"
|
||||
class="w-full text-left px-3 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-white font-mono transition-colors"
|
||||
@click="pickDetectedRouter(ip)"
|
||||
>
|
||||
{{ ip }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">SSH User</label>
|
||||
<input
|
||||
v-model="sshUser"
|
||||
type="text"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/40 mb-1">Password</label>
|
||||
<input
|
||||
v-model="sshPassword"
|
||||
type="password"
|
||||
placeholder="(blank for none)"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/20 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:disabled="connecting || !host.trim()"
|
||||
class="glass-button w-full text-sm font-medium"
|
||||
:class="connecting || !host.trim() ? 'opacity-40 cursor-not-allowed' : 'glass-button-warning'"
|
||||
@click="connect"
|
||||
>
|
||||
{{ connecting ? 'Connecting…' : 'Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="mt-3 text-xs text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<template v-else-if="loading">
|
||||
<div v-for="i in 3" :key="i" class="glass-card p-6 mb-4 animate-pulse">
|
||||
<div class="h-4 bg-white/10 rounded w-1/3 mb-4"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-white/10 rounded w-2/3"></div>
|
||||
<div class="h-3 bg-white/10 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="glass-card p-6 mb-4">
|
||||
<p class="text-sm text-red-300">{{ error }}</p>
|
||||
<button class="mt-3 text-xs text-white/50 hover:text-white transition-colors underline" @click="load()">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Status panels -->
|
||||
<template v-else-if="status">
|
||||
|
||||
<!-- System info -->
|
||||
<div class="glass-card p-6 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80">System</h2>
|
||||
<span class="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400 inline-block"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Hostname</dt>
|
||||
<dd class="text-white font-medium">{{ status.hostname }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Address</dt>
|
||||
<dd class="text-white font-medium font-mono">{{ status.host }}</dd>
|
||||
</div>
|
||||
<div v-if="openwrtVersion">
|
||||
<dt class="text-xs text-white/40 mb-0.5">OpenWrt</dt>
|
||||
<dd class="text-white/70">{{ openwrtVersion }}</dd>
|
||||
</div>
|
||||
<div v-if="boardName">
|
||||
<dt class="text-xs text-white/40 mb-0.5">Board</dt>
|
||||
<dd class="text-white/70">{{ boardName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Uptime</dt>
|
||||
<dd class="text-white/70">{{ formatUptime(status.uptime_secs) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<button class="text-xs text-white/40 hover:text-white/70 transition-colors" @click="load()">
|
||||
↻ Refresh
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white/70 transition-colors" @click="disconnectRouter">
|
||||
Switch router →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WAN / Uplink -->
|
||||
<div class="glass-card p-6 mb-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80">WAN / Uplink</h2>
|
||||
<button
|
||||
v-if="wanStep === 'idle' || wanStep === 'done'"
|
||||
class="text-xs text-white/40 hover:text-white transition-colors"
|
||||
@click="startWanSetup"
|
||||
>
|
||||
{{ status.wan?.configured ? 'Reconfigure →' : 'Set up →' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current status (idle) -->
|
||||
<template v-if="wanStep === 'idle' || wanStep === 'done'">
|
||||
<div v-if="status.wan?.configured" class="flex items-center gap-3 mb-3">
|
||||
<span class="w-2 h-2 rounded-full inline-block flex-shrink-0"
|
||||
:class="status.wan.internet ? 'bg-green-400' : 'bg-yellow-400 animate-pulse'"></span>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-white">{{ status.wan.ssid }}</div>
|
||||
<div class="text-xs text-white/40">
|
||||
{{ status.wan.internet ? status.wan.ip : 'Connecting…' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-white/50 mb-3">
|
||||
Not configured — router has no internet access.
|
||||
</div>
|
||||
|
||||
<!-- Quick summary of DHCP + masq when connected -->
|
||||
<dl v-if="status.wan?.configured && status.wan.internet"
|
||||
class="grid grid-cols-3 gap-x-4 gap-y-1 text-xs text-white/40 mb-3">
|
||||
<div>
|
||||
<dt class="text-white/25">LAN</dt>
|
||||
<dd class="text-white/60 font-mono">{{ status.wan.lan_ip }}/24</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/25">DHCP</dt>
|
||||
<dd class="text-white/60 font-mono">.{{ status.wan.dhcp_start }}+{{ status.wan.dhcp_limit }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/25">NAT</dt>
|
||||
<dd :class="status.wan.masq ? 'text-green-400' : 'text-red-400'">
|
||||
{{ status.wan.masq ? 'on' : 'off' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Diagnostics (shown when not connected) -->
|
||||
<dl v-if="status.wan?.configured && !status.wan.internet"
|
||||
class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs mt-1 mb-3 border-t border-white/10 pt-3">
|
||||
<div>
|
||||
<dt class="text-white/30 mb-0.5">Radio</dt>
|
||||
<dd :class="status.wan.radio0_disabled ? 'text-red-400' : 'text-green-400'">
|
||||
{{ status.wan.radio0_disabled ? 'disabled' : 'enabled' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/30 mb-0.5">Interface</dt>
|
||||
<dd class="text-white/70 font-mono">
|
||||
{{ status.wan.sta_iface || 'none' }}
|
||||
<span v-if="status.wan.sta_iface" class="ml-1"
|
||||
:class="status.wan.sta_state === 'up' ? 'text-green-400' : 'text-yellow-400'">
|
||||
({{ status.wan.sta_state }})
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/30 mb-0.5">Associated to</dt>
|
||||
<dd class="text-white/70">{{ status.wan.assoc_ssid || 'none' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-white/30 mb-0.5">Configured SSID</dt>
|
||||
<dd class="text-white/70">{{ status.wan.ssid || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="status.wan.wifi_log" class="col-span-2">
|
||||
<dt class="text-white/30 mb-1">Recent wifi log</dt>
|
||||
<dd class="font-mono text-white/50 text-[10px] leading-relaxed whitespace-pre-wrap break-all bg-black/20 rounded p-2">{{ status.wan.wifi_log }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p v-if="wanStep === 'done'" class="mt-2 text-xs text-green-400">
|
||||
WAN configured. Router is connecting…
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Step: scan prompt -->
|
||||
<template v-else-if="wanStep === 'scan'">
|
||||
<button
|
||||
class="glass-button glass-button-warning w-full text-sm font-medium"
|
||||
@click="scanWifi"
|
||||
>
|
||||
Scan for Networks
|
||||
</button>
|
||||
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||
<button class="mt-3 text-xs text-white/40 hover:text-white" @click="wanStep = 'idle'">Cancel</button>
|
||||
</template>
|
||||
|
||||
<!-- Step: scanning -->
|
||||
<template v-else-if="wanStep === 'scanning'">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<svg class="animate-spin w-4 h-4 text-white/50" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-white/50">Scanning for networks…</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step: network list -->
|
||||
<template v-else-if="wanStep === 'list'">
|
||||
<p class="text-xs text-white/40 mb-3">{{ scannedNetworks.length }} network{{ scannedNetworks.length !== 1 ? 's' : '' }} found. Select one:</p>
|
||||
<div v-if="scannedNetworks.length === 0" class="text-sm text-white/50">No networks found. Try scanning again.</div>
|
||||
<div class="space-y-1.5">
|
||||
<button
|
||||
v-for="net in scannedNetworks"
|
||||
:key="net.bssid"
|
||||
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg border border-white/10 hover:border-white/30 hover:bg-white/5 transition-colors text-left"
|
||||
@click="selectNetwork(net)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<!-- Signal bars -->
|
||||
<div class="flex items-end gap-0.5 h-4">
|
||||
<div v-for="b in 4" :key="b"
|
||||
class="w-1 rounded-sm"
|
||||
:class="[
|
||||
b * 1 <= signalBars(net.signal) ? 'bg-white/70' : 'bg-white/15',
|
||||
b === 1 ? 'h-1' : b === 2 ? 'h-2' : b === 3 ? 'h-3' : 'h-4'
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-white text-sm">{{ net.ssid || '(hidden)' }}</span>
|
||||
<span v-if="net.encryption !== 'none'" class="text-white/30 text-xs">🔒</span>
|
||||
</div>
|
||||
<span class="text-xs text-white/30 font-mono flex-shrink-0">ch{{ net.channel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="mt-3 text-xs text-white/40 hover:text-white" @click="wanStep = 'scan'">← Scan again</button>
|
||||
</template>
|
||||
|
||||
<!-- Step: password entry -->
|
||||
<template v-else-if="wanStep === 'password'">
|
||||
<p class="text-sm text-white mb-3">
|
||||
Connect to <span class="font-semibold">{{ selectedNetwork?.ssid }}</span>
|
||||
</p>
|
||||
<input
|
||||
v-if="selectedNetwork?.encryption !== 'none'"
|
||||
v-model="wanPassword"
|
||||
type="password"
|
||||
placeholder="WiFi password"
|
||||
class="w-full px-4 py-3 mb-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
@keyup.enter="wanStep = 'dhcp'"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||
:disabled="selectedNetwork?.encryption !== 'none' && !wanPassword"
|
||||
:class="selectedNetwork?.encryption !== 'none' && !wanPassword ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="wanStep = 'dhcp'"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'list'">←</button>
|
||||
</div>
|
||||
<p v-if="wanError" class="mt-2 text-xs text-red-400">{{ wanError }}</p>
|
||||
</template>
|
||||
|
||||
<!-- Step: DHCP + masquerade config -->
|
||||
<template v-else-if="wanStep === 'dhcp'">
|
||||
<p class="text-xs text-white/50 mb-4">
|
||||
Configure how this router hands out addresses to WiFi clients.
|
||||
</p>
|
||||
|
||||
<!-- LAN info -->
|
||||
<div class="mb-4 text-xs font-mono bg-black/20 rounded-lg px-3 py-2 text-white/50">
|
||||
<span class="text-white/30">LAN: </span>{{ status?.wan?.lan_ip || '192.168.1.1' }}/24
|
||||
</div>
|
||||
|
||||
<!-- DHCP range -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">DHCP range for clients</label>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-white/40 font-mono text-xs">{{ status?.wan?.lan_ip?.split('.').slice(0,3).join('.') || '192.168.1' }}.</span>
|
||||
<input
|
||||
v-model.number="dhcpStart"
|
||||
type="number"
|
||||
min="2" max="250"
|
||||
class="w-20 px-2 py-1.5 bg-transparent border border-white/20 rounded text-white text-sm text-center focus:outline-none focus:border-white/40"
|
||||
/>
|
||||
<span class="text-white/30">–</span>
|
||||
<span class="text-white/60 font-mono text-xs">{{ Math.min(254, dhcpStart + dhcpLimit - 1) }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/30 mt-1">{{ dhcpLimit }} addresses</p>
|
||||
</div>
|
||||
|
||||
<!-- Masquerade -->
|
||||
<div class="flex items-center justify-between mb-4 py-3 border-t border-white/10">
|
||||
<div>
|
||||
<div class="text-sm text-white">Enable NAT masquerade</div>
|
||||
<div class="text-xs text-white/40">Routes LAN traffic through the WiFi uplink</div>
|
||||
</div>
|
||||
<button
|
||||
class="relative w-11 h-6 rounded-full transition-colors flex-shrink-0"
|
||||
:class="masqEnabled ? 'bg-green-500/60' : 'bg-white/15'"
|
||||
@click="masqEnabled = !masqEnabled"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
|
||||
:class="masqEnabled ? 'translate-x-5' : 'translate-x-0.5'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="wanError" class="mb-3 text-xs text-red-400">{{ wanError }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||
@click="configureWan"
|
||||
>
|
||||
Apply Settings
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white px-3 py-2" @click="wanStep = 'password'">←</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step: applying -->
|
||||
<template v-else-if="wanStep === 'connecting'">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<svg class="animate-spin w-4 h-4 text-white/50" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-white/50">Applying WAN config…</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- TollGate -->
|
||||
<div class="glass-card p-6 mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">TollGate</h2>
|
||||
|
||||
<div v-if="!status.tollgate.installed">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="w-2 h-2 rounded-full bg-white/20 inline-block"></span>
|
||||
<span class="text-sm text-white/50">Not installed</span>
|
||||
</div>
|
||||
<div v-if="!status.wan?.internet" class="mb-3 text-xs text-yellow-400/80 bg-yellow-400/10 rounded-lg px-3 py-2">
|
||||
Router needs internet access to install TollGate. Configure WAN above first.
|
||||
</div>
|
||||
<button
|
||||
:disabled="provisioning"
|
||||
class="glass-button glass-button-success w-full text-sm font-medium"
|
||||
:class="provisioning ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="provisionTollgate"
|
||||
>
|
||||
{{ provisioning ? 'Installing… this may take a few minutes' : 'Install TollGate' }}
|
||||
</button>
|
||||
<p v-if="provisionError" class="mt-3 text-xs text-red-400">{{ provisionError }}</p>
|
||||
<p v-if="provisionSuccess && !provisioning" class="mt-3 text-xs text-green-400">TollGate provisioned successfully.</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="!editingTollgate">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full inline-block"
|
||||
:class="status.tollgate.enabled ? 'bg-green-400' : 'bg-yellow-400'"></span>
|
||||
<span class="text-sm" :class="status.tollgate.enabled ? 'text-green-300' : 'text-yellow-300'">
|
||||
{{ status.tollgate.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="text-xs text-white/40 hover:text-white" @click="startEditTollgate">Edit</button>
|
||||
</div>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Price</dt>
|
||||
<dd class="text-white font-medium">
|
||||
{{ status.tollgate.price_per_step }} {{ status.tollgate.currency }}
|
||||
/ {{ formatStepSize(status.tollgate.step_size_ms ?? 60000) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Metric</dt>
|
||||
<dd class="text-white/70 capitalize">{{ status.tollgate.metric }}</dd>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<dt class="text-xs text-white/40 mb-0.5">Mint URL</dt>
|
||||
<dd class="text-white/70 font-mono text-xs break-all">{{ status.tollgate.mint_url }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<!-- Reconfigure form -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-4 py-3 border-b border-white/10">
|
||||
<div>
|
||||
<div class="text-sm text-white">Enable TollGate</div>
|
||||
<div class="text-xs text-white/40">Stops the service and the SSID broadcast when off</div>
|
||||
</div>
|
||||
<button
|
||||
class="relative w-11 h-6 rounded-full transition-colors flex-shrink-0"
|
||||
:class="editEnabled ? 'bg-green-500/60' : 'bg-white/15'"
|
||||
@click="editEnabled = !editEnabled"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
|
||||
:class="editEnabled ? 'translate-x-5' : 'translate-x-0.5'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">Price per step (sats)</label>
|
||||
<input
|
||||
v-model.number="editPriceSats"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">Step size (minutes)</label>
|
||||
<input
|
||||
v-model.number="editStepSizeMin"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">Minimum steps per purchase</label>
|
||||
<input
|
||||
v-model.number="editMinSteps"
|
||||
type="number"
|
||||
min="1"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-white/40 mb-2">Mint URL</label>
|
||||
<input
|
||||
v-model="editMintUrl"
|
||||
type="text"
|
||||
placeholder="http://..."
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white placeholder-white/30 focus:outline-none focus:border-white/40 transition-colors font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
:disabled="updatingTollgate"
|
||||
class="glass-button glass-button-success flex-1 text-sm font-medium"
|
||||
:class="updatingTollgate ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
@click="saveTollgateConfig"
|
||||
>
|
||||
{{ updatingTollgate ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
<button class="text-xs text-white/40 hover:text-white px-3 py-2" :disabled="updatingTollgate" @click="editingTollgate = false">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="updateTollgateError" class="mt-3 text-xs text-red-400">{{ updateTollgateError }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- WiFi interfaces -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">
|
||||
WiFi Interfaces
|
||||
<span class="ml-2 text-xs font-normal text-white/40">(AP mode)</span>
|
||||
</h2>
|
||||
|
||||
<div v-if="status.wifi_interfaces.length === 0" class="text-sm text-white/50">
|
||||
No AP interfaces configured.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="iface in status.wifi_interfaces"
|
||||
:key="iface.section"
|
||||
class="flex items-start justify-between py-3 border-b border-white/10 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full inline-block"
|
||||
:class="iface.disabled ? 'bg-white/20' : 'bg-green-400'"></span>
|
||||
<span class="text-sm font-medium text-white">{{ iface.ssid || '(hidden)' }}</span>
|
||||
<span v-if="iface.network === 'tollgate'"
|
||||
class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-300">TollGate</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/40 ml-3.5">
|
||||
{{ iface.device }} · {{ iface.encryption === 'none' ? 'Open' : iface.encryption }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-white/30 font-mono mt-0.5">{{ iface.section }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div v-if="false" data-controller-container tabindex="0" class="glass-card p-6 mb-6 transition-all hover:-translate-y-1">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Service Status -->
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="servicesRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Services</p>
|
||||
<p class="text-xs text-white/60">{{ servicesRunning ? 'All Running' : 'Some Stopped' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('restartServices')"
|
||||
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
:disabled="restarting"
|
||||
>
|
||||
{{ restarting ? 'Restarting...' : 'Restart' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tor Status -->
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="torStatusColor"></div>
|
||||
<div v-if="torStatusLabel === 'running'" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Tor Status</p>
|
||||
<p class="text-xs text-white/60 capitalize">{{ torStatusLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('checkTor')"
|
||||
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||
:disabled="checkingTor"
|
||||
>
|
||||
{{ checkingTor ? 'Checking...' : 'Check Tor' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Auto-Sync Toggle (hidden — not functional yet) -->
|
||||
<div v-if="false" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center justify-between min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Auto-Sync</p>
|
||||
<p class="text-xs text-white/60">{{ autoSyncEnabled ? 'Enabled' : 'Disabled' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="autoSyncEnabled" @update:model-value="$emit('update:autoSyncEnabled', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs & Diagnostics -->
|
||||
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">Logs</p>
|
||||
<p class="text-xs text-white/60">{{ logCount > 0 ? `${logCount} new` : 'No new logs' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('viewLogs')"
|
||||
class="w-full min-h-[44px] glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors flex items-center justify-center"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
|
||||
defineProps<{
|
||||
servicesRunning: boolean
|
||||
restarting: boolean
|
||||
torStatusLabel: string
|
||||
torStatusColor: string
|
||||
checkingTor: boolean
|
||||
autoSyncEnabled: boolean
|
||||
logCount: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
restartServices: []
|
||||
checkTor: []
|
||||
'update:autoSyncEnabled': [value: boolean]
|
||||
viewLogs: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<!-- Add Tor Service Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showAddServiceModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="$emit('closeAddService')" @keydown.escape="$emit('closeAddService')">
|
||||
<div class="glass-card p-6 max-w-md w-full">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Add Tor Hidden Service</h3>
|
||||
|
||||
<!-- Tabs: Installed Apps | Manual -->
|
||||
<div class="flex gap-1 mb-4 border-b border-white/10">
|
||||
<button
|
||||
@click="localTab = 'apps'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="localTab === 'apps' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Installed Apps
|
||||
</button>
|
||||
<button
|
||||
@click="localTab = 'manual'"
|
||||
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
|
||||
:class="localTab === 'manual' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
|
||||
>
|
||||
Manual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Installed Apps tab -->
|
||||
<div v-if="localTab === 'apps'">
|
||||
<p class="text-white/60 text-sm mb-3">Select an installed app to create a .onion address for it.</p>
|
||||
<div v-if="availableAppsForTor.length === 0" class="p-4 text-center text-white/40 text-sm">
|
||||
All installed apps already have Tor services.
|
||||
</div>
|
||||
<div v-else class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<button
|
||||
v-for="app in availableAppsForTor"
|
||||
:key="app.id"
|
||||
@click="$emit('createServiceForApp', app.id)"
|
||||
:disabled="addingService"
|
||||
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-white">{{ app.title }}</p>
|
||||
<p class="text-xs text-white/40">{{ app.id }}</p>
|
||||
</div>
|
||||
<span class="text-xs text-orange-400 shrink-0">+ Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual tab -->
|
||||
<div v-if="localTab === 'manual'">
|
||||
<p class="text-white/60 text-sm mb-3">Create a .onion address for any local service.</p>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-1">Service Name</label>
|
||||
<input
|
||||
v-model="localName"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="my-app"
|
||||
maxlength="64"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-white/80 mb-1">Local Port</label>
|
||||
<input
|
||||
v-model.number="localPort"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
|
||||
placeholder="8080"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
@click="$emit('createService', localName.trim(), localPort)"
|
||||
:disabled="!localName.trim() || !localPort || addingService"
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ addingService ? 'Creating...' : 'Create Service' }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('closeAddService')"
|
||||
class="px-4 py-2 rounded-lg bg-white/10 text-white font-medium hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="addServiceError" class="mt-3 text-sm text-red-400">{{ addServiceError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- WiFi Scan Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showWifiModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeWifi')">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">WiFi Networks</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="$emit('scanWifi')" :disabled="wifiScanning" class="text-xs text-white/50 hover:text-white disabled:opacity-40 transition-colors">Refresh</button>
|
||||
<button @click="$emit('closeWifi')" class="text-white/40 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="wifiScanning">
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in 4" :key="i" class="p-3 bg-white/5 rounded-lg animate-pulse h-12"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="wifiNetworks.length > 0">
|
||||
<div class="space-y-2 max-h-72 overflow-y-auto">
|
||||
<button
|
||||
v-for="net in wifiNetworks"
|
||||
:key="net.ssid"
|
||||
class="w-full flex items-center justify-between p-3 bg-white/5 rounded-lg hover:bg-white/10 transition-colors text-left"
|
||||
@click="$emit('selectWifi', net)"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ net.ssid }}</p>
|
||||
<p class="text-xs text-white/50">{{ net.security || 'Open' }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex gap-0.5">
|
||||
<div v-for="bar in 4" :key="bar" class="w-1 rounded-full" :class="bar <= Math.ceil(net.signal / 25) ? 'bg-white/80' : 'bg-white/20'" :style="{ height: (bar * 3 + 4) + 'px' }"></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/50">{{ net.signal }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="wifiScanError">
|
||||
<div class="rounded-lg border border-red-400/20 bg-red-500/10 p-4 text-sm text-red-200">
|
||||
<p>{{ wifiScanError }}</p>
|
||||
<button @click="$emit('scanWifi')" class="mt-3 text-white/80 hover:text-white underline underline-offset-4">Try again</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-sm text-white/50 text-center py-8">No networks found</p>
|
||||
</template>
|
||||
|
||||
<!-- WiFi password prompt -->
|
||||
<div v-if="wifiConnecting" class="mt-4 pt-4 border-t border-white/10">
|
||||
<p class="text-sm text-white/80 mb-2">Connect to <span class="font-medium text-white">{{ wifiSelectedSsid }}</span></p>
|
||||
<input
|
||||
v-model="localWifiPassword"
|
||||
type="password"
|
||||
placeholder="WiFi password"
|
||||
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30 mb-3"
|
||||
@keyup.enter="$emit('connectWifi', localWifiPassword)"
|
||||
/>
|
||||
<p v-if="wifiError" class="text-sm text-red-400 mb-3">{{ wifiError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="$emit('cancelWifiConnect')" :disabled="wifiSubmitting" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm">Cancel</button>
|
||||
<button @click="$emit('connectWifi', localWifiPassword)" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" :disabled="!localWifiPassword || wifiSubmitting">{{ wifiSubmitting ? 'Connecting...' : 'Connect' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- DNS Configuration Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDnsModal" class="fixed inset-0 bg-black/60 backdrop-blur-md z-50 flex items-center justify-center p-4" @click.self="$emit('closeDns')">
|
||||
<div class="glass-card p-6 w-full max-w-md">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">DNS Configuration</h3>
|
||||
<button @click="$emit('closeDns')" class="text-white/40 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-white/60 mb-4">Choose a DNS provider. Providers with DoH encrypt your DNS queries.</p>
|
||||
|
||||
<div class="space-y-2 mb-4">
|
||||
<button
|
||||
v-for="opt in dnsProviderOptions"
|
||||
:key="opt.value"
|
||||
class="w-full flex items-center justify-between p-3 rounded-lg transition-colors text-left"
|
||||
:class="dnsSelectedProvider === opt.value ? 'bg-white/15 border border-white/20' : 'bg-white/5 border border-transparent hover:bg-white/10'"
|
||||
@click="$emit('selectDnsProvider', opt.value)"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">{{ opt.label }}</p>
|
||||
<p class="text-xs text-white/50">{{ opt.description }}</p>
|
||||
</div>
|
||||
<span v-if="opt.doh" class="text-xs px-2 py-0.5 rounded-full bg-green-400/20 text-green-400">DoH</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom servers input -->
|
||||
<div v-if="dnsSelectedProvider === 'custom'" class="mb-4">
|
||||
<label class="block text-sm text-white/70 mb-1">DNS Servers (comma-separated)</label>
|
||||
<input
|
||||
v-model="localCustomServers"
|
||||
type="text"
|
||||
placeholder="1.1.1.1, 8.8.8.8"
|
||||
class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white text-sm placeholder-white/30 focus:outline-none focus:border-white/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Current servers info -->
|
||||
<div v-if="dnsServers.length > 0" class="mb-4 p-3 bg-white/5 rounded-lg">
|
||||
<p class="text-xs text-white/50 mb-1">Current resolv.conf servers</p>
|
||||
<p class="text-sm text-white/80">{{ dnsServers.join(', ') }}</p>
|
||||
</div>
|
||||
|
||||
<p v-if="dnsError" class="text-sm text-red-400 mb-3">{{ dnsError }}</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button @click="$emit('closeDns')" class="flex-1 px-3 py-2 glass-button rounded-lg text-sm">Cancel</button>
|
||||
<button
|
||||
@click="$emit('applyDns', localCustomServers)"
|
||||
class="flex-1 px-3 py-2 glass-button rounded-lg text-sm font-medium"
|
||||
:disabled="dnsApplying || (dnsSelectedProvider === 'custom' && !localCustomServers.trim())"
|
||||
>
|
||||
{{ dnsApplying ? 'Applying...' : 'Apply' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
showAddServiceModal: boolean
|
||||
showWifiModal: boolean
|
||||
showDnsModal: boolean
|
||||
availableAppsForTor: { id: string; title: string }[]
|
||||
addingService: boolean
|
||||
addServiceError: string
|
||||
wifiScanning: boolean
|
||||
wifiNetworks: { ssid: string; signal: number; security: string }[]
|
||||
wifiConnecting: boolean
|
||||
wifiSubmitting: boolean
|
||||
wifiSelectedSsid: string
|
||||
wifiError: string
|
||||
wifiScanError: string
|
||||
dnsSelectedProvider: string
|
||||
dnsServers: string[]
|
||||
dnsApplying: boolean
|
||||
dnsError: string
|
||||
dnsProviderOptions: { value: string; label: string; description: string; doh: boolean }[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
closeAddService: []
|
||||
createServiceForApp: [appId: string]
|
||||
createService: [name: string, port: number | null]
|
||||
closeWifi: []
|
||||
selectWifi: [network: { ssid: string; signal: number; security: string }]
|
||||
connectWifi: [password: string]
|
||||
scanWifi: []
|
||||
cancelWifiConnect: []
|
||||
closeDns: []
|
||||
selectDnsProvider: [provider: string]
|
||||
applyDns: [customServers: string]
|
||||
}>()
|
||||
|
||||
const localTab = ref<'apps' | 'manual'>('apps')
|
||||
const localName = ref('')
|
||||
const localPort = ref<number | null>(null)
|
||||
const localWifiPassword = ref('')
|
||||
const localCustomServers = ref('')
|
||||
</script>
|
||||
@@ -0,0 +1,140 @@
|
||||
<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-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-3 h-3 rounded-full" :class="torDaemonRunning ? 'bg-green-400' : 'bg-red-400'"></div>
|
||||
<div v-if="torDaemonRunning" class="absolute inset-0 w-3 h-3 rounded-full bg-green-400 animate-ping opacity-75"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-white/96">Tor Services</h2>
|
||||
<p class="text-sm text-white/60 mt-1">Manage hidden service addresses for your node and apps</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="responsive-card-actions-top items-center gap-2">
|
||||
<button @click="$emit('restartTor')" :disabled="torRestarting" class="glass-button px-3 py-2 rounded-lg text-sm">
|
||||
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
|
||||
</button>
|
||||
<button @click="$emit('showAddService')" class="glass-button px-3 py-2 rounded-lg text-sm">
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="torServicesLoading && torServices.length === 0" class="text-sm text-white/40 py-4 text-center">Loading Tor services...</div>
|
||||
<div v-else-if="torServices.length === 0" class="text-sm text-white/40 py-4 text-center">No Tor services configured</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="torServicesLoading" 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 Tor services...
|
||||
</div>
|
||||
<div v-for="svc in torServices" :key="svc.name" class="bg-black/20 rounded-xl border border-white/10 p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-white text-sm font-medium">{{ svc.name }}</p>
|
||||
<span class="text-white/30 text-xs">:{{ svc.local_port }}</span>
|
||||
<span v-if="svc.protocol" class="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">protocol</span>
|
||||
<span v-else-if="!svc.unauthenticated" class="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">auth</span>
|
||||
<span v-else class="text-xs px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">open</span>
|
||||
</div>
|
||||
<p v-if="svc.onion_address" class="text-amber-300/80 text-xs font-mono truncate cursor-pointer" :title="svc.onion_address" @click="$emit('copyAddress', svc.onion_address)">{{ svc.onion_address }}</p>
|
||||
<p v-else-if="svc.enabled" class="text-white/30 text-xs">Waiting for .onion address...</p>
|
||||
<p v-else class="text-white/30 text-xs">Disabled</p>
|
||||
</div>
|
||||
<!-- Desktop: compact inline actions next to the toggle -->
|
||||
<div class="hidden md:flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
v-if="svc.onion_address && svc.enabled"
|
||||
@click="$emit('rotateService', svc.name)"
|
||||
:disabled="torRotating === svc.name"
|
||||
class="glass-button px-3 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="svc.name !== 'archipelago'"
|
||||
@click="$emit('deleteService', svc.name)"
|
||||
:disabled="torDeleting === svc.name"
|
||||
class="glass-button px-2 py-1.5 rounded-lg text-xs text-red-400 hover:text-red-300"
|
||||
:title="'Delete ' + svc.name + ' hidden service'"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<ToggleSwitch :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
|
||||
</div>
|
||||
<ToggleSwitch class="shrink-0 md:hidden" :model-value="svc.enabled" @update:model-value="$emit('toggleApp', svc.name, $event)" />
|
||||
</div>
|
||||
<!-- Mobile: actions in their own 50/50 row — Delete on the LEFT, far
|
||||
from the toggle above, so a rushed thumb can't hit the wrong control. -->
|
||||
<div
|
||||
v-if="svc.name !== 'archipelago' || (svc.onion_address && svc.enabled)"
|
||||
class="grid md:hidden grid-cols-2 gap-2 mt-3"
|
||||
>
|
||||
<button
|
||||
v-if="svc.name !== 'archipelago'"
|
||||
@click="$emit('deleteService', svc.name)"
|
||||
:disabled="torDeleting === svc.name"
|
||||
class="glass-button py-2 rounded-lg text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||
:class="{ 'col-span-2': !(svc.onion_address && svc.enabled) }"
|
||||
:title="'Delete ' + svc.name + ' hidden service'"
|
||||
>
|
||||
{{ torDeleting === svc.name ? 'Deleting...' : 'Delete' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="svc.onion_address && svc.enabled"
|
||||
@click="$emit('rotateService', svc.name)"
|
||||
:disabled="torRotating === svc.name"
|
||||
class="glass-button py-2 rounded-lg text-xs disabled:opacity-50"
|
||||
:class="{ 'col-span-2': svc.name === 'archipelago' }"
|
||||
>
|
||||
{{ torRotating === svc.name ? 'Rotating...' : 'Rotate' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="responsive-card-actions-bottom-grid mt-auto pt-4 grid-cols-2 gap-3">
|
||||
<button @click="$emit('restartTor')" :disabled="torRestarting" class="mobile-card-action glass-button rounded-lg text-sm font-medium disabled:opacity-50">
|
||||
{{ torRestarting ? 'Restarting...' : 'Restart Tor' }}
|
||||
</button>
|
||||
<button @click="$emit('showAddService')" class="mobile-card-action glass-button rounded-lg text-sm font-medium">
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
|
||||
export interface TorServiceInfo {
|
||||
name: string
|
||||
local_port: number
|
||||
onion_address: string | null
|
||||
enabled: boolean
|
||||
unauthenticated: boolean
|
||||
protocol: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
torServices: TorServiceInfo[]
|
||||
torServicesLoading: boolean
|
||||
torDaemonRunning: boolean
|
||||
torRestarting: boolean
|
||||
torRotating: string | false
|
||||
torDeleting: string | false
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
restartTor: []
|
||||
showAddService: []
|
||||
copyAddress: [address: string]
|
||||
rotateService: [name: string]
|
||||
deleteService: [name: string]
|
||||
toggleApp: [name: string, enabled: boolean]
|
||||
}>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user