Redesign ServiceCard: state-driven controls with visual indicators
- Color-coded left border: green=running, red=stopped, grey=unknown - Status pill with icon: "Running" (green), "Stopped" (red), etc. - Stopped cards appear muted (75% opacity), recover on hover - Running: restart (↻) + stop (⏻) icon buttons, stop needs confirm - Stopped: prominent green play (▶) button - Loading state: spinner replaces controls with "Processing..." - Added coolify.ts lib and config.ts for shared Coolify API access - No pause support (Coolify API doesn't expose docker pause) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { controlResource, triggerDeploy } from '@/lib/coolify';
|
||||||
|
|
||||||
const COOLIFY_API = 'http://192.168.1.3:8000/api/v1';
|
const VALID_ACTIONS = ['start', 'stop', 'restart', 'deploy'] as const;
|
||||||
const COOLIFY_TOKEN = process.env.COOLIFY_API_TOKEN || '';
|
|
||||||
|
|
||||||
const VALID_ACTIONS = ['start', 'stop', 'restart'] as const;
|
|
||||||
const VALID_RESOURCE_TYPES = ['application', 'service', 'database'] as const;
|
const VALID_RESOURCE_TYPES = ['application', 'service', 'database'] as const;
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -15,54 +13,41 @@ export async function POST(request: NextRequest) {
|
|||||||
action: string;
|
action: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!uuid || !resourceType || !action) {
|
if (!uuid || !action) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Missing uuid or action' }, { status: 400 });
|
||||||
{ error: 'Missing uuid, resourceType, or action' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_ACTIONS.includes(action as typeof VALID_ACTIONS[number])) {
|
if (!VALID_ACTIONS.includes(action as typeof VALID_ACTIONS[number])) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: `Invalid action: ${action}` }, { status: 400 });
|
||||||
{ error: `Invalid action: ${action}` },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_RESOURCE_TYPES.includes(resourceType as typeof VALID_RESOURCE_TYPES[number])) {
|
// Deploy action
|
||||||
return NextResponse.json(
|
if (action === 'deploy') {
|
||||||
{ error: `Invalid resourceType: ${resourceType}` },
|
const result = await triggerDeploy(uuid);
|
||||||
{ status: 400 }
|
if (!result.ok) {
|
||||||
);
|
return NextResponse.json({ error: result.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true, action, uuid });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coolify API: POST /api/v1/{resourceType}s/{uuid}/{action}
|
// Start/stop/restart
|
||||||
const endpoint = `${COOLIFY_API}/${resourceType}s/${uuid}/${action}`;
|
if (!resourceType || !VALID_RESOURCE_TYPES.includes(resourceType as typeof VALID_RESOURCE_TYPES[number])) {
|
||||||
|
return NextResponse.json({ error: `Invalid resourceType: ${resourceType}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
const result = await controlResource(
|
||||||
method: 'POST',
|
uuid,
|
||||||
headers: {
|
resourceType as 'application' | 'service' | 'database',
|
||||||
Authorization: `Bearer ${COOLIFY_TOKEN}`,
|
action as 'start' | 'stop' | 'restart'
|
||||||
Accept: 'application/json',
|
);
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(15000),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!result.ok) {
|
||||||
const text = await res.text();
|
return NextResponse.json({ error: `Coolify returned ${result.status}` }, { status: result.status });
|
||||||
console.error(`Coolify control error: ${res.status} ${text}`);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Coolify returned ${res.status}` },
|
|
||||||
{ status: res.status }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, action, uuid });
|
return NextResponse.json({ ok: true, action, uuid });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Control error:', error);
|
console.error('Control error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Failed to control service' }, { status: 500 });
|
||||||
{ error: 'Failed to control service' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ interface ServiceCardProps {
|
|||||||
status: HealthStatus;
|
status: HealthStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<HealthStatus, string> = {
|
const borderColors: Record<HealthStatus, string> = {
|
||||||
running: 'bg-emerald-500',
|
running: 'border-l-emerald-500',
|
||||||
stopped: 'bg-red-500',
|
stopped: 'border-l-red-500',
|
||||||
unknown: 'bg-slate-400 dark:bg-stone-500',
|
unknown: 'border-l-slate-400 dark:border-l-stone-600',
|
||||||
loading: 'bg-amber-500 animate-pulse',
|
loading: 'border-l-amber-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusPillStyles: Record<HealthStatus, string> = {
|
||||||
|
running: 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400',
|
||||||
|
stopped: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
|
||||||
|
unknown: 'bg-slate-100 dark:bg-stone-800 text-slate-500 dark:text-stone-500',
|
||||||
|
loading: 'bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabels: Record<HealthStatus, string> = {
|
const statusLabels: Record<HealthStatus, string> = {
|
||||||
@@ -24,6 +31,13 @@ const statusLabels: Record<HealthStatus, string> = {
|
|||||||
loading: 'Checking...',
|
loading: 'Checking...',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusIcons: Record<HealthStatus, string> = {
|
||||||
|
running: 'circle',
|
||||||
|
stopped: 'power',
|
||||||
|
unknown: 'circle',
|
||||||
|
loading: 'loader',
|
||||||
|
};
|
||||||
|
|
||||||
function isDiscovered(service: Service): service is DiscoveredService {
|
function isDiscovered(service: Service): service is DiscoveredService {
|
||||||
return 'source' in service && (service as DiscoveredService).source === 'discovered';
|
return 'source' in service && (service as DiscoveredService).source === 'discovered';
|
||||||
}
|
}
|
||||||
@@ -55,6 +69,7 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
const discovered = isDiscovered(service);
|
const discovered = isDiscovered(service);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [confirmStop, setConfirmStop] = useState(false);
|
const [confirmStop, setConfirmStop] = useState(false);
|
||||||
|
const isStopped = status === 'stopped' || status === 'unknown';
|
||||||
|
|
||||||
const controlService = useCallback(async (action: 'start' | 'stop' | 'restart') => {
|
const controlService = useCallback(async (action: 'start' | 'stop' | 'restart') => {
|
||||||
if (!discovered) return;
|
if (!discovered) return;
|
||||||
@@ -70,7 +85,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
action,
|
action,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
// Coolify takes a moment to process — page will refresh via polling
|
|
||||||
} catch { /* ignore */ } finally {
|
} catch { /* ignore */ } finally {
|
||||||
setTimeout(() => setLoading(false), 3000);
|
setTimeout(() => setLoading(false), 3000);
|
||||||
}
|
}
|
||||||
@@ -86,18 +100,14 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
}, [confirmStop, controlService]);
|
}, [confirmStop, controlService]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative block p-4 bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm hover:border-slate-200 dark:hover:border-stone-600/50 hover:shadow-md transition-all duration-200">
|
<div className={`group relative p-4 bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 border-l-[3px] ${borderColors[status]} shadow-sm hover:shadow-md transition-all duration-200 ${isStopped ? 'opacity-75 hover:opacity-100' : ''}`}>
|
||||||
{/* Status indicator */}
|
{/* Top row: badges */}
|
||||||
<div className="absolute top-3 right-3 flex items-center gap-1.5">
|
<div className="absolute top-3 right-3 flex items-center gap-1.5">
|
||||||
{resourceBadge && (
|
{resourceBadge && (
|
||||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 dark:bg-stone-800 text-slate-500 dark:text-stone-400">
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 dark:bg-stone-800 text-slate-500 dark:text-stone-400">
|
||||||
{resourceBadge}
|
{resourceBadge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
|
||||||
className={`w-2 h-2 rounded-full ${statusColors[status]}`}
|
|
||||||
title={statusLabels[status]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clickable area for opening service URL */}
|
{/* Clickable area for opening service URL */}
|
||||||
@@ -107,7 +117,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
{/* Icon */}
|
|
||||||
<div className="w-10 h-10 flex items-center justify-center rounded-lg bg-slate-100 dark:bg-stone-800 mb-3 group-hover:bg-slate-200 dark:group-hover:bg-stone-700 transition-colors">
|
<div className="w-10 h-10 flex items-center justify-center rounded-lg bg-slate-100 dark:bg-stone-800 mb-3 group-hover:bg-slate-200 dark:group-hover:bg-stone-700 transition-colors">
|
||||||
<Icon
|
<Icon
|
||||||
name={service.icon}
|
name={service.icon}
|
||||||
@@ -116,7 +125,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<h3 className="font-medium text-slate-900 dark:text-stone-100 mb-1">
|
<h3 className="font-medium text-slate-900 dark:text-stone-100 mb-1">
|
||||||
{service.name}
|
{service.name}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -126,7 +134,6 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Badge: FQDN subdomain or port */}
|
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
{fqdnLabel && (
|
{fqdnLabel && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400 font-mono">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400 font-mono">
|
||||||
@@ -141,50 +148,54 @@ export function ServiceCard({ service, status }: ServiceCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Control buttons for discovered services */}
|
{/* Controls footer */}
|
||||||
{discovered && (
|
{discovered && (
|
||||||
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-stone-800 flex items-center gap-1.5">
|
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-stone-800 flex items-center justify-between">
|
||||||
{status === 'stopped' || status === 'unknown' ? (
|
{/* Status pill */}
|
||||||
<button
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium ${statusPillStyles[status]}`}>
|
||||||
onClick={() => controlService('start')}
|
<Icon
|
||||||
disabled={loading}
|
name={loading ? 'loader' : statusIcons[status]}
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors disabled:opacity-50"
|
size={10}
|
||||||
>
|
className={loading || status === 'loading' ? 'animate-spin' : ''}
|
||||||
{loading ? (
|
/>
|
||||||
<Icon name="loader" size={12} className="animate-spin" />
|
{loading ? 'Processing...' : statusLabels[status]}
|
||||||
) : (
|
</span>
|
||||||
<Icon name="play" size={12} />
|
|
||||||
)}
|
{/* Action buttons */}
|
||||||
Start
|
{!loading && (
|
||||||
</button>
|
<div className="flex items-center gap-1">
|
||||||
) : status === 'running' ? (
|
{isStopped ? (
|
||||||
<>
|
<button
|
||||||
<button
|
onClick={() => controlService('start')}
|
||||||
onClick={() => controlService('restart')}
|
title="Start"
|
||||||
disabled={loading}
|
className="p-1.5 rounded-md bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100 dark:hover:bg-emerald-900/40 transition-colors"
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-slate-50 dark:bg-stone-800 text-slate-500 dark:text-stone-400 hover:bg-slate-100 dark:hover:bg-stone-700 transition-colors disabled:opacity-50"
|
>
|
||||||
>
|
<Icon name="play" size={14} />
|
||||||
{loading ? (
|
</button>
|
||||||
<Icon name="loader" size={12} className="animate-spin" />
|
) : status === 'running' ? (
|
||||||
) : (
|
<>
|
||||||
<Icon name="refresh-cw" size={12} />
|
<button
|
||||||
)}
|
onClick={() => controlService('restart')}
|
||||||
Restart
|
title="Restart"
|
||||||
</button>
|
className="p-1.5 rounded-md text-slate-400 dark:text-stone-500 hover:bg-slate-100 dark:hover:bg-stone-800 hover:text-slate-600 dark:hover:text-stone-300 transition-colors"
|
||||||
<button
|
>
|
||||||
onClick={handleStop}
|
<Icon name="refresh-cw" size={14} />
|
||||||
disabled={loading}
|
</button>
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${
|
<button
|
||||||
confirmStop
|
onClick={handleStop}
|
||||||
? 'bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400'
|
title={confirmStop ? 'Click again to confirm' : 'Stop'}
|
||||||
: 'bg-slate-50 dark:bg-stone-800 text-slate-500 dark:text-stone-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 dark:hover:text-red-400'
|
className={`p-1.5 rounded-md transition-colors ${
|
||||||
}`}
|
confirmStop
|
||||||
>
|
? 'bg-red-100 dark:bg-red-900/40 text-red-500 dark:text-red-400 animate-pulse'
|
||||||
<Icon name="power" size={12} />
|
: 'text-slate-400 dark:text-stone-500 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-500 dark:hover:text-red-400'
|
||||||
{confirmStop ? 'Confirm' : 'Stop'}
|
}`}
|
||||||
</button>
|
>
|
||||||
</>
|
<Icon name="power" size={14} />
|
||||||
) : null}
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
19
src/lib/config.ts
Normal file
19
src/lib/config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Server-side configuration (only available in API routes / server components)
|
||||||
|
export const serverConfig = {
|
||||||
|
coolifyToken: process.env.COOLIFY_API_TOKEN || '',
|
||||||
|
coolifyApiUrl: process.env.COOLIFY_API_URL || 'http://192.168.1.3:8000/api/v1',
|
||||||
|
coolifyServerUuid: process.env.COOLIFY_SERVER_UUID || 'qk84w0goo4w48g4ggsoo0oss',
|
||||||
|
coolifyDbUrl: process.env.COOLIFY_DB_URL || '',
|
||||||
|
prometheusUrl: process.env.PROMETHEUS_URL || 'http://192.168.1.3:9091',
|
||||||
|
nodeExporterInstance: process.env.NODE_EXPORTER_INSTANCE || '192.168.1.3:9100',
|
||||||
|
nicDevice: process.env.NIC_DEVICE || 'eno1',
|
||||||
|
nucHost: process.env.NUC_HOST || '192.168.1.3',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client-side configuration (available everywhere via NEXT_PUBLIC_ prefix)
|
||||||
|
export const clientConfig = {
|
||||||
|
nucHost: process.env.NEXT_PUBLIC_NUC_HOST || '192.168.1.3',
|
||||||
|
coolifyUrl: process.env.NEXT_PUBLIC_COOLIFY_URL || 'http://192.168.1.3:8000',
|
||||||
|
coolifyProjectUuid: process.env.NEXT_PUBLIC_COOLIFY_PROJECT_UUID || 'a8484ggc88c40w4g4k004ow0',
|
||||||
|
grafanaUrl: process.env.NEXT_PUBLIC_GRAFANA_URL || 'http://192.168.1.3:3333',
|
||||||
|
};
|
||||||
97
src/lib/coolify.ts
Normal file
97
src/lib/coolify.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { serverConfig } from './config';
|
||||||
|
|
||||||
|
const { coolifyApiUrl, coolifyToken, coolifyServerUuid } = serverConfig;
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json() as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoolifyResource {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchResources(): Promise<CoolifyResource[] | null> {
|
||||||
|
return fetchJson<CoolifyResource[]>(
|
||||||
|
`${coolifyApiUrl}/servers/${coolifyServerUuid}/resources`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAppDetail(uuid: string) {
|
||||||
|
return fetchJson<{
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
fqdn: string | null;
|
||||||
|
ports_exposes: string | null;
|
||||||
|
ports_mappings: string | null;
|
||||||
|
status: string;
|
||||||
|
description: string | null;
|
||||||
|
}>(`${coolifyApiUrl}/applications/${uuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceDetail(uuid: string) {
|
||||||
|
return fetchJson<{
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
applications?: Array<{
|
||||||
|
name: string;
|
||||||
|
human_name: string | null;
|
||||||
|
fqdn: string | null;
|
||||||
|
ports: string | null;
|
||||||
|
status: string;
|
||||||
|
image: string | null;
|
||||||
|
}>;
|
||||||
|
databases?: Array<{
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}>(`${coolifyApiUrl}/services/${uuid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function controlResource(
|
||||||
|
uuid: string,
|
||||||
|
resourceType: 'application' | 'service' | 'database',
|
||||||
|
action: 'start' | 'stop' | 'restart'
|
||||||
|
): Promise<{ ok: boolean; status: number }> {
|
||||||
|
const endpoint = `${coolifyApiUrl}/${resourceType}s/${uuid}/${action}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
return { ok: res.ok, status: res.status };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 500 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerDeploy(uuid: string): Promise<{ ok: boolean; message?: string }> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${coolifyApiUrl}/deploy?uuid=${uuid}&force=false`, {
|
||||||
|
headers: { Authorization: `Bearer ${coolifyToken}`, Accept: 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
return { ok: false, message: `${res.status}: ${text}` };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, message: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user