- 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
202 lines
8.4 KiB
TypeScript
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 });
|
|
}
|
|
}
|