feat(cluster): Rolling Update — Secondary-first upgrade orchestration (v1.2.3)
POST /cluster/rolling-update startet den gestaffelten Upgrade-Prozess: 1. Secondary via mTLS /agent/cluster/trigger-update anstoßen 2. /agent/cluster/version pollen bis Secondary Version-Flip zeigt (max 10 min) 3. Primary self-upgrade via systemd-run (identisch zu /system/upgrade) State wird in /var/lib/edgeguard/rolling-update-state.json persistiert: Phasen: updating-secondary → waiting-secondary → updating-primary. "done" wird nicht geschrieben — Prozess stirbt beim Upgrade. UI erkennt Abschluss via /system/health version-flip (analog Single-Node-Upgrade). UI: UpdateBanner erkennt Cluster-Modus (/cluster/status mode="cluster") und tauscht den "Install now"-Button gegen "Rolling Update (Cluster)" aus. Multi-Step-Modal zeigt die drei Phasen; ab updating-primary wechselt der Client auf /system/health polling. Aggregator.PostPeer: neuer einzel-POST-Helper für mTLS-trigger-update. WithVersion(): ClusterHandler bekommt Binary-Version für /agent/cluster/version. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Alert, Button, Popconfirm, Space, Tooltip, message } from 'antd'
|
||||
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'
|
||||
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined, ClusterOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -20,6 +20,19 @@ interface SystemHealth { status: string; version: string }
|
||||
|
||||
interface PendingUpdate { pkg: string; installed: string; available: string }
|
||||
|
||||
interface ClusterStatus {
|
||||
mode: string // "single-node" | "cluster"
|
||||
peers: Array<{ id: string; fqdn: string }>
|
||||
}
|
||||
|
||||
interface RollingUpdateState {
|
||||
phase: string // idle | updating-secondary | waiting-secondary | updating-primary | failed
|
||||
secondary_fqdn: string
|
||||
secondary_id: string
|
||||
error?: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// allUpdates parsed das flache map-Format ({pkg_installed,pkg_available})
|
||||
// das /system/package-versions zurückliefert. Eines davon ist meist
|
||||
// das meta-Paket "edgeguard" → die "Ziel-Version".
|
||||
@@ -53,6 +66,41 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
const clusterStatus = useQuery({
|
||||
queryKey: ['cluster', 'status-update-banner'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const r = await apiClient.get('/cluster/status')
|
||||
return isEnvelope(r.data) ? (r.data.data as ClusterStatus) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const rollingStatus = useQuery({
|
||||
queryKey: ['cluster', 'rolling-update-status'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const r = await apiClient.get('/cluster/rolling-update/status')
|
||||
return isEnvelope(r.data) ? (r.data.data as RollingUpdateState) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
refetchInterval: 5_000,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
const isCluster = clusterStatus.data?.mode === 'cluster'
|
||||
const rollingPhase = rollingStatus.data?.phase ?? 'idle'
|
||||
const rollingActive = rollingPhase !== 'idle' && rollingPhase !== 'failed'
|
||||
const secondaryFQDN = rollingStatus.data?.secondary_fqdn ?? ''
|
||||
|
||||
// Normal single-node upgrade state
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [upgradeElapsed, setUpgradeElapsed] = useState(0)
|
||||
const [forceChecking, setForceChecking] = useState(false)
|
||||
@@ -61,11 +109,61 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
const installedRef = useRef<string>('')
|
||||
const targetRef = useRef<string>('')
|
||||
|
||||
// Rolling update elapsed counter
|
||||
const [rollingElapsed, setRollingElapsed] = useState(0)
|
||||
const rollingTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const rollingPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
|
||||
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
|
||||
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
|
||||
if (rollingPollRef.current) clearInterval(rollingPollRef.current)
|
||||
}, [])
|
||||
|
||||
// Start rolling elapsed timer when rolling becomes active
|
||||
useEffect(() => {
|
||||
if (rollingActive && !rollingTickRef.current) {
|
||||
setRollingElapsed(0)
|
||||
rollingTickRef.current = setInterval(() => setRollingElapsed(e => e + 1), 1000)
|
||||
} else if (!rollingActive && rollingTickRef.current) {
|
||||
clearInterval(rollingTickRef.current)
|
||||
rollingTickRef.current = null
|
||||
}
|
||||
}, [rollingActive])
|
||||
|
||||
// When phase reaches "updating-primary": switch to health polling
|
||||
// (primary will restart, state file can't be updated after that)
|
||||
useEffect(() => {
|
||||
if (rollingPhase === 'updating-primary' && !rollingPollRef.current) {
|
||||
const primaryInstalled = installedRef.current
|
||||
let sawDown = false
|
||||
rollingPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/system/health')
|
||||
const newV = isEnvelope(res.data) ? (res.data.data as SystemHealth).version : ''
|
||||
const flipped = newV && primaryInstalled && newV !== primaryInstalled
|
||||
if (flipped || sawDown) {
|
||||
if (rollingPollRef.current) clearInterval(rollingPollRef.current)
|
||||
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
|
||||
rollingPollRef.current = null
|
||||
rollingTickRef.current = null
|
||||
msg.success(t('update.success', { version: targetRef.current }))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
}
|
||||
} catch {
|
||||
sawDown = true
|
||||
}
|
||||
}, 3000)
|
||||
// Safety timeout
|
||||
setTimeout(() => {
|
||||
if (rollingPollRef.current) clearInterval(rollingPollRef.current)
|
||||
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
|
||||
window.location.reload()
|
||||
}, 120_000)
|
||||
}
|
||||
}, [rollingPhase, msg, t])
|
||||
|
||||
const data = pkgVersions.data ?? {}
|
||||
const updates = allUpdates(data)
|
||||
const updateAvailable = updates.length > 0
|
||||
@@ -76,14 +174,8 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
const forceCheck = async () => {
|
||||
setForceChecking(true)
|
||||
try {
|
||||
// ?force=1: bypassed den Server-seitigen 5-min-Throttle für
|
||||
// apt-get update. Ohne den Force-Hint würde der Endpoint
|
||||
// einfach den letzten Cache zurückliefern (max. 5 min alt) und
|
||||
// der Button fühlt sich kaputt an. Pattern aus mail-gateway.
|
||||
const r = await apiClient.get('/system/package-versions?force=1')
|
||||
const fresh = (isEnvelope(r.data) ? (r.data.data as PackageVersions) : {})
|
||||
// useQuery-Cache mit dem frischen Wert füttern damit der Banner
|
||||
// sofort umschaltet, ohne auf die nächste 30s-Welle zu warten.
|
||||
void pkgVersions.refetch()
|
||||
const found = allUpdates(fresh).length > 0
|
||||
msg[found ? 'success' : 'info'](
|
||||
@@ -105,8 +197,6 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
|
||||
apiClient.post('/system/upgrade')
|
||||
.then(() => {
|
||||
// Poll /healthz (kein Auth, robust auch wenn die API gerade
|
||||
// restartet und Cookie ihre Session nicht erkennt).
|
||||
let sawDown = false
|
||||
upgradePollRef.current = setInterval(async () => {
|
||||
try {
|
||||
@@ -121,14 +211,9 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
}
|
||||
} catch {
|
||||
// Connection refused / 502 → API restartet. Beim nächsten
|
||||
// erfolgreichen Poll erkennen wir den Version-Flip.
|
||||
sawDown = true
|
||||
}
|
||||
}, 3000)
|
||||
// Sicherheits-Timeout: nach 2 Min einfach reload — falls der
|
||||
// Restart länger braucht als erwartet, kommt die UI in jedem
|
||||
// Fall wieder hoch.
|
||||
setTimeout(() => {
|
||||
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
|
||||
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
|
||||
@@ -144,13 +229,19 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
})
|
||||
}
|
||||
|
||||
const startRollingUpdate = () => {
|
||||
installedRef.current = installedVersion
|
||||
targetRef.current = targetVersion
|
||||
apiClient.post('/cluster/rolling-update')
|
||||
.then(() => {
|
||||
void rollingStatus.refetch()
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
msg.error(t('update.failed') + ': ' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
// Compact-Variante: Force-Check-Button für "ich will jetzt prüfen",
|
||||
// wenn aktuell NICHTS ausstehendes da ist. Sobald ein Update
|
||||
// verfügbar ist, übernimmt der gelbe Full-Mode-Banner (in
|
||||
// AppLayout) die Sichtbarkeit — wir blenden den Compact-Button
|
||||
// dann komplett aus, sonst doppelt-doppelt Info (Befund 2026-05-15:
|
||||
// "die roten Banner können weg, der gelbe Banner reicht").
|
||||
if (updateAvailable) {
|
||||
return <>{msgCtx}</>
|
||||
}
|
||||
@@ -171,7 +262,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
)
|
||||
}
|
||||
|
||||
if (!updateAvailable && !upgrading) {
|
||||
if (!updateAvailable && !upgrading && !rollingActive) {
|
||||
return <>{msgCtx}</>
|
||||
}
|
||||
|
||||
@@ -179,7 +270,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
<>
|
||||
{msgCtx}
|
||||
|
||||
{updateAvailable && !upgrading && (
|
||||
{updateAvailable && !upgrading && !rollingActive && (
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
@@ -199,17 +290,33 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
>
|
||||
{t('update.checkNow')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('update.confirmTitle')}
|
||||
description={t('update.confirmDesc', { version: targetVersion })}
|
||||
okText={t('update.applyNow')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={startUpgrade}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
|
||||
{t('update.applyNow')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{isCluster ? (
|
||||
<Popconfirm
|
||||
title={t('update.rollingConfirmTitle')}
|
||||
description={t('update.rollingConfirmDesc', {
|
||||
secondary: clusterStatus.data?.peers?.[0]?.fqdn ?? 'secondary',
|
||||
})}
|
||||
okText={t('update.rollingUpdate')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={startRollingUpdate}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<ClusterOutlined />}>
|
||||
{t('update.rollingUpdate')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title={t('update.confirmTitle')}
|
||||
description={t('update.confirmDesc', { version: targetVersion })}
|
||||
okText={t('update.applyNow')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={startUpgrade}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
|
||||
{t('update.applyNow')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
@@ -234,26 +341,10 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
</div>
|
||||
|
||||
<div className="update-modal__steps">
|
||||
<Step
|
||||
done={upgradeElapsed >= 5}
|
||||
active={upgradeElapsed < 5}
|
||||
label={t('update.stepDownload')}
|
||||
/>
|
||||
<Step
|
||||
done={upgradeElapsed >= 15}
|
||||
active={upgradeElapsed >= 5 && upgradeElapsed < 15}
|
||||
label={t('update.stepInstall')}
|
||||
/>
|
||||
<Step
|
||||
done={upgradeElapsed >= 25}
|
||||
active={upgradeElapsed >= 15 && upgradeElapsed < 25}
|
||||
label={t('update.stepRestart')}
|
||||
/>
|
||||
<Step
|
||||
done={false}
|
||||
active={upgradeElapsed >= 25}
|
||||
label={t('update.stepVerify')}
|
||||
/>
|
||||
<Step done={upgradeElapsed >= 5} active={upgradeElapsed < 5} label={t('update.stepDownload')} />
|
||||
<Step done={upgradeElapsed >= 15} active={upgradeElapsed >= 5 && upgradeElapsed < 15} label={t('update.stepInstall')} />
|
||||
<Step done={upgradeElapsed >= 25} active={upgradeElapsed >= 15 && upgradeElapsed < 25} label={t('update.stepRestart')} />
|
||||
<Step done={false} active={upgradeElapsed >= 25} label={t('update.stepVerify')} />
|
||||
</div>
|
||||
|
||||
<div className="update-modal__timer">{upgradeElapsed}s</div>
|
||||
@@ -261,6 +352,54 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rollingActive && (
|
||||
<div className="update-modal-overlay">
|
||||
<div className="update-modal">
|
||||
<div className="update-modal__orbit">
|
||||
<div className="update-modal__ring" />
|
||||
<div className="update-modal__ring update-modal__ring--2" />
|
||||
<div className="update-modal__dot" />
|
||||
<div className="update-modal__dot update-modal__dot--2" />
|
||||
<div className="update-modal__center">
|
||||
<ClusterOutlined className="update-modal__icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="update-modal__title">{t('update.rollingRunning')}</div>
|
||||
<div className="update-modal__version">
|
||||
v{installedRef.current || '…'} → v{targetRef.current || '…'}
|
||||
</div>
|
||||
|
||||
<div className="update-modal__steps">
|
||||
<Step
|
||||
done={rollingPhase === 'waiting-secondary' || rollingPhase === 'updating-primary'}
|
||||
active={rollingPhase === 'updating-secondary'}
|
||||
label={t('update.rollingStepSecondary', { fqdn: secondaryFQDN })}
|
||||
/>
|
||||
<Step
|
||||
done={rollingPhase === 'updating-primary'}
|
||||
active={rollingPhase === 'waiting-secondary'}
|
||||
label={t('update.rollingStepWaiting')}
|
||||
/>
|
||||
<Step
|
||||
done={false}
|
||||
active={rollingPhase === 'updating-primary'}
|
||||
label={t('update.rollingStepPrimary')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="update-modal__timer">{rollingElapsed}s</div>
|
||||
<div className="update-modal__hint">{t('update.waitHint')}</div>
|
||||
|
||||
{rollingStatus.data?.error && (
|
||||
<div className="update-modal__hint" style={{ color: '#ff4d4f' }}>
|
||||
{rollingStatus.data.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -629,6 +629,7 @@
|
||||
"node": "Knoten",
|
||||
"status": "Status",
|
||||
"role": "Rolle",
|
||||
"pgRole": "PG-Rolle",
|
||||
"apiUrl": "API-URL",
|
||||
"configHash": "Config-Hash",
|
||||
"version": "Version",
|
||||
@@ -641,6 +642,30 @@
|
||||
"uptime": "Uptime",
|
||||
"fetchMs": "Fetch"
|
||||
},
|
||||
"pgRole": {
|
||||
"standalone": "standalone",
|
||||
"primary": "primary",
|
||||
"standby": "standby"
|
||||
},
|
||||
"vipCard": {
|
||||
"title": "Hochverfügbarkeit (VIP / Keepalived)",
|
||||
"vipAddress": "VIP-Adresse",
|
||||
"vipAddressHelp": "Virtuelle IP-Adresse die zwischen Nodes wandert (z.B. 89.163.205.10)",
|
||||
"vipInterface": "Netzwerk-Interface",
|
||||
"vipInterfaceHelp": "Interface auf dem die VIP gebunden wird (z.B. eth0)",
|
||||
"vipAuthPass": "VRRP Auth-Passwort",
|
||||
"vipAuthPassHelp": "Max. 8 Zeichen — Keepalived-Limit. Gleich auf allen Nodes.",
|
||||
"vrrpRouterId": "VRRP Router-ID",
|
||||
"vrrpRouterIdHelp": "Muss im Subnetz eindeutig sein (1–255). Standard: 51.",
|
||||
"saveBtn": "Speichern & Keepalived neu konfigurieren",
|
||||
"saved": "VIP-Einstellungen gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"hintTitle": "Nächste Schritte nach dem Speichern",
|
||||
"hintPrimary": "Auf dem Primary: edgeguard-ctl cluster-init-replication",
|
||||
"hintStandby": "Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
|
||||
"hintKeepalived": "Keepalived auf beiden Nodes: sudo systemctl enable --now keepalived",
|
||||
"hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)"
|
||||
},
|
||||
"loadTitle": "Per-Node Resources (mTLS-Aggregator)",
|
||||
"loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?",
|
||||
"certCardTitle": "Cluster-TLS-Zertifikate",
|
||||
@@ -841,7 +866,14 @@
|
||||
"stepDownload": "Pakete laden",
|
||||
"stepInstall": "Installation",
|
||||
"stepRestart": "Service-Restart",
|
||||
"stepVerify": "Verifizierung"
|
||||
"stepVerify": "Verifizierung",
|
||||
"rollingUpdate": "Rolling Update (Cluster)",
|
||||
"rollingConfirmTitle": "Rolling Update starten?",
|
||||
"rollingConfirmDesc": "Der Secondary-Node ({{secondary}}) wird zuerst aktualisiert, danach dieser Primary. Kein Ausfall für den Proxied-Traffic während der Secondary-Phase.",
|
||||
"rollingRunning": "Rolling Update läuft…",
|
||||
"rollingStepSecondary": "Secondary aktualisieren ({{fqdn}})",
|
||||
"rollingStepWaiting": "Warte auf Neustart des Secondary",
|
||||
"rollingStepPrimary": "Primary aktualisieren (dieser Node)"
|
||||
},
|
||||
"wg": {
|
||||
"title": "WireGuard",
|
||||
|
||||
@@ -629,6 +629,7 @@
|
||||
"node": "Node",
|
||||
"status": "Status",
|
||||
"role": "Role",
|
||||
"pgRole": "PG role",
|
||||
"apiUrl": "API URL",
|
||||
"configHash": "Config hash",
|
||||
"version": "Version",
|
||||
@@ -641,6 +642,30 @@
|
||||
"uptime": "Uptime",
|
||||
"fetchMs": "Fetch"
|
||||
},
|
||||
"pgRole": {
|
||||
"standalone": "standalone",
|
||||
"primary": "primary",
|
||||
"standby": "standby"
|
||||
},
|
||||
"vipCard": {
|
||||
"title": "High Availability (VIP / Keepalived)",
|
||||
"vipAddress": "VIP address",
|
||||
"vipAddressHelp": "Virtual IP address that moves between nodes (e.g. 89.163.205.10)",
|
||||
"vipInterface": "Network interface",
|
||||
"vipInterfaceHelp": "Interface to bind the VIP on (e.g. eth0)",
|
||||
"vipAuthPass": "VRRP auth password",
|
||||
"vipAuthPassHelp": "Max. 8 characters — Keepalived limit. Same on all nodes.",
|
||||
"vrrpRouterId": "VRRP router ID",
|
||||
"vrrpRouterIdHelp": "Must be unique in the subnet (1–255). Default: 51.",
|
||||
"saveBtn": "Save & reconfigure Keepalived",
|
||||
"saved": "VIP settings saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"hintTitle": "Next steps after saving",
|
||||
"hintPrimary": "On primary: edgeguard-ctl cluster-init-replication",
|
||||
"hintStandby": "On secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
|
||||
"hintKeepalived": "Keepalived on both nodes: sudo systemctl enable --now keepalived",
|
||||
"hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)"
|
||||
},
|
||||
"loadTitle": "Per-node resources (mTLS aggregator)",
|
||||
"loadEmpty": "No node resources available — agent listener unreachable?",
|
||||
"certCardTitle": "Cluster TLS certificates",
|
||||
@@ -841,7 +866,14 @@
|
||||
"stepDownload": "Download packages",
|
||||
"stepInstall": "Install",
|
||||
"stepRestart": "Service restart",
|
||||
"stepVerify": "Verification"
|
||||
"stepVerify": "Verification",
|
||||
"rollingUpdate": "Rolling Update (Cluster)",
|
||||
"rollingConfirmTitle": "Start Rolling Update?",
|
||||
"rollingConfirmDesc": "The secondary node ({{secondary}}) is updated first, then this primary. No downtime for proxied traffic during the secondary phase.",
|
||||
"rollingRunning": "Rolling update in progress…",
|
||||
"rollingStepSecondary": "Updating secondary ({{fqdn}})",
|
||||
"rollingStepWaiting": "Waiting for secondary restart",
|
||||
"rollingStepPrimary": "Updating primary (this node)"
|
||||
},
|
||||
"wg": {
|
||||
"title": "WireGuard",
|
||||
|
||||
Reference in New Issue
Block a user