mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -04:00
Fix React errors, add retry countdowns, stats depth, mobile layout, ARM64 nightly CI
- Fix React #418/#423: remove activeIndex effect from SettingsPanel, defer applyRootTheme via requestAnimationFrame in AppearanceSettings - Add retry countdown on failed job rows with 30s refresh tick - Add attempt_count to Job interface; add job_count to CodecSavings (Rust + frontend) - Stats page: Total Library Reduction headline, job counts per codec, recharts BarChart for jobs-per-day histogram - Mobile layout: hamburger overlay sidebar with backdrop, hide Updated/priority columns on small screens in jobs table - CI: check-web now runs e2e tests; docker workflow adds nightly schedule building linux/amd64,linux/arm64 and tagging :nightly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
24
.github/workflows/docker.yml
vendored
24
.github/workflows/docker.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # 02:00 UTC nightly, runs against master
|
||||
pull_request:
|
||||
paths:
|
||||
- 'VERSION'
|
||||
@@ -43,7 +45,7 @@ jobs:
|
||||
| tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -90,3 +92,23 @@ jobs:
|
||||
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true
|
||||
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true
|
||||
provenance: false
|
||||
|
||||
- name: Build and push (nightly)
|
||||
if: github.event_name == 'schedule'
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.image.outputs.name }}:nightly
|
||||
cache-from: |
|
||||
type=gha,scope=docker-amd64
|
||||
type=gha,scope=docker-arm64
|
||||
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-amd64
|
||||
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-arm64
|
||||
cache-to: |
|
||||
type=gha,scope=docker-amd64,mode=max
|
||||
type=gha,scope=docker-arm64,mode=max
|
||||
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true
|
||||
type=registry,ref=ghcr.io/bybrooklyn/alchemist:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true
|
||||
provenance: false
|
||||
|
||||
1
justfile
1
justfile
@@ -80,6 +80,7 @@ check-rust:
|
||||
# Frontend checks only
|
||||
check-web:
|
||||
cd web && bun install --frozen-lockfile && bun run typecheck && bun run build
|
||||
cd web-e2e && bun install --frozen-lockfile && bun run test
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# TESTS
|
||||
|
||||
@@ -550,6 +550,7 @@ pub struct EncodeStatsInput {
|
||||
pub struct CodecSavings {
|
||||
pub codec: String,
|
||||
pub bytes_saved: i64,
|
||||
pub job_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -1918,7 +1919,8 @@ impl Db {
|
||||
let savings_by_codec = sqlx::query(
|
||||
"SELECT
|
||||
COALESCE(NULLIF(TRIM(e.output_codec), ''), 'unknown') as codec,
|
||||
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved
|
||||
COALESCE(SUM(e.input_size_bytes - e.output_size_bytes), 0) as bytes_saved,
|
||||
COUNT(*) as job_count
|
||||
FROM encode_stats e
|
||||
JOIN jobs j ON j.id = e.job_id
|
||||
WHERE e.output_size_bytes IS NOT NULL
|
||||
@@ -1931,6 +1933,7 @@ impl Db {
|
||||
.map(|row| CodecSavings {
|
||||
codec: row.get("codec"),
|
||||
bytes_saved: row.get("bytes_saved"),
|
||||
job_count: row.get("job_count"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
|
||||
@@ -303,7 +303,10 @@ export default function AppearanceSettings() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyRootTheme(activeThemeId);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
applyRootTheme(activeThemeId);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [activeThemeId]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
|
||||
@@ -258,11 +258,35 @@ interface Job {
|
||||
progress: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
attempt_count: number;
|
||||
vmaf_score?: number;
|
||||
decision_reason?: string;
|
||||
encoder?: string;
|
||||
}
|
||||
|
||||
function retryCountdown(job: Job): string | null {
|
||||
if (job.status !== "failed") return null;
|
||||
if (!job.attempt_count || job.attempt_count === 0) return null;
|
||||
|
||||
const backoffMins =
|
||||
job.attempt_count === 1 ? 5
|
||||
: job.attempt_count === 2 ? 15
|
||||
: job.attempt_count === 3 ? 60
|
||||
: 360;
|
||||
|
||||
const updatedMs = new Date(job.updated_at).getTime();
|
||||
const retryAtMs = updatedMs + backoffMins * 60 * 1000;
|
||||
const remainingMs = retryAtMs - Date.now();
|
||||
|
||||
if (remainingMs <= 0) return "Retrying soon";
|
||||
|
||||
const remainingMins = Math.ceil(remainingMs / 60_000);
|
||||
if (remainingMins < 60) return `Retrying in ${remainingMins}m`;
|
||||
const hrs = Math.floor(remainingMins / 60);
|
||||
const mins = remainingMins % 60;
|
||||
return mins > 0 ? `Retrying in ${hrs}h ${mins}m` : `Retrying in ${hrs}h`;
|
||||
}
|
||||
|
||||
interface JobMetadata {
|
||||
duration_secs: number;
|
||||
codec_name: string;
|
||||
@@ -346,6 +370,12 @@ export default function JobManager() {
|
||||
confirmTone?: "danger" | "primary";
|
||||
onConfirm: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setTick(t => t + 1), 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const isJobActive = (job: Job) => ["analyzing", "encoding", "remuxing", "resuming"].includes(job.status);
|
||||
|
||||
@@ -1024,7 +1054,7 @@ export default function JobManager() {
|
||||
<th className="px-6 py-4">File</th>
|
||||
<th className="px-6 py-4">Status</th>
|
||||
<th className="px-6 py-4">Progress</th>
|
||||
<th className="px-6 py-4">Updated</th>
|
||||
<th className="hidden md:table-cell px-6 py-4">Updated</th>
|
||||
<th className="px-6 py-4 w-14"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1070,7 +1100,7 @@ export default function JobManager() {
|
||||
<span className="text-xs text-helios-slate truncate max-w-[240px]">
|
||||
{job.input_path}
|
||||
</span>
|
||||
<span className="rounded-full border border-helios-line/20 px-2 py-0.5 text-xs font-bold text-helios-slate">
|
||||
<span className="hidden md:inline rounded-full border border-helios-line/20 px-2 py-0.5 text-xs font-bold text-helios-slate">
|
||||
P{job.priority}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1080,6 +1110,16 @@ export default function JobManager() {
|
||||
<motion.div layoutId={`job-status-${job.id}`}>
|
||||
{getStatusBadge(job.status)}
|
||||
</motion.div>
|
||||
{job.status === "failed" && (() => {
|
||||
// Reference tick so React re-renders countdowns on interval
|
||||
void tick;
|
||||
const countdown = retryCountdown(job);
|
||||
return countdown ? (
|
||||
<p className="text-[10px] font-mono text-helios-slate mt-0.5">
|
||||
{countdown}
|
||||
</p>
|
||||
) : null;
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{["encoding", "analyzing", "remuxing"].includes(job.status) ? (
|
||||
@@ -1114,7 +1154,7 @@ export default function JobManager() {
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-helios-slate font-mono">
|
||||
<td className="hidden md:table-cell px-6 py-4 text-xs text-helios-slate font-mono">
|
||||
{new Date(job.updated_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { showToast } from "../lib/toast";
|
||||
interface CodecSavings {
|
||||
codec: string;
|
||||
bytes_saved: number;
|
||||
job_count: number;
|
||||
}
|
||||
|
||||
interface DailySavings {
|
||||
@@ -127,6 +128,34 @@ export default function SavingsOverview() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Total Library Reduction */}
|
||||
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
|
||||
<div className="text-sm font-medium text-helios-slate mb-4">Total Library Reduction</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<div className="text-xs text-helios-slate/70">Original size</div>
|
||||
<div className="mt-1 font-mono text-2xl font-bold text-helios-ink">
|
||||
{formatHeroStorage(summary.total_input_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-helios-slate/70">Current size</div>
|
||||
<div className="mt-1 font-mono text-2xl font-bold text-helios-ink">
|
||||
{formatHeroStorage(summary.total_output_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-helios-slate/70">Space recovered</div>
|
||||
<div className="mt-1 font-mono text-2xl font-bold text-helios-solar">
|
||||
{formatHeroStorage(summary.total_bytes_saved)}
|
||||
<span className="ml-2 text-base font-semibold text-helios-slate">
|
||||
({summary.savings_percent.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-helios-line/40 bg-helios-surface p-6">
|
||||
<div className="text-sm font-medium text-helios-slate">Total saved</div>
|
||||
@@ -190,7 +219,7 @@ export default function SavingsOverview() {
|
||||
{summary.savings_by_codec.map((entry) => (
|
||||
<div
|
||||
key={entry.codec}
|
||||
className="grid grid-cols-[120px_minmax(0,1fr)_110px] items-center gap-3"
|
||||
className="grid grid-cols-[120px_minmax(0,1fr)_160px] items-center gap-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-helios-ink">
|
||||
{entry.codec}
|
||||
@@ -207,7 +236,8 @@ export default function SavingsOverview() {
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-sm text-helios-slate">
|
||||
{formatCompactStorage(entry.bytes_saved)}
|
||||
{entry.job_count} {entry.job_count === 1 ? "job" : "jobs"},{" "}
|
||||
{formatCompactStorage(entry.bytes_saved)} saved
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -32,14 +32,17 @@ export default function SettingsPanel() {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const requested = params.get("tab");
|
||||
if (requested && TABS.some((tab) => tab.id === requested)) {
|
||||
setActiveTab(requested);
|
||||
if (requested) {
|
||||
if (TABS.some((tab) => tab.id === requested)) {
|
||||
setActiveTab(requested);
|
||||
} else {
|
||||
setActiveTab("watch");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const activeIndex = TABS.findIndex(t => t.id === activeTab);
|
||||
|
||||
const paginate = (newTabId: string) => {
|
||||
if (!TABS.some((tab) => tab.id === newTabId)) return;
|
||||
setActiveTab(newTabId);
|
||||
if (typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
@@ -50,12 +53,6 @@ export default function SettingsPanel() {
|
||||
|
||||
const navItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex < 0) {
|
||||
setActiveTab("watch");
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = navItemRefs.current[activeTab];
|
||||
if (target) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
Video,
|
||||
Terminal,
|
||||
BarChart3,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import SystemStatus from "./SystemStatus.tsx";
|
||||
|
||||
@@ -21,19 +23,49 @@ const navItems = [
|
||||
];
|
||||
---
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<div class="lg:hidden flex items-center justify-between px-4 py-3 bg-helios-surface border-b border-helios-line/60">
|
||||
<a href="/" class="font-bold text-lg tracking-tight text-helios-ink">Alchemist</a>
|
||||
<button
|
||||
id="sidebar-hamburger"
|
||||
aria-label="Open navigation"
|
||||
class="p-2 rounded-md text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile overlay backdrop */}
|
||||
<div
|
||||
id="sidebar-backdrop"
|
||||
class="hidden lg:hidden fixed inset-0 z-40 bg-black/50"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
{/* Sidebar — hidden on mobile until hamburger opens it */}
|
||||
<aside
|
||||
class="w-full lg:w-64 bg-helios-surface border-b lg:border-b-0 lg:border-r border-helios-line/60 flex flex-col lg:flex-col p-3 lg:p-4 gap-3 lg:gap-4"
|
||||
id="sidebar"
|
||||
class="hidden lg:flex w-64 bg-helios-surface border-r border-helios-line/60 flex-col p-4 gap-4
|
||||
fixed lg:static inset-y-0 left-0 z-50 lg:z-auto
|
||||
transition-transform duration-200 lg:transition-none"
|
||||
>
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center px-3 pb-3 lg:pb-4 border-b border-helios-line/40"
|
||||
class="flex items-center px-3 pb-4 border-b border-helios-line/40"
|
||||
>
|
||||
<span class="font-bold text-lg tracking-tight text-helios-ink">
|
||||
Alchemist
|
||||
</span>
|
||||
<button
|
||||
id="sidebar-close"
|
||||
aria-label="Close navigation"
|
||||
class="lg:hidden ml-auto p-1 rounded-md text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</a>
|
||||
|
||||
<nav class="flex flex-row lg:flex-col gap-2 flex-1 overflow-x-auto lg:overflow-visible pb-1 lg:pb-0">
|
||||
<nav class="flex flex-col gap-2 flex-1">
|
||||
{
|
||||
navItems.map(({ href, label, Icon }) => {
|
||||
const isActive =
|
||||
@@ -43,7 +75,7 @@ const navItems = [
|
||||
<a
|
||||
href={href}
|
||||
class:list={[
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap shrink-0",
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md border-l-2 border-transparent transition-colors whitespace-nowrap",
|
||||
isActive
|
||||
? "border-helios-solar bg-helios-solar/10 text-helios-ink font-semibold"
|
||||
: "text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink",
|
||||
@@ -57,9 +89,40 @@ const navItems = [
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto hidden lg:block">
|
||||
<div class="mt-3 border-t border-helios-line/30 pt-3">
|
||||
<div class="mt-auto">
|
||||
<div class="border-t border-helios-line/30 pt-3">
|
||||
<SystemStatus client:load />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
function initSidebar() {
|
||||
const hamburger = document.getElementById("sidebar-hamburger");
|
||||
const closeBtn = document.getElementById("sidebar-close");
|
||||
const backdrop = document.getElementById("sidebar-backdrop");
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
|
||||
if (!hamburger || !closeBtn || !backdrop || !sidebar) return;
|
||||
|
||||
function openSidebar() {
|
||||
sidebar!.classList.remove("hidden");
|
||||
backdrop!.classList.remove("hidden");
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebar!.classList.add("hidden");
|
||||
backdrop!.classList.add("hidden");
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
hamburger.addEventListener("click", openSidebar);
|
||||
closeBtn.addEventListener("click", closeSidebar);
|
||||
backdrop.addEventListener("click", closeSidebar);
|
||||
}
|
||||
|
||||
// Run on initial load and after Astro view transitions
|
||||
initSidebar();
|
||||
document.addEventListener("astro:after-swap", initSidebar);
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Timer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import { apiJson, isApiError } from "../lib/api";
|
||||
|
||||
interface AggregatedStats {
|
||||
@@ -127,9 +128,6 @@ export default function StatsCharts() {
|
||||
? (detailedStats.reduce((sum, s) => sum + s.avg_bitrate_kbps, 0) / detailedStats.length).toFixed(0)
|
||||
: "N/A";
|
||||
|
||||
// Find max for bar chart scaling
|
||||
const maxDailyJobs = Math.max(...dailyStats.map(d => d.jobs_completed), 1);
|
||||
|
||||
interface StatCardProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
@@ -217,41 +215,47 @@ export default function StatsCharts() {
|
||||
<div className="p-6 rounded-lg bg-helios-surface border border-helios-line/40">
|
||||
<h3 className="text-lg font-bold text-helios-ink mb-4 flex items-center gap-2">
|
||||
<BarChart3 size={20} className="text-blue-500" />
|
||||
Daily Activity (Last 30 Days)
|
||||
Jobs per Day (Last 30 Days)
|
||||
</h3>
|
||||
{dailyStats.length > 0 ? (
|
||||
<div className="h-48 flex items-end gap-1">
|
||||
{dailyStats.map((day, i) => {
|
||||
const height = (day.jobs_completed / maxDailyJobs) * 100;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 flex flex-col items-center group"
|
||||
>
|
||||
<div
|
||||
className="w-full bg-gradient-to-t from-blue-500 to-blue-400 rounded-t transition-all hover:from-blue-600 hover:to-blue-500 cursor-pointer relative"
|
||||
style={{ height: `${Math.max(height, 4)}%` }}
|
||||
title={`${formatDate(day.date)}: ${day.jobs_completed} jobs, ${formatBytes(day.bytes_saved)} saved`}
|
||||
>
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-helios-ink text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 whitespace-nowrap pointer-events-none z-10">
|
||||
{day.jobs_completed} jobs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={192}>
|
||||
<BarChart
|
||||
data={dailyStats.map(d => ({
|
||||
label: formatDate(d.date),
|
||||
jobs: d.jobs_completed,
|
||||
}))}
|
||||
margin={{ top: 4, right: 4, left: -24, bottom: 4 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: "rgb(var(--text-muted, 160 160 160))" }}
|
||||
interval="preserveStartEnd"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 10, fill: "rgb(var(--text-muted, 160 160 160))" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [value, "Jobs"]}
|
||||
contentStyle={{
|
||||
background: "rgb(var(--bg-panel, 30 30 30))",
|
||||
border: "1px solid rgb(var(--border, 60 60 60))",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="jobs" fill="rgb(59 130 246)" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-helios-slate">
|
||||
No daily data available
|
||||
</div>
|
||||
)}
|
||||
{dailyStats.length > 0 && (
|
||||
<div className="flex justify-between text-xs text-helios-slate mt-2">
|
||||
<span>{formatDate(dailyStats[0]?.date)}</span>
|
||||
<span>{formatDate(dailyStats[dailyStats.length - 1]?.date)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Space Efficiency */}
|
||||
|
||||
Reference in New Issue
Block a user