From ddb4383e25b0bb9997a2faf86056ade946d041b0 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 10 Aug 2026 21:12:32 +0100 Subject: [PATCH 1/4] feat(logs): Read system logs, filter by service, and delete a log The logs page now has a system tab for logs the host writes rather than a deployment, starting with the proxy's access and error logs, and the access log can be narrowed to one deployment. A deployment's own logs can again be narrowed to a single container, which had been lost. The trash button now empties the log on the server behind a confirmation that names exactly what goes, rather than clearing the view, which is what refresh already did. A log entry can be handed to the assistant on its own, so the question is about the line the reader is pointing at instead of everything on screen. Follow moves in with the other actions, since it acts on the stream rather than filtering it. --- src/components/LogViewer.test.ts | 91 ++++++++ src/components/LogViewer.vue | 32 ++- src/components/StructuredLogView.vue | 22 +- src/composables/useLogStream.ts | 14 +- src/layouts/DashboardLayout.vue | 4 +- src/services/api.ts | 55 ++++- src/views/DeploymentDetailView.test.ts | 87 +++++++ src/views/DeploymentDetailView.vue | 85 ++++++- src/views/LogsView.test.ts | 182 +++++++++++++++ src/views/LogsView.vue | 304 +++++++++++++++++++++++-- 10 files changed, 821 insertions(+), 55 deletions(-) create mode 100644 src/components/LogViewer.test.ts create mode 100644 src/views/LogsView.test.ts 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 @@ +