mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
fix: sync page dirty state tracking, remove old upgrade logs page *content* (will be refactored for something else)
This commit is contained in:
@@ -1,34 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { ServerLoad } from '@sveltejs/kit';
|
||||
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
||||
import { readFilteredLogs } from '$logger/reader.ts';
|
||||
import type { UpgradeJobLog } from '$lib/server/upgrades/types.ts';
|
||||
|
||||
/**
|
||||
* Extract UpgradeJobLog from a DEBUG log entry
|
||||
* DEBUG logs contain the full structured log in the meta field
|
||||
*/
|
||||
function extractUpgradeJobLog(meta: unknown): UpgradeJobLog | null {
|
||||
if (!meta || typeof meta !== 'object') return null;
|
||||
|
||||
const log = meta as Record<string, unknown>;
|
||||
|
||||
// Check for required UpgradeJobLog fields
|
||||
if (
|
||||
typeof log.id === 'string' &&
|
||||
typeof log.instanceId === 'number' &&
|
||||
typeof log.status === 'string' &&
|
||||
log.config &&
|
||||
log.library &&
|
||||
log.filter &&
|
||||
log.selection &&
|
||||
log.results
|
||||
) {
|
||||
return log as unknown as UpgradeJobLog;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
@@ -43,34 +15,7 @@ export const load: ServerLoad = async ({ params }) => {
|
||||
error(404, `Instance not found: ${id}`);
|
||||
}
|
||||
|
||||
// Load upgrade job logs for this instance
|
||||
const logs = await readFilteredLogs({
|
||||
source: 'UpgradeJob',
|
||||
instanceId: id
|
||||
});
|
||||
|
||||
// Extract full UpgradeJobLog objects from DEBUG entries
|
||||
const upgradeRuns: UpgradeJobLog[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const log of logs) {
|
||||
if (log.level === 'DEBUG' && log.meta) {
|
||||
const upgradeLog = extractUpgradeJobLog(log.meta);
|
||||
if (upgradeLog && !seenIds.has(upgradeLog.id)) {
|
||||
seenIds.add(upgradeLog.id);
|
||||
upgradeRuns.push(upgradeLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by startedAt (newest first)
|
||||
upgradeRuns.sort(
|
||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()
|
||||
);
|
||||
|
||||
return {
|
||||
instance,
|
||||
logs,
|
||||
upgradeRuns
|
||||
instance
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,122 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw } from 'lucide-svelte';
|
||||
import type { PageData } from './$types';
|
||||
import UpgradeRunCard from './components/UpgradeRunCard.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
// Filter state
|
||||
type StatusFilter = 'all' | 'success' | 'partial' | 'failed' | 'skipped';
|
||||
let statusFilter: StatusFilter = 'all';
|
||||
|
||||
const statusFilters: { value: StatusFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'partial', label: 'Partial' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'skipped', label: 'Skipped' }
|
||||
];
|
||||
|
||||
// Filtered runs
|
||||
$: filteredRuns = data.upgradeRuns.filter((run) => {
|
||||
if (statusFilter === 'all') return true;
|
||||
return run.status === statusFilter;
|
||||
});
|
||||
|
||||
// Stats
|
||||
$: stats = {
|
||||
total: data.upgradeRuns.length,
|
||||
success: data.upgradeRuns.filter((r) => r.status === 'success').length,
|
||||
partial: data.upgradeRuns.filter((r) => r.status === 'partial').length,
|
||||
failed: data.upgradeRuns.filter((r) => r.status === 'failed').length,
|
||||
skipped: data.upgradeRuns.filter((r) => r.status === 'skipped').length
|
||||
};
|
||||
|
||||
function refreshLogs() {
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.instance.name} - Logs - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">Upgrade Logs</h1>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
View upgrade job history for this {data.instance.type} instance.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={refreshLogs}
|
||||
class="flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats & Filters -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<!-- Stats -->
|
||||
<div class="flex items-center gap-6 text-sm">
|
||||
<div class="text-neutral-600 dark:text-neutral-400">
|
||||
<span class="font-medium text-neutral-900 dark:text-neutral-100">{stats.total}</span> total runs
|
||||
</div>
|
||||
{#if stats.success > 0}
|
||||
<div class="text-green-600 dark:text-green-400">
|
||||
<span class="font-medium">{stats.success}</span> successful
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.partial > 0}
|
||||
<div class="text-yellow-600 dark:text-yellow-400">
|
||||
<span class="font-medium">{stats.partial}</span> partial
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.failed > 0}
|
||||
<div class="text-red-600 dark:text-red-400">
|
||||
<span class="font-medium">{stats.failed}</span> failed
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="flex gap-2">
|
||||
{#each statusFilters as filter}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (statusFilter = filter.value)}
|
||||
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {statusFilter ===
|
||||
filter.value
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'border border-neutral-300 bg-white text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700'}"
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade Runs -->
|
||||
{#if filteredRuns.length === 0}
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<p class="text-neutral-600 dark:text-neutral-400">
|
||||
{#if data.upgradeRuns.length === 0}
|
||||
No upgrade runs yet. Configure upgrades and run a test to see logs here.
|
||||
{:else}
|
||||
No runs match the selected filter.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredRuns as run, index (run.id)}
|
||||
<UpgradeRunCard {run} runNumber={data.upgradeRuns.length - data.upgradeRuns.indexOf(run)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-6">
|
||||
<!-- TODO: New logs page content -->
|
||||
</div>
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Check, AlertTriangle, X, Zap } from 'lucide-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import type { UpgradeJobLog } from '$lib/server/upgrades/types.ts';
|
||||
import Score from '$ui/arr/Score.svelte';
|
||||
import Badge from '$ui/badge/Badge.svelte';
|
||||
|
||||
export let run: UpgradeJobLog;
|
||||
export let runNumber: number;
|
||||
|
||||
let expanded = false;
|
||||
|
||||
// Status badge styling (card uses neutral bg)
|
||||
const statusBadges = {
|
||||
success: {
|
||||
badge: 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400',
|
||||
icon: Check
|
||||
},
|
||||
partial: {
|
||||
badge: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-400',
|
||||
icon: AlertTriangle
|
||||
},
|
||||
failed: {
|
||||
badge: 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400',
|
||||
icon: X
|
||||
},
|
||||
skipped: {
|
||||
badge: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400',
|
||||
icon: X
|
||||
}
|
||||
};
|
||||
|
||||
$: badgeStyle = statusBadges[run.status] || statusBadges.failed;
|
||||
$: StatusIcon = badgeStyle.icon;
|
||||
|
||||
// Format schedule
|
||||
function formatSchedule(minutes: number): string {
|
||||
if (minutes < 60) return `Every ${minutes} minutes`;
|
||||
if (minutes === 60) return 'Every hour';
|
||||
if (minutes < 1440) return `Every ${minutes / 60} hours`;
|
||||
return 'Every day';
|
||||
}
|
||||
|
||||
// Format date
|
||||
function formatDate(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
// Format duration
|
||||
function formatDuration(startedAt: string, completedAt: string): string {
|
||||
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
}
|
||||
|
||||
// Format filter mode
|
||||
function formatFilterMode(mode: string): string {
|
||||
return mode
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Format selector method
|
||||
function formatMethod(method: string): string {
|
||||
return method
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="overflow-hidden rounded-lg border border-neutral-200 bg-white transition-shadow hover:shadow-sm dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div on:click={() => (expanded = !expanded)} class="flex cursor-pointer items-start justify-between gap-4 p-4">
|
||||
<div class="flex-1">
|
||||
<!-- Title row -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm text-neutral-500 dark:text-neutral-500">#{runNumber}</span>
|
||||
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{run.config.selectedFilter || 'Unknown Filter'}
|
||||
</span>
|
||||
{#if run.config.dryRun}
|
||||
<span class="rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/50 dark:text-amber-400">
|
||||
DRY RUN
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<div class="mt-1 flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<span>{formatDate(run.startedAt)} @ {formatTime(run.startedAt)}</span>
|
||||
<span class="text-neutral-300 dark:text-neutral-600">|</span>
|
||||
<span class="font-mono text-xs">{formatDuration(run.startedAt, run.completedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status + Chevron -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium {badgeStyle.badge}">
|
||||
<svelte:component this={StatusIcon} size={12} />
|
||||
{run.status.charAt(0).toUpperCase() + run.status.slice(1)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={18}
|
||||
class="text-neutral-400 transition-transform {expanded ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
{#if expanded}
|
||||
<div transition:slide={{ duration: 200 }} class="border-t border-neutral-200 bg-white p-4 dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<div class="space-y-3">
|
||||
<!-- Config -->
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Config</span>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
Schedule: {formatSchedule(run.config.schedule)} | Mode: {formatFilterMode(run.config.filterMode)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Library -->
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Library</span>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{run.library.totalItems.toLocaleString()} items
|
||||
{#if run.library.fetchedFromCache}
|
||||
<span class="text-neutral-500 dark:text-neutral-400">(cached)</span>
|
||||
{/if}
|
||||
<span class="ml-1 font-mono text-xs text-neutral-500 dark:text-neutral-400">
|
||||
({run.library.fetchDurationMs}ms)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Filter</span>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
"{run.filter.name}"
|
||||
<span class="mx-1 text-neutral-400">→</span>
|
||||
<span class="font-medium">{run.filter.matchedCount}</span> matched
|
||||
<span class="mx-1 text-neutral-400">→</span>
|
||||
<span class="font-medium">{run.filter.afterCooldown}</span> after cooldown
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Selection -->
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Selection</span>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{formatMethod(run.selection.method)}
|
||||
<span class="font-medium">{run.selection.actualCount}</span> of {run.selection.requestedCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Results</span>
|
||||
<span class="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{run.results.searchesTriggered} searches triggered,
|
||||
<span class="{run.results.successful > 0 ? 'text-green-600 dark:text-green-400' : ''}">{run.results.successful} successful</span>
|
||||
{#if run.results.failed > 0}
|
||||
<span class="text-red-600 dark:text-red-400">, {run.results.failed} failed</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Errors -->
|
||||
{#if run.results.errors.length > 0}
|
||||
<div class="flex">
|
||||
<span class="w-24 shrink-0 text-sm font-medium text-neutral-500 dark:text-neutral-400">Notes</span>
|
||||
<div class="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{#each run.results.errors as error}
|
||||
<div class="italic">{error}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Items Searched -->
|
||||
{#if run.selection.items.length > 0}
|
||||
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-neutral-700">
|
||||
<div class="mb-3 flex items-center gap-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
<Zap size={14} />
|
||||
Items Searched
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each run.selection.items as item}
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-3 dark:border-neutral-700 dark:bg-neutral-800/50">
|
||||
<!-- Title and Score Delta -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-neutral-900 dark:text-neutral-100">{item.title}</span>
|
||||
{#if item.upgrade}
|
||||
<Badge variant={item.scoreDelta && item.scoreDelta >= 0 ? 'success' : 'danger'}>
|
||||
<Score score={item.scoreDelta} size="sm" />
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="neutral">No upgrade</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Original File -->
|
||||
<div class="mt-3">
|
||||
<div class="mb-1.5 text-xs font-medium text-neutral-500 dark:text-neutral-400">Current File</div>
|
||||
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-2 dark:border-neutral-700 dark:bg-neutral-900">
|
||||
<div class="truncate font-mono text-[11px] text-neutral-700 dark:text-neutral-300" title={item.original.fileName}>
|
||||
{item.original.fileName}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Score: <Score score={item.original.score} size="sm" showSign={false} colored={false} />
|
||||
</span>
|
||||
{#if item.original.formats.length > 0}
|
||||
<span class="text-neutral-300 dark:text-neutral-600">|</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each item.original.formats as format}
|
||||
<Badge variant="neutral" size="sm">{format}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade (if found) -->
|
||||
{#if item.upgrade}
|
||||
<div class="mt-3">
|
||||
<div class="mb-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">Upgrade Available</div>
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-2 dark:border-emerald-800 dark:bg-emerald-900/20">
|
||||
<div class="truncate font-mono text-[11px] text-neutral-700 dark:text-neutral-300" title={item.upgrade.release}>
|
||||
{item.upgrade.release}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Score: <Score score={item.upgrade.score} size="sm" showSign={false} colored={false} />
|
||||
</span>
|
||||
{#if item.upgrade.formats.length > 0}
|
||||
<span class="text-neutral-300 dark:text-neutral-600">|</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each item.upgrade.formats as format}
|
||||
<Badge variant="success" size="sm">{format}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { onMount } from 'svelte';
|
||||
import { Info } from 'lucide-svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import DirtyModal from '$ui/modal/DirtyModal.svelte';
|
||||
@@ -7,7 +8,7 @@
|
||||
import DelayProfiles from './components/DelayProfiles.svelte';
|
||||
import MediaManagement from './components/MediaManagement.svelte';
|
||||
import type { SyncTrigger } from '$db/queries/arrSync.ts';
|
||||
import { initEdit, initCreate } from '$lib/client/stores/dirty';
|
||||
import { initEdit, update, clear } from '$lib/client/stores/dirty';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@@ -48,13 +49,15 @@
|
||||
let delayProfilesDirty = false;
|
||||
let mediaManagementDirty = false;
|
||||
|
||||
// Initialize dirty tracking on mount
|
||||
onMount(() => {
|
||||
initEdit({ anyDirty: false });
|
||||
return () => clear();
|
||||
});
|
||||
|
||||
// Sync combined dirty state to global dirty store for DirtyModal
|
||||
$: anyDirty = qualityProfilesDirty || delayProfilesDirty || mediaManagementDirty;
|
||||
$: if (anyDirty) {
|
||||
initCreate({});
|
||||
} else {
|
||||
initEdit({});
|
||||
}
|
||||
$: update('anyDirty', anyDirty);
|
||||
|
||||
// Validation: Quality profiles require media management settings (saved, not dirty)
|
||||
$: hasQualityProfilesSelected = Object.values(qualityProfileState).some((db) =>
|
||||
|
||||
Reference in New Issue
Block a user