Restore campaign terminology across UI and docs
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
?? presets.find(p => JSON.stringify(p.plan) === JSON.stringify(plan)));
|
||||
const savedPresets = $derived(presets.filter(p => !builtInPlans.some(b => b.name === p.name)));
|
||||
const failureSummary = $derived(plan.failureAction === 'fail' ? 'Fail and hold for review' : plan.failureAction === 'pause' ? 'Pause for investigation' : 'Roll back automatically');
|
||||
const rolloutSummary = $derived(plan.waveSize ? `${plan.waveSize} server${plan.waveSize === 1 ? '' : 's'} per wave` : 'Deploy to all selected servers');
|
||||
const waveSummary = $derived(plan.waveSize ? `${plan.waveSize} server${plan.waveSize === 1 ? '' : 's'} per wave` : 'Deploy to all selected servers');
|
||||
|
||||
async function loadPresets() {
|
||||
const response = await fetch('/api/betas/presets');
|
||||
@@ -175,7 +175,7 @@
|
||||
|
||||
<div class="policy-settings">
|
||||
<details class="policy" bind:open={coverageOpen}>
|
||||
<summary><span>Coverage & rollout<small>{coverageSummary(plan)} · {rolloutSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
||||
<summary><span>Coverage & waves<small>{coverageSummary(plan)} · {waveSummary}</small></span><span class="edit-hint">Configure</span></summary>
|
||||
<div class="policy-body">
|
||||
<div class="fields">
|
||||
<label>Qualifying servers<select bind:value={plan.scope}>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
|
||||
<h2 id="destroy-title">Queue server removal?</h2>
|
||||
<p class="name">{name}</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and rollout details. Deployment history and the saved server key are retained.</p>
|
||||
<p>This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Deployment history and the saved server key are retained.</p>
|
||||
<div class="notice">
|
||||
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
|
||||
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the deployment page.
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
const secondary = $derived(tabs[1].match($page.url.pathname)
|
||||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
|
||||
: tabs[2].match($page.url.pathname)
|
||||
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Rollouts' }]
|
||||
? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Campaigns' }]
|
||||
: tabs[4].match($page.url.pathname) ? [{ href: '/settings', label: 'System status' }, { href: '/server-keys', label: 'Server keys' }] : []);
|
||||
</script>
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
<Confirmation />
|
||||
<NotificationHistory bind:open={notificationsOpen} />
|
||||
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
|
||||
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Server deployments'],['g a','Rollouts'],['g b','Betas'],['g h','System status'],['g k','Server keys'],['g l','Leaderboard'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||
<dl class="shortcuts">{#each [['g f','Fleet'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Server deployments'],['g a','Campaigns'],['g b','Betas'],['g h','System status'],['g k','Server keys'],['g l','Leaderboard'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<span><strong>{healthy.length ? players : '—'}</strong> players online</span>
|
||||
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
|
||||
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
|
||||
<a class="metric-link" class:no-rollouts={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active rollouts</a>
|
||||
<a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a>
|
||||
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
|
||||
</div>{/if}
|
||||
{#if $fleetIntegrations && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
|
||||
@@ -282,7 +282,7 @@
|
||||
.freshness { margin-left: auto; font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||
.setup-note { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.setup-note strong { color: var(--text-primary); }
|
||||
@media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-rollouts { display: none; } }
|
||||
@media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-campaigns { display: none; } }
|
||||
.page { display: flex; flex-direction: column; gap: var(--space-4); }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
@@ -31,9 +31,9 @@ export async function GET({ params }: { params: { id: string } }) {
|
||||
const servers = listBetaServers(id);
|
||||
|
||||
const events = db().prepare('SELECT id, created_at, message FROM beta_events WHERE beta_id = ? ORDER BY id').all(id);
|
||||
const rollout = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
|
||||
const promotion = rollout ? { ...rollout,
|
||||
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state NOT IN ('done','failed','cancelled')").get(rollout.id) as { n: number }).n,
|
||||
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state = 'failed'").get(rollout.id) as { n: number }).n } : null;
|
||||
const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
|
||||
const promotion = campaign ? { ...campaign,
|
||||
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state NOT IN ('done','failed','cancelled')").get(campaign.id) as { n: number }).n,
|
||||
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
|
||||
return json({ beta, scripts, servers, events, test: testView(id), promotion });
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
const filtered = $derived(betas.filter(b => active(b) === (tab === 'active') && ('#' + b.id + ' ' + (b.notes ?? '') + ' ' + b.scripts.map(s => s.name).join(' ')).toLowerCase().includes(search.toLowerCase())));
|
||||
function age(date:string) { const minutes=Math.max(0,Math.floor((now-Date.parse(date))/60000));return minutes<60?`${minutes}m ago`:minutes<1440?`${Math.floor(minutes/60)}h ago`:`${Math.floor(minutes/1440)}d ago`; }
|
||||
function blocker(beta:BetaListRow) {
|
||||
if(beta.state==='promoted')return 'Version promoted; check its rollout for remaining activation.';
|
||||
if(beta.state==='promoted')return 'Version promoted; check its campaign for remaining activation.';
|
||||
if(beta.state==='rolled-back')return 'Rolled back. The files and plan can be reused for another attempt.';
|
||||
if(beta.state==='ready-promote')return 'Ready for promotion review.';
|
||||
if(beta.testReason)return beta.testReason;
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
const passed = $derived(test?.servers.filter(server => server.passed).length ?? servers.filter(server => server.stage === 'ready').length);
|
||||
const ranked = $derived([...servers].sort((a,b) => (a.stage === 'failed' ? 0 : a.stage === 'ready' ? 2 : 1) - (b.stage === 'failed' ? 0 : b.stage === 'ready' ? 2 : 1) || a.server.localeCompare(b.server)));
|
||||
const waiting = $derived(ranked.filter(server => server.stage !== 'ready'));
|
||||
const nextAction = $derived(closed ? beta?.state === 'promoted' ? 'Follow the rollout to check activation on additional servers.' : 'Reuse these files and this plan when you are ready to test again.'
|
||||
const nextAction = $derived(closed ? beta?.state === 'promoted' ? 'Follow the campaign to check activation on additional servers.' : 'Reuse these files and this plan when you are ready to test again.'
|
||||
: test?.outcome === 'inconclusive' ? 'The deadline passed without enough evidence. Roll back, then reuse this plan with a longer testing window.'
|
||||
: test?.outcome === 'paused' ? 'Resume to continue collecting evidence, or start a fresh attempt. The original deadline still applies.'
|
||||
: test?.outcome === 'failed' ? 'Investigate the failed check before a fresh attempt, or roll back.'
|
||||
@@ -137,7 +137,7 @@
|
||||
<div class="actions">
|
||||
{#if !closed}<Button variant="primary" onclick={reviewPromotion} disabled={beta.state !== 'ready-promote' || acting !== null}>Review promotion</Button><Button variant="danger" onclick={rollback} disabled={acting !== null} loading={acting === 'rollback'}>{beta.state === 'ready-promote' ? 'Reject beta' : 'Roll back'}</Button>{/if}
|
||||
{#if closed}<Button href={`/betas/new?reuse=${id}`}>Reuse files and test plan</Button>{/if}
|
||||
{#if promotion}<Button href={`/campaigns/${encodeURIComponent(promotion.id)}`}>View rollout</Button>{/if}
|
||||
{#if promotion}<Button href={`/campaigns/${encodeURIComponent(promotion.id)}`}>View campaign</Button>{/if}
|
||||
</div>
|
||||
{#if promotion}<p class="muted">{promotion.pending} servers awaiting activation · {promotion.failed} need attention. Promoting a version does not mean it is already running everywhere.</p>{/if}
|
||||
{#if flash}<p role="alert" class="ui-error">{flash}</p>{/if}
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
{#if !visibleServers.length}<p>No servers match these filters. <button type="button" class="text-action" onclick={()=>{search='';serverGame='';availability='';}}>Clear filters</button></p>{/if}
|
||||
{/if}
|
||||
{:else if step===2}<BetaPlanBuilder bind:this={planBuilder} bind:plan={testPlan} serverCount={pickedServers.size}/>
|
||||
{:else}<section class="ui-panel review"><div class="review-heading"><h3>Files and exact versions</h3><Button size="sm" onclick={()=>move(0)}>Edit files</Button></div><ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}</li>{/each}</ul><div class="review-heading"><h3>Test servers ({pickedServers.size})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div><ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul><div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Rollout and failure handling</h3><p>{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs the selected versions and restarts test servers when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p></section>{/if}
|
||||
{:else}<section class="ui-panel review"><div class="review-heading"><h3>Files and exact versions</h3><Button size="sm" onclick={()=>move(0)}>Edit files</Button></div><ul>{#each picks as pick}<li><strong>{scripts.find(file=>file.id===pick.scriptId)?.name}</strong> · {artifactVersionLabel(versionsFor(pick).find(v=>v.id===pick.versionId))} · {versionsFor(pick).find(v=>v.id===pick.versionId)?.sha256.slice(0,12)}</li>{/each}</ul><div class="review-heading"><h3>Test servers ({pickedServers.size})</h3><Button size="sm" onclick={()=>move(1)}>Edit servers</Button></div><ul>{#each [...pickedServers] as name}<li><ServerIdentity {name}/></li>{/each}</ul><div class="review-heading"><h3>Pass criteria</h3><Button size="sm" onclick={()=>move(2)}>Edit plan</Button></div><p>{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour window, starting when this beta opens.</p><ul>{#each testPlan.requirements as requirement}<li>{requirementSummary(requirement)}</li>{/each}</ul><h3>Waves and failure handling</h3><p>{testPlan.waveSize?`${testPlan.waveSize} servers per wave.`:'Deploy to all selected servers.'} {testPlan.failureAction==='rollback'?'Failed checks trigger automatic rollback.':testPlan.failureAction==='pause'?'Failed checks pause testing.':'Failed checks hold the beta for review.'} {testPlan.rollbackRestart==='when-empty'?'Rollback restarts wait for empty servers.':'Rollback restores files; restart servers manually.'}</p><p>Starting installs the selected versions and restarts test servers when empty. Waiting, pausing, and retrying use the original testing window. Promotion is a separate reviewed action after testing.</p></section>{/if}
|
||||
<div class="ui-sticky-actions"><p id="beta-forward-help" class="muted">{forwardReason}</p><Button onclick={()=>move(step-1)} disabled={step===0||openingBeta}>Back</Button>{#if step<3}<Button variant="primary" onclick={()=>move(step+1)} disabled={!!forwardReason}>Continue</Button>{:else}<Button type="submit" variant="primary" disabled={!!forwardReason||openingBeta} loading={openingBeta}>Start beta on {pickedServers.size} servers</Button>{/if}</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
async function loadCampaigns(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/campaigns');
|
||||
if (!res.ok) throw new Error('Could not refresh rollouts.');
|
||||
if (!res.ok) throw new Error('Could not refresh campaigns.');
|
||||
deployments = (await res.json()).deployments; loadError = '';
|
||||
} catch (error) { loadError = (error as Error).message; }
|
||||
finally { loading = false; }
|
||||
@@ -225,12 +225,12 @@
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Rollouts | Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>Campaigns | Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<SectionHeader
|
||||
title="Rollouts"
|
||||
subtitle="Track file rollouts, reconcile drift, and review rollbacks."
|
||||
title="Campaigns"
|
||||
subtitle="Track file campaigns, reconcile drift, and review rollbacks."
|
||||
/>
|
||||
|
||||
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={loadCampaigns}>Retry</Button></div>{/if}
|
||||
@@ -325,7 +325,7 @@
|
||||
{#if result}<div class="result" class:result-error={skipped.length > 0} role={skipped.length ? 'alert' : 'status'}>
|
||||
<strong>{result.totalServers} server{result.totalServers === 1 ? '' : 's'} updated{skipped.length ? `, ${skipped.length} with skipped files` : ''}</strong>
|
||||
<p>{result.totalRemoved} files removed; {result.totalRestored} installed. {result.totalServers ? 'Affected servers restart when empty, then health is verified.' : skipped.length ? 'No changes could be applied. Review the reasons below.' : 'The reviewed servers already match the intended files.'}</p>
|
||||
{#if result.deploymentId}<a href="/campaigns/{result.deploymentId}">View rollout →</a>{/if}
|
||||
{#if result.deploymentId}<a href="/campaigns/{result.deploymentId}">View campaign →</a>{/if}
|
||||
{#each skipped as server}<div><a href="/servers/{encodeURIComponent(server.server)}?tab=diagnostics"><ServerIdentity name={server.server} /></a><ul>{#each server.skipped as reason}<li>{reason}</li>{/each}</ul></div>{/each}
|
||||
{#if skipped.length}<Button onclick={() => reviewDrift(false, skipped.map(server => server.server))}>Review unresolved servers</Button>{/if}
|
||||
</div>{/if}
|
||||
@@ -359,7 +359,7 @@
|
||||
</div>
|
||||
{#if history.length === 0}
|
||||
<EmptyState
|
||||
title="No completed rollouts in the retention window"
|
||||
title="No completed campaigns in the retention window"
|
||||
description="Retention is 7 days by default."
|
||||
/>
|
||||
{:else}
|
||||
@@ -375,7 +375,7 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
<ListFooter total={history.length} bind:limit onchange={saveContext} label="rollouts" />
|
||||
<ListFooter total={history.length} bind:limit onchange={saveContext} label="campaigns" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -396,7 +396,7 @@
|
||||
{#if flash && flashId === d.id}<div class="result" class:result-error={flashError} role={flashError ? 'alert' : 'status'}>{flash}</div>{/if}
|
||||
<div class="row-head">
|
||||
<div class="row-head-l">
|
||||
<a class="action" href="/campaigns/{d.id}">{d.action} rollout <span class="sr-only">{d.id}</span></a>
|
||||
<a class="action" href="/campaigns/{d.id}">{d.action} campaign <span class="sr-only">{d.id}</span></a>
|
||||
{#if d.action === 'promote' || d.action === 'rollback'}
|
||||
<span class="target">{scriptSummary(d)}</span>
|
||||
{/if}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
const current = ++request;
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${encodeURIComponent(id ?? '')}`);
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Rollout not found.' : 'Could not refresh rollout.');
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Campaign not found.' : 'Could not refresh campaign.');
|
||||
const body = await res.json() as Detail;
|
||||
if (current !== request || id !== $page.params.id) return;
|
||||
detail = body; loadError = '';
|
||||
@@ -109,22 +109,22 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{detail ? `${detail.deployment.action} rollout · ${detail.deployment.id}` : 'Rollout'} | Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>{detail ? `${detail.deployment.action} campaign · ${detail.deployment.id}` : 'Campaign'} | Electric Cherry</title></svelte:head>
|
||||
{#if loadError}<p role="alert" class="err">{loadError} <button onclick={loadDetail}>Retry</button></p>{/if}
|
||||
|
||||
{#if detail}
|
||||
{@const action = detail.deployment.action}
|
||||
<div class="page">
|
||||
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} rollout`} subtitle={detail.deployment.id}>
|
||||
<SectionHeader title={`${action.charAt(0).toUpperCase() + action.slice(1)} campaign`} subtitle={detail.deployment.id}>
|
||||
{#snippet crumbs()}
|
||||
<Breadcrumbs items={[
|
||||
{ label: 'Rollouts', href: '/campaigns' },
|
||||
{ label: 'Campaigns', href: '/campaigns' },
|
||||
{ label: action }
|
||||
]} />
|
||||
{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<section class="outcome" aria-label="Rollout outcome"><h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}</h2><p>Files are staged before activation. Servers restart when empty, then their health is verified.</p>{#if unresolved}<p>Review failed or waiting servers below. Diagnose connectivity or inspect startup logs from the affected row.</p>{:else}<p>Every recorded server completed activation.</p>{/if}</section>
|
||||
<section class="outcome" aria-label="Campaign outcome"><h2>{completed} / {detail.servers.length} servers activated{unresolved ? ` · ${unresolved} unresolved` : ''}</h2><p>Files are staged before activation. Servers restart when empty, then their health is verified.</p>{#if unresolved}<p>Review failed or waiting servers below. Diagnose connectivity or inspect startup logs from the affected row.</p>{:else}<p>Every recorded server completed activation.</p>{/if}</section>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="meta-grid">
|
||||
@@ -171,7 +171,7 @@
|
||||
<Badge variant="neutral" size="sm">{detail.files.length}</Badge>
|
||||
</div>
|
||||
{#if detail.files.length === 0}
|
||||
<EmptyState title="No file changes recorded" description="This rollout didn't change any tracked files." />
|
||||
<EmptyState title="No file changes recorded" description="This campaign didn't change any tracked files." />
|
||||
{:else}
|
||||
{#each [...byServer.entries()] as [srv, files] (srv)}
|
||||
<div class="file-group">
|
||||
|
||||
@@ -522,12 +522,12 @@
|
||||
{/if}
|
||||
<Card padding="lg">
|
||||
<div class="panel-head">
|
||||
<h2>Rollout history</h2>
|
||||
<h2>Campaign history</h2>
|
||||
<span class="panel-count">{history.length}</span>
|
||||
</div>
|
||||
{#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0}
|
||||
<EmptyState
|
||||
title="No rollouts yet"
|
||||
title="No campaigns yet"
|
||||
description="Nothing has been pushed to this server. Open a beta or push scripts to see history here."
|
||||
/>
|
||||
{:else}
|
||||
@@ -550,7 +550,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
<ListFooter total={history.length} bind:limit={historyLimit} label="rollouts" />
|
||||
<ListFooter total={history.length} bind:limit={historyLimit} label="campaigns" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user