feat(remote-speakers): enhance remote speaker management and API integration
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped
- Added support for remote speaker configuration in the README and documentation. - Implemented a new endpoint for retrieving the bundle signing public key. - Updated the `evobgp-agent` to include a `serve` command for Panel→Node sync API. - Enhanced CI workflow to validate remote speaker compose files. - Introduced new fields in the API and UI for managing speaker metadata, including dispatch status and sync status. - Improved error handling and response formatting in speaker-related API endpoints. - Updated documentation to reflect changes in remote speaker functionality and usage guidelines.
This commit is contained in:
@@ -177,14 +177,30 @@ export type SpeakerRow = {
|
||||
role: string;
|
||||
endpoint: string;
|
||||
last_applied_revision_id: string | null;
|
||||
published_revision_id?: string | null;
|
||||
published_at?: string | null;
|
||||
agent_domain?: string;
|
||||
node_ipv4?: string;
|
||||
bird_bgp_source_ipv4?: string;
|
||||
dispatch_status?: string;
|
||||
sync_status?: string;
|
||||
last_dispatch_at?: string | null;
|
||||
last_dispatch_error?: string | null;
|
||||
meta_json?: Record<string, unknown>;
|
||||
agent_secret?: string;
|
||||
};
|
||||
export type SpeakersResponse = Page<SpeakerRow>;
|
||||
export type BgpSpeakerCreate = {
|
||||
endpoint: string;
|
||||
role?: string;
|
||||
meta_json?: string;
|
||||
};
|
||||
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>;
|
||||
|
||||
export type BundleSigningPublicKey = {
|
||||
public_key_base64: string;
|
||||
};
|
||||
|
||||
// ---- Revisions ----
|
||||
export type RevisionRow = {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
@@ -15,8 +15,10 @@
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '$lib/ui/core/dialog/index.js';
|
||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
@@ -24,6 +26,7 @@
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
|
||||
type Props = {
|
||||
items: SpeakerRow[];
|
||||
@@ -35,41 +38,209 @@
|
||||
|
||||
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
|
||||
|
||||
type SpeakerForm = {
|
||||
endpoint: string;
|
||||
role: string;
|
||||
agent_domain: string;
|
||||
node_ipv4: string;
|
||||
bird_bgp_source_ipv4: string;
|
||||
bgpSourceManual: boolean;
|
||||
};
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let wizardOpen = $state(false);
|
||||
let applyDialogOpen = $state(false);
|
||||
let composeDialogOpen = $state(false);
|
||||
let editTarget = $state<SpeakerRow | null>(null);
|
||||
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
|
||||
let applyTarget = $state<SpeakerRow | null>(null);
|
||||
let composeTarget = $state<SpeakerRow | null>(null);
|
||||
let applyRevisionId = $state('');
|
||||
let composeText = $state('');
|
||||
let createdSpeaker = $state<SpeakerRow | null>(null);
|
||||
let form = $state<SpeakerForm>({
|
||||
endpoint: '',
|
||||
role: 'replica',
|
||||
agent_domain: '',
|
||||
node_ipv4: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bgpSourceManual: false
|
||||
});
|
||||
let saving = $state(false);
|
||||
let applyingId = $state<string | null>(null);
|
||||
|
||||
const columns = [
|
||||
{ id: 'status', label: 'Статус' },
|
||||
{
|
||||
id: 'endpoint',
|
||||
label: 'Endpoint',
|
||||
id: 'agent_domain',
|
||||
label: 'Agent domain',
|
||||
sortable: true,
|
||||
sortValue: (s: SpeakerRow) => s.endpoint
|
||||
sortValue: (s: SpeakerRow) => s.agent_domain ?? s.endpoint
|
||||
},
|
||||
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
||||
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
|
||||
{ id: 'actions', label: '', class: 'w-32' }
|
||||
{ id: 'drift', label: 'Drift' },
|
||||
{ id: 'actions', label: '', class: 'w-40' }
|
||||
] as const;
|
||||
|
||||
function parseIpv4FromEndpoint(ep: string): string {
|
||||
try {
|
||||
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`);
|
||||
const host = u.hostname;
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function onNodeIPv4Change(ip: string) {
|
||||
form.node_ipv4 = ip;
|
||||
if (!form.bgpSourceManual) {
|
||||
form.bird_bgp_source_ipv4 = ip;
|
||||
}
|
||||
}
|
||||
|
||||
function onEndpointChange(ep: string) {
|
||||
form.endpoint = ep;
|
||||
const ip = parseIpv4FromEndpoint(ep);
|
||||
if (ip && !form.node_ipv4) {
|
||||
onNodeIPv4Change(ip);
|
||||
}
|
||||
}
|
||||
|
||||
function emptyForm(): SpeakerForm {
|
||||
return {
|
||||
endpoint: '',
|
||||
role: 'replica',
|
||||
agent_domain: '',
|
||||
node_ipv4: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bgpSourceManual: false
|
||||
};
|
||||
}
|
||||
|
||||
function formFromSpeaker(s: SpeakerRow): SpeakerForm {
|
||||
return {
|
||||
endpoint: s.endpoint,
|
||||
role: s.role,
|
||||
agent_domain: s.agent_domain ?? '',
|
||||
node_ipv4: s.node_ipv4 ?? '',
|
||||
bird_bgp_source_ipv4: s.bird_bgp_source_ipv4 ?? s.node_ipv4 ?? '',
|
||||
bgpSourceManual: Boolean(s.bird_bgp_source_ipv4 && s.node_ipv4 && s.bird_bgp_source_ipv4 !== s.node_ipv4)
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetaJson(f: SpeakerForm): string {
|
||||
const meta: Record<string, string> = {};
|
||||
if (f.agent_domain.trim()) meta.agent_domain = f.agent_domain.trim();
|
||||
if (f.node_ipv4.trim()) meta.node_ipv4 = f.node_ipv4.trim();
|
||||
if (f.bird_bgp_source_ipv4.trim()) meta.bird_bgp_source_ipv4 = f.bird_bgp_source_ipv4.trim();
|
||||
return JSON.stringify(meta);
|
||||
}
|
||||
|
||||
function buildApiBody(f: SpeakerForm): BgpSpeakerCreate {
|
||||
const ep =
|
||||
f.endpoint.trim() ||
|
||||
(f.agent_domain.trim() ? `https://${f.agent_domain.trim()}` : '');
|
||||
return {
|
||||
endpoint: ep,
|
||||
role: f.role.trim() || 'replica',
|
||||
meta_json: buildMetaJson(f)
|
||||
};
|
||||
}
|
||||
|
||||
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (s.sync_status === 'synced' || s.dispatch_status === 'ok') return 'default';
|
||||
if (s.sync_status === 'error' || s.dispatch_status === 'error') return 'destructive';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function statusLabel(s: SpeakerRow): string {
|
||||
if (s.sync_status === 'synced') return 'Connected';
|
||||
if (s.sync_status === 'error' || s.last_dispatch_error) return 'Offline';
|
||||
if (s.dispatch_status === 'ok') return 'Synced';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function driftLabel(s: SpeakerRow): string {
|
||||
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
|
||||
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
|
||||
return `${app} / ${pub}`;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { endpoint: '', role: 'operator' };
|
||||
form = emptyForm();
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(s: SpeakerRow) {
|
||||
editTarget = s;
|
||||
form = { endpoint: s.endpoint, role: s.role };
|
||||
form = formFromSpeaker(s);
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
async function applySpeaker(id: string) {
|
||||
applyingId = id;
|
||||
function openApply(s: SpeakerRow) {
|
||||
applyTarget = s;
|
||||
applyRevisionId = s.published_revision_id ?? '';
|
||||
applyDialogOpen = true;
|
||||
}
|
||||
|
||||
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
|
||||
let pubkey = '';
|
||||
try {
|
||||
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
|
||||
const pk = await apiJSON<BundleSigningPublicKey>('/v1/bundle/signing-public-key');
|
||||
pubkey = pk.public_key_base64;
|
||||
} catch {
|
||||
pubkey = '<GET /v1/bundle/signing-public-key>';
|
||||
}
|
||||
const domain = s.agent_domain ?? 'bgp-dc.example.com';
|
||||
return `# deploy/compose/docker-compose.remote-speaker.yaml
|
||||
# cp .env.remote-speaker.example .env.remote-speaker
|
||||
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
|
||||
|
||||
EVOBGP_SPEAKER_ID=${s.id}
|
||||
EVOBGP_AGENT_SECRET=<from UI wizard>
|
||||
EVOBGP_NODE_TOKEN=<node API key from /access>
|
||||
EVOBGP_BUNDLE_PUBKEY_BASE64=${pubkey}
|
||||
EVOBGP_CONTROL_PLANE_URL=https://<your-cp-host>:8080
|
||||
|
||||
AGENT_DOMAIN=${domain}
|
||||
PANEL_IP_WHITELIST=<CP public IP>/32
|
||||
[email protected]
|
||||
CF_DNS_API_TOKEN=<cloudflare token>
|
||||
|
||||
# docker compose -f docker-compose.remote-speaker.yaml \\
|
||||
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls \\
|
||||
# --profile production up -d`;
|
||||
}
|
||||
|
||||
async function openCompose(s: SpeakerRow) {
|
||||
composeTarget = s;
|
||||
composeText = await buildComposeSnippet(s);
|
||||
composeDialogOpen = true;
|
||||
}
|
||||
|
||||
async function copyCompose() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(composeText);
|
||||
notify.success('Скопировано');
|
||||
} catch {
|
||||
notify.error('Не удалось скопировать');
|
||||
}
|
||||
}
|
||||
|
||||
async function applySpeaker() {
|
||||
if (!applyTarget || !applyRevisionId.trim()) {
|
||||
notify.error('Укажите revision_id');
|
||||
return;
|
||||
}
|
||||
applyingId = applyTarget.id;
|
||||
try {
|
||||
await apiMutate(`/v1/speakers/${applyTarget.id}/apply`, 'POST', {
|
||||
revision_id: applyRevisionId.trim()
|
||||
});
|
||||
notify.success('Apply запущен');
|
||||
applyDialogOpen = false;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
@@ -78,20 +249,25 @@
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.endpoint.trim()) {
|
||||
notify.error('Укажите endpoint');
|
||||
const body = buildApiBody(form);
|
||||
if (!body.endpoint.trim()) {
|
||||
notify.error('Укажите endpoint или agent domain');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
|
||||
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', body);
|
||||
notify.success('Спикер обновлён');
|
||||
dialogOpen = false;
|
||||
} else {
|
||||
await apiMutate('/v1/speakers', 'POST', form);
|
||||
const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
|
||||
notify.success('Спикер создан');
|
||||
dialogOpen = false;
|
||||
createdSpeaker = created;
|
||||
composeText = await buildComposeSnippet(created);
|
||||
wizardOpen = true;
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
@@ -99,6 +275,17 @@
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAgentSecret() {
|
||||
const secret = createdSpeaker?.agent_secret;
|
||||
if (!secret) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
notify.success('agent_secret скопирован');
|
||||
} catch {
|
||||
notify.error('Не удалось скопировать');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
@@ -107,7 +294,7 @@
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="text-base">Спикеры</CardTitle>
|
||||
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription>
|
||||
<CardDescription>Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)</CardDescription>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||
@@ -121,28 +308,32 @@
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет спикеров"
|
||||
emptyDescription="Добавьте BIRD-агент для применения конфигурации."
|
||||
emptyDescription="Добавьте реплику для применения signed bundle."
|
||||
>
|
||||
{#snippet cell({ row: s, column })}
|
||||
{#if column.id === 'endpoint'}
|
||||
<span class="font-mono text-sm">{s.endpoint}</span>
|
||||
{#if column.id === 'status'}
|
||||
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
|
||||
{:else if column.id === 'agent_domain'}
|
||||
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
|
||||
{:else if column.id === 'role'}
|
||||
<Badge variant="outline">{s.role}</Badge>
|
||||
{:else if column.id === 'last_applied_revision_id'}
|
||||
<span class="font-mono text-xs text-muted-foreground">
|
||||
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
|
||||
{:else if column.id === 'drift'}
|
||||
<span class="font-mono text-xs text-muted-foreground" title="applied / published">
|
||||
{driftLabel(s)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
|
||||
<Copy class="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
title="Запустить применение ревизии на спикере"
|
||||
onclick={() => applySpeaker(s.id)}
|
||||
title="Apply revision (canary)"
|
||||
onclick={() => openApply(s)}
|
||||
disabled={applyingId === s.id}
|
||||
>
|
||||
<Play class="size-3" />
|
||||
{applyingId === s.id ? 'Apply…' : 'Apply'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
|
||||
<Pencil class="size-3.5" />
|
||||
@@ -155,16 +346,48 @@
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<FormField label="Endpoint" id="s-endpoint" required>
|
||||
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
|
||||
<FormField label="Agent domain (FQDN)" id="s-domain">
|
||||
<AppInput
|
||||
id="s-domain"
|
||||
placeholder="bgp-dc2.example.com"
|
||||
bind:value={form.agent_domain}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Endpoint" id="s-endpoint">
|
||||
<AppInput
|
||||
id="s-endpoint"
|
||||
placeholder="https://bgp-dc2.example.com"
|
||||
value={form.endpoint}
|
||||
oninput={(e) => onEndpointChange((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="IP ноды (IPv4)" id="s-node-ip">
|
||||
<AppInput
|
||||
id="s-node-ip"
|
||||
placeholder="203.0.113.10"
|
||||
value={form.node_ipv4}
|
||||
oninput={(e) => onNodeIPv4Change((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="BGP source IPv4" id="s-bgp-src">
|
||||
<AppInput
|
||||
id="s-bgp-src"
|
||||
placeholder="= IP ноды"
|
||||
bind:value={form.bird_bgp_source_ipv4}
|
||||
disabled={!form.bgpSourceManual}
|
||||
/>
|
||||
</FormField>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox bind:checked={form.bgpSourceManual} />
|
||||
Задать BGP source вручную
|
||||
</label>
|
||||
<FormField label="Роль" id="s-role">
|
||||
<AppInput id="s-role" placeholder="operator" bind:value={form.role} />
|
||||
<AppInput id="s-role" placeholder="replica" bind:value={form.role} />
|
||||
</FormField>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -175,3 +398,67 @@
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog bind:open={wizardOpen}>
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Спикер создан</DialogTitle>
|
||||
<DialogDescription>
|
||||
Сохраните agent_secret — он больше не отображается. Скопируйте compose на VPS реплики.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{#if createdSpeaker?.agent_secret}
|
||||
<FormField label="agent_secret (один раз)" id="w-secret">
|
||||
<div class="flex gap-2">
|
||||
<AppInput id="w-secret" readonly value={createdSpeaker.agent_secret} class="font-mono text-xs" />
|
||||
<Button variant="outline" size="icon-sm" onclick={copyAgentSecret}><Copy /></Button>
|
||||
</div>
|
||||
</FormField>
|
||||
{/if}
|
||||
<FormField label="docker-compose env" id="w-compose">
|
||||
<textarea
|
||||
id="w-compose"
|
||||
class="min-h-[200px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
|
||||
readonly
|
||||
value={composeText}
|
||||
></textarea>
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={copyCompose}><Copy />Copy compose</Button>
|
||||
<Button onclick={() => (wizardOpen = false)}>Готово</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog bind:open={applyDialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Apply на спикер</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FormField label="revision_id" id="a-rev" required>
|
||||
<AppInput id="a-rev" bind:value={applyRevisionId} class="font-mono text-xs" />
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (applyDialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={applySpeaker} disabled={applyingId != null}>Apply</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog bind:open={composeDialogOpen}>
|
||||
<DialogContent class="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Copy docker-compose</DialogTitle>
|
||||
<DialogDescription>Спикер {composeTarget?.agent_domain ?? composeTarget?.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
class="min-h-[240px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
|
||||
readonly
|
||||
value={composeText}
|
||||
></textarea>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={copyCompose}><Copy />Копировать</Button>
|
||||
<Button onclick={() => (composeDialogOpen = false)}>Закрыть</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user