diff --git a/src/components/LogRulesPanel.test.ts b/src/components/LogRulesPanel.test.ts new file mode 100644 index 0000000..b073b57 --- /dev/null +++ b/src/components/LogRulesPanel.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import LogRulesPanel from "./LogRulesPanel.vue"; + +vi.mock("@/services/observability", () => ({ + observabilityApi: { + logRules: vi.fn().mockResolvedValue({ data: [] }), + saveLogRules: vi.fn().mockImplementation((rules) => Promise.resolve({ data: rules })), + }, +})); + +vi.mock("@/services/api", () => ({ + notificationsApi: { + getTargets: vi.fn().mockResolvedValue({ data: { targets: [{ id: "t1", name: "Ops chat" }] } }), + }, +})); + +describe("LogRulesPanel", () => { + beforeEach(() => vi.clearAllMocks()); + + const mountPanel = () => + mount(LogRulesPanel, { + props: { deployments: ["shop", "blog"] }, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + stubs: { BaseModal: { template: "
", props: ["visible", "title"] } }, + }, + }); + + const openForm = async (wrapper: ReturnType) => { + await wrapper.find("button.btn-primary").trigger("click"); + await flushPromises(); + }; + + // The defaults are the whole cost argument, so a rule created without touching them must + // still carry them. + it("creates a rule with the conservative defaults", async () => { + const { observabilityApi } = await import("@/services/observability"); + const wrapper = mountPanel(); + await flushPromises(); + await openForm(wrapper); + + await wrapper + .findAll("input") + .find((i) => i.attributes("placeholder") === "Checkout errors")! + .setValue("Boom"); + await wrapper.findAll("select")[0].setValue("shop"); + await wrapper + .findAll("button") + .find((b) => b.text().includes("Save rule"))! + .trigger("click"); + await flushPromises(); + + expect(observabilityApi.saveLogRules).toHaveBeenCalledWith([ + expect.objectContaining({ + name: "Boom", + deployment: "shop", + min_level: "error", + min_count: 3, + window_seconds: 300, + cooldown_seconds: 3600, + triage: false, + enabled: true, + }), + ]); + }); + + // Triage is the only setting that costs money, so it must be off until it is ticked. + it("only asks for triage when the box is ticked", async () => { + const { observabilityApi } = await import("@/services/observability"); + const wrapper = mountPanel(); + await flushPromises(); + await openForm(wrapper); + + await wrapper + .findAll("input") + .find((i) => i.attributes("placeholder") === "Checkout errors")! + .setValue("Boom"); + await wrapper.findAll("select")[0].setValue("shop"); + const triageBox = wrapper + .findAll("label") + .find((l) => l.text().includes("Ask the assistant"))! + .find("input"); + await triageBox.setValue(true); + await wrapper + .findAll("button") + .find((b) => b.text().includes("Save rule"))! + .trigger("click"); + await flushPromises(); + + expect(observabilityApi.saveLogRules).toHaveBeenCalledWith([expect.objectContaining({ triage: true })]); + }); + + it("shows what a saved rule watches", async () => { + const { observabilityApi } = await import("@/services/observability"); + vi.mocked(observabilityApi.logRules).mockResolvedValueOnce({ + data: [ + { + id: "r1", + name: "OOM", + enabled: true, + deployment: "shop", + service: "worker", + min_level: "error", + pattern: "out of memory", + min_count: 3, + window_seconds: 300, + cooldown_seconds: 3600, + triage: true, + }, + ], + } as any); + + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.text()).toContain("OOM"); + expect(wrapper.text()).toContain("shop/worker"); + expect(wrapper.text()).toContain("out of memory"); + expect(wrapper.text()).toContain("triage"); + }); + + it("deletes a rule by saving the set without it", async () => { + const { observabilityApi } = await import("@/services/observability"); + vi.mocked(observabilityApi.logRules).mockResolvedValueOnce({ + data: [ + { id: "r1", name: "One", enabled: true, deployment: "shop" }, + { id: "r2", name: "Two", enabled: true, deployment: "blog" }, + ], + } as any); + + const wrapper = mountPanel(); + await flushPromises(); + + await wrapper + .findAll("button") + .find((b) => b.attributes("title") === "Delete rule")! + .trigger("click"); + await flushPromises(); + + expect(observabilityApi.saveLogRules).toHaveBeenCalledWith([expect.objectContaining({ id: "r2" })]); + }); +}); diff --git a/src/components/LogRulesPanel.vue b/src/components/LogRulesPanel.vue new file mode 100644 index 0000000..e42d710 --- /dev/null +++ b/src/components/LogRulesPanel.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/src/components/LogViewer.test.ts b/src/components/LogViewer.test.ts new file mode 100644 index 0000000..3a31cc7 --- /dev/null +++ b/src/components/LogViewer.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import LogViewer from "./LogViewer.vue"; +import { useAssistStore } from "@/stores/assist"; + +// xterm needs a real canvas; the raw view is not what these tests are about. +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + open() {} + write() {} + clear() {} + dispose() {} + loadAddon() {} + scrollToBottom() {} + onResize() {} + }, +})); +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit() {} + }, +})); +vi.mock("@xterm/addon-search", () => ({ + SearchAddon: class { + findNext() {} + findPrevious() {} + }, +})); +vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} })); + +const logs = ["web | ERROR first failure", "web | ERROR second failure"].join("\n"); + +describe("LogViewer", () => { + beforeEach(() => vi.clearAllMocks()); + + const mountViewer = (props = {}) => + mount(LogViewer, { + props: { logs, ...props }, + global: { plugins: [createTestingPinia({ createSpy: vi.fn })] }, + attachTo: document.body, + }); + + const clickTitle = async (wrapper: ReturnType, title: string) => { + await wrapper + .findAll("button") + .find((b) => b.attributes("title") === title)! + .trigger("click"); + await flushPromises(); + }; + + // Deleting empties the log on the server, so the button asks rather than acts. + it("asks the parent to delete rather than clearing the view itself", async () => { + const wrapper = mountViewer({ deletable: true }); + await flushPromises(); + + await clickTitle(wrapper, "Delete these logs"); + + expect(wrapper.emitted("delete")).toBeTruthy(); + // Nothing is hidden locally: what is on screen still reflects the server. + expect(wrapper.text()).toContain("first failure"); + }); + + // A viewer whose source cannot be emptied should not offer the button at all. + it("hides the delete button unless deleting is possible", async () => { + const wrapper = mountViewer(); + await flushPromises(); + + expect(wrapper.findAll("button").some((b) => b.attributes("title") === "Delete these logs")).toBe(false); + }); + + it("hands one entry to the assistant when asked to debug it", async () => { + const wrapper = mountViewer(); + await flushPromises(); + const store = useAssistStore(); + + await wrapper.find(".row-head").trigger("click"); + await wrapper + .findAll("button") + .find((b) => b.text().includes("Debug with AI"))! + .trigger("click"); + await flushPromises(); + + expect(store.open).toHaveBeenCalledWith( + expect.objectContaining({ seedContext: expect.stringContaining("first failure") }), + ); + // The whole log would bury the line the reader pointed at. + const call = vi.mocked(store.open).mock.calls[0][0] as { seedContext: string }; + expect(call.seedContext).not.toContain("second failure"); + }); +}); diff --git a/src/components/LogViewer.vue b/src/components/LogViewer.vue index 0fc4f79..5d032f1 100644 --- a/src/components/LogViewer.vue +++ b/src/components/LogViewer.vue @@ -45,6 +45,7 @@ +