diff --git a/assets/components/Links.vue b/assets/components/Links.vue
index 6d97270e..14ea4501 100644
--- a/assets/components/Links.vue
+++ b/assets/components/Links.vue
@@ -23,7 +23,7 @@
-
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 778a6b62..7bc1c8ce 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,10 @@
+x-dozzle-healthcheck: &dozzle-healthcheck
+ test: ["CMD", "/dozzle", "healthcheck"]
+ interval: 5s
+ retries: 5
+ start_period: 5s
+ start_interval: 5s
+
services:
custom_base:
container_name: custom_base
@@ -10,6 +17,7 @@ services:
- DOZZLE_HOSTNAME=custom name
ports:
- 8080:8080
+ healthcheck: *dozzle-healthcheck
build:
context: .
simple-auth:
@@ -20,6 +28,7 @@ services:
environment:
- DOZZLE_AUTH_PROVIDER=simple
- DOZZLE_NO_ANALYTICS=1
+ healthcheck: *dozzle-healthcheck
build:
context: .
dozzle:
@@ -33,8 +42,33 @@ services:
- DOZZLE_LEVEL=debug
ports:
- 7070:8080
+ # Deliberately no healthcheck: this container is the subject of the visual
+ # snapshots, and a healthcheck makes a health badge appear in the sidebar
+ # (HostMenu.vue), which shifts the reference images.
build:
context: .
+ # Dedicated instance + noisy container so log streaming can be asserted without
+ # perturbing the `name=dozzle` filter the other instances rely on.
+ logs-viewer:
+ container_name: logs-viewer
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ environment:
+ - DOZZLE_FILTER=name=logspam
+ - DOZZLE_NO_ANALYTICS=1
+ - DOZZLE_HOSTNAME=logs
+ healthcheck: *dozzle-healthcheck
+ build:
+ context: .
+ depends_on:
+ - logspam
+ logspam:
+ container_name: logspam
+ image: alpine:3
+ command:
+ - sh
+ - -c
+ - 'i=0; while true; do i=$$((i+1)); echo "logspam line $$i"; sleep 1; done'
remote:
container_name: remote
environment:
@@ -43,6 +77,7 @@ services:
- DOZZLE_NO_ANALYTICS=1
ports:
- 5050:8080
+ healthcheck: *dozzle-healthcheck
build:
context: .
depends_on:
@@ -56,6 +91,7 @@ services:
- DOZZLE_LEVEL=debug
ports:
- 8082:8080
+ healthcheck: *dozzle-healthcheck
build:
context: .
depends_on:
@@ -85,6 +121,9 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- CONTAINERS=1
+ # Dozzle calls /info on connect; without it the host never initializes and
+ # lists zero containers.
+ - INFO=1
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "2375"]
interval: 5s
@@ -105,6 +144,15 @@ services:
- PWTEST_SKIP_TEST_OUTPUT=1
- CI=1
depends_on:
- - dozzle
- - custom_base
- - remote
+ dozzle:
+ condition: service_started
+ custom_base:
+ condition: service_healthy
+ remote:
+ condition: service_healthy
+ simple-auth:
+ condition: service_healthy
+ dozzle-with-agent:
+ condition: service_healthy
+ logs-viewer:
+ condition: service_healthy
diff --git a/e2e/remote.spec.ts b/e2e/agent.spec.ts
similarity index 84%
rename from e2e/remote.spec.ts
rename to e2e/agent.spec.ts
index 5da7c44d..8720d51f 100644
--- a/e2e/remote.spec.ts
+++ b/e2e/agent.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
+// Covers DOZZLE_REMOTE_AGENT (gRPC agent). See remote-host.spec.ts for DOZZLE_REMOTE_HOST.
test.beforeEach(async ({ page }) => {
await page.goto("http://dozzle-with-agent:8080/");
});
diff --git a/e2e/agent.ts b/e2e/agent.ts
deleted file mode 100644
index 3694eabc..00000000
--- a/e2e/agent.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { test, expect } from "@playwright/test";
-
-test.beforeEach(async ({ page }) => {
- await page.goto("http://remote:8080/");
-});
-
-test("has right title", async ({ page }) => {
- await expect(page).toHaveTitle(/.* - Dozzle/);
-});
-
-test("select running container", async ({ page }) => {
- await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
- await expect(page).toHaveURL(/\/container/);
- await expect(page.getByText("Accepting connections")).toBeVisible();
-});
diff --git a/e2e/logs.spec.ts b/e2e/logs.spec.ts
new file mode 100644
index 00000000..9ee0dbe8
--- /dev/null
+++ b/e2e/logs.spec.ts
@@ -0,0 +1,34 @@
+import { test, expect } from "@playwright/test";
+
+// Targets a dedicated instance filtered to the `logspam` container, which prints a
+// numbered line every second. That makes "logs are actually streaming" assertable
+// without depending on incidental output from Dozzle itself.
+test.beforeEach(async ({ page }) => {
+ await page.goto("http://logs-viewer:8080/");
+ await page.getByTestId("side-menu").getByRole("link", { name: "logspam" }).click();
+ await expect(page).toHaveURL(/\/container\//);
+});
+
+test("renders historical logs on load", async ({ page }) => {
+ await expect(page.locator("ul[data-logs] > li").first()).toBeVisible();
+ await expect(page.getByTestId("no-logs")).toBeHidden();
+ await expect(page.getByText(/logspam line \d+/).first()).toBeVisible();
+});
+
+test("streams new logs over SSE without a reload", async ({ page }) => {
+ const entries = page.locator("ul[data-logs] > li");
+ await expect(entries.first()).toBeVisible();
+
+ const initial = await entries.count();
+ // logspam emits ~1 line/sec, so a few new entries must arrive on the open stream.
+ await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
+});
+
+test("keeps the newest log entry in view while streaming", async ({ page }) => {
+ const entries = page.locator("ul[data-logs] > li");
+ await expect(entries.first()).toBeVisible();
+
+ const initial = await entries.count();
+ await expect.poll(() => entries.count(), { timeout: 20_000 }).toBeGreaterThan(initial);
+ await expect(entries.last()).toBeInViewport();
+});
diff --git a/e2e/remote-host.spec.ts b/e2e/remote-host.spec.ts
new file mode 100644
index 00000000..aa065211
--- /dev/null
+++ b/e2e/remote-host.spec.ts
@@ -0,0 +1,24 @@
+import { test, expect } from "@playwright/test";
+
+// Covers DOZZLE_REMOTE_HOST (tcp:// through the socket proxy), a different code path
+// than DOZZLE_REMOTE_AGENT. See agent.spec.ts for that one.
+test.beforeEach(async ({ page }) => {
+ await page.goto("http://remote:8080/");
+});
+
+test("has right title", async ({ page }) => {
+ await expect(page).toHaveTitle(/.* - Dozzle/);
+});
+
+test("shows the labeled remote host", async ({ page }) => {
+ // The host column on the dashboard, rather than a bare getByText: the label also
+ // appears in the sidebar and the merge link, and which of them exist depends on
+ // whether containers have loaded yet.
+ await expect(page.getByRole("cell", { name: "remote-host" }).first()).toBeVisible();
+});
+
+test("select running container", async ({ page }) => {
+ await page.getByTestId("side-menu").getByRole("link", { name: "dozzle" }).click();
+ await expect(page).toHaveURL(/\/container/);
+ await expect(page.getByText("Accepting connections")).toBeVisible();
+});
diff --git a/e2e/simple.spec.ts b/e2e/simple.spec.ts
index c45a8f77..4691d2bf 100644
--- a/e2e/simple.spec.ts
+++ b/e2e/simple.spec.ts
@@ -1,9 +1,59 @@
import { test, expect } from "@playwright/test";
-test("simple authentication", async ({ page }) => {
- await page.goto("http://simple-auth:8080/");
+const BASE = "http://simple-auth:8080";
+
+async function login(page: import("@playwright/test").Page) {
await page.locator('input[name="username"]').fill("admin");
await page.locator('input[name="password"]').fill("password");
await page.locator('button[type="submit"]').click();
+}
+
+test("simple authentication", async ({ page }) => {
+ await page.goto(BASE + "/");
+ await login(page);
await expect(page.getByTestId("settings")).toBeVisible();
});
+
+test("unauthenticated request redirects to the login page", async ({ page }) => {
+ await page.goto(BASE + "/");
+ await expect(page).toHaveURL(/\/login/);
+ await expect(page.locator('input[name="username"]')).toBeVisible();
+});
+
+test("preserves the requested url as redirectUrl", async ({ page }) => {
+ await page.goto(BASE + "/settings");
+ await expect(page).toHaveURL(/\/login\?redirectUrl=/);
+});
+
+test("rejects a wrong password", async ({ page }) => {
+ await page.goto(BASE + "/");
+ await page.locator('input[name="username"]').fill("admin");
+ await page.locator('input[name="password"]').fill("not-the-password");
+ await page.locator('button[type="submit"]').click();
+
+ await expect(page.getByText("Username or password are not valid")).toBeVisible();
+ await expect(page.getByTestId("settings")).toBeHidden();
+});
+
+test("api rejects unauthenticated requests with 401", async ({ request }) => {
+ // A regression here is a data leak, not a UI bug: /api must never serve an anonymous caller.
+ for (const path of ["/api/version", "/api/events/stream", "/api/notifications/rules"]) {
+ const response = await request.get(BASE + path, { maxRedirects: 0 });
+ expect(response.status(), `${path} should be unauthorized`).toBe(401);
+ }
+});
+
+test("logout clears the session", async ({ page }) => {
+ await page.goto(BASE + "/");
+ await login(page);
+ await expect(page.getByTestId("settings")).toBeVisible();
+
+ await page.getByTestId("user-menu").locator("label").click();
+ await page.getByRole("button", { name: "Logout" }).click();
+
+ await expect(page.locator('input[name="username"]')).toBeVisible();
+
+ // The cookie must be gone server-side, not just visually logged out.
+ const response = await page.request.get(BASE + "/api/version", { maxRedirects: 0 });
+ expect(response.status()).toBe(401);
+});