- 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
253 lines
10 KiB
TypeScript
253 lines
10 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|