mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(services): sleep mode — scale-to-zero on idle, wake on traffic
Services gain sleep_enabled + sleep_idle_minutes. A 30s sweeper tracks container NetworkIO deltas; a flat counter for the configured window scales the service to zero (status=sleeping, workload containers removed). While slept, a busybox wake container takes over the service's Traefik host rule at priority 100: it serves a self-reloading "waking" page and calls the unauthenticated idempotent GET/POST /api/v1/internal/wake/:id, which reconciles the service back to running. Wake placeholders carry a dedicated label (containr.wake-for) so runtime state never counts them; the sweeper prunes them once the workload is up. The wake URL resolves the API via its container name when containerized, else the edge network's IPAM gateway — bare-metal installs need HOST=0.0.0.0 for the callback. Surfaces: PUT fields on services, POST /services/:id/sleep|wake, CLI --sleep/--sleep-idle + services sleep|wake, 2 MCP tools (89 total), OpenAPI paths/schema, and a Settings > Sleep card with toggle, idle minutes, and manual sleep/wake plus a Sleeping badge. Verified live: idle sweep scaled whoami-web to zero; the wake container served the page on the edge network and its callback woke the service (container recreated, status running, placeholder pruned). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
b4baa853b2
commit
dbc87bf34c
15 files changed
+957
-49
No files matched your search
@@ -33,6 +33,8 @@ import {
|
||||
updateService,
|
||||
restartService,
|
||||
stopService,
|
||||
sleepService,
|
||||
wakeService,
|
||||
getServiceRuntime,
|
||||
type CronJobEntity,
|
||||
type ServiceVolume,
|
||||
@@ -459,6 +461,14 @@ export function ServiceDetailPage() {
|
||||
mutationFn: () => stopService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const sleepMutation = useMutation({
|
||||
mutationFn: () => sleepService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const wakeMutation = useMutation({
|
||||
mutationFn: () => wakeService(serviceId),
|
||||
onSuccess: invalidateService,
|
||||
});
|
||||
const updateServiceMutation = useMutation({
|
||||
mutationFn: (input: Parameters<typeof updateService>[1]) => updateService(serviceId, input),
|
||||
onSuccess: () => {
|
||||
@@ -481,6 +491,24 @@ export function ServiceDetailPage() {
|
||||
const [domainInput, setDomainInput] = useState('');
|
||||
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
|
||||
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
|
||||
const [sleepForm, setSleepForm] = useState<{ enabled: boolean; minutes: string; dirty: boolean }>({
|
||||
enabled: false,
|
||||
minutes: '15',
|
||||
dirty: false,
|
||||
});
|
||||
const sleepFormInitRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
const svc = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||
const key = svc ? `${svc.id}:${svc.sleepEnabled}:${svc.sleepIdleMinutes}` : '';
|
||||
if (svc && key !== sleepFormInitRef.current) {
|
||||
sleepFormInitRef.current = key;
|
||||
setSleepForm({
|
||||
enabled: Boolean(svc.sleepEnabled),
|
||||
minutes: String(svc.sleepIdleMinutes ?? 15),
|
||||
dirty: false,
|
||||
});
|
||||
}
|
||||
}, [isDemoMode, serviceId, serviceQuery.data]);
|
||||
const [buildForm, setBuildForm] = useState<{
|
||||
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
|
||||
} | null>(null);
|
||||
@@ -689,7 +717,7 @@ export function ServiceDetailPage() {
|
||||
className={`badge-${service.status === 'running' ? 'active' : service.status === 'degraded' ? 'degraded' : 'stopped'}`}
|
||||
>
|
||||
{service.status === 'running' && <span className="live-dot" />}
|
||||
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
|
||||
{service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : service.status === 'sleeping' ? 'Sleeping' : 'Stopped'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
|
||||
@@ -2339,6 +2367,80 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-[var(--text-primary)]">Sleep</h3>
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
|
||||
Scale to zero after idle minutes without traffic. Requests resume the service automatically.
|
||||
</p>
|
||||
</div>
|
||||
{service.status === 'sleeping' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={wakeMutation.isPending}
|
||||
onClick={() => wakeMutation.mutate()}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
{wakeMutation.isPending ? 'Waking…' : 'Wake now'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={sleepMutation.isPending || service.status !== 'running'}
|
||||
onClick={() => sleepMutation.mutate()}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{sleepMutation.isPending ? 'Sleeping…' : 'Sleep now'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<label className="flex items-center gap-2 text-[var(--text-primary)]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(sleepForm.enabled)}
|
||||
onChange={(e) =>
|
||||
setSleepForm((f) => ({ ...f, enabled: e.target.checked, dirty: true }))
|
||||
}
|
||||
/>
|
||||
Sleep on idle
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[var(--text-tertiary)]">
|
||||
after
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
value={sleepForm.minutes}
|
||||
onChange={(e) =>
|
||||
setSleepForm((f) => ({ ...f, minutes: e.target.value, dirty: true }))
|
||||
}
|
||||
className="w-20 px-2 py-1 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
/>
|
||||
min
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateServiceMutation.isPending}
|
||||
onClick={() =>
|
||||
updateServiceMutation.mutate({
|
||||
sleep_enabled: sleepForm.enabled,
|
||||
sleep_idle_minutes: Math.max(1, Math.min(1440, Number(sleepForm.minutes) || 15)),
|
||||
})
|
||||
}
|
||||
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{sleepForm.dirty && (
|
||||
<span className="text-[var(--text-tertiary)]">unsaved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDemoMode && (
|
||||
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -932,6 +932,130 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/sleep": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Sleep service
|
||||
* @description Scale the service to zero immediately. When it has public domains a wake placeholder holds the route and resumes the service on the next request.
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Sleep initiated */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/wake": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Wake service
|
||||
* @description Reconcile a sleeping service back to running on demand
|
||||
*/
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Wake initiated */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/internal/wake/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Wake callback (internal)
|
||||
* @description Unauthenticated wake endpoint hit by the busybox wake placeholder while a slept service resumes. Idempotent and non-destructive.
|
||||
*/
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Wake accepted */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Service not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/services/{id}/redeploy": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -8936,6 +9060,10 @@ export interface components {
|
||||
static_build_cmd?: string;
|
||||
/** @description Output directory for the static builder (default dist) */
|
||||
static_dir?: string;
|
||||
/** @description Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while the service resumes */
|
||||
sleep_enabled?: boolean;
|
||||
/** @description Idle timeout in minutes before sleeping (1-1440, default 15) */
|
||||
sleep_idle_minutes?: number;
|
||||
/** @description Computed public URL (published port or domain) */
|
||||
public_url?: string;
|
||||
/**
|
||||
@@ -9063,6 +9191,10 @@ export interface components {
|
||||
static_build_cmd?: string;
|
||||
/** @description Output directory for the static builder (empty string clears) */
|
||||
static_dir?: string;
|
||||
/** @description Scale to zero after sleep_idle_minutes without inbound traffic; a wake placeholder answers requests while resuming */
|
||||
sleep_enabled?: boolean;
|
||||
/** @description Idle timeout in minutes before sleeping (1-1440) */
|
||||
sleep_idle_minutes?: number;
|
||||
};
|
||||
ServiceDomain: {
|
||||
/** Format: uuid */
|
||||
|
||||
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
|
||||
"path": "guides/CLI.md",
|
||||
"section": "guides",
|
||||
"title": "Containr CLI",
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`voLine truncated
|
||||
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|analytics (admin)\nadmin overview|users|settings|audit-logs\nnotifications list|read\nup deploy the current directory\n```\n\n## containr up\n\nRun inside a git checkout: uses `remote.origin.url` + the current branch\nas the build source, creates a service named after the directory when none\nexists, then triggers a deployment.\n\n```bash\ncontainr up --project <id>\ncontainr up --project <id> --image ghcr.io/example/app:latest # image, no build\n```\n\nSet `project_id` on the active profile to omit `--project` (stored in\n`~/.containr.yaml` under `profiles.<name>.project_id`).\n\n## Service volumes\n\n`services update` manages mounts; flags replace the whole mount list:\n\n```bash\ncontainr services update <id> --volume data:/data --bind /srv/cfg:/etc/app:ro\ncontainr services update <id> --clear-volumes\n```\n\n`type` is `volume` (docker named volume, auto-created) or `bind` (host\npath). `target` must be absolute. Mounts apply on the next deploy or\nredeploy — `containr deploy <id>` to apply immediately.\n\n`voLine truncated
|
||||
},
|
||||
{
|
||||
"path": "guides/CLOUDFLARE_SETUP.md",
|
||||
|
||||
@@ -63,6 +63,8 @@ export type ServiceEntity = {
|
||||
memoryReserve?: string;
|
||||
staticBuildCmd?: string;
|
||||
staticDir?: string;
|
||||
sleepEnabled?: boolean;
|
||||
sleepIdleMinutes?: number;
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
@@ -611,6 +613,8 @@ function normalizeService(service: RawService): ServiceEntity | null {
|
||||
memoryReserve: service.memory_reserve,
|
||||
staticBuildCmd: service.static_build_cmd,
|
||||
staticDir: service.static_dir,
|
||||
sleepEnabled: service.sleep_enabled,
|
||||
sleepIdleMinutes: service.sleep_idle_minutes,
|
||||
publicUrl: service.public_url,
|
||||
};
|
||||
}
|
||||
@@ -1290,13 +1294,15 @@ export async function getServiceRuntime(serviceId: string): Promise<ServiceRunti
|
||||
return normalizeRuntime(payload.runtime);
|
||||
}
|
||||
|
||||
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart'): Promise<void> {
|
||||
async function serviceAction(serviceId: string, action: 'start' | 'stop' | 'restart' | 'sleep' | 'wake'): Promise<void> {
|
||||
await requestJson<{ message?: string }>(`/services/${serviceId}/${action}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export const startService = (serviceId: string) => serviceAction(serviceId, 'start');
|
||||
export const stopService = (serviceId: string) => serviceAction(serviceId, 'stop');
|
||||
export const restartService = (serviceId: string) => serviceAction(serviceId, 'restart');
|
||||
export const sleepService = (serviceId: string) => serviceAction(serviceId, 'sleep');
|
||||
export const wakeService = (serviceId: string) => serviceAction(serviceId, 'wake');
|
||||
|
||||
export async function redeployService(serviceId: string): Promise<ServiceRuntime> {
|
||||
const payload = await requestJson<{ runtime?: ServiceRuntime }>(`/services/${serviceId}/redeploy`, {
|
||||
|
||||
Reference in new issue
Block a user