feat: tableize device/directory outputs
This commit is contained in:
@@ -3,6 +3,8 @@ import type { Command } from "commander";
|
|||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { renderTable } from "../terminal/table.js";
|
||||||
|
import { theme } from "../terminal/theme.js";
|
||||||
import { withProgress } from "./progress.js";
|
import { withProgress } from "./progress.js";
|
||||||
|
|
||||||
type DevicesRpcOpts = {
|
type DevicesRpcOpts = {
|
||||||
@@ -96,11 +98,11 @@ function parseDevicePairingList(value: unknown): DevicePairingList {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
|
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
|
||||||
if (!tokens || tokens.length === 0) return "tokens: none";
|
if (!tokens || tokens.length === 0) return "none";
|
||||||
const parts = tokens
|
const parts = tokens
|
||||||
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
|
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
|
||||||
.sort((a, b) => a.localeCompare(b));
|
.sort((a, b) => a.localeCompare(b));
|
||||||
return `tokens: ${parts.join(", ")}`;
|
return parts.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerDevicesCli(program: Command) {
|
export function registerDevicesCli(program: Command) {
|
||||||
@@ -118,32 +120,59 @@ export function registerDevicesCli(program: Command) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (list.pending?.length) {
|
if (list.pending?.length) {
|
||||||
defaultRuntime.log("Pending:");
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
for (const req of list.pending) {
|
defaultRuntime.log(
|
||||||
const name = req.displayName || req.deviceId;
|
`${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`,
|
||||||
const repair = req.isRepair ? " (repair)" : "";
|
);
|
||||||
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
|
defaultRuntime.log(
|
||||||
const age =
|
renderTable({
|
||||||
typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
|
width: tableWidth,
|
||||||
const role = req.role ? ` · role: ${req.role}` : "";
|
columns: [
|
||||||
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
|
{ key: "Request", header: "Request", minWidth: 10 },
|
||||||
}
|
{ key: "Device", header: "Device", minWidth: 16, flex: true },
|
||||||
|
{ key: "Role", header: "Role", minWidth: 8 },
|
||||||
|
{ key: "IP", header: "IP", minWidth: 12 },
|
||||||
|
{ key: "Age", header: "Age", minWidth: 8 },
|
||||||
|
{ key: "Flags", header: "Flags", minWidth: 8 },
|
||||||
|
],
|
||||||
|
rows: list.pending.map((req) => ({
|
||||||
|
Request: req.requestId,
|
||||||
|
Device: req.displayName || req.deviceId,
|
||||||
|
Role: req.role ?? "",
|
||||||
|
IP: req.remoteIp ?? "",
|
||||||
|
Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "",
|
||||||
|
Flags: req.isRepair ? "repair" : "",
|
||||||
|
})),
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (list.paired?.length) {
|
if (list.paired?.length) {
|
||||||
defaultRuntime.log("Paired:");
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
for (const device of list.paired) {
|
defaultRuntime.log(
|
||||||
const name = device.displayName || device.deviceId;
|
`${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`,
|
||||||
const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
|
);
|
||||||
const scopes = device.scopes?.length
|
defaultRuntime.log(
|
||||||
? `scopes: ${device.scopes.join(", ")}`
|
renderTable({
|
||||||
: "scopes: -";
|
width: tableWidth,
|
||||||
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
|
columns: [
|
||||||
const tokens = formatTokenSummary(device.tokens);
|
{ key: "Device", header: "Device", minWidth: 16, flex: true },
|
||||||
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
|
{ key: "Roles", header: "Roles", minWidth: 12, flex: true },
|
||||||
}
|
{ key: "Scopes", header: "Scopes", minWidth: 12, flex: true },
|
||||||
|
{ key: "Tokens", header: "Tokens", minWidth: 12, flex: true },
|
||||||
|
{ key: "IP", header: "IP", minWidth: 12 },
|
||||||
|
],
|
||||||
|
rows: list.paired.map((device) => ({
|
||||||
|
Device: device.displayName || device.deviceId,
|
||||||
|
Roles: device.roles?.length ? device.roles.join(", ") : "",
|
||||||
|
Scopes: device.scopes?.length ? device.scopes.join(", ") : "",
|
||||||
|
Tokens: formatTokenSummary(device.tokens),
|
||||||
|
IP: device.remoteIp ?? "",
|
||||||
|
})),
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!list.pending?.length && !list.paired?.length) {
|
if (!list.pending?.length && !list.paired?.length) {
|
||||||
defaultRuntime.log("No device pairing entries.");
|
defaultRuntime.log(theme.muted("No device pairing entries."));
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -160,7 +189,7 @@ export function registerDevicesCli(program: Command) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
|
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
|
||||||
defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`);
|
defaultRuntime.log(`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -176,7 +205,7 @@ export function registerDevicesCli(program: Command) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const deviceId = (result as { deviceId?: string })?.deviceId;
|
const deviceId = (result as { deviceId?: string })?.deviceId;
|
||||||
defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`);
|
defaultRuntime.log(`${theme.warn("Rejected")} ${theme.command(deviceId ?? "ok")}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
|
import { renderTable } from "../terminal/table.js";
|
||||||
|
|
||||||
function parseLimit(value: unknown): number | null {
|
function parseLimit(value: unknown): number | null {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
@@ -22,9 +23,11 @@ function parseLimit(value: unknown): number | null {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEntry(entry: { kind: string; id: string; name?: string | undefined }): string {
|
function buildRows(entries: Array<{ id: string; name?: string | undefined }>) {
|
||||||
const name = entry.name?.trim();
|
return entries.map((entry) => ({
|
||||||
return name ? `${entry.id}\t${name}` : entry.id;
|
ID: entry.id,
|
||||||
|
Name: entry.name?.trim() ?? "",
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerDirectoryCli(program: Command) {
|
export function registerDirectoryCli(program: Command) {
|
||||||
@@ -77,10 +80,21 @@ export function registerDirectoryCli(program: Command) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!result) {
|
if (!result) {
|
||||||
defaultRuntime.log("not available");
|
defaultRuntime.log(theme.muted("Not available."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(formatEntry(result));
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
|
defaultRuntime.log(`${theme.heading("Self")}`);
|
||||||
|
defaultRuntime.log(
|
||||||
|
renderTable({
|
||||||
|
width: tableWidth,
|
||||||
|
columns: [
|
||||||
|
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||||
|
{ key: "Name", header: "Name", minWidth: 18, flex: true },
|
||||||
|
],
|
||||||
|
rows: buildRows([result]),
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
defaultRuntime.error(danger(String(err)));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@@ -111,9 +125,22 @@ export function registerDirectoryCli(program: Command) {
|
|||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const entry of result) {
|
if (result.length === 0) {
|
||||||
defaultRuntime.log(formatEntry(entry));
|
defaultRuntime.log(theme.muted("No peers found."));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
|
defaultRuntime.log(`${theme.heading("Peers")} ${theme.muted(`(${result.length})`)}`);
|
||||||
|
defaultRuntime.log(
|
||||||
|
renderTable({
|
||||||
|
width: tableWidth,
|
||||||
|
columns: [
|
||||||
|
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||||
|
{ key: "Name", header: "Name", minWidth: 18, flex: true },
|
||||||
|
],
|
||||||
|
rows: buildRows(result),
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
defaultRuntime.error(danger(String(err)));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@@ -143,9 +170,22 @@ export function registerDirectoryCli(program: Command) {
|
|||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const entry of result) {
|
if (result.length === 0) {
|
||||||
defaultRuntime.log(formatEntry(entry));
|
defaultRuntime.log(theme.muted("No groups found."));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
|
defaultRuntime.log(`${theme.heading("Groups")} ${theme.muted(`(${result.length})`)}`);
|
||||||
|
defaultRuntime.log(
|
||||||
|
renderTable({
|
||||||
|
width: tableWidth,
|
||||||
|
columns: [
|
||||||
|
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||||
|
{ key: "Name", header: "Name", minWidth: 18, flex: true },
|
||||||
|
],
|
||||||
|
rows: buildRows(result),
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
defaultRuntime.error(danger(String(err)));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { Command } from "commander";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
||||||
import { getWideAreaZonePath, WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
|
import { getWideAreaZonePath, WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
type RunOpts = { allowFailure?: boolean; inherit?: boolean };
|
type RunOpts = { allowFailure?: boolean; inherit?: boolean };
|
||||||
@@ -112,14 +114,28 @@ export function registerDnsCli(program: Command) {
|
|||||||
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||||
const zonePath = getWideAreaZonePath();
|
const zonePath = getWideAreaZonePath();
|
||||||
|
|
||||||
console.log(`Domain: ${WIDE_AREA_DISCOVERY_DOMAIN}`);
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
console.log(`Zone file (gateway-owned): ${zonePath}`);
|
defaultRuntime.log(theme.heading("DNS setup"));
|
||||||
console.log(
|
defaultRuntime.log(
|
||||||
`Detected tailnet IP: ${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`,
|
renderTable({
|
||||||
|
width: tableWidth,
|
||||||
|
columns: [
|
||||||
|
{ key: "Key", header: "Key", minWidth: 18 },
|
||||||
|
{ key: "Value", header: "Value", minWidth: 24, flex: true },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{ Key: "Domain", Value: WIDE_AREA_DISCOVERY_DOMAIN },
|
||||||
|
{ Key: "Zone file", Value: zonePath },
|
||||||
|
{
|
||||||
|
Key: "Tailnet IP",
|
||||||
|
Value: `${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).trimEnd(),
|
||||||
);
|
);
|
||||||
console.log("");
|
defaultRuntime.log("");
|
||||||
console.log("Recommended ~/.clawdbot/clawdbot.json:");
|
defaultRuntime.log(theme.heading("Recommended ~/.clawdbot/clawdbot.json:"));
|
||||||
console.log(
|
defaultRuntime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
gateway: { bind: "auto" },
|
gateway: { bind: "auto" },
|
||||||
@@ -129,14 +145,16 @@ export function registerDnsCli(program: Command) {
|
|||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
console.log("");
|
defaultRuntime.log("");
|
||||||
console.log("Tailscale admin (DNS → Nameservers):");
|
defaultRuntime.log(theme.heading("Tailscale admin (DNS → Nameservers):"));
|
||||||
console.log(`- Add nameserver: ${tailnetIPv4 ?? "<this machine's tailnet IPv4>"}`);
|
defaultRuntime.log(
|
||||||
console.log(`- Restrict to domain (Split DNS): clawdbot.internal`);
|
theme.muted(`- Add nameserver: ${tailnetIPv4 ?? "<this machine's tailnet IPv4>"}`),
|
||||||
|
);
|
||||||
|
defaultRuntime.log(theme.muted("- Restrict to domain (Split DNS): clawdbot.internal"));
|
||||||
|
|
||||||
if (!opts.apply) {
|
if (!opts.apply) {
|
||||||
console.log("");
|
defaultRuntime.log("");
|
||||||
console.log("Run with --apply to install CoreDNS and configure it.");
|
defaultRuntime.log(theme.muted("Run with --apply to install CoreDNS and configure it."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,16 +223,18 @@ export function registerDnsCli(program: Command) {
|
|||||||
fs.writeFileSync(zonePath, zoneLines.join("\n"), "utf-8");
|
fs.writeFileSync(zonePath, zoneLines.join("\n"), "utf-8");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("");
|
defaultRuntime.log("");
|
||||||
console.log("Starting CoreDNS (sudo)…");
|
defaultRuntime.log(theme.heading("Starting CoreDNS (sudo)…"));
|
||||||
run("sudo", ["brew", "services", "restart", "coredns"], {
|
run("sudo", ["brew", "services", "restart", "coredns"], {
|
||||||
inherit: true,
|
inherit: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cfg.discovery?.wideArea?.enabled !== true) {
|
if (cfg.discovery?.wideArea?.enabled !== true) {
|
||||||
console.log("");
|
defaultRuntime.log("");
|
||||||
console.log(
|
defaultRuntime.log(
|
||||||
|
theme.muted(
|
||||||
"Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.",
|
"Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
listChannelPairingRequests,
|
listChannelPairingRequests,
|
||||||
type PairingChannel,
|
type PairingChannel,
|
||||||
} from "../pairing/pairing-store.js";
|
} from "../pairing/pairing-store.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
|
|
||||||
@@ -70,18 +72,35 @@ export function registerPairingCli(program: Command) {
|
|||||||
const channel = parseChannel(channelRaw, channels);
|
const channel = parseChannel(channelRaw, channels);
|
||||||
const requests = await listChannelPairingRequests(channel);
|
const requests = await listChannelPairingRequests(channel);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
console.log(JSON.stringify({ channel, requests }, null, 2));
|
defaultRuntime.log(JSON.stringify({ channel, requests }, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (requests.length === 0) {
|
if (requests.length === 0) {
|
||||||
console.log(`No pending ${channel} pairing requests.`);
|
defaultRuntime.log(theme.muted(`No pending ${channel} pairing requests.`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const r of requests) {
|
|
||||||
const meta = r.meta ? JSON.stringify(r.meta) : "";
|
|
||||||
const idLabel = resolvePairingIdLabel(channel);
|
const idLabel = resolvePairingIdLabel(channel);
|
||||||
console.log(`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`);
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
}
|
defaultRuntime.log(
|
||||||
|
`${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`,
|
||||||
|
);
|
||||||
|
defaultRuntime.log(
|
||||||
|
renderTable({
|
||||||
|
width: tableWidth,
|
||||||
|
columns: [
|
||||||
|
{ key: "Code", header: "Code", minWidth: 10 },
|
||||||
|
{ key: "ID", header: idLabel, minWidth: 12, flex: true },
|
||||||
|
{ key: "Meta", header: "Meta", minWidth: 8, flex: true },
|
||||||
|
{ key: "Requested", header: "Requested", minWidth: 12 },
|
||||||
|
],
|
||||||
|
rows: requests.map((r) => ({
|
||||||
|
Code: r.code,
|
||||||
|
ID: r.id,
|
||||||
|
Meta: r.meta ? JSON.stringify(r.meta) : "",
|
||||||
|
Requested: r.createdAt,
|
||||||
|
})),
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
pairing
|
pairing
|
||||||
@@ -113,11 +132,13 @@ export function registerPairingCli(program: Command) {
|
|||||||
throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`);
|
throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Approved ${channel} sender ${approved.id}.`);
|
defaultRuntime.log(
|
||||||
|
`${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!opts.notify) return;
|
if (!opts.notify) return;
|
||||||
await notifyApproved(channel, approved.id).catch((err) => {
|
await notifyApproved(channel, approved.id).catch((err) => {
|
||||||
console.log(`Failed to notify requester: ${String(err)}`);
|
defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user