wulf-pulse/app/api/veeam/rpo-comparison/route.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

202 lines
8.4 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
const PERIOD_INTERVALS: Record<string, string> = {
'1d': '1 day',
'7d': '7 days',
'14d': '14 days',
'30d': '30 days',
};
// Closed AT status values (Complete, Closed variants)
const CLOSED_STATUSES = [5, 29832279, 29832280];
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const period = searchParams.get('period') ?? '7d';
const interval = PERIOD_INTERVALS[period] ?? '7 days';
// ── 1. Pulse shadow tickets (open) with company_id ──────────────────────
// Pull agent name as the reliable hostname — rmm_hostname on the shadow
// ticket is only populated when the RMM resolver matched the device.
// datto_rmm_sites.autotask_company_id is not populated, so join companies
// via name match to get site_id, enabling hostname resolution via agent name.
const shadowRes = await postgresClient.query(`
SELECT
st.job_instance_uid,
st.job_name,
st.org_name,
st.priority_level,
st.hours_overdue,
st.failure_category,
st.opened_at,
vo.company_id,
-- Use agent name as the device hostname (most reliable source)
COALESCE(st.rmm_hostname, ba.name) AS rmm_hostname
FROM veeam_rpo_shadow_tickets st
JOIN veeam_backup_agent_jobs j ON j.instance_uid = st.job_instance_uid
JOIN veeam_organizations vo ON vo.instance_uid = j.organization_uid
LEFT JOIN veeam_backup_agents ba ON ba.instance_uid = j.backup_agent_uid
WHERE st.resolved_at IS NULL
`);
// ── 2. AT Veeam tickets created by Datto (title: "Veeam:* on HOST at Site")
// Use LATERAL to pull one alert row per ticket (most recent) so the
// GROUP BY stays clean — no multi-row fanout from the alert join.
const atRes = await postgresClient.query(`
WITH ticket_with_alert AS (
SELECT
t.ticket_number,
t.company_id,
t.title,
t.status,
t.priority,
t.create_date,
t.completed_date,
UPPER((regexp_match(t.title, ' on ([A-Za-z0-9][A-Za-z0-9._-]*) at '))[1]) AS hostname,
a.alert_uid AS datto_alert_uid,
a.resolved AS datto_resolved,
a.alert_context->>'source' AS datto_source,
a.timestamp AS datto_alert_fired
FROM tickets t
LEFT JOIN LATERAL (
SELECT alert_uid, resolved, alert_context, timestamp
FROM datto_rmm_alerts
WHERE ticket_number = t.ticket_number
ORDER BY timestamp DESC
LIMIT 1
) a ON true
WHERE t.title ILIKE 'Veeam:%'
AND t.title NOT ILIKE '[Veeam RPO]%'
AND t.create_date > NOW() - $1::interval
AND (regexp_match(t.title, ' on ([A-Za-z0-9][A-Za-z0-9._-]*) at '))[1] IS NOT NULL
)
SELECT
company_id,
hostname,
COUNT(*) AS ticket_count,
COUNT(*) FILTER (
WHERE completed_date IS NULL
AND status NOT IN (${CLOSED_STATUSES.join(',')})
) AS open_count,
MAX(create_date) AS latest_ticket_at,
json_agg(
json_build_object(
'ticket_number', ticket_number,
'title', title,
'status', status,
'priority', priority,
'created_at', create_date,
'completed_at', completed_date,
'datto_alert_uid', datto_alert_uid,
'datto_resolved', datto_resolved,
'datto_source', datto_source,
'datto_alert_fired', datto_alert_fired
)
ORDER BY create_date DESC
) AS tickets
FROM ticket_with_alert
GROUP BY company_id, hostname
`, [interval]);
// ── 3. Recent offline suppressions (latest per job) ─────────────────────
const offlineRes = await postgresClient.query(`
SELECT DISTINCT ON (job_instance_uid)
job_instance_uid, rmm_hostname, org_name, hours_offline, checked_at
FROM veeam_rpo_offline_log
WHERE checked_at > NOW() - $1::interval
ORDER BY job_instance_uid, checked_at DESC
`, [interval]);
// ── 4. Build lookup maps ─────────────────────────────────────────────────
// Pulse: keyed by "company_id|hostname_lower".
// If no hostname is available (agent not found), use job_instance_uid as key
// so the row still appears as pulse_only in the comparison.
const shadowByKey = new Map<string, any>();
for (const row of shadowRes.rows) {
const key = row.rmm_hostname
? `${row.company_id}|${row.rmm_hostname.toLowerCase()}`
: `pulse_only|${row.job_instance_uid}`;
shadowByKey.set(key, row);
}
// Offline: keyed by job_instance_uid
const offlineByUid = new Map<string, any>();
for (const row of offlineRes.rows) {
offlineByUid.set(row.job_instance_uid, row);
}
// AT/Datto: keyed by "company_id|hostname_lower"
const atByKey = new Map<string, any>();
for (const row of atRes.rows) {
if (!row.hostname) continue;
const key = `${row.company_id}|${row.hostname.toLowerCase()}`;
atByKey.set(key, row);
}
// ── 5. Build comparison rows ─────────────────────────────────────────────
const allKeys = new Set([...shadowByKey.keys(), ...atByKey.keys()]);
const matches: any[] = [];
for (const key of allKeys) {
const shadow = shadowByKey.get(key) ?? null;
const at = atByKey.get(key) ?? null;
const offline = shadow ? offlineByUid.get(shadow.job_instance_uid) ?? null : null;
let status: string;
if (shadow && at) status = 'both';
else if (shadow) status = offline ? 'offline_suppressed' : 'pulse_only';
else status = 'datto_only';
const tickets = (at?.tickets ?? []).slice(0, 5);
matches.push({
key,
hostname: shadow?.rmm_hostname ?? at?.hostname ?? null,
org_name: shadow?.org_name ?? null,
company_id: shadow?.company_id ?? at?.company_id ?? null,
status,
pulse: shadow ? {
job_instance_uid: shadow.job_instance_uid,
job_name: shadow.job_name,
priority_level: shadow.priority_level,
hours_overdue: shadow.hours_overdue,
failure_category: shadow.failure_category,
opened_at: shadow.opened_at,
} : null,
offline: offline ? {
hours_offline: offline.hours_offline,
last_suppressed: offline.checked_at,
} : null,
// Datto/AT side
at_ticket_count: at?.ticket_count ?? 0,
at_open_count: at?.open_count ?? 0,
at_latest_at: at?.latest_ticket_at ?? null,
at_tickets: tickets,
});
}
// Sort: both → pulse_only → datto_only → offline_suppressed
const rank: Record<string, number> = { both: 0, pulse_only: 1, datto_only: 2, offline_suppressed: 3 };
matches.sort((a, b) => {
const r = rank[a.status] - rank[b.status];
if (r !== 0) return r;
return (b.pulse?.hours_overdue ?? 0) - (a.pulse?.hours_overdue ?? 0);
});
const summary = {
pulse_open: shadowRes.rows.length,
datto_at_total: atRes.rows.reduce((s: number, r: any) => s + parseInt(r.ticket_count), 0),
datto_at_open: atRes.rows.reduce((s: number, r: any) => s + parseInt(r.open_count), 0),
both: matches.filter(m => m.status === 'both').length,
pulse_only: matches.filter(m => m.status === 'pulse_only').length,
datto_only: matches.filter(m => m.status === 'datto_only').length,
offline_suppressed: offlineRes.rows.length,
};
return NextResponse.json({ period, summary, matches });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}