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:
lorentz 2026-04-29 09:16:46 -04:00
parent 07067bef19
commit ea3471d38d
36 changed files with 5604 additions and 217 deletions

View file

@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import postgresClient from '@/lib/services/postgres-client';
const HUMAN_NOTE_TYPES = [1, 2, 3];
const MODEL = 'claude-haiku-4-5-20251001';
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { at_ticket_number, hostname, org_name, hours_offline } = body as {
at_ticket_number: string;
hostname: string;
org_name: string;
hours_offline?: number;
};
if (!at_ticket_number) {
return NextResponse.json({ error: 'at_ticket_number required' }, { status: 400 });
}
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'ANTHROPIC_API_KEY not configured' }, { status: 503 });
}
// ── Fetch ticket, notes, time entries ────────────────────────────────────
const [ticketRes, notesRes, timeRes] = await Promise.all([
postgresClient.query(`
SELECT t.id, t.ticket_number, t.title, t.description, t.status,
t.create_date, t.completed_date,
c.company_name
FROM tickets t
LEFT JOIN companies c ON c.id = t.company_id
WHERE t.ticket_number = $1
LIMIT 1
`, [at_ticket_number]),
postgresClient.query(`
SELECT tn.note_type, tn.title, tn.description, tn.create_date_time,
r.first_name || ' ' || r.last_name AS author
FROM ticket_notes tn
JOIN tickets t ON t.id = tn.ticket_id
LEFT JOIN resources r ON r.id = tn.creator_resource_id
WHERE t.ticket_number = $1
AND tn.note_type = ANY($2)
AND tn.is_deleted = false
ORDER BY tn.create_date_time
`, [at_ticket_number, HUMAN_NOTE_TYPES]),
postgresClient.query(`
SELECT te.hours_worked, te.notes, te.entry_date,
r.first_name || ' ' || r.last_name AS tech
FROM time_entries te
JOIN tickets t ON t.id = te.ticket_id
LEFT JOIN resources r ON r.id = te.resource_id
WHERE t.ticket_number = $1
ORDER BY te.entry_date
`, [at_ticket_number]),
]);
if (ticketRes.rows.length === 0) {
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 });
}
const ticket = ticketRes.rows[0];
const notes = notesRes.rows;
const entries = timeRes.rows;
const totalHours = entries.reduce((s: number, e: any) => s + parseFloat(e.hours_worked ?? 0), 0);
// ── Build prompt ─────────────────────────────────────────────────────────
const offlineContext = hours_offline != null
? `The device (${hostname}) was last seen by RMM ${Math.round(hours_offline)} hours before this ticket was created, meaning it was offline at the time the backup alert fired.`
: `We do not have RMM last-seen data for this device.`;
const notesText = notes.length > 0
? notes.map((n: any) =>
`[${n.author ?? 'Unknown'} - ${new Date(n.create_date_time).toLocaleDateString()}]\n${n.title ? n.title + '\n' : ''}${(n.description ?? '').substring(0, 800)}`
).join('\n\n---\n\n')
: '(No human-written notes on this ticket)';
const timeText = entries.length > 0
? entries.map((e: any) =>
`${e.tech ?? 'Unknown'}: ${e.hours_worked}h — ${(e.notes ?? '').substring(0, 300)}`
).join('\n')
: '(No time logged)';
const systemPrompt = `You are analyzing Autotask service tickets created by Datto RMM backup monitors to help determine whether an automated RPO-based suppression system would have correctly identified that a ticket was unnecessary.
Context: Pulse RPO is a system that monitors Veeam backup jobs. When a workstation (laptop/desktop) has been offline (not seen by RMM) for longer than its backup interval (typically 24 hours), Pulse suppresses the backup alert because if the machine is offline, it cannot run a backup, so the alert is a false positive requiring no human action.
Your task: Analyze the ticket below and determine:
1. Was any meaningful human work performed that fixed an actual backup problem?
2. Or was the ticket resolved simply because the machine came back online / the alert self-cleared?
3. Would suppressing this ticket (never creating it) have been the correct call?
Answer concisely in JSON with these fields:
- "would_suppress_correctly": boolean true if suppression would have been correct (no real work needed)
- "confidence": "high" | "medium" | "low"
- "work_summary": string 1-2 sentences describing what was actually done (or not done)
- "reasoning": string 2-3 sentences explaining why suppression would/would not have been correct
- "recommendation": string one actionable sentence`;
const userPrompt = `Ticket: ${at_ticket_number}
Title: ${ticket.title}
Client: ${ticket.company_name ?? org_name}
Device: ${hostname}
Created: ${new Date(ticket.create_date).toLocaleDateString()}
Completed: ${ticket.completed_date ? new Date(ticket.completed_date).toLocaleDateString() : 'N/A'}
Total time logged: ${totalHours.toFixed(2)}h
Offline status: ${offlineContext}
Tech notes on this ticket:
${notesText}
Time entries:
${timeText}`;
// ── Call Anthropic ────────────────────────────────────────────────────────
const client = new Anthropic({ apiKey });
const message = await client.messages.create({
model: MODEL,
max_tokens: 600,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
});
const content = message.content[0].type === 'text' ? message.content[0].text : '{}';
// Strip markdown code fences if the model wraps the JSON
const jsonText = content.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
let analysis: Record<string, any> = {};
try { analysis = JSON.parse(jsonText); } catch { analysis = { raw: content }; }
return NextResponse.json({
ticket_number: at_ticket_number,
hostname,
org_name,
hours_offline: hours_offline ?? null,
total_hours_logged: totalHours,
note_count: notes.length,
model: MODEL,
analysis,
});
} catch (err: any) {
console.error('[RPO-ANALYZE] Error:', err.message);
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,202 @@
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 });
}
}

