Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as http from "node:http";
import * as net from "node:net";
import { installHooks, removeHooks } from "./hooks.ts";
import { install, readLockFile, removeLockFile, uninstall, writeLockFile } from "./installer.ts";
import { createLogger } from "./logging.ts";
import { createServer } from "./server.ts";
import { createStore } from "./state.ts";

Expand Down Expand Up @@ -257,6 +258,7 @@ function main(): void {

function startDashboard(port: number, noHooks: boolean, noOpen: boolean): void {
const store = createStore();
const logger = createLogger();

let cleanedUp = false;
function cleanup() {
Expand All @@ -276,6 +278,7 @@ function startDashboard(port: number, noHooks: boolean, noOpen: boolean): void {

const dashboard = createServer({
store,
logger,
onShutdown() {
cleanup();
process.exit(0);
Expand Down
36 changes: 36 additions & 0 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,13 @@ export function getDashboardHtml(): string {
</div>
</main>
<footer>
<div class="notification-toggle">
<label class="toggle-switch">
<input type="checkbox" id="logToggle">
<span class="toggle-slider"></span>
</label>
<label class="toggle-label" for="logToggle">Logging</label>
</div>
<button id="btnRestart" disabled>Restart</button>
<button id="btnStop" class="btn-danger" disabled>Stop</button>
</footer>
Expand All @@ -455,6 +462,7 @@ export function getDashboardHtml(): string {
var btnRestart = document.getElementById('btnRestart');
var notifToggle = document.getElementById('notifToggle');
var notifBanner = document.getElementById('notifBanner');
var logToggle = document.getElementById('logToggle');
var notificationsEnabled = localStorage.getItem('notificationsEnabled') !== 'false';
var sessions = [];
var previousStatuses = {};
Expand Down Expand Up @@ -720,6 +728,34 @@ export function getDashboardHtml(): string {
};
}

// Logging toggle
function fetchLoggingStatus() {
var req = new XMLHttpRequest();
req.open('GET', '/api/logging', true);
req.onload = function() {
if (req.status === 200) {
var data = JSON.parse(req.responseText);
logToggle.checked = data.enabled;
}
};
req.send();
}

logToggle.addEventListener('change', function() {
var req = new XMLHttpRequest();
req.open('POST', '/api/logging', true);
req.setRequestHeader('Content-Type', 'application/json');
req.onload = function() {
if (req.status === 200) {
var data = JSON.parse(req.responseText);
logToggle.checked = data.enabled;
}
};
req.send(JSON.stringify({ enabled: logToggle.checked }));
});

fetchLoggingStatus();

// Update time-ago values every 10 seconds
setInterval(render, 10000);

Expand Down
4 changes: 3 additions & 1 deletion src/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("installHooks", () => {
assert.ok(settings.hooks);
});

it("creates all 7 hook events including PreToolUse, PermissionRequest, and PostToolUse", () => {
it("creates all 9 hook events including PreToolUse, PermissionRequest, PostToolUse, Notification, and SubagentStop", () => {
installHooks(8377, tmpDir);
const settings = readSettings() as { hooks: Record<string, unknown[]> };
assert.ok(settings.hooks.SessionStart);
Expand All @@ -47,6 +47,8 @@ describe("installHooks", () => {
assert.ok(settings.hooks.PreToolUse);
assert.ok(settings.hooks.PermissionRequest);
assert.ok(settings.hooks.PostToolUse);
assert.ok(settings.hooks.Notification);
assert.ok(settings.hooks.SubagentStop);
});

it("PreToolUse hook captures all tools without matcher", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const HOOK_EVENTS = [
"SessionEnd",
"PermissionRequest",
"PostToolUse",
"Notification",
"SubagentStop",
] as const;

interface HookEntry {
Expand Down
146 changes: 146 additions & 0 deletions src/logging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import { createLogger } from "./logging.ts";

let tmpDir: string;
let logDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ccd-log-test-"));
logDir = path.join(tmpDir, "logs");
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

describe("createLogger", () => {
it("starts disabled by default", () => {
const logger = createLogger(logDir);
assert.equal(logger.isEnabled(), false);
});

it("can be enabled and disabled", () => {
const logger = createLogger(logDir);
logger.setEnabled(true);
assert.equal(logger.isEnabled(), true);
logger.setEnabled(false);
assert.equal(logger.isEnabled(), false);
});

it("does not write logs when disabled", () => {
const logger = createLogger(logDir);
logger.logEvent(
{ session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" },
{
sessionId: "s1",
status: "done",
cwd: "/test",
lastEvent: "SessionStart",
updatedAt: Date.now(),
startedAt: Date.now(),
},
);
assert.equal(fs.existsSync(logDir), false);
});

it("writes log file when enabled", () => {
const logger = createLogger(logDir);
logger.setEnabled(true);
logger.logEvent(
{ session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" },
{
sessionId: "s1",
status: "done",
cwd: "/test",
lastEvent: "SessionStart",
updatedAt: Date.now(),
startedAt: Date.now(),
},
);

assert.ok(fs.existsSync(logDir));

const files = fs.readdirSync(logDir);
assert.equal(files.length, 1);
assert.ok(files[0].endsWith(".log"));

const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
const entry = JSON.parse(content.trim());
assert.equal(entry.hook_event_name, "SessionStart");
assert.equal(entry.session_id, "s1");
assert.equal(entry.status, "done");
assert.ok(entry.timestamp);
assert.ok(entry.payload);
});

it("logs null session for SessionEnd events", () => {
const logger = createLogger(logDir);
logger.setEnabled(true);
logger.logEvent({ session_id: "s1", hook_event_name: "SessionEnd" }, null);

const files = fs.readdirSync(logDir);
const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
const entry = JSON.parse(content.trim());
assert.equal(entry.status, "n/a");
assert.equal(entry.hook_event_name, "SessionEnd");
});

it("appends multiple entries to same log file", () => {
const logger = createLogger(logDir);
logger.setEnabled(true);
logger.logEvent(
{ session_id: "s1", hook_event_name: "SessionStart", cwd: "/test" },
{
sessionId: "s1",
status: "done",
cwd: "/test",
lastEvent: "SessionStart",
updatedAt: Date.now(),
startedAt: Date.now(),
},
);
logger.logEvent(
{ session_id: "s1", hook_event_name: "UserPromptSubmit", cwd: "/test" },
{
sessionId: "s1",
status: "running",
cwd: "/test",
lastEvent: "UserPromptSubmit",
updatedAt: Date.now(),
startedAt: Date.now(),
},
);

const files = fs.readdirSync(logDir);
assert.equal(files.length, 1);

const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
const lines = content.trim().split("\n");
assert.equal(lines.length, 2);
});

it("includes full payload with extra fields like tool_name", () => {
const logger = createLogger(logDir);
logger.setEnabled(true);
logger.logEvent(
{ session_id: "s1", hook_event_name: "PreToolUse", tool_name: "Bash", cwd: "/test" },
{
sessionId: "s1",
status: "running",
cwd: "/test",
lastEvent: "Bash",
updatedAt: Date.now(),
startedAt: Date.now(),
},
);

const files = fs.readdirSync(logDir);
const content = fs.readFileSync(path.join(logDir, files[0]), "utf-8");
const entry = JSON.parse(content.trim());
assert.equal(entry.payload.tool_name, "Bash");
});
});
61 changes: 61 additions & 0 deletions src/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { HookPayload, Session } from "./state.ts";

export interface Logger {
isEnabled(): boolean;
setEnabled(enabled: boolean): void;
logEvent(payload: HookPayload, session: Session | null): void;
}

function getLogDir(logDir?: string): string {
return logDir ?? path.join(os.homedir(), ".claude-code-dashboard", "logs");
}

function getLogFilePath(logDir?: string): string {
const date = new Date().toISOString().slice(0, 10);
return path.join(getLogDir(logDir), `${date}.log`);
}

function formatLogEntry(payload: HookPayload, session: Session | null): string {
const timestamp = new Date().toISOString();
const status = session ? session.status : "n/a";
const entry = {
timestamp,
status,
hook_event_name: payload.hook_event_name,
session_id: payload.session_id,
payload,
};
return JSON.stringify(entry);
}

export function createLogger(logDir?: string): Logger {
let enabled = false;

return {
isEnabled() {
return enabled;
},

setEnabled(value: boolean) {
enabled = value;
},

logEvent(payload: HookPayload, session: Session | null) {
if (!enabled) return;

try {
const dir = getLogDir(logDir);
fs.mkdirSync(dir, { recursive: true });

const logFile = getLogFilePath(logDir);
const line = `${formatLogEntry(payload, session)}\n`;
fs.appendFileSync(logFile, line, "utf-8");
} catch {
// Silently ignore logging errors to avoid disrupting the dashboard
}
},
};
}
3 changes: 3 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import * as http from "node:http";
import { afterEach, describe, it } from "node:test";
import { createLogger } from "./logging.ts";
import { createServer, type DashboardServer, type ServerOptions } from "./server.ts";
import { createStore } from "./state.ts";

Expand All @@ -11,8 +12,10 @@ function startServer(
): Promise<{ port: number; dashboard: DashboardServer }> {
return new Promise((resolve) => {
const store = createStore();
const logger = createLogger();
const d = createServer({
store,
logger,
onShutdown: opts?.onShutdown,
onRestart: opts?.onRestart,
idleTimeoutMs: opts?.idleTimeoutMs,
Expand Down
Loading