bito-code-review[bot] commented on code in PR #37107:
URL: https://github.com/apache/superset/pull/37107#discussion_r2702153881
##########
superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx:
##########
@@ -72,7 +72,9 @@ export const useResultsPane = ({
// it's an invalid formData when gets a errorMessage
if (errorMessage) return;
if (isRequest && cache.has(queryFormData)) {
- setResultResp(ensureIsArray(cache.get(queryFormData)));
+ setResultResp(
+ ensureIsArray(cache.get(queryFormData)) as QueryResultInterface[],
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Type assertion on incompatible types</b></div>
<div id="fix">
The type assertion to QueryResultInterface[] may hide a type mismatch. The
defined type for QueryResultInterface.data is Record<string, any>[][], but
usage in useFilteredTableData expects Record<string, any>[]. This could lead to
runtime errors. Consider fixing the type definition instead of asserting.
</div>
</div>
<details>
<summary><b>Citations</b></summary>
<ul>
<li>
Rule Violated: <a
href="https://github.com/apache/superset/blob/7a7661e/.cursor/rules/dev-standard.mdc#L35">dev-standard.mdc:35</a>
</li>
</ul>
</details>
<small><i>Code Review Run #118f21</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a
href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
superset-frontend/src/components/Chart/chartAction.ts:
##########
@@ -0,0 +1,1013 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+/* eslint no-param-reassign: ["error", { "props": false }] */
+import {
+ FeatureFlag,
+ isDefined,
+ SupersetClient,
+ isFeatureEnabled,
+ getClientErrorObject,
+ QueryFormData,
+ JsonObject,
+ QueryData,
+ AnnotationLayer,
+ DataMask,
+ DatasourceType,
+ LatestQueryFormData,
+} from '@superset-ui/core';
+import { t } from '@apache-superset/core/ui';
+import type { ControlStateMapping } from '@superset-ui/chart-controls';
+import { getControlsState } from 'src/explore/store';
+import {
+ getAnnotationJsonUrl,
+ getExploreUrl,
+ getLegacyEndpointType,
+ buildV1ChartDataPayload,
+ getQuerySettings,
+ getChartDataUri,
+} from 'src/explore/exploreUtils';
+import { addDangerToast } from 'src/components/MessageToasts/actions';
+import { logEvent } from 'src/logger/actions';
+import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
+import { allowCrossDomain as domainShardingEnabled } from
'src/utils/hostNamesConfig';
+import { updateDataMask } from 'src/dataMask/actions';
+import { waitForAsyncData } from 'src/middleware/asyncEvent';
+import { ensureAppRoot } from 'src/utils/pathUtils';
+import { safeStringify } from 'src/utils/safeStringify';
+import { extendedDayjs } from '@superset-ui/core/utils/dates';
+import type { Dispatch, Action, AnyAction } from 'redux';
+import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
+import type { History } from 'history';
+import type { ChartState } from 'src/explore/types';
+
+// Types for the Redux state
+export interface ChartsState {
+ [key: string]: ChartState;
+}
+
+export interface CommonState {
+ conf: {
+ SUPERSET_WEBSERVER_TIMEOUT?: number;
+ [key: string]: unknown;
+ };
+}
+
+export interface DashboardInfoState {
+ common: CommonState;
+}
+
+export interface DataMaskState {
+ [key: number]: {
+ ownState?: JsonObject;
+ };
+}
+
+// RootState uses flexible types to accommodate various state shapes
+// across dashboard and explore views
+export interface RootState {
+ charts: ChartsState;
+ common: CommonState;
+ dashboardInfo: DashboardInfoState;
+ dataMask: DataMaskState;
+ explore: {
+ form_data: QueryFormData;
+ datasource?: { type: string };
+ common?: { conf: { DEFAULT_VIZ_TYPE?: string } };
+ [key: string]: unknown;
+ };
+}
+
+// Action types
+export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED' as const;
+export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED' as const;
+export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED' as const;
+export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED' as const;
+export const CHART_RENDERING_FAILED = 'CHART_RENDERING_FAILED' as const;
+export const CHART_RENDERING_SUCCEEDED = 'CHART_RENDERING_SUCCEEDED' as const;
+export const REMOVE_CHART = 'REMOVE_CHART' as const;
+export const ANNOTATION_QUERY_SUCCESS = 'ANNOTATION_QUERY_SUCCESS' as const;
+export const ANNOTATION_QUERY_STARTED = 'ANNOTATION_QUERY_STARTED' as const;
+export const ANNOTATION_QUERY_FAILED = 'ANNOTATION_QUERY_FAILED' as const;
+export const DYNAMIC_PLUGIN_CONTROLS_READY =
+ 'DYNAMIC_PLUGIN_CONTROLS_READY' as const;
+export const TRIGGER_QUERY = 'TRIGGER_QUERY' as const;
+export const RENDER_TRIGGERED = 'RENDER_TRIGGERED' as const;
+export const UPDATE_QUERY_FORM_DATA = 'UPDATE_QUERY_FORM_DATA' as const;
+export const UPDATE_CHART_ID = 'UPDATE_CHART_ID' as const;
+export const ADD_CHART = 'ADD_CHART' as const;
+
+// Action interfaces
+export interface ChartUpdateStartedAction {
+ type: typeof CHART_UPDATE_STARTED;
+ queryController: AbortController;
+ latestQueryFormData: QueryFormData | LatestQueryFormData;
+ key: string | number;
+}
+
+export interface ChartUpdateSucceededAction {
+ type: typeof CHART_UPDATE_SUCCEEDED;
+ queriesResponse: QueryData[];
+ key: string | number;
+}
+
+export interface ChartUpdateStoppedAction {
+ type: typeof CHART_UPDATE_STOPPED;
+ key: string | number;
+}
+
+export interface ChartUpdateFailedAction {
+ type: typeof CHART_UPDATE_FAILED;
+ queriesResponse: QueryData[] | JsonObject[];
+ key: string | number;
+}
+
+export interface ChartRenderingFailedAction {
+ type: typeof CHART_RENDERING_FAILED;
+ error: string;
+ key: string | number;
+ stackTrace: string | null;
+}
+
+export interface ChartRenderingSucceededAction {
+ type: typeof CHART_RENDERING_SUCCEEDED;
+ key: string | number;
+}
+
+export interface RemoveChartAction {
+ type: typeof REMOVE_CHART;
+ key: string | number;
+}
+
+export interface AnnotationQuerySuccessAction {
+ type: typeof ANNOTATION_QUERY_SUCCESS;
+ annotation: AnnotationLayer;
+ queryResponse: { data: unknown } | JsonObject;
+ key: string | number;
+}
+
+export interface AnnotationQueryStartedAction {
+ type: typeof ANNOTATION_QUERY_STARTED;
+ annotation: AnnotationLayer;
+ queryController: AbortController;
+ key: string | number;
+}
+
+export interface AnnotationQueryFailedAction {
+ type: typeof ANNOTATION_QUERY_FAILED;
+ annotation: AnnotationLayer;
+ queryResponse: { error: string } | JsonObject;
+ key: string | number;
+}
+
+export interface DynamicPluginControlsReadyAction {
+ type: typeof DYNAMIC_PLUGIN_CONTROLS_READY;
+ key: string | number;
+ controlsState: ControlStateMapping;
+}
+
+export interface TriggerQueryAction {
+ type: typeof TRIGGER_QUERY;
+ value: boolean;
+ key: string | number;
+}
+
+export interface RenderTriggeredAction {
+ type: typeof RENDER_TRIGGERED;
+ value: number;
+ key: string | number;
+}
+
+export interface UpdateQueryFormDataAction {
+ type: typeof UPDATE_QUERY_FORM_DATA;
+ value: QueryFormData | LatestQueryFormData;
+ key: string | number;
+}
+
+export interface UpdateChartIdAction {
+ type: typeof UPDATE_CHART_ID;
+ newId: number;
+ key: string | number;
+}
+
+export interface AddChartAction {
+ type: typeof ADD_CHART;
+ chart: ChartState;
+ key: string | number;
+}
+
+export type ChartAction =
+ | ChartUpdateStartedAction
+ | ChartUpdateSucceededAction
+ | ChartUpdateStoppedAction
+ | ChartUpdateFailedAction
+ | ChartRenderingFailedAction
+ | ChartRenderingSucceededAction
+ | RemoveChartAction
+ | AnnotationQuerySuccessAction
+ | AnnotationQueryStartedAction
+ | AnnotationQueryFailedAction
+ | DynamicPluginControlsReadyAction
+ | TriggerQueryAction
+ | RenderTriggeredAction
+ | UpdateQueryFormDataAction
+ | UpdateChartIdAction
+ | AddChartAction;
+
+// Type for thunk actions
+export type ChartThunkDispatch = ThunkDispatch<RootState, undefined,
AnyAction>;
+export type ChartThunkAction<R = void> = ThunkAction<
+ R,
+ RootState,
+ undefined,
+ AnyAction
+>;
+
+// Request params interface
+export interface RequestParams {
+ signal?: AbortSignal;
+ timeout?: number;
+ dashboard_id?: number;
+ mode?: string;
+ credentials?: RequestCredentials;
+ [key: string]: unknown;
+}
+
+// Query settings type
+export interface QuerySettings extends RequestParams {
+ url?: string;
+ postPayload?: { form_data: QueryFormData | LatestQueryFormData };
+ parseMethod?: string;
+ headers?: Record<string, string>;
+ body?: string;
+}
+
+// API response type for chart data request
+export interface ChartDataRequestResponse {
+ response: Response;
+ json: {
+ result: QueryData[];
+ };
+}
+
+// getChartDataRequest params interface
+export interface GetChartDataRequestParams {
+ formData: QueryFormData | LatestQueryFormData;
+ setDataMask?: (dataMask: DataMask) => void;
+ resultFormat?: string;
+ resultType?: string;
+ force?: boolean;
+ method?: 'GET' | 'POST';
+ requestParams?: RequestParams;
+ ownState?: JsonObject;
+}
+
+// runAnnotationQuery params interface
+// Extended annotation layer with optional overrides for time range
+// Using type intersection instead of interface extension because
+// AnnotationLayer may have dynamic members
+type AnnotationLayerWithOverrides = AnnotationLayer & {
+ overrides?: Record<string, unknown>;
+};
+
+export interface RunAnnotationQueryParams {
+ annotation: AnnotationLayerWithOverrides;
+ timeout?: number;
+ formData?: QueryFormData | LatestQueryFormData;
+ key?: string | number;
+ isDashboardRequest?: boolean;
+ force?: boolean;
+}
+
+// Datasource samples params interface
+export interface DatasourceSamplesSearchParams {
+ force: boolean;
+ datasource_type: DatasourceType;
+ datasource_id: number;
+ dashboard_id?: number;
+ per_page?: number;
+ page?: number;
+}
+
+// Action creators
+export function chartUpdateStarted(
+ queryController: AbortController,
+ latestQueryFormData: QueryFormData | LatestQueryFormData,
+ key: string | number,
+): ChartUpdateStartedAction {
+ return {
+ type: CHART_UPDATE_STARTED,
+ queryController,
+ latestQueryFormData,
+ key,
+ };
+}
+
+export function chartUpdateSucceeded(
+ queriesResponse: QueryData[],
+ key: string | number,
+): ChartUpdateSucceededAction {
+ return { type: CHART_UPDATE_SUCCEEDED, queriesResponse, key };
+}
+
+export function chartUpdateStopped(
+ key: string | number,
+): ChartUpdateStoppedAction {
+ return { type: CHART_UPDATE_STOPPED, key };
+}
+
+export function chartUpdateFailed(
+ queriesResponse: QueryData[] | JsonObject[],
+ key: string | number,
+): ChartUpdateFailedAction {
+ return { type: CHART_UPDATE_FAILED, queriesResponse, key };
+}
+
+export function chartRenderingFailed(
+ error: string,
+ key: string | number,
+ stackTrace: string | null,
+): ChartRenderingFailedAction {
+ return { type: CHART_RENDERING_FAILED, error, key, stackTrace };
+}
+
+export function chartRenderingSucceeded(
+ key: string | number,
+): ChartRenderingSucceededAction {
+ return { type: CHART_RENDERING_SUCCEEDED, key };
+}
+
+export function removeChart(key: string | number): RemoveChartAction {
+ return { type: REMOVE_CHART, key };
+}
+
+export function annotationQuerySuccess(
+ annotation: AnnotationLayer,
+ queryResponse: { data: unknown } | JsonObject,
+ key: string | number,
+): AnnotationQuerySuccessAction {
+ return { type: ANNOTATION_QUERY_SUCCESS, annotation, queryResponse, key };
+}
+
+export function annotationQueryStarted(
+ annotation: AnnotationLayer,
+ queryController: AbortController,
+ key: string | number,
+): AnnotationQueryStartedAction {
+ return { type: ANNOTATION_QUERY_STARTED, annotation, queryController, key };
+}
+
+export function annotationQueryFailed(
+ annotation: AnnotationLayer,
+ queryResponse: { error: string } | JsonObject,
+ key: string | number,
+): AnnotationQueryFailedAction {
+ return { type: ANNOTATION_QUERY_FAILED, annotation, queryResponse, key };
+}
+
+export const dynamicPluginControlsReady =
+ (): ChartThunkAction =>
+ (dispatch: Dispatch, getState: () => RootState): void => {
+ const state = getState();
+ // getControlsState expects datasource to be defined, provide a default
+ const exploreState = {
+ ...state.explore,
+ datasource: state.explore.datasource || { type: 'table' },
+ };
+ const controlsState = getControlsState(
+ exploreState,
+ state.explore.form_data,
+ ) as ControlStateMapping;
+ const sliceIdControl = controlsState.slice_id as { value?: unknown };
+ dispatch({
+ type: DYNAMIC_PLUGIN_CONTROLS_READY,
+ key: sliceIdControl?.value,
+ controlsState,
+ });
+ };
+
+const legacyChartDataRequest = async (
+ formData: QueryFormData | LatestQueryFormData,
+ resultFormat: string,
+ resultType: string,
+ force: boolean,
+ method: 'GET' | 'POST' = 'POST',
+ requestParams: RequestParams = {},
+ parseMethod?: string,
+): Promise<ChartDataRequestResponse> => {
+ const endpointType = getLegacyEndpointType({ resultFormat, resultType });
+ const allowDomainSharding = Boolean(
+ // eslint-disable-next-line camelcase
+ domainShardingEnabled && requestParams?.dashboard_id,
+ );
+ const url = getExploreUrl({
+ formData: formData as QueryFormData & {
+ label_colors?: Record<string, string>;
+ },
+ endpointType,
+ force,
+ allowDomainSharding,
+ method,
+ requestParams: requestParams.dashboard_id
+ ? { dashboard_id: String(requestParams.dashboard_id) }
+ : {},
+ });
+ const querySettings: QuerySettings = {
+ ...requestParams,
+ url: url ?? undefined,
+ postPayload: { form_data: formData },
+ parseMethod,
+ };
+
+ return SupersetClient.post(
+ querySettings as Parameters<typeof SupersetClient.post>[0],
+ ).then(({ json, response }: { json: JsonObject; response: Response }) =>
+ // Make the legacy endpoint return a payload that corresponds to the
+ // V1 chart data endpoint response signature.
+ ({
+ response,
+ json: { result: [json] },
+ }),
+ );
+};
+
+const v1ChartDataRequest = async (
+ formData: QueryFormData | LatestQueryFormData,
+ resultFormat: string,
+ resultType: string,
+ force: boolean,
+ requestParams: RequestParams,
+ setDataMask: (dataMask: DataMask) => void,
+ ownState: JsonObject,
+ parseMethod?: string,
+): Promise<ChartDataRequestResponse> => {
+ const payload = await buildV1ChartDataPayload({
+ formData: formData as QueryFormData,
+ resultType,
+ resultFormat,
+ force,
+ setDataMask,
+ ownState,
+ });
+
+ // The dashboard id is added to query params for tracking purposes
+ const { slice_id: sliceId } = formData;
+ const { dashboard_id: dashboardId } = requestParams;
+
+ const qs: Record<string, string> = {};
+ if (sliceId !== undefined) qs.form_data = `{"slice_id":${sliceId}}`;
+ if (dashboardId !== undefined) qs.dashboard_id = String(dashboardId);
+ if (force) qs.force = String(force);
+
+ const allowDomainSharding = Boolean(
+ // eslint-disable-next-line camelcase
+ domainShardingEnabled && requestParams?.dashboard_id,
+ );
+ const url = getChartDataUri({
+ path: '/api/v1/chart/data',
+ qs,
+ allowDomainSharding,
+ }).toString();
+
+ const querySettings: QuerySettings = {
+ ...requestParams,
+ url,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ parseMethod,
+ };
+
+ return SupersetClient.post(
+ querySettings as Parameters<typeof SupersetClient.post>[0],
+ ) as Promise<ChartDataRequestResponse>;
+};
+
+export async function getChartDataRequest({
+ formData,
+ setDataMask = () => {},
+ resultFormat = 'json',
+ resultType = 'full',
+ force = false,
+ method = 'POST' as const,
+ requestParams = {},
+ ownState = {},
+}: GetChartDataRequestParams): Promise<ChartDataRequestResponse> {
+ let querySettings: RequestParams = {
+ ...requestParams,
+ };
+
+ if (domainShardingEnabled) {
+ querySettings = {
+ ...querySettings,
+ mode: 'cors',
+ credentials: 'include',
+ };
+ }
+ const [useLegacyApi, parseMethod] = getQuerySettings(formData);
+ if (useLegacyApi) {
+ return legacyChartDataRequest(
+ formData,
+ resultFormat,
+ resultType,
+ force,
+ method,
+ querySettings,
+ parseMethod,
+ );
+ }
+ return v1ChartDataRequest(
+ formData,
+ resultFormat,
+ resultType,
+ force,
+ querySettings,
+ setDataMask,
+ ownState,
+ parseMethod,
+ );
+}
+
+export function runAnnotationQuery({
+ annotation,
+ timeout,
+ formData,
+ key,
+ isDashboardRequest = false,
+ force = false,
+}: RunAnnotationQueryParams): ChartThunkAction<Promise<void | Action>> {
+ return async function (
+ dispatch: ChartThunkDispatch,
+ getState: () => RootState,
+ ): Promise<void | Action> {
+ const { charts, common } = getState();
+ const sliceKey = key || Object.keys(charts)[0];
+ const queryTimeout = timeout || common.conf.SUPERSET_WEBSERVER_TIMEOUT ||
0;
+
+ // make a copy of formData, not modifying original formData
+ const fd: JsonObject = {
+ ...(formData || charts[sliceKey].latestQueryFormData),
+ };
+
+ if (!annotation.sourceType) {
+ return Promise.resolve();
+ }
+
+ // In the original formData the `granularity` attribute represents the
time grain (eg
+ // `P1D`), but in the request payload it corresponds to the name of the
column where
+ // the time grain should be applied (eg, `Date`), so we need to move
things around.
+ fd.time_grain_sqla = fd.time_grain_sqla || fd.granularity;
+ fd.granularity = fd.granularity_sqla;
+
+ const overridesKeys = Object.keys(annotation.overrides || {});
+ if (overridesKeys.includes('since') || overridesKeys.includes('until')) {
+ annotation.overrides = {
+ ...annotation.overrides,
+ time_range: null,
+ };
+ }
+ const sliceFormData: JsonObject = Object.keys(
+ annotation.overrides || {},
+ ).reduce(
+ (d, k) => ({
+ ...d,
+ [k]: annotation.overrides?.[k] || fd[k],
+ }),
+ {},
+ );
+
+ if (!isDashboardRequest && fd) {
+ const hasExtraFilters = fd.extra_filters && fd.extra_filters.length > 0;
+ sliceFormData.extra_filters = hasExtraFilters
+ ? fd.extra_filters
+ : undefined;
+ }
+
+ const url = getAnnotationJsonUrl(annotation.value, force);
+ // If url is null (slice_id was null/undefined), skip the request
+ if (!url) {
+ return Promise.resolve();
+ }
+
+ const controller = new AbortController();
+ const { signal } = controller;
+
+ dispatch(annotationQueryStarted(annotation, controller, sliceKey));
+
+ const annotationIndex = fd?.annotation_layers?.findIndex(
+ (it: AnnotationLayer) => it.name === annotation.name,
+ );
+ if (annotationIndex !== undefined && annotationIndex >= 0) {
+ fd.annotation_layers[annotationIndex].overrides = sliceFormData;
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Input mutation bug</b></div>
<div id="fix">
The code assigns to fd.annotation_layers[annotationIndex].overrides, which
modifies the original formData passed as input, contrary to the comment 'make a
copy of formData, not modifying original formData'. This can lead to unexpected
behavior if formData is reused elsewhere. A deep copy is needed to ensure the
input remains unchanged.
</div>
</div>
<small><i>Code Review Run #118f21</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a
href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx:
##########
@@ -139,7 +147,7 @@ const createProps = (additionalProps = {}) => ({
canDownload: false,
isStarred: false,
...additionalProps,
-});
+}) as unknown as ExploreChartHeaderProps;
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Type assertion bypasses safety</b></div>
<div id="fix">
Using 'as unknown as ExploreChartHeaderProps' bypasses TypeScript type
checking, which violates the project's standards against using 'any' types.
This can hide type mismatches like the incorrect slice property name.
</div>
<details>
<summary>
<b>Code suggestion</b>
</summary>
<blockquote>Check the AI-generated fix before applying</blockquote>
<div id="code">
```
---
a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
+++
b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
@@ -149,3 +149,3 @@
...additionalProps,
-}) as unknown as ExploreChartHeaderProps;
+}) as ExploreChartHeaderProps;
fetchMock.post(
```
</div>
</details>
</div>
<details>
<summary><b>Citations</b></summary>
<ul>
<li>
Rule Violated: <a
href="https://github.com/apache/superset/blob/7a7661e/.cursor/rules/dev-standard.mdc#L16">dev-standard.mdc:16</a>
</li>
<li>
Rule Violated: <a
href="https://github.com/apache/superset/blob/7a7661e/AGENTS.md#L57">AGENTS.md:57</a>
</li>
</ul>
</details>
<small><i>Code Review Run #118f21</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a
href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]