Initial commit
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Zap, Loader2, ChevronRight, CircleCheck, CircleAlert } from 'lucide-react';
|
||||
import i18n from '@/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { resolveObject, resolveSchema, resolveVariantForm } from '@/lib/schemaResolver';
|
||||
import { jmapSet, getAccountId } from '@/services/jmap/client';
|
||||
import { FieldWidget } from '@/components/forms/FieldWidget';
|
||||
import { DynamicView } from '@/components/views/DynamicView';
|
||||
import type { Schema, ObjectVariant } from '@/types/schema';
|
||||
import type { JmapSetResponse } from '@/types/jmap';
|
||||
|
||||
type ActionState =
|
||||
| { kind: 'pick' }
|
||||
| { kind: 'form'; variant: ObjectVariant; formData: Record<string, unknown> }
|
||||
| { kind: 'submitting'; variant: ObjectVariant }
|
||||
| {
|
||||
kind: 'result';
|
||||
variant: ObjectVariant;
|
||||
props: Record<string, unknown> | null;
|
||||
inputData: Record<string, unknown>;
|
||||
}
|
||||
| { kind: 'error'; variant: ObjectVariant; error: string };
|
||||
|
||||
interface ActionPageProps {
|
||||
viewName: string;
|
||||
}
|
||||
|
||||
export function ActionPage({ viewName }: ActionPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const [state, setState] = useState<ActionState>({ kind: 'pick' });
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
if (!schema) return null;
|
||||
return resolveObject(schema, viewName);
|
||||
}, [schema, viewName]);
|
||||
|
||||
const sch = useMemo(() => {
|
||||
if (!schema || !resolved) return null;
|
||||
return resolveSchema(schema, resolved.objectName);
|
||||
}, [schema, resolved]);
|
||||
|
||||
const canCreate = resolved ? hasObjectPermission(resolved.permissionPrefix, 'Create') : false;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const variants = sch?.type === 'multiple' ? sch.variants : [];
|
||||
const map = new Map<string, ObjectVariant[]>();
|
||||
const otherLabel = t('actions.otherGroup', 'Other');
|
||||
for (const v of variants) {
|
||||
if (!hasPermission(`action${v.name}`)) continue;
|
||||
const colonIdx = v.label.indexOf(':');
|
||||
const group = colonIdx > 0 ? v.label.slice(0, colonIdx).trim() : otherLabel;
|
||||
let list = map.get(group);
|
||||
if (!list) {
|
||||
list = [];
|
||||
map.set(group, list);
|
||||
}
|
||||
list.push(v);
|
||||
}
|
||||
return map;
|
||||
}, [sch, hasPermission, t]);
|
||||
|
||||
const list = schema?.lists[viewName] ?? schema?.lists[resolved?.objectName ?? ''];
|
||||
const title = list?.title ?? t('actions.title', 'Actions');
|
||||
const subtitle = list?.subtitle ?? resolved?.objectType?.description;
|
||||
|
||||
const handlePickVariant = useCallback(
|
||||
(variant: ObjectVariant) => {
|
||||
if (!schema || !resolved) return;
|
||||
if (!variant.schemaName) {
|
||||
executeAction(schema, resolved.objectName, variant.name, {}, setState);
|
||||
} else {
|
||||
const fieldsDef = schema.fields[variant.schemaName!];
|
||||
const defaults = fieldsDef?.defaults ? { ...fieldsDef.defaults } : {};
|
||||
setState({ kind: 'form', variant, formData: defaults });
|
||||
}
|
||||
},
|
||||
[schema, resolved],
|
||||
);
|
||||
|
||||
const handleSubmitForm = useCallback(
|
||||
async (variant: ObjectVariant, formData: Record<string, unknown>) => {
|
||||
if (!schema || !resolved) return;
|
||||
setState({ kind: 'submitting', variant });
|
||||
|
||||
const fields = variant.schemaName ? schema.fields[variant.schemaName] : null;
|
||||
const payload: Record<string, unknown> = { '@type': variant.name };
|
||||
|
||||
if (fields) {
|
||||
for (const [name, def] of Object.entries(fields.properties)) {
|
||||
if (def.update === 'serverSet') continue;
|
||||
if (name in formData) {
|
||||
const isSecret =
|
||||
def.type.type === 'string' && (def.type.format === 'secret' || def.type.format === 'secretText');
|
||||
if (isSecret && formData[name] === '*****') continue;
|
||||
payload[name] = formData[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const accountId = getAccountId(resolved.objectName);
|
||||
const responses = await jmapSet(resolved.objectName, accountId, {
|
||||
create: { 'action-0': payload },
|
||||
});
|
||||
const setResponse = responses[responses.length - 1];
|
||||
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
||||
|
||||
if (setResult.created && setResult.created['action-0']) {
|
||||
const created = setResult.created['action-0'];
|
||||
const noisyKeys = new Set(['id', 'blobId']);
|
||||
const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k));
|
||||
if (extraKeys.length > 0) {
|
||||
const extra: Record<string, unknown> = {};
|
||||
for (const k of extraKeys) extra[k] = created[k];
|
||||
setState({ kind: 'result', variant, props: extra, inputData: formData });
|
||||
} else {
|
||||
setState({ kind: 'result', variant, props: null, inputData: formData });
|
||||
}
|
||||
} else if (setResult.notCreated && setResult.notCreated['action-0']) {
|
||||
const err = setResult.notCreated['action-0'];
|
||||
setState({
|
||||
kind: 'error',
|
||||
variant,
|
||||
error: (err as { description?: string }).description ?? t('actions.actionFailed', 'Action failed.'),
|
||||
});
|
||||
} else {
|
||||
setState({ kind: 'result', variant, props: null, inputData: formData });
|
||||
}
|
||||
} catch (e) {
|
||||
setState({
|
||||
kind: 'error',
|
||||
variant,
|
||||
error: e instanceof Error ? e.message : t('actions.actionFailed', 'Action failed.'),
|
||||
});
|
||||
}
|
||||
},
|
||||
[schema, resolved, t],
|
||||
);
|
||||
|
||||
if (!schema || !resolved || !sch) return null;
|
||||
|
||||
if (state.kind === 'pick') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{Array.from(groups.entries()).map(([group, groupVariants]) => (
|
||||
<div key={group}>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">{group}</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{groupVariants.map((v) => {
|
||||
const colonIdx = v.label.indexOf(':');
|
||||
const actionName = colonIdx > 0 ? v.label.slice(colonIdx + 1).trim() : v.label;
|
||||
return (
|
||||
<Button
|
||||
key={v.name}
|
||||
variant="outline"
|
||||
className="justify-between h-auto py-3 px-4"
|
||||
disabled={!canCreate}
|
||||
onClick={() => handlePickVariant(v)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span>{actionName}</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'form') {
|
||||
return (
|
||||
<ActionFormView
|
||||
schema={schema}
|
||||
variant={state.variant}
|
||||
formData={state.formData}
|
||||
onSubmit={(data) => handleSubmitForm(state.variant, data)}
|
||||
onBack={() => setState({ kind: 'pick' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'submitting') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
{t('actions.executing', 'Executing {{label}}...', { label: state.variant.label })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'result') {
|
||||
return (
|
||||
<ActionResultView
|
||||
schema={schema}
|
||||
variant={state.variant}
|
||||
props={state.props}
|
||||
inputData={state.inputData}
|
||||
onNewAction={() => setState({ kind: 'pick' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'error') {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4 pt-8">
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CircleAlert className="h-5 w-5 text-destructive shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{t('actions.failedTitle', 'Action failed')}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{state.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
|
||||
{t('actions.backToActions', 'Back to actions')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function executeAction(
|
||||
schema: Schema,
|
||||
objectName: string,
|
||||
variantName: string,
|
||||
formData: Record<string, unknown>,
|
||||
setState: React.Dispatch<React.SetStateAction<ActionState>>,
|
||||
) {
|
||||
const variant = { name: variantName, label: variantName } as ObjectVariant;
|
||||
setState({ kind: 'submitting', variant });
|
||||
|
||||
try {
|
||||
const accountId = getAccountId(objectName);
|
||||
const payload: Record<string, unknown> = { '@type': variantName, ...formData };
|
||||
const responses = await jmapSet(objectName, accountId, {
|
||||
create: { 'action-0': payload },
|
||||
});
|
||||
const setResponse = responses[responses.length - 1];
|
||||
const setResult = setResponse[1] as unknown as JmapSetResponse;
|
||||
|
||||
const sch = resolveSchema(schema, objectName);
|
||||
const realVariant =
|
||||
sch?.type === 'multiple' ? (sch.variants.find((v) => v.name === variantName) ?? variant) : variant;
|
||||
|
||||
if (setResult.created && setResult.created['action-0']) {
|
||||
const created = setResult.created['action-0'];
|
||||
const noisyKeys = new Set(['id', 'blobId']);
|
||||
const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k));
|
||||
if (extraKeys.length > 0) {
|
||||
const extra: Record<string, unknown> = {};
|
||||
for (const k of extraKeys) extra[k] = created[k];
|
||||
setState({ kind: 'result', variant: realVariant, props: extra, inputData: formData });
|
||||
} else {
|
||||
setState({ kind: 'result', variant: realVariant, props: null, inputData: formData });
|
||||
}
|
||||
} else if (setResult.notCreated && setResult.notCreated['action-0']) {
|
||||
const err = setResult.notCreated['action-0'];
|
||||
setState({
|
||||
kind: 'error',
|
||||
variant: realVariant,
|
||||
error: (err as { description?: string }).description ?? i18n.t('actions.actionFailed', 'Action failed.'),
|
||||
});
|
||||
} else {
|
||||
setState({ kind: 'result', variant: realVariant, props: null, inputData: formData });
|
||||
}
|
||||
} catch (e) {
|
||||
setState({
|
||||
kind: 'error',
|
||||
variant,
|
||||
error: e instanceof Error ? e.message : i18n.t('actions.actionFailed', 'Action failed.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function ActionFormView({
|
||||
schema,
|
||||
variant,
|
||||
formData: initialData,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: {
|
||||
schema: Schema;
|
||||
variant: ObjectVariant;
|
||||
formData: Record<string, unknown>;
|
||||
onSubmit: (data: Record<string, unknown>) => void;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
if (!variant.schemaName) return null;
|
||||
const fields = schema.fields[variant.schemaName];
|
||||
const form = resolveVariantForm(schema, '', '', variant.schemaName);
|
||||
if (!fields || !form) return null;
|
||||
|
||||
const mutableSections = form.sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
fields: section.fields.filter((ff) => {
|
||||
const def = fields.properties[ff.name];
|
||||
return def && def.update === 'mutable';
|
||||
}),
|
||||
}))
|
||||
.filter((s) => s.fields.length > 0);
|
||||
|
||||
const handleChange = (fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
setErrors((prev) => {
|
||||
const copy = { ...prev };
|
||||
delete copy[fieldName];
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
for (const section of mutableSections) {
|
||||
for (const ff of section.fields) {
|
||||
const def = fields.properties[ff.name];
|
||||
if (!def) continue;
|
||||
const isRequired =
|
||||
(def.type.type === 'string' ||
|
||||
def.type.type === 'number' ||
|
||||
def.type.type === 'enum' ||
|
||||
def.type.type === 'objectId') &&
|
||||
!('nullable' in def.type && def.type.nullable);
|
||||
if (isRequired) {
|
||||
const val = formData[ff.name];
|
||||
if (val === undefined || val === null || val === '') {
|
||||
newErrors[ff.name] = t('form.required', 'This field is required.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
{t('common.back', 'Back')}
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{variant.label}</h1>
|
||||
</div>
|
||||
|
||||
{mutableSections.map((section, si) => (
|
||||
<Card key={si}>
|
||||
{section.title && (
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className={section.title ? 'pt-0' : ''}>
|
||||
<div className="space-y-4">
|
||||
{section.fields.map((ff) => {
|
||||
const def = fields.properties[ff.name];
|
||||
if (!def) return null;
|
||||
return (
|
||||
<FieldWidget
|
||||
key={ff.name}
|
||||
formField={ff}
|
||||
field={def}
|
||||
value={formData[ff.name]}
|
||||
onChange={(v) => handleChange(ff.name, v)}
|
||||
readOnly={false}
|
||||
schema={schema}
|
||||
error={errors[ff.name]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
{t('actions.execute', 'Execute')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionResultView({
|
||||
schema,
|
||||
variant,
|
||||
props,
|
||||
inputData,
|
||||
onNewAction,
|
||||
}: {
|
||||
schema: Schema;
|
||||
variant: ObjectVariant;
|
||||
props: Record<string, unknown> | null;
|
||||
inputData: Record<string, unknown>;
|
||||
onNewAction: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const mergedData = useMemo(() => ({ ...inputData, ...(props ?? {}) }), [inputData, props]);
|
||||
const hasData = variant.schemaName && Object.keys(mergedData).length > 0;
|
||||
|
||||
const visibleFields = useMemo(() => new Set(Object.keys(mergedData)), [mergedData]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CircleCheck className="h-5 w-5 text-emerald-500 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">{variant.label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('actions.executedSuccess', 'Action executed successfully.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasData && variant.schemaName && (
|
||||
<DynamicView
|
||||
schema={schema}
|
||||
objectName={variant.schemaName}
|
||||
viewName={variant.schemaName}
|
||||
data={mergedData}
|
||||
visibleFields={visibleFields}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hasData && (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
||||
{t('actions.noAdditionalData', 'No additional data returned.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Button onClick={onNewAction}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
{t('actions.executeAnother', 'Execute another action')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
function ChartSizedContainer({
|
||||
height,
|
||||
children,
|
||||
}: {
|
||||
height: number;
|
||||
children: (width: number, height: number) => React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
if (w > 0) setWidth(w);
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
const w = el.getBoundingClientRect().width;
|
||||
if (w > 0) setWidth(w);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ height }}>
|
||||
{width > 0 && children(width, height)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tooltip as UiTooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getChartColor } from '@/components/ui/chart';
|
||||
import { ChartTooltipContent } from '@/components/ui/chart';
|
||||
import type { Chart as ChartSchema } from '../types/schema';
|
||||
import type { Metric, Period } from '../types/metrics';
|
||||
import {
|
||||
bucketize,
|
||||
bucketTimestamps,
|
||||
getBucketCount,
|
||||
seriesBucketValue,
|
||||
formatTimeTick,
|
||||
formatValue,
|
||||
} from '../helpers';
|
||||
|
||||
interface DashboardChartProps {
|
||||
chart: ChartSchema;
|
||||
historySamples: Metric[];
|
||||
historyWindow: { from: Date; to: Date };
|
||||
period: Period;
|
||||
}
|
||||
|
||||
export function DashboardChart({ chart, historySamples, historyWindow, period }: DashboardChartProps) {
|
||||
const { from, to } = historyWindow;
|
||||
const bucketCount = getBucketCount(period);
|
||||
const valueFormat = chart.valueFormat ?? 'number';
|
||||
|
||||
const data = useMemo(() => {
|
||||
const buckets = bucketize(historySamples, from, to, bucketCount);
|
||||
const timestamps = bucketTimestamps(from, to, bucketCount);
|
||||
|
||||
const points = timestamps.map((ts, i) => {
|
||||
const point: Record<string, unknown> = {
|
||||
time: ts.getTime(),
|
||||
timeLabel: formatTimeTick(ts, period),
|
||||
};
|
||||
for (const series of chart.series) {
|
||||
point[series.label] = seriesBucketValue(series, buckets[i]);
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
if (chart.stacked) {
|
||||
const lastSeen: Record<string, number> = {};
|
||||
for (const point of points) {
|
||||
for (const series of chart.series) {
|
||||
const v = point[series.label];
|
||||
if (typeof v === 'number') {
|
||||
lastSeen[series.label] = v;
|
||||
} else {
|
||||
point[series.label] = lastSeen[series.label] ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}, [historySamples, from, to, bucketCount, chart.series, chart.stacked, period]);
|
||||
|
||||
const tickFormatter = (value: number) => formatValue(value, valueFormat);
|
||||
|
||||
const tooltipFormatter = (value: number) => formatValue(value, valueFormat);
|
||||
|
||||
const renderChart = (chartWidth: number, chartHeight: number) => {
|
||||
const commonProps = {
|
||||
data,
|
||||
width: chartWidth,
|
||||
height: chartHeight,
|
||||
margin: { top: 5, right: 10, left: 10, bottom: 5 },
|
||||
};
|
||||
|
||||
const seriesElements = chart.series.map((s, i) => {
|
||||
const color = getChartColor(i);
|
||||
const key = s.label;
|
||||
|
||||
switch (chart.kind) {
|
||||
case 'line':
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
);
|
||||
case 'area':
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
stackId={chart.stacked ? '1' : undefined}
|
||||
isAnimationActive={false}
|
||||
connectNulls
|
||||
/>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={color}
|
||||
stackId={chart.stacked ? '1' : undefined}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const axes = (
|
||||
<>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="timeLabel"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={tickFormatter}
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => (
|
||||
<ChartTooltipContent
|
||||
active={active}
|
||||
payload={payload}
|
||||
label={label as string}
|
||||
formatter={tooltipFormatter}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: '12px' }} />
|
||||
</>
|
||||
);
|
||||
|
||||
switch (chart.kind) {
|
||||
case 'line':
|
||||
return (
|
||||
<LineChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</LineChart>
|
||||
);
|
||||
case 'area':
|
||||
return (
|
||||
<AreaChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</AreaChart>
|
||||
);
|
||||
case 'bar':
|
||||
return (
|
||||
<BarChart {...commonProps}>
|
||||
{axes}
|
||||
{seriesElements}
|
||||
</BarChart>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{chart.title}</CardTitle>
|
||||
{chart.description && (
|
||||
<TooltipProvider>
|
||||
<UiTooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs">{chart.description}</p>
|
||||
</TooltipContent>
|
||||
</UiTooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartSizedContainer height={288}>{(width, height) => renderChart(width, height)}</ChartSizedContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { useHistoryMetricsStore } from '../stores/historyMetricsStore';
|
||||
import { collectHistoryMetricIds, collectLiveMetricIds, periodKey, periodWindow, deltaHistograms } from '../helpers';
|
||||
import { StatCard } from './StatCard';
|
||||
import { DashboardChart } from './DashboardChart';
|
||||
import { PeriodSelector } from './PeriodSelector';
|
||||
|
||||
interface DashboardViewProps {
|
||||
dashboardId: string;
|
||||
section: string;
|
||||
}
|
||||
|
||||
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
|
||||
const navigate = useNavigate();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const fetchHistory = useHistoryMetricsStore((s) => s.fetch);
|
||||
const refreshHistory = useHistoryMetricsStore((s) => s.refresh);
|
||||
const historyStatus = useHistoryMetricsStore((s) => s.status);
|
||||
const historyCache = useHistoryMetricsStore((s) => s.cache);
|
||||
const subscribeLive = useLiveMetricsStore((s) => s.subscribe);
|
||||
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
|
||||
const liveStatus = useLiveMetricsStore((s) => s.status);
|
||||
const liveError = useLiveMetricsStore((s) => s.error);
|
||||
|
||||
const dashboards = useMemo<Dashboard[]>(() => schema?.dashboards ?? [], [schema]);
|
||||
const dashboard = dashboards.find((d) => d.id === dashboardId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboard && dashboards.length > 0) {
|
||||
navigate(`/${section}/Dashboard/${dashboards[0].id}`, { replace: true });
|
||||
}
|
||||
}, [dashboard, dashboards, navigate, section]);
|
||||
|
||||
const historyIds = useMemo(
|
||||
() => (dashboard ? collectHistoryMetricIds(dashboard.cards, dashboard.charts) : new Set<string>()),
|
||||
[dashboard],
|
||||
);
|
||||
const liveIds = useMemo(() => (dashboard ? collectLiveMetricIds(dashboard.cards) : new Set<string>()), [dashboard]);
|
||||
|
||||
const cacheKey = dashboard ? `${dashboard.id}|${periodKey(period)}` : '';
|
||||
const [fetchVersion, setFetchVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboard || historyIds.size === 0) return;
|
||||
let cancelled = false;
|
||||
fetchHistory(dashboard.id, period, historyIds).then(() => {
|
||||
if (!cancelled) setFetchVersion((v) => v + 1);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [dashboard, period, historyIds, fetchHistory]);
|
||||
|
||||
const { historySamples, historyWindow } = useMemo(() => {
|
||||
void fetchVersion;
|
||||
const raw = historyCache.get(cacheKey)?.metrics ?? [];
|
||||
return {
|
||||
historySamples: deltaHistograms(raw),
|
||||
historyWindow: periodWindow(period),
|
||||
};
|
||||
}, [historyCache, cacheKey, fetchVersion, period]);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveIds.size > 0) {
|
||||
subscribeLive(liveIds);
|
||||
}
|
||||
return () => {
|
||||
unsubscribeLive();
|
||||
};
|
||||
}, [liveIds, subscribeLive, unsubscribeLive]);
|
||||
|
||||
const isLoading = historyStatus.get(cacheKey) === 'loading';
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (dashboard && historyIds.size > 0) {
|
||||
refreshHistory(dashboard.id, period, historyIds).then(() => setFetchVersion((v) => v + 1));
|
||||
}
|
||||
}, [dashboard, period, historyIds, refreshHistory]);
|
||||
|
||||
if (!dashboard) {
|
||||
if (dashboards.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">No dashboards configured.</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{dashboards.length > 1 && (
|
||||
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
|
||||
<TabsList>
|
||||
{dashboards.map((d) => (
|
||||
<TabsTrigger key={d.id} value={d.id}>
|
||||
{d.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
{dashboards.length === 1 && <h1 className="text-xl font-semibold">{dashboard.label}</h1>}
|
||||
|
||||
<PeriodSelector onRefresh={handleRefresh} loading={isLoading} />
|
||||
</div>
|
||||
|
||||
{liveStatus === 'error' && liveError && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{liveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard.cards && dashboard.cards.length > 0 && (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(220px,1fr))]">
|
||||
{dashboard.cards.map((card, i) => (
|
||||
<StatCard
|
||||
key={`${card.title}-${i}`}
|
||||
card={card}
|
||||
historySamples={historySamples}
|
||||
historyWindow={historyWindow}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dashboard.charts && dashboard.charts.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{dashboard.charts.map((chart, i) => (
|
||||
<DashboardChart
|
||||
key={`${chart.title}-${i}`}
|
||||
chart={chart}
|
||||
historySamples={historySamples}
|
||||
historyWindow={historyWindow}
|
||||
period={period}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
import type { PresetKey } from '../types/metrics';
|
||||
import { presetLabel, PRESET_KEYS } from '../types/metrics';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
onRefresh: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function PeriodSelector({ onRefresh, loading }: PeriodSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const period = useDashboardStore((s) => s.period);
|
||||
const setPreset = useDashboardStore((s) => s.setPreset);
|
||||
const setPeriod = useDashboardStore((s) => s.setPeriod);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
|
||||
const currentValue = period.kind === 'preset' ? period.preset : 'custom';
|
||||
|
||||
const handleSelectChange = (value: string) => {
|
||||
if (value === 'custom') {
|
||||
setCustomOpen(true);
|
||||
} else {
|
||||
setPreset(value as PresetKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyCustom = () => {
|
||||
if (customFrom && customTo) {
|
||||
setPeriod({
|
||||
kind: 'custom',
|
||||
from: new Date(customFrom),
|
||||
to: new Date(customTo),
|
||||
});
|
||||
setCustomOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={customOpen} onOpenChange={setCustomOpen}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={currentValue} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESET_KEYS.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{presetLabel(t, key)}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{t('dashboard.customEllipsis', 'Custom...')}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<PopoverTrigger asChild>
|
||||
<span />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
|
||||
<PopoverContent className="w-72 p-4" align="end">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">{t('dashboard.customRange', 'Custom range')}</h4>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-from" className="text-xs">
|
||||
{t('dashboard.from', 'From')}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-from"
|
||||
type="datetime-local"
|
||||
value={customFrom}
|
||||
onChange={(e) => setCustomFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-to" className="text-xs">
|
||||
{t('dashboard.to', 'To')}
|
||||
</Label>
|
||||
<Input
|
||||
id="custom-to"
|
||||
type="datetime-local"
|
||||
value={customTo}
|
||||
onChange={(e) => setCustomTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="w-full" onClick={handleApplyCustom}>
|
||||
{t('common.apply', 'Apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} disabled={loading} className="h-9 w-9">
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { LineChart, Line } from 'recharts';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { Card as CardSchema } from '../types/schema';
|
||||
import type { Metric } from '../types/metrics';
|
||||
import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers';
|
||||
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
|
||||
import { getChartColor } from '@/components/ui/chart';
|
||||
|
||||
const warnedIcons = new Set<string>();
|
||||
|
||||
function LucideIcon({ name, className }: { name: string; className?: string }) {
|
||||
const formatted = name
|
||||
.split('-')
|
||||
.map((s) => s[0].toUpperCase() + s.slice(1))
|
||||
.join('');
|
||||
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
|
||||
if (!IconComp) {
|
||||
if (import.meta.env.DEV && !warnedIcons.has(name)) {
|
||||
warnedIcons.add(name);
|
||||
console.warn(`Unknown icon name: "${name}"`);
|
||||
}
|
||||
return <LucideIcons.HelpCircle className={className} />;
|
||||
}
|
||||
return <IconComp className={className} />;
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
card: CardSchema;
|
||||
historySamples: Metric[];
|
||||
historyWindow: { from: Date; to: Date };
|
||||
}
|
||||
|
||||
export function StatCard({ card, historySamples, historyWindow }: StatCardProps) {
|
||||
const liveSnapshot = useLiveMetricsStore((s) => s.snapshot);
|
||||
|
||||
const value = useMemo(() => {
|
||||
if (card.source === 'live') {
|
||||
const liveSamples = card.metrics.map((id) => liveSnapshot.get(id)).filter((m): m is Metric => m !== undefined);
|
||||
return cardValue(card, liveSamples);
|
||||
}
|
||||
return cardValue(card, historySamples);
|
||||
}, [card, liveSnapshot, historySamples]);
|
||||
|
||||
const formattedValue = formatValue(value, card.format);
|
||||
|
||||
const { from, to } = historyWindow;
|
||||
|
||||
const sparkline = useMemo(() => {
|
||||
if (card.source !== 'history' || !card.sparkline) return null;
|
||||
return sparklineData(card, historySamples, from, to).map((v, i) => ({
|
||||
v,
|
||||
i,
|
||||
}));
|
||||
}, [card, historySamples, from, to]);
|
||||
|
||||
const delta = useMemo(() => {
|
||||
if (card.source !== 'history' || !card.delta) return null;
|
||||
return computeDelta(card, historySamples, from, to);
|
||||
}, [card, historySamples, from, to]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LucideIcon name={card.icon} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
|
||||
{card.description && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs">{card.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-2xl font-bold">{formattedValue}</div>
|
||||
|
||||
{(delta || sparkline) && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{delta && (
|
||||
<Badge variant="secondary" className="text-xs font-normal text-muted-foreground">
|
||||
{delta.direction === 'up'
|
||||
? `\u2191 ${Math.abs(delta.pct)}%`
|
||||
: delta.direction === 'down'
|
||||
? `\u2193 ${Math.abs(delta.pct)}%`
|
||||
: '\u2013'}
|
||||
</Badge>
|
||||
)}
|
||||
{sparkline && (
|
||||
<LineChart width={64} height={32} data={sparkline}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="v"
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import type { Metric, MetricId, Period } from './types/metrics';
|
||||
import type { Card, Series, MetricFormat, Aggregate } from './types/schema';
|
||||
import { PRESET_MS, BUCKET_CONFIG, CUSTOM_BUCKET_COUNT, SPARKLINE_BUCKET_COUNT } from './types/metrics';
|
||||
|
||||
export function metricToScalar(m: Metric): number {
|
||||
if (m['@type'] === 'Histogram') {
|
||||
return m.count === 0 ? 0 : m.sum / m.count;
|
||||
}
|
||||
return m.count;
|
||||
}
|
||||
|
||||
export function deltaHistograms(samples: Metric[]): Metric[] {
|
||||
const nonHistograms: Metric[] = [];
|
||||
const byMetric = new Map<MetricId, Metric[]>();
|
||||
|
||||
for (const m of samples) {
|
||||
if (m['@type'] !== 'Histogram') {
|
||||
nonHistograms.push(m);
|
||||
continue;
|
||||
}
|
||||
let list = byMetric.get(m.metric);
|
||||
if (!list) {
|
||||
list = [];
|
||||
byMetric.set(m.metric, list);
|
||||
}
|
||||
list.push(m);
|
||||
}
|
||||
|
||||
const result = [...nonHistograms];
|
||||
|
||||
for (const [, records] of byMetric) {
|
||||
records.sort((a, b) => {
|
||||
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
|
||||
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
|
||||
return ta - tb;
|
||||
});
|
||||
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
const prev = records[i - 1] as Metric & { '@type': 'Histogram' };
|
||||
const curr = records[i] as Metric & { '@type': 'Histogram' };
|
||||
const deltaCount = curr.count - prev.count;
|
||||
const deltaSum = curr.sum - prev.sum;
|
||||
|
||||
if (deltaCount <= 0 || deltaSum < 0) continue;
|
||||
|
||||
result.push({
|
||||
'@type': 'Histogram',
|
||||
metric: curr.metric,
|
||||
count: deltaCount,
|
||||
sum: deltaSum,
|
||||
timestamp: curr.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function periodWindow(p: Period): { from: Date; to: Date } {
|
||||
if (p.kind === 'custom') return { from: p.from, to: p.to };
|
||||
const to = new Date();
|
||||
const ms = PRESET_MS[p.preset];
|
||||
return { from: new Date(to.getTime() - ms), to };
|
||||
}
|
||||
|
||||
export function periodKey(p: Period): string {
|
||||
if (p.kind === 'custom') return `custom|${p.from.toISOString()}|${p.to.toISOString()}`;
|
||||
return p.preset;
|
||||
}
|
||||
|
||||
export function getBucketCount(p: Period): number {
|
||||
if (p.kind === 'custom') return CUSTOM_BUCKET_COUNT;
|
||||
return BUCKET_CONFIG[p.preset];
|
||||
}
|
||||
|
||||
function aggregate(values: number[], agg: Aggregate | undefined): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
if (agg === 'avg') return sum / values.length;
|
||||
return sum;
|
||||
}
|
||||
|
||||
export function cardValue(card: Card, samples: Metric[]): number {
|
||||
const values = samples.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar);
|
||||
return aggregate(values, card.aggregate);
|
||||
}
|
||||
|
||||
export function seriesBucketValue(series: Series, bucketSamples: Metric[]): number | null {
|
||||
const values = bucketSamples.filter((m) => series.metrics.includes(m.metric)).map(metricToScalar);
|
||||
if (values.length === 0) return null;
|
||||
return aggregate(values, series.aggregate);
|
||||
}
|
||||
|
||||
export function bucketize(samples: Metric[], from: Date, to: Date, bucketCount: number): Metric[][] {
|
||||
const buckets: Metric[][] = Array.from({ length: bucketCount }, () => []);
|
||||
const span = to.getTime() - from.getTime();
|
||||
if (span <= 0) return buckets;
|
||||
for (const m of samples) {
|
||||
if (!m.timestamp) continue;
|
||||
const t = new Date(m.timestamp).getTime();
|
||||
if (t < from.getTime() || t > to.getTime()) continue;
|
||||
const idx = Math.min(bucketCount - 1, Math.floor(((t - from.getTime()) / span) * bucketCount));
|
||||
buckets[idx].push(m);
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
export function bucketTimestamps(from: Date, to: Date, bucketCount: number): Date[] {
|
||||
const span = to.getTime() - from.getTime();
|
||||
const width = span / bucketCount;
|
||||
return Array.from({ length: bucketCount }, (_, i) => new Date(from.getTime() + width * (i + 0.5)));
|
||||
}
|
||||
|
||||
export function computeDelta(
|
||||
card: Card,
|
||||
samples: Metric[],
|
||||
from: Date,
|
||||
to: Date,
|
||||
): { pct: number; direction: 'up' | 'down' | 'neutral' } | null {
|
||||
const mid = new Date((from.getTime() + to.getTime()) / 2);
|
||||
const firstHalf = samples.filter((m) => {
|
||||
if (!m.timestamp) return false;
|
||||
return new Date(m.timestamp).getTime() < mid.getTime();
|
||||
});
|
||||
const secondHalf = samples.filter((m) => {
|
||||
if (!m.timestamp) return false;
|
||||
return new Date(m.timestamp).getTime() >= mid.getTime();
|
||||
});
|
||||
|
||||
const first = aggregate(firstHalf.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar), card.aggregate);
|
||||
const second = aggregate(
|
||||
secondHalf.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar),
|
||||
card.aggregate,
|
||||
);
|
||||
|
||||
if (first === 0) return null;
|
||||
const pct = ((second - first) / Math.abs(first)) * 100;
|
||||
if (pct === 0) return { pct: 0, direction: 'neutral' };
|
||||
return { pct: Math.round(pct * 10) / 10, direction: pct > 0 ? 'up' : 'down' };
|
||||
}
|
||||
|
||||
export function sparklineData(card: Card, samples: Metric[], from: Date, to: Date): number[] {
|
||||
const buckets = bucketize(samples, from, to, SPARKLINE_BUCKET_COUNT);
|
||||
return buckets.map((bucket) => {
|
||||
const values = bucket.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar);
|
||||
return aggregate(values, card.aggregate);
|
||||
});
|
||||
}
|
||||
|
||||
export function formatValue(value: number, format: MetricFormat): string {
|
||||
switch (format) {
|
||||
case 'number': {
|
||||
if (Number.isInteger(value) && Math.abs(value) < 1000) {
|
||||
return value.toString();
|
||||
}
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
notation: Math.abs(value) >= 1000 ? 'compact' : 'standard',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
case 'bytes': {
|
||||
if (value === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
const factor = 1024 ** i;
|
||||
const v = value / factor;
|
||||
if (v >= 1) {
|
||||
const decimals = v < 10 ? 1 : 0;
|
||||
return `${v.toFixed(decimals)} ${units[i]}`;
|
||||
}
|
||||
}
|
||||
return `${value} B`;
|
||||
}
|
||||
case 'duration': {
|
||||
if (value === 0) return '0 ms';
|
||||
if (value < 1000) return `${Math.round(value)} ms`;
|
||||
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
|
||||
const m = Math.floor(value / 60_000);
|
||||
const s = Math.floor((value % 60_000) / 1000);
|
||||
if (m < 60) return s > 0 ? `${m} m ${s} s` : `${m} m`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return rm > 0 ? `${h} h ${rm} m` : `${h} h`;
|
||||
}
|
||||
case 'percent':
|
||||
return `${value.toFixed(1)}%`;
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimeTick(date: Date, period: Period): string {
|
||||
if (period.kind === 'preset') {
|
||||
switch (period.preset) {
|
||||
case '24h':
|
||||
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
default:
|
||||
return date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
}
|
||||
const span = period.to.getTime() - period.from.getTime();
|
||||
if (span <= 86_400_000) {
|
||||
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
export function collectHistoryMetricIds(
|
||||
cards: Card[] | undefined,
|
||||
charts: import('./types/schema').Chart[] | undefined,
|
||||
): Set<MetricId> {
|
||||
const ids = new Set<MetricId>();
|
||||
if (cards) {
|
||||
for (const card of cards) {
|
||||
if (card.source === 'history') {
|
||||
for (const id of card.metrics) ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (charts) {
|
||||
for (const chart of charts) {
|
||||
for (const series of chart.series) {
|
||||
for (const id of series.metrics) ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function collectLiveMetricIds(cards: Card[] | undefined): Set<MetricId> {
|
||||
const ids = new Set<MetricId>();
|
||||
if (cards) {
|
||||
for (const card of cards) {
|
||||
if (card.source === 'live') {
|
||||
for (const id of card.metrics) ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Period, PresetKey } from '../types/metrics';
|
||||
|
||||
interface DashboardState {
|
||||
period: Period;
|
||||
setPeriod: (period: Period) => void;
|
||||
setPreset: (preset: PresetKey) => void;
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
period: { kind: 'preset', preset: '24h' } as Period,
|
||||
|
||||
setPeriod: (period) => set({ period }),
|
||||
setPreset: (preset) => set({ period: { kind: 'preset', preset } }),
|
||||
}),
|
||||
{
|
||||
name: 'dashboard.period',
|
||||
partialize: (state) => ({ period: state.period }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { Metric, MetricId, Period } from '../types/metrics';
|
||||
import { periodWindow, periodKey } from '../helpers';
|
||||
import { getAccountId, jmapQueryAllAndGet } from '@/services/jmap/client';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
type FetchStatus = 'idle' | 'loading' | 'error';
|
||||
const STALE_MS = 60_000;
|
||||
|
||||
interface CacheEntry {
|
||||
metrics: Metric[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface HistoryMetricsState {
|
||||
cache: Map<string, CacheEntry>;
|
||||
status: Map<string, FetchStatus>;
|
||||
error: Map<string, string>;
|
||||
|
||||
fetch: (dashboardId: string, period: Period, ids: Set<MetricId>) => Promise<Metric[]>;
|
||||
invalidate: (dashboardId: string) => void;
|
||||
refresh: (dashboardId: string, period: Period, ids: Set<MetricId>) => Promise<Metric[]>;
|
||||
}
|
||||
|
||||
async function fetchMetrics(period: Period, ids: Set<MetricId>): Promise<Metric[]> {
|
||||
if (ids.size === 0) return [];
|
||||
|
||||
const { from, to } = periodWindow(period);
|
||||
const accountId = getAccountId('x:Metric');
|
||||
const metricIds = Array.from(ids);
|
||||
|
||||
const result = await jmapQueryAllAndGet('x:Metric', accountId, {
|
||||
filter: {
|
||||
timestampIsGreaterThanOrEqual: from.toISOString(),
|
||||
timestampIsLessThanOrEqual: to.toISOString(),
|
||||
metric: metricIds,
|
||||
},
|
||||
});
|
||||
|
||||
return result.list as Metric[];
|
||||
}
|
||||
|
||||
export const useHistoryMetricsStore = create<HistoryMetricsState>()((set, get) => ({
|
||||
cache: new Map(),
|
||||
status: new Map(),
|
||||
error: new Map(),
|
||||
|
||||
fetch: async (dashboardId, period, ids) => {
|
||||
const key = `${dashboardId}|${periodKey(period)}`;
|
||||
const existing = get().cache.get(key);
|
||||
|
||||
if (existing && Date.now() - existing.fetchedAt < STALE_MS) {
|
||||
return existing.metrics;
|
||||
}
|
||||
|
||||
if (get().status.get(key) === 'loading') {
|
||||
return existing?.metrics ?? [];
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
status: new Map(state.status).set(key, 'loading'),
|
||||
}));
|
||||
|
||||
try {
|
||||
const metrics = await fetchMetrics(period, ids);
|
||||
set((state) => ({
|
||||
cache: new Map(state.cache).set(key, { metrics, fetchedAt: Date.now() }),
|
||||
status: new Map(state.status).set(key, 'idle'),
|
||||
error: new Map([...state.error].filter(([k]) => k !== key)),
|
||||
}));
|
||||
return metrics;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : i18n.t('dashboard.failedFetchMetrics', 'Failed to fetch metrics');
|
||||
set((state) => ({
|
||||
status: new Map(state.status).set(key, 'error'),
|
||||
error: new Map(state.error).set(key, msg),
|
||||
}));
|
||||
return existing?.metrics ?? [];
|
||||
}
|
||||
},
|
||||
|
||||
invalidate: (dashboardId) => {
|
||||
set((state) => {
|
||||
const newCache = new Map(state.cache);
|
||||
for (const key of newCache.keys()) {
|
||||
if (key.startsWith(`${dashboardId}|`)) {
|
||||
newCache.delete(key);
|
||||
}
|
||||
}
|
||||
return { cache: newCache };
|
||||
});
|
||||
},
|
||||
|
||||
refresh: async (dashboardId, period, ids) => {
|
||||
const key = `${dashboardId}|${periodKey(period)}`;
|
||||
set((state) => {
|
||||
const newCache = new Map(state.cache);
|
||||
newCache.delete(key);
|
||||
return { cache: newCache };
|
||||
});
|
||||
return get().fetch(dashboardId, period, ids);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { MetricId, Metric } from '../types/metrics';
|
||||
import { apiFetch } from '@/services/api';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
type LiveStatus = 'idle' | 'connecting' | 'open' | 'error' | 'closed';
|
||||
|
||||
interface LiveMetricsState {
|
||||
snapshot: Map<MetricId, Metric>;
|
||||
subscribedIds: Set<MetricId>;
|
||||
status: LiveStatus;
|
||||
error: string | null;
|
||||
|
||||
subscribe: (ids: Set<MetricId>) => void;
|
||||
unsubscribe: () => void;
|
||||
handleBatch: (batch: Metric[]) => void;
|
||||
}
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const MAX_RECONNECT = 5;
|
||||
const RECONNECT_DELAY = 2000;
|
||||
|
||||
function cleanup() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
async function openStream(ids: Set<MetricId>) {
|
||||
cleanup();
|
||||
|
||||
if (ids.size === 0) {
|
||||
useLiveMetricsStore.setState({ status: 'idle', subscribedIds: ids });
|
||||
return;
|
||||
}
|
||||
|
||||
useLiveMetricsStore.setState({ status: 'connecting', subscribedIds: ids, error: null });
|
||||
|
||||
try {
|
||||
const tokenResponse = await apiFetch('/api/token/metrics');
|
||||
const token = await tokenResponse.text();
|
||||
|
||||
const metricsParam = Array.from(ids).join(',');
|
||||
const url = `${getOrigin()}/api/live/metrics?token=${encodeURIComponent(token)}&interval=30&metrics=${encodeURIComponent(metricsParam)}`;
|
||||
|
||||
const es = new EventSource(url);
|
||||
eventSource = es;
|
||||
reconnectAttempts = 0;
|
||||
|
||||
es.addEventListener('metrics', (event) => {
|
||||
try {
|
||||
const batch = JSON.parse(event.data) as Metric[];
|
||||
useLiveMetricsStore.getState().handleBatch(batch);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse live metrics:', e);
|
||||
}
|
||||
});
|
||||
|
||||
es.onopen = () => {
|
||||
useLiveMetricsStore.setState({ status: 'open' });
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
if (es !== eventSource) return;
|
||||
es.close();
|
||||
eventSource = null;
|
||||
|
||||
if (reconnectAttempts < MAX_RECONNECT) {
|
||||
reconnectAttempts++;
|
||||
useLiveMetricsStore.setState({ status: 'connecting' });
|
||||
reconnectTimer = setTimeout(() => {
|
||||
const currentIds = useLiveMetricsStore.getState().subscribedIds;
|
||||
openStream(currentIds);
|
||||
}, RECONNECT_DELAY);
|
||||
} else {
|
||||
useLiveMetricsStore.setState({
|
||||
status: 'error',
|
||||
error: i18n.t(
|
||||
'dashboard.liveMetricsDisconnected',
|
||||
'Live metrics stream disconnected after multiple retries.',
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
useLiveMetricsStore.setState({
|
||||
status: 'error',
|
||||
error:
|
||||
e instanceof Error ? e.message : i18n.t('dashboard.liveMetricsFailed', 'Failed to connect to live metrics.'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getOrigin(): string {
|
||||
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
if (envUrl && envUrl.length > 0) return envUrl.replace(/\/+$/, '');
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
let visibilityTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
visibilityTimer = setTimeout(() => {
|
||||
if (eventSource) {
|
||||
cleanup();
|
||||
useLiveMetricsStore.setState({ status: 'closed' });
|
||||
}
|
||||
}, 60_000);
|
||||
} else {
|
||||
if (visibilityTimer) {
|
||||
clearTimeout(visibilityTimer);
|
||||
visibilityTimer = null;
|
||||
}
|
||||
const state = useLiveMetricsStore.getState();
|
||||
if (state.status === 'closed' && state.subscribedIds.size > 0) {
|
||||
openStream(state.subscribedIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
|
||||
export const useLiveMetricsStore = create<LiveMetricsState>()((set) => ({
|
||||
snapshot: new Map(),
|
||||
subscribedIds: new Set(),
|
||||
status: 'idle',
|
||||
error: null,
|
||||
|
||||
subscribe: (ids) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
openStream(ids);
|
||||
}, 200);
|
||||
},
|
||||
|
||||
unsubscribe: () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
cleanup();
|
||||
set({ snapshot: new Map(), subscribedIds: new Set(), status: 'idle', error: null });
|
||||
},
|
||||
|
||||
handleBatch: (batch) => {
|
||||
set((state) => {
|
||||
const next = new Map(state.snapshot);
|
||||
for (const m of batch) {
|
||||
next.set(m.metric, m);
|
||||
}
|
||||
return { snapshot: next };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
export type MetricId = string;
|
||||
|
||||
export type Metric =
|
||||
| { '@type': 'Counter'; metric: MetricId; count: number; timestamp?: string }
|
||||
| { '@type': 'Gauge'; metric: MetricId; count: number; timestamp?: string }
|
||||
| { '@type': 'Histogram'; metric: MetricId; count: number; sum: number; timestamp?: string };
|
||||
|
||||
export type Period = { kind: 'preset'; preset: PresetKey } | { kind: 'custom'; from: Date; to: Date };
|
||||
|
||||
export type PresetKey = '24h' | '7d' | '30d' | '90d';
|
||||
|
||||
export const PRESET_MS: Record<PresetKey, number> = {
|
||||
'24h': 86_400_000,
|
||||
'7d': 7 * 86_400_000,
|
||||
'30d': 30 * 86_400_000,
|
||||
'90d': 90 * 86_400_000,
|
||||
};
|
||||
|
||||
export function presetLabel(t: (key: string, fallback: string) => string, key: PresetKey): string {
|
||||
switch (key) {
|
||||
case '24h':
|
||||
return t('dashboard.preset24h', 'Last 24 hours');
|
||||
case '7d':
|
||||
return t('dashboard.preset7d', 'Last 7 days');
|
||||
case '30d':
|
||||
return t('dashboard.preset30d', 'Last 30 days');
|
||||
case '90d':
|
||||
return t('dashboard.preset90d', 'Last 90 days');
|
||||
}
|
||||
}
|
||||
|
||||
export const PRESET_KEYS: PresetKey[] = ['24h', '7d', '30d', '90d'];
|
||||
|
||||
export const BUCKET_CONFIG: Record<PresetKey, number> = {
|
||||
'24h': 48,
|
||||
'7d': 56,
|
||||
'30d': 60,
|
||||
'90d': 90,
|
||||
};
|
||||
|
||||
export const CUSTOM_BUCKET_COUNT = 60;
|
||||
export const SPARKLINE_BUCKET_COUNT = 20;
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
export type Dashboard = {
|
||||
id: string;
|
||||
label: string;
|
||||
cards?: Card[];
|
||||
charts?: Chart[];
|
||||
};
|
||||
|
||||
export type Card = {
|
||||
title: string;
|
||||
icon: string;
|
||||
source: CardSource;
|
||||
metrics: string[];
|
||||
aggregate?: Aggregate;
|
||||
format: MetricFormat;
|
||||
description?: string;
|
||||
sparkline?: boolean;
|
||||
delta?: boolean;
|
||||
};
|
||||
|
||||
export type Chart = {
|
||||
title: string;
|
||||
kind: ChartKind;
|
||||
series: Series[];
|
||||
stacked?: boolean;
|
||||
valueFormat?: MetricFormat;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type Series = {
|
||||
label: string;
|
||||
metrics: string[];
|
||||
aggregate?: Aggregate;
|
||||
};
|
||||
|
||||
export type Aggregate = 'sum' | 'avg';
|
||||
export type ChartKind = 'line' | 'area' | 'bar';
|
||||
export type CardSource = 'live' | 'history';
|
||||
export type MetricFormat = 'number' | 'bytes' | 'duration' | 'percent';
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Square, RotateCcw, Plus, Check, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { apiFetch, getApiBaseUrl } from '@/services/api';
|
||||
import type { TraceEvent } from '../types';
|
||||
import { TraceTimeline } from './TraceTimeline';
|
||||
|
||||
const MAX_EVENTS = 1000;
|
||||
const MAX_RECONNECT = 5;
|
||||
const RECONNECT_DELAY = 2000;
|
||||
|
||||
interface KeyValueFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type LiveTracingState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'starting'; keywords: string; filters: KeyValueFilter[] }
|
||||
| { kind: 'streaming'; events: TraceEvent[]; anchorTimestamp: string; eventSource: EventSource }
|
||||
| { kind: 'stopped'; events: TraceEvent[]; anchorTimestamp: string }
|
||||
| { kind: 'error'; events: TraceEvent[]; error: string };
|
||||
|
||||
export function LiveTracingPage() {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const [state, setState] = useState<LiveTracingState>({ kind: 'idle' });
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [filters, setFilters] = useState<KeyValueFilter[]>([]);
|
||||
const [addingFilter, setAddingFilter] = useState(false);
|
||||
const [newFilterKey, setNewFilterKey] = useState('');
|
||||
const [newFilterValue, setNewFilterValue] = useState('');
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const eventsRef = useRef<TraceEvent[]>([]);
|
||||
const anchorRef = useRef<string>('');
|
||||
const reconnectAttempts = useRef(0);
|
||||
|
||||
const keyEnum = useMemo(() => schema?.enums?.['Key'] ?? [], [schema]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openStream = useCallback(async () => {
|
||||
setState({ kind: 'starting', keywords, filters });
|
||||
eventsRef.current = [];
|
||||
anchorRef.current = '';
|
||||
reconnectAttempts.current = 0;
|
||||
|
||||
try {
|
||||
const tokenResponse = await apiFetch('/api/token/tracing');
|
||||
const token = await tokenResponse.text();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('token', token);
|
||||
if (keywords.trim()) {
|
||||
params.set('filter', keywords.trim());
|
||||
}
|
||||
for (const f of filters) {
|
||||
params.set(f.key, f.value);
|
||||
}
|
||||
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/live/tracing?${params.toString()}`;
|
||||
|
||||
const connectEventSource = () => {
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener('trace', (event) => {
|
||||
try {
|
||||
const batch = JSON.parse(event.data) as TraceEvent[];
|
||||
const currentEvents = eventsRef.current;
|
||||
|
||||
for (const evt of batch) {
|
||||
currentEvents.push(evt);
|
||||
if (!anchorRef.current) {
|
||||
anchorRef.current = evt.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEvents.length > MAX_EVENTS) {
|
||||
eventsRef.current = currentEvents.slice(currentEvents.length - MAX_EVENTS);
|
||||
}
|
||||
|
||||
setState({
|
||||
kind: 'streaming',
|
||||
events: [...eventsRef.current],
|
||||
anchorTimestamp: anchorRef.current,
|
||||
eventSource: es,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse trace event:', e);
|
||||
}
|
||||
});
|
||||
|
||||
es.onopen = () => {
|
||||
reconnectAttempts.current = 0;
|
||||
setState({
|
||||
kind: 'streaming',
|
||||
events: [...eventsRef.current],
|
||||
anchorTimestamp: anchorRef.current || '',
|
||||
eventSource: es,
|
||||
});
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
if (es !== eventSourceRef.current) return;
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
if (reconnectAttempts.current < MAX_RECONNECT) {
|
||||
reconnectAttempts.current++;
|
||||
setTimeout(connectEventSource, RECONNECT_DELAY);
|
||||
} else {
|
||||
setState({
|
||||
kind: 'error',
|
||||
events: [...eventsRef.current],
|
||||
error: t('tracing.liveDisconnected', 'Live tracing stream disconnected after multiple retries.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
connectEventSource();
|
||||
} catch (e) {
|
||||
setState({
|
||||
kind: 'error',
|
||||
events: [],
|
||||
error: e instanceof Error ? e.message : t('tracing.liveFailedStart', 'Failed to start live tracing.'),
|
||||
});
|
||||
}
|
||||
}, [keywords, filters, t]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setState((prev) => {
|
||||
if (prev.kind === 'streaming') {
|
||||
return {
|
||||
kind: 'stopped',
|
||||
events: prev.events,
|
||||
anchorTimestamp: prev.anchorTimestamp,
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetToIdle = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
eventsRef.current = [];
|
||||
anchorRef.current = '';
|
||||
setState({ kind: 'idle' });
|
||||
}, []);
|
||||
|
||||
const addFilter = useCallback(() => {
|
||||
if (!newFilterKey || !newFilterValue.trim()) return;
|
||||
const keyEntry = keyEnum.find((e) => e.name === newFilterKey);
|
||||
setFilters((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: newFilterKey,
|
||||
label: keyEntry?.label ?? newFilterKey,
|
||||
value: newFilterValue.trim(),
|
||||
},
|
||||
]);
|
||||
setNewFilterKey('');
|
||||
setNewFilterValue('');
|
||||
setAddingFilter(false);
|
||||
}, [newFilterKey, newFilterValue, keyEnum]);
|
||||
|
||||
const removeFilter = useCallback((idx: number) => {
|
||||
setFilters((prev) => prev.filter((_, i) => i !== idx));
|
||||
}, []);
|
||||
|
||||
const isIdle = state.kind === 'idle';
|
||||
const isStarting = state.kind === 'starting';
|
||||
const isStreaming = state.kind === 'streaming';
|
||||
const isStopped = state.kind === 'stopped';
|
||||
const isError = state.kind === 'error';
|
||||
|
||||
if (isIdle || isStarting) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6 pt-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('tracing.liveTitle', 'Live Tracing')}</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t('tracing.liveSubtitle', 'Stream server events in real time.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('tracing.keywordsLabel', 'Keywords (optional)')}</label>
|
||||
<Input
|
||||
placeholder={t('tracing.keywordsPlaceholder', 'Enter keywords to filter events...')}
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((f, i) => (
|
||||
<Badge key={i} variant="secondary" className="gap-1">
|
||||
{f.label} = {f.value}
|
||||
<button onClick={() => removeFilter(i)} className="ml-1 hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addingFilter ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={newFilterKey} onValueChange={setNewFilterKey}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={t('field.selectKey', 'Select key...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{keyEnum.map((entry) => (
|
||||
<SelectItem key={entry.name} value={entry.name}>
|
||||
{entry.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder={t('tracing.valuePlaceholder', 'Value...')}
|
||||
value={newFilterValue}
|
||||
onChange={(e) => setNewFilterValue(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') addFilter();
|
||||
}}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={addFilter}>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => setAddingFilter(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setAddingFilter(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('tracing.addFilter', 'Add filter')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button className="w-full" onClick={openStream} disabled={isStarting}>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t('tracing.connecting', 'Connecting...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{t('tracing.startTracing', 'Start tracing')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const events = isStreaming ? state.events : isStopped ? state.events : isError ? state.events : [];
|
||||
const anchor = isStreaming ? state.anchorTimestamp : isStopped ? state.anchorTimestamp : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">{t('tracing.liveTitle', 'Live Tracing')}</h2>
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
{t('tracing.streamingEvents', 'Streaming events...')}
|
||||
</div>
|
||||
)}
|
||||
{isStopped && <span className="text-sm text-muted-foreground">{t('tracing.stopped', 'Stopped')}</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isStreaming && (
|
||||
<Button variant="outline" onClick={stopStream}>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
{t('tracing.stopTracing', 'Stop tracing')}
|
||||
</Button>
|
||||
)}
|
||||
{(isStopped || isError) && (
|
||||
<Button variant="outline" onClick={resetToIdle}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('tracing.restart', 'Restart')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TraceTimeline events={events} anchorTimestamp={anchor} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{events.length === 1
|
||||
? t('tracing.eventCount_one', '{{count}} event', { count: events.length })
|
||||
: t('tracing.eventCount_other', '{{count}} events', { count: events.length })}
|
||||
{events.length >= MAX_EVENTS && ' ' + t('tracing.bufferFull', '(buffer full)')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, FileText, Clock, Timer, Activity, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||
import { TraceTimeline } from './TraceTimeline';
|
||||
|
||||
import { jmapMapToArray } from '@/lib/jmapUtils';
|
||||
|
||||
function normalizeTraceEvents(raw: unknown): TraceEvent[] {
|
||||
const events = jmapMapToArray<Record<string, unknown>>(raw);
|
||||
return events.map((evt) => ({
|
||||
event: String(evt.event ?? ''),
|
||||
timestamp: String(evt.timestamp ?? ''),
|
||||
keyValues: normalizeKeyValues(evt.keyValues),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeKeyValues(raw: unknown): TraceKeyValue[] {
|
||||
const kvs = jmapMapToArray<Record<string, unknown>>(raw);
|
||||
return kvs.map((kv) => ({
|
||||
key: String(kv.key ?? ''),
|
||||
value: normalizeTraceValue(kv.value),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeTraceValue(raw: unknown): TraceValue {
|
||||
if (!raw || typeof raw !== 'object') return { '@type': 'Null' };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const type = obj['@type'] as string;
|
||||
|
||||
if (type === 'List') {
|
||||
return { '@type': 'List', value: jmapMapToArray<unknown>(obj.value).map(normalizeTraceValue) };
|
||||
}
|
||||
if (type === 'Event') {
|
||||
return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) };
|
||||
}
|
||||
return raw as TraceValue;
|
||||
}
|
||||
|
||||
interface TraceDetailViewProps {
|
||||
viewName: string;
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const [events, setEvents] = useState<TraceEvent[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema) return;
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const resolved = resolveObject(schema!, viewName);
|
||||
if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object'));
|
||||
|
||||
const accountId = getAccountId(resolved.objectName);
|
||||
const responses = await jmapGet(resolved.objectName, accountId, [objectId]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const getResult = responses.find(([name]) => name.endsWith('/get'));
|
||||
if (!getResult) throw new Error(t('view.noGetResponse', 'No get response'));
|
||||
|
||||
const data = getResult[1] as { list?: Array<{ id: string; events?: unknown }> };
|
||||
const trace = data.list?.[0];
|
||||
if (!trace) throw new Error(t('tracing.traceNotFound', 'Trace not found'));
|
||||
|
||||
setEvents(normalizeTraceEvents(trace.events));
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : t('tracing.failedToLoadTrace', 'Failed to load trace'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [schema, viewName, objectId, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('common.back', 'Back')}
|
||||
</Button>
|
||||
<div className="text-destructive p-4">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!events) return null;
|
||||
|
||||
const eventTypeEnum = schema?.enums?.['EventType'] ?? [];
|
||||
const firstEvent = events[0];
|
||||
const lastEvent = events[events.length - 1];
|
||||
|
||||
const spanLabel = firstEvent
|
||||
? (eventTypeEnum.find((e) => e.name === firstEvent.event)?.label ?? firstEvent.event)
|
||||
: '-';
|
||||
|
||||
const dateStr = firstEvent
|
||||
? new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(firstEvent.timestamp))
|
||||
: '-';
|
||||
|
||||
const durationMs =
|
||||
firstEvent && lastEvent && events.length >= 2
|
||||
? new Date(lastEvent.timestamp).getTime() - new Date(firstEvent.timestamp).getTime()
|
||||
: null;
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.floor((ms % 60_000) / 1000);
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('common.back', 'Back')}
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(180px,1fr))]">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('tracing.spanType', 'Span Type')}
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-semibold truncate">{spanLabel}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t('tracing.date', 'Date')}
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-semibold">{dateStr}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Timer className="h-4 w-4" />
|
||||
{t('tracing.duration', 'Duration')}
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-semibold">{durationMs !== null ? formatDuration(durationMs) : '-'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Activity className="h-4 w-4" />
|
||||
{t('tracing.events', 'Events')}
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-semibold">{events.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TraceTimeline events={events} anchorTimestamp={firstEvent?.timestamp} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CircleDot, CircleX, Clock } from 'lucide-react';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||
|
||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
||||
|
||||
function intToBase32(input: number | string): string {
|
||||
let n: bigint;
|
||||
try {
|
||||
n = BigInt(typeof input === 'number' ? Math.floor(input) : input);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
if (n === 0n) return 'a';
|
||||
const chars: string[] = [];
|
||||
while (n > 0n) {
|
||||
chars.push(BASE32_ALPHABET[Number(n % 32n)]);
|
||||
n = n / 32n;
|
||||
}
|
||||
return chars.reverse().join('');
|
||||
}
|
||||
|
||||
function isIdKey(key: string): boolean {
|
||||
return key === 'id' || key.endsWith('Id');
|
||||
}
|
||||
|
||||
interface TraceTimelineProps {
|
||||
events: TraceEvent[];
|
||||
anchorTimestamp?: string;
|
||||
}
|
||||
|
||||
export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wasAtBottomRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (wasAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
||||
};
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
{t('tracing.noEvents', 'No trace events to display.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const anchorMs = anchorTimestamp ? new Date(anchorTimestamp).getTime() : new Date(events[0].timestamp).getTime();
|
||||
|
||||
const eventTypeEnum = schema?.enums?.['EventType'] ?? [];
|
||||
const keyEnum = schema?.enums?.['Key'] ?? [];
|
||||
|
||||
function resolveEventLabel(eventType: string): { label: string; explanation?: string } {
|
||||
const entry = eventTypeEnum.find((e) => e.name === eventType);
|
||||
if (entry) return { label: entry.label, explanation: entry.explanation };
|
||||
return { label: eventType };
|
||||
}
|
||||
|
||||
function resolveKeyLabel(key: string): string {
|
||||
const entry = keyEnum.find((e) => e.name === key);
|
||||
return entry?.label ?? key;
|
||||
}
|
||||
|
||||
function eventIcon(eventType: string) {
|
||||
if (
|
||||
eventType.includes('error') ||
|
||||
eventType.includes('failed') ||
|
||||
eventType.includes('invalid') ||
|
||||
eventType.includes('reject')
|
||||
) {
|
||||
return <CircleX className="h-4 w-4 text-red-500" />;
|
||||
}
|
||||
if (eventType.endsWith('-start') || eventType.endsWith('-end')) {
|
||||
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
return <CircleDot className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
} as Intl.DateTimeFormatOptions);
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts: string): string {
|
||||
const ms = new Date(ts).getTime() - anchorMs;
|
||||
if (ms <= 0) return '(+0 ms)';
|
||||
if (ms < 1000) return `(+${Math.round(ms)} ms)`;
|
||||
if (ms < 60_000) return `(+${(ms / 1000).toFixed(1)} s)`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.floor((ms % 60_000) / 1000);
|
||||
return `(+${m} m ${s} s)`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={containerRef} onScroll={handleScroll} className="max-h-[70vh] overflow-y-auto scroll-smooth">
|
||||
<div className="space-y-4 p-2">
|
||||
{events.map((event, idx) => {
|
||||
const { label, explanation } = resolveEventLabel(event.event);
|
||||
const isFallback = !eventTypeEnum.find((e) => e.name === event.event);
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex gap-3">
|
||||
<div className="mt-1 shrink-0">{eventIcon(event.event)}</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
{isFallback ? (
|
||||
<span className="font-mono text-sm bg-muted px-1.5 py-0.5 rounded">{label}</span>
|
||||
) : (
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
)}
|
||||
{explanation && <p className="text-xs text-muted-foreground mt-0.5">{explanation}</p>}
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-xs text-muted-foreground">{formatAbsoluteTime(event.timestamp)}</div>
|
||||
<div className="text-xs text-muted-foreground/60">{formatRelativeTime(event.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.keyValues.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{event.keyValues.map((kv, ki) => (
|
||||
<TraceKeyValueRow
|
||||
key={ki}
|
||||
kv={kv}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-card to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TlsBoolean({ on }: { on: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return <>{on ? t('tracing.enabled', 'enabled') : t('tracing.disabled', 'disabled')}</>;
|
||||
}
|
||||
|
||||
function TraceKeyValueRow({
|
||||
kv,
|
||||
resolveKeyLabel,
|
||||
resolveEventLabel,
|
||||
depth,
|
||||
}: {
|
||||
kv: TraceKeyValue;
|
||||
resolveKeyLabel: (key: string) => string;
|
||||
resolveEventLabel: (event: string) => { label: string; explanation?: string };
|
||||
depth: number;
|
||||
}) {
|
||||
const label = resolveKeyLabel(kv.key);
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 text-sm" style={{ paddingLeft: depth * 16 }}>
|
||||
<span className="shrink-0 text-muted-foreground text-xs min-w-24 text-right pt-0.5">{label}</span>
|
||||
<span className="text-xs pt-0.5">
|
||||
<TraceValueDisplay
|
||||
keyName={kv.key}
|
||||
value={kv.value}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
viewToSection={viewToSection}
|
||||
depth={depth}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceValueDisplay({
|
||||
keyName,
|
||||
value,
|
||||
resolveKeyLabel,
|
||||
resolveEventLabel,
|
||||
viewToSection,
|
||||
depth,
|
||||
}: {
|
||||
keyName: string;
|
||||
value: TraceValue;
|
||||
resolveKeyLabel: (key: string) => string;
|
||||
resolveEventLabel: (event: string) => { label: string; explanation?: string };
|
||||
viewToSection: Record<string, string>;
|
||||
depth: number;
|
||||
}) {
|
||||
switch (value['@type']) {
|
||||
case 'String':
|
||||
return <>{value.value}</>;
|
||||
case 'UnsignedInt':
|
||||
case 'Integer': {
|
||||
const raw = value.value;
|
||||
const numericInput =
|
||||
typeof raw === 'object' && raw !== null && 'source' in (raw as Record<string, unknown>)
|
||||
? ((raw as Record<string, unknown>).source as string)
|
||||
: raw;
|
||||
|
||||
if (isIdKey(keyName)) {
|
||||
const encoded = intToBase32(numericInput as number | string);
|
||||
if (keyName === 'queueId') {
|
||||
const section = viewToSection['x:QueuedMessage'] ?? 'Management';
|
||||
return (
|
||||
<Link to={`/${section}/x:QueuedMessage/${encoded}`} className="text-primary underline hover:no-underline">
|
||||
{encoded}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono">{encoded}</span>;
|
||||
}
|
||||
|
||||
const displayNum = typeof numericInput === 'number' ? numericInput : Number(numericInput);
|
||||
return <>{displayNum.toLocaleString()}</>;
|
||||
}
|
||||
case 'Boolean': {
|
||||
if (keyName === 'tls') {
|
||||
return <TlsBoolean on={value.value} />;
|
||||
}
|
||||
return <>{String(value.value)}</>;
|
||||
}
|
||||
case 'Float':
|
||||
return <>{value.value.toFixed(2)}</>;
|
||||
case 'UTCDateTime':
|
||||
return (
|
||||
<>
|
||||
{new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(value.value))}
|
||||
</>
|
||||
);
|
||||
case 'Duration': {
|
||||
const ms = value.value;
|
||||
if (ms < 1000) return <>{Math.round(ms)} ms</>;
|
||||
if (ms < 60_000) return <>{(ms / 1000).toFixed(1)} s</>;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.floor((ms % 60_000) / 1000);
|
||||
return (
|
||||
<>
|
||||
{m} m {s} s
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'IpAddr':
|
||||
return <>{value.value}</>;
|
||||
case 'List': {
|
||||
if (value.value.length <= 3) {
|
||||
return (
|
||||
<>
|
||||
{value.value.map((v, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
<TraceValueDisplay
|
||||
keyName={keyName}
|
||||
value={v}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
viewToSection={viewToSection}
|
||||
depth={depth}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{value.value.map((v, i) => (
|
||||
<div key={i}>
|
||||
<TraceValueDisplay
|
||||
keyName={keyName}
|
||||
value={v}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
viewToSection={viewToSection}
|
||||
depth={depth}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'Event': {
|
||||
if (depth >= 2) {
|
||||
return (
|
||||
<pre className="text-xs bg-muted/30 p-2 rounded whitespace-pre-wrap">
|
||||
{JSON.stringify({ event: value.event, values: value.value }, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
const { label, explanation } = resolveEventLabel(value.event);
|
||||
return (
|
||||
<div className="mt-1 border-l-2 border-muted pl-3 space-y-1">
|
||||
<div className="text-xs font-semibold">{label}</div>
|
||||
{explanation && <div className="text-xs text-muted-foreground">{explanation}</div>}
|
||||
{value.value.map((kv, i) => (
|
||||
<TraceKeyValueRow
|
||||
key={i}
|
||||
kv={kv}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'Null':
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
default:
|
||||
return <>{JSON.stringify(value)}</>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
export interface Trace {
|
||||
events: TraceEvent[];
|
||||
}
|
||||
|
||||
export interface TraceEvent {
|
||||
event: string;
|
||||
keyValues: TraceKeyValue[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface TraceKeyValue {
|
||||
key: string;
|
||||
value: TraceValue;
|
||||
}
|
||||
|
||||
export type TraceValue =
|
||||
| { '@type': 'String'; value: string }
|
||||
| { '@type': 'UnsignedInt'; value: number }
|
||||
| { '@type': 'Integer'; value: number }
|
||||
| { '@type': 'Boolean'; value: boolean }
|
||||
| { '@type': 'Float'; value: number }
|
||||
| { '@type': 'UTCDateTime'; value: string }
|
||||
| { '@type': 'Duration'; value: number }
|
||||
| { '@type': 'IpAddr'; value: string }
|
||||
| { '@type': 'List'; value: TraceValue[] }
|
||||
| { '@type': 'Event'; event: string; value: TraceKeyValue[] }
|
||||
| { '@type': 'Null' };
|
||||
@@ -0,0 +1,678 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Play,
|
||||
StopCircle,
|
||||
Loader2,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
TriangleAlert,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { apiFetch, getApiBaseUrl } from '@/services/api';
|
||||
import type { DeliveryStage, MX, ReportUri, StageSeverity } from './types';
|
||||
import { stageSeverity } from './types';
|
||||
|
||||
function SimpleAlert({ variant, children }: { variant: 'default' | 'destructive'; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-4 ${
|
||||
variant === 'destructive' ? 'border-destructive/50 bg-destructive/10 text-destructive' : 'border-border bg-card'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TraceState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'starting'; target: string }
|
||||
| { kind: 'running'; target: string; startedAt: Date; events: DeliveryStage[]; eventSource: EventSource }
|
||||
| { kind: 'completed'; target: string; startedAt: Date; completedAt: Date; events: DeliveryStage[] }
|
||||
| { kind: 'failed'; target: string; events: DeliveryStage[]; error: string };
|
||||
|
||||
function phaseKey(stage: DeliveryStage): string {
|
||||
const t = stage.type;
|
||||
if (t.startsWith('mxLookup')) return 'mxLookup';
|
||||
if (t.startsWith('mtaStsFetch') || t === 'mtaStsNotFound') return 'mtaStsFetch';
|
||||
if (t.startsWith('tlsRptLookup') || t === 'tlsRptNotFound') return 'tlsRptLookup';
|
||||
if (t.startsWith('mtaStsVerify')) return 'mtaStsVerify';
|
||||
if (t.startsWith('tlsaLookup') || t === 'tlsaNotFound') return 'tlsaLookup';
|
||||
if (t.startsWith('ipLookup')) return 'ipLookup';
|
||||
if (t.startsWith('connection')) return 'connection';
|
||||
if (t.startsWith('readGreeting')) return 'readGreeting';
|
||||
if (t.startsWith('ehlo')) return 'ehlo';
|
||||
if (t.startsWith('startTls')) return 'startTls';
|
||||
if (t.startsWith('daneVerify')) return 'daneVerify';
|
||||
if (t.startsWith('mailFrom')) return 'mailFrom';
|
||||
if (t.startsWith('rcptTo')) return 'rcptTo';
|
||||
if (t.startsWith('quit')) return 'quit';
|
||||
if (t === 'deliveryAttemptStart') return `attempt-${(stage as { hostname: string }).hostname}`;
|
||||
return t;
|
||||
}
|
||||
|
||||
type TFn = (key: string, fallback: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function phaseLabelFor(t: TFn, pk: string): string {
|
||||
switch (pk) {
|
||||
case 'mxLookup':
|
||||
return t('deliveryTrace.phase.mxLookup', 'MX Lookup');
|
||||
case 'mtaStsFetch':
|
||||
return t('deliveryTrace.phase.mtaStsFetch', 'MTA-STS Policy Fetch');
|
||||
case 'tlsRptLookup':
|
||||
return t('deliveryTrace.phase.tlsRptLookup', 'TLS-RPT Lookup');
|
||||
case 'mtaStsVerify':
|
||||
return t('deliveryTrace.phase.mtaStsVerify', 'MTA-STS Verify');
|
||||
case 'tlsaLookup':
|
||||
return t('deliveryTrace.phase.tlsaLookup', 'TLSA / DANE Lookup');
|
||||
case 'ipLookup':
|
||||
return t('deliveryTrace.phase.ipLookup', 'IP Lookup');
|
||||
case 'connection':
|
||||
return t('deliveryTrace.phase.connection', 'TCP Connection');
|
||||
case 'readGreeting':
|
||||
return t('deliveryTrace.phase.readGreeting', 'Read Greeting');
|
||||
case 'ehlo':
|
||||
return 'EHLO';
|
||||
case 'startTls':
|
||||
return 'STARTTLS';
|
||||
case 'daneVerify':
|
||||
return t('deliveryTrace.phase.daneVerify', 'DANE Verify');
|
||||
case 'mailFrom':
|
||||
return 'MAIL FROM';
|
||||
case 'rcptTo':
|
||||
return 'RCPT TO';
|
||||
case 'quit':
|
||||
return 'QUIT';
|
||||
default:
|
||||
return pk;
|
||||
}
|
||||
}
|
||||
|
||||
function phaseLabel(t: TFn, stage: DeliveryStage): string {
|
||||
if (stage.type === 'deliveryAttemptStart') {
|
||||
return t('deliveryTrace.attemptLabel', 'Delivery Attempt: {{hostname}}', {
|
||||
hostname: (stage as { hostname: string }).hostname,
|
||||
});
|
||||
}
|
||||
const pk = phaseKey(stage);
|
||||
return phaseLabelFor(t, pk);
|
||||
}
|
||||
|
||||
function SeverityIcon({ severity }: { severity: StageSeverity }) {
|
||||
switch (severity) {
|
||||
case 'pending':
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
|
||||
case 'ok':
|
||||
return <CircleCheck className="h-4 w-4 text-emerald-500" />;
|
||||
case 'warn':
|
||||
return <TriangleAlert className="h-4 w-4 text-amber-500" />;
|
||||
case 'error':
|
||||
return <CircleX className="h-4 w-4 text-red-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
return `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
|
||||
function eventBodyText(t: TFn, stage: DeliveryStage): string | null {
|
||||
const stageType = stage.type;
|
||||
switch (stageType) {
|
||||
case 'mxLookupSuccess': {
|
||||
const total = stage.mxs.reduce((n, mx) => n + mx.exchanges.length, 0);
|
||||
return total === 1
|
||||
? t('deliveryTrace.body.mxLookupSuccess_one', 'Resolved {{count}} MX record', { count: total })
|
||||
: t('deliveryTrace.body.mxLookupSuccess_other', 'Resolved {{count}} MX records', { count: total });
|
||||
}
|
||||
case 'mxLookupStart':
|
||||
return t('deliveryTrace.body.mxLookupStart', 'Looking up MX for {{domain}}', { domain: stage.domain });
|
||||
case 'mtaStsFetchSuccess':
|
||||
return t('deliveryTrace.body.mtaStsFetchSuccess', 'Policy fetched successfully');
|
||||
case 'mtaStsNotFound':
|
||||
return t('deliveryTrace.body.mtaStsNotFound', 'No MTA-STS policy published');
|
||||
case 'tlsRptLookupSuccess': {
|
||||
const n = stage.rua.length;
|
||||
return n === 1
|
||||
? t('deliveryTrace.body.tlsRptLookupSuccess_one', 'Found {{count}} reporting URI', { count: n })
|
||||
: t('deliveryTrace.body.tlsRptLookupSuccess_other', 'Found {{count}} reporting URIs', { count: n });
|
||||
}
|
||||
case 'tlsRptNotFound':
|
||||
return t('deliveryTrace.body.tlsRptNotFound', 'No TLS-RPT record published');
|
||||
case 'ipLookupSuccess': {
|
||||
const ips = stage.remoteIps;
|
||||
return ips.length <= 3
|
||||
? ips.join(', ')
|
||||
: t('deliveryTrace.body.ipLookupOverflow', '{{shown}} +{{extra}} more', {
|
||||
shown: ips.slice(0, 3).join(', '),
|
||||
extra: ips.length - 3,
|
||||
});
|
||||
}
|
||||
case 'connectionStart':
|
||||
return t('deliveryTrace.body.connectionStart', 'Connecting to {{ip}}', { ip: stage.remoteIp });
|
||||
case 'connectionSuccess':
|
||||
return t('deliveryTrace.body.connectionSuccess', 'Connected');
|
||||
case 'tlsaLookupSuccess':
|
||||
return t('deliveryTrace.body.tlsaLookupSuccess', 'TLSA record found');
|
||||
case 'tlsaNotFound':
|
||||
return stage.reason || t('deliveryTrace.body.tlsaNotFound', 'No TLSA record found');
|
||||
case 'readGreetingSuccess':
|
||||
return t('deliveryTrace.body.readGreetingSuccess', 'Server greeting received');
|
||||
case 'ehloSuccess':
|
||||
return t('deliveryTrace.body.ehloSuccess', 'EHLO accepted');
|
||||
case 'startTlsSuccess':
|
||||
return t('deliveryTrace.body.startTlsSuccess', 'TLS negotiated');
|
||||
case 'daneVerifySuccess':
|
||||
return t('deliveryTrace.body.daneVerifySuccess', 'DANE verification passed');
|
||||
case 'mtaStsVerifySuccess':
|
||||
return t('deliveryTrace.body.mtaStsVerifySuccess', 'MTA-STS hostname verified');
|
||||
case 'mailFromSuccess':
|
||||
return t('deliveryTrace.body.mailFromSuccess', 'Sender accepted');
|
||||
case 'rcptToSuccess':
|
||||
return t('deliveryTrace.body.rcptToSuccess', 'Recipient accepted');
|
||||
case 'quitCompleted':
|
||||
return t('deliveryTrace.body.quitCompleted', 'Session closed');
|
||||
default:
|
||||
if ('reason' in stage && typeof (stage as { reason?: string }).reason === 'string') {
|
||||
return (stage as { reason: string }).reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function MxTable({ mxs }: { mxs: MX[] }) {
|
||||
const sorted = [...mxs].sort((a, b) => a.preference - b.preference);
|
||||
return (
|
||||
<div className="mt-1 rounded border text-xs">
|
||||
{sorted.map((mx, i) =>
|
||||
mx.exchanges.map((ex, j) => (
|
||||
<div key={`${i}-${j}`} className="flex gap-3 px-3 py-1 border-b last:border-b-0">
|
||||
<span className="w-8 text-right text-muted-foreground">{mx.preference}</span>
|
||||
<span>{ex}</span>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportUris({ rua }: { rua: ReportUri[] }) {
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
{rua.map((uri, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
{uri.type === 'mail' ? (
|
||||
<>
|
||||
<Mail className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{uri.email}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{uri.url}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpaqueObject({ data }: { data: Record<string, unknown> }) {
|
||||
return (
|
||||
<div className="mt-1 rounded border bg-muted/30 p-2">
|
||||
<dl className="space-y-1 text-xs">
|
||||
{Object.entries(data).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2">
|
||||
<dt className="font-medium text-muted-foreground min-w-24">{k}</dt>
|
||||
<dd className="break-all">
|
||||
{typeof v === 'object' && v !== null ? (
|
||||
<pre className="text-xs whitespace-pre-wrap">{JSON.stringify(v, null, 2)}</pre>
|
||||
) : (
|
||||
String(v)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ stage }: { stage: DeliveryStage }) {
|
||||
const { t } = useTranslation();
|
||||
const severity = stageSeverity(stage);
|
||||
const body = eventBodyText(t, stage);
|
||||
const elapsed = 'elapsed' in stage ? (stage as { elapsed: number }).elapsed : null;
|
||||
const hasDetails =
|
||||
stage.type === 'mxLookupSuccess' ||
|
||||
stage.type === 'mtaStsFetchSuccess' ||
|
||||
stage.type === 'tlsRptLookupSuccess' ||
|
||||
stage.type === 'tlsaLookupSuccess';
|
||||
|
||||
const content = (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<SeverityIcon severity={severity} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{phaseLabel(t, stage)}</span>
|
||||
{elapsed !== null && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatElapsed(elapsed)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{body && (
|
||||
<p className={`text-xs mt-0.5 ${severity === 'error' ? 'text-red-500' : 'text-muted-foreground'}`}>{body}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!hasDetails) return content;
|
||||
|
||||
return (
|
||||
<Collapsible>
|
||||
{content}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-7">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
{t('deliveryTrace.viewDetails', 'View details')}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="ml-7">
|
||||
{stage.type === 'mxLookupSuccess' && <MxTable mxs={stage.mxs} />}
|
||||
{stage.type === 'mtaStsFetchSuccess' && <OpaqueObject data={stage.policy} />}
|
||||
{stage.type === 'tlsRptLookupSuccess' && <ReportUris rua={stage.rua} />}
|
||||
{stage.type === 'tlsaLookupSuccess' && <OpaqueObject data={stage.record} />}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function useRelativeTime(startedAtMs: number | null): string {
|
||||
const { t } = useTranslation();
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAtMs === null) return;
|
||||
const id = setInterval(() => setTick((prev) => prev + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [startedAtMs]);
|
||||
|
||||
if (startedAtMs === null) return '';
|
||||
const s = tick;
|
||||
if (s < 60) return t('deliveryTrace.startedAgoSec', 'Started {{s}}s ago', { s });
|
||||
return t('deliveryTrace.startedAgoMin', 'Started {{m}}m {{s}}s ago', {
|
||||
m: Math.floor(s / 60),
|
||||
s: s % 60,
|
||||
});
|
||||
}
|
||||
|
||||
function isValidTarget(target: string): boolean {
|
||||
target = target.trim();
|
||||
if (!target) return false;
|
||||
if (target.includes('@')) {
|
||||
const [local, domain] = target.split('@');
|
||||
return local.length > 0 && domain.length > 0 && domain.includes('.');
|
||||
}
|
||||
return target.includes('.');
|
||||
}
|
||||
|
||||
export function DeliveryTracePage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [inputValue, setInputValue] = useState(searchParams.get('target') ?? '');
|
||||
const [inputError, setInputError] = useState<string | null>(null);
|
||||
const [state, setState] = useState<TraceState>({ kind: 'idle' });
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const relTime = useRelativeTime(state.kind === 'running' ? state.startedAt.getTime() : null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startTrace = useCallback(async () => {
|
||||
const target = inputValue.trim();
|
||||
if (!isValidTarget(target)) {
|
||||
setInputError(t('deliveryTrace.invalidTarget', 'Enter a valid email address or domain.'));
|
||||
return;
|
||||
}
|
||||
setInputError(null);
|
||||
|
||||
setState({ kind: 'starting', target });
|
||||
|
||||
try {
|
||||
const tokenResponse = await apiFetch('/api/token/delivery');
|
||||
const token = await tokenResponse.text();
|
||||
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/live/delivery/${encodeURIComponent(target)}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
const events: DeliveryStage[] = [];
|
||||
const startedAt = new Date();
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setState({
|
||||
kind: 'failed',
|
||||
target,
|
||||
events: [...events],
|
||||
error: t('deliveryTrace.timedOut', 'Trace timed out after 120 seconds.'),
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
es.addEventListener('event', (event) => {
|
||||
try {
|
||||
const batch = JSON.parse(event.data) as DeliveryStage[];
|
||||
for (const stage of batch) {
|
||||
if (stage.type === 'completed') {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setState({
|
||||
kind: 'completed',
|
||||
target,
|
||||
startedAt,
|
||||
completedAt: new Date(),
|
||||
events: [...events],
|
||||
});
|
||||
return;
|
||||
}
|
||||
events.push(stage);
|
||||
}
|
||||
setState({
|
||||
kind: 'running',
|
||||
target,
|
||||
startedAt,
|
||||
events: [...events],
|
||||
eventSource: es,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse delivery event:', e);
|
||||
}
|
||||
});
|
||||
|
||||
es.onopen = () => {
|
||||
setState({
|
||||
kind: 'running',
|
||||
target,
|
||||
startedAt,
|
||||
events: [],
|
||||
eventSource: es,
|
||||
});
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setState((prev) => {
|
||||
if (prev.kind === 'completed') return prev;
|
||||
return {
|
||||
kind: 'failed',
|
||||
target,
|
||||
events: prev.kind === 'running' ? prev.events : [],
|
||||
error: t('deliveryTrace.connectionLost', 'Connection lost before trace completed.'),
|
||||
};
|
||||
});
|
||||
};
|
||||
} catch (e) {
|
||||
setState({
|
||||
kind: 'failed',
|
||||
target,
|
||||
events: [],
|
||||
error: e instanceof Error ? e.message : t('deliveryTrace.failedToStart', 'Failed to start trace.'),
|
||||
});
|
||||
}
|
||||
}, [inputValue, t]);
|
||||
|
||||
const cancelTrace = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setState({ kind: 'idle' });
|
||||
}, []);
|
||||
|
||||
const resetToIdle = useCallback(() => {
|
||||
setState({ kind: 'idle' });
|
||||
}, []);
|
||||
|
||||
const rawEvents = useMemo(() => (state.kind === 'idle' || state.kind === 'starting' ? [] : state.events), [state]);
|
||||
const pairedEvents = useMemo(() => {
|
||||
const result: DeliveryStage[] = [];
|
||||
const pendingByPhase = new Map<string, number>();
|
||||
|
||||
for (const evt of rawEvents) {
|
||||
const pk = phaseKey(evt);
|
||||
const severity = stageSeverity(evt);
|
||||
|
||||
if (severity === 'pending') {
|
||||
pendingByPhase.set(pk, result.length);
|
||||
result.push(evt);
|
||||
} else {
|
||||
const pendingIdx = pendingByPhase.get(pk);
|
||||
if (pendingIdx !== undefined) {
|
||||
result[pendingIdx] = evt;
|
||||
pendingByPhase.delete(pk);
|
||||
} else {
|
||||
result.push(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [rawEvents]);
|
||||
|
||||
if (state.kind === 'idle' || state.kind === 'starting') {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6 pt-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('deliveryTrace.title', 'Delivery Trace')}</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t(
|
||||
'deliveryTrace.subtitle',
|
||||
'Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t('deliveryTrace.targetLabel', 'Target address or domain')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('deliveryTrace.targetPlaceholder', 'john@example.org or example.org')}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value);
|
||||
setInputError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') startTrace();
|
||||
}}
|
||||
autoFocus
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={startTrace} disabled={state.kind === 'starting'}>
|
||||
{state.kind === 'starting' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">{t('deliveryTrace.startTrace', 'Start trace')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{inputError && <p className="text-xs text-destructive">{inputError}</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const events = pairedEvents;
|
||||
const target = state.target;
|
||||
const isRunning = state.kind === 'running';
|
||||
const isCompleted = state.kind === 'completed';
|
||||
const isFailed = state.kind === 'failed';
|
||||
|
||||
const groups: { attempt: DeliveryStage | null; events: DeliveryStage[] }[] = [];
|
||||
let currentGroup: { attempt: DeliveryStage | null; events: DeliveryStage[] } = {
|
||||
attempt: null,
|
||||
events: [],
|
||||
};
|
||||
for (const evt of events) {
|
||||
if (evt.type === 'deliveryAttemptStart') {
|
||||
if (currentGroup.events.length > 0 || currentGroup.attempt) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
currentGroup = { attempt: evt, events: [] };
|
||||
} else {
|
||||
currentGroup.events.push(evt);
|
||||
}
|
||||
}
|
||||
if (currentGroup.events.length > 0 || currentGroup.attempt) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
const attemptCount = events.filter((e) => e.type === 'deliveryAttemptStart').length;
|
||||
const lastEvent = events[events.length - 1];
|
||||
const hasSuccessfulDelivery = lastEvent?.type === 'quitCompleted' || events.some((e) => e.type === 'rcptToSuccess');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isRunning
|
||||
? t('deliveryTrace.tracingDelivery', 'Tracing delivery to {{target}}', { target })
|
||||
: isCompleted
|
||||
? t('deliveryTrace.traceCompletedFor', 'Trace completed for {{target}}', { target })
|
||||
: t('deliveryTrace.traceFailedFor', 'Trace failed for {{target}}', { target })}
|
||||
</h2>
|
||||
{isRunning && <p className="text-sm text-muted-foreground">{relTime}</p>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isRunning && (
|
||||
<Button variant="outline" onClick={cancelTrace}>
|
||||
<StopCircle className="mr-2 h-4 w-4" />
|
||||
{t('deliveryTrace.cancelTrace', 'Cancel trace')}
|
||||
</Button>
|
||||
)}
|
||||
{(isCompleted || isFailed) && (
|
||||
<Button variant="outline" onClick={resetToIdle}>
|
||||
{t('deliveryTrace.runAnother', 'Run another trace')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCompleted && (
|
||||
<SimpleAlert variant="default">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasSuccessfulDelivery ? (
|
||||
<CircleCheck className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<CircleX className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{hasSuccessfulDelivery
|
||||
? t('deliveryTrace.deliverySuccessful', 'Delivery successful')
|
||||
: t('deliveryTrace.deliveryFailed', 'Delivery failed')}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{attemptCount === 1
|
||||
? t('deliveryTrace.attemptCount_one', '{{count}} attempt', { count: attemptCount })
|
||||
: t('deliveryTrace.attemptCount_other', '{{count}} attempts', { count: attemptCount })}
|
||||
{state.kind === 'completed' && (
|
||||
<>
|
||||
{' '}
|
||||
{t('deliveryTrace.inDuration', 'in {{elapsed}}', {
|
||||
elapsed: formatElapsed(state.completedAt.getTime() - state.startedAt.getTime()),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</SimpleAlert>
|
||||
)}
|
||||
|
||||
{isFailed && (
|
||||
<SimpleAlert variant="destructive">
|
||||
<p className="text-sm">{state.error}</p>
|
||||
</SimpleAlert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-0">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={gi}>
|
||||
{group.attempt && (
|
||||
<div className="mt-3 mb-1">
|
||||
<EventRow stage={group.attempt} />
|
||||
</div>
|
||||
)}
|
||||
<div className={group.attempt ? 'ml-6 border-l-2 border-muted/40 pl-4' : ''}>
|
||||
{group.events.map((evt, ei) => (
|
||||
<EventRow key={`${gi}-${ei}`} stage={evt} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isRunning && (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex gap-1">
|
||||
<span className="animate-pulse">.</span>
|
||||
<span className="animate-pulse" style={{ animationDelay: '0.2s' }}>
|
||||
.
|
||||
</span>
|
||||
<span className="animate-pulse" style={{ animationDelay: '0.4s' }}>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
{t('deliveryTrace.waiting', 'Waiting for more events')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
export type DeliveryStage =
|
||||
| { type: 'mxLookupStart'; domain: string }
|
||||
| { type: 'mxLookupSuccess'; mxs: MX[]; elapsed: number }
|
||||
| { type: 'mxLookupError'; reason: string; elapsed: number }
|
||||
| { type: 'mtaStsFetchStart' }
|
||||
| { type: 'mtaStsFetchSuccess'; policy: Policy; elapsed: number }
|
||||
| { type: 'mtaStsFetchError'; reason: string; elapsed: number }
|
||||
| { type: 'mtaStsNotFound'; elapsed: number }
|
||||
| { type: 'tlsRptLookupStart' }
|
||||
| { type: 'tlsRptLookupSuccess'; rua: ReportUri[]; elapsed: number }
|
||||
| { type: 'tlsRptLookupError'; reason: string; elapsed: number }
|
||||
| { type: 'tlsRptNotFound'; elapsed: number }
|
||||
| { type: 'deliveryAttemptStart'; hostname: string }
|
||||
| { type: 'mtaStsVerifySuccess' }
|
||||
| { type: 'mtaStsVerifyError'; reason: string }
|
||||
| { type: 'tlsaLookupStart' }
|
||||
| { type: 'tlsaLookupSuccess'; record: Tlsa; elapsed: number }
|
||||
| { type: 'tlsaNotFound'; elapsed: number; reason: string }
|
||||
| { type: 'tlsaLookupError'; elapsed: number; reason: string }
|
||||
| { type: 'ipLookupStart' }
|
||||
| { type: 'ipLookupSuccess'; remoteIps: string[]; elapsed: number }
|
||||
| { type: 'ipLookupError'; reason: string; elapsed: number }
|
||||
| { type: 'connectionStart'; remoteIp: string }
|
||||
| { type: 'connectionSuccess'; elapsed: number }
|
||||
| { type: 'connectionError'; elapsed: number; reason: string }
|
||||
| { type: 'readGreetingStart' }
|
||||
| { type: 'readGreetingSuccess'; elapsed: number }
|
||||
| { type: 'readGreetingError'; elapsed: number; reason: string }
|
||||
| { type: 'ehloStart' }
|
||||
| { type: 'ehloSuccess'; elapsed: number }
|
||||
| { type: 'ehloError'; elapsed: number; reason: string }
|
||||
| { type: 'startTlsStart' }
|
||||
| { type: 'startTlsSuccess'; elapsed: number }
|
||||
| { type: 'startTlsError'; elapsed: number; reason: string }
|
||||
| { type: 'daneVerifySuccess' }
|
||||
| { type: 'daneVerifyError'; reason: string }
|
||||
| { type: 'mailFromStart' }
|
||||
| { type: 'mailFromSuccess'; elapsed: number }
|
||||
| { type: 'mailFromError'; reason: string; elapsed: number }
|
||||
| { type: 'rcptToStart' }
|
||||
| { type: 'rcptToSuccess'; elapsed: number }
|
||||
| { type: 'rcptToError'; reason: string; elapsed: number }
|
||||
| { type: 'quitStart' }
|
||||
| { type: 'quitCompleted'; elapsed: number }
|
||||
| { type: 'completed' };
|
||||
|
||||
export interface MX {
|
||||
exchanges: string[];
|
||||
preference: number;
|
||||
}
|
||||
|
||||
export type ReportUri = { type: 'mail'; email: string } | { type: 'http'; url: string };
|
||||
|
||||
export type Policy = Record<string, unknown>;
|
||||
export type Tlsa = Record<string, unknown>;
|
||||
|
||||
export type StageSeverity = 'pending' | 'ok' | 'warn' | 'error';
|
||||
|
||||
export function stageSeverity(stage: DeliveryStage): StageSeverity {
|
||||
const t = stage.type;
|
||||
if (t === 'completed') return 'ok';
|
||||
if (t === 'deliveryAttemptStart') return 'ok';
|
||||
if (t.endsWith('Error')) return 'error';
|
||||
if (t.endsWith('NotFound')) return 'warn';
|
||||
if (t.endsWith('Start')) return 'pending';
|
||||
return 'ok';
|
||||
}
|
||||
Reference in New Issue
Block a user