feat(rabbitmq): add message queue management support

This commit is contained in:
Yann丶 authored and GitHub committed 2026-07-22 23:52:09 +08:00
1 parent 503d6748d4
commit 39e554369f
76 files changed
+14915 -707

No files matched your search

@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
const nsRoot = { tenant: "_rabbitmq", namespace: "/" };
describe("mq client connections/channels tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_list_client_connections with the namespace ref", async () => {
const { mqListClientConnections } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "192.168.1.10:51000 -> 192.168.1.126:5672", user: "jjsd", peerHost: "192.168.1.10", peerPort: 51000, state: "running", channels: 2 }]);
const result = await mqListClientConnections("conn-1", nsRoot);
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_client_connections", { connectionId: "conn-1", ns: nsRoot });
expect(result).toHaveLength(1);
expect(result[0]?.peerHost).toBe("192.168.1.10");
});
it("invokes mq_list_client_channels with optional connection filter", async () => {
const { mqListClientChannels } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "conn (1)", state: "running", prefetch: 10, messagesUnacked: 0, consumerCount: 1 }]);
const result = await mqListClientChannels("conn-1", nsRoot, "conn");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_client_channels", { connectionId: "conn-1", ns: nsRoot, connection: "conn" });
expect(result[0]?.consumerCount).toBe(1);
await mqListClientChannels("conn-1", nsRoot);
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_client_channels", { connectionId: "conn-1", ns: nsRoot, connection: undefined });
});
it("invokes mq_close_client_connection with the connection name", async () => {
const { mqCloseClientConnection } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqCloseClientConnection("conn-1", nsRoot, "192.168.1.10:51000 -> 192.168.1.126:5672");
expect(mocks.invoke).toHaveBeenCalledWith("mq_close_client_connection", { connectionId: "conn-1", ns: nsRoot, name: "192.168.1.10:51000 -> 192.168.1.126:5672" });
});
});
describe("mq client connections/channels HTTP API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function stubFetch() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([]),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts to the client-connections endpoints", async () => {
const fetchMock = stubFetch();
const { mqListClientConnections, mqCloseClientConnection } = await import("@/lib/backend/mq-http");
await mqListClientConnections("conn-1", nsRoot);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/client-connections/list", body: { connectionId: "conn-1", ns: nsRoot } });
await mqCloseClientConnection("conn-1", nsRoot, "conn-name");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/client-connections/close", body: { connectionId: "conn-1", ns: nsRoot, name: "conn-name" } });
});
it("posts to the channels list endpoint with optional connection filter", async () => {
const fetchMock = stubFetch();
const { mqListClientChannels } = await import("@/lib/backend/mq-http");
await mqListClientChannels("conn-1", nsRoot, "conn");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/channels/list", body: { connectionId: "conn-1", ns: nsRoot, connection: "conn" } });
await mqListClientChannels("conn-1", nsRoot);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/channels/list", body: { connectionId: "conn-1", ns: nsRoot, connection: undefined } });
});
it("surfaces HTTP errors with the response detail", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue("connection not found"),
}),
);
const { mqCloseClientConnection } = await import("@/lib/backend/mq-http");
await expect(mqCloseClientConnection("conn-1", nsRoot, "gone")).rejects.toThrow("connection not found");
});
});
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
const NS = { tenant: "_rabbitmq", namespace: "/" };
describe("mq exchanges/bindings tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_list_exchanges with the namespace ref", async () => {
const { mqListExchanges } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "dbx-events", type: "topic", durable: true, autoDelete: false, internal: false }]);
const result = await mqListExchanges("conn-1", NS);
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_exchanges", { connectionId: "conn-1", ns: NS });
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("dbx-events");
});
it("invokes mq_create_exchange with flattened fields", async () => {
const { mqCreateExchange } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqCreateExchange("conn-1", NS, { name: "dbx-events", type: "topic", durable: true, autoDelete: false });
expect(mocks.invoke).toHaveBeenCalledWith("mq_create_exchange", {
connectionId: "conn-1",
ns: NS,
name: "dbx-events",
exchangeType: "topic",
durable: true,
autoDelete: false,
});
});
it("invokes mq_delete_exchange with the exchange name", async () => {
const { mqDeleteExchange } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqDeleteExchange("conn-1", NS, "dbx-events");
expect(mocks.invoke).toHaveBeenCalledWith("mq_delete_exchange", { connectionId: "conn-1", ns: NS, name: "dbx-events" });
});
it("invokes mq_list_bindings with optional filters", async () => {
const { mqListBindings } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([]);
await mqListBindings("conn-1", NS, { exchange: "dbx-events" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_bindings", { connectionId: "conn-1", ns: NS, exchange: "dbx-events", queue: undefined });
await mqListBindings("conn-1", NS);
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_bindings", { connectionId: "conn-1", ns: NS, exchange: undefined, queue: undefined });
});
it("invokes mq_bind and mq_unbind with the binding object", async () => {
const { mqBind, mqUnbind } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
const binding = { source: "dbx-events", destination: "dbx-queue", destinationType: "queue", routingKey: "orders.*" };
await mqBind("conn-1", NS, binding);
expect(mocks.invoke).toHaveBeenCalledWith("mq_bind", { connectionId: "conn-1", ns: NS, binding });
await mqUnbind("conn-1", NS, binding);
expect(mocks.invoke).toHaveBeenCalledWith("mq_unbind", { connectionId: "conn-1", ns: NS, binding });
});
});
describe("mq exchanges/bindings HTTP API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function stubFetch() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([]),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts to the exchanges endpoints", async () => {
const fetchMock = stubFetch();
const { mqListExchanges, mqCreateExchange, mqDeleteExchange } = await import("@/lib/backend/mq-http");
await mqListExchanges("conn-1", NS);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/exchanges/list", body: { connectionId: "conn-1", ns: NS } });
await mqCreateExchange("conn-1", NS, { name: "dbx-events", type: "fanout", durable: false, autoDelete: true });
expect(lastCall(fetchMock)).toEqual({
url: "/api/mq/exchanges/create",
body: { connectionId: "conn-1", ns: NS, name: "dbx-events", exchangeType: "fanout", durable: false, autoDelete: true },
});
await mqDeleteExchange("conn-1", NS, "dbx-events");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/exchanges/delete", body: { connectionId: "conn-1", ns: NS, name: "dbx-events" } });
});
it("posts to the bindings endpoints", async () => {
const fetchMock = stubFetch();
const { mqListBindings, mqBind, mqUnbind } = await import("@/lib/backend/mq-http");
const binding = { source: "dbx-events", destination: "dbx-queue", destinationType: "queue", routingKey: "orders.*" };
await mqListBindings("conn-1", NS, { queue: "dbx-queue" });
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/bindings/list", body: { connectionId: "conn-1", ns: NS, exchange: undefined, queue: "dbx-queue" } });
await mqBind("conn-1", NS, binding);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/bindings/bind", body: { connectionId: "conn-1", ns: NS, binding } });
await mqUnbind("conn-1", NS, binding);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/bindings/unbind", body: { connectionId: "conn-1", ns: NS, binding } });
});
it("surfaces HTTP errors with the response detail", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue("cannot delete built-in exchange"),
}),
);
const { mqDeleteExchange } = await import("@/lib/backend/mq-http");
await expect(mqDeleteExchange("conn-1", NS, "amq.direct")).rejects.toThrow("cannot delete built-in exchange");
});
});
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
describe("mq policies tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_list_policies with flattened filters", async () => {
const { mqListPolicies } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "dbx-ttl", vhost: "/", pattern: "^dbx-", applyTo: "queues", priority: 0, definition: { "message-ttl": 60000 } }]);
const result = await mqListPolicies("conn-1", { virtualHost: "/" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_policies", { connectionId: "conn-1", virtualHost: "/", allVhosts: undefined });
expect(result[0]?.name).toBe("dbx-ttl");
expect(result[0]?.definition).toEqual({ "message-ttl": 60000 });
await mqListPolicies("conn-1", { allVhosts: true });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_policies", { connectionId: "conn-1", virtualHost: undefined, allVhosts: true });
await mqListPolicies("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_policies", { connectionId: "conn-1", virtualHost: undefined, allVhosts: undefined });
});
it("invokes mq_set_policy with flattened fields", async () => {
const { mqSetPolicy } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqSetPolicy("conn-1", "/", { name: "dbx-ttl", pattern: "^dbx-", applyTo: "queues", priority: 10, definition: { "message-ttl": 60000 } });
expect(mocks.invoke).toHaveBeenCalledWith("mq_set_policy", {
connectionId: "conn-1",
virtualHost: "/",
name: "dbx-ttl",
pattern: "^dbx-",
applyTo: "queues",
priority: 10,
definition: { "message-ttl": 60000 },
});
await mqSetPolicy("conn-1", "/", { name: "dbx-dlx", pattern: ".*", definition: { "dead-letter-exchange": "dbx-dlx" } });
expect(mocks.invoke).toHaveBeenCalledWith("mq_set_policy", {
connectionId: "conn-1",
virtualHost: "/",
name: "dbx-dlx",
pattern: ".*",
applyTo: undefined,
priority: undefined,
definition: { "dead-letter-exchange": "dbx-dlx" },
});
});
it("invokes mq_delete_policy with vhost and name", async () => {
const { mqDeletePolicy } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqDeletePolicy("conn-1", "/", "dbx-ttl");
expect(mocks.invoke).toHaveBeenCalledWith("mq_delete_policy", { connectionId: "conn-1", virtualHost: "/", name: "dbx-ttl" });
});
});
describe("mq overview/nodes tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_get_overview with only the connection id", async () => {
const { mqGetOverview } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue({ messagesReady: 3, messagesUnacked: 1, publishRate: 0.5, totalQueues: 2 });
const result = await mqGetOverview("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_get_overview", { connectionId: "conn-1" });
expect(result.messagesReady).toBe(3);
expect(result.totalQueues).toBe(2);
});
it("invokes mq_list_nodes with only the connection id", async () => {
const { mqListNodes } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "rabbit@dbx", running: true, memUsed: 1024, memLimit: 4096, uptimeMs: 60000 }]);
const result = await mqListNodes("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_nodes", { connectionId: "conn-1" });
expect(result[0]?.name).toBe("rabbit@dbx");
expect(result[0]?.running).toBe(true);
});
});
describe("mq policies/overview/nodes HTTP API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function stubFetch(payload: unknown = []) {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(payload),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts to the policies endpoints", async () => {
const fetchMock = stubFetch();
const { mqListPolicies, mqSetPolicy, mqDeletePolicy } = await import("@/lib/backend/mq-http");
await mqListPolicies("conn-1", { allVhosts: true });
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/policies/list", body: { connectionId: "conn-1", virtualHost: undefined, allVhosts: true } });
await mqSetPolicy("conn-1", "/", { name: "dbx-ttl", pattern: "^dbx-", applyTo: "queues", priority: 10, definition: { "message-ttl": 60000 } });
expect(lastCall(fetchMock)).toEqual({
url: "/api/mq/policies/set",
body: { connectionId: "conn-1", virtualHost: "/", name: "dbx-ttl", pattern: "^dbx-", applyTo: "queues", priority: 10, definition: { "message-ttl": 60000 } },
});
await mqDeletePolicy("conn-1", "/", "dbx-ttl");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/policies/delete", body: { connectionId: "conn-1", virtualHost: "/", name: "dbx-ttl" } });
});
it("posts to the overview and nodes endpoints", async () => {
const fetchMock = stubFetch({});
const { mqGetOverview, mqListNodes } = await import("@/lib/backend/mq-http");
await mqGetOverview("conn-1");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/overview", body: { connectionId: "conn-1" } });
await mqListNodes("conn-1");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/nodes", body: { connectionId: "conn-1" } });
});
it("surfaces HTTP errors with the response detail", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 400,
text: vi.fn().mockResolvedValue('virtual host "*" is a listing sentinel'),
}),
);
const { mqSetPolicy } = await import("@/lib/backend/mq-http");
await expect(mqSetPolicy("conn-1", "*", { name: "dbx-ttl", pattern: ".*", definition: {} })).rejects.toThrow('virtual host "*" is a listing sentinel');
});
});
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
describe("mq users/permissions tauri API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("invokes mq_list_users with only the connection id", async () => {
const { mqListUsers } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ name: "dbx-app", tags: ["administrator"] }]);
const result = await mqListUsers("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_users", { connectionId: "conn-1" });
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe("dbx-app");
expect(result[0]?.tags).toEqual(["administrator"]);
});
it("invokes mq_create_user with name, password and optional tags", async () => {
const { mqCreateUser } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqCreateUser("conn-1", "dbx-app", "secret", ["monitoring", "management"]);
expect(mocks.invoke).toHaveBeenCalledWith("mq_create_user", { connectionId: "conn-1", name: "dbx-app", password: "secret", tags: ["monitoring", "management"] });
await mqCreateUser("conn-1", "dbx-app2", "secret2");
expect(mocks.invoke).toHaveBeenCalledWith("mq_create_user", { connectionId: "conn-1", name: "dbx-app2", password: "secret2", tags: undefined });
});
it("invokes mq_delete_user with the user name", async () => {
const { mqDeleteUser } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqDeleteUser("conn-1", "dbx-app");
expect(mocks.invoke).toHaveBeenCalledWith("mq_delete_user", { connectionId: "conn-1", name: "dbx-app" });
});
it("invokes mq_list_user_permissions with flattened filters", async () => {
const { mqListUserPermissions } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue([{ user: "dbx-app", vhost: "/", configure: ".*", write: ".*", read: ".*" }]);
const result = await mqListUserPermissions("conn-1", { virtualHost: "/" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_user_permissions", { connectionId: "conn-1", virtualHost: "/", user: undefined, allVhosts: undefined });
expect(result[0]?.vhost).toBe("/");
await mqListUserPermissions("conn-1", { allVhosts: true, user: "dbx-app" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_user_permissions", { connectionId: "conn-1", virtualHost: undefined, user: "dbx-app", allVhosts: true });
await mqListUserPermissions("conn-1");
expect(mocks.invoke).toHaveBeenCalledWith("mq_list_user_permissions", { connectionId: "conn-1", virtualHost: undefined, user: undefined, allVhosts: undefined });
});
it("invokes mq_grant_user_permission with optional patterns", async () => {
const { mqGrantUserPermission } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqGrantUserPermission("conn-1", "dbx-app", "/", { configure: "^dbx-", write: ".*", read: ".*" });
expect(mocks.invoke).toHaveBeenCalledWith("mq_grant_user_permission", { connectionId: "conn-1", user: "dbx-app", virtualHost: "/", configure: "^dbx-", write: ".*", read: ".*" });
await mqGrantUserPermission("conn-1", "dbx-app", "/");
expect(mocks.invoke).toHaveBeenCalledWith("mq_grant_user_permission", { connectionId: "conn-1", user: "dbx-app", virtualHost: "/", configure: undefined, write: undefined, read: undefined });
});
it("invokes mq_revoke_user_permission with user and vhost", async () => {
const { mqRevokeUserPermission } = await import("@/lib/backend/mq-tauri");
mocks.invoke.mockResolvedValue(undefined);
await mqRevokeUserPermission("conn-1", "dbx-app", "/");
expect(mocks.invoke).toHaveBeenCalledWith("mq_revoke_user_permission", { connectionId: "conn-1", user: "dbx-app", virtualHost: "/" });
});
});
describe("mq users/permissions HTTP API", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function stubFetch() {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([]),
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
function lastCall(fetchMock: ReturnType<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
}
it("posts to the users endpoints", async () => {
const fetchMock = stubFetch();
const { mqListUsers, mqCreateUser, mqDeleteUser } = await import("@/lib/backend/mq-http");
await mqListUsers("conn-1");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/users/list", body: { connectionId: "conn-1" } });
await mqCreateUser("conn-1", "dbx-app", "secret", ["monitoring"]);
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/users/create", body: { connectionId: "conn-1", name: "dbx-app", password: "secret", tags: ["monitoring"] } });
await mqDeleteUser("conn-1", "dbx-app");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/users/delete", body: { connectionId: "conn-1", name: "dbx-app" } });
});
it("posts to the user-permissions endpoints", async () => {
const fetchMock = stubFetch();
const { mqListUserPermissions, mqGrantUserPermission, mqRevokeUserPermission } = await import("@/lib/backend/mq-http");
await mqListUserPermissions("conn-1", { virtualHost: "/" });
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/user-permissions/list", body: { connectionId: "conn-1", virtualHost: "/", user: undefined, allVhosts: undefined } });
await mqGrantUserPermission("conn-1", "dbx-app", "/", { configure: "^dbx-" });
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/user-permissions/grant", body: { connectionId: "conn-1", user: "dbx-app", virtualHost: "/", configure: "^dbx-", write: undefined, read: undefined } });
await mqRevokeUserPermission("conn-1", "dbx-app", "/");
expect(lastCall(fetchMock)).toEqual({ url: "/api/mq/user-permissions/revoke", body: { connectionId: "conn-1", user: "dbx-app", virtualHost: "/" } });
});
it("surfaces HTTP errors with the response detail", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: vi.fn().mockResolvedValue("cannot delete the current connection user"),
}),
);
const { mqDeleteUser } = await import("@/lib/backend/mq-http");
await expect(mqDeleteUser("conn-1", "jjsd")).rejects.toThrow("cannot delete the current connection user");
});
});
+20
View File
@@ -414,6 +414,15 @@ export const mqDeleteTopic = forward("mqDeleteTopic");
export const mqUpdatePartitions = forward("mqUpdatePartitions");
export const mqGetTopicStats = forward("mqGetTopicStats");
export const mqGetTopicInternalStats = forward("mqGetTopicInternalStats");
export const mqListExchanges = forward("mqListExchanges");
export const mqCreateExchange = forward("mqCreateExchange");
export const mqDeleteExchange = forward("mqDeleteExchange");
export const mqListBindings = forward("mqListBindings");
export const mqBind = forward("mqBind");
export const mqUnbind = forward("mqUnbind");
export const mqListClientConnections = forward("mqListClientConnections");
export const mqListClientChannels = forward("mqListClientChannels");
export const mqCloseClientConnection = forward("mqCloseClientConnection");
export const mqListSubscriptions = forward("mqListSubscriptions");
export const mqCreateSubscription = forward("mqCreateSubscription");
export const mqDeleteSubscription = forward("mqDeleteSubscription");
@@ -449,6 +458,17 @@ export const mqQueryMessagesByTopic = forward("mqQueryMessagesByTopic");
export const mqQueryMessageTrace = forward("mqQueryMessageTrace");
export const mqRawRequest = forward("mqRawRequest");
export const mqSendMessage = forward("mqSendMessage");
export const mqListUsers = forward("mqListUsers");
export const mqCreateUser = forward("mqCreateUser");
export const mqDeleteUser = forward("mqDeleteUser");
export const mqListUserPermissions = forward("mqListUserPermissions");
export const mqGrantUserPermission = forward("mqGrantUserPermission");
export const mqRevokeUserPermission = forward("mqRevokeUserPermission");
export const mqListPolicies = forward("mqListPolicies");
export const mqSetPolicy = forward("mqSetPolicy");
export const mqDeletePolicy = forward("mqDeletePolicy");
export const mqGetOverview = forward("mqGetOverview");
export const mqListNodes = forward("mqListNodes");
// MongoDB
export const documentListDatabases = forward("documentListDatabases");
+98
View File
@@ -36,6 +36,21 @@ import type {
MqRawResponse,
SendMessageRequest,
SendMessageResponse,
MqExchangeInfo,
MqExchangeCreateRequest,
MqBindingInfo,
MqBindingListFilter,
MqClientConnectionInfo,
MqChannelInfo,
MqUserInfo,
MqVhostPermission,
MqUserPermissionListFilter,
MqUserPermissionPatterns,
MqPolicyInfo,
MqPolicyListFilter,
MqPolicyUpsertRequest,
MqOverviewInfo,
MqNodeInfo,
} from "@/types/mq";
async function post<T>(path: string, body: unknown): Promise<T> {
@@ -55,6 +70,89 @@ export async function mqTestConnection(connectionId: string): Promise<MqClusterI
return post("/api/mq/test-connection", { connectionId });
}
export async function mqListExchanges(connectionId: string, ns: NamespaceRef): Promise<MqExchangeInfo[]> {
return post("/api/mq/exchanges/list", { connectionId, ns });
}
export async function mqCreateExchange(connectionId: string, ns: NamespaceRef, exchange: MqExchangeCreateRequest): Promise<void> {
return post("/api/mq/exchanges/create", { connectionId, ns, name: exchange.name, exchangeType: exchange.type, durable: exchange.durable, autoDelete: exchange.autoDelete });
}
export async function mqDeleteExchange(connectionId: string, ns: NamespaceRef, name: string): Promise<void> {
return post("/api/mq/exchanges/delete", { connectionId, ns, name });
}
export async function mqListBindings(connectionId: string, ns: NamespaceRef, filter?: MqBindingListFilter): Promise<MqBindingInfo[]> {
return post("/api/mq/bindings/list", { connectionId, ns, exchange: filter?.exchange, queue: filter?.queue });
}
export async function mqBind(connectionId: string, ns: NamespaceRef, binding: MqBindingInfo): Promise<void> {
return post("/api/mq/bindings/bind", { connectionId, ns, binding });
}
export async function mqUnbind(connectionId: string, ns: NamespaceRef, binding: MqBindingInfo): Promise<void> {
return post("/api/mq/bindings/unbind", { connectionId, ns, binding });
}
export async function mqListClientConnections(connectionId: string, ns: NamespaceRef): Promise<MqClientConnectionInfo[]> {
return post("/api/mq/client-connections/list", { connectionId, ns });
}
export async function mqListClientChannels(connectionId: string, ns: NamespaceRef, connection?: string): Promise<MqChannelInfo[]> {
return post("/api/mq/channels/list", { connectionId, ns, connection });
}
export async function mqCloseClientConnection(connectionId: string, ns: NamespaceRef, name: string): Promise<void> {
return post("/api/mq/client-connections/close", { connectionId, ns, name });
}
// Users & vhost permissions (RabbitMQ)
export async function mqListUsers(connectionId: string): Promise<MqUserInfo[]> {
return post("/api/mq/users/list", { connectionId });
}
export async function mqCreateUser(connectionId: string, name: string, password: string, tags?: string[]): Promise<void> {
return post("/api/mq/users/create", { connectionId, name, password, tags });
}
export async function mqDeleteUser(connectionId: string, name: string): Promise<void> {
return post("/api/mq/users/delete", { connectionId, name });
}
export async function mqListUserPermissions(connectionId: string, filter?: MqUserPermissionListFilter): Promise<MqVhostPermission[]> {
return post("/api/mq/user-permissions/list", { connectionId, virtualHost: filter?.virtualHost, user: filter?.user, allVhosts: filter?.allVhosts });
}
export async function mqGrantUserPermission(connectionId: string, user: string, virtualHost: string, patterns?: MqUserPermissionPatterns): Promise<void> {
return post("/api/mq/user-permissions/grant", { connectionId, user, virtualHost, configure: patterns?.configure, write: patterns?.write, read: patterns?.read });
}
export async function mqRevokeUserPermission(connectionId: string, user: string, virtualHost: string): Promise<void> {
return post("/api/mq/user-permissions/revoke", { connectionId, user, virtualHost });
}
// Policies (RabbitMQ)
export async function mqListPolicies(connectionId: string, filter?: MqPolicyListFilter): Promise<MqPolicyInfo[]> {
return post("/api/mq/policies/list", { connectionId, virtualHost: filter?.virtualHost, allVhosts: filter?.allVhosts });
}
export async function mqSetPolicy(connectionId: string, virtualHost: string, policy: MqPolicyUpsertRequest): Promise<void> {
return post("/api/mq/policies/set", { connectionId, virtualHost, name: policy.name, pattern: policy.pattern, applyTo: policy.applyTo, priority: policy.priority, definition: policy.definition });
}
export async function mqDeletePolicy(connectionId: string, virtualHost: string, name: string): Promise<void> {
return post("/api/mq/policies/delete", { connectionId, virtualHost, name });
}
// Cluster overview & nodes (RabbitMQ)
export async function mqGetOverview(connectionId: string): Promise<MqOverviewInfo> {
return post("/api/mq/overview", { connectionId });
}
export async function mqListNodes(connectionId: string): Promise<MqNodeInfo[]> {
return post("/api/mq/nodes", { connectionId });
}
export async function mqListTenants(connectionId: string): Promise<TenantInfo[]> {
return post("/api/mq/tenants/list", { connectionId });
}
+100
View File
@@ -36,6 +36,21 @@ import type {
MqRawResponse,
SendMessageRequest,
SendMessageResponse,
MqExchangeInfo,
MqExchangeCreateRequest,
MqBindingInfo,
MqBindingListFilter,
MqClientConnectionInfo,
MqChannelInfo,
MqUserInfo,
MqVhostPermission,
MqUserPermissionListFilter,
MqUserPermissionPatterns,
MqPolicyInfo,
MqPolicyListFilter,
MqPolicyUpsertRequest,
MqOverviewInfo,
MqNodeInfo,
} from "@/types/mq";
// Connectivity
@@ -43,6 +58,91 @@ export async function mqTestConnection(connectionId: string): Promise<MqClusterI
return invoke("mq_test_connection", { connectionId });
}
// Exchanges / Bindings (RabbitMQ)
export async function mqListExchanges(connectionId: string, ns: NamespaceRef): Promise<MqExchangeInfo[]> {
return invoke("mq_list_exchanges", { connectionId, ns });
}
export async function mqCreateExchange(connectionId: string, ns: NamespaceRef, exchange: MqExchangeCreateRequest): Promise<void> {
return invoke("mq_create_exchange", { connectionId, ns, name: exchange.name, exchangeType: exchange.type, durable: exchange.durable, autoDelete: exchange.autoDelete });
}
export async function mqDeleteExchange(connectionId: string, ns: NamespaceRef, name: string): Promise<void> {
return invoke("mq_delete_exchange", { connectionId, ns, name });
}
export async function mqListBindings(connectionId: string, ns: NamespaceRef, filter?: MqBindingListFilter): Promise<MqBindingInfo[]> {
return invoke("mq_list_bindings", { connectionId, ns, exchange: filter?.exchange, queue: filter?.queue });
}
export async function mqBind(connectionId: string, ns: NamespaceRef, binding: MqBindingInfo): Promise<void> {
return invoke("mq_bind", { connectionId, ns, binding });
}
export async function mqUnbind(connectionId: string, ns: NamespaceRef, binding: MqBindingInfo): Promise<void> {
return invoke("mq_unbind", { connectionId, ns, binding });
}
// Client connections / channels (RabbitMQ)
export async function mqListClientConnections(connectionId: string, ns: NamespaceRef): Promise<MqClientConnectionInfo[]> {
return invoke("mq_list_client_connections", { connectionId, ns });
}
export async function mqListClientChannels(connectionId: string, ns: NamespaceRef, connection?: string): Promise<MqChannelInfo[]> {
return invoke("mq_list_client_channels", { connectionId, ns, connection });
}
export async function mqCloseClientConnection(connectionId: string, ns: NamespaceRef, name: string): Promise<void> {
return invoke("mq_close_client_connection", { connectionId, ns, name });
}
// Users & vhost permissions (RabbitMQ)
export async function mqListUsers(connectionId: string): Promise<MqUserInfo[]> {
return invoke("mq_list_users", { connectionId });
}
export async function mqCreateUser(connectionId: string, name: string, password: string, tags?: string[]): Promise<void> {
return invoke("mq_create_user", { connectionId, name, password, tags });
}
export async function mqDeleteUser(connectionId: string, name: string): Promise<void> {
return invoke("mq_delete_user", { connectionId, name });
}
export async function mqListUserPermissions(connectionId: string, filter?: MqUserPermissionListFilter): Promise<MqVhostPermission[]> {
return invoke("mq_list_user_permissions", { connectionId, virtualHost: filter?.virtualHost, user: filter?.user, allVhosts: filter?.allVhosts });
}
export async function mqGrantUserPermission(connectionId: string, user: string, virtualHost: string, patterns?: MqUserPermissionPatterns): Promise<void> {
return invoke("mq_grant_user_permission", { connectionId, user, virtualHost, configure: patterns?.configure, write: patterns?.write, read: patterns?.read });
}
export async function mqRevokeUserPermission(connectionId: string, user: string, virtualHost: string): Promise<void> {
return invoke("mq_revoke_user_permission", { connectionId, user, virtualHost });
}
// Policies (RabbitMQ)
export async function mqListPolicies(connectionId: string, filter?: MqPolicyListFilter): Promise<MqPolicyInfo[]> {
return invoke("mq_list_policies", { connectionId, virtualHost: filter?.virtualHost, allVhosts: filter?.allVhosts });
}
export async function mqSetPolicy(connectionId: string, virtualHost: string, policy: MqPolicyUpsertRequest): Promise<void> {
return invoke("mq_set_policy", { connectionId, virtualHost, name: policy.name, pattern: policy.pattern, applyTo: policy.applyTo, priority: policy.priority, definition: policy.definition });
}
export async function mqDeletePolicy(connectionId: string, virtualHost: string, name: string): Promise<void> {
return invoke("mq_delete_policy", { connectionId, virtualHost, name });
}
// Cluster overview & nodes (RabbitMQ)
export async function mqGetOverview(connectionId: string): Promise<MqOverviewInfo> {
return invoke("mq_get_overview", { connectionId });
}
export async function mqListNodes(connectionId: string): Promise<MqNodeInfo[]> {
return invoke("mq_list_nodes", { connectionId });
}
// Tenants
export async function mqListTenants(connectionId: string): Promise<TenantInfo[]> {
return invoke("mq_list_tenants", { connectionId });
@@ -17,4 +17,21 @@ describe("mqAuth", () => {
}),
).toBe("basic");
});
it("allows basic auth for RabbitMQ", () => {
expect(isMqAuthKindAllowedForSystem("rabbitmq", "basic")).toBe(true);
expect(isMqAuthKindAllowedForSystem("rabbitmq", "kerberos")).toBe(false);
expect(isMqAuthKindAllowedForSystem("rabbitmq", "token")).toBe(false);
});
it("detects RabbitMQ basic auth from config", () => {
expect(
detectMqUiAuthKind({
systemKind: "rabbitmq",
authKind: "basic",
saslMechanism: "",
jaasConfig: "",
}),
).toBe("basic");
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses";
describe("RabbitMQ addresses", () => {
it("keeps comma-separated addresses", () => {
expect(normalizeRabbitmqAddresses("node1:5672, node2:5672")).toBe("node1:5672,node2:5672");
});
it("appends the default AMQP port when missing", () => {
expect(normalizeRabbitmqAddresses("127.0.0.1")).toBe("127.0.0.1:5672");
expect(normalizeRabbitmqAddresses("node1, node2:5673")).toBe("node1:5672,node2:5673");
});
it("normalizes common address separators to commas", () => {
expect(normalizeRabbitmqAddresses("node1:5672;node2:5672,node3:5672\nnode4:5672 node5:5672")).toBe("node1:5672,node2:5672,node3:5672,node4:5672,node5:5672");
});
it("keeps IPv6 addresses", () => {
expect(normalizeRabbitmqAddresses("[::1]:5672;[2001:db8::1]:5672")).toBe("[::1]:5672,[2001:db8::1]:5672");
});
it("rejects empty addresses", () => {
expect(() => normalizeRabbitmqAddresses(" ")).toThrow("RabbitMQ addresses are required");
});
it("rejects addresses with URL schemes", () => {
expect(() => normalizeRabbitmqAddresses("amqp://node1:5672,node2:5672")).toThrow("RabbitMQ addresses must be host:port values without a URL scheme");
});
it("rejects invalid address values", () => {
expect(() => normalizeRabbitmqAddresses("node1:5672/path,node2:5672")).toThrow("RabbitMQ addresses are invalid");
});
});
@@ -15,6 +15,7 @@ export function agentDriverInstallKey(dbType: DatabaseType | undefined, driverPr
if (dbType === "mq") {
if (driverProfile === "kafka") return "kafka";
if (driverProfile === "rocketmq") return "rocketmq";
if (driverProfile === "rabbitmq") return "rabbitmq";
return undefined;
}
return driverProfile && driverProfile !== dbType ? driverProfile : dbType;
@@ -5,10 +5,12 @@ export type MqUiAuthKind = MqAuthKind | "kerberos";
const KAFKA_AUTH_KINDS = new Set<MqUiAuthKind>(["none", "basic", "kerberos"]);
const ROCKETMQ_AUTH_KINDS = new Set<MqUiAuthKind>(["none", "basic"]);
const RABBITMQ_AUTH_KINDS = new Set<MqUiAuthKind>(["none", "basic"]);
export function isMqAuthKindAllowedForSystem(systemKind: MqSystemKind, authKind: MqUiAuthKind): boolean {
if (systemKind === "kafka") return KAFKA_AUTH_KINDS.has(authKind);
if (systemKind === "rocketmq") return ROCKETMQ_AUTH_KINDS.has(authKind);
if (systemKind === "rabbitmq") return RABBITMQ_AUTH_KINDS.has(authKind);
return authKind !== "kerberos";
}
@@ -22,6 +24,9 @@ export function detectMqUiAuthKind({ systemKind, authKind, saslMechanism, jaasCo
if (systemKind === "rocketmq") {
return authKind === "basic" ? "basic" : "none";
}
if (systemKind === "rabbitmq") {
return authKind === "basic" ? "basic" : "none";
}
return authKind || "none";
}
@@ -0,0 +1,34 @@
const RABBITMQ_ADDRESS_SEPARATOR = /[\s,;,;]+/u;
const RABBITMQ_DEFAULT_PORT = "5672";
function requireRabbitmqAddresses(value: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error("RabbitMQ addresses are required");
return trimmed;
}
function normalizeRabbitmqAddress(address: string): string {
if (address.includes("://")) {
throw new Error("RabbitMQ addresses must be host:port values without a URL scheme");
}
let parsed: URL;
try {
parsed = new URL(`amqp://${address}`);
} catch {
throw new Error("RabbitMQ addresses are invalid");
}
if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname && parsed.pathname !== "/")) {
throw new Error("RabbitMQ addresses are invalid");
}
return parsed.port ? address : `${address}:${RABBITMQ_DEFAULT_PORT}`;
}
export function normalizeRabbitmqAddresses(value: string): string {
const addresses = requireRabbitmqAddresses(value)
.split(RABBITMQ_ADDRESS_SEPARATOR)
.map((address) => address.trim())
.filter(Boolean)
.map(normalizeRabbitmqAddress);
if (!addresses.length) throw new Error("RabbitMQ addresses are required");
return addresses.join(",");
}
+152 -1
View File
@@ -1,6 +1,17 @@
import { describe, expect, it } from "vitest";
import type { ConnectionConfig } from "@/types/database";
import { defaultMqCapabilitiesForSystemKind, normalizeMqTabForSystemKind, resolveAvailableMqTabs, resolveInitialMqTab, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults";
import {
defaultMqCapabilitiesForSystemKind,
isAllVhostsNamespace,
normalizeMqTabForSystemKind,
RABBITMQ_ALL_VHOSTS,
resolveAvailableMqTabs,
resolveInitialMqTab,
resolveMqRowNamespace,
resolveMqSystemKindFromConnection,
resolveRabbitMqDefaultVhost,
resolveRabbitMqSendNamespace,
} from "@/lib/mq/mqConsoleDefaults";
describe("mqConsoleDefaults", () => {
it("resolves RocketMQ from driver profile before cluster info loads", () => {
@@ -34,4 +45,144 @@ describe("mqConsoleDefaults", () => {
const caps = defaultMqCapabilitiesForSystemKind("rocketmq");
expect(resolveAvailableMqTabs({ systemKind: "rocketmq", capabilities: caps })).toEqual(["broker", "topics", "subscriptions", "producers", "messages", "trace", "permissions"]);
});
it("resolves RabbitMQ from driver profile and external config", () => {
const config = {
id: "mq-2",
db_type: "mq",
driver_profile: "rabbitmq",
external_config: { systemKind: "rabbitmq", adminUrl: "", auth: { kind: "none" } },
} as ConnectionConfig;
expect(resolveMqSystemKindFromConnection(config)).toBe("rabbitmq");
expect(resolveMqSystemKindFromConnection({ ...config, external_config: undefined })).toBe("rabbitmq");
});
it("treats RabbitMQ as a flat MQ system with vhost namespaces", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(caps.supportsTenants).toBe(false);
expect(caps.supportsNamespaces).toBe(true);
expect(caps.supportsPartitionedTopics).toBe(false);
expect(caps.supportsClearBacklog).toBe(true);
expect(caps.supportsSubscriptions).toBe(true);
expect(caps.supportsPeekMessages).toBe(true);
expect(caps.supportsSendMessage).toBe(true);
expect(resolveInitialMqTab({ systemKind: "rabbitmq", initialTenant: "_flat_mq" })).toBe("topics");
});
it("enables client connection management for RabbitMQ only", () => {
expect(defaultMqCapabilitiesForSystemKind("rabbitmq").supportsClientConnections).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsClientConnections).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("rocketmq").supportsClientConnections).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsClientConnections).toBeFalsy();
});
it("exposes the namespaces tab for RabbitMQ vhost management", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: caps })).toEqual(["namespaces", "topics", "subscriptions", "monitoring", "clients", "messages", "broker", "policies", "permissions"]);
});
it("enables policies & cluster monitoring for RabbitMQ only", () => {
expect(defaultMqCapabilitiesForSystemKind("rabbitmq").supportsPolicies).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("rabbitmq").supportsClusterMonitoring).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsPolicies).toBeFalsy();
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsClusterMonitoring).toBeFalsy();
expect(defaultMqCapabilitiesForSystemKind("rocketmq").supportsPolicies).toBeFalsy();
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsPolicies).toBeFalsy();
});
it("lights the policies tab via supportsPolicies for RabbitMQ", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(caps.supportsRateLimits).toBe(false);
expect(caps.supportsBacklogQuota).toBe(false);
expect(caps.supportsRetention).toBe(false);
// The tab appears even when Pulsar-style rates/quotas are unsupported.
const rabbitTabs = resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: { ...caps, supportsPolicies: true } });
expect(rabbitTabs).toContain("policies");
// Without any policy capability the tab stays hidden.
const noPolicyCaps = { ...defaultMqCapabilitiesForSystemKind("kafka"), supportsRetention: false, supportsPolicies: false };
expect(resolveAvailableMqTabs({ systemKind: "kafka", capabilities: noPolicyCaps })).not.toContain("policies");
});
it("lights the permissions tab via supportsUserPermissions for RabbitMQ", () => {
const caps = defaultMqCapabilitiesForSystemKind("rabbitmq");
expect(caps.supportsPermissions).toBe(false);
expect(caps.supportsUserPermissions).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsUserPermissions).toBeFalsy();
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsUserPermissions).toBeFalsy();
// The tab appears even when role-grant permissions are unsupported.
const rabbitTabs = resolveAvailableMqTabs({ systemKind: "rabbitmq", capabilities: { ...caps, supportsPermissions: false, supportsUserPermissions: true } });
expect(rabbitTabs).toContain("permissions");
// Without either capability the tab stays hidden.
const noPermCaps = { ...defaultMqCapabilitiesForSystemKind("kafka"), supportsPermissions: false, supportsUserPermissions: false };
expect(resolveAvailableMqTabs({ systemKind: "kafka", capabilities: noPermCaps })).not.toContain("permissions");
});
it("resolves the RabbitMQ default vhost from the connection config", () => {
expect(resolveRabbitMqDefaultVhost(undefined)).toBe("/");
expect(resolveRabbitMqDefaultVhost({ id: "mq-3", db_type: "mq" } as ConnectionConfig)).toBe("/");
const config = {
id: "mq-3",
db_type: "mq",
driver_profile: "rabbitmq",
external_config: { systemKind: "rabbitmq", adminUrl: "", auth: { kind: "none" }, extra: { virtualHost: "orders" } },
} as ConnectionConfig;
expect(resolveRabbitMqDefaultVhost(config)).toBe("orders");
const snakeCase = {
...config,
external_config: { systemKind: "rabbitmq", adminUrl: "", auth: { kind: "none" }, extra: { virtual_host: "billing" } },
} as ConnectionConfig;
expect(resolveRabbitMqDefaultVhost(snakeCase)).toBe("billing");
});
it('marks the all-vhosts selection with the "*" namespace', () => {
expect(RABBITMQ_ALL_VHOSTS).toBe("*");
expect(isAllVhostsNamespace("*")).toBe(true);
expect(isAllVhostsNamespace("/")).toBe(false);
expect(isAllVhostsNamespace("orders")).toBe(false);
expect(isAllVhostsNamespace(undefined)).toBe(false);
expect(isAllVhostsNamespace(null)).toBe(false);
});
it("routes row-level operations to the row vhost with selection fallback", () => {
expect(resolveMqRowNamespace({ namespace: "orders" }, "*")).toBe("orders");
expect(resolveMqRowNamespace({ namespace: "orders" }, "/")).toBe("orders");
expect(resolveMqRowNamespace({}, "/")).toBe("/");
expect(resolveMqRowNamespace(undefined, "/")).toBe("/");
expect(resolveMqRowNamespace(undefined, undefined)).toBeUndefined();
});
it("never falls back to the all-vhosts sentinel for row-level operations", () => {
// "*" is a listing sentinel: a row without its own vhost must not resolve
// to it, otherwise a write would fan out across every vhost.
expect(resolveMqRowNamespace({}, "*")).toBeUndefined();
expect(resolveMqRowNamespace(undefined, "*")).toBeUndefined();
});
it("publishes to the row vhost or the connection default in all-vhosts mode", () => {
// A topic entered from a cross-vhost row keeps its own vhost.
expect(resolveRabbitMqSendNamespace({ namespace: "orders" }, "*")).toBe("orders");
expect(resolveRabbitMqSendNamespace({ namespace: "orders" }, "/")).toBe("orders");
// All-vhosts mode without a row topic falls back to the connection default vhost.
expect(resolveRabbitMqSendNamespace(undefined, "*")).toBeUndefined();
expect(resolveRabbitMqSendNamespace({}, "*")).toBeUndefined();
// Single-vhost mode keeps the selected namespace.
expect(resolveRabbitMqSendNamespace(undefined, "/")).toBe("/");
});
it("prefers the datalist row vhost, then the fallback topic, when publishing", () => {
// The datalist row (picked/typed topic) wins over the panel's topic prop.
expect(resolveRabbitMqSendNamespace({ namespace: "orders" }, "*", { namespace: "billing" })).toBe("orders");
// Without a datalist row the fallback topic's vhost is used, even in all-vhosts mode.
expect(resolveRabbitMqSendNamespace(undefined, "*", { namespace: "billing" })).toBe("billing");
expect(resolveRabbitMqSendNamespace({}, "*", { namespace: "billing" })).toBe("billing");
// The fallback topic also wins over a single-vhost selection.
expect(resolveRabbitMqSendNamespace(undefined, "/", { namespace: "billing" })).toBe("billing");
// Nothing to go on in all-vhosts mode: fall back to the connection default vhost.
expect(resolveRabbitMqSendNamespace(undefined, "*", undefined)).toBeUndefined();
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { defaultMqCapabilitiesForSystemKind } from "@/lib/mq/mqConsoleDefaults";
import { isBuiltinRabbitMqExchange, rabbitMqExchangeDisplayName, RABBITMQ_EXCHANGE_TYPES } from "@/lib/mq/rabbitmqExchanges";
import type { MqExchangeInfo } from "@/types/mq";
function exchange(partial: Partial<MqExchangeInfo>): MqExchangeInfo {
return { name: "", type: "direct", durable: true, autoDelete: false, internal: false, ...partial };
}
describe("rabbitmqExchanges", () => {
it("offers the four creatable exchange types", () => {
expect(RABBITMQ_EXCHANGE_TYPES).toEqual(["direct", "fanout", "topic", "headers"]);
});
it("renders the default exchange with a display name", () => {
expect(rabbitMqExchangeDisplayName(exchange({ name: "" }))).toBe("(AMQP default)");
expect(rabbitMqExchangeDisplayName(exchange({ name: "dbx-events" }))).toBe("dbx-events");
expect(rabbitMqExchangeDisplayName({ name: "amq.topic" })).toBe("amq.topic");
});
it("marks the default and amq.* exchanges as built-in", () => {
expect(isBuiltinRabbitMqExchange(exchange({ name: "" }))).toBe(true);
expect(isBuiltinRabbitMqExchange(exchange({ name: "amq.direct" }))).toBe(true);
expect(isBuiltinRabbitMqExchange(exchange({ name: "amq.headers", type: "headers" }))).toBe(true);
expect(isBuiltinRabbitMqExchange(exchange({ name: "dbx-internal", internal: true }))).toBe(true);
expect(isBuiltinRabbitMqExchange(exchange({ name: "dbx-events" }))).toBe(false);
expect(isBuiltinRabbitMqExchange(exchange({ name: "amqp.custom" }))).toBe(false);
});
});
describe("mqConsoleDefaults RabbitMQ exchanges capability", () => {
it("enables exchange management for RabbitMQ only", () => {
expect(defaultMqCapabilitiesForSystemKind("rabbitmq").supportsExchanges).toBe(true);
expect(defaultMqCapabilitiesForSystemKind("kafka").supportsExchanges).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("rocketmq").supportsExchanges).toBe(false);
expect(defaultMqCapabilitiesForSystemKind("pulsar").supportsExchanges).toBeFalsy();
});
});
+81 -5
View File
@@ -23,6 +23,8 @@ const FLAT_MQ_BASE_CAPABILITIES: MqCapabilities = {
supportsMessageQuery: false,
supportsDlq: false,
supportsMessageTrace: false,
supportsExchanges: false,
supportsClientConnections: false,
};
const PULSAR_DEFAULT_CAPABILITIES: MqCapabilities = {
@@ -52,17 +54,17 @@ const PULSAR_DEFAULT_CAPABILITIES: MqCapabilities = {
export function resolveMqSystemKindFromConnection(config: ConnectionConfig | undefined): MqSystemKind | undefined {
if (!config || config.db_type !== "mq") return undefined;
const external = config.external_config as Partial<MqAdminConfig> | undefined;
if (external?.systemKind === "kafka" || external?.systemKind === "rocketmq" || external?.systemKind === "pulsar") {
if (external?.systemKind === "kafka" || external?.systemKind === "rocketmq" || external?.systemKind === "rabbitmq" || external?.systemKind === "pulsar") {
return external.systemKind;
}
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "pulsar") {
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq" || config.driver_profile === "pulsar") {
return config.driver_profile;
}
return "pulsar";
}
export function isFlatMqSystemKind(kind: MqSystemKind | undefined): boolean {
return kind === "kafka" || kind === "rocketmq";
return kind === "kafka" || kind === "rocketmq" || kind === "rabbitmq";
}
export function defaultMqCapabilitiesForSystemKind(kind: MqSystemKind | undefined): MqCapabilities {
@@ -81,9 +83,81 @@ export function defaultMqCapabilitiesForSystemKind(kind: MqSystemKind | undefine
supportsMessageTrace: true,
};
}
if (kind === "rabbitmq") {
return {
...FLAT_MQ_BASE_CAPABILITIES,
// RabbitMQ is an intermediate form between flat and tenant/namespace systems:
// namespaces map to virtual hosts (list/create/delete via the management API),
// and clearing the backlog purges the selected queue.
supportsNamespaces: true,
supportsPartitionedTopics: false,
supportsResetCursor: false,
supportsPermissions: false,
// RabbitMQ manages exchanges & bindings on top of queues.
supportsExchanges: true,
// Client connections / channels come from the management API.
supportsClientConnections: true,
// Users & per-vhost permission triples come from the management API.
supportsUserPermissions: true,
// Virtual-host policies come from the management API.
supportsPolicies: true,
// Cluster overview & node stats come from the management API.
supportsClusterMonitoring: true,
};
}
return { ...PULSAR_DEFAULT_CAPABILITIES };
}
/**
* Synthetic tenant used for RabbitMQ connections. RabbitMQ has no tenant
* concept; the console pins the tenant to this value and exposes virtual
* hosts as namespaces instead.
*/
export const RABBITMQ_MQ_TENANT = "_rabbitmq";
/**
* Marker namespace meaning "all virtual hosts" for RabbitMQ. The backend
* translates it into a vhost-less management API listing where every item
* carries its own vhost.
*/
export const RABBITMQ_ALL_VHOSTS = "*";
export function isAllVhostsNamespace(namespace: string | undefined | null): boolean {
return namespace === RABBITMQ_ALL_VHOSTS;
}
/**
* Resolve the namespace for a row-level operation: prefer the namespace the
* row itself carries (cross-vhost listings), falling back to the currently
* selected namespace. In "all vhosts" mode a row without its own namespace
* resolves to undefined — "*" is a listing sentinel and must never reach a
* write operation.
*/
export function resolveMqRowNamespace(row: { namespace?: string } | undefined, selectedNamespace: string | undefined): string | undefined {
if (row?.namespace) return row.namespace;
return isAllVhostsNamespace(selectedNamespace) ? undefined : selectedNamespace;
}
/**
* Resolve the virtual host a RabbitMQ publish targets. A topic chosen from a
* row keeps its own vhost, then the fallback topic (e.g. the panel's selected
* topic prop); in all-vhosts mode without a row topic the publish falls back
* to the connection default vhost (no explicit namespace).
*/
export function resolveRabbitMqSendNamespace(topic: { namespace?: string } | undefined, selectedNamespace: string | undefined, fallbackTopic?: { namespace?: string }): string | undefined {
const namespace = topic?.namespace || fallbackTopic?.namespace;
if (namespace) return namespace;
return isAllVhostsNamespace(selectedNamespace) ? undefined : selectedNamespace;
}
/** Resolve the connection's default virtual host, used as the initial namespace. */
export function resolveRabbitMqDefaultVhost(config: ConnectionConfig | undefined): string {
const external = config?.external_config as Partial<MqAdminConfig> | undefined;
const extra = external?.extra as Record<string, unknown> | undefined;
const vhost = extra?.virtualHost ?? extra?.virtual_host;
return typeof vhost === "string" && vhost.trim() ? vhost.trim() : "/";
}
export type MqTab = "tenants" | "namespaces" | "topics" | "subscriptions" | "monitoring" | "clients" | "producers" | "policies" | "permissions" | "messages" | "raw" | "broker" | "dlq" | "trace";
export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; capabilities: MqCapabilities }): MqTab[] {
@@ -105,10 +179,12 @@ export function resolveAvailableMqTabs(options: { systemKind?: MqSystemKind; cap
tabs.push("clients");
if (capabilities.supportsSendMessage) tabs.push("messages");
tabs.push("broker");
if (capabilities.supportsRateLimits || capabilities.supportsBacklogQuota || capabilities.supportsRetention) {
// RabbitMQ lights this tab via virtual-host policies instead of Pulsar rates/quotas.
if (capabilities.supportsRateLimits || capabilities.supportsBacklogQuota || capabilities.supportsRetention || capabilities.supportsPolicies) {
tabs.push("policies");
}
if (capabilities.supportsPermissions) tabs.push("permissions");
// RabbitMQ lights this tab via user/vhost permissions instead of role grants.
if (capabilities.supportsPermissions || capabilities.supportsUserPermissions) tabs.push("permissions");
if (capabilities.supportsRawAdminApi) tabs.push("raw");
return tabs;
}
@@ -0,0 +1,20 @@
import type { MqExchangeInfo, MqExchangeType } from "@/types/mq";
/** Exchange types creatable through the RabbitMQ admin API. */
export const RABBITMQ_EXCHANGE_TYPES: MqExchangeType[] = ["direct", "fanout", "topic", "headers"];
/**
* The default exchange has an empty name; display it as "(AMQP default)" in
* the list, the same way the RabbitMQ management UI does.
*/
export function rabbitMqExchangeDisplayName(exchange: Pick<MqExchangeInfo, "name">): string {
return exchange.name || "(AMQP default)";
}
/**
* Built-in exchanges (the default exchange and the amq.* set) must not be
* deleted from the console.
*/
export function isBuiltinRabbitMqExchange(exchange: MqExchangeInfo): boolean {
return exchange.name === "" || exchange.name.startsWith("amq.") || exchange.internal;
}