feat(openwrt): add OpenWrt gateway status view and get-status RPC
Backend: new `openwrt.get-status` RPC endpoint SSHes into the saved (or provided) OpenWrt router and returns system info, TollGate config, and WiFi AP interfaces via UCI. Frontend: new OpenWrtGateway.vue view at /dashboard/server/openwrt shows system hostname, OpenWrt version, uptime, TollGate install/enable state with pricing and mint URL, and all AP-mode WiFi interfaces. Linked from the Local Network section of the Server view. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e0cc00be0f
commit
d71f36370d
@ -232,6 +232,7 @@ impl RpcHandler {
|
||||
|
||||
// OpenWrt / TollGate
|
||||
"openwrt.scan" => self.handle_openwrt_scan(params).await,
|
||||
"openwrt.get-status" => self.handle_openwrt_get_status(params).await,
|
||||
"openwrt.provision-tollgate" => self.handle_openwrt_provision_tollgate(params).await,
|
||||
|
||||
// Ecash wallet
|
||||
|
||||
@ -5,6 +5,7 @@ use archipelago_openwrt::{
|
||||
router::Router,
|
||||
tollgate::{self, TollGateConfig},
|
||||
};
|
||||
use crate::network::router as net_router;
|
||||
|
||||
/// Default port for the local Cashu mint (nutshell / cashu-mint app).
|
||||
const LOCAL_MINT_PORT: u16 = 3338;
|
||||
@ -40,6 +41,89 @@ impl RpcHandler {
|
||||
Ok(serde_json::json!({ "routers": ips }))
|
||||
}
|
||||
|
||||
/// Read current settings from a saved or ad-hoc OpenWrt router via SSH/UCI.
|
||||
///
|
||||
/// Params (all optional): `{ "host": "...", "ssh_user": "root", "ssh_password": "" }`
|
||||
/// If params are omitted the saved `router_config.json` credentials are used.
|
||||
pub(super) async fn handle_openwrt_get_status(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let saved = net_router::load_router_config(&self.config.data_dir).await?;
|
||||
let p = params.unwrap_or_default();
|
||||
|
||||
let host = p
|
||||
.get("host")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| if saved.configured { Some(saved.address.clone()) } else { None })
|
||||
.ok_or_else(|| anyhow::anyhow!("No router configured — provide host or call router.configure first"))?;
|
||||
|
||||
let ssh_user = p
|
||||
.get("ssh_user")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.username.clone())
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
|
||||
let ssh_password = p
|
||||
.get("ssh_password")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| saved.password.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let router = Router::connect_password(&host, 22, &ssh_user, &ssh_password)?;
|
||||
router.verify_openwrt()?;
|
||||
|
||||
// System info
|
||||
let release = router.run_ok("cat /etc/openwrt_release").unwrap_or_default();
|
||||
let hostname = router
|
||||
.uci_get("system.@system[0].hostname")
|
||||
.unwrap_or_else(|_| "unknown".into());
|
||||
let uptime_secs: u64 = router
|
||||
.run_ok("cat /proc/uptime")
|
||||
.unwrap_or_default()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.split('.').next())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
// TollGate
|
||||
let tollgate_installed = router
|
||||
.run("opkg list-installed | grep -q '^tollgate-module-basic-go '")
|
||||
.map(|(_, code)| code == 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
let tollgate = if tollgate_installed {
|
||||
serde_json::json!({
|
||||
"installed": true,
|
||||
"enabled": router.uci_get("tollgate.main.enabled").map(|v| v == "1").unwrap_or(false),
|
||||
"metric": router.uci_get("tollgate.main.metric").unwrap_or_default(),
|
||||
"step_size_ms": router.uci_get("tollgate.main.step_size").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"price_per_step":router.uci_get("tollgate.main.price_per_step").ok().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0),
|
||||
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
|
||||
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({ "installed": false })
|
||||
};
|
||||
|
||||
// WiFi interfaces
|
||||
let wifi_raw = router.run_ok("uci show wireless").unwrap_or_default();
|
||||
let wifi_interfaces = parse_wifi_interfaces(&wifi_raw);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"host": host,
|
||||
"hostname": hostname,
|
||||
"uptime_secs": uptime_secs,
|
||||
"release": parse_release(&release),
|
||||
"tollgate": tollgate,
|
||||
"wifi_interfaces": wifi_interfaces,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Provision TollGate on an OpenWrt router and create the "archipelago" SSID.
|
||||
///
|
||||
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
|
||||
@ -104,6 +188,54 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse /etc/openwrt_release key=value pairs into a JSON object.
|
||||
fn parse_release(raw: &str) -> serde_json::Value {
|
||||
let mut m = serde_json::Map::new();
|
||||
for line in raw.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
m.insert(
|
||||
k.to_lowercase(),
|
||||
serde_json::Value::String(v.trim_matches('"').to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(m)
|
||||
}
|
||||
|
||||
/// Extract AP wifi-iface sections from `uci show wireless` output.
|
||||
fn parse_wifi_interfaces(raw: &str) -> Vec<serde_json::Value> {
|
||||
use std::collections::HashMap;
|
||||
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
for line in raw.lines() {
|
||||
if let Some((lhs, rhs)) = line.trim().split_once('=') {
|
||||
let parts: Vec<&str> = lhs.splitn(3, '.').collect();
|
||||
if parts.len() == 3 && parts[0] == "wireless" {
|
||||
sections
|
||||
.entry(parts[1].to_string())
|
||||
.or_default()
|
||||
.insert(parts[2].to_string(), rhs.trim_matches('\'').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut ifaces: Vec<serde_json::Value> = sections
|
||||
.into_iter()
|
||||
.filter(|(_, f)| f.get("mode").map(|m| m == "ap").unwrap_or(false))
|
||||
.map(|(name, f)| serde_json::json!({
|
||||
"section": name,
|
||||
"ssid": f.get("ssid").cloned().unwrap_or_default(),
|
||||
"device": f.get("device").cloned().unwrap_or_default(),
|
||||
"encryption": f.get("encryption").cloned().unwrap_or_else(|| "none".into()),
|
||||
"network": f.get("network").cloned().unwrap_or_default(),
|
||||
"disabled": f.get("disabled").map(|v| v == "1").unwrap_or(false),
|
||||
}))
|
||||
.collect();
|
||||
|
||||
ifaces.sort_by_key(|v| v["section"].as_str().unwrap_or("").to_string());
|
||||
ifaces
|
||||
}
|
||||
|
||||
fn parse_ipv4(s: &str) -> Result<[u8; 4]> {
|
||||
let parts: Vec<&str> = s.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
|
||||
@ -176,6 +176,11 @@ const router = createRouter({
|
||||
name: 'server',
|
||||
component: () => import('../views/Server.vue'),
|
||||
},
|
||||
{
|
||||
path: 'server/openwrt',
|
||||
name: 'openwrt-gateway',
|
||||
component: () => import('../views/server/OpenWrtGateway.vue'),
|
||||
},
|
||||
{
|
||||
path: 'monitoring',
|
||||
name: 'monitoring',
|
||||
|
||||
@ -108,6 +108,16 @@
|
||||
</div>
|
||||
<span class="text-sm" :class="torStatusLabel === 'running' ? 'text-green-400' : 'text-white/60'">{{ torStatusLabel === 'running' ? 'Connected' : torStatusLabel === 'checking' ? 'Checking...' : 'Stopped' }}</span>
|
||||
</div>
|
||||
<router-link
|
||||
to="/dashboard/server/openwrt"
|
||||
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="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
|
||||
<span class="text-white/80 text-sm">OpenWrt Gateway</span>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</router-link>
|
||||
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-5 h-5 text-white/60" 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>
|
||||
|
||||
305
neode-ui/src/views/server/OpenWrtGateway.vue
Normal file
305
neode-ui/src/views/server/OpenWrtGateway.vue
Normal file
@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
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
|
||||
currency?: string
|
||||
mint_url?: string
|
||||
}
|
||||
|
||||
interface RouterStatus {
|
||||
host: string
|
||||
hostname: string
|
||||
uptime_secs: number
|
||||
release: ReleaseInfo
|
||||
tollgate: TollGateStatus
|
||||
wifi_interfaces: WifiInterface[]
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<BackButton />
|
||||
<h1 class="text-lg font-semibold text-white">OpenWrt Gateway</h1>
|
||||
</div>
|
||||
|
||||
<!-- Connect form (shown when no router is configured) -->
|
||||
<div v-if="showConnectForm" class="rounded-xl border border-white/10 bg-white/5 p-5 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/50 mb-1">Router IP</label>
|
||||
<input
|
||||
v-model="host"
|
||||
type="text"
|
||||
placeholder="192.168.1.1"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-white/50 mb-1">SSH User</label>
|
||||
<input
|
||||
v-model="sshUser"
|
||||
type="text"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-white/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-white/50 mb-1">Password</label>
|
||||
<input
|
||||
v-model="sshPassword"
|
||||
type="password"
|
||||
placeholder="(blank for none)"
|
||||
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:disabled="connecting || !host.trim()"
|
||||
class="w-full py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="connecting || !host.trim()
|
||||
? 'bg-white/5 text-white/30 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white'"
|
||||
@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="rounded-xl border border-white/10 bg-white/5 p-5 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="rounded-xl border border-red-500/30 bg-red-500/10 p-5 mb-4">
|
||||
<p class="text-sm text-red-300">{{ error }}</p>
|
||||
<button class="mt-3 text-xs text-white/50 hover:text-white underline" @click="load()">Retry</button>
|
||||
</div>
|
||||
|
||||
<!-- Status panels -->
|
||||
<template v-else-if="status">
|
||||
|
||||
<!-- System info -->
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-5 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/80">{{ openwrtVersion }}</dd>
|
||||
</div>
|
||||
<div v-if="boardName">
|
||||
<dt class="text-xs text-white/40 mb-0.5">Board</dt>
|
||||
<dd class="text-white/80">{{ boardName }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-white/40 mb-0.5">Uptime</dt>
|
||||
<dd class="text-white/80">{{ formatUptime(status.uptime_secs) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
class="mt-4 text-xs text-white/40 hover:text-white/70 transition-colors"
|
||||
@click="load()"
|
||||
>
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TollGate -->
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-5 mb-4">
|
||||
<h2 class="text-sm font-semibold text-white/80 mb-4">TollGate</h2>
|
||||
|
||||
<div v-if="!status.tollgate.installed" class="flex items-center gap-3">
|
||||
<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>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<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>
|
||||
<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/80 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/80 font-mono text-xs break-all">{{ status.tollgate.mint_url }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- WiFi interfaces -->
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-5">
|
||||
<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/40">
|
||||
No AP interfaces found.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="iface in status.wifi_interfaces"
|
||||
:key="iface.section"
|
||||
class="flex items-start justify-between py-3 border-b border-white/5 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>
|
||||
Loading…
x
Reference in New Issue
Block a user