This is an automated email from the ASF dual-hosted git repository.
vogievetsky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new 3e42ebbaead Web console: Fix the supervisor offset reset dialog.
(#16298)
3e42ebbaead is described below
commit 3e42ebbaeadb55877b3a587b056f266c7b508673
Author: Vadim Ogievetsky <[email protected]>
AuthorDate: Fri Apr 19 17:25:46 2024 -0700
Web console: Fix the supervisor offset reset dialog. (#16298)
* Add host to query output
* Init fixes for reset offsets
* fix the supervisor offset reset dialog
* Update web-console/src/views/load-data-view/load-data-view.tsx
Co-authored-by: Katya Macedo <[email protected]>
* Update web-console/src/views/load-data-view/load-data-view.tsx
Co-authored-by: Katya Macedo <[email protected]>
* Update web-console/src/views/load-data-view/load-data-view.tsx
Co-authored-by: Katya Macedo <[email protected]>
* reformat code
* '
* fix conflict
---------
Co-authored-by: Katya Macedo <[email protected]>
---
.../query-error-pane/query-error-pane.tsx | 1 +
.../supervisor-reset-offsets-dialog.scss | 4 +-
.../supervisor-reset-offsets-dialog.tsx | 194 ++++++++++++++++-----
web-console/src/druid-models/index.ts | 1 +
.../supervisor-status/supervisor-status.ts | 54 ++++++
web-console/src/utils/basic-action.tsx | 13 +-
web-console/src/utils/general.tsx | 5 +
.../src/views/load-data-view/load-data-view.tsx | 20 +++
.../views/supervisors-view/supervisors-view.tsx | 4 +-
9 files changed, 244 insertions(+), 52 deletions(-)
diff --git a/web-console/src/components/query-error-pane/query-error-pane.tsx
b/web-console/src/components/query-error-pane/query-error-pane.tsx
index 284b58e21ff..72e0b59521d 100644
--- a/web-console/src/components/query-error-pane/query-error-pane.tsx
+++ b/web-console/src/components/query-error-pane/query-error-pane.tsx
@@ -100,6 +100,7 @@ export const QueryErrorPane = React.memo(function
QueryErrorPane(props: QueryErr
</p>
)}
{error.errorClass && <p>{error.errorClass}</p>}
+ {error.host && <p>{`Host: ${error.host}`}</p>}
</div>
);
});
diff --git
a/web-console/src/dialogs/supervisor-reset-offsets-dialog/supervisor-reset-offsets-dialog.scss
b/web-console/src/dialogs/supervisor-reset-offsets-dialog/supervisor-reset-offsets-dialog.scss
index 23015cf0bb0..0a04204564f 100644
---
a/web-console/src/dialogs/supervisor-reset-offsets-dialog/supervisor-reset-offsets-dialog.scss
+++
b/web-console/src/dialogs/supervisor-reset-offsets-dialog/supervisor-reset-offsets-dialog.scss
@@ -26,7 +26,7 @@
max-height: 80vh;
}
- .label-button {
- pointer-events: none;
+ .new-offset-label {
+ margin: 4px 9px 0 0 !important;
}
}
diff --git
a/web-console/src/dialogs/supervisor-reset-offsets-dialog/supervisor-reset-offsets-dialog.tsx
b/web-console/src/dialogs/supervisor-reset-offsets-dialog/supervisor-reset-offsets-dialog.tsx
index d9247b809f1..168796072bc 100644
---
a/web-console/src/dialogs/supervisor-reset-offsets-dialog/supervisor-reset-offsets-dialog.tsx
+++
b/web-console/src/dialogs/supervisor-reset-offsets-dialog/supervisor-reset-offsets-dialog.tsx
@@ -16,32 +16,92 @@
* limitations under the License.
*/
-import { Button, Classes, Code, ControlGroup, Dialog, FormGroup, Intent } from
'@blueprintjs/core';
+import {
+ Button,
+ Classes,
+ ControlGroup,
+ Dialog,
+ FormGroup,
+ Intent,
+ Label,
+ Tag,
+} from '@blueprintjs/core';
import React, { useState } from 'react';
-import { Loader } from '../../components';
+import { type FormJsonTabs, FormJsonSelector, JsonInput, Loader } from
'../../components';
import { FancyNumericInput } from
'../../components/fancy-numeric-input/fancy-numeric-input';
+import type { SupervisorOffsetMap, SupervisorStatus } from
'../../druid-models';
import { useQueryManager } from '../../hooks';
import { Api, AppToaster } from '../../singletons';
-import { deepDelete, deepGet, getDruidErrorMessage } from '../../utils';
+import {
+ deepDelete,
+ deepGet,
+ formatInteger,
+ getDruidErrorMessage,
+ isNumberLike,
+} from '../../utils';
import './supervisor-reset-offsets-dialog.scss';
-type OffsetMap = Record<string, number>;
+function numberOrUndefined(x: any): number | undefined {
+ if (typeof x === 'undefined') return;
+ return Number(x);
+}
+
+interface PartitionEntry {
+ partition: string;
+ currentOffset?: number;
+}
+function getPartitionEntries(
+ supervisorStatus: SupervisorStatus,
+ partitionOffsetMap: SupervisorOffsetMap,
+): PartitionEntry[] {
+ const latestOffsets = supervisorStatus.payload?.latestOffsets;
+ const minimumLag = supervisorStatus.payload?.minimumLag;
+ let partitions: PartitionEntry[];
+ if (latestOffsets && minimumLag) {
+ partitions = Object.entries(latestOffsets).map(([partition, latestOffset])
=> {
+ return {
+ partition,
+ currentOffset: Number(latestOffset) - Number(minimumLag[partition] ||
0),
+ };
+ });
+ } else {
+ partitions = [];
+ const numPartitions = supervisorStatus.payload?.partitions;
+ for (let p = 0; p < numPartitions; p++) {
+ partitions.push({ partition: String(p) });
+ }
+ }
+
+ Object.keys(partitionOffsetMap).forEach(p => {
+ if (partitions.some(({ partition }) => partition === p)) return;
+ partitions.push({ partition: p });
+ });
+
+ partitions.sort((a, b) => {
+ return a.partition.localeCompare(b.partition, undefined, { numeric: true
});
+ });
+
+ return partitions;
+}
interface SupervisorResetOffsetsDialogProps {
supervisorId: string;
supervisorType: string;
- onClose: () => void;
+ onClose(): void;
}
export const SupervisorResetOffsetsDialog = React.memo(function
SupervisorResetOffsetsDialog(
props: SupervisorResetOffsetsDialogProps,
) {
const { supervisorId, supervisorType, onClose } = props;
- const [offsetsToResetTo, setOffsetsToResetTo] = useState<OffsetMap>({});
+ const [partitionOffsetMap, setPartitionOffsetMap] =
useState<SupervisorOffsetMap>({});
+ const [currentTab, setCurrentTab] = useState<FormJsonTabs>('form');
+ const [jsonError, setJsonError] = useState<Error | undefined>();
+ const disableSubmit = Boolean(jsonError);
- const [statusResp] = useQueryManager<string, OffsetMap>({
+ const [statusResp] = useQueryManager<string, SupervisorStatus>({
initQuery: supervisorId,
processQuery: async supervisorId => {
const statusResp = await Api.instance.get(
@@ -51,13 +111,15 @@ export const SupervisorResetOffsetsDialog =
React.memo(function SupervisorResetO
},
});
- const stream = deepGet(statusResp.data || {}, 'payload.stream');
- const latestOffsets = deepGet(statusResp.data || {},
'payload.latestOffsets');
- const latestOffsetsEntries = latestOffsets ? Object.entries(latestOffsets) :
undefined;
+ // Kafka: Topic, Partition, Offset
+ // Kinesis: Stream, Shard, Sequence number
+ const partitionLabel = supervisorType === 'kinesis' ? 'Shard' : 'Partition';
+ const offsetLabel = supervisorType === 'kinesis' ? 'sequence number' :
'offset';
- async function onSave() {
+ async function onSubmit() {
+ const stream = deepGet(statusResp.data || {}, 'payload.stream');
if (!stream) return;
- if (!Object.keys(offsetsToResetTo).length) return;
+ if (!Object.keys(partitionOffsetMap).length) return;
try {
await Api.instance.post(
@@ -67,68 +129,112 @@ export const SupervisorResetOffsetsDialog =
React.memo(function SupervisorResetO
partitions: {
type: 'end',
stream,
- partitionOffsetMap: offsetsToResetTo,
+ partitionOffsetMap,
},
},
);
} catch (e) {
AppToaster.show({
- message: `Failed to set offsets: ${getDruidErrorMessage(e)}`,
+ message: `Failed to set ${offsetLabel}s: ${getDruidErrorMessage(e)}`,
intent: Intent.DANGER,
});
return;
}
AppToaster.show({
- message: `${supervisorId} offsets have been set`,
+ message: (
+ <>
+ <Tag minimal>{supervisorId}</Tag> {offsetLabel}s have been set.
+ </>
+ ),
intent: Intent.SUCCESS,
});
onClose();
}
+ const supervisorStatus = statusResp.data;
return (
<Dialog
className="supervisor-reset-offsets-dialog"
isOpen
onClose={onClose}
- title={`Set supervisor offsets: ${supervisorId}`}
+ title={`Set supervisor ${offsetLabel}s`}
>
<div className={Classes.DIALOG_BODY}>
- {statusResp.loading && <Loader />}
- {latestOffsetsEntries && (
+ <p>
+ Set <Tag minimal>{supervisorId}</Tag> to read from specific
{offsetLabel}s.
+ </p>
+ <FormJsonSelector
+ tab={currentTab}
+ onChange={t => {
+ setJsonError(undefined);
+ setCurrentTab(t);
+ }}
+ />
+ {currentTab === 'form' ? (
<>
- <p>
- Set <Code>{supervisorId}</Code> to specific offsets
- </p>
- {latestOffsetsEntries.map(([key, latestOffset]) => (
- <FormGroup key={key} label={key} helperText={`(currently:
${latestOffset})`}>
- <ControlGroup>
- <Button className="label-button" text="New offset:" disabled
/>
- <FancyNumericInput
- value={offsetsToResetTo[key]}
- onValueChange={valueAsNumber => {
- setOffsetsToResetTo({ ...offsetsToResetTo, [key]:
valueAsNumber });
- }}
- onValueEmpty={() => {
- setOffsetsToResetTo(deepDelete(offsetsToResetTo, key));
- }}
- min={0}
- fill
- placeholder="Don't change offset"
- />
- </ControlGroup>
- </FormGroup>
- ))}
- {latestOffsetsEntries.length === 0 && (
- <p>There are no partitions currently in this supervisor.</p>
- )}
+ {statusResp.loading && <Loader />}
+ {supervisorStatus &&
+ getPartitionEntries(supervisorStatus, partitionOffsetMap).map(
+ ({ partition, currentOffset }) => (
+ <FormGroup
+ key={partition}
+ label={`${partitionLabel} ${partition}${
+ typeof currentOffset === 'undefined'
+ ? ''
+ : ` (current
${offsetLabel}=${formatInteger(currentOffset)})`
+ }:`}
+ >
+ <ControlGroup>
+ <Label className="new-offset-label">{`New
${offsetLabel}:`}</Label>
+ <FancyNumericInput
+
value={numberOrUndefined(partitionOffsetMap[partition])}
+ onValueChange={valueAsNumber => {
+ setPartitionOffsetMap({
+ ...partitionOffsetMap,
+ [partition]: valueAsNumber,
+ });
+ }}
+ onValueEmpty={() => {
+ setPartitionOffsetMap(deepDelete(partitionOffsetMap,
partition));
+ }}
+ min={0}
+ fill
+ placeholder={`Don't change ${offsetLabel}`}
+ />
+ </ControlGroup>
+ </FormGroup>
+ ),
+ )}
</>
+ ) : (
+ <JsonInput
+ value={partitionOffsetMap}
+ onChange={setPartitionOffsetMap}
+ setError={setJsonError}
+ issueWithValue={value => {
+ if (!value || typeof value !== 'object') {
+ return `The ${offsetLabel} map must be an object`;
+ }
+ const badValue = Object.entries(value).find(([_, v]) =>
!isNumberLike(v));
+ if (badValue) {
+ return `The value of ${badValue[0]} is not a number`;
+ }
+ return;
+ }}
+ height="300px"
+ />
)}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button text="Close" onClick={onClose} />
- <Button text="Save" intent={Intent.PRIMARY} onClick={() => void
onSave()} />
+ <Button
+ text="Submit"
+ intent={Intent.PRIMARY}
+ disabled={disableSubmit}
+ onClick={() => void onSubmit()}
+ />
</div>
</div>
</Dialog>
diff --git a/web-console/src/druid-models/index.ts
b/web-console/src/druid-models/index.ts
index 18cd812c610..e768afeb4b9 100644
--- a/web-console/src/druid-models/index.ts
+++ b/web-console/src/druid-models/index.ts
@@ -36,6 +36,7 @@ export * from './metric-spec/metric-spec';
export * from './overlord-dynamic-config/overlord-dynamic-config';
export * from './query-context/query-context';
export * from './stages/stages';
+export * from './supervisor-status/supervisor-status';
export * from './task/task';
export * from './time/time';
export * from './timestamp-spec/timestamp-spec';
diff --git
a/web-console/src/druid-models/supervisor-status/supervisor-status.ts
b/web-console/src/druid-models/supervisor-status/supervisor-status.ts
new file mode 100644
index 00000000000..b25c9f5fb1f
--- /dev/null
+++ b/web-console/src/druid-models/supervisor-status/supervisor-status.ts
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+
+import type { NumberLike } from '../../utils';
+
+export type SupervisorOffsetMap = Record<string, NumberLike>;
+
+export interface SupervisorStatus {
+ generationTime: string;
+ id: string;
+ payload: {
+ dataSource: string;
+ stream: string;
+ partitions: number;
+ replicas: number;
+ durationSeconds: number;
+ activeTasks: SupervisorStatusTask[];
+ publishingTasks: SupervisorStatusTask[];
+ latestOffsets?: SupervisorOffsetMap;
+ minimumLag?: SupervisorOffsetMap;
+ aggregateLag: number;
+ offsetsLastUpdated: string;
+ suspended: boolean;
+ healthy: boolean;
+ state: string;
+ detailedState: string;
+ recentErrors: any[];
+ };
+}
+
+export interface SupervisorStatusTask {
+ id: string;
+ startingOffsets: SupervisorOffsetMap;
+ startTime: '2024-04-12T21:35:34.834Z';
+ remainingSeconds: number;
+ type: string;
+ currentOffsets: SupervisorOffsetMap;
+ lag: SupervisorOffsetMap;
+}
diff --git a/web-console/src/utils/basic-action.tsx
b/web-console/src/utils/basic-action.tsx
index 5e0b270cc12..7cb591b6322 100644
--- a/web-console/src/utils/basic-action.tsx
+++ b/web-console/src/utils/basic-action.tsx
@@ -26,19 +26,22 @@ export interface BasicAction {
title: string;
intent?: Intent;
onAction: () => void;
+ disabledReason?: string;
}
export function basicActionsToMenu(basicActions: BasicAction[]): JSX.Element |
undefined {
if (!basicActions.length) return;
return (
<Menu>
- {basicActions.map((action, i) => (
+ {basicActions.map(({ icon, title, intent, onAction, disabledReason }, i)
=> (
<MenuItem
key={i}
- icon={action.icon}
- text={action.title}
- intent={action.intent}
- onClick={action.onAction}
+ icon={icon}
+ text={title}
+ intent={intent}
+ onClick={onAction}
+ disabled={Boolean(disabledReason)}
+ title={disabledReason}
/>
))}
</Menu>
diff --git a/web-console/src/utils/general.tsx
b/web-console/src/utils/general.tsx
index dd3876eecc6..3a770c67630 100644
--- a/web-console/src/utils/general.tsx
+++ b/web-console/src/utils/general.tsx
@@ -33,6 +33,11 @@ export const EMPTY_ARRAY: any[] = [];
export type NumberLike = number | bigint;
+export function isNumberLike(x: unknown): x is NumberLike {
+ const t = typeof x;
+ return t === 'number' || t === 'bigint';
+}
+
export function isNumberLikeNaN(x: NumberLike): boolean {
return isNaN(Number(x));
}
diff --git a/web-console/src/views/load-data-view/load-data-view.tsx
b/web-console/src/views/load-data-view/load-data-view.tsx
index ccc48a17459..2bd3623fa0e 100644
--- a/web-console/src/views/load-data-view/load-data-view.tsx
+++ b/web-console/src/views/load-data-view/load-data-view.tsx
@@ -3246,6 +3246,26 @@ export class LoadDataView extends
React.PureComponent<LoadDataViewProps, LoadDat
</>
),
},
+ {
+ name: 'suspended',
+ type: 'boolean',
+ defined: isStreamingSpec,
+ defaultValue: false,
+ info: (
+ <>
+ <p>Create a supervisor in a suspended state.</p>
+ <p>
+ Creating a supervisor in a suspended state can be
helpful if you are not yet
+ ready to begin ingesting data or if you prefer to
configure the
+ supervisor's metadata before starting it.
+ </p>
+ <p>
+ You can configure the exact offsets that the supervisor
will read from using
+ the <Code>Actions</Code> menu on the
<Code>Supervisors</Code> tab.
+ </p>
+ </>
+ ),
+ },
]}
model={spec}
onChange={this.updateSpec}
diff --git a/web-console/src/views/supervisors-view/supervisors-view.tsx
b/web-console/src/views/supervisors-view/supervisors-view.tsx
index 2959e06f62c..fef74fbeebe 100644
--- a/web-console/src/views/supervisors-view/supervisors-view.tsx
+++ b/web-console/src/views/supervisors-view/supervisors-view.tsx
@@ -343,14 +343,16 @@ GROUP BY 1, 2`;
},
{
icon: IconNames.STEP_BACKWARD,
- title: 'Set offsets',
+ title: `Set ${type === 'kinesis' ? 'sequence numbers' : 'offsets'}`,
onAction: () => this.setState({ resetOffsetsSupervisorInfo: { id, type
} }),
+ disabledReason: supervisorSuspended ? undefined : `Supervisor must be
suspended`,
},
{
icon: IconNames.STEP_BACKWARD,
title: 'Hard reset',
intent: Intent.DANGER,
onAction: () => this.setState({ resetSupervisorId: id }),
+ disabledReason: supervisorSuspended ? undefined : `Supervisor must be
suspended`,
},
{
icon: IconNames.CROSS,
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]