Initial commit

This commit is contained in:
Maurus Decimus
2026-04-20 15:02:57 +02:00
parent e72c9614f1
commit 4470616775
115 changed files with 26207 additions and 21 deletions
+483
View File
@@ -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>
);
}
+246
View File
@@ -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 };
});
},
}));
+48
View File
@@ -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;
+44
View File
@@ -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">&mdash;</span>;
default:
return <>{JSON.stringify(value)}</>;
}
}
+33
View File
@@ -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>
);
}
+73
View File
@@ -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';
}