Skip to content

Commit 6706929

Browse files
committed
✨ feat(watchers): add next run metadata to watcher API and UI (#288)
1 parent 6b5c1f7 commit 6706929

File tree

14 files changed

+529
-23
lines changed

14 files changed

+529
-23
lines changed

app/agent/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export async function init() {
160160
app.get('/api/containers/:id/logs', containerApi.getContainerLogs);
161161
app.delete('/api/containers/:id', containerApi.deleteContainer);
162162
app.get('/api/watchers', watcherApi.getWatchers);
163+
app.get('/api/watchers/:type/:name', watcherApi.getWatcher);
163164
app.get('/api/triggers', triggerApi.getTriggers);
164165
app.get('/api/events', eventApi.subscribeEvents);
165166
app.post('/api/triggers/:type/:name', triggerApi.runTrigger);

app/agent/api/watcher.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ vi.mock('../../registry/index.js', () => ({
1313

1414
vi.mock('../../api/component.js', () => ({
1515
mapComponentsToList: vi.fn().mockReturnValue([]),
16+
mapComponentToItem: vi.fn().mockImplementation((key, component) => ({
17+
id: key,
18+
type: component.type,
19+
name: component.name,
20+
configuration: component.configuration ?? {},
21+
metadata: component.metadata,
22+
})),
1623
}));
1724

1825
vi.mock('../../store/container.js', () => ({
@@ -95,6 +102,45 @@ describe('agent API watcher', () => {
95102
});
96103
});
97104

105+
describe('getWatcher', () => {
106+
test('should return a specific watcher', () => {
107+
req.params = { type: 'docker', name: 'local' };
108+
registry.getState.mockReturnValue({
109+
watcher: {
110+
'docker.local': {
111+
type: 'docker',
112+
name: 'local',
113+
configuration: { cron: '0 * * * *' },
114+
metadata: { nextRunAt: '2026-04-09T13:00:00.000Z' },
115+
},
116+
},
117+
});
118+
119+
watcherApi.getWatcher(req, res);
120+
121+
expect(res.status).toHaveBeenCalledWith(200);
122+
expect(res.json).toHaveBeenCalledWith({
123+
id: 'docker.local',
124+
type: 'docker',
125+
name: 'local',
126+
configuration: { cron: '0 * * * *' },
127+
metadata: { nextRunAt: '2026-04-09T13:00:00.000Z' },
128+
});
129+
});
130+
131+
test('should return 404 when the watcher detail is missing', () => {
132+
req.params = { type: 'docker', name: 'missing' };
133+
registry.getState.mockReturnValue({ watcher: {} });
134+
135+
watcherApi.getWatcher(req, res);
136+
137+
expect(res.status).toHaveBeenCalledWith(404);
138+
expect(res.json).toHaveBeenCalledWith(
139+
expect.objectContaining({ error: 'Watcher missing not found' }),
140+
);
141+
});
142+
});
143+
98144
describe('watchContainer', () => {
99145
test('should return 404 when watcher is not found', async () => {
100146
req.params = { type: 'docker', name: 'local', id: 'c1' };

app/agent/api/watcher.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Request, Response } from 'express';
2-
import { mapComponentsToList } from '../../api/component.js';
2+
import { mapComponentsToList, mapComponentToItem } from '../../api/component.js';
33
import { sendErrorResponse } from '../../api/error-response.js';
44
import logger from '../../log/index.js';
55
import { sanitizeLogParam } from '../../log/sanitize.js';
@@ -40,6 +40,23 @@ export function getWatchers(req: Request, res: Response) {
4040
res.json(items);
4141
}
4242

43+
/**
44+
* Get a specific watcher.
45+
*/
46+
export function getWatcher(req: Request, res: Response) {
47+
const type = req.params.type as string;
48+
const name = req.params.name as string;
49+
const watcherId = `${type.toLowerCase()}.${name.toLowerCase()}`;
50+
const watcher = registry.getState().watcher[watcherId];
51+
52+
if (!watcher) {
53+
sendErrorResponse(res, 404, `Watcher ${name} not found`);
54+
return;
55+
}
56+
57+
res.status(200).json(mapComponentToItem(watcherId, watcher, 'watcher'));
58+
}
59+
4360
/**
4461
* Watch a specific watcher.
4562
*/

app/api/watcher.test.ts

Lines changed: 143 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,153 @@
1-
vi.mock('./component', () => ({
2-
init: vi.fn(() => 'watcher-router'),
1+
import * as agentManager from '../agent/manager.js';
2+
import * as registry from '../registry/index.js';
3+
import * as watcherRouter from './watcher.js';
4+
5+
vi.mock('../registry/index.js', () => ({
6+
getState: vi.fn(),
37
}));
48

5-
import * as component from './component.js';
6-
import * as watcherRouter from './watcher.js';
9+
vi.mock('../agent/manager.js', () => ({
10+
getAgent: vi.fn(),
11+
}));
12+
13+
function createMockResponse() {
14+
return {
15+
status: vi.fn().mockReturnThis(),
16+
json: vi.fn(),
17+
};
18+
}
719

820
describe('Watcher Router', () => {
921
beforeEach(() => {
1022
vi.clearAllMocks();
1123
});
1224

13-
test('should init component router with watcher kind', () => {
14-
const router = watcherRouter.init();
15-
expect(component.init).toHaveBeenCalledWith('watcher');
16-
expect(router).toBe('watcher-router');
25+
test('getWatchers should return local watcher metadata', async () => {
26+
registry.getState.mockReturnValue({
27+
watcher: {
28+
'docker.local': {
29+
type: 'docker',
30+
name: 'local',
31+
configuration: { cron: '0 * * * *' },
32+
maskConfiguration: vi.fn(() => ({ cron: '0 * * * *' })),
33+
getMetadata: vi.fn(() => ({ nextRunAt: '2026-04-09T13:00:00.000Z' })),
34+
},
35+
},
36+
});
37+
38+
const res = createMockResponse();
39+
await watcherRouter.getWatchers({ query: {} }, res);
40+
41+
expect(res.status).toHaveBeenCalledWith(200);
42+
expect(res.json).toHaveBeenCalledWith({
43+
data: [
44+
{
45+
id: 'docker.local',
46+
type: 'docker',
47+
name: 'local',
48+
configuration: { cron: '0 * * * *' },
49+
agent: undefined,
50+
metadata: { nextRunAt: '2026-04-09T13:00:00.000Z' },
51+
},
52+
],
53+
total: 1,
54+
limit: 0,
55+
offset: 0,
56+
hasMore: false,
57+
});
58+
});
59+
60+
test('getWatchers should merge fresh metadata from agent-backed watchers', async () => {
61+
registry.getState.mockReturnValue({
62+
watcher: {
63+
'edge.docker.remote': {
64+
type: 'docker',
65+
name: 'remote',
66+
agent: 'edge',
67+
configuration: { cron: '0 * * * *' },
68+
maskConfiguration: vi.fn(() => ({ cron: '0 * * * *' })),
69+
getMetadata: vi.fn(() => ({})),
70+
},
71+
},
72+
});
73+
agentManager.getAgent.mockReturnValue({
74+
getWatcher: vi.fn().mockResolvedValue({
75+
id: 'docker.remote',
76+
type: 'docker',
77+
name: 'remote',
78+
configuration: { cron: '*/15 * * * *' },
79+
metadata: { nextRunAt: '2026-04-09T12:45:00.000Z' },
80+
}),
81+
});
82+
83+
const res = createMockResponse();
84+
await watcherRouter.getWatchers({ query: {} }, res);
85+
86+
expect(res.json).toHaveBeenCalledWith({
87+
data: [
88+
{
89+
id: 'edge.docker.remote',
90+
type: 'docker',
91+
name: 'remote',
92+
configuration: { cron: '*/15 * * * *' },
93+
agent: 'edge',
94+
metadata: { nextRunAt: '2026-04-09T12:45:00.000Z' },
95+
},
96+
],
97+
total: 1,
98+
limit: 0,
99+
offset: 0,
100+
hasMore: false,
101+
});
102+
});
103+
104+
test('getWatcher should return a specific agent-backed watcher with refreshed metadata', async () => {
105+
registry.getState.mockReturnValue({
106+
watcher: {
107+
'edge.docker.remote': {
108+
type: 'docker',
109+
name: 'remote',
110+
agent: 'edge',
111+
configuration: { cron: '0 * * * *' },
112+
maskConfiguration: vi.fn(() => ({ cron: '0 * * * *' })),
113+
getMetadata: vi.fn(() => ({})),
114+
},
115+
},
116+
});
117+
agentManager.getAgent.mockReturnValue({
118+
getWatcher: vi.fn().mockResolvedValue({
119+
id: 'docker.remote',
120+
type: 'docker',
121+
name: 'remote',
122+
configuration: { cron: '*/15 * * * *' },
123+
metadata: { nextRunAt: '2026-04-09T12:45:00.000Z' },
124+
}),
125+
});
126+
127+
const res = createMockResponse();
128+
await watcherRouter.getWatcher(
129+
{ params: { type: 'docker', name: 'remote', agent: 'edge' } },
130+
res,
131+
);
132+
133+
expect(res.status).toHaveBeenCalledWith(200);
134+
expect(res.json).toHaveBeenCalledWith({
135+
id: 'edge.docker.remote',
136+
type: 'docker',
137+
name: 'remote',
138+
configuration: { cron: '*/15 * * * *' },
139+
agent: 'edge',
140+
metadata: { nextRunAt: '2026-04-09T12:45:00.000Z' },
141+
});
142+
});
143+
144+
test('getWatcher should return 404 when watcher is missing', async () => {
145+
registry.getState.mockReturnValue({ watcher: {} });
146+
147+
const res = createMockResponse();
148+
await watcherRouter.getWatcher({ params: { type: 'docker', name: 'missing' } }, res);
149+
150+
expect(res.status).toHaveBeenCalledWith(404);
151+
expect(res.json).toHaveBeenCalledWith({ error: 'Component not found' });
17152
});
18153
});

app/api/watcher.ts

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,128 @@
1-
import * as component from './component.js';
1+
import express, { type Request, type Response } from 'express';
2+
import nocache from 'nocache';
3+
import { byString, byValues } from 'sort-es';
4+
import { getAgent } from '../agent/manager.js';
5+
import logger from '../log/index.js';
6+
import * as registry from '../registry/index.js';
7+
import { getErrorMessage } from '../util/error.js';
8+
import type Watcher from '../watchers/Watcher.js';
9+
import { type ApiComponent, mapComponentToItem } from './component.js';
10+
import { normalizeLimitOffsetPagination } from './container/request-helpers.js';
11+
import { sendErrorResponse } from './error-response.js';
12+
13+
const log = logger.child({ component: 'api-watcher' });
14+
const WATCHER_LIST_MAX_LIMIT = 200;
15+
16+
interface WatcherRouteParams {
17+
agent?: string;
18+
type: string;
19+
name: string;
20+
}
21+
22+
function resolveWatcherId(params: WatcherRouteParams): string {
23+
return params.agent
24+
? `${params.agent}.${params.type}.${params.name}`
25+
: `${params.type}.${params.name}`;
26+
}
27+
28+
function paginateWatcherItems(
29+
watchers: ApiComponent[],
30+
pagination: { limit: number; offset: number },
31+
): ApiComponent[] {
32+
if (pagination.offset >= watchers.length) {
33+
return [];
34+
}
35+
36+
if (pagination.limit === 0) {
37+
return watchers.slice(pagination.offset);
38+
}
39+
40+
return watchers.slice(pagination.offset, pagination.offset + pagination.limit);
41+
}
42+
43+
function sortWatcherItems(watchers: ApiComponent[]): ApiComponent[] {
44+
return [...watchers].sort(
45+
byValues([
46+
[(watcher) => watcher.type, byString()],
47+
[(watcher) => watcher.name, byString()],
48+
]),
49+
);
50+
}
51+
52+
async function resolveWatcherItem(id: string, watcher: Watcher): Promise<ApiComponent> {
53+
const fallback = mapComponentToItem(id, watcher, 'watcher');
54+
55+
if (!watcher.agent) {
56+
return fallback;
57+
}
58+
59+
const agentClient = getAgent(watcher.agent);
60+
if (!agentClient) {
61+
return fallback;
62+
}
63+
64+
try {
65+
const remoteWatcher = await agentClient.getWatcher(watcher.type, watcher.name);
66+
return {
67+
...fallback,
68+
configuration: remoteWatcher.configuration ?? fallback.configuration,
69+
metadata: remoteWatcher.metadata ?? fallback.metadata,
70+
};
71+
} catch (error: unknown) {
72+
log.debug(
73+
`Unable to refresh watcher ${watcher.agent}.${watcher.type}.${watcher.name} (${getErrorMessage(error)})`,
74+
);
75+
return fallback;
76+
}
77+
}
78+
79+
export async function getWatchers(req: Request, res: Response): Promise<void> {
80+
const watchers = registry.getState().watcher || {};
81+
const items = await Promise.all(
82+
Object.entries(watchers).map(([id, watcher]) => resolveWatcherItem(id, watcher)),
83+
);
84+
const allItems = sortWatcherItems(items);
85+
const pagination = normalizeLimitOffsetPagination(req.query, {
86+
maxLimit: WATCHER_LIST_MAX_LIMIT,
87+
});
88+
const data = paginateWatcherItems(allItems, pagination);
89+
90+
res.status(200).json({
91+
data,
92+
total: allItems.length,
93+
limit: pagination.limit,
94+
offset: pagination.offset,
95+
hasMore: pagination.limit > 0 && pagination.offset + data.length < allItems.length,
96+
});
97+
}
98+
99+
export async function getWatcher(req: Request<WatcherRouteParams>, res: Response): Promise<void> {
100+
const watcherId = resolveWatcherId(req.params);
101+
const watcher = registry.getState().watcher[watcherId];
102+
103+
if (!watcher) {
104+
sendErrorResponse(res, 404, 'Component not found');
105+
return;
106+
}
107+
108+
const item = await resolveWatcherItem(watcherId, watcher);
109+
res.status(200).json(item);
110+
}
2111

3112
/**
4113
* Init Router.
5-
* @returns {*}
6114
*/
7115
export function init() {
8-
return component.init('watcher');
116+
const router = express.Router();
117+
router.use(nocache());
118+
router.get('/', (req: Request, res: Response) => {
119+
void getWatchers(req, res);
120+
});
121+
router.get('/:type/:name', (req: Request<WatcherRouteParams>, res: Response) => {
122+
void getWatcher(req, res);
123+
});
124+
router.get('/:type/:name/:agent', (req: Request<WatcherRouteParams>, res: Response) => {
125+
void getWatcher(req, res);
126+
});
127+
return router;
9128
}

0 commit comments

Comments
 (0)