wldx-admin-cli 1.1.0 → 1.2.1
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/README.md +57 -9
- package/package.json +2 -2
- package/src/commands/config.mjs +248 -1
- package/src/commands/pbx.mjs +1 -1
- package/src/commands/system.mjs +1 -1
- package/src/commands/trunk.mjs +1 -1
- package/src/transfer/apply.mjs +109 -0
- package/src/transfer/folder.mjs +77 -0
- package/src/transfer/kinds.mjs +259 -0
- package/src/transfer/log.mjs +35 -0
- package/src/transfer/next-steps.mjs +251 -0
- package/src/transfer/plan.mjs +153 -0
- package/src/transfer/refs.mjs +251 -0
- package/src/transfer/run.mjs +308 -0
- package/src/transfer/selection.mjs +124 -0
- package/src/secret.mjs +0 -42
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// The kinds of objects a config transfer carries, in the order they are written (what others point at first). Each
|
|
2
|
+
// kind reads its objects as {id, name, data} (data without ids, dns and runtime fields) and writes one: create
|
|
3
|
+
// answers the new id (re-reading the list where the PBX answers only "Success"), update sends the whole object (the
|
|
4
|
+
// PBX wants every field on a PUT).
|
|
5
|
+
import {apiPath, query} from 'wldx-core/http';
|
|
6
|
+
|
|
7
|
+
const listOf = (r) => (Array.isArray(r) ? r : (r?.records ?? []));
|
|
8
|
+
const without = (o, keys) => Object.fromEntries(Object.entries(o ?? {}).filter(([k]) => !keys.includes(k)));
|
|
9
|
+
const RUNTIME = ['id', 'dn', 'status'];
|
|
10
|
+
|
|
11
|
+
// A kind kept in one list at `path`, its objects known by `nameField`; item paths are `<path><id>/` unless `item`.
|
|
12
|
+
function listed({group, label, path, scope, nameField, strip = [], item = (id) => `${path}${id}/`, postAnswers}) {
|
|
13
|
+
const read = async (client) =>
|
|
14
|
+
listOf(await client.call('GET', path, undefined, {scope})).map((r) => ({
|
|
15
|
+
id: String(r.id),
|
|
16
|
+
name: String(r[nameField] ?? r.id),
|
|
17
|
+
...(r.dn ? {dn: r.dn} : {}),
|
|
18
|
+
data: without(r, [...RUNTIME, ...strip]),
|
|
19
|
+
}));
|
|
20
|
+
return {
|
|
21
|
+
group,
|
|
22
|
+
label,
|
|
23
|
+
scope,
|
|
24
|
+
read,
|
|
25
|
+
async create(client, data) {
|
|
26
|
+
const answer = await client.call('POST', path, data, {scope});
|
|
27
|
+
if (postAnswers === 'object' && answer?.id !== undefined) return String(answer.id);
|
|
28
|
+
const made = (await read(client)).find((o) => o.name === String(data[nameField]));
|
|
29
|
+
if (!made) throw new Error(`The PBX did not show the new ${label} ${data[nameField]}`);
|
|
30
|
+
return made.id;
|
|
31
|
+
},
|
|
32
|
+
async update(client, id, data) {
|
|
33
|
+
await client.call('PUT', item(id), data, {scope});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The user fields bound to the PBX they live on: never carried over.
|
|
39
|
+
const USER_BOUND = ['id', 'dn', 'uid', 'sourceId', 'jid', 'pbx', 'pbxDn', 'login', 'password', 'sipPassword'];
|
|
40
|
+
const PAGE = 1000;
|
|
41
|
+
|
|
42
|
+
const users = {
|
|
43
|
+
group: 'users',
|
|
44
|
+
label: 'user',
|
|
45
|
+
scope: 'colleagues',
|
|
46
|
+
async read(client) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (let start = 0; ; start += PAGE) {
|
|
49
|
+
const page = listOf(
|
|
50
|
+
await client.call('GET', `/PBX/Colleagues/${query({count: PAGE, start})}`, undefined, {scope: 'colleagues'}),
|
|
51
|
+
);
|
|
52
|
+
for (const u of page)
|
|
53
|
+
out.push({
|
|
54
|
+
id: String(u.id),
|
|
55
|
+
name: String(u.extension),
|
|
56
|
+
...(u.dn ? {dn: u.dn} : {}),
|
|
57
|
+
data: without(u, USER_BOUND),
|
|
58
|
+
});
|
|
59
|
+
if (page.length < PAGE) return out;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
async create(client, data) {
|
|
63
|
+
return String((await client.call('POST', '/PBX/Colleagues/', data, {scope: 'colleagues'})).id);
|
|
64
|
+
},
|
|
65
|
+
async update(client, id, data) {
|
|
66
|
+
await client.call('PUT', apiPath`/PBX/Colleagues/${id}/`, data, {scope: 'colleagues'});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const PRICES = '/Trunks/Prices/';
|
|
71
|
+
const priceForm = (data, extra = {}) => {
|
|
72
|
+
const form = new FormData();
|
|
73
|
+
form.append('name', data.name);
|
|
74
|
+
form.append('upload', new Blob([data.csv ?? ''], {type: 'text/csv'}), `${data.name}.csv`);
|
|
75
|
+
for (const [k, v] of Object.entries(extra)) form.append(k, v);
|
|
76
|
+
return form;
|
|
77
|
+
};
|
|
78
|
+
const priceLists = {
|
|
79
|
+
group: 'trunks',
|
|
80
|
+
label: 'price list',
|
|
81
|
+
scope: 'trunks',
|
|
82
|
+
async read(client) {
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const p of listOf(await client.call('GET', PRICES, undefined, {scope: 'trunks'}))) {
|
|
85
|
+
const csv = await client.request('GET', apiPath`/Trunks/Prices/${p.id}/export`, {
|
|
86
|
+
responseType: 'buffer',
|
|
87
|
+
scope: 'trunks',
|
|
88
|
+
});
|
|
89
|
+
out.push({id: String(p.id), name: String(p.name), data: {name: p.name, csv: Buffer.from(csv).toString('utf8')}});
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
},
|
|
93
|
+
async create(client, data) {
|
|
94
|
+
return String((await client.request('POST', PRICES, {data: priceForm(data), scope: 'trunks'})).id);
|
|
95
|
+
},
|
|
96
|
+
async update(client, id, data) {
|
|
97
|
+
await client.request('POST', apiPath`/Trunks/Prices/${id}/`, {
|
|
98
|
+
data: priceForm(data, {method: 'PUT'}),
|
|
99
|
+
scope: 'trunks',
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// The dialplan database has no list of its families: only the families asked for are read.
|
|
105
|
+
const dbKeyPath = (name) =>
|
|
106
|
+
`${String(name)
|
|
107
|
+
.split('/')
|
|
108
|
+
.map((p) => apiPath`/${p}`)
|
|
109
|
+
.join('')
|
|
110
|
+
.replace(/^/, '/Dialplan/DB')}/`;
|
|
111
|
+
const dialplanDb = {
|
|
112
|
+
group: 'routing',
|
|
113
|
+
label: 'dialplan database key',
|
|
114
|
+
scope: 'dialplan',
|
|
115
|
+
async read(client, {dbFamilies = []} = {}) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const family of dbFamilies) {
|
|
118
|
+
for (const r of listOf(
|
|
119
|
+
await client.call('GET', apiPath`/Dialplan/DB/${family}/`, undefined, {scope: 'dialplan'}),
|
|
120
|
+
)) {
|
|
121
|
+
const name = String(r.key).replace(/^\//, '');
|
|
122
|
+
out.push({id: name, name, data: {value: r.value}});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
},
|
|
127
|
+
async create(client, data, name) {
|
|
128
|
+
await client.call('PUT', dbKeyPath(name), {value: data.value}, {scope: 'dialplan'});
|
|
129
|
+
return name;
|
|
130
|
+
},
|
|
131
|
+
async update(client, id, data) {
|
|
132
|
+
await client.call('PUT', dbKeyPath(id), {value: data.value}, {scope: 'dialplan'});
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const DIALPLANS = '/PBX/Dialplans/';
|
|
137
|
+
const dialplans = {
|
|
138
|
+
group: 'routing',
|
|
139
|
+
label: 'dialplan',
|
|
140
|
+
scope: 'dialplan',
|
|
141
|
+
async read(client) {
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const r of listOf(await client.call('GET', DIALPLANS, undefined, {scope: 'dialplan'}))) {
|
|
144
|
+
const full = await client.call('GET', apiPath`/PBX/Dialplans/${r.id}/`, undefined, {scope: 'dialplan'});
|
|
145
|
+
out.push({id: String(r.id), name: String(r.name), data: without(full, RUNTIME)});
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
},
|
|
149
|
+
async create(client, data) {
|
|
150
|
+
const body = {
|
|
151
|
+
name: data.name,
|
|
152
|
+
description: data.description ?? '',
|
|
153
|
+
numbers: data.numbers ?? [],
|
|
154
|
+
includedDialplans: data.includedDialplans ?? [],
|
|
155
|
+
};
|
|
156
|
+
return String((await client.call('POST', DIALPLANS, body, {scope: 'dialplan'})).id);
|
|
157
|
+
},
|
|
158
|
+
// What PUT takes: description, numbers, includedDialplans (the name does not change).
|
|
159
|
+
async update(client, id, data) {
|
|
160
|
+
const body = {
|
|
161
|
+
description: data.description ?? '',
|
|
162
|
+
numbers: data.numbers ?? [],
|
|
163
|
+
includedDialplans: data.includedDialplans ?? [],
|
|
164
|
+
};
|
|
165
|
+
await client.call('PUT', apiPath`/PBX/Dialplans/${id}/`, body, {scope: 'dialplan'});
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const GS = '/Dialplan/GeneralSettings/';
|
|
170
|
+
const dialplanSettings = {
|
|
171
|
+
group: 'routing',
|
|
172
|
+
label: 'general dialplan settings',
|
|
173
|
+
scope: 'dialplan',
|
|
174
|
+
single: true,
|
|
175
|
+
async read(client) {
|
|
176
|
+
return [{id: 'settings', name: 'settings', data: await client.call('GET', GS, undefined, {scope: 'dialplan'})}];
|
|
177
|
+
},
|
|
178
|
+
async create() {
|
|
179
|
+
throw new Error('The general dialplan settings exist on every PBX: they are only updated');
|
|
180
|
+
},
|
|
181
|
+
async update(client, _id, data) {
|
|
182
|
+
await client.call('PUT', GS, data, {scope: 'dialplan'});
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const TRANSFER_KINDS = {
|
|
187
|
+
'acl-groups': listed({
|
|
188
|
+
group: 'users',
|
|
189
|
+
label: 'ACL group',
|
|
190
|
+
path: '/PBX/AclGroups/',
|
|
191
|
+
scope: 'aclgroups',
|
|
192
|
+
nameField: 'name',
|
|
193
|
+
strip: ['wcgrp'], // a number the PBX gives each group, unique on it (checked live): never carried
|
|
194
|
+
postAnswers: 'object',
|
|
195
|
+
}),
|
|
196
|
+
users,
|
|
197
|
+
'trunks-sip': listed({
|
|
198
|
+
group: 'trunks',
|
|
199
|
+
label: 'SIP trunk',
|
|
200
|
+
path: '/Trunks/Sip/',
|
|
201
|
+
scope: 'trunks',
|
|
202
|
+
nameField: 'title',
|
|
203
|
+
}),
|
|
204
|
+
'trunk-groups': listed({
|
|
205
|
+
group: 'trunks',
|
|
206
|
+
label: 'trunk group',
|
|
207
|
+
path: '/Trunks/Groups/',
|
|
208
|
+
scope: 'trunks',
|
|
209
|
+
nameField: 'name',
|
|
210
|
+
item: (id) => apiPath`/Trunks/Groups/${id}`,
|
|
211
|
+
}),
|
|
212
|
+
'price-lists': priceLists,
|
|
213
|
+
timetables: listed({
|
|
214
|
+
group: 'routing',
|
|
215
|
+
label: 'timetable',
|
|
216
|
+
path: '/Dialplan/timeTables/',
|
|
217
|
+
scope: 'dialplan',
|
|
218
|
+
nameField: 'title',
|
|
219
|
+
}),
|
|
220
|
+
ivr: listed({group: 'routing', label: 'IVR menu', path: '/Dialplan/Ivr/', scope: 'dialplan', nameField: 'title'}),
|
|
221
|
+
switches: listed({
|
|
222
|
+
group: 'routing',
|
|
223
|
+
label: 'switch',
|
|
224
|
+
path: '/Dialplan/Switches/',
|
|
225
|
+
scope: 'dialplan',
|
|
226
|
+
nameField: 'title',
|
|
227
|
+
}),
|
|
228
|
+
paging: listed({
|
|
229
|
+
group: 'routing',
|
|
230
|
+
label: 'paging group',
|
|
231
|
+
path: '/Dialplan/PagingGroups/',
|
|
232
|
+
scope: 'dialplan',
|
|
233
|
+
nameField: 'title',
|
|
234
|
+
}),
|
|
235
|
+
queues: listed({
|
|
236
|
+
group: 'routing',
|
|
237
|
+
label: 'queue',
|
|
238
|
+
path: '/Dialplan/CallGroups/',
|
|
239
|
+
scope: 'callgroups',
|
|
240
|
+
nameField: 'title',
|
|
241
|
+
}),
|
|
242
|
+
'dialplan-db': dialplanDb,
|
|
243
|
+
dialplans,
|
|
244
|
+
'dialplan-settings': dialplanSettings,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// The objects of each kind: {[kind]: [{id, name, data}]}, or {error} for a kind the PBX refuses to give.
|
|
248
|
+
export async function readKinds(client, kinds, {dbFamilies, info = () => {}} = {}) {
|
|
249
|
+
const out = {};
|
|
250
|
+
for (const kind of kinds) {
|
|
251
|
+
try {
|
|
252
|
+
out[kind] = await TRANSFER_KINDS[kind].read(client, {dbFamilies});
|
|
253
|
+
} catch (e) {
|
|
254
|
+
info(`${kind}: not read (${e.message})`);
|
|
255
|
+
out[kind] = {error: e.message};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The log of a config import: wx-admin-import-<host>-<YYYY-MM-DDTHH-MM-SS>.log in the folder asked for (else the
|
|
2
|
+
// current one), mode 0600, never over a file, never into a hidden or startup folder. One line per event, timed;
|
|
3
|
+
// written at once (a run that stops keeps what it logged). The caller never passes a secret.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {UsageError} from 'wldx-core/errors';
|
|
7
|
+
import {refuseUnsafeWrite} from 'wldx-core/files';
|
|
8
|
+
import {clean} from 'wldx-core/format';
|
|
9
|
+
|
|
10
|
+
export function openLog(ctx, {dir, host, now}) {
|
|
11
|
+
const folder = path.resolve(ctx.cwd ?? process.cwd(), dir ?? '.');
|
|
12
|
+
const stamp = new Date(now).toISOString().slice(0, 19).replace(/:/g, '-');
|
|
13
|
+
const name = `wx-admin-import-${String(host).replace(/[^A-Za-z0-9_.-]/g, '_')}-${stamp}.log`;
|
|
14
|
+
const file = path.join(refuseUnsafeWrite(folder, ctx.env), name);
|
|
15
|
+
refuseUnsafeWrite(file, ctx.env);
|
|
16
|
+
let fd;
|
|
17
|
+
try {
|
|
18
|
+
fd = fs.openSync(file, 'wx', 0o600);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e.code === 'EEXIST') throw new UsageError(`${file} exists: nothing was overwritten`);
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
const clock = ctx.clock ?? (() => now);
|
|
24
|
+
return {
|
|
25
|
+
path: file,
|
|
26
|
+
line(text) {
|
|
27
|
+
// clean() keeps colour codes for a terminal; a file gets none
|
|
28
|
+
const plain = clean(String(text)).replace(/\x1b\[[0-9;]*m/g, '');
|
|
29
|
+
for (const l of plain.split('\n')) fs.writeSync(fd, `${new Date(clock()).toISOString()} ${l}\n`);
|
|
30
|
+
},
|
|
31
|
+
close() {
|
|
32
|
+
fs.closeSync(fd);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// What is left to do on the target after a config import (or, with --dry-run, what will be): one step per thing to
|
|
2
|
+
// do, {what, why, objects: [{kind, name, where?}], wms, command?, blocking}; repeated objects folded into one step,
|
|
3
|
+
// steps that keep something transferred from working first. Never a secret: steps are made from names and reasons.
|
|
4
|
+
import {splitKey} from './refs.mjs';
|
|
5
|
+
|
|
6
|
+
// Where things are in WMS. Confirmed in the Wildix admin guide: Trunks, Dialplan → IVR, Dialplan → Call groups,
|
|
7
|
+
// PBX → Integrations. The others are the menus of WMS 7 as known, not checked against its documentation.
|
|
8
|
+
export const WMS = {
|
|
9
|
+
trunks: 'WMS → Trunks',
|
|
10
|
+
users: 'WMS → Users',
|
|
11
|
+
licenses: 'WMS → Settings → PBX → License (unchecked)',
|
|
12
|
+
acl: 'WMS → Users → Permissions (unchecked)',
|
|
13
|
+
sounds: 'WMS → Dialplan → Sounds (unchecked)',
|
|
14
|
+
moh: 'WMS → Dialplan → Sounds, the music on hold folders (unchecked)',
|
|
15
|
+
phonebooks: 'WMS → Users → Phonebooks (unchecked)',
|
|
16
|
+
queues: 'WMS → Dialplan → Call groups',
|
|
17
|
+
dialplans: 'WMS → Dialplan → Dialplan rules (unchecked)',
|
|
18
|
+
devices: 'WMS → Devices',
|
|
19
|
+
settings: 'WMS → Settings → PBX (unchecked)',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const MAX = 5;
|
|
23
|
+
const fold = (names) =>
|
|
24
|
+
names.length <= MAX ? names.join(', ') : `${names.slice(0, MAX).join(', ')} … and ${names.length - MAX} more`;
|
|
25
|
+
const objectOf = (key, where) => ({...splitKey(key), ...(where ? {where} : {})});
|
|
26
|
+
|
|
27
|
+
// One argument for a POSIX shell: as it is when it is plain, else in single quotes. Names and values come from the
|
|
28
|
+
// PBX or the folder, so every one that goes into a command is quoted.
|
|
29
|
+
export const shellArg = (s) =>
|
|
30
|
+
/^[A-Za-z0-9_@%+=:,./-]+$/.test(String(s)) ? String(s) : `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
31
|
+
// The value of --only for these keys: kind:name, with \ and , in names escaped (parseOnly reads them back).
|
|
32
|
+
export const onlyArg = (keys) =>
|
|
33
|
+
shellArg(
|
|
34
|
+
keys
|
|
35
|
+
.map((k) => {
|
|
36
|
+
const {kind, name} = splitKey(k);
|
|
37
|
+
return `${kind}:${name.replace(/\\/g, '\\\\').replace(/,/g, '\\,')}`;
|
|
38
|
+
})
|
|
39
|
+
.join(','),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export function nextSteps({plan, results, host, rerun}) {
|
|
43
|
+
const cmd = (rest) => `wx-admin --pbx ${shellArg(host)} ${rest}`;
|
|
44
|
+
const steps = [];
|
|
45
|
+
const resultOf = new Map(results.map((r) => [r.key, r]));
|
|
46
|
+
const done = (kind, actions = ['create', 'overwrite']) =>
|
|
47
|
+
results.filter((r) => r.key.startsWith(`${kind}/`) && actions.includes(r.action)).map((r) => r.key);
|
|
48
|
+
|
|
49
|
+
// What was not written, by what caused it: a password not given, an object that failed, a reference missing on
|
|
50
|
+
// the target. Each group is finished by the same import for these objects only, overwriting (an ACL group or
|
|
51
|
+
// dialplan made bare in the first pass is on the target, empty, until then).
|
|
52
|
+
const byKey = new Map(results.map((r) => [r.key, r]));
|
|
53
|
+
const rootOf = (r) => {
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
let x = r;
|
|
56
|
+
while (x.action === 'skip' && byKey.has(x.because) && !seen.has(x.key)) {
|
|
57
|
+
seen.add(x.key);
|
|
58
|
+
x = byKey.get(x.because);
|
|
59
|
+
}
|
|
60
|
+
return x;
|
|
61
|
+
};
|
|
62
|
+
const left = results.filter((r) => r.action === 'skip' || r.action === 'failed');
|
|
63
|
+
const groupOf = (r) => {
|
|
64
|
+
const root = rootOf(r);
|
|
65
|
+
if (root.action === 'failed') return 'failed';
|
|
66
|
+
return /^no password/.test(root.because ?? '') ? 'password' : 'missing';
|
|
67
|
+
};
|
|
68
|
+
const finish = (list) => `${rerun} --only ${onlyArg(list.map((r) => r.key))} --overwrite`;
|
|
69
|
+
const empty = (list) => {
|
|
70
|
+
const keys = list.filter((r) => r.bare).map((r) => r.key);
|
|
71
|
+
return keys.length ? `; ${fold(keys)} ${keys.length === 1 ? 'is' : 'are'} on ${host} empty until then` : '';
|
|
72
|
+
};
|
|
73
|
+
const pw = left.filter((r) => groupOf(r) === 'password');
|
|
74
|
+
if (pw.length) {
|
|
75
|
+
const trunks = pw.filter((r) => /^no password/.test(r.because ?? '')).map((r) => splitKey(r.key).name);
|
|
76
|
+
const also = pw.filter((r) => !/^no password/.test(r.because ?? ''));
|
|
77
|
+
steps.push({
|
|
78
|
+
what: `${trunks.length === 1 ? `Set the password of the trunk ${trunks[0]}` : `Set the password of ${trunks.length} trunks: ${fold(trunks)}`}${also.length ? `, then write what uses ${trunks.length === 1 ? 'it' : 'them'}: ${fold(also.map((r) => r.key))}` : ''}${empty(pw)}`,
|
|
79
|
+
why: 'Passwords are never exported; none was given for these, so they were not written (nor what uses them). The command asks for them in a terminal; else add --secrets FILE.',
|
|
80
|
+
objects: pw.map((r) => objectOf(r.key)),
|
|
81
|
+
wms: WMS.trunks,
|
|
82
|
+
command: finish(pw),
|
|
83
|
+
blocking: true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const failed = left.filter((r) => groupOf(r) === 'failed');
|
|
87
|
+
if (failed.length) {
|
|
88
|
+
const roots = failed.filter((r) => r.action === 'failed');
|
|
89
|
+
const also = failed.filter((r) => r.action !== 'failed');
|
|
90
|
+
steps.push({
|
|
91
|
+
what: `${roots.length} object${roots.length === 1 ? '' : 's'} failed: ${fold(roots.map((r) => r.key))}${also.length ? `; skipped with them: ${fold(also.map((r) => r.key))}` : ''}${empty(failed)}`,
|
|
92
|
+
why: roots.map((r) => `${r.key}: ${r.error}`).join('; '),
|
|
93
|
+
objects: failed.map((r) => objectOf(r.key)),
|
|
94
|
+
wms: '',
|
|
95
|
+
command: finish(failed),
|
|
96
|
+
blocking: true,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const missing = left.filter((r) => groupOf(r) === 'missing');
|
|
100
|
+
if (missing.length) {
|
|
101
|
+
steps.push({
|
|
102
|
+
what: `${missing.length} object${missing.length === 1 ? ' was' : 's were'} skipped: ${fold(missing.map((r) => r.key))}${empty(missing)}`,
|
|
103
|
+
why: missing.map((r) => `${r.key}: needs ${rootOf(r).because}`).join('; '),
|
|
104
|
+
objects: missing.map((r) => objectOf(r.key)),
|
|
105
|
+
wms: '',
|
|
106
|
+
command: finish(missing),
|
|
107
|
+
blocking: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Users that could not be made: licenses, taken extensions.
|
|
112
|
+
const users = plan.items.filter(
|
|
113
|
+
(i) => i.kind === 'users' && i.state === 'blocked' && /license|extension/.test(i.reason ?? ''),
|
|
114
|
+
);
|
|
115
|
+
if (users.length) {
|
|
116
|
+
steps.push({
|
|
117
|
+
what: `Add licenses or free the extensions, then run the same import again: users ${fold(users.map((i) => i.name))}`,
|
|
118
|
+
why: users.map((i) => `${i.name}: ${i.reason}`).join('; '),
|
|
119
|
+
objects: users.map((i) => objectOf(i.key)),
|
|
120
|
+
wms: WMS.licenses,
|
|
121
|
+
command: rerun,
|
|
122
|
+
blocking: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Other objects the plan could not write.
|
|
127
|
+
const other = plan.items.filter((i) => i.state === 'blocked' && !users.includes(i));
|
|
128
|
+
if (other.length) {
|
|
129
|
+
steps.push({
|
|
130
|
+
what: `Not written: ${fold(other.map((i) => i.key))}`,
|
|
131
|
+
why: other.map((i) => `${i.key}: ${i.reason}`).join('; '),
|
|
132
|
+
objects: other.map((i) => objectOf(i.key)),
|
|
133
|
+
wms: '',
|
|
134
|
+
command: rerun,
|
|
135
|
+
blocking: true,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Warnings of the plan, grouped by what they are about.
|
|
140
|
+
const groups = new Map();
|
|
141
|
+
for (const item of plan.items) {
|
|
142
|
+
if (item.state === 'blocked' || resultOf.get(item.key)?.action === 'failed') continue;
|
|
143
|
+
for (const w of item.warnings ?? []) {
|
|
144
|
+
const id = `${w.type}:${w.value}`;
|
|
145
|
+
if (!groups.has(id)) groups.set(id, {w, objects: []});
|
|
146
|
+
groups.get(id).objects.push(objectOf(item.key, w.path));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const {w, objects} of groups.values()) {
|
|
150
|
+
const users = fold(objects.map((o) => `${o.kind}/${o.name}`));
|
|
151
|
+
const step = {
|
|
152
|
+
sound: {
|
|
153
|
+
what: `Upload the sound ${w.value} used by ${users}`,
|
|
154
|
+
why: `Sounds are not transferred and ${host} has no ${w.value}.`,
|
|
155
|
+
wms: WMS.sounds,
|
|
156
|
+
command: cmd(`sound upload <file> --name ${shellArg(w.value)}`),
|
|
157
|
+
blocking: true,
|
|
158
|
+
},
|
|
159
|
+
moh: {
|
|
160
|
+
what: `Create the music on hold class ${w.value} used by ${users}`,
|
|
161
|
+
why: `Music on hold is not transferred and ${host} has no class ${w.value}.`,
|
|
162
|
+
wms: WMS.moh,
|
|
163
|
+
command: cmd('sound list'),
|
|
164
|
+
blocking: true,
|
|
165
|
+
},
|
|
166
|
+
phonebook: {
|
|
167
|
+
what: `Create the phonebook ${w.value} used by ${users}, then point the rule at it`,
|
|
168
|
+
why: `Phonebooks are not transferred and ${host} has no phonebook ${w.value}.`,
|
|
169
|
+
wms: WMS.phonebooks,
|
|
170
|
+
command: cmd('phonebook create <name>'),
|
|
171
|
+
blocking: true,
|
|
172
|
+
},
|
|
173
|
+
'not-checked': {
|
|
174
|
+
what: `Check that ${w.value} (used by ${users}) is on ${host}`,
|
|
175
|
+
why: 'It could not be checked with this access.',
|
|
176
|
+
wms: WMS.sounds,
|
|
177
|
+
command: cmd('sound list'),
|
|
178
|
+
blocking: false,
|
|
179
|
+
},
|
|
180
|
+
member: {
|
|
181
|
+
what: `Create the user ${w.value} or take it out of ${users}`,
|
|
182
|
+
why: `${w.value} is a member there but not a user on ${host}.`,
|
|
183
|
+
wms: WMS.queues,
|
|
184
|
+
command: cmd(`queue update ${shellArg(objects[0].name)} --member <ext>... --yes`),
|
|
185
|
+
blocking: false,
|
|
186
|
+
},
|
|
187
|
+
external: {
|
|
188
|
+
what: `Check that the ${w.value} of ${users} works on ${host}`,
|
|
189
|
+
why: 'It is outside the PBX (an object of the company in the cloud, or another PBX) and was kept as it is.',
|
|
190
|
+
wms: WMS.dialplans,
|
|
191
|
+
blocking: false,
|
|
192
|
+
},
|
|
193
|
+
unresolved: {
|
|
194
|
+
what: `Check by hand: ${w.value} in ${users}${objects[0].where ? ` (${objects[0].where})` : ''}`,
|
|
195
|
+
why: 'It pointed at nothing on the source PBX (a stale id, or a value that is not a reference) and was kept as it is.',
|
|
196
|
+
wms: WMS.dialplans,
|
|
197
|
+
blocking: false,
|
|
198
|
+
},
|
|
199
|
+
}[w.type];
|
|
200
|
+
if (step) steps.push({objects, ...step});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Checks after what was written.
|
|
204
|
+
const trunks = done('trunks-sip', ['create']);
|
|
205
|
+
if (trunks.length) {
|
|
206
|
+
const names = trunks.map((k) => splitKey(k).name);
|
|
207
|
+
steps.push({
|
|
208
|
+
what:
|
|
209
|
+
names.length === 1
|
|
210
|
+
? `Check that the trunk ${names[0]} registers`
|
|
211
|
+
: `Check that ${names.length} trunks register: ${fold(names)}`,
|
|
212
|
+
why: 'A trunk works only once its carrier accepts the registration from this PBX.',
|
|
213
|
+
objects: trunks.map((k) => objectOf(k)),
|
|
214
|
+
wms: WMS.trunks,
|
|
215
|
+
command: cmd('trunk list'),
|
|
216
|
+
blocking: false,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const madeUsers = done('users', ['create']);
|
|
220
|
+
if (madeUsers.length) {
|
|
221
|
+
steps.push({
|
|
222
|
+
what: `Tell the ${madeUsers.length} new user${madeUsers.length === 1 ? '' : 's'} how to log in: ${fold(madeUsers.map((k) => splitKey(k).name))}`,
|
|
223
|
+
why: `The PBX gave them passwords of its own${plan.welcomeMails ? `; ${plan.welcomeMails} of them got the welcome e-mail of the PBX` : ''}.`,
|
|
224
|
+
objects: madeUsers.map((k) => objectOf(k)),
|
|
225
|
+
wms: WMS.users,
|
|
226
|
+
command: cmd('user list'),
|
|
227
|
+
blocking: false,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const acl = done('acl-groups');
|
|
231
|
+
if (acl.length) {
|
|
232
|
+
steps.push({
|
|
233
|
+
what: `Check who may administer ${host}: ACL groups were written (${fold(acl.map((k) => splitKey(k).name))})`,
|
|
234
|
+
why: 'Their admin rules now point at this PBX.',
|
|
235
|
+
objects: acl.map((k) => objectOf(k)),
|
|
236
|
+
wms: WMS.acl,
|
|
237
|
+
command: cmd('acl groups'),
|
|
238
|
+
blocking: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
steps.push({
|
|
242
|
+
what: 'Set up by hand what a transfer never carries: devices, PSTN and FXO trunks, system settings (SMTP, NTP, features, trusted IPs)',
|
|
243
|
+
why: 'They are bound to the hardware or to the PBX itself; compare them with the source PBX.',
|
|
244
|
+
objects: [],
|
|
245
|
+
wms: `${WMS.devices}; ${WMS.settings}`,
|
|
246
|
+
command: `${cmd('device list')}; ${cmd('system smtp')} (and the same with --pbx of the source)`,
|
|
247
|
+
blocking: false,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return [...steps.filter((s) => s.blocking), ...steps.filter((s) => !s.blocking)];
|
|
251
|
+
}
|