This is an automated email from the ASF dual-hosted git repository.
zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/2.0.0 by this push:
new 713e003d69 Use source-backed OTLP metrics inventory
713e003d69 is described below
commit 713e003d6988370c0eef7931fb57bfa69e061708
Author: Logic <[email protected]>
AuthorDate: Wed Jun 10 07:53:55 2026 +0800
Use source-backed OTLP metrics inventory
---
.../ingestion/otlp/metrics/otlp-metrics-page.tsx | 45 +++++++--
web-next/app/ingestion/otlp/metrics/page.test.tsx | 106 ++++++++++++++++++++-
web-next/lib/otlp-metrics/view-model.test.ts | 54 +++++++++++
web-next/lib/otlp-metrics/view-model.ts | 62 +++++++++++-
4 files changed, 253 insertions(+), 14 deletions(-)
diff --git a/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx
b/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx
index 74aca8e8a2..431c375dcd 100644
--- a/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx
+++ b/web-next/app/ingestion/otlp/metrics/otlp-metrics-page.tsx
@@ -12,7 +12,7 @@ import { useI18n } from
'@/components/providers/i18n-provider';
import { apiMessageGet } from '@/lib/api-client';
import { copyTextToClipboard } from '@/lib/browser-clipboard';
import { formatTime } from '@/lib/format';
-import { buildOtlpMetricsConsoleUrl, loadOtlpMetricsConsole,
queryStateFromParams, type OtlpMetricsQueryState } from
'@/lib/otlp-metrics/controller';
+import { buildOtlpMetricsConsoleUrl, buildOtlpMetricsInventoryUrl,
loadOtlpMetricsConsole, loadOtlpMetricsInventory, queryStateFromParams, type
OtlpMetricsQueryState } from '@/lib/otlp-metrics/controller';
import { buildOtlpMetricsCsv, buildOtlpMetricsExportFilename,
buildOtlpMetricsJsonl, type OtlpMetricsExportFormat, type
OtlpMetricsExportScope } from '@/lib/otlp-metrics/export';
import {
createSignalDashboardPanelDraft,
@@ -43,6 +43,7 @@ import {
buildMetricSeriesRows,
buildMetricSeriesSampleRows,
buildMetricSeriesViews,
+ buildMetricInventorySourceRows,
buildMetricInventoryRows,
applyMetricsFormula,
buildMetricsChartOption,
@@ -55,7 +56,7 @@ import {
type OtlpMetricInventorySort,
type OtlpMetricSeriesView
} from '@/lib/otlp-metrics/view-model';
-import type { OtlpMetricsConsole } from '@/lib/types';
+import type { OtlpMetricsConsole, OtlpMetricsInventory } from '@/lib/types';
import { buildOtlpMetricsRoute, hasMetricsDisplayReturnLabel } from
'./route-state';
type OtlpMetricsTranslate = ReturnType<typeof useI18n>['t'];
@@ -63,6 +64,9 @@ type OtlpMetricsTranslate = ReturnType<typeof useI18n>['t'];
type MetricsSavedQueryView = SignalSavedQueryView;
type MetricsDashboardPanelDraftState = 'idle' | 'saving' | 'saved' | 'failed';
type MetricAttributeOperator = 'filter' | 'contains' | 'not-contains' | 'in' |
'not-in' | 'exclude' | 'exists' | 'not-exists' | 'replace' | 'group';
+type OtlpMetricsWorkbenchData = OtlpMetricsConsole & {
+ inventory?: OtlpMetricsInventory | null;
+};
const METRICS_SAVED_QUERY_VIEW_STORAGE_KEY =
'hertzbeat.otlp-metrics.saved-query-views';
const METRICS_SAVED_QUERY_VIEW_LIMIT = 5;
@@ -463,8 +467,17 @@ export default function OtlpMetricsPage() {
router.replace(appendMetricsPanelEditContext(route, panelEditContext));
}, [panelEditContext, router]);
const currentMetricsRoute = useMemo(() => buildOtlpMetricsRoute(query),
[query]);
- const workbenchCacheKey = useMemo(() => buildOtlpMetricsConsoleUrl(query),
[query]);
- const load = useCallback(async (): Promise<OtlpMetricsConsole> =>
loadOtlpMetricsConsole(apiMessageGet, query), [query]);
+ const workbenchCacheKey = useMemo(
+ () =>
`${buildOtlpMetricsConsoleUrl(query)}|${buildOtlpMetricsInventoryUrl(query)}`,
+ [query]
+ );
+ const load = useCallback(async (): Promise<OtlpMetricsWorkbenchData> => {
+ const [consoleData, inventory] = await Promise.all([
+ loadOtlpMetricsConsole(apiMessageGet, query),
+ loadOtlpMetricsInventory(apiMessageGet, query).catch(() => null)
+ ]);
+ return { ...consoleData, inventory };
+ }, [query]);
const initialDraft = useMemo(() => ({
query: query.query || '',
filter: query.filter || '',
@@ -1260,7 +1273,23 @@ export default function OtlpMetricsPage() {
pointCount: metricSeries[index]?.points.length ?? 0,
series: metricSeries[index]
}));
- const metricInventoryRows =
buildMetricInventoryRows(metricSeriesTableRows, metricInventorySearch,
metricInventorySort);
+ const sourceMetricInventoryRows =
buildMetricInventorySourceRows(mergedData.inventory, t).map((row, index) => {
+ const matchingSeriesRow = metricSeriesTableRows.find(seriesRow =>
+ seriesRow.title === row.title || seriesRow.series?.name ===
row.title
+ );
+ return {
+ ...(matchingSeriesRow || {}),
+ ...row,
+ rowKey: matchingSeriesRow?.rowKey ||
`inventory-${row.title}-${index}`,
+ series: matchingSeriesRow?.series || row.series || null,
+ meta: matchingSeriesRow?.meta || row.meta,
+ pointCount: matchingSeriesRow?.pointCount ?? row.pointCount,
+ sampleCount: matchingSeriesRow?.sampleCount ?? row.sampleCount,
+ timeSeriesCount: row.timeSeriesCount ??
matchingSeriesRow?.timeSeriesCount
+ };
+ });
+ const metricInventoryBaseRows = sourceMetricInventoryRows.length > 0 ?
sourceMetricInventoryRows : metricSeriesTableRows;
+ const metricInventoryRows =
buildMetricInventoryRows(metricInventoryBaseRows, metricInventorySearch,
metricInventorySort);
const metricInventoryPageSizeNumber = Number(metricInventoryPageSize);
const metricInventoryTotalPages = Math.max(1,
Math.ceil(metricInventoryRows.length / metricInventoryPageSizeNumber));
const clampedMetricInventoryPageIndex =
Math.min(Number(metricInventoryPageIndex), metricInventoryTotalPages - 1);
@@ -1276,8 +1305,8 @@ export default function OtlpMetricsPage() {
total: metricInventoryRows.length
});
const metricInventorySummary = metricInventorySearch.trim()
- ? t('otlp.metrics.inventory.filtered-count', { filtered:
metricInventoryRows.length, total: metricSeriesTableRows.length })
- : t('otlp.metrics.scope.series-count', { count:
metricSeriesTableRows.length });
+ ? t('otlp.metrics.inventory.filtered-count', { filtered:
metricInventoryRows.length, total: metricInventoryBaseRows.length })
+ : t('otlp.metrics.scope.series-count', { count:
metricInventoryBaseRows.length });
const firstSeries = (selectedMetricSeriesIndex >= 0 ?
seriesRows[selectedMetricSeriesIndex] : undefined) ?? seriesRows[0] ?? {
title: mergedData.query || t('otlp.metrics.query.unselected'),
copy: mergedData.context?.serviceName || routeContext.serviceName ||
'-',
@@ -2262,7 +2291,7 @@ export default function OtlpMetricsPage() {
data-otlp-metrics-inventory="metric-inventory"
data-otlp-metrics-inventory-owner="hertzbeat-ui-data-table"
data-otlp-metrics-inventory-filtered-count={metricInventoryRows.length}
-
data-otlp-metrics-inventory-total-count={metricSeriesTableRows.length}
+
data-otlp-metrics-inventory-total-count={metricInventoryBaseRows.length}
data-otlp-metrics-inventory-page-size={metricInventoryPageSize}
data-otlp-metrics-inventory-page-index={clampedMetricInventoryPageIndex}
variant="embedded"
diff --git a/web-next/app/ingestion/otlp/metrics/page.test.tsx
b/web-next/app/ingestion/otlp/metrics/page.test.tsx
index a60c5a5c7b..68df062b36 100644
--- a/web-next/app/ingestion/otlp/metrics/page.test.tsx
+++ b/web-next/app/ingestion/otlp/metrics/page.test.tsx
@@ -30,6 +30,7 @@ const mockState = vi.hoisted(() => ({
const apiMessageGet = vi.fn();
const loadOtlpMetricsConsole = vi.fn();
+const loadOtlpMetricsInventory = vi.fn();
const zhT = createTranslatorMock({ locale: 'zh-CN' });
const originalFetch = globalThis.fetch;
@@ -107,6 +108,7 @@ vi.mock('@/lib/format', () => ({
vi.mock('@/lib/otlp-metrics/controller', () => ({
loadOtlpMetricsConsole,
+ loadOtlpMetricsInventory,
buildOtlpMetricsConsoleUrl: (query: Record<string, unknown> = {}) => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
@@ -125,6 +127,17 @@ vi.mock('@/lib/otlp-metrics/controller', () => ({
const search = params.toString();
return `/api/otlp/v1/metrics${search ? `?${search}` : ''}`;
},
+ buildOtlpMetricsInventoryUrl: (query: Record<string, unknown> = {}) => {
+ const params = new URLSearchParams();
+ ['entityId', 'entityType', 'serviceName', 'serviceNamespace',
'environment', 'start', 'end', 'limit'].forEach(key => {
+ const value = query[key];
+ if (value !== undefined && value !== null && value !== '') {
+ params.set(key, String(value));
+ }
+ });
+ const search = params.toString();
+ return `/api/otlp/v1/metrics/inventory${search ? `?${search}` : ''}`;
+ },
queryStateFromParams: (params: { get(key: string): string | null }) => ({
query: params.get('query') || undefined,
series: params.get('series') || undefined,
@@ -150,6 +163,7 @@ vi.mock('@/lib/otlp-metrics/controller', () => ({
collector: params.get('collector') || undefined,
template: params.get('template') || undefined,
entityId: params.get('entityId') || undefined,
+ entityType: params.get('entityType') || undefined,
entityName: params.get('entityName') || undefined,
returnTo: params.get('returnTo') || undefined,
traceId: params.get('traceId') || undefined,
@@ -399,10 +413,40 @@ vi.mock('@/lib/otlp-metrics/view-model', () => ({
: tZh('otlp.metrics.series.entity-missing'),
entityState: series.labels['hertzbeat.entity_id'] ||
series.labels.hertzbeat_entity_id ? 'present' : 'missing'
})),
+ buildMetricInventorySourceRows: (inventory: any) => (inventory?.items ||
[]).map((item: any) => ({
+ title: item.metricName,
+ copy: item.labels?.service_name || inventory.context?.serviceName || '-',
+ meta: '-',
+ description: '-',
+ metricType: item.family || '-',
+ unit: '-',
+ pointCount: 0,
+ sampleCount: 0,
+ timeSeriesCount: item.timeSeriesCount ?? 0,
+ latestObservedAt: item.latestObservedAt ?? null,
+ entityLabel: inventory.context?.entityName || '-',
+ entityMeta: inventory.context?.entityId
+ ? tZh('otlp.metrics.series.entity-id', { entityId:
inventory.context.entityId })
+ : tZh('otlp.metrics.series.entity-missing'),
+ entityState: inventory.context?.entityId ? 'present' : 'missing',
+ inventorySource: inventory.source,
+ inventoryLabels: item.labels || {},
+ series: null
+ })),
buildMetricInventoryRows: (rows: any[], search: string, sort: string) => {
const normalizedSearch = search.trim().toLowerCase();
const filteredRows = normalizedSearch
- ? rows.filter(row => [row.title, row.copy, row.meta, row.entityLabel,
row.entityMeta, row.series?.name, ...Object.values(row.series?.labels ||
{})].join(' ').toLowerCase().includes(normalizedSearch))
+ ? rows.filter(row => [
+ row.title,
+ row.copy,
+ row.meta,
+ row.entityLabel,
+ row.entityMeta,
+ row.inventorySource,
+ row.series?.name,
+ ...Object.values(row.series?.labels || {}),
+ ...Object.values(row.inventoryLabels || {})
+ ].join(' ').toLowerCase().includes(normalizedSearch))
: [...rows];
return filteredRows.sort((left, right) => {
if (sort === 'latest') return (right.series?.latestValue ??
Number.NEGATIVE_INFINITY) - (left.series?.latestValue ??
Number.NEGATIVE_INFINITY);
@@ -464,7 +508,9 @@ beforeEach(() => {
mockState.metricSeries = [];
apiMessageGet.mockReset();
loadOtlpMetricsConsole.mockReset();
+ loadOtlpMetricsInventory.mockReset();
loadOtlpMetricsConsole.mockResolvedValue(mockState.renderData);
+ loadOtlpMetricsInventory.mockResolvedValue(null);
});
let interactionRoot: Root | null = null;
@@ -875,7 +921,9 @@ describe('otlp metrics page', () => {
expect(source).toContain('data-otlp-metrics-series-table-summary-owner="hertzbeat-ui-status-badge"');
expect(source).toContain('data-otlp-metrics-inventory-count="filtered-series"');
expect(source).toContain('metricInventorySummary');
- expect(source).toContain('buildMetricInventoryRows(metricSeriesTableRows,
metricInventorySearch, metricInventorySort)');
+
expect(source).toContain('buildMetricInventorySourceRows(mergedData.inventory,
t)');
+ expect(source).toContain('const metricInventoryBaseRows =
sourceMetricInventoryRows.length > 0 ? sourceMetricInventoryRows :
metricSeriesTableRows');
+
expect(source).toContain('buildMetricInventoryRows(metricInventoryBaseRows,
metricInventorySearch, metricInventorySort)');
expect(source).toContain('metricInventoryPageRows');
expect(source).toContain('HzPaginationBar');
expect(source).toContain("const metricInventorySearch =
query.inventorySearch || ''");
@@ -906,7 +954,7 @@ describe('otlp metrics page', () => {
expect(source).toContain('data-otlp-metrics-inventory="metric-inventory"');
expect(source).toContain('data-otlp-metrics-inventory-owner="hertzbeat-ui-data-table"');
expect(source).toContain('data-otlp-metrics-inventory-filtered-count={metricInventoryRows.length}');
-
expect(source).toContain('data-otlp-metrics-inventory-total-count={metricSeriesTableRows.length}');
+
expect(source).toContain('data-otlp-metrics-inventory-total-count={metricInventoryBaseRows.length}');
expect(source).toContain('data-otlp-metrics-inventory-page-size={metricInventoryPageSize}');
expect(source).toContain('data-otlp-metrics-inventory-page-index={clampedMetricInventoryPageIndex}');
expect(source).toContain('rows={metricInventoryPageRows}');
@@ -2026,6 +2074,58 @@ describe('otlp metrics page', () => {
}));
});
+ it('loads source-backed metrics inventory with the same entity and service
context', async () => {
+ mockState.searchParams = new
URLSearchParams('entityId=7&entityType=service&serviceName=checkout&serviceNamespace=commerce&environment=prod&start=1000&end=2000&limit=20');
+ loadOtlpMetricsInventory.mockResolvedValue({
+ source: 'promql-inventory',
+ context: { entityId: 7, entityType: 'service', serviceName: 'checkout',
serviceNamespace: 'commerce', environment: 'prod', start: 1000, end: 2000 },
+ items: [
+ {
+ metricName: 'http_server_duration',
+ family: 'latency',
+ timeSeriesCount: 3,
+ latestObservedAt: 2000,
+ labels: { service_name: 'checkout', http_route: '/checkout' }
+ }
+ ]
+ });
+ const { default: OtlpMetricsPage } = await import('./page');
+ renderToStaticMarkup(<OtlpMetricsPage />);
+ const data = await mockState.lastLoad?.();
+
+ expect(loadOtlpMetricsInventory).toHaveBeenCalledWith(apiMessageGet,
expect.objectContaining({
+ entityId: '7',
+ entityType: 'service',
+ serviceName: 'checkout',
+ serviceNamespace: 'commerce',
+ environment: 'prod',
+ start: '1000',
+ end: '2000',
+ limit: '20'
+ }));
+ expect(data).toMatchObject({
+ inventory: {
+ source: 'promql-inventory',
+ items: [expect.objectContaining({ metricName: 'http_server_duration'
})]
+ }
+ });
+ });
+
+ it('falls back to console metrics when the inventory endpoint is
unavailable', async () => {
+ mockState.searchParams = new URLSearchParams('serviceName=checkout');
+ loadOtlpMetricsInventory.mockRejectedValue(new Error('inventory
unavailable'));
+ const { default: OtlpMetricsPage } = await import('./page');
+ renderToStaticMarkup(<OtlpMetricsPage />);
+ const data = await mockState.lastLoad?.();
+
+ expect(loadOtlpMetricsConsole).toHaveBeenCalled();
+ expect(loadOtlpMetricsInventory).toHaveBeenCalled();
+ expect(data).toMatchObject({
+ datasource: 'prometheus',
+ inventory: null
+ });
+ });
+
it('keeps the context handoff links when opened from traced entity context',
async () => {
mockState.searchParams = new URLSearchParams(
'entityId=7&entityName=checkout&returnTo=%2Foverview&returnLabel=Overview&traceId=trace-123&spanId=span-456&serviceName=checkout&serviceNamespace=payments&environment=prod&start=1712730000000&end=1712733600000'
diff --git a/web-next/lib/otlp-metrics/view-model.test.ts
b/web-next/lib/otlp-metrics/view-model.test.ts
index 5951c8e1ce..41b0c6435c 100644
--- a/web-next/lib/otlp-metrics/view-model.test.ts
+++ b/web-next/lib/otlp-metrics/view-model.test.ts
@@ -7,6 +7,7 @@ import {
buildContextRows,
buildMetricsExplorerState,
buildMetricsHandoffLinks,
+ buildMetricInventorySourceRows,
buildMetricInventoryRows,
buildMetricSeriesRows,
buildMetricSeriesSampleRows,
@@ -504,6 +505,59 @@ describe('otlp metrics view model', () => {
]);
});
+ it('builds source-backed metric inventory rows from the backend inventory
contract', () => {
+ const rows = buildMetricInventorySourceRows(
+ {
+ source: 'promql-inventory',
+ context: {
+ entityId: 7,
+ entityType: 'service',
+ entityName: 'Checkout API',
+ serviceName: 'checkout',
+ serviceNamespace: 'commerce',
+ environment: 'prod',
+ start: 1000,
+ end: 2000
+ },
+ total: 1,
+ items: [
+ {
+ metricName: 'http_server_duration',
+ family: 'latency',
+ timeSeriesCount: 3,
+ latestObservedAt: 2000,
+ labels: {
+ service_name: 'checkout',
+ http_route: '/checkout'
+ }
+ }
+ ]
+ },
+ t
+ );
+
+ expect(rows).toEqual([
+ expect.objectContaining({
+ title: 'http_server_duration',
+ copy: 'checkout',
+ metricType: 'latency',
+ timeSeriesCount: 3,
+ latestObservedAt: 2000,
+ entityLabel: 'Checkout API',
+ entityMeta: t('otlp.metrics.series.entity-id', { entityId: '7' }),
+ inventorySource: 'promql-inventory',
+ inventoryLabels: {
+ service_name: 'checkout',
+ http_route: '/checkout'
+ },
+ series: null
+ })
+ ]);
+ expect(buildMetricInventoryRows(rows, 'checkout', 'latest').map(row =>
row.title)).toEqual([
+ 'http_server_duration'
+ ]);
+ });
+
it('builds SigNoz-style metric inventory metadata from real frame schema
fields', () => {
const rows = buildMetricSeriesRows(
buildMetricSeriesViews(
diff --git a/web-next/lib/otlp-metrics/view-model.ts
b/web-next/lib/otlp-metrics/view-model.ts
index ed52464839..c82769cf3f 100644
--- a/web-next/lib/otlp-metrics/view-model.ts
+++ b/web-next/lib/otlp-metrics/view-model.ts
@@ -12,7 +12,7 @@ import {
import { buildChartDataZoomTimeContext, type ChartDataZoomRange, type
TimeContext } from '../time-context';
import type { OtlpMetricsQueryState } from './controller';
import type { EChartsOption } from 'echarts';
-import type { OtlpMetricsConsole } from '@/lib/types';
+import type { OtlpMetricsConsole, OtlpMetricsInventory } from '@/lib/types';
type Translator = (key: string, params?: Record<string, string | number | null
| undefined>) => string;
@@ -35,12 +35,16 @@ export type OtlpMetricInventoryRow = {
meta?: string;
entityLabel?: string;
entityMeta?: string;
+ entityState?: 'present' | 'missing';
pointCount?: number;
sampleCount?: number;
timeSeriesCount?: number;
+ latestObservedAt?: number | null;
description?: string;
metricType?: string;
unit?: string;
+ inventorySource?: string;
+ inventoryLabels?: Record<string, string>;
series?: OtlpMetricSeriesView | null;
};
@@ -620,10 +624,60 @@ export function buildMetricSeriesRows(seriesList:
OtlpMetricSeriesView[], t: Tra
});
}
+export function buildMetricInventorySourceRows(
+ inventory: OtlpMetricsInventory | null | undefined,
+ t: Translator
+): OtlpMetricInventoryRow[] {
+ return (inventory?.items || [])
+ .map(item => {
+ const metricName = item.metricName?.trim();
+ if (!metricName) return null;
+ const labels = item.labels || {};
+ const context = inventory?.context || {};
+ const entityId = readEntityIdRouteParam(firstText(
+ labels['hertzbeat.entity_id'],
+ labels.hertzbeat_entity_id,
+ labels['entity.id'],
+ labels.entity_id,
+ context.entityId == null ? undefined : String(context.entityId)
+ ));
+ return {
+ title: metricName,
+ copy: firstText(labels['service.name'], labels.service_name,
labels.serviceName, context.serviceName)
+ || t('otlp.metrics.series.unknown-service'),
+ meta: '-',
+ description: '-',
+ metricType: item.family || '-',
+ unit: '-',
+ pointCount: 0,
+ sampleCount: 0,
+ timeSeriesCount: item.timeSeriesCount ?? 0,
+ latestObservedAt: item.latestObservedAt ?? null,
+ entityLabel: firstText(
+ labels['hertzbeat.entity_name'],
+ labels.hertzbeat_entity_name,
+ labels['entity.name'],
+ labels.entity_name,
+ context.entityName,
+ entityId
+ ) || '-',
+ entityMeta: entityId ? t('otlp.metrics.series.entity-id', { entityId
}) : t('otlp.metrics.series.entity-missing'),
+ entityState: entityId ? 'present' : 'missing',
+ inventorySource: inventory?.source || undefined,
+ inventoryLabels: labels,
+ series: null
+ } satisfies OtlpMetricInventoryRow;
+ })
+ .filter((row): row is OtlpMetricInventoryRow => row != null);
+}
+
function buildMetricInventorySearchText(row: OtlpMetricInventoryRow) {
const labelText = row.series
? Object.entries(row.series.labels).flatMap(([key, value]) => [key, value])
: [];
+ const inventoryLabelText = row.inventoryLabels
+ ? Object.entries(row.inventoryLabels).flatMap(([key, value]) => [key,
value])
+ : [];
return [
row.title,
row.copy,
@@ -633,8 +687,10 @@ function buildMetricInventorySearchText(row:
OtlpMetricInventoryRow) {
row.description,
row.metricType,
row.unit,
+ row.inventorySource,
row.series?.name,
- ...labelText
+ ...labelText,
+ ...inventoryLabelText
]
.filter((value): value is string => typeof value === 'string' &&
value.trim() !== '')
.join(' ')
@@ -646,7 +702,7 @@ function metricInventoryName(row: OtlpMetricInventoryRow) {
}
function metricInventoryLatestValue(row: OtlpMetricInventoryRow) {
- const value = row.series?.latestValue ?? Number(row.meta);
+ const value = row.series?.latestValue ?? row.latestObservedAt ??
Number(row.meta);
return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]