diff --git a/package.json b/package.json index 50adedd13..defc51779 100644 --- a/package.json +++ b/package.json @@ -835,6 +835,12 @@ "title": "Refresh", "category": "Confluent: Topics" }, + { + "command": "confluent.topics.refreshResourceContainer", + "icon": "$(sync)", + "title": "Refresh Group", + "category": "Confluent: Topics View" + }, { "command": "confluent.topics.search", "icon": "$(search)", @@ -1722,6 +1728,10 @@ "command": "confluent.topics.refresh", "when": "true" }, + { + "command": "confluent.topics.refreshResourceContainer", + "when": "false" + }, { "command": "confluent.topics.search", "when": "true" @@ -2192,6 +2202,21 @@ "when": "view == confluent-topics && viewItem =~ /ccloud-kafka-topic.*-flinkable.*/", "group": "z_openInCloud" }, + { + "command": "confluent.topics.refreshResourceContainer", + "when": "view == confluent-topics && viewItem =~ /.*-container/", + "group": "inline@2" + }, + { + "command": "confluent.copyResourceId", + "when": "view == confluent-topics && viewItem =~ /.*-consumer-group-.*/", + "group": "2_copy@1" + }, + { + "command": "confluent.openCCloudLink", + "when": "view == confluent-topics && viewItem =~ /ccloud-consumer-group-.*/", + "group": "z_openInCloud" + }, { "command": "confluent.artifacts.createUdfRegistrationDocument", "when": "view == confluent-flink-database && viewItem =~ /.*-flink-artifact/", diff --git a/src/commands/topicsView.test.ts b/src/commands/topicsView.test.ts new file mode 100644 index 000000000..b134ad556 --- /dev/null +++ b/src/commands/topicsView.test.ts @@ -0,0 +1,135 @@ +import * as assert from "assert"; +import * as sinon from "sinon"; + +import * as indexModule from "."; + +import { refreshResourceContainerCommand, registerTopicsViewCommands } from "./topicsView"; + +import { ConnectionType } from "../clients/sidecar"; +import { + KafkaClusterContainerLabel, + KafkaClusterResourceContainer, +} from "../models/containers/kafkaClusterResourceContainer"; +import type { ConsumerGroup } from "../models/consumerGroup"; +import type { KafkaTopic } from "../models/topic"; +import { TopicViewProvider } from "../viewProviders/topics"; +import { TEST_CCLOUD_KAFKA_CLUSTER } from "../../tests/unit/testResources/kafkaCluster"; + +describe("commands/topicsView.ts", () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("registerTopicsViewCommands", () => { + let registerCommandWithLoggingStub: sinon.SinonStub; + + beforeEach(() => { + registerCommandWithLoggingStub = sandbox.stub(indexModule, "registerCommandWithLogging"); + }); + + it("should register the expected commands", () => { + registerTopicsViewCommands(); + + assert.strictEqual(registerCommandWithLoggingStub.callCount, 1); + + sinon.assert.calledWithExactly( + registerCommandWithLoggingStub, + "confluent.topics.refreshResourceContainer", + refreshResourceContainerCommand, + ); + }); + }); + + describe("refreshResourceContainerCommand", () => { + let provider: TopicViewProvider; + let refreshTopicsStub: sinon.SinonStub; + let refreshConsumerGroupsStub: sinon.SinonStub; + + beforeEach(() => { + provider = TopicViewProvider.getInstance(); + provider["resource"] = TEST_CCLOUD_KAFKA_CLUSTER; + + refreshTopicsStub = sandbox.stub(provider, "refreshTopics").resolves(); + refreshConsumerGroupsStub = sandbox.stub(provider, "refreshConsumerGroups").resolves(); + }); + + afterEach(() => { + provider.dispose(); + TopicViewProvider["instanceMap"].clear(); + }); + + it("should bail early if no container is provided", async () => { + await refreshResourceContainerCommand(undefined as any); + + sinon.assert.notCalled(refreshTopicsStub); + sinon.assert.notCalled(refreshConsumerGroupsStub); + }); + + it("should bail early if no Kafka cluster is selected", async () => { + provider["resource"] = null; + const container = new KafkaClusterResourceContainer( + TEST_CCLOUD_KAFKA_CLUSTER.connectionId, + ConnectionType.Ccloud, + KafkaClusterContainerLabel.CONSUMER_GROUPS, + [], + ); + + await refreshResourceContainerCommand(container); + + sinon.assert.notCalled(refreshTopicsStub); + sinon.assert.notCalled(refreshConsumerGroupsStub); + }); + + it("should call refreshTopics when the Topics container is provided", async () => { + const container = new KafkaClusterResourceContainer( + TEST_CCLOUD_KAFKA_CLUSTER.connectionId, + ConnectionType.Ccloud, + KafkaClusterContainerLabel.TOPICS, + [], + ); + + await refreshResourceContainerCommand(container); + + sinon.assert.calledOnceWithExactly(refreshTopicsStub, TEST_CCLOUD_KAFKA_CLUSTER, true); + sinon.assert.notCalled(refreshConsumerGroupsStub); + }); + + it("should call refreshConsumerGroups when the Consumer Groups container is provided", async () => { + const container = new KafkaClusterResourceContainer( + TEST_CCLOUD_KAFKA_CLUSTER.connectionId, + ConnectionType.Ccloud, + KafkaClusterContainerLabel.CONSUMER_GROUPS, + [], + ); + + await refreshResourceContainerCommand(container); + + sinon.assert.notCalled(refreshTopicsStub); + sinon.assert.calledOnceWithExactly( + refreshConsumerGroupsStub, + TEST_CCLOUD_KAFKA_CLUSTER, + true, + ); + }); + + it("should log an error for an unknown container label", async () => { + const container = new KafkaClusterResourceContainer( + TEST_CCLOUD_KAFKA_CLUSTER.connectionId, + ConnectionType.Ccloud, + "Unknown Label" as any, + [], + ); + + await refreshResourceContainerCommand(container); + + sinon.assert.notCalled(refreshTopicsStub); + sinon.assert.notCalled(refreshConsumerGroupsStub); + }); + }); +}); diff --git a/src/commands/topicsView.ts b/src/commands/topicsView.ts new file mode 100644 index 000000000..981a4063e --- /dev/null +++ b/src/commands/topicsView.ts @@ -0,0 +1,54 @@ +import type { Disposable } from "vscode"; +import { registerCommandWithLogging } from "."; +import { Logger } from "../logging"; +import type { ISearchable } from "../models/resource"; +import { + KafkaClusterContainerLabel, + type KafkaClusterResourceContainer, +} from "../models/containers/kafkaClusterResourceContainer"; +import { TopicViewProvider } from "../viewProviders/topics"; + +const logger = new Logger("commands.topicsView"); + +/** + * Refresh a resource container (Topics or Consumer Groups) in the Topics view. + * @param container The {@link KafkaClusterResourceContainer} tree item that was clicked. + */ +export async function refreshResourceContainerCommand( + container: KafkaClusterResourceContainer, +): Promise { + if (!container) { + logger.error("No container provided to refreshResourceContainerCommand"); + return; + } + + const provider = TopicViewProvider.getInstance(); + const cluster = provider.kafkaCluster; + if (!cluster) { + logger.error("No Kafka cluster selected when attempting to refresh resource container."); + return; + } + + switch (container.label) { + case KafkaClusterContainerLabel.TOPICS: + await provider.refreshTopics(cluster, true); + break; + case KafkaClusterContainerLabel.CONSUMER_GROUPS: + await provider.refreshConsumerGroups(cluster, true); + break; + default: + logger.error( + `Unknown container label "${container.label}" in refreshResourceContainerCommand`, + ); + } +} + +/** Register commands for the Topics view's container-level actions. */ +export function registerTopicsViewCommands(): Disposable[] { + return [ + registerCommandWithLogging( + "confluent.topics.refreshResourceContainer", + refreshResourceContainerCommand, + ), + ]; +} diff --git a/src/extension.ts b/src/extension.ts index 6a33b0e20..e1752e1ae 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,6 +23,7 @@ import { registerChatTools } from "./chat/tools/registration"; import { FlinkSqlCodelensProvider } from "./codelens/flinkSqlProvider"; import { registerCommandWithLogging } from "./commands"; import { registerConnectionCommands } from "./commands/connections"; +import { registerTopicsViewCommands } from "./commands/topicsView"; import { registerDebugCommands } from "./commands/debugtools"; import { registerDiffCommands } from "./commands/diffs"; import { registerDockerCommands } from "./commands/docker"; @@ -263,6 +264,7 @@ async function _activateExtension( ...registerSchemaRegistryCommands(), ...registerSchemaCommands(), ...registerTopicCommands(), + ...registerTopicsViewCommands(), ...registerDiffCommands(), ...registerExtraCommands(), ...registerDockerCommands(), diff --git a/src/models/consumerGroup.test.ts b/src/models/consumerGroup.test.ts index 664bb9778..45945ae7d 100644 --- a/src/models/consumerGroup.test.ts +++ b/src/models/consumerGroup.test.ts @@ -16,16 +16,18 @@ import { TEST_CCLOUD_KAFKA_CLUSTER } from "../../tests/unit/testResources/kafkaC import { ConnectionType } from "../clients/sidecar"; import { CCLOUD_BASE_PATH, CCLOUD_CONNECTION_ID } from "../constants"; import { IconNames } from "../icons"; -import { ConsumerGroupState, ConsumerGroupTreeItem, ConsumerTreeItem } from "./consumerGroup"; +import { + ConsumerGroup, + ConsumerGroupState, + ConsumerGroupTreeItem, + ConsumerTreeItem, +} from "./consumerGroup"; describe("models/consumerGroup.ts", () => { describe("ConsumerGroup", () => { describe("id", () => { - it("should return clusterId-consumerGroupId", () => { - assert.strictEqual( - TEST_CCLOUD_CONSUMER_GROUP.id, - `${TEST_CCLOUD_CONSUMER_GROUP.clusterId}-${TEST_CCLOUD_CONSUMER_GROUP_ID}`, - ); + it("should return consumerGroupId", () => { + assert.strictEqual(TEST_CCLOUD_CONSUMER_GROUP.id, TEST_CCLOUD_CONSUMER_GROUP_ID); }); }); @@ -111,12 +113,8 @@ describe("models/consumerGroup.ts", () => { describe("Consumer", () => { describe("id", () => { - it("should return clusterId-consumerGroupId-consumerId", () => { - const consumer = TEST_CCLOUD_CONSUMER; - assert.strictEqual( - consumer.id, - `${consumer.clusterId}-${consumer.consumerGroupId}-${consumer.consumerId}`, - ); + it("should return consumerId", () => { + assert.strictEqual(TEST_CCLOUD_CONSUMER.id, TEST_CCLOUD_CONSUMER.consumerId); }); }); @@ -179,10 +177,11 @@ describe("models/consumerGroup.ts", () => { assert.strictEqual(treeItem.label, TEST_CCLOUD_CONSUMER_GROUP.consumerGroupId); }); - it("should set id from the resource", () => { + it("should set id as clusterId-consumerGroupId", () => { const treeItem = new ConsumerGroupTreeItem(TEST_CCLOUD_CONSUMER_GROUP); + const group = TEST_CCLOUD_CONSUMER_GROUP; - assert.strictEqual(treeItem.id, TEST_CCLOUD_CONSUMER_GROUP.id); + assert.strictEqual(treeItem.id, `${group.clusterId}-${group.consumerGroupId}`); }); it("should include connection type and state in contextValue", () => { @@ -315,6 +314,39 @@ describe("models/consumerGroup.ts", () => { assert.ok(text.includes("currently rebalancing")); }); + + it("should show 'Yes' for simple consumer groups", () => { + const group = createConsumerGroup({ + connectionId: CCLOUD_CONNECTION_ID, + connectionType: ConnectionType.Ccloud, + environmentId: TEST_CCLOUD_KAFKA_CLUSTER.environmentId, + clusterId: TEST_CCLOUD_KAFKA_CLUSTER.id, + isSimple: true, + }); + const item = new ConsumerGroupTreeItem(group); + const tooltip = (item.tooltip as MarkdownString).value; + + assert.ok(tooltip.includes("Yes")); + }); + + it("should omit coordinator when coordinatorId is null", () => { + const group = new ConsumerGroup({ + connectionId: CCLOUD_CONNECTION_ID, + connectionType: ConnectionType.Ccloud, + environmentId: TEST_CCLOUD_KAFKA_CLUSTER.environmentId, + clusterId: TEST_CCLOUD_KAFKA_CLUSTER.id, + consumerGroupId: "test-group", + state: ConsumerGroupState.Stable, + isSimple: false, + partitionAssignor: "range", + coordinatorId: null, + members: [], + }); + const item = new ConsumerGroupTreeItem(group); + const tooltip = (item.tooltip as MarkdownString).value; + + assert.ok(!tooltip.includes("Coordinator Broker")); + }); }); }); @@ -340,9 +372,14 @@ describe("models/consumerGroup.ts", () => { assert.strictEqual(treeItem.label, consumer.consumerId); }); - it("should set id from the resource", () => { + it("should set id as clusterId-clientId-consumerId", () => { const treeItem = new ConsumerTreeItem(TEST_CCLOUD_CONSUMER); - assert.strictEqual(treeItem.id, TEST_CCLOUD_CONSUMER.id); + const consumer = TEST_CCLOUD_CONSUMER; + + assert.strictEqual( + treeItem.id, + `${consumer.clusterId}-${consumer.clientId}-${consumer.consumerId}`, + ); }); it("should include connection type in contextValue", () => { diff --git a/src/models/consumerGroup.ts b/src/models/consumerGroup.ts index 492af9e5e..fd542b69c 100644 --- a/src/models/consumerGroup.ts +++ b/src/models/consumerGroup.ts @@ -79,7 +79,8 @@ export class ConsumerGroup implements IResourceBase, ISearchable, IdItem { } get id(): string { - return `${this.clusterId}-${this.consumerGroupId}`; + // this can't include the cluster ID since this will be used in Copy commands + return this.consumerGroupId; } get hasMembers(): boolean { @@ -147,7 +148,8 @@ export class Consumer implements IResourceBase, ISearchable, IdItem { } get id(): string { - return `${this.clusterId}-${this.consumerGroupId}-${this.consumerId}`; + // this can't include the cluster ID since this will be used in Copy commands + return this.consumerId; } searchableText(): string { @@ -169,7 +171,7 @@ export class ConsumerGroupTreeItem extends vscode.TreeItem { constructor(resource: ConsumerGroup) { super(resource.consumerGroupId); - this.id = resource.id; + this.id = `${resource.clusterId}-${resource.consumerGroupId}`; this.resource = resource; // includes state for conditional menu visibility, like: // "ccloud-consumer-group-STABLE" or "local-consumer-group-EMPTY" @@ -231,7 +233,7 @@ export class ConsumerTreeItem extends vscode.TreeItem { : resource.consumerId; super(label); - this.id = resource.id; + this.id = `${resource.clusterId}-${resource.clientId}-${resource.consumerId}`; this.resource = resource; this.contextValue = `${resource.connectionType.toLowerCase()}-consumer-group-member`; diff --git a/src/viewProviders/topics.ts b/src/viewProviders/topics.ts index cb43195d7..8379e6bf1 100644 --- a/src/viewProviders/topics.ts +++ b/src/viewProviders/topics.ts @@ -216,7 +216,7 @@ export class TopicViewProvider extends ParentedBaseViewProvider< cluster.connectionType, KafkaClusterContainerLabel.CONSUMER_GROUPS, [], - undefined, // no context value for now since no commands are needed yet for this container + "consumer-groups-container", new ThemeIcon(IconNames.CONSUMER_GROUP), );