Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit dc80b552a1
1639 changed files with 352647 additions and 0 deletions
@@ -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>
+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>