feat(rabbitmq): add message queue management support
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user