feat: Veeam RPO analysis, comparison, ticket analysis + company teams table
- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison) - Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis - Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison) - Add veeam-analysis-state.ts and rmm-device-resolver.ts services - Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis - Add backup-status page updates and nav links for new Veeam pages - Add scripts: deactivate-cis-for-inactive-companies, workstation category updates - Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt - Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
This commit is contained in:
parent
07067bef19
commit
ea3471d38d
36 changed files with 5604 additions and 217 deletions
253
scripts/deactivate-cis-for-inactive-companies.ts
Normal file
253
scripts/deactivate-cis-for-inactive-companies.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* deactivate-cis-for-inactive-companies.ts
|
||||
*
|
||||
* Finds all active Configuration Items whose parent company is inactive in
|
||||
* Autotask and deactivates them (sets isActive = false).
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/deactivate-cis-for-inactive-companies.ts [options]
|
||||
*
|
||||
* Options:
|
||||
* --dry-run Preview changes without writing (default)
|
||||
* --commit Apply changes
|
||||
* --concurrency <n> Parallel PATCH calls (default: 5)
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../.env') });
|
||||
|
||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_BASE = process.env.AUTOTASK_API_URL!;
|
||||
const USERNAME = process.env.AUTOTASK_USERNAME!;
|
||||
const SECRET = process.env.AUTOTASK_SECRET!;
|
||||
const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Username': USERNAME,
|
||||
'Secret': SECRET,
|
||||
'APIIntegrationcode': INT_CODE,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
/** Paginate through all results of a POST /query endpoint */
|
||||
async function queryAll<T>(entity: string, filter: object[]): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let nextUrl: string | null = null;
|
||||
const requestBody = JSON.stringify({ MaxRecords: 500, filter });
|
||||
|
||||
while (true) {
|
||||
const url = nextUrl ?? `${API_BASE}/${entity}/query`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: requestBody,
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST ${entity}/query → ${res.status}: ${await res.text()}`);
|
||||
const data: any = await res.json();
|
||||
all.push(...(data.items ?? []));
|
||||
nextUrl = data.pageDetails?.nextPageUrl ?? null;
|
||||
if (!nextUrl) break;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
async function patchCI(id: number, patch: object, retries = 5): Promise<void> {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const res = await fetch(`${API_BASE}/ConfigurationItems`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ id, ...patch }),
|
||||
});
|
||||
if (res.ok) return;
|
||||
if (res.status === 429 && attempt < retries) {
|
||||
const delay = 2000 * (attempt + 1); // 2s, 4s, 6s, 8s, 10s
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`PATCH ConfigurationItems/${id} → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run tasks with a concurrency cap, returns results in original order */
|
||||
async function pLimit<T>(
|
||||
tasks: (() => Promise<T>)[],
|
||||
concurrency: number
|
||||
): Promise<{ result?: T; error?: Error }[]> {
|
||||
const results: { result?: T; error?: Error }[] = new Array(tasks.length);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < tasks.length) {
|
||||
const i = next++;
|
||||
try { results[i] = { result: await tasks[i]() }; }
|
||||
catch (e) { results[i] = { error: e instanceof Error ? e : new Error(String(e)) }; }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Arg parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let dryRun = true;
|
||||
let concurrency = 3; // Autotask hard limit is 3 concurrent API threads
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--commit') dryRun = false;
|
||||
if (args[i] === '--dry-run') dryRun = true;
|
||||
if (args[i] === '--concurrency' && args[i+1]) concurrency = parseInt(args[++i], 10);
|
||||
}
|
||||
return { dryRun, concurrency };
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Company {
|
||||
id: number;
|
||||
companyName: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface CIItem {
|
||||
id: number;
|
||||
companyID: number;
|
||||
referenceTitle: string;
|
||||
configurationItemType: number;
|
||||
configurationItemCategoryID: number | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const { dryRun, concurrency } = parseArgs();
|
||||
|
||||
console.log('');
|
||||
console.log('╔══════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ Deactivate CIs for Inactive Companies ║');
|
||||
console.log('╚══════════════════════════════════════════════════════════════╝');
|
||||
console.log(` Mode : ${dryRun ? '🔍 DRY RUN (no changes written)' : '✏️ COMMIT (changes will be applied)'}`);
|
||||
console.log('');
|
||||
|
||||
// ── 1. Fetch all inactive companies ─────────────────────────────────────────
|
||||
process.stdout.write('Fetching inactive companies … ');
|
||||
const inactiveCompanies = await queryAll<Company>('Companies', [
|
||||
{ field: 'isActive', op: 'eq', value: false },
|
||||
]);
|
||||
console.log(`found ${inactiveCompanies.length}.`);
|
||||
|
||||
if (inactiveCompanies.length === 0) {
|
||||
console.log('No inactive companies found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const companyMap = new Map<number, string>(
|
||||
inactiveCompanies.map(c => [c.id, c.companyName])
|
||||
);
|
||||
|
||||
// ── 2. Fetch active CIs for each inactive company ────────────────────────────
|
||||
console.log('Querying active CIs for each inactive company …');
|
||||
|
||||
const cisByCompany = new Map<number, CIItem[]>();
|
||||
let totalFound = 0;
|
||||
|
||||
for (const company of inactiveCompanies) {
|
||||
const cis = await queryAll<CIItem>('ConfigurationItems', [
|
||||
{ field: 'companyID', op: 'eq', value: company.id },
|
||||
{ field: 'isActive', op: 'eq', value: true },
|
||||
]);
|
||||
if (cis.length > 0) {
|
||||
cisByCompany.set(company.id, cis);
|
||||
totalFound += cis.length;
|
||||
process.stdout.write(` [${company.id}] ${company.companyName}: ${cis.length} active CI(s)\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Report ────────────────────────────────────────────────────────────────
|
||||
console.log('');
|
||||
console.log('─── Summary ──────────────────────────────────────────────────');
|
||||
console.log(` Inactive companies checked : ${inactiveCompanies.length}`);
|
||||
console.log(` Companies with active CIs : ${cisByCompany.size}`);
|
||||
console.log(` Total active CIs to deactivate : ${totalFound}`);
|
||||
console.log('');
|
||||
|
||||
if (totalFound === 0) {
|
||||
console.log('✓ Nothing to do — no active CIs found for inactive companies.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('─── Breakdown ────────────────────────────────────────────────');
|
||||
const sorted = [...cisByCompany.entries()].sort((a, b) =>
|
||||
(companyMap.get(a[0]) ?? '').localeCompare(companyMap.get(b[0]) ?? '')
|
||||
);
|
||||
for (const [companyId, cis] of sorted) {
|
||||
const name = companyMap.get(companyId) ?? `Company ${companyId}`;
|
||||
console.log(`\n ${name} (${cis.length} CI${cis.length !== 1 ? 's' : ''})`);
|
||||
for (const ci of cis) {
|
||||
console.log(` [${ci.id}] ${(ci.referenceTitle ?? '(no title)').substring(0, 60)}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (dryRun) {
|
||||
console.log('─── Dry run complete ─────────────────────────────────────────');
|
||||
console.log(` ${totalFound} CI(s) across ${cisByCompany.size} company/companies would be deactivated.`);
|
||||
console.log(' Run with --commit to apply.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 4. Deactivate ────────────────────────────────────────────────────────────
|
||||
const allCIs = [...cisByCompany.values()].flat();
|
||||
console.log(`─── Deactivating ${allCIs.length} CIs (concurrency=${concurrency}) ──────`);
|
||||
|
||||
const tasks = allCIs.map(ci => () => patchCI(ci.id, { isActive: false }));
|
||||
const results = await pLimit(tasks, concurrency);
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
const errors: { ci: CIItem; error: string }[] = [];
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
if (results[i].error) {
|
||||
failed++;
|
||||
errors.push({ ci: allCIs[i], error: results[i].error!.message });
|
||||
process.stdout.write('✗');
|
||||
} else {
|
||||
succeeded++;
|
||||
process.stdout.write('.');
|
||||
}
|
||||
if ((i + 1) % 80 === 0) process.stdout.write('\n');
|
||||
}
|
||||
console.log('\n');
|
||||
|
||||
// ── 5. Final report ───────────────────────────────────────────────────────────
|
||||
console.log('─── Results ──────────────────────────────────────────────────');
|
||||
console.log(` ✓ Deactivated : ${succeeded}`);
|
||||
console.log(` ✗ Failed : ${failed}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log('\n Failures:');
|
||||
for (const { ci, error } of errors) {
|
||||
const name = companyMap.get(ci.companyID) ?? `Company ${ci.companyID}`;
|
||||
console.log(` [${ci.id}] ${ci.referenceTitle ?? '(no title)'} (${name})`);
|
||||
console.log(` ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n✓ All done.');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('\nFatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
365
scripts/update-workstation-desktop-category.ts
Normal file
365
scripts/update-workstation-desktop-category.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* update-workstation-desktop-category.ts
|
||||
*
|
||||
* Finds all active Configuration Items with type "Workstation – Desktop" (typeId=40)
|
||||
* and sets their category to "Workstations".
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/update-workstation-desktop-category.ts [options]
|
||||
*
|
||||
* Options:
|
||||
* --dry-run Preview changes without writing to Autotask (default: true)
|
||||
* --commit Actually apply changes (disables dry-run)
|
||||
* --company <name|id> Filter to a single company (partial name match or numeric ID)
|
||||
* --concurrency <n> Parallel API update calls (default: 5)
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../.env') });
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const API_BASE = process.env.AUTOTASK_API_URL!; // e.g. https://webservices1.autotask.net/atservicesrest/v1.0
|
||||
const USERNAME = process.env.AUTOTASK_USERNAME!;
|
||||
const SECRET = process.env.AUTOTASK_SECRET!;
|
||||
const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!;
|
||||
|
||||
const CI_TYPE_WORKSTATION_DESKTOP = 40; // "Workstation – Desktop" picklist value
|
||||
const TARGET_CATEGORY_NAME = 'Workstations';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Username': USERNAME,
|
||||
'Secret': SECRET,
|
||||
'APIIntegrationcode': INT_CODE,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}/${path}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost<T>(path: string, body: object): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}/${path}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST ${path} → ${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPatch(path: string, body: object, retries = 5): Promise<void> {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const res = await fetch(`${API_BASE}/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) return;
|
||||
if (res.status === 429 && attempt < retries) {
|
||||
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`PATCH ${path} → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginate through all results of a POST /query endpoint */
|
||||
async function queryAll<T>(entity: string, filter: object[]): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let nextUrl: string | null = null;
|
||||
// Autotask requires a body on every POST page request — send same body for all pages
|
||||
const requestBody = JSON.stringify({ MaxRecords: 500, filter });
|
||||
|
||||
while (true) {
|
||||
const url = nextUrl ?? `${API_BASE}/${entity}/query`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: requestBody,
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST ${entity}/query → ${res.status}: ${await res.text()}`);
|
||||
|
||||
const data: any = await res.json();
|
||||
all.push(...(data.items ?? []));
|
||||
|
||||
nextUrl = data.pageDetails?.nextPageUrl ?? null;
|
||||
if (!nextUrl) break;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Run N promises with a concurrency cap */
|
||||
async function pLimit<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<{ result?: T; error?: Error; index: number }[]> {
|
||||
const results: { result?: T; error?: Error; index: number }[] = new Array(tasks.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < tasks.length) {
|
||||
const i = next++;
|
||||
try {
|
||||
results[i] = { result: await tasks[i](), index: i };
|
||||
} catch (e) {
|
||||
results[i] = { error: e instanceof Error ? e : new Error(String(e)), index: i };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Arg parsing ─────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let dryRun = true;
|
||||
let companyArg = '';
|
||||
let concurrency = 3;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--commit') dryRun = false;
|
||||
if (args[i] === '--dry-run') dryRun = true;
|
||||
if (args[i] === '--company' && args[i + 1]) { companyArg = args[++i]; }
|
||||
if (args[i] === '--concurrency' && args[i+1]) { concurrency = parseInt(args[++i], 10); }
|
||||
}
|
||||
|
||||
return { dryRun, companyArg, concurrency };
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CIItem {
|
||||
id: number;
|
||||
referenceTitle: string;
|
||||
companyID: number;
|
||||
configurationItemType: number;
|
||||
configurationItemCategoryID: number | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Company {
|
||||
id: number;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { dryRun, companyArg, concurrency } = parseArgs();
|
||||
|
||||
console.log('');
|
||||
console.log('╔══════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ Workstation-Desktop → Workstations Category Remediation ║');
|
||||
console.log('╚══════════════════════════════════════════════════════════════╝');
|
||||
console.log(` Mode : ${dryRun ? '🔍 DRY RUN (no changes written)' : '✏️ COMMIT (changes will be applied)'}`);
|
||||
if (companyArg) console.log(` Client filter: "${companyArg}"`);
|
||||
console.log('');
|
||||
|
||||
// ── 1. Resolve target category ID ─────────────────────────────────────────
|
||||
process.stdout.write('Fetching ConfigurationItemCategories … ');
|
||||
const categories = await queryAll<Category>('ConfigurationItemCategories', [
|
||||
{ field: 'isActive', op: 'eq', value: true },
|
||||
]);
|
||||
const targetCategory = categories.find(c =>
|
||||
c.name.toLowerCase() === TARGET_CATEGORY_NAME.toLowerCase()
|
||||
);
|
||||
if (!targetCategory) {
|
||||
console.error(`\n✗ Category "${TARGET_CATEGORY_NAME}" not found in Autotask. Available:\n`);
|
||||
categories.forEach(c => console.error(` [${c.id}] ${c.name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`found ${categories.length} categories.`);
|
||||
console.log(` → Target category: [${targetCategory.id}] "${targetCategory.name}"`);
|
||||
|
||||
// ── 2. Resolve company filter ──────────────────────────────────────────────
|
||||
let companyIdFilter: number | null = null;
|
||||
let companyName = '';
|
||||
|
||||
if (companyArg) {
|
||||
process.stdout.write(`Resolving company "${companyArg}" … `);
|
||||
const numericId = parseInt(companyArg, 10);
|
||||
|
||||
if (!isNaN(numericId)) {
|
||||
// Direct ID
|
||||
const resp: any = await apiGet(`Companies/${numericId}`);
|
||||
const co: Company = resp.item;
|
||||
if (!co) { console.error(`\n✗ Company ID ${numericId} not found.`); process.exit(1); }
|
||||
companyIdFilter = co.id;
|
||||
companyName = co.companyName;
|
||||
} else {
|
||||
// Name search — fetch all, filter locally (Autotask doesn't support contains on companyName efficiently)
|
||||
const companies = await queryAll<Company>('Companies', [
|
||||
{ field: 'isActive', op: 'eq', value: true },
|
||||
]);
|
||||
const matches = companies.filter(c =>
|
||||
c.companyName.toLowerCase().includes(companyArg.toLowerCase())
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
console.error(`\n✗ No active company matching "${companyArg}".`); process.exit(1);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
console.error(`\n✗ "${companyArg}" matched ${matches.length} companies — be more specific:`);
|
||||
matches.forEach(c => console.error(` [${c.id}] ${c.companyName}`));
|
||||
process.exit(1);
|
||||
}
|
||||
companyIdFilter = matches[0].id;
|
||||
companyName = matches[0].companyName;
|
||||
}
|
||||
console.log(`resolved → [${companyIdFilter}] ${companyName}`);
|
||||
}
|
||||
|
||||
// ── 3. Fetch matching CIs from Autotask ────────────────────────────────────
|
||||
process.stdout.write('Querying ConfigurationItems (type=Workstation-Desktop, isActive=true) … ');
|
||||
|
||||
// Note: configurationItemType is not queryable via API filter — fetch by isActive
|
||||
// (+ optional companyID) then filter by type client-side.
|
||||
const ciFilter: object[] = [
|
||||
{ field: 'isActive', op: 'eq', value: true },
|
||||
];
|
||||
if (companyIdFilter !== null) {
|
||||
ciFilter.push({ field: 'companyID', op: 'eq', value: companyIdFilter });
|
||||
}
|
||||
|
||||
const rawCIs = await queryAll<CIItem>('ConfigurationItems', ciFilter);
|
||||
const allCIs = rawCIs.filter(ci => ci.configurationItemType === CI_TYPE_WORKSTATION_DESKTOP);
|
||||
console.log(`found ${rawCIs.length} active items, ${allCIs.length} are type "Workstation – Desktop".`);
|
||||
|
||||
// ── 4. Identify items that actually need updating ──────────────────────────
|
||||
const toUpdate = allCIs.filter(ci => ci.configurationItemCategoryID !== targetCategory.id);
|
||||
const alreadyCorrect = allCIs.length - toUpdate.length;
|
||||
|
||||
// ── 5. Build per-company summary ──────────────────────────────────────────
|
||||
const byCompany = new Map<number, { companyID: number; items: CIItem[] }>();
|
||||
for (const ci of toUpdate) {
|
||||
if (!byCompany.has(ci.companyID)) byCompany.set(ci.companyID, { companyID: ci.companyID, items: [] });
|
||||
byCompany.get(ci.companyID)!.items.push(ci);
|
||||
}
|
||||
|
||||
// Fetch company names for the affected companies
|
||||
const companyNames = new Map<number, string>();
|
||||
if (byCompany.size > 0) {
|
||||
process.stdout.write(`Fetching company names for ${byCompany.size} affected companies … `);
|
||||
const companyIds = [...byCompany.keys()];
|
||||
// Batch in groups of 50 (Autotask OR filter limit)
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < companyIds.length; i += chunkSize) {
|
||||
const chunk = companyIds.slice(i, i + chunkSize);
|
||||
const filter = chunk.map(id => ({ field: 'id', op: 'eq', value: id }));
|
||||
const cos = await queryAll<Company>('Companies', filter);
|
||||
for (const co of cos) companyNames.set(co.id, co.companyName);
|
||||
}
|
||||
console.log('done.');
|
||||
}
|
||||
|
||||
// ── 6. Report ──────────────────────────────────────────────────────────────
|
||||
console.log('');
|
||||
console.log('─── Summary ──────────────────────────────────────────────────');
|
||||
console.log(` Total "Workstation – Desktop" CIs found : ${allCIs.length}`);
|
||||
console.log(` Already categorised as "Workstations" : ${alreadyCorrect}`);
|
||||
console.log(` Require update : ${toUpdate.length}`);
|
||||
console.log('');
|
||||
|
||||
if (toUpdate.length === 0) {
|
||||
console.log('✓ Nothing to do — all items already have the correct category.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('─── Breakdown by client ──────────────────────────────────────');
|
||||
const sortedCompanies = [...byCompany.values()].sort((a, b) =>
|
||||
(companyNames.get(a.companyID) ?? '').localeCompare(companyNames.get(b.companyID) ?? '')
|
||||
);
|
||||
for (const { companyID, items } of sortedCompanies) {
|
||||
const name = companyNames.get(companyID) ?? `Company ${companyID}`;
|
||||
console.log(` ${name.padEnd(45)} ${items.length} item(s)`);
|
||||
for (const ci of items) {
|
||||
const oldCat = ci.configurationItemCategoryID ?? 'none';
|
||||
console.log(` [${ci.id}] ${(ci.referenceTitle ?? '(no title)').substring(0, 60)} (cat: ${oldCat} → ${targetCategory.id})`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (dryRun) {
|
||||
console.log('─── Dry run complete ─────────────────────────────────────────');
|
||||
console.log(` ${toUpdate.length} item(s) would be updated.`);
|
||||
console.log(' Run with --commit to apply changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 7. Apply updates ───────────────────────────────────────────────────────
|
||||
console.log(`─── Applying ${toUpdate.length} updates (concurrency=${concurrency}) ────────`);
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
const errors: { id: number; title: string; error: string }[] = [];
|
||||
|
||||
const tasks = toUpdate.map(ci => async () => {
|
||||
await apiPatch('ConfigurationItems', {
|
||||
id: ci.id,
|
||||
configurationItemCategoryID: targetCategory.id,
|
||||
});
|
||||
return ci.id;
|
||||
});
|
||||
|
||||
const results = await pLimit(tasks, concurrency);
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const ci = toUpdate[i];
|
||||
const r = results[i];
|
||||
if (r.error) {
|
||||
failed++;
|
||||
errors.push({ id: ci.id, title: ci.referenceTitle ?? '', error: r.error.message });
|
||||
process.stdout.write('✗');
|
||||
} else {
|
||||
succeeded++;
|
||||
process.stdout.write('.');
|
||||
}
|
||||
if ((i + 1) % 80 === 0) process.stdout.write('\n');
|
||||
}
|
||||
console.log('\n');
|
||||
|
||||
// ── 8. Final report ────────────────────────────────────────────────────────
|
||||
console.log('─── Results ──────────────────────────────────────────────────');
|
||||
console.log(` ✓ Updated successfully : ${succeeded}`);
|
||||
console.log(` ✗ Failed : ${failed}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log('');
|
||||
console.log(' Failures:');
|
||||
for (const e of errors) {
|
||||
console.log(` [${e.id}] ${e.title}`);
|
||||
console.log(` ${e.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (failed === 0) {
|
||||
console.log('✓ All done.');
|
||||
} else {
|
||||
console.log('⚠ Completed with errors — review failures above.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('\nFatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
365
scripts/update-workstation-laptop-category.ts
Normal file
365
scripts/update-workstation-laptop-category.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* update-workstation-laptop-category.ts
|
||||
*
|
||||
* Finds all active Configuration Items with type "Workstation – Laptop" (typeId=40)
|
||||
* and sets their category to "Workstations".
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/update-workstation-laptop-category.ts [options]
|
||||
*
|
||||
* Options:
|
||||
* --dry-run Preview changes without writing to Autotask (default: true)
|
||||
* --commit Actually apply changes (disables dry-run)
|
||||
* --company <name|id> Filter to a single company (partial name match or numeric ID)
|
||||
* --concurrency <n> Parallel API update calls (default: 5)
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../.env') });
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const API_BASE = process.env.AUTOTASK_API_URL!; // e.g. https://webservices1.autotask.net/atservicesrest/v1.0
|
||||
const USERNAME = process.env.AUTOTASK_USERNAME!;
|
||||
const SECRET = process.env.AUTOTASK_SECRET!;
|
||||
const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!;
|
||||
|
||||
const CI_TYPE_WORKSTATION_LAPTOP = 39; // "Workstation – Laptop" picklist value
|
||||
const TARGET_CATEGORY_NAME = 'Workstations';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Username': USERNAME,
|
||||
'Secret': SECRET,
|
||||
'APIIntegrationcode': INT_CODE,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}/${path}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPost<T>(path: string, body: object): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}/${path}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST ${path} → ${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function apiPatch(path: string, body: object, retries = 5): Promise<void> {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const res = await fetch(`${API_BASE}/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) return;
|
||||
if (res.status === 429 && attempt < retries) {
|
||||
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
throw new Error(`PATCH ${path} → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginate through all results of a POST /query endpoint */
|
||||
async function queryAll<T>(entity: string, filter: object[]): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let nextUrl: string | null = null;
|
||||
// Autotask requires a body on every POST page request — send same body for all pages
|
||||
const requestBody = JSON.stringify({ MaxRecords: 500, filter });
|
||||
|
||||
while (true) {
|
||||
const url = nextUrl ?? `${API_BASE}/${entity}/query`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: requestBody,
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST ${entity}/query → ${res.status}: ${await res.text()}`);
|
||||
|
||||
const data: any = await res.json();
|
||||
all.push(...(data.items ?? []));
|
||||
|
||||
nextUrl = data.pageDetails?.nextPageUrl ?? null;
|
||||
if (!nextUrl) break;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Run N promises with a concurrency cap */
|
||||
async function pLimit<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<{ result?: T; error?: Error; index: number }[]> {
|
||||
const results: { result?: T; error?: Error; index: number }[] = new Array(tasks.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < tasks.length) {
|
||||
const i = next++;
|
||||
try {
|
||||
results[i] = { result: await tasks[i](), index: i };
|
||||
} catch (e) {
|
||||
results[i] = { error: e instanceof Error ? e : new Error(String(e)), index: i };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Arg parsing ─────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let dryRun = true;
|
||||
let companyArg = '';
|
||||
let concurrency = 3; // Autotask hard limit is 3 concurrent API threads
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--commit') dryRun = false;
|
||||
if (args[i] === '--dry-run') dryRun = true;
|
||||
if (args[i] === '--company' && args[i + 1]) { companyArg = args[++i]; }
|
||||
if (args[i] === '--concurrency' && args[i+1]) { concurrency = parseInt(args[++i], 10); }
|
||||
}
|
||||
|
||||
return { dryRun, companyArg, concurrency };
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CIItem {
|
||||
id: number;
|
||||
referenceTitle: string;
|
||||
companyID: number;
|
||||
configurationItemType: number;
|
||||
configurationItemCategoryID: number | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface Company {
|
||||
id: number;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { dryRun, companyArg, concurrency } = parseArgs();
|
||||
|
||||
console.log('');
|
||||
console.log('╔══════════════════════════════════════════════════════════════╗');
|
||||
console.log('║ Workstation-Laptop → Workstations Category Remediation ║');
|
||||
console.log('╚══════════════════════════════════════════════════════════════╝');
|
||||
console.log(` Mode : ${dryRun ? '🔍 DRY RUN (no changes written)' : '✏️ COMMIT (changes will be applied)'}`);
|
||||
if (companyArg) console.log(` Client filter: "${companyArg}"`);
|
||||
console.log('');
|
||||
|
||||
// ── 1. Resolve target category ID ─────────────────────────────────────────
|
||||
process.stdout.write('Fetching ConfigurationItemCategories … ');
|
||||
const categories = await queryAll<Category>('ConfigurationItemCategories', [
|
||||
{ field: 'isActive', op: 'eq', value: true },
|
||||
]);
|
||||
const targetCategory = categories.find(c =>
|
||||
c.name.toLowerCase() === TARGET_CATEGORY_NAME.toLowerCase()
|
||||
);
|
||||
if (!targetCategory) {
|
||||
console.error(`\n✗ Category "${TARGET_CATEGORY_NAME}" not found in Autotask. Available:\n`);
|
||||
categories.forEach(c => console.error(` [${c.id}] ${c.name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`found ${categories.length} categories.`);
|
||||
console.log(` → Target category: [${targetCategory.id}] "${targetCategory.name}"`);
|
||||
|
||||
// ── 2. Resolve company filter ──────────────────────────────────────────────
|
||||
let companyIdFilter: number | null = null;
|
||||
let companyName = '';
|
||||
|
||||
if (companyArg) {
|
||||
process.stdout.write(`Resolving company "${companyArg}" … `);
|
||||
const numericId = parseInt(companyArg, 10);
|
||||
|
||||
if (!isNaN(numericId)) {
|
||||
// Direct ID
|
||||
const resp: any = await apiGet(`Companies/${numericId}`);
|
||||
const co: Company = resp.item;
|
||||
if (!co) { console.error(`\n✗ Company ID ${numericId} not found.`); process.exit(1); }
|
||||
companyIdFilter = co.id;
|
||||
companyName = co.companyName;
|
||||
} else {
|
||||
// Name search — fetch all, filter locally (Autotask doesn't support contains on companyName efficiently)
|
||||
const companies = await queryAll<Company>('Companies', [
|
||||
{ field: 'isActive', op: 'eq', value: true },
|
||||
]);
|
||||
const matches = companies.filter(c =>
|
||||
c.companyName.toLowerCase().includes(companyArg.toLowerCase())
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
console.error(`\n✗ No active company matching "${companyArg}".`); process.exit(1);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
console.error(`\n✗ "${companyArg}" matched ${matches.length} companies — be more specific:`);
|
||||
matches.forEach(c => console.error(` [${c.id}] ${c.companyName}`));
|
||||
process.exit(1);
|
||||
}
|
||||
companyIdFilter = matches[0].id;
|
||||
companyName = matches[0].companyName;
|
||||
}
|
||||
console.log(`resolved → [${companyIdFilter}] ${companyName}`);
|
||||
}
|
||||
|
||||
// ── 3. Fetch matching CIs from Autotask ────────────────────────────────────
|
||||
process.stdout.write('Querying ConfigurationItems (type=Workstation-Laptop, isActive=true) … ');
|
||||
|
||||
// Note: configurationItemType is not queryable via API filter — fetch by isActive
|
||||
// (+ optional companyID) then filter by type client-side.
|
||||
const ciFilter: object[] = [
|
||||
{ field: 'isActive', op: 'eq', value: true },
|
||||
];
|
||||
if (companyIdFilter !== null) {
|
||||
ciFilter.push({ field: 'companyID', op: 'eq', value: companyIdFilter });
|
||||
}
|
||||
|
||||
const rawCIs = await queryAll<CIItem>('ConfigurationItems', ciFilter);
|
||||
const allCIs = rawCIs.filter(ci => ci.configurationItemType === CI_TYPE_WORKSTATION_LAPTOP);
|
||||
console.log(`found ${rawCIs.length} active items, ${allCIs.length} are type "Workstation – Laptop".`);
|
||||
|
||||
// ── 4. Identify items that actually need updating ──────────────────────────
|
||||
const toUpdate = allCIs.filter(ci => ci.configurationItemCategoryID !== targetCategory.id);
|
||||
const alreadyCorrect = allCIs.length - toUpdate.length;
|
||||
|
||||
// ── 5. Build per-company summary ──────────────────────────────────────────
|
||||
const byCompany = new Map<number, { companyID: number; items: CIItem[] }>();
|
||||
for (const ci of toUpdate) {
|
||||
if (!byCompany.has(ci.companyID)) byCompany.set(ci.companyID, { companyID: ci.companyID, items: [] });
|
||||
byCompany.get(ci.companyID)!.items.push(ci);
|
||||
}
|
||||
|
||||
// Fetch company names for the affected companies
|
||||
const companyNames = new Map<number, string>();
|
||||
if (byCompany.size > 0) {
|
||||
process.stdout.write(`Fetching company names for ${byCompany.size} affected companies … `);
|
||||
const companyIds = [...byCompany.keys()];
|
||||
// Batch in groups of 50 (Autotask OR filter limit)
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < companyIds.length; i += chunkSize) {
|
||||
const chunk = companyIds.slice(i, i + chunkSize);
|
||||
const filter = chunk.map(id => ({ field: 'id', op: 'eq', value: id }));
|
||||
const cos = await queryAll<Company>('Companies', filter);
|
||||
for (const co of cos) companyNames.set(co.id, co.companyName);
|
||||
}
|
||||
console.log('done.');
|
||||
}
|
||||
|
||||
// ── 6. Report ──────────────────────────────────────────────────────────────
|
||||
console.log('');
|
||||
console.log('─── Summary ──────────────────────────────────────────────────');
|
||||
console.log(` Total "Workstation – Laptop" CIs found : ${allCIs.length}`);
|
||||
console.log(` Already categorised as "Workstations" : ${alreadyCorrect}`);
|
||||
console.log(` Require update : ${toUpdate.length}`);
|
||||
console.log('');
|
||||
|
||||
if (toUpdate.length === 0) {
|
||||
console.log('✓ Nothing to do — all items already have the correct category.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('─── Breakdown by client ──────────────────────────────────────');
|
||||
const sortedCompanies = [...byCompany.values()].sort((a, b) =>
|
||||
(companyNames.get(a.companyID) ?? '').localeCompare(companyNames.get(b.companyID) ?? '')
|
||||
);
|
||||
for (const { companyID, items } of sortedCompanies) {
|
||||
const name = companyNames.get(companyID) ?? `Company ${companyID}`;
|
||||
console.log(` ${name.padEnd(45)} ${items.length} item(s)`);
|
||||
for (const ci of items) {
|
||||
const oldCat = ci.configurationItemCategoryID ?? 'none';
|
||||
console.log(` [${ci.id}] ${(ci.referenceTitle ?? '(no title)').substring(0, 60)} (cat: ${oldCat} → ${targetCategory.id})`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
if (dryRun) {
|
||||
console.log('─── Dry run complete ─────────────────────────────────────────');
|
||||
console.log(` ${toUpdate.length} item(s) would be updated.`);
|
||||
console.log(' Run with --commit to apply changes.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 7. Apply updates ───────────────────────────────────────────────────────
|
||||
console.log(`─── Applying ${toUpdate.length} updates (concurrency=${concurrency}) ────────`);
|
||||
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
const errors: { id: number; title: string; error: string }[] = [];
|
||||
|
||||
const tasks = toUpdate.map(ci => async () => {
|
||||
await apiPatch('ConfigurationItems', {
|
||||
id: ci.id,
|
||||
configurationItemCategoryID: targetCategory.id,
|
||||
});
|
||||
return ci.id;
|
||||
});
|
||||
|
||||
const results = await pLimit(tasks, concurrency);
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const ci = toUpdate[i];
|
||||
const r = results[i];
|
||||
if (r.error) {
|
||||
failed++;
|
||||
errors.push({ id: ci.id, title: ci.referenceTitle ?? '', error: r.error.message });
|
||||
process.stdout.write('✗');
|
||||
} else {
|
||||
succeeded++;
|
||||
process.stdout.write('.');
|
||||
}
|
||||
if ((i + 1) % 80 === 0) process.stdout.write('\n');
|
||||
}
|
||||
console.log('\n');
|
||||
|
||||
// ── 8. Final report ────────────────────────────────────────────────────────
|
||||
console.log('─── Results ──────────────────────────────────────────────────');
|
||||
console.log(` ✓ Updated successfully : ${succeeded}`);
|
||||
console.log(` ✗ Failed : ${failed}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log('');
|
||||
console.log(' Failures:');
|
||||
for (const e of errors) {
|
||||
console.log(` [${e.id}] ${e.title}`);
|
||||
console.log(` ${e.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (failed === 0) {
|
||||
console.log('✓ All done.');
|
||||
} else {
|
||||
console.log('⚠ Completed with errors — review failures above.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('\nFatal error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue