feat(03-01): rewrite /api/mobile/dashboard to return kpis/needsAttention/workers shape
- Exports MobileDashboardResponse, KpiResponse, AttentionResponse, WorkerResponse interfaces - Single Promise.all with 6 parameterless queries (KPI, failed backups, stalled workflows, analyzer, RMM, backup success) - Ticket KPIs exclude out-of-scope companies via company_scope filter - SLA breaches tone='attention' when value > 0 - Worker status rules: down if fail_1h>0 and in_flight=0, warn if fail_1h>0, otherwise ok - Backup status: ok >= 95%, warn >= 80%, down otherwise
This commit is contained in:
parent
e5e444b36d
commit
24e20c7ae7
1 changed files with 203 additions and 104 deletions
|
|
@ -1,115 +1,214 @@
|
|||
/**
|
||||
* GET /api/mobile/dashboard
|
||||
* Single round-trip returning the three sections consumed by the new mobile
|
||||
* dashboard layout: 4 KPIs, 3 Needs Attention items, 3 worker/backup status
|
||||
* entries.
|
||||
*
|
||||
* All ticket counts exclude out-of-scope companies (company_scope filter,
|
||||
* same idiom as /api/dashboard/overview).
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { postgresClient } from '@/lib/services/postgres-client';
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
import postgresClient from '@/lib/services/postgres-client';
|
||||
|
||||
async function getMobileClassFilter(): Promise<string> {
|
||||
try {
|
||||
const result = await postgresClient.query(
|
||||
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('mobile_company_category_ids', 'mobile_excluded_company_ids')`
|
||||
);
|
||||
const map: Record<string, string> = {};
|
||||
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
|
||||
// ─── Response shape ──────────────────────────────────────────────────────────
|
||||
|
||||
const catIds = (map['mobile_company_category_ids'] || '1')
|
||||
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||
const exclIds = (map['mobile_excluded_company_ids'] || '')
|
||||
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
|
||||
|
||||
const catCond = catIds.length > 0 ? `c.company_category_id IN (${catIds.join(',')})` : 'true';
|
||||
const exclCond = exclIds.length > 0 ? `c.id NOT IN (${exclIds.join(',')})` : '';
|
||||
return [catCond, exclCond].filter(Boolean).join(' AND ');
|
||||
} catch (error) {
|
||||
console.error('Error fetching mobile company filter:', error);
|
||||
return 'c.company_category_id = 1';
|
||||
}
|
||||
export interface KpiResponse {
|
||||
id: 'open_total' | 'opened_today' | 'resolved_today' | 'sla_breaches';
|
||||
label: string;
|
||||
value: number;
|
||||
caption?: string;
|
||||
tone?: 'default' | 'attention';
|
||||
}
|
||||
|
||||
export interface AttentionResponse {
|
||||
id: 'overdue_tickets' | 'failed_backups' | 'stalled_workflows';
|
||||
label: string;
|
||||
count: number;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface WorkerResponse {
|
||||
id: 'analyzer' | 'rmm' | 'backup_success_rate';
|
||||
label: string;
|
||||
value: string;
|
||||
status: 'ok' | 'warn' | 'down';
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface MobileDashboardResponse {
|
||||
kpis: KpiResponse[];
|
||||
needsAttention: AttentionResponse[];
|
||||
workers: WorkerResponse[];
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function GET() {
|
||||
const classFilter = await getMobileClassFilter();
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
|
||||
const [byStatus, byQueue, byPriority, recentActivity, sla] = await Promise.all([
|
||||
postgresClient.query(`
|
||||
SELECT t.status, COUNT(*) as count
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
WHERE t.status != 5 AND t.is_deleted = false
|
||||
GROUP BY t.status ORDER BY count DESC
|
||||
`),
|
||||
postgresClient.query(`
|
||||
SELECT t.queue_id, q.label as queue_label, COUNT(*) as count
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
LEFT JOIN queues q ON q.value = t.queue_id
|
||||
WHERE t.status != 5 AND t.is_deleted = false
|
||||
GROUP BY t.queue_id, q.label ORDER BY count DESC LIMIT 8
|
||||
`),
|
||||
postgresClient.query(`
|
||||
SELECT t.priority, p.label, COUNT(*) as count
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
LEFT JOIN priorities p ON p.value = t.priority
|
||||
WHERE t.status != 5 AND t.is_deleted = false
|
||||
GROUP BY t.priority, p.label ORDER BY count DESC
|
||||
`),
|
||||
postgresClient.query(`
|
||||
SELECT t.id, t.ticket_number, t.title, t.status, t.priority,
|
||||
t.last_activity_date, t.company_id,
|
||||
c.company_name, q.label as queue_label,
|
||||
r.first_name || ' ' || r.last_name as assigned_to
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
LEFT JOIN queues q ON q.value = t.queue_id
|
||||
LEFT JOIN resources r ON r.id = t.assigned_resource_id
|
||||
WHERE t.status != 5 AND t.is_deleted = false AND t.last_activity_date IS NOT NULL
|
||||
ORDER BY t.last_activity_date DESC LIMIT 10
|
||||
`),
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL
|
||||
AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 <= 1) as resp_met,
|
||||
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL) as resp_total,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL
|
||||
AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 <= 24) as res_met,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL) as res_total
|
||||
FROM tickets t
|
||||
INNER JOIN companies c ON c.id = t.company_id AND ${classFilter}
|
||||
WHERE t.create_date >= NOW() - INTERVAL '30 days' AND t.is_deleted = false
|
||||
`),
|
||||
]);
|
||||
try {
|
||||
const [
|
||||
kpiRes,
|
||||
failedBackupsRes,
|
||||
stalledWorkflowsRes,
|
||||
analyzerRes,
|
||||
rmmRes,
|
||||
backupSuccessRes,
|
||||
] = await Promise.all([
|
||||
/* 1. KPI snapshot — four counts in one row, scoped companies excluded */
|
||||
postgresClient.query<{
|
||||
open_total: string;
|
||||
opened_today: string;
|
||||
resolved_today: string;
|
||||
sla_breaches: string;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total,
|
||||
COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today,
|
||||
COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today,
|
||||
COUNT(*) FILTER (
|
||||
WHERE completed_date IS NULL
|
||||
AND due_date_time IS NOT NULL
|
||||
AND due_date_time < NOW()
|
||||
)::text AS sla_breaches
|
||||
FROM tickets
|
||||
WHERE (is_deleted = false OR is_deleted IS NULL)
|
||||
AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)
|
||||
`),
|
||||
|
||||
const statusLabels: Record<number, string> = {
|
||||
1: 'New', 5: 'Complete', 7: 'In Progress', 8: 'In Progress',
|
||||
9: 'Scheduled', 12: 'On Hold', 14: 'Waiting Customer',
|
||||
19: 'Waiting Materials', 21: 'Dispatched', 25: 'In Review',
|
||||
27: 'Pending Decision', 30: 'On Hold', 45: 'Escalated', 47: 'Waiting Customer',
|
||||
56: 'Waiting Vendor', 58: 'Waiting Parts', 59: 'Pending Approval',
|
||||
60: 'In Deployment', 66: 'Closed', 68: 'Resolved', 70: 'Customer Follow-Up', 71: 'Archived',
|
||||
};
|
||||
/* 2. Failed backups in the last 24 hours (Needs Attention) */
|
||||
postgresClient.query<{ count: string }>(`
|
||||
SELECT COUNT(*)::text AS count FROM (
|
||||
SELECT 1 FROM veeam_backup_jobs
|
||||
WHERE last_run >= NOW() - INTERVAL '24 hours'
|
||||
AND is_enabled = true AND status = 'Failed'
|
||||
UNION ALL
|
||||
SELECT 1 FROM veeam_backup_agent_jobs
|
||||
WHERE last_run >= NOW() - INTERVAL '24 hours'
|
||||
AND is_enabled = true AND status = 'Failed'
|
||||
) f
|
||||
`),
|
||||
|
||||
const slaRow = sla.rows[0];
|
||||
/* 3. Stalled workflow executions — pending > 5 minutes (Needs Attention) */
|
||||
postgresClient.query<{ count: string }>(`
|
||||
SELECT COUNT(*)::text AS count
|
||||
FROM workflow_executions
|
||||
WHERE status = 'pending' AND created_at < NOW() - INTERVAL '5 minutes'
|
||||
`),
|
||||
|
||||
return NextResponse.json({
|
||||
open_total: byStatus.rows.reduce((s, r) => s + parseInt(r.count), 0),
|
||||
by_status: byStatus.rows.map(r => ({
|
||||
status: parseInt(r.status),
|
||||
label: statusLabels[r.status] ?? `Status ${r.status}`,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
by_queue: byQueue.rows.map(r => ({
|
||||
queue_id: r.queue_id,
|
||||
label: r.queue_label ?? `Queue ${r.queue_id}`,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
by_priority: byPriority.rows.map(r => ({
|
||||
priority: parseInt(r.priority),
|
||||
label: r.label ?? `P${r.priority}`,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
recent: recentActivity.rows,
|
||||
sla: {
|
||||
response_met: parseInt(slaRow.resp_met ?? 0),
|
||||
response_total: parseInt(slaRow.resp_total ?? 0),
|
||||
resolution_met: parseInt(slaRow.res_met ?? 0),
|
||||
resolution_total: parseInt(slaRow.res_total ?? 0),
|
||||
},
|
||||
});
|
||||
/* 4. Analyzer worker — in-flight + recent failures */
|
||||
postgresClient.query<{ in_flight: string; fail_1h: string }>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review')
|
||||
)::text AS in_flight,
|
||||
COUNT(*) FILTER (WHERE status = 'failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
|
||||
FROM analyzer_jobs
|
||||
`),
|
||||
|
||||
/* 5. RMM worker — in-flight + recent failures */
|
||||
postgresClient.query<{ in_flight: string; fail_1h: string }>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight,
|
||||
COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h
|
||||
FROM rmm_executions
|
||||
`),
|
||||
|
||||
/* 6. Backup success rate (24h) — same calculation as /api/veeam/backup-status */
|
||||
postgresClient.query<{ success: string; total: string }>(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'Success')::text AS success,
|
||||
COUNT(*)::text AS total
|
||||
FROM (
|
||||
SELECT status FROM veeam_backup_jobs
|
||||
WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
|
||||
UNION ALL
|
||||
SELECT status FROM veeam_backup_agent_jobs
|
||||
WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true
|
||||
) j
|
||||
`),
|
||||
]);
|
||||
|
||||
// ── Build KPIs ────────────────────────────────────────────────────────────
|
||||
const kpiRow = kpiRes.rows[0];
|
||||
const openTotal = parseInt(kpiRow?.open_total ?? '0', 10);
|
||||
const openedToday = parseInt(kpiRow?.opened_today ?? '0', 10);
|
||||
const resolvedToday = parseInt(kpiRow?.resolved_today ?? '0', 10);
|
||||
const slaBreaches = parseInt(kpiRow?.sla_breaches ?? '0', 10);
|
||||
|
||||
const kpis: KpiResponse[] = [
|
||||
{ id: 'open_total', label: 'Open total', value: openTotal, tone: 'default' },
|
||||
{ id: 'opened_today', label: 'Opened today', value: openedToday, tone: 'default' },
|
||||
{ id: 'resolved_today', label: 'Resolved today', value: resolvedToday, tone: 'default' },
|
||||
{ id: 'sla_breaches', label: 'SLA breaches', value: slaBreaches, tone: slaBreaches > 0 ? 'attention' : 'default' },
|
||||
];
|
||||
|
||||
// ── Build Needs Attention ─────────────────────────────────────────────────
|
||||
const failedBackups = parseInt(failedBackupsRes.rows[0]?.count ?? '0', 10);
|
||||
const stalledWorkflows = parseInt(stalledWorkflowsRes.rows[0]?.count ?? '0', 10);
|
||||
|
||||
const needsAttention: AttentionResponse[] = [
|
||||
{ id: 'overdue_tickets', label: 'Overdue tickets', count: slaBreaches, href: '/tickets?overdue=true' },
|
||||
{ id: 'failed_backups', label: 'Failed backups (24h)', count: failedBackups, href: '/backup-status' },
|
||||
{ id: 'stalled_workflows', label: 'Stalled workflows', count: stalledWorkflows, href: '/admin/workflow' },
|
||||
];
|
||||
|
||||
// ── Build Workers ─────────────────────────────────────────────────────────
|
||||
const aRow = analyzerRes.rows[0];
|
||||
const analyzerInFlight = parseInt(aRow?.in_flight ?? '0', 10);
|
||||
const analyzerFail1h = parseInt(aRow?.fail_1h ?? '0', 10);
|
||||
let analyzerStatus: 'ok' | 'warn' | 'down' = 'ok';
|
||||
if (analyzerFail1h > 0 && analyzerInFlight === 0) analyzerStatus = 'down';
|
||||
else if (analyzerFail1h > 0) analyzerStatus = 'warn';
|
||||
|
||||
const rRow = rmmRes.rows[0];
|
||||
const rmmInFlight = parseInt(rRow?.in_flight ?? '0', 10);
|
||||
const rmmFail1h = parseInt(rRow?.fail_1h ?? '0', 10);
|
||||
let rmmStatus: 'ok' | 'warn' | 'down' = 'ok';
|
||||
if (rmmFail1h > 0 && rmmInFlight === 0) rmmStatus = 'down';
|
||||
else if (rmmFail1h > 0) rmmStatus = 'warn';
|
||||
|
||||
const bRow = backupSuccessRes.rows[0];
|
||||
const bSuccess = parseInt(bRow?.success ?? '0', 10);
|
||||
const bTotal = parseInt(bRow?.total ?? '0', 10);
|
||||
const backupPct = bTotal > 0 ? Math.round((bSuccess / bTotal) * 1000) / 10 : 100;
|
||||
let backupStatus: 'ok' | 'warn' | 'down' = 'ok';
|
||||
if (backupPct < 80) backupStatus = 'down';
|
||||
else if (backupPct < 95) backupStatus = 'warn';
|
||||
|
||||
const workers: WorkerResponse[] = [
|
||||
{
|
||||
id: 'analyzer',
|
||||
label: 'Analyzer',
|
||||
value: `${analyzerInFlight} in flight`,
|
||||
status: analyzerStatus,
|
||||
href: '/admin/analytics',
|
||||
},
|
||||
{
|
||||
id: 'rmm',
|
||||
label: 'RMM Overshell',
|
||||
value: `${rmmInFlight} in flight`,
|
||||
status: rmmStatus,
|
||||
href: '/admin/rmm-overshell',
|
||||
},
|
||||
{
|
||||
id: 'backup_success_rate',
|
||||
label: 'Backup success (24h)',
|
||||
value: `${backupPct}%`,
|
||||
status: backupStatus,
|
||||
href: '/backup-status',
|
||||
},
|
||||
];
|
||||
|
||||
return NextResponse.json<MobileDashboardResponse>({ kpis, needsAttention, workers });
|
||||
} catch (e) {
|
||||
console.error('[/api/mobile/dashboard] failed:', e);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load dashboard', message: e instanceof Error ? e.message : 'Unknown error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue