Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { extractQueryTerms, filterSessionsByQuery } from "../usage-helpers.ts";
import {
buildAggregatesFromSessions,
buildPeakErrorHours,
buildUsageInsightStats,
formatCost,
formatIsoDate,
formatTokens,
getZonedHour,
renderUsageMosaic,
setToHourEnd,
} from "./usage-metrics.ts";
import {
addQueryToken,
applySuggestionToQuery,
buildDailyCsv,
buildQuerySuggestions,
buildSessionsCsv,
downloadTextFile,
normalizeQueryText,
removeQueryToken,
setQueryTokensForKey,
} from "./usage-query.ts";
import { renderSessionDetailPanel } from "./usage-render-details.ts";
import {
renderCostBreakdownCompact,
renderDailyChartCompact,
renderFilterChips,
renderSessionsCard,
renderUsageInsights,
} from "./usage-render-overview.ts";
import {
SessionLogEntry,
SessionLogRole,
UsageColumnId,
UsageFilterState,
UsageProps,
UsageSessionEntry,
UsageTotals,
} from "./usageTypes.ts";
export type { UsageColumnId, SessionLogEntry, SessionLogRole };
function createEmptyUsageTotals(): UsageTotals {
return {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
};
}
function addUsageTotals(
acc: UsageTotals,
usage: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
totalCost: number;
inputCost?: number;
outputCost?: number;
cacheReadCost?: number;
cacheWriteCost?: number;
missingCostEntries?: number;
},
): UsageTotals {
acc.input += usage.input;
acc.output += usage.output;
acc.cacheRead += usage.cacheRead;
acc.cacheWrite += usage.cacheWrite;
acc.totalTokens += usage.totalTokens;
acc.totalCost += usage.totalCost;
acc.inputCost += usage.inputCost ?? 0;
acc.outputCost += usage.outputCost ?? 0;
acc.cacheReadCost += usage.cacheReadCost ?? 0;
acc.cacheWriteCost += usage.cacheWriteCost ?? 0;
acc.missingCostEntries += usage.missingCostEntries ?? 0;
return acc;
}
function renderUsageLoadingState(filters: UsageFilterState) {
return html`
<section class="card usage-loading-card">
<div class="usage-loading-header">
<div class="usage-loading-title-group">
<div class="card-title usage-section-title">${t("usage.loading.title")}</div>
<span class="usage-loading-badge">
<span class="usage-loading-spinner" aria-hidden="true"></span>
${t("usage.loading.badge")}
</span>
</div>
<div class="usage-loading-controls">
<div class="usage-date-range usage-date-range--loading">
<input class="usage-date-input" type="date" .value=${filters.startDate} disabled />
<span class="usage-separator">${t("usage.filters.to")}</span>
<input class="usage-date-input" type="date" .value=${filters.endDate} disabled />
</div>
</div>
</div>
<div class="usage-loading-grid">
<div class="usage-skeleton-block usage-skeleton-block--tall"></div>
<div class="usage-skeleton-block"></div>
<div class="usage-skeleton-block"></div>
</div>
</section>
`;
}
function renderUsageEmptyState(onRefresh: () => void) {
return html`
<section class="card usage-empty-state">
<div class="usage-empty-state__title">${t("usage.empty.title")}</div>
<div class="card-sub usage-empty-state__subtitle">${t("usage.empty.subtitle")}</div>
<div class="usage-empty-state__features">
<span class="usage-empty-state__feature">${t("usage.empty.featureOverview")}</span>
<span class="usage-empty-state__feature">${t("usage.empty.featureSessions")}</span>
<span class="usage-empty-state__feature">${t("usage.empty.featureTimeline")}</span>
</div>
<div class="usage-empty-state__actions">
<button class="btn primary" @click=${onRefresh}>${t("common.refresh")}</button>
</div>
</section>
`;
}
export function renderUsage(props: UsageProps) {
const { data, filters, display, detail, callbacks } = props;
const filterActions = callbacks.filters;
const displayActions = callbacks.display;
const detailActions = callbacks.details;
if (data.loading && !data.totals) {
return html`<div class="usage-page">${renderUsageLoadingState(filters)}</div>`;
}
const isTokenMode = display.chartMode === "tokens";
const hasQuery = filters.query.trim().length > 0;
const hasDraftQuery = filters.queryDraft.trim().length > 0;
// Sort sessions by tokens or cost depending on mode
const sortedSessions = [...data.sessions].toSorted((a, b) => {
const valA = isTokenMode ? (a.usage?.totalTokens ?? 0) : (a.usage?.totalCost ?? 0);
const valB = isTokenMode ? (b.usage?.totalTokens ?? 0) : (b.usage?.totalCost ?? 0);
return valB - valA;
});
// Filter sessions by selected days
const dayFilteredSessions =
filters.selectedDays.length > 0
? sortedSessions.filter((s) => {
if (s.usage?.activityDates?.length) {
return s.usage.activityDates.some((d) => filters.selectedDays.includes(d));
}
if (!s.updatedAt) {
return false;
}
const d = new Date(s.updatedAt);
const sessionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return filters.selectedDays.includes(sessionDate);
})
: sortedSessions;
const sessionTouchesHours = (session: UsageSessionEntry, hours: number[]): boolean => {
if (hours.length === 0) {
return true;
}
const usage = session.usage;
const start = usage?.firstActivity ?? session.updatedAt;
const end = usage?.lastActivity ?? session.updatedAt;
if (!start || !end) {
return false;
}
const startMs = Math.min(start, end);
const endMs = Math.max(start, end);
let cursor = startMs;
while (cursor <= endMs) {
const date = new Date(cursor);
const hour = getZonedHour(date, filters.timeZone);
if (hours.includes(hour)) {
return true;
}
const nextHour = setToHourEnd(date, filters.timeZone);
const nextMs = Math.min(nextHour.getTime(), endMs);
cursor = nextMs + 1;
}
return false;
};
const hourFilteredSessions =
filters.selectedHours.length > 0
? dayFilteredSessions.filter((s) => sessionTouchesHours(s, filters.selectedHours))
: dayFilteredSessions;
// Filter sessions by query (client-side)
const queryResult = filterSessionsByQuery(hourFilteredSessions, filters.query);
const filteredSessions = queryResult.sessions;
const queryWarnings = queryResult.warnings;
const querySuggestions = buildQuerySuggestions(
filters.queryDraft,
sortedSessions,
data.aggregates,
);
const queryTerms = extractQueryTerms(filters.query);
const selectedValuesFor = (key: string): string[] => {
const normalized = normalizeQueryText(key);
return queryTerms
.filter((term) => normalizeQueryText(term.key ?? "") === normalized)
.map((term) => term.value)
.filter(Boolean);
};
const unique = (items: Array<string | undefined>) => {
const set = new Set<string>();
for (const item of items) {
if (item) {
set.add(item);
}
}
return Array.from(set);
};
const agentOptions = unique(sortedSessions.map((s) => s.agentId)).slice(0, 12);
const channelOptions = unique(sortedSessions.map((s) => s.channel)).slice(0, 12);
const providerOptions = unique([
...sortedSessions.map((s) => s.modelProvider),
...sortedSessions.map((s) => s.providerOverride),
...(data.aggregates?.byProvider.map((entry) => entry.provider) ?? []),
]).slice(0, 12);
const modelOptions = unique([
...sortedSessions.map((s) => s.model),
...(data.aggregates?.byModel.map((entry) => entry.model) ?? []),
]).slice(0, 12);
const toolOptions = unique(data.aggregates?.tools.tools.map((tool) => tool.name) ?? []).slice(
0,
12,
);
// Get first selected session for detail view (timeseries, logs)
const primarySelectedEntry =
filters.selectedSessions.length === 1
? (data.sessions.find((s) => s.key === filters.selectedSessions[0]) ??
filteredSessions.find((s) => s.key === filters.selectedSessions[0]))
: null;
// Compute totals from sessions
const computeSessionTotals = (sessions: UsageSessionEntry[]): UsageTotals => {
return sessions.reduce(
(acc, s) => (s.usage ? addUsageTotals(acc, s.usage) : acc),
createEmptyUsageTotals(),
);
};
// Compute totals from daily data for selected days (more accurate than session totals)
const computeDailyTotals = (days: string[]): UsageTotals => {
const matchingDays = data.costDaily.filter((d) => days.includes(d.date));
return matchingDays.reduce((acc, day) => addUsageTotals(acc, day), createEmptyUsageTotals());
};
// Compute display totals and count based on filters
let displayTotals: UsageTotals | null;
let displaySessionCount: number;
const totalSessions = sortedSessions.length;
if (filters.selectedSessions.length > 0) {
// Sessions selected - compute totals from selected sessions
const selectedSessionEntries = filteredSessions.filter((s) =>
filters.selectedSessions.includes(s.key),
);
displayTotals = computeSessionTotals(selectedSessionEntries);
displaySessionCount = selectedSessionEntries.length;
} else if (filters.selectedDays.length > 0 && filters.selectedHours.length === 0) {
// Days selected - use daily aggregates for accurate per-day totals
displayTotals = computeDailyTotals(filters.selectedDays);
displaySessionCount = filteredSessions.length;
} else if (filters.selectedHours.length > 0) {
displayTotals = computeSessionTotals(filteredSessions);
displaySessionCount = filteredSessions.length;
} else if (hasQuery) {
displayTotals = computeSessionTotals(filteredSessions);
displaySessionCount = filteredSessions.length;
} else {
// No filters - show all
displayTotals = data.totals;
displaySessionCount = totalSessions;
}
const aggregateSessions =
filters.selectedSessions.length > 0
? filteredSessions.filter((s) => filters.selectedSessions.includes(s.key))
: hasQuery || filters.selectedHours.length > 0
? filteredSessions
: filters.selectedDays.length > 0
? dayFilteredSessions
: sortedSessions;
const activeAggregates = buildAggregatesFromSessions(aggregateSessions, data.aggregates);
// Filter daily chart data if sessions are selected
const filteredDaily =
filters.selectedSessions.length > 0
? (() => {
const selectedEntries = filteredSessions.filter((s) =>
filters.selectedSessions.includes(s.key),
);
const allActivityDates = new Set<string>();
for (const entry of selectedEntries) {
for (const date of entry.usage?.activityDates ?? []) {
allActivityDates.add(date);
}
}
return allActivityDates.size > 0
? data.costDaily.filter((d) => allActivityDates.has(d.date))
: data.costDaily;
})()
: data.costDaily;
const insightStats = buildUsageInsightStats(aggregateSessions, displayTotals, activeAggregates);
const isEmpty = !data.loading && !data.totals && data.sessions.length === 0;
const hasMissingCost =
(displayTotals?.missingCostEntries ?? 0) > 0 ||
(displayTotals
? displayTotals.totalTokens > 0 &&
displayTotals.totalCost === 0 &&
displayTotals.input +
displayTotals.output +
displayTotals.cacheRead +
displayTotals.cacheWrite >
0
: false);
const datePresets = [
{ label: t("usage.presets.today"), days: 1 },
{ label: t("usage.presets.last7d"), days: 7 },
{ label: t("usage.presets.last30d"), days: 30 },
];
const applyPreset = (days: number) => {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - (days - 1));
filterActions.onStartDateChange(formatIsoDate(start));
filterActions.onEndDateChange(formatIsoDate(end));
};
const renderFilterSelect = (key: string, label: string, options: string[]) => {
if (options.length === 0) {
return nothing;
}
const selected = selectedValuesFor(key);
const selectedSet = new Set(selected.map((value) => normalizeQueryText(value)));
const allSelected =
options.length > 0 && options.every((value) => selectedSet.has(normalizeQueryText(value)));
const selectedCount = selected.length;
return html`
<details
class="usage-filter-select"
@toggle=${(e: Event) => {
const el = e.currentTarget as HTMLDetailsElement;
if (!el.open) {
return;
}
const onClick = (ev: MouseEvent) => {
const path = ev.composedPath();
if (!path.includes(el)) {
el.open = false;
window.removeEventListener("click", onClick, true);
}
};
window.addEventListener("click", onClick, true);
}}
>
<summary>
<span>${label}</span>
${selectedCount > 0
? html`<span class="usage-filter-badge">${selectedCount}</span>`
: html` <span class="usage-filter-badge">${t("usage.filters.all")}</span> `}
</summary>
<div class="usage-filter-popover">
<div class="usage-filter-actions">
<button
class="btn btn--sm"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
filterActions.onQueryDraftChange(
setQueryTokensForKey(filters.queryDraft, key, options),
);
}}
?disabled=${allSelected}
>
${t("usage.filters.selectAll")}
</button>
<button
class="btn btn--sm"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
filterActions.onQueryDraftChange(setQueryTokensForKey(filters.queryDraft, key, []));
}}
?disabled=${selectedCount === 0}
>
${t("usage.filters.clear")}
</button>
</div>
<div class="usage-filter-options">
${options.map((value) => {
const checked = selectedSet.has(normalizeQueryText(value));
return html`
<label class="usage-filter-option">
<input
type="checkbox"
.checked=${checked}
@change=${(e: Event) => {
const target = e.target as HTMLInputElement;
const token = `${key}:${value}`;
filterActions.onQueryDraftChange(
target.checked
? addQueryToken(filters.queryDraft, token)
: removeQueryToken(filters.queryDraft, token),
);
}}
/>
<span>${value}</span>
</label>
`;
})}
</div>
</div>
</details>
`;
};
const exportStamp = formatIsoDate(new Date());
return html`
<div class="usage-page">
<section class="usage-page-header">
<div class="usage-page-title">${t("tabs.usage")}</div>
<div class="usage-page-subtitle">${t("usage.page.subtitle")}</div>
</section>
<section class="card usage-header ${display.headerPinned ? "pinned" : ""}">
<div class="usage-header-row">
<div class="usage-header-title">
<div class="card-title usage-section-title">${t("usage.filters.title")}</div>
${data.loading
? html`<span class="usage-refresh-indicator">${t("usage.loading.badge")}</span>`
: nothing}
${isEmpty
? html`<span class="usage-query-hint">${t("usage.empty.hint")}</span>`
: nothing}
</div>
<div class="usage-header-metrics">
${displayTotals
? html`
<span class="usage-metric-badge">
<strong>${formatTokens(displayTotals.totalTokens)}</strong>
${t("usage.metrics.tokens")}
</span>
<span class="usage-metric-badge">
<strong>${formatCost(displayTotals.totalCost)}</strong>
${t("usage.metrics.cost")}
</span>
<span class="usage-metric-badge">
<strong>${displaySessionCount}</strong>
${displaySessionCount === 1
? t("usage.metrics.session")
: t("usage.metrics.sessions")}
</span>
`
: nothing}
<button
class="btn btn--sm usage-pin-btn ${display.headerPinned ? "active" : ""}"
title=${display.headerPinned ? t("usage.filters.unpin") : t("usage.filters.pin")}
@click=${filterActions.onToggleHeaderPinned}
>
${display.headerPinned ? t("usage.filters.pinned") : t("usage.filters.pin")}
</button>
<details
class="usage-export-menu"
@toggle=${(e: Event) => {
const el = e.currentTarget as HTMLDetailsElement;
if (!el.open) {
return;
}
const onClick = (ev: MouseEvent) => {
const path = ev.composedPath();
if (!path.includes(el)) {
el.open = false;
window.removeEventListener("click", onClick, true);
}
};
window.addEventListener("click", onClick, true);
}}
>
<summary class="btn btn--sm">${t("usage.export.label")} ▾</summary>
<div class="usage-export-popover">
<div class="usage-export-list">
<button
class="usage-export-item"
@click=${() =>
downloadTextFile(
`openclaw-usage-sessions-${exportStamp}.csv`,
buildSessionsCsv(filteredSessions),
"text/csv",
)}
?disabled=${filteredSessions.length === 0}
>
${t("usage.export.sessionsCsv")}
</button>
<button
class="usage-export-item"
@click=${() =>
downloadTextFile(
`openclaw-usage-daily-${exportStamp}.csv`,
buildDailyCsv(filteredDaily),
"text/csv",
)}
?disabled=${filteredDaily.length === 0}
>
${t("usage.export.dailyCsv")}
</button>
<button
class="usage-export-item"
@click=${() =>
downloadTextFile(
`openclaw-usage-${exportStamp}.json`,
JSON.stringify(
{
totals: displayTotals,
sessions: filteredSessions,
daily: filteredDaily,
aggregates: activeAggregates,
},
null,
2,
),
"application/json",
)}
?disabled=${filteredSessions.length === 0 && filteredDaily.length === 0}
>
${t("usage.export.json")}
</button>
</div>
</div>
</details>
</div>
</div>
<div class="usage-header-row">
<div class="usage-controls">
${renderFilterChips(
filters.selectedDays,
filters.selectedHours,
filters.selectedSessions,
data.sessions,
filterActions.onClearDays,
filterActions.onClearHours,
filterActions.onClearSessions,
filterActions.onClearFilters,
)}
<div class="usage-presets">
${datePresets.map(
(preset) => html`
<button class="btn btn--sm" @click=${() => applyPreset(preset.days)}>
${preset.label}
</button>
`,
)}
</div>
<div class="usage-date-range">
<input
class="usage-date-input"
type="date"
.value=${filters.startDate}
title=${t("usage.filters.startDate")}
aria-label=${t("usage.filters.startDate")}
@change=${(e: Event) =>
filterActions.onStartDateChange((e.target as HTMLInputElement).value)}
/>
<span class="usage-separator">${t("usage.filters.to")}</span>
<input
class="usage-date-input"
type="date"
.value=${filters.endDate}
title=${t("usage.filters.endDate")}
aria-label=${t("usage.filters.endDate")}
@change=${(e: Event) =>
filterActions.onEndDateChange((e.target as HTMLInputElement).value)}
/>
</div>
<select
class="usage-select"
title=${t("usage.filters.timeZone")}
aria-label=${t("usage.filters.timeZone")}
.value=${filters.timeZone}
@change=${(e: Event) =>
filterActions.onTimeZoneChange(
(e.target as HTMLSelectElement).value as "local" | "utc",
)}
>
<option value="local">${t("usage.filters.timeZoneLocal")}</option>
<option value="utc">${t("usage.filters.timeZoneUtc")}</option>
</select>
<div class="chart-toggle">
<button
class="btn btn--sm toggle-btn ${isTokenMode ? "active" : ""}"
@click=${() => displayActions.onChartModeChange("tokens")}
>
${t("usage.metrics.tokens")}
</button>
<button
class="btn btn--sm toggle-btn ${!isTokenMode ? "active" : ""}"
@click=${() => displayActions.onChartModeChange("cost")}
>
${t("usage.metrics.cost")}
</button>
</div>
<button
class="btn btn--sm primary"
@click=${filterActions.onRefresh}
?disabled=${data.loading}
>
${t("common.refresh")}
</button>
</div>
</div>
<div class="usage-query-section">
<div class="usage-query-bar">
<input
class="usage-query-input"
type="text"
.value=${filters.queryDraft}
placeholder=${t("usage.query.placeholder")}
@input=${(e: Event) =>
filterActions.onQueryDraftChange((e.target as HTMLInputElement).value)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
filterActions.onApplyQuery();
}
}}
/>
<div class="usage-query-actions">
<button
class="btn btn--sm"
@click=${filterActions.onApplyQuery}
?disabled=${data.loading || (!hasDraftQuery && !hasQuery)}
>
${t("usage.query.apply")}
</button>
${hasDraftQuery || hasQuery
? html`
<button class="btn btn--sm" @click=${filterActions.onClearQuery}>
${t("usage.filters.clear")}
</button>
`
: nothing}
<span class="usage-query-hint">
${hasQuery
? t("usage.query.matching", {
shown: String(filteredSessions.length),
total: String(totalSessions),
})
: t("usage.query.inRange", { total: String(totalSessions) })}
</span>
</div>
</div>
<div class="usage-filter-row">
${renderFilterSelect("agent", t("usage.filters.agent"), agentOptions)}
${renderFilterSelect("channel", t("usage.filters.channel"), channelOptions)}
${renderFilterSelect("provider", t("usage.filters.provider"), providerOptions)}
${renderFilterSelect("model", t("usage.filters.model"), modelOptions)}
${renderFilterSelect("tool", t("usage.filters.tool"), toolOptions)}
<span class="usage-query-hint">${t("usage.query.tip")}</span>
</div>
${queryTerms.length > 0
? html`
<div class="usage-query-chips">
${queryTerms.map((term) => {
const label = term.raw;
return html`
<span class="usage-query-chip">
${label}
<button
title=${t("usage.filters.remove")}
@click=${() =>
filterActions.onQueryDraftChange(
removeQueryToken(filters.queryDraft, label),
)}
>
×
</button>
</span>
`;
})}
</div>
`
: nothing}
${querySuggestions.length > 0
? html`
<div class="usage-query-suggestions">
${querySuggestions.map(
(suggestion) => html`
<button
class="usage-query-suggestion"
@click=${() =>
filterActions.onQueryDraftChange(
applySuggestionToQuery(filters.queryDraft, suggestion.value),
)}
>
${suggestion.label}
</button>
`,
)}
</div>
`
: nothing}
${queryWarnings.length > 0
? html`
<div class="callout warning usage-callout usage-callout--tight">
${queryWarnings.join(" · ")}
</div>
`
: nothing}
</div>
${data.error
? html`<div class="callout danger usage-callout">${data.error}</div>`
: nothing}
${data.sessionsLimitReached
? html`
<div class="callout warning usage-callout">${t("usage.sessions.limitReached")}</div>
`
: nothing}
</section>
${isEmpty
? renderUsageEmptyState(filterActions.onRefresh)
: html`
${renderUsageInsights(
displayTotals,
activeAggregates,
insightStats,
hasMissingCost,
buildPeakErrorHours(aggregateSessions, filters.timeZone),
displaySessionCount,
totalSessions,
)}
${renderUsageMosaic(
aggregateSessions,
filters.timeZone,
filters.selectedHours,
filterActions.onSelectHour,
)}
<div class="usage-grid">
<div class="usage-grid-column">
<div class="card usage-left-card">
${renderDailyChartCompact(
filteredDaily,
filters.selectedDays,
display.chartMode,
display.dailyChartMode,
displayActions.onDailyChartModeChange,
filterActions.onSelectDay,
)}
${displayTotals
? renderCostBreakdownCompact(displayTotals, display.chartMode)
: nothing}
</div>
${renderSessionsCard(
filteredSessions,
filters.selectedSessions,
filters.selectedDays,
isTokenMode,
display.sessionSort,
display.sessionSortDir,
display.recentSessions,
display.sessionsTab,
detailActions.onSelectSession,
displayActions.onSessionSortChange,
displayActions.onSessionSortDirChange,
displayActions.onSessionsTabChange,
display.visibleColumns,
totalSessions,
filterActions.onClearSessions,
)}
</div>
${primarySelectedEntry
? html`<div class="usage-grid-column">
${renderSessionDetailPanel(
primarySelectedEntry,
detail.timeSeries,
detail.timeSeriesLoading,
detail.timeSeriesMode,
detailActions.onTimeSeriesModeChange,
detail.timeSeriesBreakdownMode,
detailActions.onTimeSeriesBreakdownChange,
detail.timeSeriesCursorStart,
detail.timeSeriesCursorEnd,
detailActions.onTimeSeriesCursorRangeChange,
filters.startDate,
filters.endDate,
filters.selectedDays,
detail.sessionLogs,
detail.sessionLogsLoading,
detail.sessionLogsExpanded,
detailActions.onToggleSessionLogsExpanded,
detail.logFilters,
detailActions.onLogFilterRolesChange,
detailActions.onLogFilterToolsChange,
detailActions.onLogFilterHasToolsChange,
detailActions.onLogFilterQueryChange,
detailActions.onLogFilterClear,
display.contextExpanded,
detailActions.onToggleContextExpanded,
filterActions.onClearSessions,
)}
</div>`
: nothing}
</div>
`}
</div>
`;
}
// Exposed for Playwright/Vitest browser unit tests.
¤ Dauer der Verarbeitung: 0.26 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|