The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
214 lines
8.9 KiB
TypeScript
214 lines
8.9 KiB
TypeScript
/**
|
|
* 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 { requireAuth } from '@/lib/auth-utils';
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
|
|
// ─── Response shape ──────────────────────────────────────────────────────────
|
|
|
|
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 { error } = await requireAuth();
|
|
if (error) return error;
|
|
|
|
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)
|
|
`),
|
|
|
|
/* 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
|
|
`),
|
|
|
|
/* 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'
|
|
`),
|
|
|
|
/* 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 },
|
|
);
|
|
}
|
|
}
|