Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,303 @@
<template>
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="flex-1">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-xl font-semibold text-white">F*ck IPs Mesh</h2>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2" :title="statusLabel">
<span class="w-2 h-2 rounded-full" :class="statusDotColor"></span>
<span class="text-sm font-medium" :class="statusTextColor">{{ statusLabel }}</span>
</div>
<button
type="button"
class="p-1.5 rounded-md text-white/50 hover:text-white hover:bg-white/10 transition-colors"
title="Seed anchors"
aria-label="Open FIPS seed anchors settings"
@click="showAnchorsModal = true"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
<p class="text-white/70 text-sm mb-4">Fast Nostr-keyed mesh routing</p>
</div>
</div>
<!-- Seed anchors modal — operator-editable list of peers this node
dials to bootstrap the mesh. Tucked behind the gear so it
doesn't crowd the card but is still one click away. Close
button and layout mirror the What's New modal (and the rest
of the app) so it feels like a first-class modal. -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="showAnchorsModal"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click="showAnchorsModal = false"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
class="relative z-10 max-w-xl w-full"
style="max-height: 90vh; overflow-y: auto"
@click.stop
>
<FipsSeedAnchorsCard closable @close="showAnchorsModal = false" />
</div>
</div>
</Transition>
</Teleport>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3 shrink-0">
<div class="p-3 bg-white/5 rounded-lg">
<p class="text-xs text-white/60 mb-1">Daemon version</p>
<p class="text-sm font-medium text-white break-all">{{ status.version || '—' }}</p>
<p v-if="!status.installed" class="text-xs text-white/40 mt-1">Package not installed</p>
</div>
<div class="p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-2 mb-1">
<p class="text-xs text-white/60">FIPS npub</p>
<button
v-if="status.npub"
type="button"
class="text-xs text-white/60 hover:text-white transition-colors flex items-center gap-1"
:title="copied ? 'Copied!' : 'Copy full npub to clipboard'"
@click="copyNpub"
>
<svg v-if="!copied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
<svg v-else class="w-3.5 h-3.5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
<span :class="{ 'text-green-400': copied }">{{ copied ? 'Copied' : 'Copy' }}</span>
</button>
</div>
<p class="text-sm font-mono text-white break-all select-all">{{ npubDisplay }}</p>
<p v-if="!status.key_present && status.npub" class="text-xs text-white/40 mt-1">Upstream key (not seed-derived)</p>
<p v-else-if="!status.key_present" class="text-xs text-white/40 mt-1">Unlock seed to derive archipelago-managed key</p>
</div>
</div>
<!-- Anchor status: always full-width to keep desktop layout tidy -->
<div v-if="status.service_active" class="p-3 bg-white/5 rounded-lg mb-3 shrink-0">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-2 text-xs">
<span class="w-2 h-2 rounded-full" :class="status.anchor_connected ? 'bg-cyan-400' : 'bg-orange-400'"></span>
<span class="text-white/70">Anchor:</span>
<span :class="status.anchor_connected ? 'text-cyan-300' : 'text-orange-300'">
{{ status.anchor_connected ? 'connected' : 'not reached' }}
</span>
<span class="text-white/40">·</span>
<span class="text-white/60">{{ status.authenticated_peer_count ?? 0 }} peer{{ (status.authenticated_peer_count ?? 0) === 1 ? '' : 's' }}</span>
</div>
<button
v-if="!status.anchor_connected"
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-orange-400/15 hover:bg-orange-400/25 text-orange-200 disabled:opacity-60 transition-colors"
:disabled="reconnecting"
@click="reconnectAnchor"
>
{{ reconnecting ? 'Reconnecting…' : 'Reconnect' }}
</button>
</div>
<p v-if="!status.anchor_connected" class="mt-2 text-[11px] text-white/40 leading-snug">
No known anchor is currently an authenticated peer. DHT routing to unknown npubs can't bootstrap; federation and messaging fall back to Tor until one reconnects. Reconnect restarts the FIPS daemon, which usually clears a stale identity cache. Add a cluster-local anchor in Seed Anchors if the public one is unreachable.
</p>
</div>
<div v-if="statusMessage" class="mb-3 p-3 rounded-lg text-xs" :class="statusIsError ? 'bg-red-400/10 text-red-300' : 'bg-green-400/10 text-green-300'">{{ statusMessage }}</div>
<div v-if="status.key_present && !status.service_active" class="flex gap-2 mt-auto pt-3 shrink-0">
<button class="flex-1 min-h-[44px] px-4 py-2 glass-button rounded-lg text-sm font-medium transition-colors" :disabled="installing" @click="installAndActivate">{{ installing ? 'Starting…' : 'Start' }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, onActivated, onDeactivated, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
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
}
// Shares `server.fips-summary` with the Local Network card's FIPS row, so
// both paint from the same cache instantly on revisit and never disagree.
// persist:false (T-02-01) — the response carries `npub`, this node's own
// FIPS identity public key; must match Server.vue's persist decision for
// this same key exactly (a mismatch would trip the dev-only entry() warning
// and leave it ambiguous which decision actually governs the shared cache).
const statusRes = useCachedResource<FipsStatus>({
key: 'server.fips-summary',
ttlMs: 15_000,
persist: false,
fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
})
const status = computed<FipsStatus>(() => statusRes.data.value ?? {
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 installAndActivate() {
installing.value = true
try {
const next = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
statusRes.optimistic(() => next) // confirmed server state, not a guess
flash('FIPS started')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
flash(`Start failed: ${msg}`, true)
} finally {
installing.value = false
}
}
// Restart the FIPS daemon and wait for the anchor bootstrap window.
// The backend runs a proper recovery sequence (stop → start → wait →
// classify) and returns a structured diagnostic we can show the user
// instead of a generic "still unreachable".
async function reconnectAnchor() {
reconnecting.value = true
try {
const res = await rpcClient.call<{
recovered: boolean
likely_cause: string
hint: string
after: FipsStatus
}>({ method: 'fips.reconnect', timeout: 60_000 })
// Update the card with the post-reconnect status returned by the
// backend — avoids an extra status fetch race.
statusRes.optimistic((cur) => ({ ...(cur ?? 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.
//
// 02-11 gap closure: this card is rendered inside Server.vue, which joined
// KEEP_ALIVE_PATHS in 02-04 — but this poll was only ever armed in
// onMounted and disarmed in onUnmounted, so once Server.vue (and this card
// with it) is KeepAlive'd, onUnmounted never fires again and the poll ran
// forever in the background, firing `fips.status` every 15s regardless of
// which dashboard tab was actually showing (`document.hidden` only helps
// for a backgrounded BROWSER tab, not an in-SPA tab switch). Arm/disarm now
// follows activate/deactivate, mirroring Server.vue's own vpnPollInterval
// fix from 02-04; calling armFipsPoll from both onMounted and onActivated
// on a fresh KeepAlive-wrapped mount is safe since it always clears any
// prior timer first.
let statusInterval: ReturnType<typeof setInterval> | null = null
function armFipsPoll() {
if (statusInterval) clearInterval(statusInterval)
statusInterval = setInterval(() => {
if (document.hidden) return
void statusRes.refresh()
}, 15000)
}
function disarmFipsPoll() {
if (statusInterval) {
clearInterval(statusInterval)
statusInterval = null
}
}
onMounted(() => {
armFipsPoll()
})
onActivated(() => {
armFipsPoll()
})
onDeactivated(() => {
disarmFipsPoll()
})
onUnmounted(() => {
disarmFipsPoll()
})
</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 { computed, reactive, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
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 anchorsRes = useCachedResource<SeedAnchor[]>({
key: 'server.fips-seed-anchors',
fetcher: async (signal) => {
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
method: 'fips.list-seed-anchors', signal, dedup: true, maxRetries: 1,
})
return res.seed_anchors
},
persist: false, // SeedAnchor carries an npub (peer identity key material) — T-02-01
})
const anchors = computed(() => anchorsRes.data.value ?? [])
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 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(),
},
})
anchorsRes.optimistic(() => res.seed_anchors) // authoritative post-add list
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 },
})
anchorsRes.optimistic(() => 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
}
}
</script>
@@ -0,0 +1,979 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
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
}
// Router status is cached via the shared useCachedResource hook so revisits
// paint the last-known state instantly and a mount inside the TTL issues no
// new RPC. No item id in this key — there is exactly one configured gateway
// per node, not a per-item collection (route is the fixed `server/openwrt`,
// no :id param). This fetcher only ever re-fetches with `{}` params (the
// server-persisted config, no credentials needed) — an explicit-params call
// (Connect form submit, or any caller passing connectedParams) never routes
// through this fetcher at all; see load()'s bypass path below (WR-03).
const routerResource = useCachedResource<RouterStatus>({
key: 'server.openwrt-status',
fetcher: (signal) => rpcClient.call<RouterStatus>({
method: 'openwrt.get-status',
params: {},
signal,
timeout: 30000,
dedup: true,
maxRetries: 1,
}),
ttlMs: 30_000,
persist: true, // router/network status (host/uptime/wifi/wan) — no identity/money payload
immediate: false, // initial fetch is gated explicitly in onMounted below
})
const status = computed(() => routerResource.data.value)
// 'refreshing' deliberately excluded (D-07, keep-last-value): a background
// TTL revalidation must not hide the already-rendered status panels behind
// the full loading skeleton — only a true first-load (no data at all yet)
// blocks on it. Previously this conflated the two, so a cache hit would
// still briefly show the skeleton on every mount instead of painting
// instantly.
const loading = computed(() => {
const s = routerResource.loadState.value
return routerResource.data.value === null && (s === 'loading' || s === 'idle')
})
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)
// Force-fetch: always refetches regardless of TTL. Used by every explicit
// user action (connect, tollgate provision/save) that must reflect a fresh
// server-side state, and by onMounted below when the cache is missing/stale.
//
// When explicit params are supplied, this bypasses routerResource.refresh()
// and calls rpcClient directly instead (WR-03): resources.ts's
// store.refresh() dedupes concurrent calls for the same key, so routing an
// explicit-params call (e.g. the Connect form's submit) through it could
// silently coalesce into an unrelated refresh already in flight (e.g.
// useCachedResource's own TTL-gated onActivated/focus revalidation) —
// the caller's own params would never actually be sent, and the caller has
// no way to tell. Writing the resolved result directly into
// routerResource.entry keeps the cache/TTL/status-panel rendering
// consistent with what a normal refresh() success does.
async function load(params?: Record<string, string>) {
error.value = ''
if (params) {
try {
const result = await rpcClient.call<RouterStatus>({
method: 'openwrt.get-status',
params,
timeout: 30000,
maxRetries: 1,
})
routerResource.entry.data = result
routerResource.entry.error = null
routerResource.entry.fetchedAt = Date.now()
routerResource.entry.loadState = 'ready'
showConnectForm.value = false
connectedParams.value = params
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
if (message.includes('No router configured')) {
showConnectForm.value = true
} else {
error.value = message
}
}
return
}
await routerResource.refresh()
const err = routerResource.error.value
if (err) {
if (err.includes('No router configured')) {
showConnectForm.value = true
} else {
error.value = err
}
} else {
showConnectForm.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 || ''
})
// Only force-fetch when the cache is missing or past its TTL, so a repeat
// open inside the TTL paints from cache with no new RPC. On a within-TTL
// remount, still reconcile the connect-form/error UI state from the cached
// entry (mirrors what a completed load() call would have set).
onMounted(() => {
if (routerResource.data.value === null || routerResource.isStale.value) {
void load()
return
}
const err = routerResource.error.value
if (err) {
if (err.includes('No router configured')) showConnectForm.value = true
else error.value = err
} else {
showConnectForm.value = false
}
})
</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>
+275
View File
@@ -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>