git-watchtower 1.9.20 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/git-watchtower.js +273 -0
- package/package.json +1 -1
- package/src/cli/args.js +29 -0
- package/src/config/loader.js +8 -0
- package/src/config/schema.js +25 -0
- package/src/index.js +14 -0
- package/src/server/coordinator.js +519 -0
- package/src/server/web-ui.js +2474 -0
- package/src/server/web.js +537 -0
- package/src/ui/keybindings.js +2 -0
- package/src/ui/renderer.js +1 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-instance coordinator for Git Watchtower web dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Manages a shared web server across multiple git-watchtower instances.
|
|
5
|
+
* The first instance becomes the "coordinator" and starts the web server.
|
|
6
|
+
* Subsequent instances connect as workers via Unix domain socket IPC
|
|
7
|
+
* and push their project state to the coordinator.
|
|
8
|
+
*
|
|
9
|
+
* Lock file: ~/.watchtower/web.lock { pid, port, socketPath }
|
|
10
|
+
* Socket: ~/.watchtower/web.sock
|
|
11
|
+
* Registry: in-memory, rebuilt from live connections
|
|
12
|
+
*
|
|
13
|
+
* Zero dependencies — uses only Node built-in modules (net, fs, path, os, crypto).
|
|
14
|
+
*
|
|
15
|
+
* @module server/coordinator
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const net = require('net');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Directory for watchtower runtime files
|
|
26
|
+
*/
|
|
27
|
+
const WATCHTOWER_DIR = path.join(os.homedir(), '.watchtower');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lock file path
|
|
31
|
+
*/
|
|
32
|
+
const LOCK_FILE = path.join(WATCHTOWER_DIR, 'web.lock');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Socket path
|
|
36
|
+
*/
|
|
37
|
+
const SOCKET_PATH = path.join(WATCHTOWER_DIR, 'web.sock');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Ensure the ~/.watchtower directory exists.
|
|
41
|
+
*/
|
|
42
|
+
function ensureDir() {
|
|
43
|
+
if (!fs.existsSync(WATCHTOWER_DIR)) {
|
|
44
|
+
fs.mkdirSync(WATCHTOWER_DIR, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if a process with the given PID is alive.
|
|
50
|
+
* @param {number} pid
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function isProcessAlive(pid) {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(pid, 0);
|
|
56
|
+
return true;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read the lock file.
|
|
64
|
+
* @returns {{ pid: number, port: number, socketPath: string } | null}
|
|
65
|
+
*/
|
|
66
|
+
function readLock() {
|
|
67
|
+
try {
|
|
68
|
+
if (!fs.existsSync(LOCK_FILE)) return null;
|
|
69
|
+
const data = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8'));
|
|
70
|
+
if (!data || !data.pid || !data.port) return null;
|
|
71
|
+
return data;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Write the lock file.
|
|
79
|
+
* @param {number} pid
|
|
80
|
+
* @param {number} port
|
|
81
|
+
* @param {string} socketPath
|
|
82
|
+
*/
|
|
83
|
+
function writeLock(pid, port, socketPath) {
|
|
84
|
+
ensureDir();
|
|
85
|
+
fs.writeFileSync(LOCK_FILE, JSON.stringify({ pid, port, socketPath }, null, 2) + '\n', 'utf8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remove the lock file.
|
|
90
|
+
*/
|
|
91
|
+
function removeLock() {
|
|
92
|
+
try { fs.unlinkSync(LOCK_FILE); } catch (e) { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove stale socket file.
|
|
97
|
+
*/
|
|
98
|
+
function removeSocket() {
|
|
99
|
+
try { fs.unlinkSync(SOCKET_PATH); } catch (e) { /* ignore */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if a coordinator is already running.
|
|
104
|
+
* Cleans up stale lock if the process is dead.
|
|
105
|
+
* @returns {{ pid: number, port: number, socketPath: string } | null}
|
|
106
|
+
*/
|
|
107
|
+
function getActiveCoordinator() {
|
|
108
|
+
const lock = readLock();
|
|
109
|
+
if (!lock) return null;
|
|
110
|
+
if (isProcessAlive(lock.pid)) return lock;
|
|
111
|
+
// Stale lock — clean up
|
|
112
|
+
removeLock();
|
|
113
|
+
removeSocket();
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Coordinator (first instance) ────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @typedef {Object} ProjectState
|
|
121
|
+
* @property {string} id - Unique project identifier
|
|
122
|
+
* @property {string} projectPath - Absolute path to project
|
|
123
|
+
* @property {string} projectName - Directory name
|
|
124
|
+
* @property {Object} state - Serializable state snapshot
|
|
125
|
+
* @property {number} lastUpdate - Timestamp of last state push
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Coordinator server — manages worker connections and aggregates state.
|
|
130
|
+
*/
|
|
131
|
+
class Coordinator {
|
|
132
|
+
/**
|
|
133
|
+
* @param {Object} [options]
|
|
134
|
+
* @param {string} [options.socketPath] - Unix socket path
|
|
135
|
+
*/
|
|
136
|
+
constructor(options = {}) {
|
|
137
|
+
this.socketPath = options.socketPath || SOCKET_PATH;
|
|
138
|
+
/** @type {Map<string, ProjectState>} */
|
|
139
|
+
this.projects = new Map();
|
|
140
|
+
/** @type {Map<string, net.Socket>} */
|
|
141
|
+
this.workerSockets = new Map();
|
|
142
|
+
/** @type {net.Server|null} */
|
|
143
|
+
this.ipcServer = null;
|
|
144
|
+
/** @type {Function|null} */
|
|
145
|
+
this.onProjectsChanged = null;
|
|
146
|
+
/** @type {Function|null} */
|
|
147
|
+
this.onActionRequest = null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Start the IPC server.
|
|
152
|
+
* @returns {Promise<void>}
|
|
153
|
+
*/
|
|
154
|
+
start() {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
ensureDir();
|
|
157
|
+
removeSocket();
|
|
158
|
+
|
|
159
|
+
this.ipcServer = net.createServer((socket) => {
|
|
160
|
+
this._handleWorkerConnection(socket);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
this.ipcServer.on('error', (err) => {
|
|
164
|
+
reject(err);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
this.ipcServer.listen(this.socketPath, () => {
|
|
168
|
+
resolve();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Stop the IPC server and clean up.
|
|
175
|
+
*/
|
|
176
|
+
stop() {
|
|
177
|
+
// Close all worker sockets
|
|
178
|
+
for (const socket of this.workerSockets.values()) {
|
|
179
|
+
try { socket.destroy(); } catch (e) { /* ignore */ }
|
|
180
|
+
}
|
|
181
|
+
this.workerSockets.clear();
|
|
182
|
+
this.projects.clear();
|
|
183
|
+
|
|
184
|
+
if (this.ipcServer) {
|
|
185
|
+
this.ipcServer.close();
|
|
186
|
+
this.ipcServer = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
removeLock();
|
|
190
|
+
removeSocket();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Register the coordinator's own project (local instance).
|
|
195
|
+
* @param {string} id
|
|
196
|
+
* @param {string} projectPath
|
|
197
|
+
* @param {string} projectName
|
|
198
|
+
* @param {Object} state
|
|
199
|
+
*/
|
|
200
|
+
registerLocal(id, projectPath, projectName, state) {
|
|
201
|
+
this.projects.set(id, {
|
|
202
|
+
id,
|
|
203
|
+
projectPath,
|
|
204
|
+
projectName,
|
|
205
|
+
state: state || {},
|
|
206
|
+
lastUpdate: Date.now(),
|
|
207
|
+
});
|
|
208
|
+
this._notifyProjectsChanged();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Update the coordinator's own project state.
|
|
213
|
+
* @param {string} id
|
|
214
|
+
* @param {Object} state
|
|
215
|
+
*/
|
|
216
|
+
updateLocal(id, state) {
|
|
217
|
+
const project = this.projects.get(id);
|
|
218
|
+
if (project) {
|
|
219
|
+
project.state = state;
|
|
220
|
+
project.lastUpdate = Date.now();
|
|
221
|
+
this._notifyProjectsChanged();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get all project states.
|
|
227
|
+
* @returns {ProjectState[]}
|
|
228
|
+
*/
|
|
229
|
+
getProjects() {
|
|
230
|
+
return Array.from(this.projects.values());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get a specific project.
|
|
235
|
+
* @param {string} id
|
|
236
|
+
* @returns {ProjectState|undefined}
|
|
237
|
+
*/
|
|
238
|
+
getProject(id) {
|
|
239
|
+
return this.projects.get(id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Send a command to a worker.
|
|
244
|
+
* @param {string} projectId
|
|
245
|
+
* @param {string} action
|
|
246
|
+
* @param {Object} payload
|
|
247
|
+
*/
|
|
248
|
+
sendCommand(projectId, action, payload) {
|
|
249
|
+
const socket = this.workerSockets.get(projectId);
|
|
250
|
+
if (socket) {
|
|
251
|
+
this._sendMessage(socket, { type: 'command', action, payload });
|
|
252
|
+
} else if (this.onActionRequest) {
|
|
253
|
+
// Local project — handle directly
|
|
254
|
+
this.onActionRequest(projectId, action, payload);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Private ─────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Handle a new worker connection.
|
|
262
|
+
* @param {net.Socket} socket
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
_handleWorkerConnection(socket) {
|
|
266
|
+
let workerId = null;
|
|
267
|
+
let buffer = '';
|
|
268
|
+
|
|
269
|
+
socket.on('data', (data) => {
|
|
270
|
+
buffer += data.toString();
|
|
271
|
+
let newlineIdx;
|
|
272
|
+
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
273
|
+
const line = buffer.slice(0, newlineIdx);
|
|
274
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
275
|
+
if (line.trim()) {
|
|
276
|
+
try {
|
|
277
|
+
const msg = JSON.parse(line);
|
|
278
|
+
this._handleWorkerMessage(socket, msg, (id) => { workerId = id; }, () => workerId);
|
|
279
|
+
} catch (e) { /* ignore bad JSON */ }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
socket.on('close', () => {
|
|
285
|
+
if (workerId) {
|
|
286
|
+
this.projects.delete(workerId);
|
|
287
|
+
this.workerSockets.delete(workerId);
|
|
288
|
+
this._notifyProjectsChanged();
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
socket.on('error', () => {
|
|
293
|
+
if (workerId) {
|
|
294
|
+
this.projects.delete(workerId);
|
|
295
|
+
this.workerSockets.delete(workerId);
|
|
296
|
+
this._notifyProjectsChanged();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Handle a message from a worker.
|
|
303
|
+
* @param {net.Socket} socket
|
|
304
|
+
* @param {Object} msg
|
|
305
|
+
* @param {Function} setWorkerId
|
|
306
|
+
* @private
|
|
307
|
+
*/
|
|
308
|
+
_handleWorkerMessage(socket, msg, setWorkerId, getWorkerId) {
|
|
309
|
+
switch (msg.type) {
|
|
310
|
+
case 'register':
|
|
311
|
+
setWorkerId(msg.id);
|
|
312
|
+
this.workerSockets.set(msg.id, socket);
|
|
313
|
+
this.projects.set(msg.id, {
|
|
314
|
+
id: msg.id,
|
|
315
|
+
projectPath: msg.projectPath,
|
|
316
|
+
projectName: msg.projectName,
|
|
317
|
+
state: msg.state || {},
|
|
318
|
+
lastUpdate: Date.now(),
|
|
319
|
+
});
|
|
320
|
+
this._sendMessage(socket, { type: 'registered', id: msg.id });
|
|
321
|
+
this._notifyProjectsChanged();
|
|
322
|
+
break;
|
|
323
|
+
|
|
324
|
+
case 'state': {
|
|
325
|
+
// Validate sender — only accept state for the worker's own registered ID
|
|
326
|
+
const registeredId = getWorkerId();
|
|
327
|
+
if (msg.id && msg.id === registeredId && this.projects.has(msg.id)) {
|
|
328
|
+
this.projects.get(msg.id).state = msg.state;
|
|
329
|
+
this.projects.get(msg.id).lastUpdate = Date.now();
|
|
330
|
+
this._notifyProjectsChanged();
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
case 'unregister': {
|
|
336
|
+
const regId = getWorkerId();
|
|
337
|
+
if (msg.id && msg.id === regId) {
|
|
338
|
+
this.projects.delete(msg.id);
|
|
339
|
+
this.workerSockets.delete(msg.id);
|
|
340
|
+
this._notifyProjectsChanged();
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Send a JSON message over a socket.
|
|
349
|
+
* @param {net.Socket} socket
|
|
350
|
+
* @param {Object} msg
|
|
351
|
+
* @private
|
|
352
|
+
*/
|
|
353
|
+
_sendMessage(socket, msg) {
|
|
354
|
+
try {
|
|
355
|
+
socket.write(JSON.stringify(msg) + '\n');
|
|
356
|
+
} catch (e) { /* ignore write errors on dead sockets */ }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Notify that projects changed.
|
|
361
|
+
* @private
|
|
362
|
+
*/
|
|
363
|
+
_notifyProjectsChanged() {
|
|
364
|
+
if (this.onProjectsChanged) {
|
|
365
|
+
this.onProjectsChanged(this.getProjects());
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─── Worker (subsequent instances) ───────────────────────────────
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Worker client — connects to the coordinator and pushes state.
|
|
374
|
+
*/
|
|
375
|
+
class Worker {
|
|
376
|
+
/**
|
|
377
|
+
* @param {Object} options
|
|
378
|
+
* @param {string} options.id - Unique project ID
|
|
379
|
+
* @param {string} options.projectPath - Absolute path
|
|
380
|
+
* @param {string} options.projectName - Directory name
|
|
381
|
+
* @param {string} [options.socketPath] - Unix socket path
|
|
382
|
+
*/
|
|
383
|
+
constructor(options) {
|
|
384
|
+
this.id = options.id;
|
|
385
|
+
this.projectPath = options.projectPath;
|
|
386
|
+
this.projectName = options.projectName;
|
|
387
|
+
this.socketPath = options.socketPath || SOCKET_PATH;
|
|
388
|
+
/** @type {net.Socket|null} */
|
|
389
|
+
this.socket = null;
|
|
390
|
+
/** @type {Function|null} */
|
|
391
|
+
this.onCommand = null;
|
|
392
|
+
this._connected = false;
|
|
393
|
+
this._buffer = '';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Connect to the coordinator.
|
|
398
|
+
* @returns {Promise<void>}
|
|
399
|
+
*/
|
|
400
|
+
connect() {
|
|
401
|
+
return new Promise((resolve, reject) => {
|
|
402
|
+
this.socket = net.createConnection(this.socketPath, () => {
|
|
403
|
+
this._connected = true;
|
|
404
|
+
// Register with coordinator
|
|
405
|
+
this._send({
|
|
406
|
+
type: 'register',
|
|
407
|
+
id: this.id,
|
|
408
|
+
projectPath: this.projectPath,
|
|
409
|
+
projectName: this.projectName,
|
|
410
|
+
});
|
|
411
|
+
resolve();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
this.socket.on('data', (data) => {
|
|
415
|
+
this._buffer += data.toString();
|
|
416
|
+
let idx;
|
|
417
|
+
while ((idx = this._buffer.indexOf('\n')) !== -1) {
|
|
418
|
+
const line = this._buffer.slice(0, idx);
|
|
419
|
+
this._buffer = this._buffer.slice(idx + 1);
|
|
420
|
+
if (line.trim()) {
|
|
421
|
+
try {
|
|
422
|
+
const msg = JSON.parse(line);
|
|
423
|
+
this._handleMessage(msg);
|
|
424
|
+
} catch (e) { /* ignore */ }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
this.socket.on('error', (err) => {
|
|
430
|
+
this._connected = false;
|
|
431
|
+
reject(err);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
this.socket.on('close', () => {
|
|
435
|
+
this._connected = false;
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Push state update to the coordinator.
|
|
442
|
+
* @param {Object} state - Serializable state
|
|
443
|
+
*/
|
|
444
|
+
pushState(state) {
|
|
445
|
+
if (!this._connected) return;
|
|
446
|
+
this._send({ type: 'state', id: this.id, state });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Disconnect from the coordinator.
|
|
451
|
+
*/
|
|
452
|
+
disconnect() {
|
|
453
|
+
if (this.socket) {
|
|
454
|
+
if (this._connected) {
|
|
455
|
+
this._send({ type: 'unregister', id: this.id });
|
|
456
|
+
}
|
|
457
|
+
this.socket.destroy();
|
|
458
|
+
this.socket = null;
|
|
459
|
+
this._connected = false;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Check if connected.
|
|
465
|
+
* @returns {boolean}
|
|
466
|
+
*/
|
|
467
|
+
isConnected() {
|
|
468
|
+
return this._connected;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ─── Private ─────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @param {Object} msg
|
|
475
|
+
* @private
|
|
476
|
+
*/
|
|
477
|
+
_send(msg) {
|
|
478
|
+
if (this.socket && this._connected) {
|
|
479
|
+
try {
|
|
480
|
+
this.socket.write(JSON.stringify(msg) + '\n');
|
|
481
|
+
} catch (e) { /* ignore */ }
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* @param {Object} msg
|
|
487
|
+
* @private
|
|
488
|
+
*/
|
|
489
|
+
_handleMessage(msg) {
|
|
490
|
+
if (msg.type === 'command' && this.onCommand) {
|
|
491
|
+
this.onCommand(msg.action, msg.payload);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Generate a unique project ID from the project path.
|
|
498
|
+
* @param {string} projectPath
|
|
499
|
+
* @returns {string}
|
|
500
|
+
*/
|
|
501
|
+
function generateProjectId(projectPath) {
|
|
502
|
+
return crypto.createHash('md5').update(projectPath).digest('hex').slice(0, 12);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
module.exports = {
|
|
506
|
+
Coordinator,
|
|
507
|
+
Worker,
|
|
508
|
+
generateProjectId,
|
|
509
|
+
getActiveCoordinator,
|
|
510
|
+
readLock,
|
|
511
|
+
writeLock,
|
|
512
|
+
removeLock,
|
|
513
|
+
removeSocket,
|
|
514
|
+
isProcessAlive,
|
|
515
|
+
ensureDir,
|
|
516
|
+
WATCHTOWER_DIR,
|
|
517
|
+
LOCK_FILE,
|
|
518
|
+
SOCKET_PATH,
|
|
519
|
+
};
|