mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- Added session lifecycle tracking with created/closed events and durations 🚀 - Introduced `session_lifecycle` SQLite table plus indexes via v3 migration 🗄️ - Expanded stats aggregation with total sessions, per-agent, per-day rollups 📊 - Usage Dashboard gained a new “Sessions” summary card (now six metrics) 🧩 - New IPC endpoints to record sessions and fetch lifecycle history cleanly 🔌 - Session create/close events now fire automatically from the main app flow 🧠 - Old-data cleanup now purges session lifecycle rows for leaner databases 🧹 - Stats collection toggle respected for session-created recording, avoiding noise 🔒 - Updated typings across shared/renderer APIs for new session stats fields 🧾 - Bumped package version to 0.14.1 for this stats upgrade release 🎉
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "maestro",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.1",
|
||||
"description": "Maestro hones fractured attention into focused intent.",
|
||||
"main": "dist/main/index.js",
|
||||
"author": {
|
||||
|
||||
@@ -60,9 +60,20 @@ describe('stats IPC handlers', () => {
|
||||
avgDuration: 0,
|
||||
byAgent: {},
|
||||
bySource: { user: 0, auto: 0 },
|
||||
byLocation: { local: 0, remote: 0 },
|
||||
byDay: [],
|
||||
byHour: [],
|
||||
totalSessions: 0,
|
||||
sessionsByAgent: {},
|
||||
sessionsByDay: [],
|
||||
avgSessionDuration: 0,
|
||||
}),
|
||||
exportToCsv: vi.fn().mockReturnValue('id,sessionId,...'),
|
||||
clearOldData: vi.fn().mockReturnValue({ success: true, deletedCount: 0 }),
|
||||
getDatabaseSize: vi.fn().mockReturnValue({ sizeBytes: 1024, sizeFormatted: '1 KB' }),
|
||||
recordSessionCreated: vi.fn().mockReturnValue('session-lifecycle-id'),
|
||||
recordSessionClosed: vi.fn().mockReturnValue(true),
|
||||
getSessionLifecycleEvents: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
vi.mocked(statsDbModule.getStatsDB).mockReturnValue(mockStatsDB as unknown as StatsDB);
|
||||
@@ -103,6 +114,11 @@ describe('stats IPC handlers', () => {
|
||||
'stats:get-autorun-tasks',
|
||||
'stats:get-aggregation',
|
||||
'stats:export-csv',
|
||||
'stats:clear-old-data',
|
||||
'stats:get-database-size',
|
||||
'stats:record-session-created',
|
||||
'stats:record-session-closed',
|
||||
'stats:get-session-lifecycle',
|
||||
];
|
||||
|
||||
for (const channel of expectedChannels) {
|
||||
@@ -375,4 +391,109 @@ describe('stats IPC handlers', () => {
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenNthCalledWith(4, 'stats:updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session lifecycle handlers', () => {
|
||||
describe('stats:record-session-created', () => {
|
||||
it('should broadcast stats:updated after recording session created', async () => {
|
||||
const handler = handlers.get('stats:record-session-created');
|
||||
const lifecycleEvent = {
|
||||
sessionId: 'session-1',
|
||||
agentType: 'claude-code',
|
||||
projectPath: '/test/project',
|
||||
createdAt: Date.now(),
|
||||
isRemote: false,
|
||||
};
|
||||
|
||||
const result = await handler!({} as any, lifecycleEvent);
|
||||
|
||||
expect(result).toBe('session-lifecycle-id');
|
||||
expect(mockStatsDB.recordSessionCreated).toHaveBeenCalledWith(lifecycleEvent);
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('stats:updated');
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not broadcast when main window is null', async () => {
|
||||
const nullWindowGetMainWindow = () => null;
|
||||
handlers.clear();
|
||||
vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => {
|
||||
handlers.set(channel, handler);
|
||||
});
|
||||
registerStatsHandlers({ getMainWindow: nullWindowGetMainWindow });
|
||||
|
||||
const handler = handlers.get('stats:record-session-created');
|
||||
const lifecycleEvent = {
|
||||
sessionId: 'session-1',
|
||||
agentType: 'claude-code',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
await handler!({} as any, lifecycleEvent);
|
||||
|
||||
expect(mockStatsDB.recordSessionCreated).toHaveBeenCalled();
|
||||
expect(mockMainWindow.webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats:record-session-closed', () => {
|
||||
it('should broadcast stats:updated after recording session closed', async () => {
|
||||
const handler = handlers.get('stats:record-session-closed');
|
||||
const sessionId = 'session-1';
|
||||
const closedAt = Date.now();
|
||||
|
||||
const result = await handler!({} as any, sessionId, closedAt);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockStatsDB.recordSessionClosed).toHaveBeenCalledWith(sessionId, closedAt);
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('stats:updated');
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should broadcast stats:updated even when session not found', async () => {
|
||||
vi.mocked(mockStatsDB.recordSessionClosed).mockReturnValue(false);
|
||||
|
||||
const handler = handlers.get('stats:record-session-closed');
|
||||
const result = await handler!({} as any, 'nonexistent-session', Date.now());
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should still broadcast - UI may need to refresh regardless
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('stats:updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats:get-session-lifecycle', () => {
|
||||
it('should not broadcast stats:updated when getting session lifecycle events', async () => {
|
||||
const handler = handlers.get('stats:get-session-lifecycle');
|
||||
|
||||
await handler!({} as any, 'week');
|
||||
|
||||
expect(mockStatsDB.getSessionLifecycleEvents).toHaveBeenCalledWith('week');
|
||||
expect(mockMainWindow.webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear old data handler', () => {
|
||||
describe('stats:clear-old-data', () => {
|
||||
it('should broadcast stats:updated after clearing old data', async () => {
|
||||
const handler = handlers.get('stats:clear-old-data');
|
||||
|
||||
const result = await handler!({} as any, 30);
|
||||
|
||||
expect(result).toEqual({ success: true, deletedCount: 0 });
|
||||
expect(mockStatsDB.clearOldData).toHaveBeenCalledWith(30);
|
||||
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith('stats:updated');
|
||||
});
|
||||
|
||||
it('should not broadcast when clear fails', async () => {
|
||||
vi.mocked(mockStatsDB.clearOldData).mockReturnValue({ success: false, deletedCount: 0 });
|
||||
|
||||
const handler = handlers.get('stats:clear-old-data');
|
||||
const result = await handler!({} as any, 30);
|
||||
|
||||
expect(result).toEqual({ success: false, deletedCount: 0 });
|
||||
// Should not broadcast on failure
|
||||
expect(mockMainWindow.webContents.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,6 +93,7 @@ import type {
|
||||
QueryEvent,
|
||||
AutoRunSession,
|
||||
AutoRunTask,
|
||||
SessionLifecycleEvent,
|
||||
StatsTimeRange,
|
||||
StatsFilters,
|
||||
StatsAggregation,
|
||||
@@ -188,6 +189,58 @@ describe('stats-types.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SessionLifecycleEvent interface', () => {
|
||||
it('should define proper SessionLifecycleEvent structure for created session', () => {
|
||||
const event: SessionLifecycleEvent = {
|
||||
id: 'lifecycle-1',
|
||||
sessionId: 'session-1',
|
||||
agentType: 'claude-code',
|
||||
projectPath: '/test/project',
|
||||
createdAt: Date.now(),
|
||||
isRemote: false,
|
||||
};
|
||||
|
||||
expect(event.id).toBe('lifecycle-1');
|
||||
expect(event.sessionId).toBe('session-1');
|
||||
expect(event.agentType).toBe('claude-code');
|
||||
expect(event.closedAt).toBeUndefined();
|
||||
expect(event.duration).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should define proper SessionLifecycleEvent structure for closed session', () => {
|
||||
const createdAt = Date.now() - 3600000; // 1 hour ago
|
||||
const closedAt = Date.now();
|
||||
const event: SessionLifecycleEvent = {
|
||||
id: 'lifecycle-2',
|
||||
sessionId: 'session-2',
|
||||
agentType: 'claude-code',
|
||||
projectPath: '/test/project',
|
||||
createdAt,
|
||||
closedAt,
|
||||
duration: closedAt - createdAt,
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
expect(event.closedAt).toBe(closedAt);
|
||||
expect(event.duration).toBe(3600000);
|
||||
expect(event.isRemote).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow optional fields to be undefined', () => {
|
||||
const event: SessionLifecycleEvent = {
|
||||
id: 'lifecycle-3',
|
||||
sessionId: 'session-3',
|
||||
agentType: 'opencode',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(event.projectPath).toBeUndefined();
|
||||
expect(event.closedAt).toBeUndefined();
|
||||
expect(event.duration).toBeUndefined();
|
||||
expect(event.isRemote).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatsTimeRange type', () => {
|
||||
it('should accept valid time ranges', () => {
|
||||
const ranges: StatsTimeRange[] = ['day', 'week', 'month', 'year', 'all'];
|
||||
@@ -221,16 +274,37 @@ describe('stats-types.ts', () => {
|
||||
opencode: { count: 30, duration: 150000 },
|
||||
},
|
||||
bySource: { user: 60, auto: 40 },
|
||||
byLocation: { local: 80, remote: 20 },
|
||||
byDay: [
|
||||
{ date: '2024-01-01', count: 10, duration: 50000 },
|
||||
{ date: '2024-01-02', count: 15, duration: 75000 },
|
||||
],
|
||||
byHour: [
|
||||
{ hour: 9, count: 20, duration: 100000 },
|
||||
{ hour: 10, count: 25, duration: 125000 },
|
||||
],
|
||||
// Session lifecycle fields
|
||||
totalSessions: 15,
|
||||
sessionsByAgent: {
|
||||
'claude-code': 10,
|
||||
opencode: 5,
|
||||
},
|
||||
sessionsByDay: [
|
||||
{ date: '2024-01-01', count: 3 },
|
||||
{ date: '2024-01-02', count: 5 },
|
||||
],
|
||||
avgSessionDuration: 1800000,
|
||||
};
|
||||
|
||||
expect(aggregation.totalQueries).toBe(100);
|
||||
expect(aggregation.byAgent['claude-code'].count).toBe(70);
|
||||
expect(aggregation.bySource.user).toBe(60);
|
||||
expect(aggregation.byDay).toHaveLength(2);
|
||||
// Session lifecycle assertions
|
||||
expect(aggregation.totalSessions).toBe(15);
|
||||
expect(aggregation.sessionsByAgent['claude-code']).toBe(10);
|
||||
expect(aggregation.sessionsByDay).toHaveLength(2);
|
||||
expect(aggregation.avgSessionDuration).toBe(1800000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -414,13 +488,13 @@ describe('StatsDB class (mocked)', () => {
|
||||
const db = new StatsDB();
|
||||
db.initialize();
|
||||
|
||||
// Currently we have version 2 migration (v1: initial schema, v2: is_remote column)
|
||||
expect(db.getTargetVersion()).toBe(2);
|
||||
// Currently we have version 3 migration (v1: initial schema, v2: is_remote column, v3: session_lifecycle table)
|
||||
expect(db.getTargetVersion()).toBe(3);
|
||||
});
|
||||
|
||||
it('should return false from hasPendingMigrations() when up to date', async () => {
|
||||
mockDb.pragma.mockImplementation((sql: string) => {
|
||||
if (sql === 'user_version') return [{ user_version: 2 }];
|
||||
if (sql === 'user_version') return [{ user_version: 3 }];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
@@ -435,8 +509,8 @@ describe('StatsDB class (mocked)', () => {
|
||||
// This test verifies the hasPendingMigrations() logic
|
||||
// by checking current version < target version
|
||||
|
||||
// Simulate a database that's already at version 2 (target version)
|
||||
let currentVersion = 2;
|
||||
// Simulate a database that's already at version 3 (target version)
|
||||
let currentVersion = 3;
|
||||
mockDb.pragma.mockImplementation((sql: string) => {
|
||||
if (sql === 'user_version') return [{ user_version: currentVersion }];
|
||||
// Handle version updates from migration
|
||||
@@ -450,9 +524,9 @@ describe('StatsDB class (mocked)', () => {
|
||||
const db = new StatsDB();
|
||||
db.initialize();
|
||||
|
||||
// At version 2, target is 2, so no pending migrations
|
||||
expect(db.getCurrentVersion()).toBe(2);
|
||||
expect(db.getTargetVersion()).toBe(2);
|
||||
// At version 3, target is 3, so no pending migrations
|
||||
expect(db.getCurrentVersion()).toBe(3);
|
||||
expect(db.getTargetVersion()).toBe(3);
|
||||
expect(db.hasPendingMigrations()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -8240,12 +8314,15 @@ describe('Database VACUUM functionality', () => {
|
||||
|
||||
db.clearOldData(30);
|
||||
|
||||
// All DELETE queries should filter by start_time (indexed)
|
||||
// All DELETE queries should filter by indexed time columns
|
||||
// (start_time for query_events, auto_run_sessions, auto_run_tasks;
|
||||
// created_at for session_lifecycle)
|
||||
const deleteQueries = queriesExecuted.filter((sql) => sql.includes('DELETE'));
|
||||
expect(deleteQueries.length).toBeGreaterThan(0);
|
||||
|
||||
for (const query of deleteQueries) {
|
||||
expect(query).toContain('start_time <');
|
||||
const usesIndexedTimeColumn = query.includes('start_time <') || query.includes('created_at <');
|
||||
expect(usesIndexedTimeColumn).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8475,8 +8552,10 @@ describe('Database VACUUM functionality', () => {
|
||||
|
||||
for (const query of selectQueries) {
|
||||
// Each query should filter by an indexed column
|
||||
// (includes created_at for session_lifecycle table)
|
||||
const hasIndexedFilter =
|
||||
query.includes('start_time') ||
|
||||
query.includes('created_at') ||
|
||||
query.includes('auto_run_session_id') ||
|
||||
query.includes('session_id');
|
||||
expect(hasIndexedFilter).toBe(true);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tests for SummaryCards component
|
||||
*
|
||||
* Verifies:
|
||||
* - Renders all five metric cards correctly
|
||||
* - Renders all six metric cards correctly
|
||||
* - Displays formatted values (numbers, durations)
|
||||
* - Shows correct icons for each metric
|
||||
* - Applies theme colors properly
|
||||
@@ -30,10 +30,16 @@ const mockData: StatsAggregation = {
|
||||
'aider': { count: 50, duration: 2200000 },
|
||||
},
|
||||
bySource: { user: 120, auto: 30 },
|
||||
byLocation: { local: 120, remote: 30 },
|
||||
byDay: [
|
||||
{ date: '2024-12-20', count: 50, duration: 2400000 },
|
||||
{ date: '2024-12-21', count: 100, duration: 4800000 },
|
||||
],
|
||||
byHour: [],
|
||||
totalSessions: 25,
|
||||
sessionsByAgent: { 'claude-code': 15, 'aider': 10 },
|
||||
sessionsByDay: [],
|
||||
avgSessionDuration: 288000,
|
||||
};
|
||||
|
||||
// Empty data for edge case testing
|
||||
@@ -43,7 +49,13 @@ const emptyData: StatsAggregation = {
|
||||
avgDuration: 0,
|
||||
byAgent: {},
|
||||
bySource: { user: 0, auto: 0 },
|
||||
byLocation: { local: 0, remote: 0 },
|
||||
byDay: [],
|
||||
byHour: [],
|
||||
totalSessions: 0,
|
||||
sessionsByAgent: {},
|
||||
sessionsByDay: [],
|
||||
avgSessionDuration: 0,
|
||||
};
|
||||
|
||||
// Data with large numbers
|
||||
@@ -56,7 +68,13 @@ const largeNumbersData: StatsAggregation = {
|
||||
'openai-codex': { count: 500000, duration: 160000000 },
|
||||
},
|
||||
bySource: { user: 1200000, auto: 300000 },
|
||||
byLocation: { local: 1000000, remote: 500000 },
|
||||
byDay: [],
|
||||
byHour: [],
|
||||
totalSessions: 50000,
|
||||
sessionsByAgent: { 'claude-code': 30000, 'openai-codex': 20000 },
|
||||
sessionsByDay: [],
|
||||
avgSessionDuration: 7200000,
|
||||
};
|
||||
|
||||
// Single agent data
|
||||
@@ -68,7 +86,13 @@ const singleAgentData: StatsAggregation = {
|
||||
'terminal': { count: 50, duration: 1800000 },
|
||||
},
|
||||
bySource: { user: 50, auto: 0 },
|
||||
byLocation: { local: 50, remote: 0 },
|
||||
byDay: [],
|
||||
byHour: [],
|
||||
totalSessions: 5,
|
||||
sessionsByAgent: { 'terminal': 5 },
|
||||
sessionsByDay: [],
|
||||
avgSessionDuration: 360000,
|
||||
};
|
||||
|
||||
// Only auto queries
|
||||
@@ -80,7 +104,13 @@ const onlyAutoData: StatsAggregation = {
|
||||
'claude-code': { count: 100, duration: 3600000 },
|
||||
},
|
||||
bySource: { user: 0, auto: 100 },
|
||||
byLocation: { local: 100, remote: 0 },
|
||||
byDay: [],
|
||||
byHour: [],
|
||||
totalSessions: 10,
|
||||
sessionsByAgent: { 'claude-code': 10 },
|
||||
sessionsByDay: [],
|
||||
avgSessionDuration: 360000,
|
||||
};
|
||||
|
||||
describe('SummaryCards', () => {
|
||||
@@ -91,11 +121,11 @@ describe('SummaryCards', () => {
|
||||
expect(screen.getByTestId('summary-cards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all five metric cards', () => {
|
||||
it('renders all six metric cards', () => {
|
||||
render(<SummaryCards data={mockData} theme={theme} />);
|
||||
|
||||
const cards = screen.getAllByTestId('metric-card');
|
||||
expect(cards).toHaveLength(5);
|
||||
expect(cards).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('renders Total Queries metric', () => {
|
||||
@@ -291,18 +321,18 @@ describe('SummaryCards', () => {
|
||||
|
||||
// Each card should have an SVG icon
|
||||
const svgElements = container.querySelectorAll('svg');
|
||||
expect(svgElements.length).toBe(5);
|
||||
expect(svgElements.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grid Layout', () => {
|
||||
it('uses 5-column grid layout by default', () => {
|
||||
it('uses 6-column grid layout by default', () => {
|
||||
render(<SummaryCards data={mockData} theme={theme} />);
|
||||
|
||||
const container = screen.getByTestId('summary-cards');
|
||||
expect(container).toHaveClass('grid');
|
||||
expect(container).toHaveStyle({
|
||||
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
|
||||
gridTemplateColumns: 'repeat(6, minmax(0, 1fr))',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,7 +361,8 @@ describe('SummaryCards', () => {
|
||||
|
||||
// Should render without errors
|
||||
expect(screen.getByTestId('summary-cards')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
// Multiple cards show '0' for empty data (Sessions, Queries)
|
||||
expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('handles very large numbers', () => {
|
||||
|
||||
@@ -50,11 +50,24 @@ const mockStatsData: StatsAggregation = {
|
||||
user: 120,
|
||||
auto: 30,
|
||||
},
|
||||
byLocation: { local: 120, remote: 30 },
|
||||
byDay: [
|
||||
{ date: '2025-01-20', count: 50, duration: 2400000 },
|
||||
{ date: '2025-01-21', count: 45, duration: 2160000 },
|
||||
{ date: '2025-01-22', count: 55, duration: 2640000 },
|
||||
],
|
||||
byHour: [
|
||||
{ hour: 9, count: 50, duration: 2400000 },
|
||||
{ hour: 14, count: 100, duration: 4800000 },
|
||||
],
|
||||
totalSessions: 25,
|
||||
sessionsByAgent: { 'claude-code': 15, 'opencode': 6, 'gemini-cli': 4 },
|
||||
sessionsByDay: [
|
||||
{ date: '2025-01-20', count: 8 },
|
||||
{ date: '2025-01-21', count: 9 },
|
||||
{ date: '2025-01-22', count: 8 },
|
||||
],
|
||||
avgSessionDuration: 288000,
|
||||
};
|
||||
|
||||
describe('Chart Accessibility - AgentComparisonChart', () => {
|
||||
@@ -301,7 +314,7 @@ describe('Chart Accessibility - SummaryCards', () => {
|
||||
it('each metric card has role="group"', () => {
|
||||
render(<SummaryCards data={mockStatsData} theme={mockTheme} />);
|
||||
const groups = screen.getAllByRole('group');
|
||||
expect(groups).toHaveLength(5); // 5 metric cards
|
||||
expect(groups).toHaveLength(6); // 6 metric cards
|
||||
});
|
||||
|
||||
it('metric cards have descriptive aria-labels', () => {
|
||||
@@ -309,6 +322,7 @@ describe('Chart Accessibility - SummaryCards', () => {
|
||||
const groups = screen.getAllByRole('group');
|
||||
|
||||
const expectedLabels = [
|
||||
/Sessions/i,
|
||||
/Total Queries/i,
|
||||
/Total Time/i,
|
||||
/Avg Duration/i,
|
||||
@@ -376,7 +390,13 @@ describe('Chart Accessibility - General ARIA Patterns', () => {
|
||||
avgDuration: 0,
|
||||
byAgent: {},
|
||||
bySource: { user: 0, auto: 0 },
|
||||
byLocation: { local: 0, remote: 0 },
|
||||
byDay: [],
|
||||
byHour: [],
|
||||
totalSessions: 0,
|
||||
sessionsByAgent: {},
|
||||
sessionsByDay: [],
|
||||
avgSessionDuration: 0,
|
||||
};
|
||||
|
||||
render(<AgentComparisonChart data={emptyData} theme={mockTheme} />);
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
*
|
||||
* These tests verify that the Usage Dashboard modal correctly adapts its layout
|
||||
* based on container width, supporting:
|
||||
* - Narrow screens (<600px): Single column charts, 2-column summary cards
|
||||
* - Medium screens (600-900px): 2-column charts, 3-column summary cards
|
||||
* - Wide screens (>900px): 2-column charts, 5-column summary cards
|
||||
* - Narrow screens (<600px): Single column charts, 2-column summary cards grid
|
||||
* - Medium screens (600-900px): 2-column charts, 3-column summary cards grid
|
||||
* - Wide screens (>900px): 2-column charts, 5-column summary cards grid
|
||||
*
|
||||
* Note: SummaryCards always renders 6 metric cards regardless of grid column count.
|
||||
*
|
||||
* The responsive system uses ResizeObserver to track container width and
|
||||
* dynamically adjusts grid column counts via CSS grid.
|
||||
@@ -36,10 +38,14 @@ vi.mock('lucide-react', () => {
|
||||
Timer: createIcon('timer', '⏱️'),
|
||||
Bot: createIcon('bot', '🤖'),
|
||||
Users: createIcon('users', '👥'),
|
||||
Layers: createIcon('layers', '📚'),
|
||||
Play: createIcon('play', '▶️'),
|
||||
CheckSquare: createIcon('check-square', '✅'),
|
||||
ListChecks: createIcon('list-checks', '📝'),
|
||||
Target: createIcon('target', '🎯'),
|
||||
AlertTriangle: createIcon('alert-triangle', '⚠️'),
|
||||
ChevronDown: createIcon('chevron-down', '▼'),
|
||||
ChevronUp: createIcon('chevron-up', '▲'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -181,12 +187,27 @@ const createSampleData = () => ({
|
||||
'terminal': { count: 50, duration: 1200000 },
|
||||
},
|
||||
bySource: { user: 100, auto: 50 },
|
||||
byLocation: { local: 120, remote: 30 },
|
||||
byDay: [
|
||||
{ date: '2024-01-15', count: 25, duration: 600000 },
|
||||
{ date: '2024-01-16', count: 30, duration: 720000 },
|
||||
{ date: '2024-01-17', count: 45, duration: 1080000 },
|
||||
{ date: '2024-01-18', count: 50, duration: 1200000 },
|
||||
],
|
||||
byHour: [
|
||||
{ hour: 9, count: 40, duration: 960000 },
|
||||
{ hour: 14, count: 60, duration: 1440000 },
|
||||
{ hour: 15, count: 50, duration: 1200000 },
|
||||
],
|
||||
totalSessions: 25,
|
||||
sessionsByAgent: { 'claude-code': 15, 'terminal': 10 },
|
||||
sessionsByDay: [
|
||||
{ date: '2024-01-15', count: 5 },
|
||||
{ date: '2024-01-16', count: 7 },
|
||||
{ date: '2024-01-17', count: 6 },
|
||||
{ date: '2024-01-18', count: 7 },
|
||||
],
|
||||
avgSessionDuration: 144000,
|
||||
});
|
||||
|
||||
describe('UsageDashboard Responsive Layout', () => {
|
||||
@@ -378,7 +399,7 @@ describe('UsageDashboard Responsive Layout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all 5 metric cards regardless of column count', async () => {
|
||||
it('renders all 6 metric cards regardless of column count', async () => {
|
||||
render(
|
||||
<UsageDashboardModal isOpen={true} onClose={onClose} theme={theme} />
|
||||
);
|
||||
@@ -387,9 +408,9 @@ describe('UsageDashboard Responsive Layout', () => {
|
||||
expect(screen.getByTestId('usage-dashboard-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should always have 5 metric cards
|
||||
// Should always have 6 metric cards
|
||||
const metricCards = screen.getAllByTestId('metric-card');
|
||||
expect(metricCards).toHaveLength(5);
|
||||
expect(metricCards).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,10 +38,14 @@ vi.mock('lucide-react', () => {
|
||||
Timer: createIcon('timer', '⏱️'),
|
||||
Bot: createIcon('bot', '🤖'),
|
||||
Users: createIcon('users', '👥'),
|
||||
Layers: createIcon('layers', '📚'),
|
||||
Play: createIcon('play', '▶️'),
|
||||
CheckSquare: createIcon('check-square', '✅'),
|
||||
ListChecks: createIcon('list-checks', '📝'),
|
||||
Target: createIcon('target', '🎯'),
|
||||
AlertTriangle: createIcon('alert-triangle', '⚠️'),
|
||||
ChevronDown: createIcon('chevron-down', '▼'),
|
||||
ChevronUp: createIcon('chevron-up', '▲'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -136,10 +140,22 @@ beforeEach(() => {
|
||||
'opencode': { count: 20, duration: 720000 },
|
||||
},
|
||||
bySource: { user: 70, auto: 30 },
|
||||
byLocation: { local: 80, remote: 20 },
|
||||
byDay: [
|
||||
{ date: '2024-01-01', count: 50, duration: 1800000 },
|
||||
{ date: '2024-01-02', count: 50, duration: 1800000 },
|
||||
],
|
||||
byHour: [
|
||||
{ hour: 9, count: 40, duration: 1440000 },
|
||||
{ hour: 14, count: 60, duration: 2160000 },
|
||||
],
|
||||
totalSessions: 20,
|
||||
sessionsByAgent: { 'claude-code': 15, 'opencode': 5 },
|
||||
sessionsByDay: [
|
||||
{ date: '2024-01-01', count: 10 },
|
||||
{ date: '2024-01-02', count: 10 },
|
||||
],
|
||||
avgSessionDuration: 180000,
|
||||
});
|
||||
mockStats.getDatabaseSize.mockResolvedValue(1024 * 1024); // 1 MB
|
||||
});
|
||||
@@ -271,7 +287,13 @@ describe('Usage Dashboard State Transition Animations', () => {
|
||||
avgDuration: 36000,
|
||||
byAgent: { 'claude-code': { count: 100, duration: 3600000 } },
|
||||
bySource: { user: 70, auto: 30 },
|
||||
byLocation: { local: 80, remote: 20 },
|
||||
byDay: [],
|
||||
byHour: [],
|
||||
totalSessions: 15,
|
||||
sessionsByAgent: { 'claude-code': 15 },
|
||||
sessionsByDay: [],
|
||||
avgSessionDuration: 240000,
|
||||
};
|
||||
|
||||
it('applies dashboard-card-enter class to metric cards', () => {
|
||||
@@ -287,7 +309,7 @@ describe('Usage Dashboard State Transition Animations', () => {
|
||||
render(<SummaryCards data={mockData} theme={mockTheme} />);
|
||||
|
||||
const cards = screen.getAllByTestId('metric-card');
|
||||
expect(cards.length).toBe(5); // 5 metric cards
|
||||
expect(cards.length).toBe(6); // 6 metric cards
|
||||
|
||||
// Verify each card has incrementing animation delay
|
||||
cards.forEach((card, index) => {
|
||||
@@ -303,11 +325,11 @@ describe('Usage Dashboard State Transition Animations', () => {
|
||||
expect(cards[0]).toHaveStyle({ animationDelay: '0ms' });
|
||||
});
|
||||
|
||||
it('last card has 200ms delay (4 * 50ms)', () => {
|
||||
it('last card has 250ms delay (5 * 50ms)', () => {
|
||||
render(<SummaryCards data={mockData} theme={mockTheme} />);
|
||||
|
||||
const cards = screen.getAllByTestId('metric-card');
|
||||
expect(cards[4]).toHaveStyle({ animationDelay: '200ms' });
|
||||
expect(cards[5]).toHaveStyle({ animationDelay: '250ms' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ vi.mock('lucide-react', () => {
|
||||
Timer: createIcon('timer', '⏱️'),
|
||||
Bot: createIcon('bot', '🤖'),
|
||||
Users: createIcon('users', '👥'),
|
||||
Layers: createIcon('layers', '📚'),
|
||||
// AutoRunStats icons
|
||||
Play: createIcon('play', '▶️'),
|
||||
CheckSquare: createIcon('check-square', '✅'),
|
||||
ListChecks: createIcon('list-checks', '📝'),
|
||||
Target: createIcon('target', '🎯'),
|
||||
// ChartErrorBoundary icons
|
||||
AlertTriangle: createIcon('alert-triangle', '⚠️'),
|
||||
ChevronDown: createIcon('chevron-down', '▼'),
|
||||
ChevronUp: createIcon('chevron-up', '▲'),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -116,12 +121,28 @@ const createSampleData = () => ({
|
||||
'terminal': { count: 50, duration: 1200000 },
|
||||
},
|
||||
bySource: { user: 100, auto: 50 },
|
||||
byLocation: { local: 120, remote: 30 },
|
||||
byDay: [
|
||||
{ date: '2024-01-15', count: 25, duration: 600000 },
|
||||
{ date: '2024-01-16', count: 30, duration: 720000 },
|
||||
{ date: '2024-01-17', count: 45, duration: 1080000 },
|
||||
{ date: '2024-01-18', count: 50, duration: 1200000 },
|
||||
],
|
||||
byHour: [
|
||||
{ hour: 9, count: 20, duration: 480000 },
|
||||
{ hour: 10, count: 35, duration: 840000 },
|
||||
{ hour: 14, count: 45, duration: 1080000 },
|
||||
{ hour: 15, count: 50, duration: 1200000 },
|
||||
],
|
||||
totalSessions: 25,
|
||||
sessionsByAgent: { 'claude-code': 15, 'terminal': 10 },
|
||||
sessionsByDay: [
|
||||
{ date: '2024-01-15', count: 5 },
|
||||
{ date: '2024-01-16', count: 7 },
|
||||
{ date: '2024-01-17', count: 6 },
|
||||
{ date: '2024-01-18', count: 7 },
|
||||
],
|
||||
avgSessionDuration: 144000,
|
||||
});
|
||||
|
||||
describe('UsageDashboardModal', () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
QueryEvent,
|
||||
AutoRunSession,
|
||||
AutoRunTask,
|
||||
SessionLifecycleEvent,
|
||||
StatsTimeRange,
|
||||
StatsFilters,
|
||||
} from '../../../shared/stats-types';
|
||||
@@ -241,4 +242,54 @@ export function registerStatsHandlers(deps: StatsHandlerDependencies): void {
|
||||
return db.getDatabaseSize();
|
||||
})
|
||||
);
|
||||
|
||||
// Record session creation (launched)
|
||||
ipcMain.handle(
|
||||
'stats:record-session-created',
|
||||
withIpcErrorLogging(
|
||||
handlerOpts('recordSessionCreated'),
|
||||
async (event: Omit<SessionLifecycleEvent, 'id' | 'closedAt' | 'duration'>) => {
|
||||
// Check if stats collection is enabled
|
||||
if (!isStatsCollectionEnabled(settingsStore)) {
|
||||
logger.debug('Stats collection disabled, skipping session creation', LOG_CONTEXT);
|
||||
return null;
|
||||
}
|
||||
|
||||
const db = getStatsDB();
|
||||
const id = db.recordSessionCreated(event);
|
||||
logger.debug(`Recorded session created: ${event.sessionId}`, LOG_CONTEXT, {
|
||||
agentType: event.agentType,
|
||||
projectPath: event.projectPath,
|
||||
});
|
||||
broadcastStatsUpdate(getMainWindow);
|
||||
return id;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Record session closure
|
||||
ipcMain.handle(
|
||||
'stats:record-session-closed',
|
||||
withIpcErrorLogging(
|
||||
handlerOpts('recordSessionClosed'),
|
||||
async (sessionId: string, closedAt: number) => {
|
||||
const db = getStatsDB();
|
||||
const updated = db.recordSessionClosed(sessionId, closedAt);
|
||||
if (updated) {
|
||||
logger.debug(`Recorded session closed: ${sessionId}`, LOG_CONTEXT);
|
||||
}
|
||||
broadcastStatsUpdate(getMainWindow);
|
||||
return updated;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Get session lifecycle events within a time range
|
||||
ipcMain.handle(
|
||||
'stats:get-session-lifecycle',
|
||||
withIpcErrorLogging(handlerOpts('getSessionLifecycle'), async (range: StatsTimeRange) => {
|
||||
const db = getStatsDB();
|
||||
return db.getSessionLifecycleEvents(range);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
QueryEvent,
|
||||
AutoRunSession,
|
||||
AutoRunTask,
|
||||
SessionLifecycleEvent,
|
||||
StatsTimeRange,
|
||||
StatsFilters,
|
||||
StatsAggregation,
|
||||
@@ -253,6 +254,27 @@ const CREATE_AUTO_RUN_TASKS_INDEXES_SQL = `
|
||||
CREATE INDEX IF NOT EXISTS idx_task_start ON auto_run_tasks(start_time)
|
||||
`;
|
||||
|
||||
/**
|
||||
* SQL for creating session_lifecycle table
|
||||
*/
|
||||
const CREATE_SESSION_LIFECYCLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS session_lifecycle (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
agent_type TEXT NOT NULL,
|
||||
project_path TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
closed_at INTEGER,
|
||||
duration INTEGER,
|
||||
is_remote INTEGER
|
||||
)
|
||||
`;
|
||||
|
||||
const CREATE_SESSION_LIFECYCLE_INDEXES_SQL = `
|
||||
CREATE INDEX IF NOT EXISTS idx_session_created_at ON session_lifecycle(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_agent_type ON session_lifecycle(agent_type)
|
||||
`;
|
||||
|
||||
/**
|
||||
* StatsDB manages the SQLite database for usage statistics.
|
||||
* Implements singleton pattern for database connection management.
|
||||
@@ -279,6 +301,11 @@ export class StatsDB {
|
||||
description: 'Add is_remote column to query_events for tracking SSH sessions',
|
||||
up: () => this.migrateV2(),
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
description: 'Add session_lifecycle table for tracking session creation and closure',
|
||||
up: () => this.migrateV3(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -554,6 +581,26 @@ export class StatsDB {
|
||||
logger.debug('Added is_remote column to query_events table', LOG_CONTEXT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration v3: Add session_lifecycle table for tracking session creation and closure
|
||||
*
|
||||
* This enables tracking of unique sessions launched over time, session duration,
|
||||
* and session lifecycle metrics in the Usage Dashboard.
|
||||
*/
|
||||
private migrateV3(): void {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
// Create session_lifecycle table
|
||||
this.db.prepare(CREATE_SESSION_LIFECYCLE_SQL).run();
|
||||
|
||||
// Create indexes
|
||||
for (const indexSql of CREATE_SESSION_LIFECYCLE_INDEXES_SQL.split(';').filter((s) => s.trim())) {
|
||||
this.db.prepare(indexSql).run();
|
||||
}
|
||||
|
||||
logger.debug('Created session_lifecycle table', LOG_CONTEXT);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Database Lifecycle
|
||||
// ============================================================================
|
||||
@@ -1133,6 +1180,100 @@ export class StatsDB {
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Record a session being created (launched)
|
||||
*/
|
||||
recordSessionCreated(event: Omit<SessionLifecycleEvent, 'id' | 'closedAt' | 'duration'>): string {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const id = generateId();
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO session_lifecycle (id, session_id, agent_type, project_path, created_at, is_remote)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
id,
|
||||
event.sessionId,
|
||||
event.agentType,
|
||||
normalizePath(event.projectPath),
|
||||
event.createdAt,
|
||||
event.isRemote !== undefined ? (event.isRemote ? 1 : 0) : null
|
||||
);
|
||||
|
||||
logger.debug(`Recorded session created: ${event.sessionId}`, LOG_CONTEXT);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a session being closed
|
||||
*/
|
||||
recordSessionClosed(sessionId: string, closedAt: number): boolean {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
// Get the session's created_at time to calculate duration
|
||||
const session = this.db.prepare(`
|
||||
SELECT created_at FROM session_lifecycle WHERE session_id = ?
|
||||
`).get(sessionId) as { created_at: number } | undefined;
|
||||
|
||||
if (!session) {
|
||||
logger.debug(`Session not found for closure: ${sessionId}`, LOG_CONTEXT);
|
||||
return false;
|
||||
}
|
||||
|
||||
const duration = closedAt - session.created_at;
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE session_lifecycle
|
||||
SET closed_at = ?, duration = ?
|
||||
WHERE session_id = ?
|
||||
`);
|
||||
|
||||
const result = stmt.run(closedAt, duration, sessionId);
|
||||
logger.debug(`Recorded session closed: ${sessionId}, duration: ${duration}ms`, LOG_CONTEXT);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session lifecycle events within a time range
|
||||
*/
|
||||
getSessionLifecycleEvents(range: StatsTimeRange): SessionLifecycleEvent[] {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const startTime = getTimeRangeStart(range);
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT * FROM session_lifecycle
|
||||
WHERE created_at >= ?
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
const rows = stmt.all(startTime) as Array<{
|
||||
id: string;
|
||||
session_id: string;
|
||||
agent_type: string;
|
||||
project_path: string | null;
|
||||
created_at: number;
|
||||
closed_at: number | null;
|
||||
duration: number | null;
|
||||
is_remote: number | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
agentType: row.agent_type,
|
||||
projectPath: row.project_path ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
closedAt: row.closed_at ?? undefined,
|
||||
duration: row.duration ?? undefined,
|
||||
isRemote: row.is_remote !== null ? row.is_remote === 1 : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aggregations
|
||||
// ============================================================================
|
||||
@@ -1246,6 +1387,49 @@ export class StatsDB {
|
||||
}>;
|
||||
perfMetrics.end(byHourStart, 'getAggregatedStats:byHour', { range });
|
||||
|
||||
// Session lifecycle stats
|
||||
const sessionsStart = perfMetrics.start();
|
||||
|
||||
// Total sessions and average duration
|
||||
const sessionTotalsStmt = this.db.prepare(`
|
||||
SELECT COUNT(*) as count, COALESCE(AVG(duration), 0) as avg_duration
|
||||
FROM session_lifecycle
|
||||
WHERE created_at >= ?
|
||||
`);
|
||||
const sessionTotals = sessionTotalsStmt.get(startTime) as { count: number; avg_duration: number };
|
||||
|
||||
// Sessions by agent type
|
||||
const sessionsByAgentStmt = this.db.prepare(`
|
||||
SELECT agent_type, COUNT(*) as count
|
||||
FROM session_lifecycle
|
||||
WHERE created_at >= ?
|
||||
GROUP BY agent_type
|
||||
`);
|
||||
const sessionsByAgentRows = sessionsByAgentStmt.all(startTime) as Array<{
|
||||
agent_type: string;
|
||||
count: number;
|
||||
}>;
|
||||
const sessionsByAgent: Record<string, number> = {};
|
||||
for (const row of sessionsByAgentRows) {
|
||||
sessionsByAgent[row.agent_type] = row.count;
|
||||
}
|
||||
|
||||
// Sessions by day
|
||||
const sessionsByDayStmt = this.db.prepare(`
|
||||
SELECT date(created_at / 1000, 'unixepoch', 'localtime') as date,
|
||||
COUNT(*) as count
|
||||
FROM session_lifecycle
|
||||
WHERE created_at >= ?
|
||||
GROUP BY date(created_at / 1000, 'unixepoch', 'localtime')
|
||||
ORDER BY date ASC
|
||||
`);
|
||||
const sessionsByDayRows = sessionsByDayStmt.all(startTime) as Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
}>;
|
||||
|
||||
perfMetrics.end(sessionsStart, 'getAggregatedStats:sessions', { range, sessionCount: sessionTotals.count });
|
||||
|
||||
const totalDuration = perfMetrics.end(perfStart, 'getAggregatedStats:total', {
|
||||
range,
|
||||
totalQueries: totals.count,
|
||||
@@ -1269,6 +1453,10 @@ export class StatsDB {
|
||||
byDay: byDayRows,
|
||||
byLocation,
|
||||
byHour: byHourRows,
|
||||
totalSessions: sessionTotals.count,
|
||||
sessionsByAgent,
|
||||
sessionsByDay: sessionsByDayRows,
|
||||
avgSessionDuration: Math.round(sessionTotals.avg_duration),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1279,9 +1467,9 @@ export class StatsDB {
|
||||
/**
|
||||
* Clear old data from the database.
|
||||
*
|
||||
* Deletes query_events, auto_run_sessions, and auto_run_tasks that are older
|
||||
* than the specified number of days. This is useful for managing database size
|
||||
* and removing stale historical data.
|
||||
* Deletes query_events, auto_run_sessions, auto_run_tasks, and session_lifecycle
|
||||
* records that are older than the specified number of days. This is useful for
|
||||
* managing database size and removing stale historical data.
|
||||
*
|
||||
* @param olderThanDays - Delete records older than this many days (e.g., 30, 90, 180, 365)
|
||||
* @returns Object with success status, number of records deleted from each table, and any error
|
||||
@@ -1291,6 +1479,7 @@ export class StatsDB {
|
||||
deletedQueryEvents: number;
|
||||
deletedAutoRunSessions: number;
|
||||
deletedAutoRunTasks: number;
|
||||
deletedSessionLifecycle: number;
|
||||
error?: string;
|
||||
} {
|
||||
if (!this.db) {
|
||||
@@ -1299,6 +1488,7 @@ export class StatsDB {
|
||||
deletedQueryEvents: 0,
|
||||
deletedAutoRunSessions: 0,
|
||||
deletedAutoRunTasks: 0,
|
||||
deletedSessionLifecycle: 0,
|
||||
error: 'Database not initialized',
|
||||
};
|
||||
}
|
||||
@@ -1309,6 +1499,7 @@ export class StatsDB {
|
||||
deletedQueryEvents: 0,
|
||||
deletedAutoRunSessions: 0,
|
||||
deletedAutoRunTasks: 0,
|
||||
deletedSessionLifecycle: 0,
|
||||
error: 'olderThanDays must be greater than 0',
|
||||
};
|
||||
}
|
||||
@@ -1351,9 +1542,15 @@ export class StatsDB {
|
||||
.run(cutoffTime);
|
||||
const deletedEvents = eventsResult.changes;
|
||||
|
||||
const totalDeleted = deletedEvents + deletedSessions + deletedTasks;
|
||||
// Delete session_lifecycle
|
||||
const lifecycleResult = this.db
|
||||
.prepare('DELETE FROM session_lifecycle WHERE created_at < ?')
|
||||
.run(cutoffTime);
|
||||
const deletedLifecycle = lifecycleResult.changes;
|
||||
|
||||
const totalDeleted = deletedEvents + deletedSessions + deletedTasks + deletedLifecycle;
|
||||
logger.info(
|
||||
`Cleared ${totalDeleted} old stats records (${deletedEvents} query events, ${deletedSessions} auto-run sessions, ${deletedTasks} auto-run tasks)`,
|
||||
`Cleared ${totalDeleted} old stats records (${deletedEvents} query events, ${deletedSessions} auto-run sessions, ${deletedTasks} auto-run tasks, ${deletedLifecycle} session lifecycle)`,
|
||||
LOG_CONTEXT
|
||||
);
|
||||
|
||||
@@ -1362,6 +1559,7 @@ export class StatsDB {
|
||||
deletedQueryEvents: deletedEvents,
|
||||
deletedAutoRunSessions: deletedSessions,
|
||||
deletedAutoRunTasks: deletedTasks,
|
||||
deletedSessionLifecycle: deletedLifecycle,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -1371,6 +1569,7 @@ export class StatsDB {
|
||||
deletedQueryEvents: 0,
|
||||
deletedAutoRunSessions: 0,
|
||||
deletedAutoRunTasks: 0,
|
||||
deletedSessionLifecycle: 0,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5634,6 +5634,9 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
showConfirmation(
|
||||
`Are you sure you want to delete the agent "${session.name}"? This action cannot be undone.`,
|
||||
async () => {
|
||||
// Record session closure for Usage Dashboard (before cleanup)
|
||||
window.maestro.stats.recordSessionClosed(id, Date.now());
|
||||
|
||||
// Kill both processes for this session
|
||||
try {
|
||||
await window.maestro.process.kill(`${id}-ai`);
|
||||
@@ -5873,6 +5876,14 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
setActiveSessionId(newId);
|
||||
// Track session creation in global stats
|
||||
updateGlobalStats({ totalSessions: 1 });
|
||||
// Record session lifecycle for Usage Dashboard
|
||||
window.maestro.stats.recordSessionCreated({
|
||||
sessionId: newId,
|
||||
agentType: agentId,
|
||||
projectPath: workingDir,
|
||||
createdAt: Date.now(),
|
||||
isRemote: !!isRemoteSession,
|
||||
});
|
||||
// Auto-focus the input so user can start typing immediately
|
||||
// Use a small delay to ensure the modal has closed and the UI has updated
|
||||
setActiveFocus('main');
|
||||
@@ -6007,6 +6018,14 @@ You are taking over this conversation. Based on the context above, provide a bri
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveSessionId(newId);
|
||||
updateGlobalStats({ totalSessions: 1 });
|
||||
// Record session lifecycle for Usage Dashboard
|
||||
window.maestro.stats.recordSessionCreated({
|
||||
sessionId: newId,
|
||||
agentType: selectedAgent,
|
||||
projectPath: directoryPath,
|
||||
createdAt: Date.now(),
|
||||
isRemote: !!sessionSshRemoteConfig?.enabled,
|
||||
});
|
||||
|
||||
// Clear wizard resume state since we completed successfully
|
||||
clearResumeState();
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Timer,
|
||||
Bot,
|
||||
Users,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { StatsAggregation } from '../../hooks/useStats';
|
||||
@@ -33,7 +34,7 @@ interface SummaryCardsProps {
|
||||
data: StatsAggregation;
|
||||
/** Current theme for styling */
|
||||
theme: Theme;
|
||||
/** Number of columns for responsive layout (default: 5) */
|
||||
/** Number of columns for responsive layout (default: 6) */
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ function MetricCard({ icon, label, value, theme, animationIndex = 0 }: MetricCar
|
||||
);
|
||||
}
|
||||
|
||||
export function SummaryCards({ data, theme, columns = 5 }: SummaryCardsProps) {
|
||||
export function SummaryCards({ data, theme, columns = 6 }: SummaryCardsProps) {
|
||||
// Calculate derived metrics
|
||||
const { mostActiveAgent, interactiveRatio } = useMemo(() => {
|
||||
// Find most active agent by query count
|
||||
@@ -146,6 +147,11 @@ export function SummaryCards({ data, theme, columns = 5 }: SummaryCardsProps) {
|
||||
}, [data.byAgent, data.bySource]);
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
icon: <Layers className="w-4 h-4" />,
|
||||
label: 'Sessions',
|
||||
value: formatNumber(data.totalSessions),
|
||||
},
|
||||
{
|
||||
icon: <MessageSquare className="w-4 h-4" />,
|
||||
label: 'Total Queries',
|
||||
|
||||
@@ -57,6 +57,11 @@ interface StatsAggregation {
|
||||
byLocation: { local: number; remote: number };
|
||||
byDay: Array<{ date: string; count: number; duration: number }>;
|
||||
byHour: Array<{ hour: number; count: number; duration: number }>;
|
||||
// Session lifecycle stats
|
||||
totalSessions: number;
|
||||
sessionsByAgent: Record<string, number>;
|
||||
sessionsByDay: Array<{ date: string; count: number }>;
|
||||
avgSessionDuration: number;
|
||||
}
|
||||
|
||||
// View mode options for the dashboard
|
||||
|
||||
26
src/renderer/global.d.ts
vendored
26
src/renderer/global.d.ts
vendored
@@ -1647,6 +1647,10 @@ interface MaestroAPI {
|
||||
byLocation: { local: number; remote: number };
|
||||
byDay: Array<{ date: string; count: number; duration: number }>;
|
||||
byHour: Array<{ hour: number; count: number; duration: number }>;
|
||||
totalSessions: number;
|
||||
sessionsByAgent: Record<string, number>;
|
||||
sessionsByDay: Array<{ date: string; count: number }>;
|
||||
avgSessionDuration: number;
|
||||
}>;
|
||||
// Export query events to CSV
|
||||
exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<string>;
|
||||
@@ -1658,10 +1662,32 @@ interface MaestroAPI {
|
||||
deletedQueryEvents: number;
|
||||
deletedAutoRunSessions: number;
|
||||
deletedAutoRunTasks: number;
|
||||
deletedSessionLifecycle: number;
|
||||
error?: string;
|
||||
}>;
|
||||
// Get database size in bytes
|
||||
getDatabaseSize: () => Promise<number>;
|
||||
// Record session creation (launched)
|
||||
recordSessionCreated: (event: {
|
||||
sessionId: string;
|
||||
agentType: string;
|
||||
projectPath?: string;
|
||||
createdAt: number;
|
||||
isRemote?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
// Record session closure
|
||||
recordSessionClosed: (sessionId: string, closedAt: number) => Promise<boolean>;
|
||||
// Get session lifecycle events within a time range
|
||||
getSessionLifecycle: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<Array<{
|
||||
id: string;
|
||||
sessionId: string;
|
||||
agentType: string;
|
||||
projectPath?: string;
|
||||
createdAt: number;
|
||||
closedAt?: number;
|
||||
duration?: number;
|
||||
isRemote?: boolean;
|
||||
}>>;
|
||||
};
|
||||
// Document Graph API (file watching for graph visualization)
|
||||
documentGraph: {
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface StatsAggregation {
|
||||
byLocation: { local: number; remote: number };
|
||||
byDay: Array<{ date: string; count: number; duration: number }>;
|
||||
byHour: Array<{ hour: number; count: number; duration: number }>;
|
||||
// Session lifecycle stats
|
||||
totalSessions: number;
|
||||
sessionsByAgent: Record<string, number>;
|
||||
sessionsByDay: Array<{ date: string; count: number }>;
|
||||
avgSessionDuration: number;
|
||||
}
|
||||
|
||||
// Return type for the useStats hook
|
||||
|
||||
@@ -50,6 +50,22 @@ export interface AutoRunTask {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session lifecycle event - tracks when sessions are created and closed
|
||||
*/
|
||||
export interface SessionLifecycleEvent {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
agentType: string;
|
||||
projectPath?: string;
|
||||
createdAt: number;
|
||||
closedAt?: number;
|
||||
/** Duration in ms (computed from closedAt - createdAt when session is closed) */
|
||||
duration?: number;
|
||||
/** Whether this was a remote SSH session */
|
||||
isRemote?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time range for querying stats
|
||||
*/
|
||||
@@ -69,6 +85,14 @@ export interface StatsAggregation {
|
||||
byLocation: { local: number; remote: number };
|
||||
/** Breakdown by hour of day (0-23) for peak hours chart */
|
||||
byHour: Array<{ hour: number; count: number; duration: number }>;
|
||||
/** Total unique sessions launched in the time period */
|
||||
totalSessions: number;
|
||||
/** Sessions by agent type */
|
||||
sessionsByAgent: Record<string, number>;
|
||||
/** Sessions launched per day */
|
||||
sessionsByDay: Array<{ date: string; count: number }>;
|
||||
/** Average session duration in ms (for closed sessions) */
|
||||
avgSessionDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,4 +108,4 @@ export interface StatsFilters {
|
||||
/**
|
||||
* Database schema version for migrations
|
||||
*/
|
||||
export const STATS_DB_VERSION = 2;
|
||||
export const STATS_DB_VERSION = 3;
|
||||
|
||||
Reference in New Issue
Block a user