Compare commits
No commits in common. "1705c88ba2450ea98c5c93c3b080226bfc63beac" and "07152a467bb7278c602dfcd84a803234ad53fbfe" have entirely different histories.
1705c88ba2
...
07152a467b
3 changed files with 0 additions and 452 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -30,7 +30,6 @@ scripts/*
|
||||||
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
|
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
|
||||||
!scripts/reclaimWebexHosts.js
|
!scripts/reclaimWebexHosts.js
|
||||||
!scripts/removeAdvancedMessaging.js
|
!scripts/removeAdvancedMessaging.js
|
||||||
!scripts/findEmptyLocations.js
|
|
||||||
!scripts/lib/
|
!scripts/lib/
|
||||||
!scripts/lib/**
|
!scripts/lib/**
|
||||||
characterize-*.js
|
characterize-*.js
|
||||||
|
|
@ -84,4 +83,3 @@ config/config.bak
|
||||||
# These contain emails, personIds, and per-user outcome — PII, never commit.
|
# These contain emails, personIds, and per-user outcome — PII, never commit.
|
||||||
reclaim-*.csv
|
reclaim-*.csv
|
||||||
remove-*.csv
|
remove-*.csv
|
||||||
empty-locations*.csv
|
|
||||||
|
|
|
||||||
|
|
@ -1,407 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Find Webex Calling locations with no user-owned phone numbers —
|
|
||||||
* candidates for closed-store cleanup.
|
|
||||||
*
|
|
||||||
* Approach: two paginated pulls, one diff.
|
|
||||||
* 1. GET /v1/telephony/config/locations → every location
|
|
||||||
* 2. GET /v1/telephony/config/numbers → every provisioned
|
|
||||||
* number with owner + location info
|
|
||||||
* Bucket numbers by (location.id, owner.type). A location with zero
|
|
||||||
* PEOPLE-owned numbers is the "empty" verdict the user asked for.
|
|
||||||
*
|
|
||||||
* Each empty location is further categorized:
|
|
||||||
* - `safe-to-delete` → zero PEOPLE AND zero of everything else
|
|
||||||
* (no workspaces, no auto-attendants, no
|
|
||||||
* hunt groups, no call queues, no virtual
|
|
||||||
* lines, no paging groups, no unassigned
|
|
||||||
* numbers). The location shell is a ghost.
|
|
||||||
* - `needs-cleanup-first` → zero PEOPLE but other resources still
|
|
||||||
* exist. These are the "someone drew a
|
|
||||||
* line under the users but forgot the
|
|
||||||
* phones" ghosts. Break-fix through
|
|
||||||
* Control Hub before delete.
|
|
||||||
* - `has-users` → at least one PEOPLE-owned number, still
|
|
||||||
* in active use. Excluded from the report.
|
|
||||||
*
|
|
||||||
* Output:
|
|
||||||
* Console table summarising counts, plus a per-location inventory
|
|
||||||
* for the empty candidates (top N in console, full set in --report
|
|
||||||
* CSV).
|
|
||||||
*
|
|
||||||
* --execute mode (MUST be paired with --i-am-sure) deletes locations
|
|
||||||
* verdicted `safe-to-delete`, one at a time, via
|
|
||||||
* `DELETE /v1/telephony/config/locations/{id}`. This removes Webex
|
|
||||||
* Calling from the location; the underlying org-wide location record
|
|
||||||
* (billing/address) may still need to be removed in Control Hub
|
|
||||||
* depending on your subscription. Anything not `safe-to-delete` is
|
|
||||||
* never touched by --execute.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/findEmptyLocations.js \
|
|
||||||
* [--include-has-users] # include has-users rows in CSV too
|
|
||||||
* [--report empty-locations.csv]
|
|
||||||
* [--limit N] # cap for --execute (e.g. pilot 5)
|
|
||||||
* [--offset N]
|
|
||||||
* [--execute --i-am-sure] # delete safe-to-delete locations
|
|
||||||
*
|
|
||||||
* Required Webex service-app scopes:
|
|
||||||
* spark-admin:telephony_config_read
|
|
||||||
* spark-admin:telephony_config_write (only if using --execute)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'dotenv/config';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { logger } from '../utils/logger.js';
|
|
||||||
import webex from '../integrations/webex/WebexClient.js';
|
|
||||||
import {
|
|
||||||
fetchAllPaginated,
|
|
||||||
runPool,
|
|
||||||
callWithRetry,
|
|
||||||
explainWebexError,
|
|
||||||
} from './lib/webexBulk.js';
|
|
||||||
|
|
||||||
// Owner types the Webex Calling numbers endpoint returns. Categorized
|
|
||||||
// here so a stray future owner type doesn't get silently dropped.
|
|
||||||
const OWNER_BUCKET = {
|
|
||||||
PEOPLE: 'people',
|
|
||||||
PLACE: 'workspaces',
|
|
||||||
AUTO_ATTENDANT: 'autoAttendants',
|
|
||||||
HUNT_GROUP: 'huntGroups',
|
|
||||||
CALL_QUEUE: 'callQueues',
|
|
||||||
VIRTUAL_LINE: 'virtualLines',
|
|
||||||
PAGING_GROUP: 'pagingGroups',
|
|
||||||
VOICEMAIL_GROUP: 'voicemailGroups',
|
|
||||||
GROUP_PAGING: 'pagingGroups',
|
|
||||||
RECEPTIONIST_CLIENT: 'receptionist',
|
|
||||||
};
|
|
||||||
|
|
||||||
function emptyBucket() {
|
|
||||||
return {
|
|
||||||
people: 0,
|
|
||||||
workspaces: 0,
|
|
||||||
autoAttendants: 0,
|
|
||||||
huntGroups: 0,
|
|
||||||
callQueues: 0,
|
|
||||||
virtualLines: 0,
|
|
||||||
pagingGroups: 0,
|
|
||||||
voicemailGroups: 0,
|
|
||||||
receptionist: 0,
|
|
||||||
unassigned: 0,
|
|
||||||
other: 0,
|
|
||||||
totalNumbers: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bucketTotals(b) {
|
|
||||||
return b.people + b.workspaces + b.autoAttendants + b.huntGroups
|
|
||||||
+ b.callQueues + b.virtualLines + b.pagingGroups + b.voicemailGroups
|
|
||||||
+ b.receptionist + b.unassigned + b.other;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// CLI parsing
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
|
||||||
const out = {
|
|
||||||
report: null,
|
|
||||||
execute: false,
|
|
||||||
iAmSure: false,
|
|
||||||
includeHasUsers: false,
|
|
||||||
limit: null,
|
|
||||||
offset: 0,
|
|
||||||
help: false,
|
|
||||||
};
|
|
||||||
for (let i = 0; i < argv.length; i++) {
|
|
||||||
const a = argv[i];
|
|
||||||
const next = () => argv[++i];
|
|
||||||
switch (a) {
|
|
||||||
case '--report': out.report = next(); break;
|
|
||||||
case '--execute': out.execute = true; break;
|
|
||||||
case '--i-am-sure': out.iAmSure = true; break;
|
|
||||||
case '--include-has-users': out.includeHasUsers = true; break;
|
|
||||||
case '--limit': out.limit = Number(next()); break;
|
|
||||||
case '--offset': out.offset = Number(next()); break;
|
|
||||||
case '-h': case '--help': out.help = true; break;
|
|
||||||
default:
|
|
||||||
if (a.startsWith('--')) {
|
|
||||||
console.error(`Unknown flag: ${a}`);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function printHelp() {
|
|
||||||
const src = fs.readFileSync(new URL(import.meta.url), 'utf8');
|
|
||||||
const m = src.match(/\/\*\*([\s\S]*?)\*\//);
|
|
||||||
if (m) console.log(m[1].replace(/^\s*\*\s?/gm, ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// Main
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = parseArgs(process.argv.slice(2));
|
|
||||||
if (args.help) { printHelp(); process.exit(0); }
|
|
||||||
|
|
||||||
console.log('📥 Fetching all Webex Calling locations…');
|
|
||||||
let locations;
|
|
||||||
try {
|
|
||||||
locations = await fetchAllPaginated('telephony/config/locations', {
|
|
||||||
arrayKey: 'locations',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`❌ Failed to list locations: ${explainWebexError(err)}`);
|
|
||||||
console.error(
|
|
||||||
` The service app needs \`spark-admin:telephony_config_read\` scope,\n` +
|
|
||||||
` authorized as a Full / User admin on the org.`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(` → ${locations.length} locations`);
|
|
||||||
|
|
||||||
console.log('📥 Fetching all provisioned phone numbers…');
|
|
||||||
let numbers;
|
|
||||||
try {
|
|
||||||
numbers = await fetchAllPaginated('telephony/config/numbers', {
|
|
||||||
arrayKey: 'phoneNumbers',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`❌ Failed to list numbers: ${explainWebexError(err)}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(` → ${numbers.length} numbers`);
|
|
||||||
|
|
||||||
// Bucket by location + owner type. Numbers without an owner are
|
|
||||||
// counted as `unassigned` — a location with only unassigned numbers
|
|
||||||
// is still empty from a "people using it" standpoint, but the
|
|
||||||
// presence of provisioned DIDs means cleanup work remains before
|
|
||||||
// delete.
|
|
||||||
const byLocation = new Map();
|
|
||||||
for (const l of locations) byLocation.set(l.id, emptyBucket());
|
|
||||||
|
|
||||||
const unknownOwnerTypes = new Set();
|
|
||||||
for (const n of numbers) {
|
|
||||||
const locId = n.location?.id;
|
|
||||||
if (!locId) continue;
|
|
||||||
let b = byLocation.get(locId);
|
|
||||||
if (!b) {
|
|
||||||
// Number references a location the locations endpoint didn't
|
|
||||||
// return. Odd but possible during org drift. Track it so the
|
|
||||||
// location shows up in the report.
|
|
||||||
b = emptyBucket();
|
|
||||||
byLocation.set(locId, b);
|
|
||||||
locations.push({
|
|
||||||
id: locId,
|
|
||||||
name: n.location?.name || '(unknown location)',
|
|
||||||
_synthetic: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
b.totalNumbers++;
|
|
||||||
const ownerType = n.owner?.type;
|
|
||||||
if (!ownerType) {
|
|
||||||
b.unassigned++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = OWNER_BUCKET[ownerType];
|
|
||||||
if (key) b[key]++;
|
|
||||||
else {
|
|
||||||
b.other++;
|
|
||||||
unknownOwnerTypes.add(ownerType);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (unknownOwnerTypes.size > 0) {
|
|
||||||
console.log(
|
|
||||||
` ℹ️ Saw owner types not in the known set (counted as \`other\`): ` +
|
|
||||||
`${[...unknownOwnerTypes].join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verdict per location.
|
|
||||||
const verdicts = locations.map((l) => {
|
|
||||||
const b = byLocation.get(l.id) || emptyBucket();
|
|
||||||
const nonPeople = bucketTotals(b) - b.people;
|
|
||||||
let verdict;
|
|
||||||
if (b.people > 0) verdict = 'has-users';
|
|
||||||
else if (nonPeople === 0) verdict = 'safe-to-delete';
|
|
||||||
else verdict = 'needs-cleanup-first';
|
|
||||||
return { location: l, buckets: b, verdict };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Summary counts.
|
|
||||||
const summary = { hasUsers: 0, needsCleanup: 0, safeToDelete: 0 };
|
|
||||||
for (const v of verdicts) {
|
|
||||||
if (v.verdict === 'has-users') summary.hasUsers++;
|
|
||||||
else if (v.verdict === 'safe-to-delete') summary.safeToDelete++;
|
|
||||||
else summary.needsCleanup++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n📊 ${locations.length} total locations:`);
|
|
||||||
console.log(` ${summary.hasUsers.toString().padStart(4)} have users (excluded from report)`);
|
|
||||||
console.log(` ${summary.needsCleanup.toString().padStart(4)} zero users but other resources remain (workspace/AA/HG/etc)`);
|
|
||||||
console.log(` ${summary.safeToDelete.toString().padStart(4)} truly empty (safe to delete)`);
|
|
||||||
|
|
||||||
const empty = verdicts.filter((v) => v.verdict !== 'has-users');
|
|
||||||
|
|
||||||
if (empty.length > 0) {
|
|
||||||
console.log(`\n📋 Empty / needs-cleanup locations (showing up to 20):`);
|
|
||||||
console.log(
|
|
||||||
' ' +
|
|
||||||
'verdict people work aa hg cq vl pg other unassigned name',
|
|
||||||
);
|
|
||||||
for (const v of empty.slice(0, 20)) {
|
|
||||||
const b = v.buckets;
|
|
||||||
const row = [
|
|
||||||
v.verdict.padEnd(18),
|
|
||||||
String(b.people).padStart(6),
|
|
||||||
String(b.workspaces).padStart(5),
|
|
||||||
String(b.autoAttendants).padStart(3),
|
|
||||||
String(b.huntGroups).padStart(3),
|
|
||||||
String(b.callQueues).padStart(3),
|
|
||||||
String(b.virtualLines).padStart(3),
|
|
||||||
String(b.pagingGroups).padStart(3),
|
|
||||||
String(b.other).padStart(6),
|
|
||||||
String(b.unassigned).padStart(11),
|
|
||||||
v.location.name || '(unnamed)',
|
|
||||||
].join(' ');
|
|
||||||
console.log(` ${row}`);
|
|
||||||
}
|
|
||||||
if (empty.length > 20) {
|
|
||||||
console.log(` … and ${empty.length - 20} more (see --report CSV)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report CSV.
|
|
||||||
if (args.report) {
|
|
||||||
const reportPath = path.resolve(args.report);
|
|
||||||
const cols = [
|
|
||||||
'verdict', 'location_id', 'name',
|
|
||||||
'people', 'workspaces', 'auto_attendants', 'hunt_groups',
|
|
||||||
'call_queues', 'virtual_lines', 'paging_groups', 'voicemail_groups',
|
|
||||||
'receptionist', 'unassigned', 'other', 'total_numbers',
|
|
||||||
'address_city', 'address_state', 'address_country', 'timezone',
|
|
||||||
'time_zone_offset',
|
|
||||||
];
|
|
||||||
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
|
||||||
const lines = [cols.join(',')];
|
|
||||||
const included = args.includeHasUsers ? verdicts : empty;
|
|
||||||
for (const v of included) {
|
|
||||||
const b = v.buckets;
|
|
||||||
const l = v.location;
|
|
||||||
const addr = l.address || {};
|
|
||||||
lines.push(cols.map((c) => {
|
|
||||||
switch (c) {
|
|
||||||
case 'verdict': return escape(v.verdict);
|
|
||||||
case 'location_id': return escape(l.id);
|
|
||||||
case 'name': return escape(l.name);
|
|
||||||
case 'people': return b.people;
|
|
||||||
case 'workspaces': return b.workspaces;
|
|
||||||
case 'auto_attendants': return b.autoAttendants;
|
|
||||||
case 'hunt_groups': return b.huntGroups;
|
|
||||||
case 'call_queues': return b.callQueues;
|
|
||||||
case 'virtual_lines': return b.virtualLines;
|
|
||||||
case 'paging_groups': return b.pagingGroups;
|
|
||||||
case 'voicemail_groups': return b.voicemailGroups;
|
|
||||||
case 'receptionist': return b.receptionist;
|
|
||||||
case 'unassigned': return b.unassigned;
|
|
||||||
case 'other': return b.other;
|
|
||||||
case 'total_numbers': return b.totalNumbers;
|
|
||||||
case 'address_city': return escape(addr.city);
|
|
||||||
case 'address_state': return escape(addr.state);
|
|
||||||
case 'address_country': return escape(l.address?.country || l.countryCode);
|
|
||||||
case 'timezone': return escape(l.timeZone);
|
|
||||||
case 'time_zone_offset': return escape(l.timezoneOffset);
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
}).join(','));
|
|
||||||
}
|
|
||||||
fs.writeFileSync(reportPath, lines.join('\n') + '\n', 'utf8');
|
|
||||||
console.log(`\n📝 Report written to ${reportPath} (${lines.length - 1} row${lines.length === 2 ? '' : 's'})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete path (guarded).
|
|
||||||
if (!args.execute) {
|
|
||||||
if (summary.safeToDelete > 0) {
|
|
||||||
console.log(
|
|
||||||
`\n🚦 DRY-RUN. To delete the ${summary.safeToDelete} safe-to-delete\n` +
|
|
||||||
` location(s), re-run with: --execute --i-am-sure\n` +
|
|
||||||
` Only locations with zero of everything will be deleted.\n` +
|
|
||||||
` Use --limit N to pilot on a subset.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.iAmSure) {
|
|
||||||
console.error(
|
|
||||||
`\n❌ --execute requires --i-am-sure. Deleting Webex Calling from a\n` +
|
|
||||||
` location is irreversible via API — you'd rebuild the location\n` +
|
|
||||||
` from scratch in Control Hub if this is a mistake.\n\n` +
|
|
||||||
` Add \`--i-am-sure\` (and consider \`--limit N\` for a pilot) to proceed.`,
|
|
||||||
);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = verdicts
|
|
||||||
.filter((v) => v.verdict === 'safe-to-delete')
|
|
||||||
.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
|
|
||||||
|
|
||||||
if (targets.length === 0) {
|
|
||||||
console.log(`\n✅ Nothing to delete (0 safe-to-delete locations after --offset/--limit).`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\n🚀 DELETING ${targets.length} location(s), serial (concurrency=1). Ctrl-C to abort.\n`,
|
|
||||||
);
|
|
||||||
logger(
|
|
||||||
'webex:emptyloc:audit',
|
|
||||||
`START delete: count=${targets.length}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Serial (concurrency=1) — destructive ops shouldn't parallelize.
|
|
||||||
// Each 429/503 gets a Retry-After-aware retry via callWithRetry.
|
|
||||||
let ok = 0;
|
|
||||||
let failed = 0;
|
|
||||||
const outcomes = await runPool(targets, 1, async (v) => {
|
|
||||||
const l = v.location;
|
|
||||||
console.log(` • DELETE ${l.name} (${l.id}) …`);
|
|
||||||
await callWithRetry(() =>
|
|
||||||
webex.request('DELETE', `telephony/config/locations/${l.id}`),
|
|
||||||
);
|
|
||||||
console.log(` ✅ removed`);
|
|
||||||
return { deleted: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < outcomes.length; i++) {
|
|
||||||
const t = targets[i];
|
|
||||||
const r = outcomes[i];
|
|
||||||
if (r.ok) {
|
|
||||||
ok++;
|
|
||||||
logger('webex:emptyloc:audit', `OK delete ${t.location.id} name="${t.location.name}"`);
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
const msg = explainWebexError(r.error);
|
|
||||||
console.log(` ❌ ${t.location.name}: ${msg}`);
|
|
||||||
logger(
|
|
||||||
'webex:emptyloc:audit',
|
|
||||||
`FAIL delete ${t.location.id} name="${t.location.name}": ${msg}`,
|
|
||||||
'error',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\n✅ Done. ${ok} deleted, ${failed} failed.`);
|
|
||||||
logger('webex:emptyloc:audit', `END delete: ok=${ok} failed=${failed}`);
|
|
||||||
process.exit(failed === 0 ? 0 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -157,49 +157,6 @@ export function seatsFree(l) {
|
||||||
return Math.max(0, total - used);
|
return Math.max(0, total - used);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic paginated fetch that follows the Webex `Link: <…>; rel="next"`
|
|
||||||
// header cursor. Accumulates every item in the response's `arrayKey` field
|
|
||||||
// (defaults to 'items' — the shape most Webex list endpoints use). Some
|
|
||||||
// endpoints use a different key (e.g. /telephony/config/numbers returns
|
|
||||||
// `phoneNumbers`); pass `arrayKey` in that case.
|
|
||||||
//
|
|
||||||
// Uses WebexClient.requestRaw() directly so we can read headers. First
|
|
||||||
// call is relative (`endpoint`); subsequent calls follow the absolute
|
|
||||||
// URLs from the Link header, which carry the cursor query string.
|
|
||||||
export async function fetchAllPaginated(endpoint, {
|
|
||||||
params = null,
|
|
||||||
arrayKey = 'items',
|
|
||||||
pageSize = 1000,
|
|
||||||
} = {}) {
|
|
||||||
const firstParams = { max: pageSize, ...(params || {}) };
|
|
||||||
let { data, headers } = await webex.requestRaw('GET', endpoint, null, firstParams);
|
|
||||||
const all = [];
|
|
||||||
const pickArray = (d) => {
|
|
||||||
const arr = d?.[arrayKey];
|
|
||||||
return Array.isArray(arr) ? arr : [];
|
|
||||||
};
|
|
||||||
all.push(...pickArray(data));
|
|
||||||
|
|
||||||
let nextUrl = parseLinkNext(headers?.link || headers?.Link);
|
|
||||||
while (nextUrl) {
|
|
||||||
({ data, headers } = await webex.requestRaw('GET', nextUrl));
|
|
||||||
all.push(...pickArray(data));
|
|
||||||
nextUrl = parseLinkNext(headers?.link || headers?.Link);
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLinkNext(linkHeader) {
|
|
||||||
if (!linkHeader || typeof linkHeader !== 'string') return null;
|
|
||||||
// Tolerate multiple entries (comma-separated); grab the first `rel="next"`.
|
|
||||||
const entries = linkHeader.split(/,\s*(?=<)/);
|
|
||||||
for (const e of entries) {
|
|
||||||
const m = e.match(/^\s*<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
|
|
||||||
if (m) return m[1];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function explainWebexError(err) {
|
export function explainWebexError(err) {
|
||||||
const status = err?.response?.status;
|
const status = err?.response?.status;
|
||||||
const apiMsg =
|
const apiMsg =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue