wulf-pulse/scripts/update-workstation-laptop-category.ts
lorentz ea3471d38d 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
2026-04-29 09:16:46 -04:00

365 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});