489 lines
21 KiB
TypeScript
489 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||
import { formatDateTime } from '@/utils/dateTime';
|
||
|
||
type SmsApp = {
|
||
id: string;
|
||
tenantId: string;
|
||
name: string;
|
||
enterprise: string;
|
||
appId: string;
|
||
enabled: boolean;
|
||
sentToday: number;
|
||
deliveryRate: number;
|
||
unitPrice: number;
|
||
cmppStatus: 'connected' | 'disconnected' | 'inactive';
|
||
cmppConnections: CmppConnection[];
|
||
cmppParams: CmppParams;
|
||
};
|
||
|
||
type CmppParams = {
|
||
host: string;
|
||
port: number;
|
||
interfaceEnabled: boolean;
|
||
interfaceType: string;
|
||
enterpriseCode: string;
|
||
account: string;
|
||
password: string;
|
||
accessNumber: string;
|
||
maxConnections: number;
|
||
heartbeatSeconds: number;
|
||
windowSize: number;
|
||
protocolVersion: string;
|
||
};
|
||
|
||
type CmppConnection = {
|
||
id: string;
|
||
state: 'open' | 'closed' | 'reconnecting';
|
||
bindType: 'transceiver' | 'submitter' | 'receiver';
|
||
clientIp: string;
|
||
sourceAddr: string;
|
||
establishedAt: string;
|
||
lastHeartbeatAt: string;
|
||
lastSubmitAt: string;
|
||
pendingWindow: number;
|
||
};
|
||
|
||
function enabledTag(enabled: boolean) {
|
||
return <Tag tone={enabled ? 'success' : 'neutral'}>{enabled ? '启用' : '停用'}</Tag>;
|
||
}
|
||
|
||
function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: string; danger?: boolean; onCancel: () => void; onConfirm: () => void }) {
|
||
return (
|
||
<Modal
|
||
footer={(
|
||
<>
|
||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||
<Button onClick={onConfirm} variant={danger ? 'danger' : 'primary'}>确认</Button>
|
||
</>
|
||
)}
|
||
onClose={onCancel}
|
||
open
|
||
title="操作确认"
|
||
>
|
||
<p className="admin-confirm-text">{message}</p>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function AddApplicationModal({
|
||
tenants,
|
||
loading,
|
||
selectedTenantId,
|
||
onChange,
|
||
onCancel,
|
||
onConfirm,
|
||
}: {
|
||
tenants: TenantOption[];
|
||
loading: boolean;
|
||
selectedTenantId: string;
|
||
onChange: (tenantId: string) => void;
|
||
onCancel: () => void;
|
||
onConfirm: () => void;
|
||
}) {
|
||
return (
|
||
<Modal
|
||
footer={(
|
||
<>
|
||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||
<Button disabled={!selectedTenantId || loading} onClick={onConfirm}>下一步</Button>
|
||
</>
|
||
)}
|
||
onClose={onCancel}
|
||
open
|
||
title={<div className="template-modal-title"><h2>新建企业应用</h2><p>先选择真实企业,再配置短信应用和三网通道组。</p></div>}
|
||
>
|
||
<div className="form-grid app-create-modal">
|
||
<Select
|
||
disabled={loading || tenants.length === 0}
|
||
hint={!loading && tenants.length === 0 ? '暂无可选择企业,请先创建真实企业。' : undefined}
|
||
label="所属企业"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
options={[
|
||
{ label: loading ? '企业加载中...' : '请选择真实企业', value: '' },
|
||
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||
]}
|
||
value={selectedTenantId}
|
||
/>
|
||
<div className="app-create-modal__hint">
|
||
<strong>{selectedTenantId ? tenants.find((tenant) => tenant.id === selectedTenantId)?.name : '请选择要开通短信应用的企业'}</strong>
|
||
<span>下一步会进入应用参数、客户单价、IP 白名单和运营商通道组配置。</span>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone: 'success' | 'warning' | 'neutral' }> = {
|
||
open: { label: '已连接', tone: 'success' },
|
||
closed: { label: '已断开', tone: 'neutral' },
|
||
reconnecting: { label: '重连中', tone: 'warning' },
|
||
};
|
||
|
||
function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
|
||
const cmppParams = params ?? app.cmppParams;
|
||
const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled;
|
||
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
|
||
return [
|
||
`应用名称: ${app.name}`,
|
||
`企业名称: ${app.enterprise}`,
|
||
`AppID: ${app.appId}`,
|
||
`短信接口: ${interfaceEnabled ? '开通' : '关闭'}`,
|
||
`接口类型: ${interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}`,
|
||
`CMPP网关地址: ${'gatewayHost' in cmppParams ? cmppParams.gatewayHost : cmppParams.host}`,
|
||
`CMPP网关端口: ${'gatewayPort' in cmppParams ? cmppParams.gatewayPort : cmppParams.port}`,
|
||
`企业代码: ${cmppParams.enterpriseCode}`,
|
||
`接口账号: ${cmppParams.account}`,
|
||
`接口密码: ${'passwordCipher' in cmppParams ? cmppParams.passwordCipher : cmppParams.password}`,
|
||
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
|
||
`最大连接数: ${cmppParams.maxConnections}`,
|
||
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
||
`协议版本: ${cmppParams.protocolVersion}`,
|
||
].join('\n');
|
||
}
|
||
|
||
function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: ApplicationCmppParams | null; onClose: () => void }) {
|
||
const [copied, setCopied] = useState(false);
|
||
const paramsText = formatCmppParams(app, params);
|
||
const host = params?.gatewayHost ?? app.cmppParams.host;
|
||
const port = params?.gatewayPort ?? app.cmppParams.port;
|
||
const password = params?.passwordCipher ?? app.cmppParams.password;
|
||
const srcId = params?.srcId ?? app.cmppParams.accessNumber;
|
||
const interfaceEnabled = params?.interfaceEnabled ?? app.cmppParams.interfaceEnabled;
|
||
const interfaceType = params?.interfaceType ?? app.cmppParams.interfaceType;
|
||
|
||
async function copyParams() {
|
||
await navigator.clipboard.writeText(paramsText);
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1600);
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
footer={(
|
||
<>
|
||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||
<Button icon={<Copy size={15} />} onClick={copyParams}>{copied ? '已复制' : '一键复制'}</Button>
|
||
</>
|
||
)}
|
||
onClose={onClose}
|
||
open
|
||
size="xl"
|
||
title={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||
>
|
||
<div className="cmpp-param-detail">
|
||
<div className="cmpp-param-grid">
|
||
<div><span>短信接口</span><strong>{interfaceEnabled ? '开通' : '关闭'}</strong></div>
|
||
<div><span>接口类型</span><strong>{interfaceType === 'cmpp20' ? 'CMPP2.0' : 'HTTP接口'}</strong></div>
|
||
<div><span>CMPP网关地址</span><strong>{host}</strong></div>
|
||
<div><span>CMPP网关端口</span><strong>{port}</strong></div>
|
||
<div><span>企业代码</span><strong>{params?.enterpriseCode ?? app.cmppParams.enterpriseCode}</strong></div>
|
||
<div><span>接口账号</span><strong>{params?.account ?? app.cmppParams.account}</strong></div>
|
||
<div><span>接口密码</span><strong>{password}</strong></div>
|
||
<div><span>接入号</span><strong>{srcId}</strong></div>
|
||
<div><span>最大连接数</span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
|
||
<div><span>心跳间隔</span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} 秒</strong></div>
|
||
<div><span>协议版本</span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
|
||
</div>
|
||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function CmppConnectionModal({
|
||
app,
|
||
onClose,
|
||
onDeleteConnection,
|
||
}: {
|
||
app: SmsApp;
|
||
onClose: () => void;
|
||
onDeleteConnection: (connectionId: string) => void;
|
||
}) {
|
||
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
|
||
|
||
return (
|
||
<Modal
|
||
footer={<Button onClick={onClose}>关闭</Button>}
|
||
onClose={onClose}
|
||
open
|
||
size="xl"
|
||
title={<div className="template-modal-title"><h2>CMPP连接详情</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||
>
|
||
<div className="cmpp-connection-detail">
|
||
<div className="cmpp-connection-summary">
|
||
<div><span>当前连接数</span><strong>{activeConnections}</strong></div>
|
||
<div><span>配置连接数</span><strong>{Math.max(activeConnections, app.cmppConnections.length)}</strong></div>
|
||
<div><span>AppID</span><strong>{app.appId}</strong></div>
|
||
<div><span>连接状态</span><Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}</Tag></div>
|
||
</div>
|
||
<Table
|
||
columns={[
|
||
{ key: 'id', title: '连接ID', width: '150px', render: (record: CmppConnection) => <strong>{record.id}</strong> },
|
||
{ key: 'state', title: '状态', width: '130px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
|
||
{ key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType },
|
||
{ key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp },
|
||
{ key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr },
|
||
{ key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt },
|
||
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
|
||
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
|
||
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
|
||
{
|
||
key: 'actions',
|
||
title: '操作',
|
||
align: 'right',
|
||
width: '120px',
|
||
render: (record: CmppConnection) => (
|
||
<Button icon={<Trash2 size={14} />} onClick={() => onDeleteConnection(record.id)} size="sm" variant="danger">删除</Button>
|
||
),
|
||
},
|
||
]}
|
||
data={app.cmppConnections}
|
||
emptyText="暂无CMPP连接"
|
||
rowKey="id"
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
export function AdminEnterpriseApplicationsPage() {
|
||
const navigate = useNavigate();
|
||
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
|
||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
|
||
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
|
||
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
|
||
const [error, setError] = useState('');
|
||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||
const [tenantsLoading, setTenantsLoading] = useState(false);
|
||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||
const [confirmAction, setConfirmAction] = useState<
|
||
| { action: 'toggle'; id: string; name: string; enabled: boolean }
|
||
| { action: 'delete'; id: string; name: string }
|
||
| null
|
||
>(null);
|
||
|
||
async function loadSmsApps() {
|
||
try {
|
||
const applications = await adminApi.listEnterpriseApplications({ keyword: enterpriseKeyword });
|
||
setSmsApps(applications.map(mapApplication));
|
||
setError('');
|
||
} catch (err) {
|
||
setSmsApps([]);
|
||
setError(err instanceof Error ? err.message : '企业应用加载失败');
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void loadSmsApps();
|
||
}, [enterpriseKeyword]);
|
||
|
||
async function openAddModal() {
|
||
setAddModalOpen(true);
|
||
if (tenants.length === 0) {
|
||
setTenantsLoading(true);
|
||
try {
|
||
setTenants((await adminApi.listTenants()).filter((tenant) => tenant.status !== 'deleted'));
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '企业列表加载失败');
|
||
} finally {
|
||
setTenantsLoading(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
function confirmAddApplication() {
|
||
if (selectedTenantId) {
|
||
navigate(`/admin/customers/${selectedTenantId}/sms-apps/new`);
|
||
}
|
||
}
|
||
|
||
async function confirmToggle(id: string) {
|
||
const app = smsApps.find((item) => item.id === id);
|
||
if (app) {
|
||
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
|
||
await loadSmsApps();
|
||
}
|
||
}
|
||
|
||
async function confirmDelete(id: string) {
|
||
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
|
||
await loadSmsApps();
|
||
}
|
||
|
||
async function runConfirmedAction() {
|
||
if (!confirmAction) {
|
||
return;
|
||
}
|
||
if (confirmAction.action === 'toggle') {
|
||
await confirmToggle(confirmAction.id);
|
||
} else {
|
||
await confirmDelete(confirmAction.id);
|
||
}
|
||
setConfirmAction(null);
|
||
}
|
||
|
||
async function deleteConnection(appId: string, connectionId: string) {
|
||
await adminApi.disconnectApplicationConnection(appId, connectionId, '运营端断开企业应用 CMPP 连接');
|
||
const data = await adminApi.listApplicationConnections(appId);
|
||
const nextApp = mapApplication({ ...data.application, cmppConnections: data.connections, cmppStatus: data.summary.status as EnterpriseApplication['cmppStatus'] });
|
||
setConnectionApp(nextApp);
|
||
await loadSmsApps();
|
||
}
|
||
|
||
async function openParams(app: SmsApp) {
|
||
setParamsApp(app);
|
||
setParamsDetail(await adminApi.getApplicationCmppParams(app.id));
|
||
}
|
||
|
||
const filteredSmsApps = useMemo(
|
||
() => smsApps.filter((item) => !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword)),
|
||
[enterpriseKeyword, smsApps],
|
||
);
|
||
|
||
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
|
||
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
|
||
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
|
||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
|
||
{
|
||
key: 'cmppStatus',
|
||
title: 'CMPP状态',
|
||
width: '230px',
|
||
render: (record) => (
|
||
<div className="cmpp-status-cell">
|
||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||
</Tag>
|
||
<button onClick={() => setConnectionApp(record)} type="button">
|
||
{record.cmppConnections.filter((item) => item.state === 'open').length}
|
||
</button>
|
||
<button className="cmpp-status-cell__params" onClick={() => { void openParams(record); }} type="button">
|
||
<Settings2 size={13} />
|
||
参数
|
||
</button>
|
||
</div>
|
||
),
|
||
},
|
||
{ key: 'enabled', title: '状态', width: '130px', render: (record) => enabledTag(record.enabled) },
|
||
{
|
||
key: 'actions',
|
||
title: '操作',
|
||
align: 'right',
|
||
width: '190px',
|
||
render: (record) => (
|
||
<div className="table-actions">
|
||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
|
||
{record.enabled ? '停用' : '启用'}
|
||
</Button>
|
||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||
</div>
|
||
),
|
||
},
|
||
], [navigate]);
|
||
|
||
return (
|
||
<section className="page-stack admin-customer-split-page">
|
||
<div className="page-heading">
|
||
<div>
|
||
<Breadcrumb items={['客户管理', '企业应用管理']} />
|
||
<h1>企业应用管理</h1>
|
||
</div>
|
||
<Button icon={<Plus size={16} />} onClick={() => { void openAddModal(); }}>添加应用</Button>
|
||
</div>
|
||
|
||
<div className="surface admin-split-filter admin-application-filter">
|
||
<Input
|
||
label="企业名称"
|
||
onChange={(event) => setEnterpriseKeyword(event.target.value)}
|
||
placeholder="请输入企业名称"
|
||
prefix={<Search size={16} />}
|
||
value={enterpriseKeyword}
|
||
/>
|
||
<Button onClick={() => setEnterpriseKeyword('')} variant="ghost">重置</Button>
|
||
</div>
|
||
|
||
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||
|
||
<div className="surface section-stack">
|
||
<Tabs
|
||
items={[
|
||
{ label: '短信应用', value: 'sms', content: <Table columns={smsColumns} data={filteredSmsApps} rowKey="id" /> },
|
||
{ label: '彩信应用', value: 'mms', pending: true, content: <div className="ui-table__empty">彩信应用待后端能力确认,本页不展示演示数据。</div> },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{confirmAction ? (
|
||
<ConfirmModal
|
||
danger={confirmAction.action === 'delete'}
|
||
message={confirmAction.action === 'delete'
|
||
? `确认删除应用“${confirmAction.name}”吗?`
|
||
: `确认${confirmAction.enabled ? '停用' : '启用'}应用“${confirmAction.name}”吗?`}
|
||
onCancel={() => setConfirmAction(null)}
|
||
onConfirm={() => { void runConfirmedAction(); }}
|
||
/>
|
||
) : null}
|
||
{addModalOpen ? (
|
||
<AddApplicationModal
|
||
loading={tenantsLoading}
|
||
onCancel={() => setAddModalOpen(false)}
|
||
onChange={setSelectedTenantId}
|
||
onConfirm={confirmAddApplication}
|
||
selectedTenantId={selectedTenantId}
|
||
tenants={tenants}
|
||
/>
|
||
) : null}
|
||
{connectionApp ? (
|
||
<CmppConnectionModal
|
||
app={connectionApp}
|
||
onClose={() => setConnectionApp(null)}
|
||
onDeleteConnection={(connectionId) => { void deleteConnection(connectionApp.id, connectionId); }}
|
||
/>
|
||
) : null}
|
||
{paramsApp ? <CmppParamsModal app={paramsApp} params={paramsDetail} onClose={() => { setParamsApp(null); setParamsDetail(null); }} /> : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function mapApplication(application: EnterpriseApplication): SmsApp {
|
||
const connections = (application.cmppConnections ?? []).map(mapConnection);
|
||
return {
|
||
id: application.id,
|
||
tenantId: application.tenantId,
|
||
name: application.name,
|
||
enterprise: application.tenant?.name ?? application.tenantId,
|
||
appId: application.id,
|
||
enabled: application.status === 'active',
|
||
sentToday: application.sentToday ?? 0,
|
||
deliveryRate: application.deliveryRate ?? 0,
|
||
unitPrice: (application.customerUnitPrice ?? 0) / 100,
|
||
cmppStatus: application.interfaceEnabled === false ? 'inactive' : application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
|
||
cmppParams: { host: '', port: 0, interfaceEnabled: application.interfaceEnabled !== false, interfaceType: application.interfaceType ?? 'cmpp20', enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP2.0' },
|
||
cmppConnections: connections,
|
||
};
|
||
}
|
||
|
||
function mapConnection(connection: CmppConnectionState): CmppConnection {
|
||
const isOpen = connection.status === 'connected' && connection.currentConnections > 0;
|
||
return {
|
||
id: connection.connectionId,
|
||
state: isOpen ? 'open' : connection.status === 'reconnecting' ? 'reconnecting' : 'closed',
|
||
bindType: 'transceiver',
|
||
clientIp: String(connection.channel?.gatewayHost ?? ''),
|
||
sourceAddr: String(connection.channel?.enterpriseCode ?? ''),
|
||
establishedAt: formatDateTime(connection.lastConnectedAt),
|
||
lastHeartbeatAt: formatDateTime(connection.lastHeartbeatAt),
|
||
lastSubmitAt: formatDateTime(connection.updatedAt),
|
||
pendingWindow: connection.currentConnections,
|
||
};
|
||
}
|