@serve.zone/dcrouter 18.3.0 → 18.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deno.json +1 -1
- package/dist_serve/bundle.js +1827 -1726
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.dcrouter.js +4 -1
- package/dist_ts/db/documents/classes.cached.email.d.ts +44 -0
- package/dist_ts/db/documents/classes.cached.email.js +24 -4
- package/dist_ts/email/classes.accepted-email-spool.js +16 -13
- package/dist_ts/email/classes.mail-egress-coordinator.js +10 -1
- package/dist_ts/email/helpers.inbound-security.js +7 -8
- package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +3 -0
- package/dist_ts/opsserver/handlers/email-ops.handler.js +89 -22
- package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +4 -0
- package/dist_ts/remoteingress/classes.tunnel-manager.js +10 -1
- package/dist_ts_interfaces/requests/email-ops.d.ts +44 -0
- package/dist_ts_migrations/index.js +111 -11
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/email-ops.d.ts +15 -0
- package/dist_ts_web/appstate/email-ops.js +24 -1
- package/dist_ts_web/elements/email/ops-view-emails.d.ts +7 -0
- package/dist_ts_web/elements/email/ops-view-emails.js +63 -13
- package/package.json +5 -5
- package/readme.md +19 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.dcrouter.ts +7 -0
- package/ts/db/documents/classes.cached.email.ts +60 -2
- package/ts/email/classes.accepted-email-spool.ts +20 -14
- package/ts/email/classes.mail-egress-coordinator.ts +8 -0
- package/ts/email/helpers.inbound-security.ts +6 -7
- package/ts/opsserver/handlers/email-ops.handler.ts +105 -18
- package/ts/remoteingress/classes.tunnel-manager.ts +14 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/email-ops.ts +30 -0
- package/ts_web/elements/email/ops-view-emails.ts +67 -11
|
@@ -33,6 +33,8 @@ export class EmailOpsHandler {
|
|
|
33
33
|
dataArg.direction,
|
|
34
34
|
dataArg.limit,
|
|
35
35
|
dataArg.search,
|
|
36
|
+
dataArg.from,
|
|
37
|
+
dataArg.to,
|
|
36
38
|
);
|
|
37
39
|
}
|
|
38
40
|
)
|
|
@@ -116,11 +118,18 @@ export class EmailOpsHandler {
|
|
|
116
118
|
direction?: interfaces.requests.TEmailDirection,
|
|
117
119
|
limitArg?: number,
|
|
118
120
|
searchArg?: string,
|
|
121
|
+
fromArg?: number,
|
|
122
|
+
toArg?: number,
|
|
119
123
|
): Promise<interfaces.requests.IEmail[]> {
|
|
120
124
|
const limit = Math.min(Math.max(Math.floor(limitArg ?? 200), 1), 1000);
|
|
121
125
|
const search = normalizeEmailLogSearch(searchArg);
|
|
126
|
+
const range = this.normalizeEmailLogRange(fromArg, toArg);
|
|
122
127
|
const cachedDocs = this.isCachedEmailStoreAvailable()
|
|
123
|
-
? await CachedEmail.findRecent(limit, {
|
|
128
|
+
? await CachedEmail.findRecent(limit, {
|
|
129
|
+
direction,
|
|
130
|
+
search: search || undefined,
|
|
131
|
+
...(range || {}),
|
|
132
|
+
})
|
|
124
133
|
: [];
|
|
125
134
|
const emails = cachedDocs.map((doc) => this.mapCachedEmailToEmail(doc));
|
|
126
135
|
const representedIds = new Set(cachedDocs.map((doc) => doc.id));
|
|
@@ -136,7 +145,11 @@ export class EmailOpsHandler {
|
|
|
136
145
|
continue;
|
|
137
146
|
}
|
|
138
147
|
const email = this.mapQueueItemToEmail(item);
|
|
139
|
-
|
|
148
|
+
const emailTimestamp = new Date(email.timestamp).getTime();
|
|
149
|
+
if (
|
|
150
|
+
this.matchesEmailSearch(email, search)
|
|
151
|
+
&& (!range || (emailTimestamp >= range.from && emailTimestamp <= range.to))
|
|
152
|
+
) {
|
|
140
153
|
emails.push(email);
|
|
141
154
|
}
|
|
142
155
|
}
|
|
@@ -152,17 +165,23 @@ export class EmailOpsHandler {
|
|
|
152
165
|
direction?: interfaces.requests.TEmailDirection,
|
|
153
166
|
limitArg?: number,
|
|
154
167
|
searchArg?: string,
|
|
168
|
+
fromArg?: number,
|
|
169
|
+
toArg?: number,
|
|
155
170
|
): Promise<interfaces.requests.IReq_GetAllEmails['response']> {
|
|
156
171
|
const search = normalizeEmailLogSearch(searchArg);
|
|
157
172
|
const now = Date.now();
|
|
173
|
+
const storeAvailable = this.isCachedEmailStoreAvailable();
|
|
158
174
|
const filter = { direction, search: search || undefined };
|
|
159
|
-
const emails = await this.getAllEmails(direction, limitArg, search);
|
|
175
|
+
const emails = await this.getAllEmails(direction, limitArg, search, fromArg, toArg);
|
|
176
|
+
const trafficContextEmails = !storeAvailable && (fromArg !== undefined || toArg !== undefined)
|
|
177
|
+
? await this.getAllEmails(direction, 1000, search)
|
|
178
|
+
: emails;
|
|
160
179
|
|
|
161
180
|
let oldestMatchAt: number | null = null;
|
|
162
181
|
if (search) {
|
|
163
|
-
oldestMatchAt =
|
|
182
|
+
oldestMatchAt = storeAvailable
|
|
164
183
|
? await CachedEmail.findOldestAcceptedAt(filter, now - EMAIL_LOG_RETENTION_MS)
|
|
165
|
-
:
|
|
184
|
+
: trafficContextEmails.reduce<number | null>((oldest, email) => {
|
|
166
185
|
const timestamp = new Date(email.timestamp).getTime();
|
|
167
186
|
if (!Number.isFinite(timestamp)) return oldest;
|
|
168
187
|
return oldest === null ? timestamp : Math.min(oldest, timestamp);
|
|
@@ -171,14 +190,14 @@ export class EmailOpsHandler {
|
|
|
171
190
|
|
|
172
191
|
const descriptor = selectEmailLogTrafficWindow(search, oldestMatchAt, now);
|
|
173
192
|
const emptyTraffic = buildEmailLogTraffic(descriptor, [], now);
|
|
174
|
-
const sparseTraffic =
|
|
193
|
+
const sparseTraffic = storeAvailable
|
|
175
194
|
? await CachedEmail.aggregateEmailLogTraffic(
|
|
176
195
|
filter,
|
|
177
196
|
emptyTraffic.windowStart,
|
|
178
197
|
emptyTraffic.windowEnd,
|
|
179
198
|
descriptor.bucketSizeMs,
|
|
180
199
|
)
|
|
181
|
-
: aggregateEmailLogTrafficInMemory(
|
|
200
|
+
: aggregateEmailLogTrafficInMemory(trafficContextEmails, descriptor, now);
|
|
182
201
|
|
|
183
202
|
return {
|
|
184
203
|
emails,
|
|
@@ -186,6 +205,26 @@ export class EmailOpsHandler {
|
|
|
186
205
|
};
|
|
187
206
|
}
|
|
188
207
|
|
|
208
|
+
private normalizeEmailLogRange(
|
|
209
|
+
fromArg?: number,
|
|
210
|
+
toArg?: number,
|
|
211
|
+
): { from: number; to: number } | undefined {
|
|
212
|
+
if (fromArg === undefined && toArg === undefined) return undefined;
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
if (
|
|
215
|
+
!Number.isSafeInteger(fromArg)
|
|
216
|
+
|| !Number.isSafeInteger(toArg)
|
|
217
|
+
|| (fromArg as number) < now - EMAIL_LOG_RETENTION_MS
|
|
218
|
+
|| (toArg as number) > now + 60_000
|
|
219
|
+
|| (toArg as number) < (fromArg as number)
|
|
220
|
+
) {
|
|
221
|
+
throw new plugins.typedrequest.TypedResponseError(
|
|
222
|
+
'Email log time range is invalid or outside retention.',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return { from: fromArg as number, to: toArg as number };
|
|
226
|
+
}
|
|
227
|
+
|
|
189
228
|
private async getEmailSecurityFindings(
|
|
190
229
|
limitArg?: number,
|
|
191
230
|
): Promise<interfaces.requests.IEmailSecurityFinding[]> {
|
|
@@ -261,10 +300,15 @@ export class EmailOpsHandler {
|
|
|
261
300
|
* Map a CachedEmail row to catalog IEmail format
|
|
262
301
|
*/
|
|
263
302
|
private mapCachedEmailToEmail(doc: CachedEmail): interfaces.requests.IEmail {
|
|
303
|
+
const mappedStatus = this.mapStatus(doc.status);
|
|
264
304
|
return {
|
|
265
305
|
id: doc.id,
|
|
266
306
|
direction: doc.direction,
|
|
267
|
-
status:
|
|
307
|
+
status: doc.direction === 'inbound'
|
|
308
|
+
&& mappedStatus === 'accepted'
|
|
309
|
+
&& doc.inboundSecurityDisposition === 'flagged'
|
|
310
|
+
? 'flagged'
|
|
311
|
+
: mappedStatus,
|
|
268
312
|
from: doc.from || '',
|
|
269
313
|
to: doc.to?.[0] || '',
|
|
270
314
|
subject: doc.subject || '',
|
|
@@ -295,9 +339,13 @@ export class EmailOpsHandler {
|
|
|
295
339
|
// Display SmartMTA's true retry schedule — doc.nextAttempt is the spool's
|
|
296
340
|
// recovery lease (retry time + 30min grace), not the user-facing countdown.
|
|
297
341
|
const nextAttemptMs = doc.smartMtaNextAttempt ? new Date(doc.smartMtaNextAttempt).getTime() : NaN;
|
|
342
|
+
const outboundAuthentication = doc.direction === 'outbound'
|
|
343
|
+
? this.getLatestOutboundAuthentication(doc.smtpTransactions || [])
|
|
344
|
+
: undefined;
|
|
298
345
|
return {
|
|
299
346
|
...base,
|
|
300
347
|
smtpTransactions: structuredClone(doc.smtpTransactions || []),
|
|
348
|
+
...(outboundAuthentication ? { outboundAuthentication } : {}),
|
|
301
349
|
...(base.status === 'deferred' && Number.isFinite(nextAttemptMs) && nextAttemptMs > 0
|
|
302
350
|
? {
|
|
303
351
|
nextAttemptAt: nextAttemptMs,
|
|
@@ -318,7 +366,9 @@ export class EmailOpsHandler {
|
|
|
318
366
|
authMethod: '',
|
|
319
367
|
authUser: this.extractSessionUser(session),
|
|
320
368
|
},
|
|
321
|
-
authenticationResults:
|
|
369
|
+
authenticationResults: outboundAuthentication
|
|
370
|
+
? this.mapOutboundAuthenticationResults(outboundAuthentication)
|
|
371
|
+
: this.mapInboundSecurityResults(doc.inboundSecurityResults),
|
|
322
372
|
rejectionReason: doc.status === 'failed' ? doc.lastError : undefined,
|
|
323
373
|
bounceMessage: doc.status === 'failed' ? doc.lastError : undefined,
|
|
324
374
|
headers,
|
|
@@ -449,6 +499,7 @@ export class EmailOpsHandler {
|
|
|
449
499
|
const base = this.mapQueueItemToEmail(item);
|
|
450
500
|
const processingResult = item.processingResult;
|
|
451
501
|
|
|
502
|
+
const outboundAuthentication = this.getLatestOutboundAuthentication(item.smtpTransactions || []);
|
|
452
503
|
let toList: string[] = [];
|
|
453
504
|
let cc: string[] = [];
|
|
454
505
|
let headers: Record<string, string> = {};
|
|
@@ -471,6 +522,7 @@ export class EmailOpsHandler {
|
|
|
471
522
|
...base,
|
|
472
523
|
smtpTransactions: structuredClone(item.smtpTransactions || []),
|
|
473
524
|
toList,
|
|
525
|
+
...(outboundAuthentication ? { outboundAuthentication } : {}),
|
|
474
526
|
cc,
|
|
475
527
|
smtpLog: this.flattenSmtpTransactions(item.smtpTransactions || []),
|
|
476
528
|
connectionInfo: {
|
|
@@ -484,14 +536,7 @@ export class EmailOpsHandler {
|
|
|
484
536
|
authMethod: '',
|
|
485
537
|
authUser: '',
|
|
486
538
|
},
|
|
487
|
-
authenticationResults:
|
|
488
|
-
spf: 'none',
|
|
489
|
-
spfDomain: '',
|
|
490
|
-
dkim: 'none',
|
|
491
|
-
dkimDomain: '',
|
|
492
|
-
dmarc: 'none',
|
|
493
|
-
dmarcPolicy: '',
|
|
494
|
-
},
|
|
539
|
+
authenticationResults: this.mapOutboundAuthenticationResults(outboundAuthentication),
|
|
495
540
|
rejectionReason: item.status === 'failed' ? item.lastError : undefined,
|
|
496
541
|
bounceMessage: item.status === 'failed' ? item.lastError : undefined,
|
|
497
542
|
headers,
|
|
@@ -499,6 +544,47 @@ export class EmailOpsHandler {
|
|
|
499
544
|
};
|
|
500
545
|
}
|
|
501
546
|
|
|
547
|
+
private getLatestOutboundAuthentication(
|
|
548
|
+
transactionsArg: Array<{ outboundAuthentication?: interfaces.requests.IOutboundAuthenticationCheck }>,
|
|
549
|
+
): interfaces.requests.IOutboundAuthenticationCheck | undefined {
|
|
550
|
+
for (let index = transactionsArg.length - 1; index >= 0; index--) {
|
|
551
|
+
const authentication = transactionsArg[index]?.outboundAuthentication;
|
|
552
|
+
if (!authentication) continue;
|
|
553
|
+
return structuredClone(authentication);
|
|
554
|
+
}
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private mapOutboundAuthenticationResults(
|
|
559
|
+
authenticationArg?: interfaces.requests.IOutboundAuthenticationCheck,
|
|
560
|
+
): interfaces.requests.IEmailDetail['authenticationResults'] {
|
|
561
|
+
const noneResult: interfaces.requests.IEmailDetail['authenticationResults'] = {
|
|
562
|
+
spf: 'none',
|
|
563
|
+
spfDomain: '',
|
|
564
|
+
dkim: 'none',
|
|
565
|
+
dkimDomain: '',
|
|
566
|
+
dmarc: 'none',
|
|
567
|
+
dmarcPolicy: '',
|
|
568
|
+
};
|
|
569
|
+
if (authenticationArg?.state !== 'completed') return noneResult;
|
|
570
|
+
const dkimSignatures = (authenticationArg.dkim || [])
|
|
571
|
+
.filter((signature) => signature.status.toLowerCase() !== 'none');
|
|
572
|
+
const validDkim = dkimSignatures.find((signature) => signature.is_valid);
|
|
573
|
+
const spfValue = (authenticationArg.spf?.result || '').toLowerCase();
|
|
574
|
+
const spf = (['pass', 'fail', 'softfail', 'neutral'] as string[]).includes(spfValue)
|
|
575
|
+
? spfValue as interfaces.requests.IAuthenticationResults['spf']
|
|
576
|
+
: 'none';
|
|
577
|
+
return {
|
|
578
|
+
spf,
|
|
579
|
+
spfDomain: authenticationArg.spf?.domain || '',
|
|
580
|
+
dkim: validDkim ? 'pass' : dkimSignatures.length > 0 ? 'fail' : 'none',
|
|
581
|
+
dkimDomain: validDkim?.domain || dkimSignatures[0]?.domain || '',
|
|
582
|
+
dmarc: authenticationArg.dmarc ? (authenticationArg.dmarc.passed ? 'pass' : 'fail') : 'none',
|
|
583
|
+
dmarcPolicy: authenticationArg.dmarc?.policy || '',
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
|
|
502
588
|
/**
|
|
503
589
|
* Map queue status to catalog TEmailStatus
|
|
504
590
|
*/
|
|
@@ -525,7 +611,8 @@ export class EmailOpsHandler {
|
|
|
525
611
|
};
|
|
526
612
|
const spfResult = (parsed.spf?.result || '').toLowerCase();
|
|
527
613
|
const spf = (['pass', 'fail', 'softfail', 'neutral'] as const).find((value) => value === spfResult) || 'none';
|
|
528
|
-
const dkimSignatures = Array.isArray(parsed.dkim) ? parsed.dkim : []
|
|
614
|
+
const dkimSignatures = (Array.isArray(parsed.dkim) ? parsed.dkim : [])
|
|
615
|
+
.filter((signature) => String(signature?.status || '').toLowerCase() !== 'none');
|
|
529
616
|
const validSignature = dkimSignatures.find((signature) => signature?.is_valid);
|
|
530
617
|
const dkim = validSignature ? 'pass' : dkimSignatures.length > 0 ? 'fail' : 'none';
|
|
531
618
|
const dmarc = parsed.dmarc ? (parsed.dmarc.passed ? 'pass' : 'fail') : 'none';
|
|
@@ -370,6 +370,20 @@ export class TunnelManager {
|
|
|
370
370
|
return { ready: false, eligibleEdgeCount: 0, reason };
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Delegate source-bound connection evidence to the owned RemoteIngress hub.
|
|
375
|
+
*/
|
|
376
|
+
public async waitForEgressConnectionEvidence(
|
|
377
|
+
proxyId: string,
|
|
378
|
+
edgeId: string,
|
|
379
|
+
timeoutMs?: number,
|
|
380
|
+
): Promise<plugins.remoteingress.IRemoteIngressHubEgressConnectionEvent | null> {
|
|
381
|
+
if (this.stopped) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
return await this.hub.waitForEgressConnectionEvidence(proxyId, edgeId, timeoutMs);
|
|
385
|
+
}
|
|
386
|
+
|
|
373
387
|
public async stopEgressTcpProxy(proxyId: string): Promise<boolean> {
|
|
374
388
|
if (this.stopped) {
|
|
375
389
|
return false;
|
|
@@ -8,6 +8,7 @@ export interface IEmailOpsState {
|
|
|
8
8
|
emails: interfaces.requests.IEmail[];
|
|
9
9
|
traffic: interfaces.requests.IEmailLogTraffic | null;
|
|
10
10
|
searchQuery: string;
|
|
11
|
+
selectedRange: { from: number; to: number } | null;
|
|
11
12
|
securityFindings: interfaces.requests.IEmailSecurityFinding[];
|
|
12
13
|
securityFindingsLoading: boolean;
|
|
13
14
|
securityFindingsError: string | null;
|
|
@@ -23,6 +24,7 @@ export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
|
|
23
24
|
emails: [],
|
|
24
25
|
traffic: null,
|
|
25
26
|
searchQuery: '',
|
|
27
|
+
selectedRange: null,
|
|
26
28
|
securityFindings: [],
|
|
27
29
|
securityFindingsLoading: false,
|
|
28
30
|
securityFindingsError: null,
|
|
@@ -38,11 +40,13 @@ export const emailOpsStatePart = await appState.getStatePart<IEmailOpsState>(
|
|
|
38
40
|
// ============================================================================
|
|
39
41
|
|
|
40
42
|
let activeSearchQuery = '';
|
|
43
|
+
let activeSelectedRange: { from: number; to: number } | null = null;
|
|
41
44
|
let emailListRequestGeneration = 0;
|
|
42
45
|
let securityFindingsRequestGeneration = 0;
|
|
43
46
|
|
|
44
47
|
export interface IEmailOpsFetchOptions {
|
|
45
48
|
searchQuery?: string;
|
|
49
|
+
selectedRange?: { from: number; to: number } | null;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export function setActiveEmailSearchQuery(searchQueryArg: string): string {
|
|
@@ -58,6 +62,27 @@ export function setActiveEmailSearchQuery(searchQueryArg: string): string {
|
|
|
58
62
|
return activeSearchQuery;
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
export function setActiveEmailSelectedRange(
|
|
66
|
+
rangeArg: { from: number; to: number } | null,
|
|
67
|
+
): { from: number; to: number } | null {
|
|
68
|
+
if (rangeArg && (
|
|
69
|
+
!Number.isSafeInteger(rangeArg.from)
|
|
70
|
+
|| !Number.isSafeInteger(rangeArg.to)
|
|
71
|
+
|| rangeArg.from < 0
|
|
72
|
+
|| rangeArg.to <= rangeArg.from
|
|
73
|
+
)) {
|
|
74
|
+
throw new Error('Invalid email time range');
|
|
75
|
+
}
|
|
76
|
+
activeSelectedRange = rangeArg ? { ...rangeArg } : null;
|
|
77
|
+
emailListRequestGeneration++;
|
|
78
|
+
const currentState = emailOpsStatePart.getState()!;
|
|
79
|
+
emailOpsStatePart.setState({
|
|
80
|
+
...currentState,
|
|
81
|
+
selectedRange: activeSelectedRange,
|
|
82
|
+
});
|
|
83
|
+
return activeSelectedRange;
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
// Fetch All Emails Action
|
|
62
87
|
export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetchOptions | null>(async (
|
|
63
88
|
statePartArg,
|
|
@@ -69,6 +94,9 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetc
|
|
|
69
94
|
const searchQuery = dataArg?.searchQuery !== undefined
|
|
70
95
|
? setActiveEmailSearchQuery(dataArg.searchQuery)
|
|
71
96
|
: activeSearchQuery;
|
|
97
|
+
const selectedRange = dataArg?.selectedRange !== undefined
|
|
98
|
+
? setActiveEmailSelectedRange(dataArg.selectedRange)
|
|
99
|
+
: activeSelectedRange;
|
|
72
100
|
const requestGeneration = ++emailListRequestGeneration;
|
|
73
101
|
const ticket = captureRealtimeTicket(['emailQueue', 'smtpAttempts']);
|
|
74
102
|
|
|
@@ -80,6 +108,7 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetc
|
|
|
80
108
|
const response = await request.fire({
|
|
81
109
|
identity: context.identity,
|
|
82
110
|
...(searchQuery ? { search: searchQuery } : {}),
|
|
111
|
+
...(selectedRange ? selectedRange : {}),
|
|
83
112
|
});
|
|
84
113
|
if (
|
|
85
114
|
requestGeneration !== emailListRequestGeneration
|
|
@@ -92,6 +121,7 @@ export const fetchAllEmailsAction = emailOpsStatePart.createAction<IEmailOpsFetc
|
|
|
92
121
|
traffic: response.traffic,
|
|
93
122
|
searchQuery,
|
|
94
123
|
isLoading: false,
|
|
124
|
+
selectedRange,
|
|
95
125
|
error: null,
|
|
96
126
|
lastUpdated: Date.now(),
|
|
97
127
|
};
|
|
@@ -29,6 +29,9 @@ export class OpsViewEmails extends DeesElement {
|
|
|
29
29
|
@state()
|
|
30
30
|
accessor searchQuery = '';
|
|
31
31
|
|
|
32
|
+
@state()
|
|
33
|
+
accessor selectedRange: { from: number; to: number } | null = null;
|
|
34
|
+
|
|
32
35
|
private stateSubscription: any;
|
|
33
36
|
private lastEmailOpsUpdate = 0;
|
|
34
37
|
private detailRequestGeneration = 0;
|
|
@@ -40,6 +43,7 @@ export class OpsViewEmails extends DeesElement {
|
|
|
40
43
|
this.emails = state.emails;
|
|
41
44
|
this.traffic = state.traffic;
|
|
42
45
|
this.searchQuery = state.searchQuery;
|
|
46
|
+
this.selectedRange = state.selectedRange;
|
|
43
47
|
const hasNewSnapshot = state.lastUpdated > this.lastEmailOpsUpdate;
|
|
44
48
|
this.lastEmailOpsUpdate = Math.max(this.lastEmailOpsUpdate, state.lastUpdated);
|
|
45
49
|
if (hasNewSnapshot && this.selectedEmail) {
|
|
@@ -75,7 +79,23 @@ export class OpsViewEmails extends DeesElement {
|
|
|
75
79
|
height: 100%;
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
.chartStack {
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-direction: column;
|
|
85
|
+
gap: 8px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.rangeSummary {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: space-between;
|
|
92
|
+
min-height: 32px;
|
|
93
|
+
padding: 0 4px;
|
|
94
|
+
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
|
95
|
+
font-size: 13px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.chartStack dees-chart-area {
|
|
79
99
|
min-height: 360px;
|
|
80
100
|
}
|
|
81
101
|
`,
|
|
@@ -101,16 +121,26 @@ export class OpsViewEmails extends DeesElement {
|
|
|
101
121
|
@email-click=${this.handleEmailClick}
|
|
102
122
|
>
|
|
103
123
|
${this.traffic ? html`
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
<div slot="above-table" class="chartStack">
|
|
125
|
+
${this.selectedRange ? html`
|
|
126
|
+
<div class="rangeSummary">
|
|
127
|
+
<span>${this.formatSelectedRange()}</span>
|
|
128
|
+
<dees-button @click=${this.clearSelectedRange}>Clear time range</dees-button>
|
|
129
|
+
</div>
|
|
130
|
+
` : html``}
|
|
131
|
+
<dees-chart-area
|
|
132
|
+
.label=${this.traffic.label}
|
|
133
|
+
.series=${this.getEmailTrafficSeries()}
|
|
134
|
+
.realtimeMode=${true}
|
|
135
|
+
.rollingWindow=${this.traffic.durationMs}
|
|
136
|
+
.legendStats=${['latest']}
|
|
137
|
+
.chartLines=${[]}
|
|
138
|
+
.rangeSelectionEnabled=${true}
|
|
139
|
+
.selectedRange=${this.selectedRange}
|
|
140
|
+
.yAxisFormatter=${(valueArg: number) => `${valueArg}`}
|
|
141
|
+
@range-change=${this.handleRangeChange}
|
|
142
|
+
></dees-chart-area>
|
|
143
|
+
</div>
|
|
114
144
|
` : html``}
|
|
115
145
|
</sz-mta-list-view>
|
|
116
146
|
`
|
|
@@ -155,6 +185,32 @@ export class OpsViewEmails extends DeesElement {
|
|
|
155
185
|
}, 300);
|
|
156
186
|
}
|
|
157
187
|
|
|
188
|
+
|
|
189
|
+
private handleRangeChange = (
|
|
190
|
+
eventArg: CustomEvent<{ from: number; to: number }>,
|
|
191
|
+
): void => {
|
|
192
|
+
void appstate.emailOpsStatePart.dispatchAction(
|
|
193
|
+
appstate.fetchAllEmailsAction,
|
|
194
|
+
{ selectedRange: eventArg.detail },
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
private clearSelectedRange = (): void => {
|
|
199
|
+
void appstate.emailOpsStatePart.dispatchAction(
|
|
200
|
+
appstate.fetchAllEmailsAction,
|
|
201
|
+
{ selectedRange: null },
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
private formatSelectedRange(): string {
|
|
206
|
+
if (!this.selectedRange) return '';
|
|
207
|
+
const formatter = new Intl.DateTimeFormat(undefined, {
|
|
208
|
+
dateStyle: 'medium',
|
|
209
|
+
timeStyle: 'short',
|
|
210
|
+
});
|
|
211
|
+
return `Showing emails from ${formatter.format(this.selectedRange.from)} to ${formatter.format(this.selectedRange.to)}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
158
214
|
private getEmailTrafficSeries(): Array<{
|
|
159
215
|
name: string;
|
|
160
216
|
color: string;
|