View file

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get('limit') ?? '100'), 500);
const offset = parseInt(searchParams.get('offset') ?? '0');
const job = searchParams.get('job'); // filter by job_instance_uid
const host = searchParams.get('hostname'); // filter by rmm_hostname
const conditions: string[] = [];
const params: any[] = [];
if (job) {
params.push(job);
conditions.push(`job_instance_uid = $${params.length}`);
}
if (host) {
params.push(host.toLowerCase());
conditions.push(`LOWER(rmm_hostname) = $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const [rows, countRes] = await Promise.all([
postgresClient.query(`
SELECT
id, job_instance_uid, job_name, org_name,
rmm_hostname, rmm_site_name, device_type_category,
rmm_last_seen, hours_offline, backup_interval_hours, checked_at
FROM veeam_rpo_offline_log
${where}
ORDER BY checked_at DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`, [...params, limit, offset]),
postgresClient.query(`SELECT COUNT(*) FROM veeam_rpo_offline_log ${where}`, params),
]);
return NextResponse.json({
total: parseInt(countRes.rows[0].count),
limit,
offset,
rows: rows.rows,
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View file

@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get('page') ?? '1'));
const limit = 50;
const offset = (page - 1) * limit;
const category = searchParams.get('category');
const company = searchParams.get('company');
// Build filter for ticket list only (aggregations always show all data)
const filterWhere: string[] = [];
const filterParams: any[] = [];
if (category) {
filterParams.push(category);
filterWhere.push(`problem_category = $${filterParams.length}`);
}
if (company) {
filterParams.push(`%${company}%`);
filterWhere.push(`company_name ILIKE $${filterParams.length}`);
}
const baseWhere = filterWhere.length
? `WHERE ticket_created_at >= DATE_TRUNC('year', NOW()) AND ${filterWhere.join(' AND ')}`
: `WHERE ticket_created_at >= DATE_TRUNC('year', NOW())`;
const [statsRes, categoryRes, resolutionRes, skillsRes, complexityRes, ticketsRes, totalRes, ytdRes] =
await Promise.all([
// Overall stats (unfiltered)
postgresClient.query(`
SELECT
COUNT(*) AS total_analyzed,
ROUND(AVG(hours_worked)::numeric, 2) AS avg_hours,
ROUND(100.0 * COUNT(*) FILTER (WHERE same_day_close)
/ NULLIF(COUNT(*), 0), 1) AS same_day_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE preventable = true)
/ NULLIF(COUNT(*) FILTER (WHERE preventable IS NOT NULL), 0), 1) AS preventable_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE device_was_offline = true)
/ NULLIF(COUNT(*) FILTER (WHERE device_was_offline IS NOT NULL), 0), 1) AS offline_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE backup_completed_before_tech = true)
/ NULLIF(COUNT(*) FILTER (WHERE backup_completed_before_tech IS NOT NULL), 0), 1) AS auto_resolved_pct
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
`),
// By problem category (unfiltered)
postgresClient.query(`
SELECT problem_category,
COUNT(*) AS count,
ROUND(AVG(hours_worked)::numeric, 2) AS avg_hours,
ROUND(100.0 * COUNT(*) FILTER (WHERE same_day_close)
/ NULLIF(COUNT(*), 0), 1) AS same_day_pct
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY problem_category
ORDER BY count DESC
`),
// By resolution type (unfiltered)
postgresClient.query(`
SELECT resolution_type, COUNT(*) AS count
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY resolution_type
ORDER BY count DESC
`),
// Skills frequency (unfiltered)
postgresClient.query(`
SELECT skill, COUNT(*) AS count
FROM veeam_ticket_analysis, unnest(skills_required) AS skill
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY skill
ORDER BY count DESC
LIMIT 15
`),
// Complexity breakdown (unfiltered)
postgresClient.query(`
SELECT complexity, COUNT(*) AS count
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY complexity
ORDER BY CASE complexity
WHEN 'trivial' THEN 1 WHEN 'low' THEN 2 WHEN 'medium' THEN 3 WHEN 'high' THEN 4 ELSE 5
END
`),
// Filtered ticket list
postgresClient.query(`
SELECT ticket_number, company_name, device_hostname,
ticket_created_at, ticket_closed_at, same_day_close,
hours_worked, problem_category, resolution_type, complexity,
device_was_offline, backup_completed_before_tech, preventable,
work_summary, recommended_procedure
FROM veeam_ticket_analysis
${baseWhere}
ORDER BY ticket_created_at DESC
LIMIT $${filterParams.length + 1} OFFSET $${filterParams.length + 2}
`, [...filterParams, limit, offset]),
// Filtered total
postgresClient.query(
`SELECT COUNT(*) FROM veeam_ticket_analysis ${baseWhere}`,
filterParams
),
// YTD total all backup tickets
postgresClient.query(`
SELECT COUNT(*) AS total FROM tickets
WHERE title ILIKE 'Veeam:%'
AND title NOT ILIKE '[Veeam RPO]%'
AND create_date >= DATE_TRUNC('year', NOW())
`),
]);
return NextResponse.json({
stats: {
...statsRes.rows[0],
total_ytd: parseInt(ytdRes.rows[0]?.total ?? 0),
},
by_category: categoryRes.rows,
by_resolution: resolutionRes.rows,
skills: skillsRes.rows,
by_complexity: complexityRes.rows,
tickets: ticketsRes.rows,
total: parseInt(totalRes.rows[0]?.count ?? 0),
page,
limit,
});
}

View file

@ -0,0 +1,230 @@
import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import postgresClient from '@/lib/services/postgres-client';
import { analysisState } from '@/lib/services/veeam-analysis-state';
const MODEL = 'claude-haiku-4-5-20251001';
const CONCURRENCY = 4;
const HUMAN_NOTE_TYPES = [1, 2, 3];
const SYSTEM_PROMPT = `You are analyzing Autotask service tickets for Veeam backup failures to help an MSP build process and procedure documentation.
For each ticket, determine:
1. The root cause category of the backup failure
2. How it was ultimately resolved
3. What specific technical skills were required
4. Whether the device was offline/powered-off when the alert fired
5. Whether the backup completed on its own before any technician touched the ticket
6. Whether this specific issue was preventable with better process or monitoring
7. Resolution complexity
Return JSON only no explanation, no markdown:
{
"problem_category": "device_offline" | "agent_issue" | "job_failed" | "storage_issue" | "network_issue" | "authentication" | "software_error" | "self_resolved" | "configuration" | "other",
"resolution_type": "no_action_needed" | "device_powered_on" | "backup_restarted" | "agent_reinstalled" | "storage_cleared" | "settings_updated" | "escalated" | "other",
"skills_required": ["array", "of", "specific", "skill", "strings"],
"complexity": "trivial" | "low" | "medium" | "high",
"device_was_offline": true | false,
"backup_completed_before_tech": true | false,
"preventable": true | false,
"work_summary": "1-2 sentence description of what happened and what was done",
"recommended_procedure": "one concrete SOP action for this class of issue"
}`;
async function analyzeTicket(client: Anthropic, ticket: any): Promise<void> {
const sameDayClose = !!(ticket.completed_date &&
new Date(ticket.create_date).toDateString() === new Date(ticket.completed_date).toDateString());
const [notesRes, timeRes, offlineRes] = await Promise.all([
postgresClient.query(`
SELECT tn.note_type, tn.title, tn.description, tn.create_date_time,
r.first_name || ' ' || r.last_name AS author
FROM ticket_notes tn
LEFT JOIN resources r ON r.id = tn.creator_resource_id
WHERE tn.ticket_id = $1
AND tn.note_type = ANY($2)
AND tn.is_deleted = false
ORDER BY tn.create_date_time
`, [ticket.ticket_id, HUMAN_NOTE_TYPES]),
postgresClient.query(`
SELECT te.hours_worked, te.notes, te.entry_date,
r.first_name || ' ' || r.last_name AS tech
FROM time_entries te
LEFT JOIN resources r ON r.id = te.resource_id
WHERE te.ticket_id = $1
ORDER BY te.entry_date
`, [ticket.ticket_id]),
ticket.device_hostname
? postgresClient.query(`
SELECT hours_offline, rmm_last_seen
FROM veeam_rpo_offline_log
WHERE LOWER(rmm_hostname) = LOWER($1)
AND checked_at BETWEEN $2::timestamptz - INTERVAL '72 hours'
AND $2::timestamptz + INTERVAL '24 hours'
ORDER BY checked_at DESC LIMIT 1
`, [ticket.device_hostname, ticket.create_date])
: Promise.resolve({ rows: [] }),
]);
const notes = notesRes.rows;
const entries = timeRes.rows;
const offline = offlineRes.rows[0] ?? null;
const totalHours = entries.reduce((s: number, e: any) => s + parseFloat(e.hours_worked ?? 0), 0);
const notesText = notes.length > 0
? notes.map((n: any) =>
`[${n.author ?? 'Unknown'}${new Date(n.create_date_time).toLocaleDateString()}]\n` +
`${n.title ? n.title + '\n' : ''}${(n.description ?? '').substring(0, 600)}`
).join('\n\n---\n\n')
: '(No tech notes)';
const timeText = entries.length > 0
? entries.map((e: any) =>
`${e.tech ?? 'Unknown'}: ${e.hours_worked}h — ${(e.notes ?? '').substring(0, 300)}`
).join('\n')
: '(No time entries)';
const offlineCtx = offline
? `RMM offline data: device was last seen ${Math.round(offline.hours_offline)}h before ticket creation (last seen ${new Date(offline.rmm_last_seen).toLocaleDateString()}).`
: 'No RMM offline record found for this device around ticket creation time.';
const userPrompt = `Ticket: ${ticket.ticket_number}
Title: ${ticket.title}
Client: ${ticket.company_name ?? 'Unknown'}
Device: ${ticket.device_hostname ?? 'Unknown'}
Created: ${new Date(ticket.create_date).toLocaleDateString()}
Closed: ${ticket.completed_date ? new Date(ticket.completed_date).toLocaleDateString() : 'Still open'}
Same-day close: ${sameDayClose}
Time logged: ${totalHours.toFixed(2)}h
${offlineCtx}
Tech notes:
${notesText}
Time entries:
${timeText}`;
const message = await client.messages.create({
model: MODEL,
max_tokens: 512,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userPrompt }],
});
const raw = message.content[0].type === 'text' ? message.content[0].text : '{}';
const json = raw.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
let a: Record<string, any> = {};
try { a = JSON.parse(json); } catch { a = {}; }
await postgresClient.query(`
INSERT INTO veeam_ticket_analysis (
ticket_number, ticket_id, company_id, company_name, device_hostname,
ticket_created_at, ticket_closed_at, same_day_close,
hours_worked, note_count,
problem_category, resolution_type, skills_required, complexity,
device_was_offline, backup_completed_before_tech, preventable,
work_summary, recommended_procedure, model
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
ON CONFLICT (ticket_number) DO UPDATE SET
problem_category = EXCLUDED.problem_category,
resolution_type = EXCLUDED.resolution_type,
skills_required = EXCLUDED.skills_required,
complexity = EXCLUDED.complexity,
device_was_offline = EXCLUDED.device_was_offline,
backup_completed_before_tech = EXCLUDED.backup_completed_before_tech,
preventable = EXCLUDED.preventable,
work_summary = EXCLUDED.work_summary,
recommended_procedure = EXCLUDED.recommended_procedure,
hours_worked = EXCLUDED.hours_worked,
note_count = EXCLUDED.note_count,
analyzed_at = NOW()
`, [
ticket.ticket_number, ticket.ticket_id, ticket.company_id, ticket.company_name,
ticket.device_hostname,
ticket.create_date, ticket.completed_date, sameDayClose,
totalHours, notes.length,
a.problem_category ?? 'other',
a.resolution_type ?? 'other',
a.skills_required ?? [],
a.complexity ?? 'low',
a.device_was_offline ?? null,
a.backup_completed_before_tech ?? null,
a.preventable ?? null,
a.work_summary ?? null,
a.recommended_procedure ?? null,
MODEL,
]);
}
async function runBatch(tickets: any[]): Promise<void> {
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
for (let i = 0; i < tickets.length; i += CONCURRENCY) {
const chunk = tickets.slice(i, i + CONCURRENCY);
await Promise.allSettled(
chunk.map(t =>
analyzeTicket(client, t)
.then(() => { analysisState.done++; })
.catch(err => {
console.error(`[VEEAM-ANALYSIS] ${t.ticket_number}:`, err.message);
analysisState.errors++;
analysisState.done++;
})
)
);
if (i + CONCURRENCY < tickets.length) {
await new Promise(r => setTimeout(r, 150));
}
}
}
export async function POST(req: NextRequest) {
if (analysisState.isRunning) {
return NextResponse.json({
error: 'Analysis already running',
progress: analysisState,
}, { status: 409 });
}
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'ANTHROPIC_API_KEY not configured' }, { status: 503 });
}
const body = await req.json().catch(() => ({}));
const reanalyze = body.reanalyze === true;
const ticketsRes = await postgresClient.query(`
SELECT DISTINCT ON (t.id)
t.id AS ticket_id, t.ticket_number, t.title, t.company_id,
c.company_name, t.create_date, t.completed_date,
(regexp_match(t.title, ' on ([A-Za-z0-9][A-Za-z0-9._-]+) at '))[1] AS device_hostname
FROM tickets t
LEFT JOIN companies c ON c.id = t.company_id
INNER JOIN time_entries te ON te.ticket_id = t.id
${reanalyze ? '' : 'LEFT JOIN veeam_ticket_analysis vta ON vta.ticket_number = t.ticket_number'}
WHERE t.title ILIKE 'Veeam:%'
AND t.title NOT ILIKE '[Veeam RPO]%'
AND t.create_date >= DATE_TRUNC('year', NOW())
${reanalyze ? '' : 'AND vta.ticket_number IS NULL'}
ORDER BY t.id, t.create_date DESC
`);
const tickets = ticketsRes.rows;
if (tickets.length === 0) {
return NextResponse.json({ started: false, message: 'All eligible tickets already analyzed', total: 0 });
}
analysisState.isRunning = true;
analysisState.total = tickets.length;
analysisState.done = 0;
analysisState.errors = 0;
analysisState.startedAt = new Date();
runBatch(tickets).finally(() => { analysisState.isRunning = false; });
return NextResponse.json({ started: true, total: tickets.length });
}

View file

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
import { analysisState } from '@/lib/services/veeam-analysis-state';
export async function GET() {
const [analyzedRes, eligibleRes] = await Promise.all([
postgresClient.query(`
SELECT COUNT(*) AS total_analyzed, MAX(analyzed_at) AS last_analyzed_at
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
`),
postgresClient.query(`
SELECT COUNT(DISTINCT t.id) AS total_eligible
FROM tickets t
INNER JOIN time_entries te ON te.ticket_id = t.id
WHERE t.title ILIKE 'Veeam:%'
AND t.title NOT ILIKE '[Veeam RPO]%'
AND t.create_date >= DATE_TRUNC('year', NOW())
`),
]);
return NextResponse.json({
is_running: analysisState.isRunning,
run_total: analysisState.total,
run_done: analysisState.done,
run_errors: analysisState.errors,
run_started_at: analysisState.startedAt,
total_analyzed: parseInt(analyzedRes.rows[0]?.total_analyzed ?? 0),
total_eligible: parseInt(eligibleRes.rows[0]?.total_eligible ?? 0),
last_analyzed_at: analyzedRes.rows[0]?.last_analyzed_at ?? null,
});
}

View file

@ -0,0 +1,185 @@
import { NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import postgresClient from '@/lib/services/postgres-client';
const MODEL = 'claude-sonnet-4-6';
const SYSTEM = 'You are a senior MSP consultant. Respond with valid JSON only — no markdown fences, no prose before or after the JSON object.';
export async function POST() {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: 'ANTHROPIC_API_KEY not configured' }, { status: 503 });
}
try {
const [statsRes, categoryRes, resolutionRes, skillsRes, complexityRes, proceduresRes] =
await Promise.all([
postgresClient.query(`
SELECT
COUNT(*) AS total_analyzed,
ROUND(AVG(hours_worked)::numeric, 2) AS avg_hours,
ROUND(100.0 * COUNT(*) FILTER (WHERE same_day_close)
/ NULLIF(COUNT(*), 0), 1) AS same_day_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE preventable = true)
/ NULLIF(COUNT(*) FILTER (WHERE preventable IS NOT NULL), 0), 1) AS preventable_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE device_was_offline = true)
/ NULLIF(COUNT(*) FILTER (WHERE device_was_offline IS NOT NULL), 0), 1) AS offline_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE backup_completed_before_tech = true)
/ NULLIF(COUNT(*) FILTER (WHERE backup_completed_before_tech IS NOT NULL), 0), 1) AS auto_resolved_pct,
SUM(hours_worked) AS total_hours
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
`),
postgresClient.query(`
SELECT problem_category,
COUNT(*) AS count,
ROUND(AVG(hours_worked)::numeric, 2) AS avg_hours,
ROUND(100.0 * COUNT(*) FILTER (WHERE same_day_close)
/ NULLIF(COUNT(*), 0), 1) AS same_day_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE preventable = true)
/ NULLIF(COUNT(*) FILTER (WHERE preventable IS NOT NULL), 0), 1) AS preventable_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE device_was_offline = true)
/ NULLIF(COUNT(*) FILTER (WHERE device_was_offline IS NOT NULL), 0), 1) AS offline_pct
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY problem_category
ORDER BY count DESC
`),
postgresClient.query(`
SELECT resolution_type, COUNT(*) AS count
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY resolution_type ORDER BY count DESC
`),
postgresClient.query(`
SELECT skill, COUNT(*) AS count
FROM veeam_ticket_analysis, unnest(skills_required) AS skill
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY skill ORDER BY count DESC LIMIT 20
`),
postgresClient.query(`
SELECT complexity, COUNT(*) AS count
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
GROUP BY complexity
ORDER BY CASE complexity WHEN 'trivial' THEN 1 WHEN 'low' THEN 2 WHEN 'medium' THEN 3 WHEN 'high' THEN 4 ELSE 5 END
`),
// Sample recommended procedures per category (up to 3 per category)
postgresClient.query(`
SELECT problem_category, recommended_procedure
FROM (
SELECT problem_category, recommended_procedure,
ROW_NUMBER() OVER (PARTITION BY problem_category ORDER BY analyzed_at DESC) AS rn
FROM veeam_ticket_analysis
WHERE ticket_created_at >= DATE_TRUNC('year', NOW())
AND recommended_procedure IS NOT NULL AND recommended_procedure != ''
) ranked
WHERE rn <= 3
ORDER BY problem_category, rn
`),
]);
const stats = statsRes.rows[0];
const categories = categoryRes.rows;
const resolutions = resolutionRes.rows;
const skills = skillsRes.rows;
const complexity = complexityRes.rows;
const procedures = proceduresRes.rows;
if (parseInt(stats.total_analyzed) === 0) {
return NextResponse.json({ error: 'No analyzed tickets yet — run the analysis first.' }, { status: 400 });
}
const procByCategory: Record<string, string[]> = {};
for (const p of procedures) {
if (!procByCategory[p.problem_category]) procByCategory[p.problem_category] = [];
procByCategory[p.problem_category].push(p.recommended_procedure);
}
const prompt = `Review this YTD backup ticket data for a managed services provider and produce a practical operations summary.
## Dataset
- Tickets analyzed: ${stats.total_analyzed} (YTD, all had tech time logged)
- Total tech time: ${parseFloat(stats.total_hours ?? 0).toFixed(1)}h
- Avg hours per ticket: ${stats.avg_hours}h
- Same-day close rate: ${stats.same_day_pct}%
- Device was offline at alert time: ${stats.offline_pct}%
- Backup auto-completed before tech action: ${stats.auto_resolved_pct}%
- Assessed as preventable: ${stats.preventable_pct}%
## Problem Categories
${categories.map(c => `- ${c.problem_category}: ${c.count} tickets, avg ${c.avg_hours}h, ${c.same_day_pct}% same-day close, ${c.offline_pct ?? 0}% offline at time`).join('\n')}
## Resolution Types
${resolutions.map(r => `- ${r.resolution_type}: ${r.count} tickets`).join('\n')}
## Complexity Breakdown
${complexity.map(c => `- ${c.complexity}: ${c.count} tickets`).join('\n')}
## Top Skills Required (by frequency)
${skills.map(s => `- ${s.skill}: ${s.count} tickets`).join('\n')}
## Sample AI-Generated SOP Steps (per category)
${Object.entries(procByCategory).map(([cat, procs]) =>
`${cat}:\n${procs.map(p => `${p}`).join('\n')}`
).join('\n')}
---
Return a JSON object with exactly this structure:
{
"headline": "one sentence executive summary",
"key_findings": ["3-4 bullet point findings with specific numbers"],
"issue_breakdown": [
{
"category": "exact machine key from Problem Categories above (e.g. device_offline, agent_issue)",
"insight": "1-2 sentence insight about this category",
"sop": "concrete, actionable SOP recommendation for handling this type"
}
],
"skills_assessment": "2-3 sentences on the skills picture — what's needed most, any gaps",
"quick_wins": ["2-3 specific things the MSP could do to reduce ticket volume or time spent"],
"automation_opportunities": "1-2 sentences on what could be automated or suppressed",
"training_priority": "one sentence on the highest-value training investment"
}
Be specific and direct. Reference actual numbers from the data. Avoid generic MSP advice.`;
const client = new Anthropic({ apiKey });
const message = await client.messages.create({
model: MODEL,
max_tokens: 4000,
system: SYSTEM,
messages: [{ role: 'user', content: prompt }],
});
if (message.stop_reason === 'max_tokens') {
console.error('[VEEAM-SUMMARY] Response truncated at max_tokens');
return NextResponse.json({ error: 'Model response was truncated — try again' }, { status: 502 });
}
const raw = message.content[0].type === 'text' ? message.content[0].text : '{}';
const start = raw.indexOf('{');
const end = raw.lastIndexOf('}');
const json = start !== -1 && end > start ? raw.slice(start, end + 1) : raw;
let analysis: Record<string, any> = {};
try {
analysis = JSON.parse(json);
} catch (e) {
console.error('[VEEAM-SUMMARY] JSON parse failed. Raw response:', raw);
return NextResponse.json({ error: 'Model returned non-JSON response', raw }, { status: 502 });
}
return NextResponse.json({ analysis, model: MODEL, generated_at: new Date().toISOString() });
} catch (e: any) {
console.error('[VEEAM-SUMMARY] Unexpected error:', e);
return NextResponse.json({ error: e.message ?? 'Internal server error' }, { status: 500 });
}
}

View file

@ -9,7 +9,7 @@ import { CompanyBackupTable, CompanyBackupRow } from '@/components/backup/compan
import { ComplianceSummaryCards } from '@/components/backup/compliance-summary-cards';
import { ComplianceDetailTable } from '@/components/backup/compliance-detail-table';
import { ContractCoverageTable } from '@/components/backup/contract-coverage-table';
import { RefreshCw, CheckCircle2, AlertTriangle, XCircle, Clock } from 'lucide-react';
import { RefreshCw, CheckCircle2, AlertTriangle, XCircle, Clock, WifiOff } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RpoJobSummary } from '@/lib/services/veeam-rpo-service';
@ -30,6 +30,7 @@ interface RpoData {
total: number;
healthy: number;
breached: number;
offlineSuppressed: number;
withOpenTicket: number;
critical: number;
high: number;
@ -37,6 +38,19 @@ interface RpoData {
jobs: RpoJobSummary[];
}
interface OfflineLogRow {
id: number;
job_name: string;
org_name: string;
rmm_hostname: string;
rmm_site_name: string;
device_type_category: string;
rmm_last_seen: string | null;
hours_offline: number;
backup_interval_hours: number;
checked_at: string;
}
interface ComplianceData {
summary: {
totalContractedDevices: number;
@ -71,21 +85,24 @@ export default function BackupStatusPage() {
const [companies, setCompanies] = useState<CompanyBackupRow[]>([]);
const [compliance, setCompliance] = useState<ComplianceData | null>(null);
const [rpo, setRpo] = useState<RpoData | null>(null);
const [offlineLog, setOfflineLog] = useState<OfflineLogRow[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const fetchData = async () => {
try {
const [statusRes, companiesRes, complianceRes, rpoRes] = await Promise.all([
const [statusRes, companiesRes, complianceRes, rpoRes, offlineLogRes] = await Promise.all([
fetch('/api/veeam/backup-status').then(r => r.json()),
fetch('/api/veeam/companies').then(r => r.json()),
fetch('/api/veeam/compliance').then(r => r.json()),
fetch('/api/veeam/rpo-check').then(r => r.json()),
fetch('/api/veeam/rpo-offline-log?limit=200').then(r => r.json()),
]);
setStatus(statusRes);
setCompanies(Array.isArray(companiesRes) ? companiesRes : []);
setCompliance(complianceRes);
setRpo(rpoRes);
setOfflineLog(offlineLogRes.rows ?? []);
} catch (error) {
console.error('Failed to fetch backup status:', error);
} finally {
@ -156,6 +173,14 @@ export default function BackupStatusPage() {
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="offline-log">
Offline Suppressed
{rpo && (rpo.summary.offlineSuppressed ?? 0) > 0 && (
<Badge variant="secondary" className="ml-2 h-5 px-1.5 text-xs">
{rpo.summary.offlineSuppressed}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="compliance">
Contract Compliance
{compliance && (compliance.summary.contractedNotBackedUp > 0 || compliance.summary.backedUpNotContracted > 0) && (
@ -233,6 +258,16 @@ export default function BackupStatusPage() {
<p className="text-xs text-muted-foreground">{rpo.summary.high} high priority</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Offline Suppressed</CardTitle>
<WifiOff className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rpo.summary.offlineSuppressed ?? 0}</div>
<p className="text-xs text-muted-foreground">breached but device offline</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Compliance Rate</CardTitle>
@ -255,6 +290,7 @@ export default function BackupStatusPage() {
<th className="px-4 py-3 text-left font-medium">Job</th>
<th className="px-4 py-3 text-left font-medium">Organization</th>
<th className="px-4 py-3 text-left font-medium">Last Backup</th>
<th className="px-4 py-3 text-left font-medium">RMM Device</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Ticket</th>
<th className="px-4 py-3 text-left font-medium">Failure Reason</th>
@ -266,8 +302,27 @@ export default function BackupStatusPage() {
<td className="px-4 py-3 font-medium">{job.job_name}</td>
<td className="px-4 py-3 text-muted-foreground">{job.org_name}</td>
<td className="px-4 py-3 text-muted-foreground">{timeAgoHours(job.hours_since_backup)}</td>
<td className="px-4 py-3 text-xs">
{job.rmm_hostname ? (
<div>
<span className="font-mono">{job.rmm_hostname}</span>
{job.is_offline_suppressed && (
<div className="flex items-center gap-1 mt-0.5 text-muted-foreground">
<WifiOff className="h-3 w-3" />
<span>offline {timeAgo(job.rmm_last_seen)}</span>
</div>
)}
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3">
{job.is_breached ? (
{job.is_offline_suppressed ? (
<Badge variant="secondary" className="flex items-center gap-1 w-fit">
<WifiOff className="h-3 w-3" />Offline
</Badge>
) : job.is_breached ? (
<Badge variant="destructive">Breached</Badge>
) : (
<Badge variant="outline" className="text-green-600 border-green-600">Healthy</Badge>
@ -292,7 +347,7 @@ export default function BackupStatusPage() {
))}
{rpo.jobs.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</td>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No workstation jobs found</td>
</tr>
)}
</tbody>
@ -302,6 +357,52 @@ export default function BackupStatusPage() {
)}
</TabsContent>
<TabsContent value="offline-log" className="space-y-4">
<p className="text-sm text-muted-foreground">
Workstation backup jobs suppressed during the last RPO check because the device was offline longer than its backup interval.
No Autotask ticket is created while the device is offline.
</p>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Device</th>
<th className="px-4 py-3 text-left font-medium">Job</th>
<th className="px-4 py-3 text-left font-medium">Organization</th>
<th className="px-4 py-3 text-left font-medium">Type</th>
<th className="px-4 py-3 text-left font-medium">Last Seen</th>
<th className="px-4 py-3 text-left font-medium">Offline</th>
<th className="px-4 py-3 text-left font-medium">Checked</th>
</tr>
</thead>
<tbody>
{offlineLog.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-mono text-xs">{row.rmm_hostname}</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[180px] truncate">{row.job_name}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{row.org_name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="text-xs">{row.device_type_category}</Badge>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{timeAgo(row.rmm_last_seen)}</td>
<td className="px-4 py-3 text-xs">
{row.hours_offline >= 48
? `${Math.round(row.hours_offline / 24)}d`
: `${Math.round(row.hours_offline)}h`}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{timeAgo(row.checked_at)}</td>
</tr>
))}
{offlineLog.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">No offline suppressions logged yet</td>
</tr>
)}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="compliance" className="space-y-6">
{compliance && (
<>

733
app/veeam-analysis/page.tsx Normal file
View file

@ -0,0 +1,733 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
} from 'recharts';
import {
Brain, Play, RefreshCw, CheckCircle2, AlertTriangle, Clock,
WifiOff, ChevronDown, ChevronRight, Wrench, RotateCcw, Sparkles, Zap, BookOpen, GraduationCap,
} from 'lucide-react';
// ── Types ─────────────────────────────────────────────────────────────────────
interface Stats {
total_analyzed: string;
total_ytd: string;
avg_hours: string;
same_day_pct: string;
preventable_pct: string;
offline_pct: string;
auto_resolved_pct: string;
}
interface CategoryRow { problem_category: string; count: string; avg_hours: string; same_day_pct: string }
interface ResolutionRow { resolution_type: string; count: string }
interface SkillRow { skill: string; count: string }
interface ComplexityRow { complexity: string; count: string }
interface AnalysisData {
stats: Stats;
by_category: CategoryRow[];
by_resolution: ResolutionRow[];
skills: SkillRow[];
by_complexity: ComplexityRow[];
tickets: TicketRow[];
total: number;
limit: number;
page: number;
}
interface TicketRow {
ticket_number: string;
company_name: string | null;
device_hostname: string | null;
ticket_created_at: string | null;
ticket_closed_at: string | null;
same_day_close: boolean;
hours_worked: string;
problem_category: string;
resolution_type: string;
complexity: string;
device_was_offline: boolean | null;
backup_completed_before_tech: boolean | null;
preventable: boolean | null;
work_summary: string | null;
recommended_procedure: string | null;
}
interface RunStatus {
is_running: boolean;
run_total: number;
run_done: number;
run_errors: number;
total_analyzed: number;
total_eligible: number;
last_analyzed_at: string | null;
}
// ── Config ────────────────────────────────────────────────────────────────────
const CATEGORY_CFG: Record<string, { label: string; color: string }> = {
device_offline: { label: 'Device Offline', color: '#94a3b8' },
agent_issue: { label: 'Agent Issue', color: '#f97316' },
job_failed: { label: 'Job Failed', color: '#ef4444' },
storage_issue: { label: 'Storage Issue', color: '#f59e0b' },
network_issue: { label: 'Network Issue', color: '#3b82f6' },
authentication: { label: 'Authentication', color: '#8b5cf6' },
software_error: { label: 'Software Error', color: '#ec4899' },
self_resolved: { label: 'Self-Resolved', color: '#22c55e' },
configuration: { label: 'Configuration', color: '#06b6d4' },
other: { label: 'Other', color: '#6b7280' },
};
const RESOLUTION_CFG: Record<string, string> = {
no_action_needed: 'No Action Needed',
device_powered_on: 'Device Powered On',
backup_restarted: 'Backup Restarted',
agent_reinstalled: 'Agent Reinstalled',
storage_cleared: 'Storage Cleared',
settings_updated: 'Settings Updated',
escalated: 'Escalated',
other: 'Other',
};
const COMPLEXITY_COLOR: Record<string, string> = {
trivial: '#22c55e',
low: '#84cc16',
medium: '#f59e0b',
high: '#ef4444',
};
interface SummaryAnalysis {
headline: string;
key_findings: string[];
issue_breakdown: { category: string; insight: string; sop: string }[];
skills_assessment: string;
quick_wins: string[];
automation_opportunities: string;
training_priority: string;
}
interface SummaryData {
analysis: SummaryAnalysis;
model: string;
generated_at: string;
}
function catLabel(k: string) { return CATEGORY_CFG[k]?.label ?? k; }
function catColor(k: string) { return CATEGORY_CFG[k]?.color ?? '#6b7280'; }
function resLabel(k: string) { return RESOLUTION_CFG[k] ?? k; }
function timeAgo(d: string | null) {
if (!d) return '—';
const h = Math.floor((Date.now() - new Date(d).getTime()) / 3_600_000);
if (h < 1) return 'Just now';
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// ── Expandable ticket row ─────────────────────────────────────────────────────
function TicketRow({ t, categoryFilter, onFilter }: {
t: TicketRow;
categoryFilter: string;
onFilter: (cat: string) => void;
}) {
const [open, setOpen] = useState(false);
const catCfg = CATEGORY_CFG[t.problem_category];
return (
<>
<tr
className="border-b hover:bg-muted/10 cursor-pointer text-xs align-middle"
onClick={() => setOpen(o => !o)}
>
<td className="pl-3 pr-2 py-2 w-6">
{open
? <ChevronDown className="h-3 w-3 text-muted-foreground" />
: <ChevronRight className="h-3 w-3 text-muted-foreground" />}
</td>
<td className="pr-3 py-2 font-mono font-medium">{t.ticket_number}</td>
<td className="px-3 py-2 max-w-[140px] truncate">{t.company_name ?? '—'}</td>
<td className="px-3 py-2 font-mono text-[11px]">{t.device_hostname ?? '—'}</td>
<td className="px-3 py-2">
<Badge
variant="outline"
style={{ borderColor: catCfg?.color, color: catCfg?.color }}
className="text-[10px] h-4 px-1.5 whitespace-nowrap"
>
{catLabel(t.problem_category)}
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{resLabel(t.resolution_type)}</td>
<td className="px-3 py-2 text-center">
{t.same_day_close
? <CheckCircle2 className="h-3.5 w-3.5 text-green-500 mx-auto" />
: <span className="text-muted-foreground"></span>}
</td>
<td className="px-3 py-2 text-right">{parseFloat(t.hours_worked).toFixed(2)}h</td>
<td className="px-3 py-2">
<Badge
variant="outline"
style={{ borderColor: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280', color: COMPLEXITY_COLOR[t.complexity] ?? '#6b7280' }}
className="text-[10px] h-4 px-1.5"
>
{t.complexity}
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{timeAgo(t.ticket_created_at)}</td>
</tr>
{open && (
<tr className="border-b bg-muted/5">
<td colSpan={10} className="px-8 pb-3 pt-2">
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
{t.work_summary && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Summary</span>
<p className="mt-0.5">{t.work_summary}</p>
</div>
)}
<div className="flex flex-wrap gap-3 text-muted-foreground">
{t.device_was_offline != null && (
<span className="flex items-center gap-1">
<WifiOff className="h-3 w-3" />
{t.device_was_offline ? 'Device was offline' : 'Device was online'}
</span>
)}
{t.backup_completed_before_tech != null && (
<span className="flex items-center gap-1">
{t.backup_completed_before_tech
? <><CheckCircle2 className="h-3 w-3 text-green-500" /> Backup auto-completed</>
: <><Wrench className="h-3 w-3" /> Tech action required</>}
</span>
)}
{t.preventable != null && (
<span className={t.preventable ? 'text-amber-500' : ''}>
{t.preventable ? '⚠ Preventable' : '✓ Not preventable'}
</span>
)}
</div>
</div>
{t.recommended_procedure && (
<div>
<span className="text-muted-foreground uppercase tracking-wide text-[10px] font-medium">Recommended SOP</span>
<p className="mt-0.5 text-muted-foreground italic">{t.recommended_procedure}</p>
</div>
)}
</div>
</td>
</tr>
)}
</>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function VeeamAnalysisPage() {
const [data, setData] = useState<AnalysisData | null>(null);
const [status, setStatus] = useState<RunStatus | null>(null);
const [loading, setLoading] = useState(true);
const [catFilter, setCatFilter] = useState('');
const [page, setPage] = useState(1);
const [summary, setSummary] = useState<SummaryData | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchData = useCallback(async (cat = catFilter, p = page) => {
const params = new URLSearchParams({ page: String(p) });
if (cat) params.set('category', cat);
const res = await fetch(`/api/veeam/ticket-analysis?${params}`);
setData(await res.json());
setLoading(false);
}, [catFilter, page]);
const fetchStatus = useCallback(async () => {
const res = await fetch('/api/veeam/ticket-analysis/status');
const s: RunStatus = await res.json();
setStatus(s);
return s;
}, []);
useEffect(() => { fetchData(); fetchStatus(); }, []);
const startPolling = useCallback(() => {
if (pollRef.current) return;
pollRef.current = setInterval(async () => {
const s = await fetchStatus();
if (!s.is_running) {
clearInterval(pollRef.current!);
pollRef.current = null;
fetchData();
}
}, 2000);
}, [fetchStatus, fetchData]);
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const handleRun = async (reanalyze = false) => {
const res = await fetch('/api/veeam/ticket-analysis/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reanalyze }),
});
const json = await res.json();
if (json.started) {
await fetchStatus();
startPolling();
}
};
const handleSummary = async () => {
setSummaryLoading(true);
setSummaryError(null);
try {
const res = await fetch('/api/veeam/ticket-analysis/summary', { method: 'POST' });
const json = await res.json();
if (json.error) setSummaryError(json.raw ? `${json.error}\n\n${json.raw}` : json.error);
else setSummary(json);
} catch (e: any) {
setSummaryError(e.message);
} finally {
setSummaryLoading(false);
}
};
const handleCatFilter = (cat: string) => {
const next = catFilter === cat ? '' : cat;
setCatFilter(next);
setPage(1);
setLoading(true);
fetchData(next, 1);
};
const totalPages = data ? Math.ceil(data.total / (data.limit ?? 50)) : 1;
const runPct = status?.is_running && status.run_total > 0
? Math.round(100 * status.run_done / status.run_total)
: null;
return (
<div className="container mx-auto px-6 py-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Brain className="h-6 w-6" />
Veeam Backup Ticket Analysis
</h1>
<p className="text-sm text-muted-foreground mt-1">
AI classification of YTD Veeam tickets to identify failure patterns, required skills, and SOP gaps.
</p>
</div>
<div className="flex items-center gap-2">
{status && !status.is_running && status.total_analyzed > 0 && (
<Button variant="outline" size="sm" onClick={() => handleRun(true)}>
<RotateCcw className="h-3.5 w-3.5 mr-1" />
Re-analyze all
</Button>
)}
<Button
size="sm"
onClick={() => handleRun(false)}
disabled={status?.is_running}
>
{status?.is_running
? <><RefreshCw className="h-3.5 w-3.5 mr-1 animate-spin" />Running</>
: <><Play className="h-3.5 w-3.5 mr-1" />
{status && status.total_analyzed < status.total_eligible ? 'Continue Analysis' : 'Run Analysis'}
</>}
</Button>
</div>
</div>
{/* Progress bar */}
{status?.is_running && runPct !== null && (
<div className="space-y-1">
<Progress value={runPct} className="h-2" />
<p className="text-xs text-muted-foreground">
{status.run_done} / {status.run_total} tickets analyzed
{status.run_errors > 0 && ` · ${status.run_errors} errors`}
</p>
</div>
)}
{/* Status line */}
{status && !status.is_running && (
<p className="text-xs text-muted-foreground -mt-4">
{status.total_analyzed} of {status.total_eligible} eligible tickets analyzed
{status.last_analyzed_at && ` · Last run ${timeAgo(status.last_analyzed_at)}`}
{status.total_eligible === 0 && ' — no Veeam tickets with time entries found YTD'}
</p>
)}
{status?.total_analyzed === 0 && !status.is_running ? (
<div className="rounded-md border border-dashed py-16 text-center text-muted-foreground">
<Brain className="h-8 w-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">No analysis data yet.</p>
<p className="text-xs mt-1">Click <strong>Run Analysis</strong> to classify {status.total_eligible} tickets with time entries.</p>
</div>
) : (
<>
{/* Summary cards */}
{loading && !data ? (
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
) : data && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[
{
label: 'Tickets Analyzed',
value: data.stats.total_analyzed,
sub: `of ${data.stats.total_ytd} YTD`,
icon: Brain,
cls: '',
},
{
label: 'Same-Day Close',
value: `${data.stats.same_day_pct ?? 0}%`,
sub: 'Opened & closed same day',
icon: CheckCircle2,
cls: 'text-green-500',
},
{
label: 'Avg Hours/Ticket',
value: `${data.stats.avg_hours ?? 0}h`,
sub: `${data.stats.auto_resolved_pct ?? 0}% auto-resolved before tech`,
icon: Clock,
cls: '',
},
{
label: 'Preventable',
value: `${data.stats.preventable_pct ?? 0}%`,
sub: `${data.stats.offline_pct ?? 0}% device was offline`,
icon: AlertTriangle,
cls: 'text-amber-500',
},
].map(({ label, value, sub, icon: Icon, cls }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className={`h-4 w-4 ${cls || 'text-muted-foreground'}`} />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${cls}`}>{value}</div>
<p className="text-xs text-muted-foreground">{sub}</p>
</CardContent>
</Card>
))}
</div>
)}
{/* Charts row */}
{data && (
<div className="grid gap-6 lg:grid-cols-2">
{/* Problem categories */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Problem Categories</CardTitle>
<p className="text-xs text-muted-foreground">Click a bar to filter the ticket list</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={260}>
<BarChart
data={data.by_category.map(r => ({
name: catLabel(r.problem_category),
key: r.problem_category,
count: parseInt(r.count),
avg_hours: parseFloat(r.avg_hours),
}))}
layout="vertical"
margin={{ left: 10, right: 20, top: 0, bottom: 0 }}
>
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={110} />
<Tooltip
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="count" radius={[0, 3, 3, 0]} onClick={(d) => handleCatFilter(String(d.key ?? ''))}>
{data.by_category.map((r, i) => (
<Cell
key={i}
fill={catColor(r.problem_category)}
opacity={catFilter && catFilter !== r.problem_category ? 0.3 : 1}
cursor="pointer"
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Right column: Resolution + Skills */}
<div className="space-y-4">
{/* Resolution types */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Resolution Types</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{data.by_resolution.map(r => {
const total = data.by_resolution.reduce((s, x) => s + parseInt(x.count), 0);
const pct = total > 0 ? Math.round(100 * parseInt(r.count) / total) : 0;
return (
<div key={r.resolution_type} className="flex items-center gap-2 text-xs">
<span className="w-36 text-muted-foreground truncate">{resLabel(r.resolution_type)}</span>
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-primary h-1.5 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-right font-medium">{r.count}</span>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Skills */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Skills Required</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1.5">
{data.skills.map(s => (
<Badge key={s.skill} variant="secondary" className="text-[11px]">
{s.skill}
<span className="ml-1.5 text-muted-foreground">{s.count}</span>
</Badge>
))}
</div>
</CardContent>
</Card>
</div>
</div>
)}
{/* Sonnet Summary */}
{data && parseInt(data.stats.total_analyzed) > 0 && (
<Card>
<CardHeader className="pb-3 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-500" />
Operations Summary
</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">
Sonnet analysis of aggregate patterns issue types, skills, and SOPs
</p>
</div>
<Button
variant={summary ? 'outline' : 'default'}
size="sm"
onClick={handleSummary}
disabled={summaryLoading}
>
{summaryLoading
? <><RefreshCw className="h-3.5 w-3.5 mr-1.5 animate-spin" />Analyzing</>
: summary
? <><RotateCcw className="h-3.5 w-3.5 mr-1.5" />Refresh</>
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" />Generate Summary</>}
</Button>
</CardHeader>
{summaryError && (
<CardContent>
<p className="text-sm text-destructive">{summaryError}</p>
</CardContent>
)}
{summaryLoading && !summary && (
<CardContent className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-full" />
</CardContent>
)}
{summary && (
<CardContent className="space-y-6 pt-0">
{/* Headline */}
<p className="text-sm font-medium">{summary.analysis.headline}</p>
{/* Key findings */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Key Findings
</h3>
<ul className="space-y-1">
{summary.analysis.key_findings?.map((f, i) => (
<li key={i} className="text-sm flex gap-2">
<span className="text-muted-foreground mt-0.5"></span>
<span>{f}</span>
</li>
))}
</ul>
</div>
{/* Issue breakdown */}
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
Issue Breakdown &amp; SOPs
</h3>
<div className="space-y-3">
{summary.analysis.issue_breakdown?.map((item, i) => (
<div key={i} className="rounded-md border px-4 py-3 space-y-1">
<div className="flex items-center gap-2">
<Badge
variant="outline"
style={{ borderColor: catColor(item.category), color: catColor(item.category) }}
className="text-[10px] h-4 px-1.5"
>
{catLabel(item.category)}
</Badge>
</div>
<p className="text-sm">{item.insight}</p>
<div className="flex items-start gap-1.5 text-xs text-muted-foreground bg-muted/40 rounded px-2.5 py-1.5">
<BookOpen className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span><span className="font-medium text-foreground">SOP:</span> {item.sop}</span>
</div>
</div>
))}
</div>
</div>
{/* Bottom row: skills + quick wins + automation */}
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<GraduationCap className="h-3.5 w-3.5" />Skills Assessment
</h3>
<p className="text-sm text-muted-foreground">{summary.analysis.skills_assessment}</p>
{summary.analysis.training_priority && (
<p className="text-xs border-l-2 border-blue-500/50 pl-2 text-muted-foreground italic">
{summary.analysis.training_priority}
</p>
)}
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5" />Quick Wins
</h3>
<ul className="space-y-1">
{summary.analysis.quick_wins?.map((w, i) => (
<li key={i} className="text-sm flex gap-2">
<span className="text-muted-foreground mt-0.5"></span>
<span>{w}</span>
</li>
))}
</ul>
</div>
<div className="space-y-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Brain className="h-3.5 w-3.5" />Automation Opportunities
</h3>
<p className="text-sm text-muted-foreground">{summary.analysis.automation_opportunities}</p>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
Generated by {summary.model} · {new Date(summary.generated_at).toLocaleString()}
</p>
</CardContent>
)}
</Card>
)}
{/* Ticket table */}
{data && (
<Card>
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<div>
<CardTitle className="text-sm font-medium">
Tickets
{catFilter && (
<Badge variant="secondary" className="ml-2 text-[10px]">
{catLabel(catFilter)}
<button onClick={() => handleCatFilter(catFilter)} className="ml-1 hover:text-destructive">×</button>
</Badge>
)}
</CardTitle>
<p className="text-xs text-muted-foreground mt-0.5">{data.total} tickets</p>
</div>
<Button variant="ghost" size="sm" onClick={() => fetchData()}>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-xs">
<th className="w-6" />
<th className="px-3 py-2.5 text-left font-medium">Ticket</th>
<th className="px-3 py-2.5 text-left font-medium">Client</th>
<th className="px-3 py-2.5 text-left font-medium">Device</th>
<th className="px-3 py-2.5 text-left font-medium">Category</th>
<th className="px-3 py-2.5 text-left font-medium">Resolution</th>
<th className="px-3 py-2.5 text-center font-medium">Same-day</th>
<th className="px-3 py-2.5 text-right font-medium">Hours</th>
<th className="px-3 py-2.5 text-left font-medium">Complexity</th>
<th className="px-3 py-2.5 text-left font-medium">Age</th>
</tr>
</thead>
<tbody>
{data.tickets.length > 0
? data.tickets.map(t => (
<TicketRow
key={t.ticket_number}
t={t}
categoryFilter={catFilter}
onFilter={handleCatFilter}
/>
))
: (
<tr>
<td colSpan={10} className="px-4 py-10 text-center text-sm text-muted-foreground">
No analyzed tickets yet run the analysis above.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t text-xs text-muted-foreground">
<span>Page {page} of {totalPages}</span>
<div className="flex gap-2">
<Button
variant="outline" size="sm"
disabled={page <= 1}
onClick={() => { setPage(p => p - 1); fetchData(catFilter, page - 1); }}
>Previous</Button>
<Button
variant="outline" size="sm"
disabled={page >= totalPages}
onClick={() => { setPage(p => p + 1); fetchData(catFilter, page + 1); }}
>Next</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</>
)}
</div>
);
}

View file

@ -0,0 +1,575 @@
'use client';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
import {
RefreshCw, CheckCircle2, AlertTriangle, WifiOff, GitCompare,
Ticket, ChevronRight, ChevronDown, Sparkles, Loader2,
} from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
// ── Types ─────────────────────────────────────────────────────────────────────
type MatchStatus = 'both' | 'pulse_only' | 'datto_only' | 'offline_suppressed';
interface AtTicket {
ticket_number: string;
title: string;
status: number | null;
priority: number | null;
created_at: string | null;
completed_at: string | null;
datto_source: string | null;
}
interface MatchRow {
key: string;
hostname: string | null;
org_name: string | null;
company_id: number | null;
status: MatchStatus;
pulse: {
job_name: string;
priority_level: string;
hours_overdue: number;
failure_category: string | null;
opened_at: string;
} | null;
offline: { hours_offline: number; last_suppressed: string } | null;
at_ticket_count: number;
at_open_count: number;
at_tickets: AtTicket[];
}
interface ClientGroup {
org_name: string | null;
company_id: number | null;
rows: MatchRow[];
counts: Record<MatchStatus, number>;
totalAtTickets: number;
totalAtOpen: number;
}
interface ComparisonData {
period: string;
summary: {
pulse_open: number;
datto_at_total: number;
datto_at_open: number;
both: number;
pulse_only: number;
datto_only: number;
offline_suppressed: number;
};
matches: MatchRow[];
}
interface AnalysisResult {
ticket_number: string;
hostname: string;
org_name: string;
hours_offline: number | null;
total_hours_logged: number;
note_count: number;
model: string;
analysis: {
would_suppress_correctly?: boolean;
confidence?: string;
work_summary?: string;
reasoning?: string;
recommendation?: string;
raw?: string;
};
}
// ── Constants ─────────────────────────────────────────────────────────────────
const PERIODS = [
{ value: '1d', label: 'Last 24h' },
{ value: '7d', label: 'Last 7d' },
{ value: '14d', label: 'Last 14d' },
{ value: '30d', label: 'Last 30d' },
];
const CLOSED_STATUSES = [5, 29832279, 29832280];
const STATUS_CONFIG: Record<MatchStatus, {
label: string;
badgeVariant: 'default' | 'destructive' | 'secondary' | 'outline';
rowAccent: string;
}> = {
both: { label: 'Both', badgeVariant: 'default', rowAccent: 'border-l-2 border-l-blue-500/50' },
pulse_only: { label: 'Pulse Only', badgeVariant: 'destructive', rowAccent: 'border-l-2 border-l-destructive/50' },
datto_only: { label: 'Datto/AT', badgeVariant: 'secondary', rowAccent: 'border-l-2 border-l-orange-400/50' },
offline_suppressed: { label: 'Offline', badgeVariant: 'outline', rowAccent: 'border-l-2 border-l-muted-foreground/40' },
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
const diff = Date.now() - new Date(dateStr).getTime();
const h = Math.floor(diff / 3_600_000);
if (h < 1) return 'Just now';
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function atStatusOpen(status: number | null): boolean {
return !CLOSED_STATUSES.includes(status ?? -1);
}
function atStatusLabel(status: number | null): string {
const map: Record<number, string> = { 1: 'New', 5: 'Complete', 8: 'In Progress', 47: 'Waiting' };
return map[status ?? -1] ?? (status != null ? `#${status}` : '?');
}
function PriorityBadge({ level }: { level: string }) {
const cls = level === 'critical' ? 'text-destructive border-destructive'
: level === 'high' ? 'text-orange-500 border-orange-500'
: 'text-muted-foreground border-muted-foreground/40';
return <Badge variant="outline" className={`text-[10px] px-1.5 ${cls}`}>{level}</Badge>;
}
// ── Analyze Dialog ────────────────────────────────────────────────────────────
function AnalyzeDialog({
ticket, hostname, orgName, hoursOffline, onClose,
}: {
ticket: AtTicket;
hostname: string;
orgName: string;
hoursOffline?: number;
onClose: () => void;
}) {
const [loading, setLoading] = useState(true);
const [result, setResult] = useState<AnalysisResult | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/veeam/rpo-analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
at_ticket_number: ticket.ticket_number,
hostname,
org_name: orgName,
hours_offline: hoursOffline,
}),
})
.then(r => r.json())
.then(d => { if (d.error) setError(d.error); else setResult(d); })
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [ticket.ticket_number, hostname, orgName, hoursOffline]);
const a = result?.analysis;
const suppressed = a?.would_suppress_correctly;
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-500" />
RPO Suppression Analysis
</DialogTitle>
<DialogDescription>
{ticket.ticket_number} · {hostname} · {orgName}
</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex items-center gap-2 py-8 justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Analyzing ticket work...</span>
</div>
)}
{error && (
<div className="text-sm text-destructive py-4">{error}</div>
)}
{result && a && (
<div className="space-y-4 py-2">
{/* Verdict */}
<div className={`rounded-lg border px-4 py-3 flex items-start gap-3 ${
suppressed ? 'border-green-500/30 bg-green-500/5' : 'border-orange-500/30 bg-orange-500/5'
}`}>
<div className="mt-0.5">
{suppressed
? <CheckCircle2 className="h-5 w-5 text-green-500" />
: <AlertTriangle className="h-5 w-5 text-orange-500" />}
</div>
<div>
<div className="font-medium text-sm">
{suppressed
? 'Suppression would have been correct'
: 'Human intervention was needed'}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
Confidence: {a.confidence ?? 'unknown'}
{result.total_hours_logged > 0 && ` · ${result.total_hours_logged.toFixed(2)}h logged`}
{result.note_count > 0 && ` · ${result.note_count} note${result.note_count !== 1 ? 's' : ''}`}
</div>
</div>
</div>
{/* Work summary */}
{a.work_summary && (
<div>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Work Summary</div>
<p className="text-sm">{a.work_summary}</p>
</div>
)}
{/* Reasoning */}
{a.reasoning && (
<div>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Reasoning</div>
<p className="text-sm text-muted-foreground">{a.reasoning}</p>
</div>
)}
{/* Recommendation */}
{a.recommendation && (
<div className="rounded border px-3 py-2 bg-muted/30">
<p className="text-xs text-muted-foreground">{a.recommendation}</p>
</div>
)}
{a.raw && <pre className="text-xs bg-muted p-3 rounded overflow-auto max-h-40">{a.raw}</pre>}
<div className="text-[10px] text-muted-foreground">Model: {result.model}</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
// ── AT Ticket Cell ────────────────────────────────────────────────────────────
function AtTicketCell({ tickets, ticketCount, hostname, orgName, hoursOffline }: {
tickets: AtTicket[];
ticketCount: number;
hostname: string | null;
orgName: string | null;
hoursOffline?: number;
}) {
const [analyzing, setAnalyzing] = useState<AtTicket | null>(null);
if (tickets.length === 0) return <span className="text-muted-foreground"></span>;
return (
<>
<div className="space-y-1.5">
{tickets.map((t, i) => {
const isOpen = atStatusOpen(t.status);
const isClosed = !isOpen;
return (
<div key={i} className="flex items-center gap-1.5 flex-wrap">
<span className="font-mono font-medium">{t.ticket_number}</span>
<Badge
variant="outline"
className={`text-[10px] h-4 px-1 ${isOpen ? 'text-orange-500 border-orange-500' : 'text-green-600 border-green-600/50'}`}
>
{atStatusLabel(t.status)}
</Badge>
{t.datto_source && (
<span className="text-muted-foreground text-[10px]">{t.datto_source}</span>
)}
<span className="text-muted-foreground text-[10px]">{timeAgo(t.created_at)}</span>
{isClosed && hostname && (
<button
onClick={() => setAnalyzing(t)}
className="inline-flex items-center gap-0.5 text-[10px] text-blue-500 hover:text-blue-400 transition-colors"
>
<Sparkles className="h-2.5 w-2.5" />
Analyze
</button>
)}
</div>
);
})}
{ticketCount > 5 && (
<div className="text-muted-foreground text-[10px]">+{ticketCount - 5} more</div>
)}
</div>
{analyzing && (
<AnalyzeDialog
ticket={analyzing}
hostname={hostname ?? ''}
orgName={orgName ?? ''}
hoursOffline={hoursOffline}
onClose={() => setAnalyzing(null)}
/>
)}
</>
);
}
// ── Client Group Row ──────────────────────────────────────────────────────────
function ClientGroupRow({ group, defaultOpen }: { group: ClientGroup; defaultOpen: boolean }) {
const [open, setOpen] = useState(defaultOpen);
const actionable = group.counts.both + group.counts.pulse_only;
return (
<>
{/* Group summary header — columns align with the detail table below */}
<tr
className="border-b bg-muted/30 hover:bg-muted/50 cursor-pointer select-none"
onClick={() => setOpen(o => !o)}
>
{/* Device col: chevron + org name */}
<td className="pl-3 pr-2 py-2.5 w-44">
<div className="flex items-center gap-1.5">
{open
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />}
<span className="font-semibold text-sm truncate">{group.org_name ?? 'Unknown'}</span>
</div>
</td>
{/* Match col: status pills */}
<td className="px-3 py-2.5 w-36">
<div className="flex flex-wrap gap-1">
{group.counts.both > 0 && (
<Badge variant="default" className="text-[10px] h-4 px-1">{group.counts.both} Both</Badge>
)}
{group.counts.pulse_only > 0 && (
<Badge variant="destructive" className="text-[10px] h-4 px-1">{group.counts.pulse_only} Pulse</Badge>
)}
{group.counts.datto_only > 0 && (
<Badge variant="secondary" className="text-[10px] h-4 px-1">{group.counts.datto_only} Datto</Badge>
)}
{group.counts.offline_suppressed > 0 && (
<Badge variant="outline" className="text-[10px] h-4 px-1">{group.counts.offline_suppressed} Offline</Badge>
)}
</div>
</td>
{/* Pulse shadow col: total devices flagged */}
<td className="px-3 py-2.5 text-xs text-muted-foreground">
{actionable > 0
? <span className="font-medium text-foreground">{actionable} device{actionable !== 1 ? 's' : ''} need attention</span>
: <span>{group.rows.length} device{group.rows.length !== 1 ? 's' : ''}</span>}
</td>
{/* AT tickets col: ticket count */}
<td className="px-3 py-2.5 text-xs text-muted-foreground">
{group.totalAtTickets > 0
? <>{group.totalAtTickets} ticket{group.totalAtTickets !== 1 ? 's' : ''}{group.totalAtOpen > 0 && <span className="text-orange-500 ml-1">· {group.totalAtOpen} open</span>}</>
: <span></span>}
</td>
</tr>
{/* Device detail rows */}
{open && group.rows.map((row) => {
const cfg = STATUS_CONFIG[row.status];
return (
<tr key={row.key} className={`border-b last:border-0 hover:bg-muted/10 align-top text-xs ${cfg.rowAccent}`}>
<td className="pl-9 pr-3 py-2.5 font-mono font-medium w-44 text-[11px]">
{row.hostname ?? <span className="italic text-muted-foreground">unknown</span>}
</td>
<td className="px-3 py-2.5 w-36">
<Badge variant={cfg.badgeVariant} className="text-[10px]">{cfg.label}</Badge>
</td>
<td className="px-3 py-2.5 space-y-0.5 max-w-[240px]">
{row.pulse ? (
<>
<div className="flex items-center gap-1.5">
<PriorityBadge level={row.pulse.priority_level} />
<span className="font-medium">{row.pulse.hours_overdue}h overdue</span>
</div>
<div className="text-muted-foreground truncate">{row.pulse.failure_category ?? '—'}</div>
<div className="text-muted-foreground text-[10px]">since {timeAgo(row.pulse.opened_at)}</div>
</>
) : row.offline ? (
<span className="flex items-center gap-1 text-muted-foreground">
<WifiOff className="h-3 w-3" />offline {Math.round(row.offline.hours_offline)}h
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5">
<AtTicketCell
tickets={row.at_tickets}
ticketCount={row.at_ticket_count}
hostname={row.hostname}
orgName={row.org_name}
hoursOffline={row.offline?.hours_offline}
/>
</td>
</tr>
);
})}
</>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function VeeamComparisonPage() {
const [data, setData] = useState<ComparisonData | null>(null);
const [period, setPeriod] = useState('7d');
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<MatchStatus | 'all'>('all');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/veeam/rpo-comparison?period=${period}`);
setData(await res.json());
} finally {
setLoading(false);
}
}, [period]);
useEffect(() => { fetchData(); }, [fetchData]);
const groups = useMemo<ClientGroup[]>(() => {
if (!data) return [];
const filtered = data.matches.filter(m => filter === 'all' || m.status === filter);
const byOrg = new Map<string, ClientGroup>();
for (const row of filtered) {
const key = row.org_name ?? '(Unknown)';
if (!byOrg.has(key)) {
byOrg.set(key, {
org_name: row.org_name, company_id: row.company_id,
rows: [], counts: { both: 0, pulse_only: 0, datto_only: 0, offline_suppressed: 0 },
totalAtTickets: 0, totalAtOpen: 0,
});
}
const g = byOrg.get(key)!;
g.rows.push(row);
g.counts[row.status]++;
g.totalAtTickets += row.at_ticket_count;
g.totalAtOpen += row.at_open_count;
}
return Array.from(byOrg.values()).sort((a, b) => {
const aScore = (a.counts.both + a.counts.pulse_only) > 0 ? 1 : 0;
const bScore = (b.counts.both + b.counts.pulse_only) > 0 ? 1 : 0;
if (bScore !== aScore) return bScore - aScore;
return (a.org_name ?? '').localeCompare(b.org_name ?? '');
});
}, [data, filter]);
const filteredTotal = data?.matches.filter(m => filter === 'all' || m.status === filter).length ?? 0;
return (
<div className="container mx-auto px-6 py-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<GitCompare className="h-6 w-6" />
Veeam RPO Shadow vs Datto/AT
</h1>
<p className="text-sm text-muted-foreground mt-1">
What Pulse <em>would</em> ticket (shadow mode) vs what Datto RMM actually created in Autotask.
Click <Sparkles className="h-3 w-3 inline text-blue-500" /> Analyze on any closed ticket to evaluate suppression accuracy.
</p>
</div>
<div className="flex items-center gap-2">
{PERIODS.map(p => (
<Button key={p.value} variant={period === p.value ? 'default' : 'outline'} size="sm" onClick={() => setPeriod(p.value)}>
{p.label}
</Button>
))}
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{loading && !data ? (
<div className="grid gap-4 md:grid-cols-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
) : data && (
<>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{([
{ key: 'both', label: 'Both Agree', icon: CheckCircle2, iconCls: 'text-blue-500', value: data.summary.both, sub: 'Pulse + Datto both flagged', valCls: '' },
{ key: 'pulse_only', label: 'Pulse Only', icon: AlertTriangle,iconCls: 'text-destructive', value: data.summary.pulse_only, sub: 'No AT ticket from Datto', valCls: 'text-destructive' },
{ key: 'datto_only', label: 'Datto / AT Only', icon: Ticket, iconCls: 'text-orange-500', value: data.summary.datto_only, sub: `${data.summary.datto_at_total} total · ${data.summary.datto_at_open} open`, valCls: 'text-orange-500' },
{ key: 'offline_suppressed', label: 'Offline Suppressed',icon: WifiOff, iconCls: 'text-muted-foreground',value: data.summary.offline_suppressed, sub: 'Device offline — suppressed', valCls: '' },
] as const).map(({ key, label, icon: Icon, iconCls, value, sub, valCls }) => (
<Card key={key} className="cursor-pointer hover:bg-muted/30" onClick={() => setFilter(key as any)}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className={`h-4 w-4 ${iconCls}`} />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${valCls}`}>{value}</div>
<p className="text-xs text-muted-foreground">{sub}</p>
</CardContent>
</Card>
))}
</div>
{/* Table */}
<Tabs value={filter} onValueChange={v => setFilter(v as any)}>
<TabsList>
<TabsTrigger value="all">
All <Badge variant="secondary" className="ml-1.5 h-4 px-1 text-[10px]">{data.matches.length}</Badge>
</TabsTrigger>
<TabsTrigger value="both">Both ({data.summary.both})</TabsTrigger>
<TabsTrigger value="pulse_only">Pulse Only ({data.summary.pulse_only})</TabsTrigger>
<TabsTrigger value="datto_only">Datto/AT ({data.summary.datto_only})</TabsTrigger>
<TabsTrigger value="offline_suppressed">Offline ({data.summary.offline_suppressed})</TabsTrigger>
</TabsList>
<TabsContent value={filter} className="mt-4">
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2.5 text-left font-medium text-xs w-44">Device</th>
<th className="px-3 py-2.5 text-left font-medium text-xs w-36">Match</th>
<th className="px-3 py-2.5 text-left font-medium text-xs">Pulse Shadow</th>
<th className="px-3 py-2.5 text-left font-medium text-xs">Autotask Tickets</th>
</tr>
</thead>
<tbody>
{groups.length > 0 ? groups.map(group => (
<ClientGroupRow
key={group.org_name ?? 'unknown'}
group={group}
defaultOpen={(group.counts.both + group.counts.pulse_only) > 0}
/>
)) : (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-sm text-muted-foreground">
{data.matches.length === 0
? 'No data yet — RPO check must run at least once.'
: 'No rows match this filter.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{groups.length > 0 && (
<p className="text-xs text-muted-foreground mt-2 pl-1">
{groups.length} client{groups.length !== 1 ? 's' : ''} · {filteredTotal} device{filteredTotal !== 1 ? 's' : ''}
</p>
)}
</TabsContent>
</Tabs>
</>
)}
</div>
);
}