Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- Frontend/src/admin/pages/AdminDashboard.jsx +48 -24
- Frontend/src/hooks/useWebSocket.js +207 -0
- Frontend/src/store/ticketStore.js +41 -6
- MobileApp/App.js +77 -18
- MobileApp/metro.config.js +8 -0
- MobileApp/package-lock.json +1 -1
- MobileApp/package.json +1 -1
- backend/main.py +274 -24
- backend/requirements.txt +2 -0
- backend/services/classifier_service.py +26 -5
- backend/services/gemini_service.py +5 -1
- backend/services/spam_service.py +156 -0
- deploy/monitoring/grafana_dashboard.json +155 -87
- requirements.txt +2 -0
Frontend/src/admin/pages/AdminDashboard.jsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
-
import { Activity, AlertTriangle, Clock, ShieldCheck } from 'lucide-react';
|
| 4 |
|
| 5 |
import useAuthStore from "../../store/authStore";
|
|
|
|
|
|
|
| 6 |
import { supabase } from "../../lib/supabaseClient";
|
| 7 |
-
import useTicketsRealtime from "../../hooks/useTicketsRealtime";
|
| 8 |
import StatCard from "../components/StatCard";
|
| 9 |
import TicketTable from "../components/TicketTable";
|
| 10 |
-
import { formatTimelineDate } from "../../utils/dateUtils";
|
| 11 |
|
| 12 |
// Inline SVG icon components
|
| 13 |
const TicketIcon = () => (
|
|
@@ -104,17 +104,32 @@ function formatSlaCountdown(deadlineMs, nowMs) {
|
|
| 104 |
const AdminDashboard = () => {
|
| 105 |
const navigate = useNavigate();
|
| 106 |
const { profile } = useAuthStore();
|
| 107 |
-
const [tickets, setTickets] = React.useState([]);
|
| 108 |
const [isLoading, setIsLoading] = React.useState(true);
|
| 109 |
const [nowMs, setNowMs] = React.useState(() => Date.now());
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
enabled: Boolean(profile),
|
| 114 |
-
onTicketsChange: setTickets,
|
| 115 |
-
channelName: 'admin_dashboard_tickets_realtime',
|
| 116 |
-
});
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
React.useEffect(() => {
|
| 119 |
if (profile) {
|
| 120 |
const fetchStats = async () => {
|
|
@@ -134,9 +149,14 @@ const AdminDashboard = () => {
|
|
| 134 |
console.warn("Retrying dashboard fetch without relation...", error);
|
| 135 |
const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
|
| 136 |
if (basicError) throw basicError;
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
| 138 |
} else {
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
} catch (err) { console.error("Dashboard fetch error:", err); }
|
| 142 |
finally { setIsLoading(false); }
|
|
@@ -144,7 +164,7 @@ const AdminDashboard = () => {
|
|
| 144 |
|
| 145 |
fetchStats();
|
| 146 |
}
|
| 147 |
-
}, [profile]);
|
| 148 |
|
| 149 |
React.useEffect(() => {
|
| 150 |
const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
|
|
@@ -200,13 +220,17 @@ const AdminDashboard = () => {
|
|
| 200 |
Dashboard
|
| 201 |
</h1>
|
| 202 |
<p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
</p>
|
| 206 |
</div>
|
| 207 |
-
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: '#F0FDF4', border: '1.5px solid #BBF7D0', borderRadius: '100px' }}>
|
| 208 |
-
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 209 |
-
<span style={{ fontSize: '11px', fontWeight: 700, color: '#15803d', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
| 210 |
</div>
|
| 211 |
</div>
|
| 212 |
|
|
@@ -283,9 +307,9 @@ const AdminDashboard = () => {
|
|
| 283 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
| 284 |
AI Status
|
| 285 |
</h2>
|
| 286 |
-
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: '#F0FDF4', border: '1px solid #BBF7D0', borderRadius: '100px', padding: '3px 10px' }}>
|
| 287 |
-
<span style={{ width: 5, height: 5, borderRadius: '50%', background: '#22c55e', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 288 |
-
<span style={{ fontSize: '10px', fontWeight: 700, color: '#15803d' }}>
|
| 289 |
</div>
|
| 290 |
</div>
|
| 291 |
<div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
|
|
@@ -310,9 +334,9 @@ const AdminDashboard = () => {
|
|
| 310 |
<div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
|
| 311 |
<p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
|
| 312 |
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
|
| 313 |
-
<Activity size={10} color="#
|
| 314 |
-
<span style={{ fontSize: '9px', fontWeight: 600, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
| 315 |
-
|
| 316 |
</span>
|
| 317 |
</div>
|
| 318 |
</div>
|
|
|
|
| 1 |
import React, { useMemo } from 'react';
|
| 2 |
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { Activity, AlertTriangle, Clock, ShieldCheck, Wifi, WifiOff } from 'lucide-react';
|
| 4 |
|
| 5 |
import useAuthStore from "../../store/authStore";
|
| 6 |
+
import useTicketStore from "../../store/ticketStore";
|
| 7 |
+
import useWebSocket from "../../hooks/useWebSocket";
|
| 8 |
import { supabase } from "../../lib/supabaseClient";
|
|
|
|
| 9 |
import StatCard from "../components/StatCard";
|
| 10 |
import TicketTable from "../components/TicketTable";
|
|
|
|
| 11 |
|
| 12 |
// Inline SVG icon components
|
| 13 |
const TicketIcon = () => (
|
|
|
|
| 104 |
const AdminDashboard = () => {
|
| 105 |
const navigate = useNavigate();
|
| 106 |
const { profile } = useAuthStore();
|
|
|
|
| 107 |
const [isLoading, setIsLoading] = React.useState(true);
|
| 108 |
const [nowMs, setNowMs] = React.useState(() => Date.now());
|
| 109 |
|
| 110 |
+
// WebSocket connection for real-time ticket updates
|
| 111 |
+
const { isConnected: wsConnected, lastMessage } = useWebSocket(profile?.company);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
+
// Read tickets from the Zustand store (populated below and updated by WS)
|
| 114 |
+
const tickets = useTicketStore((s) => s.tickets);
|
| 115 |
+
const handleWsMessage = useTicketStore((s) => s.handleWsMessage);
|
| 116 |
+
const setWsConnected = useTicketStore((s) => s.setWsConnected);
|
| 117 |
+
const upsertTicket = useTicketStore((s) => s.upsertTicket);
|
| 118 |
+
const removeTicket = useTicketStore((s) => s.removeTicket);
|
| 119 |
+
|
| 120 |
+
// Sync WebSocket connection status to store
|
| 121 |
+
React.useEffect(() => {
|
| 122 |
+
setWsConnected(wsConnected);
|
| 123 |
+
}, [wsConnected, setWsConnected]);
|
| 124 |
+
|
| 125 |
+
// Route incoming WebSocket messages into the ticket store
|
| 126 |
+
React.useEffect(() => {
|
| 127 |
+
if (lastMessage) {
|
| 128 |
+
handleWsMessage(lastMessage);
|
| 129 |
+
}
|
| 130 |
+
}, [lastMessage, handleWsMessage]);
|
| 131 |
+
|
| 132 |
+
// Initial fetch — populate store from Supabase on mount
|
| 133 |
React.useEffect(() => {
|
| 134 |
if (profile) {
|
| 135 |
const fetchStats = async () => {
|
|
|
|
| 149 |
console.warn("Retrying dashboard fetch without relation...", error);
|
| 150 |
const { data: basicData, error: basicError } = await supabase.from('tickets').select('*').eq('company', profile?.company).order('created_at', { ascending: false });
|
| 151 |
if (basicError) throw basicError;
|
| 152 |
+
// Bulk-load into store (avoid duplicates)
|
| 153 |
+
for (const t of basicData || []) {
|
| 154 |
+
upsertTicket(t);
|
| 155 |
+
}
|
| 156 |
} else {
|
| 157 |
+
for (const t of data || []) {
|
| 158 |
+
upsertTicket(t);
|
| 159 |
+
}
|
| 160 |
}
|
| 161 |
} catch (err) { console.error("Dashboard fetch error:", err); }
|
| 162 |
finally { setIsLoading(false); }
|
|
|
|
| 164 |
|
| 165 |
fetchStats();
|
| 166 |
}
|
| 167 |
+
}, [profile, upsertTicket]);
|
| 168 |
|
| 169 |
React.useEffect(() => {
|
| 170 |
const timer = setInterval(() => setNowMs(Date.now()), 60 * 1000);
|
|
|
|
| 220 |
Dashboard
|
| 221 |
</h1>
|
| 222 |
<p style={{ color: '#6b7280', fontSize: '13px', marginTop: '4px', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 500 }}>
|
| 223 |
+
{wsConnected ? (
|
| 224 |
+
<Wifi size={14} color="#22c55e" />
|
| 225 |
+
) : (
|
| 226 |
+
<WifiOff size={14} color="#f97316" />
|
| 227 |
+
)}
|
| 228 |
+
{wsConnected ? 'WebSocket connected' : 'Reconnecting...'}
|
| 229 |
</p>
|
| 230 |
</div>
|
| 231 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 16px', background: wsConnected ? '#F0FDF4' : '#FFF7ED', border: wsConnected ? '1.5px solid #BBF7D0' : '1.5px solid #FED7AA', borderRadius: '100px' }}>
|
| 232 |
+
<span style={{ width: 6, height: 6, borderRadius: '50%', background: wsConnected ? '#22c55e' : '#f97316', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 233 |
+
<span style={{ fontSize: '11px', fontWeight: 700, color: wsConnected ? '#15803d' : '#c2410c', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{wsConnected ? 'Live' : 'Reconnecting'}</span>
|
| 234 |
</div>
|
| 235 |
</div>
|
| 236 |
|
|
|
|
| 307 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
| 308 |
AI Status
|
| 309 |
</h2>
|
| 310 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', background: wsConnected ? '#F0FDF4' : '#FFF7ED', border: wsConnected ? '1px solid #BBF7D0' : '1px solid #FED7AA', borderRadius: '100px', padding: '3px 10px' }}>
|
| 311 |
+
<span style={{ width: 5, height: 5, borderRadius: '50%', background: wsConnected ? '#22c55e' : '#f97316', display: 'inline-block', animation: 'pulse-dot 2s infinite' }}></span>
|
| 312 |
+
<span style={{ fontSize: '10px', fontWeight: 700, color: wsConnected ? '#15803d' : '#c2410c' }}>{wsConnected ? 'WS CONNECTED' : 'RECONNECTING'}</span>
|
| 313 |
</div>
|
| 314 |
</div>
|
| 315 |
<div style={{ background: '#fff', borderRadius: '20px', border: '1px solid #f0fdf4', padding: '24px' }}>
|
|
|
|
| 334 |
<div className="pt-4 mt-4 border-t border-gray-100 flex flex-col items-center gap-2">
|
| 335 |
<p style={{ fontSize: '10px', color: '#9ca3af', letterSpacing: '0.14em', fontWeight: 600, textTransform: 'uppercase' }}>All systems operating normally</p>
|
| 336 |
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 10px', background: '#f8faf9', borderRadius: '100px', border: '1px solid #e5e7eb' }}>
|
| 337 |
+
{wsConnected ? <Activity size={10} color="#22c55e" /> : <Activity size={10} color="#f97316" />}
|
| 338 |
+
<span style={{ fontSize: '9px', fontWeight: 600, color: wsConnected ? '#16a34a' : '#9ca3af', textTransform: 'uppercase', letterSpacing: '0.1em' }}>
|
| 339 |
+
{wsConnected ? 'Live via WebSocket' : 'Reconnecting via WebSocket...'}
|
| 340 |
</span>
|
| 341 |
</div>
|
| 342 |
</div>
|
Frontend/src/hooks/useWebSocket.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* useWebSocket — auto-reconnecting WebSocket hook with heartbeat support.
|
| 3 |
+
*
|
| 4 |
+
* Connects to the backend WebSocket endpoint for real-time ticket updates.
|
| 5 |
+
* Automatically re-establishes the connection on drop with exponential backoff.
|
| 6 |
+
*
|
| 7 |
+
* Usage:
|
| 8 |
+
* import useWebSocket from "../../hooks/useWebSocket";
|
| 9 |
+
*
|
| 10 |
+
* const { isConnected, sendMessage, lastMessage } = useWebSocket(companyId);
|
| 11 |
+
*
|
| 12 |
+
* // lastMessage updates on every incoming message → use in a useEffect
|
| 13 |
+
* useEffect(() => {
|
| 14 |
+
* if (lastMessage?.type === "ticket_update") {
|
| 15 |
+
* store.addTicket(lastMessage.ticket);
|
| 16 |
+
* }
|
| 17 |
+
* }, [lastMessage]);
|
| 18 |
+
*/
|
| 19 |
+
|
| 20 |
+
import { useEffect, useRef, useState, useCallback } from "react";
|
| 21 |
+
|
| 22 |
+
// ---------------------------------------------------------------------------
|
| 23 |
+
// Constants
|
| 24 |
+
// ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
const WS_BASE_URL = import.meta.env.VITE_WS_URL || "ws://localhost:7860";
|
| 27 |
+
const PING_INTERVAL_MS = 25_000; // slightly < server-side 30s so pong arrives first
|
| 28 |
+
const PONG_TIMEOUT_MS = 12_000; // slightly > server-side 10s timeout
|
| 29 |
+
const MAX_RECONNECT_DELAY_MS = 30_000;
|
| 30 |
+
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
| 31 |
+
|
| 32 |
+
// ---------------------------------------------------------------------------
|
| 33 |
+
// Hook
|
| 34 |
+
// ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
export default function useWebSocket(companyId) {
|
| 37 |
+
const [isConnected, setIsConnected] = useState(false);
|
| 38 |
+
const [lastMessage, setLastMessage] = useState(null);
|
| 39 |
+
const [connectionError, setConnectionError] = useState(null);
|
| 40 |
+
|
| 41 |
+
const wsRef = useRef(null);
|
| 42 |
+
const pingTimerRef = useRef(null);
|
| 43 |
+
const pongTimeoutRef = useRef(null);
|
| 44 |
+
const reconnectTimerRef = useRef(null);
|
| 45 |
+
const reconnectAttemptRef = useRef(0);
|
| 46 |
+
const mountedRef = useRef(true);
|
| 47 |
+
const companyIdRef = useRef(companyId);
|
| 48 |
+
|
| 49 |
+
// Keep a ref to latest companyId so the effect closure always has it
|
| 50 |
+
companyIdRef.current = companyId;
|
| 51 |
+
|
| 52 |
+
// ---- Cleanup helpers ---------------------------------------------------
|
| 53 |
+
|
| 54 |
+
const clearTimers = useCallback(() => {
|
| 55 |
+
if (pingTimerRef.current) {
|
| 56 |
+
clearInterval(pingTimerRef.current);
|
| 57 |
+
pingTimerRef.current = null;
|
| 58 |
+
}
|
| 59 |
+
if (pongTimeoutRef.current) {
|
| 60 |
+
clearTimeout(pongTimeoutRef.current);
|
| 61 |
+
pongTimeoutRef.current = null;
|
| 62 |
+
}
|
| 63 |
+
if (reconnectTimerRef.current) {
|
| 64 |
+
clearTimeout(reconnectTimerRef.current);
|
| 65 |
+
reconnectTimerRef.current = null;
|
| 66 |
+
}
|
| 67 |
+
}, []);
|
| 68 |
+
|
| 69 |
+
const cleanup = useCallback(() => {
|
| 70 |
+
clearTimers();
|
| 71 |
+
if (wsRef.current) {
|
| 72 |
+
wsRef.current.onopen = null;
|
| 73 |
+
wsRef.current.onclose = null;
|
| 74 |
+
wsRef.current.onmessage = null;
|
| 75 |
+
wsRef.current.onerror = null;
|
| 76 |
+
wsRef.current.close();
|
| 77 |
+
wsRef.current = null;
|
| 78 |
+
}
|
| 79 |
+
}, [clearTimers]);
|
| 80 |
+
|
| 81 |
+
// ---- Start heartbeat timers (called after connect) --------------------
|
| 82 |
+
|
| 83 |
+
const startHeartbeat = useCallback(() => {
|
| 84 |
+
// Periodic pings
|
| 85 |
+
pingTimerRef.current = setInterval(() => {
|
| 86 |
+
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
| 87 |
+
wsRef.current.send(JSON.stringify({ type: "pong" }));
|
| 88 |
+
}
|
| 89 |
+
}, PING_INTERVAL_MS);
|
| 90 |
+
}, []);
|
| 91 |
+
|
| 92 |
+
// ---- WebSocket lifecycle -----------------------------------------------
|
| 93 |
+
|
| 94 |
+
const connect = useCallback(() => {
|
| 95 |
+
cleanup();
|
| 96 |
+
|
| 97 |
+
const cid = companyIdRef.current;
|
| 98 |
+
if (!cid) return;
|
| 99 |
+
|
| 100 |
+
const url = `${WS_BASE_URL}/ws/${encodeURIComponent(cid)}`;
|
| 101 |
+
setConnectionError(null);
|
| 102 |
+
|
| 103 |
+
let socket;
|
| 104 |
+
try {
|
| 105 |
+
socket = new WebSocket(url);
|
| 106 |
+
} catch (err) {
|
| 107 |
+
setConnectionError(err.message || "Failed to create WebSocket");
|
| 108 |
+
scheduleReconnect();
|
| 109 |
+
return;
|
| 110 |
+
}
|
| 111 |
+
wsRef.current = socket;
|
| 112 |
+
|
| 113 |
+
socket.onopen = () => {
|
| 114 |
+
if (!mountedRef.current) return;
|
| 115 |
+
setIsConnected(true);
|
| 116 |
+
setConnectionError(null);
|
| 117 |
+
reconnectAttemptRef.current = 0;
|
| 118 |
+
startHeartbeat();
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
socket.onmessage = (event) => {
|
| 122 |
+
if (!mountedRef.current) return;
|
| 123 |
+
try {
|
| 124 |
+
const data = JSON.parse(event.data);
|
| 125 |
+
|
| 126 |
+
// Respond to server pings immediately
|
| 127 |
+
if (data.type === "ping") {
|
| 128 |
+
if (socket.readyState === WebSocket.OPEN) {
|
| 129 |
+
socket.send(JSON.stringify({ type: "pong" }));
|
| 130 |
+
}
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
setLastMessage(data);
|
| 135 |
+
} catch {
|
| 136 |
+
// ignore malformed frames
|
| 137 |
+
}
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
socket.onclose = (event) => {
|
| 141 |
+
if (!mountedRef.current) return;
|
| 142 |
+
setIsConnected(false);
|
| 143 |
+
clearTimers();
|
| 144 |
+
|
| 145 |
+
// Don't reconnect on clean closes (1000 = normal, 400x = intentional)
|
| 146 |
+
if (event.code === 1000 || (event.code >= 4000 && event.code < 5000)) {
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
scheduleReconnect();
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
socket.onerror = () => {
|
| 154 |
+
// onclose fires immediately after onerror, so reconnect is handled there
|
| 155 |
+
};
|
| 156 |
+
}, [cleanup, clearTimers, startHeartbeat]);
|
| 157 |
+
|
| 158 |
+
// ---- Reconnection with exponential backoff -----------------------------
|
| 159 |
+
|
| 160 |
+
const scheduleReconnect = useCallback(() => {
|
| 161 |
+
if (!mountedRef.current || !companyIdRef.current) return;
|
| 162 |
+
|
| 163 |
+
const attempt = reconnectAttemptRef.current;
|
| 164 |
+
const delay = Math.min(
|
| 165 |
+
INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt),
|
| 166 |
+
MAX_RECONNECT_DELAY_MS
|
| 167 |
+
);
|
| 168 |
+
reconnectAttemptRef.current = attempt + 1;
|
| 169 |
+
|
| 170 |
+
setConnectionError(`Reconnecting in ${Math.round(delay / 1000)}s...`);
|
| 171 |
+
|
| 172 |
+
reconnectTimerRef.current = setTimeout(() => {
|
| 173 |
+
if (mountedRef.current) connect();
|
| 174 |
+
}, delay);
|
| 175 |
+
}, [connect]);
|
| 176 |
+
|
| 177 |
+
// ---- Send helper -------------------------------------------------------
|
| 178 |
+
|
| 179 |
+
const sendMessage = useCallback(
|
| 180 |
+
(msg) => {
|
| 181 |
+
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
| 182 |
+
wsRef.current.send(
|
| 183 |
+
typeof msg === "string" ? msg : JSON.stringify(msg)
|
| 184 |
+
);
|
| 185 |
+
}
|
| 186 |
+
},
|
| 187 |
+
[]
|
| 188 |
+
);
|
| 189 |
+
|
| 190 |
+
// ---- Main effect -------------------------------------------------------
|
| 191 |
+
|
| 192 |
+
useEffect(() => {
|
| 193 |
+
mountedRef.current = true;
|
| 194 |
+
companyIdRef.current = companyId;
|
| 195 |
+
|
| 196 |
+
if (companyId) {
|
| 197 |
+
connect();
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
return () => {
|
| 201 |
+
mountedRef.current = false;
|
| 202 |
+
cleanup();
|
| 203 |
+
};
|
| 204 |
+
}, [companyId, connect, cleanup]);
|
| 205 |
+
|
| 206 |
+
return { isConnected, lastMessage, connectionError, sendMessage };
|
| 207 |
+
}
|
Frontend/src/store/ticketStore.js
CHANGED
|
@@ -3,14 +3,19 @@ import { persist } from 'zustand/middleware';
|
|
| 3 |
|
| 4 |
const useTicketStore = create(
|
| 5 |
persist(
|
| 6 |
-
(set) => ({
|
| 7 |
aiTicket: null,
|
| 8 |
activeTicket: null,
|
| 9 |
autoResolvedTickets: [], // For analytics
|
| 10 |
tickets: [], // Global queue for admins
|
| 11 |
notifications: [], // User notifications
|
|
|
|
|
|
|
| 12 |
setAITicket: (data) => set({ aiTicket: data }),
|
| 13 |
setActiveTicket: (ticket) => set({ activeTicket: ticket }),
|
|
|
|
|
|
|
|
|
|
| 14 |
addAutoResolvedTicket: (record) => set((state) => ({
|
| 15 |
autoResolvedTickets: [...state.autoResolvedTickets, record]
|
| 16 |
})),
|
|
@@ -27,7 +32,7 @@ const useTicketStore = create(
|
|
| 27 |
})),
|
| 28 |
addTicket: (ticket) => set((state) => {
|
| 29 |
return {
|
| 30 |
-
tickets: [
|
| 31 |
};
|
| 32 |
}),
|
| 33 |
upsertTicket: (ticket) => set((state) => {
|
|
@@ -52,16 +57,46 @@ const useTicketStore = create(
|
|
| 52 |
: state.activeTicket
|
| 53 |
})),
|
| 54 |
updateTicket: (ticketId, updates) => set((state) => {
|
| 55 |
-
// eslint-disable-next-line no-unused-vars
|
| 56 |
const existingTicket = state.tickets.find(t => (t.id ?? t.ticket_id) === ticketId);
|
| 57 |
-
|
| 58 |
-
const
|
| 59 |
-
|
| 60 |
return {
|
| 61 |
tickets: updatedTickets,
|
| 62 |
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
|
| 63 |
};
|
| 64 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
appendMessage: (ticketId, message) => set((state) => {
|
| 66 |
const updatedTickets = state.tickets.map(t =>
|
| 67 |
t.ticket_id === ticketId
|
|
|
|
| 3 |
|
| 4 |
const useTicketStore = create(
|
| 5 |
persist(
|
| 6 |
+
(set, get) => ({
|
| 7 |
aiTicket: null,
|
| 8 |
activeTicket: null,
|
| 9 |
autoResolvedTickets: [], // For analytics
|
| 10 |
tickets: [], // Global queue for admins
|
| 11 |
notifications: [], // User notifications
|
| 12 |
+
wsConnected: false, // WebSocket connection status
|
| 13 |
+
|
| 14 |
setAITicket: (data) => set({ aiTicket: data }),
|
| 15 |
setActiveTicket: (ticket) => set({ activeTicket: ticket }),
|
| 16 |
+
|
| 17 |
+
setWsConnected: (connected) => set({ wsConnected: connected }),
|
| 18 |
+
|
| 19 |
addAutoResolvedTicket: (record) => set((state) => ({
|
| 20 |
autoResolvedTickets: [...state.autoResolvedTickets, record]
|
| 21 |
})),
|
|
|
|
| 32 |
})),
|
| 33 |
addTicket: (ticket) => set((state) => {
|
| 34 |
return {
|
| 35 |
+
tickets: [...state.tickets, ticket]
|
| 36 |
};
|
| 37 |
}),
|
| 38 |
upsertTicket: (ticket) => set((state) => {
|
|
|
|
| 57 |
: state.activeTicket
|
| 58 |
})),
|
| 59 |
updateTicket: (ticketId, updates) => set((state) => {
|
|
|
|
| 60 |
const existingTicket = state.tickets.find(t => (t.id ?? t.ticket_id) === ticketId);
|
| 61 |
+
if (!existingTicket) return state;
|
| 62 |
+
const updatedTickets = state.tickets.map(t => (t.id ?? t.ticket_id) === ticketId ? { ...t, ...updates } : t);
|
| 63 |
+
const shouldUpdateActive = (state.activeTicket?.id ?? state.activeTicket?.ticket_id) === ticketId;
|
| 64 |
return {
|
| 65 |
tickets: updatedTickets,
|
| 66 |
activeTicket: shouldUpdateActive ? { ...state.activeTicket, ...updates } : state.activeTicket
|
| 67 |
};
|
| 68 |
}),
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Route an incoming WebSocket message to the correct store action.
|
| 72 |
+
*
|
| 73 |
+
* Call this from the component that owns the WebSocket connection
|
| 74 |
+
* (e.g. AdminDashboard) whenever a message arrives.
|
| 75 |
+
*/
|
| 76 |
+
handleWsMessage: (msg) => {
|
| 77 |
+
if (!msg || !msg.type) return;
|
| 78 |
+
|
| 79 |
+
const { type, event, ticket, ticket_id } = msg;
|
| 80 |
+
|
| 81 |
+
switch (type) {
|
| 82 |
+
case "ticket_update": {
|
| 83 |
+
if (!ticket) break;
|
| 84 |
+
if (event === "created") {
|
| 85 |
+
// Avoid duplicates — use upsert
|
| 86 |
+
get().upsertTicket(ticket);
|
| 87 |
+
} else if (event === "updated") {
|
| 88 |
+
get().upsertTicket(ticket);
|
| 89 |
+
} else if (event === "deleted") {
|
| 90 |
+
get().removeTicket(ticket_id);
|
| 91 |
+
}
|
| 92 |
+
break;
|
| 93 |
+
}
|
| 94 |
+
default:
|
| 95 |
+
// Ignore unknown message types (e.g. heartbeat)
|
| 96 |
+
break;
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
|
| 100 |
appendMessage: (ticketId, message) => set((state) => {
|
| 101 |
const updatedTickets = state.tickets.map(t =>
|
| 102 |
t.ticket_id === ticketId
|
MobileApp/App.js
CHANGED
|
@@ -127,17 +127,55 @@ const AppContent = () => {
|
|
| 127 |
const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
|
| 128 |
|
| 129 |
useEffect(() => {
|
|
|
|
|
|
|
| 130 |
const initialize = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
try {
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
setSession(session);
|
| 134 |
|
|
|
|
| 135 |
if (session?.user) {
|
| 136 |
-
const
|
| 137 |
.from('profiles')
|
| 138 |
.select('status, role')
|
| 139 |
.eq('id', session.user.id)
|
| 140 |
.single();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
if (error) {
|
| 143 |
console.log('[AuthInit] Profile fetch error, validating session:', error.message);
|
|
@@ -146,20 +184,29 @@ const AppContent = () => {
|
|
| 146 |
if (userError) {
|
| 147 |
console.log('[AuthInit] Token validation failed. Clearing session.');
|
| 148 |
setSession(null);
|
|
|
|
|
|
|
| 149 |
} else {
|
| 150 |
-
// Valid token but profiles table is
|
| 151 |
-
setUserStatus('active');
|
| 152 |
-
setUserRole('user');
|
| 153 |
}
|
| 154 |
} else {
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
}
|
| 159 |
} catch (e) {
|
| 160 |
-
console.log('[AuthInit]
|
| 161 |
} finally {
|
| 162 |
-
|
|
|
|
| 163 |
try {
|
| 164 |
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 165 |
setShowOnboarding(onboardingDone === null);
|
|
@@ -184,12 +231,17 @@ const AppContent = () => {
|
|
| 184 |
|
| 185 |
if (error) {
|
| 186 |
console.log('[AuthChange] Profile query failed:', error.message);
|
| 187 |
-
// Default to safe values to avoid blank screens
|
| 188 |
setUserStatus('active');
|
| 189 |
setUserRole('user');
|
| 190 |
} else {
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
}
|
| 194 |
} catch (err) {
|
| 195 |
console.warn('[AuthChange] Uncaught exception inside handler:', err);
|
|
@@ -199,6 +251,10 @@ const AppContent = () => {
|
|
| 199 |
} else {
|
| 200 |
setUserStatus(null);
|
| 201 |
setUserRole('user');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
});
|
| 204 |
|
|
@@ -216,9 +272,15 @@ const AppContent = () => {
|
|
| 216 |
schema: 'public',
|
| 217 |
table: 'profiles',
|
| 218 |
filter: `id=eq.${session.user.id}`,
|
| 219 |
-
}, (payload) => {
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
})
|
| 223 |
.subscribe();
|
| 224 |
|
|
@@ -229,7 +291,6 @@ const AppContent = () => {
|
|
| 229 |
useEffect(() => {
|
| 230 |
const handleUrl = async ({ url }) => {
|
| 231 |
if (!url) return;
|
| 232 |
-
// Parse hash fragment: helpdeskai://login#access_token=...&refresh_token=...
|
| 233 |
const hashIndex = url.indexOf('#');
|
| 234 |
if (hashIndex === -1) return;
|
| 235 |
const hash = url.substring(hashIndex + 1);
|
|
@@ -255,9 +316,7 @@ const AppContent = () => {
|
|
| 255 |
}
|
| 256 |
};
|
| 257 |
|
| 258 |
-
// Handle app already open
|
| 259 |
const subscription = Linking.addEventListener('url', handleUrl);
|
| 260 |
-
// Handle cold start — app launched from the link
|
| 261 |
Linking.getInitialURL().then(url => url && handleUrl({ url }));
|
| 262 |
|
| 263 |
return () => subscription.remove();
|
|
|
|
| 127 |
const [userRole, setUserRole] = useState('user'); // 'user', 'admin', 'master_admin'
|
| 128 |
|
| 129 |
useEffect(() => {
|
| 130 |
+
let finished = false;
|
| 131 |
+
|
| 132 |
const initialize = async () => {
|
| 133 |
+
// Safety timeout wrapper: if anything hangs, force mount after 3 seconds
|
| 134 |
+
const timeoutId = setTimeout(async () => {
|
| 135 |
+
if (!finished) {
|
| 136 |
+
console.log('[AuthInit] Timeout reached (3s). Forcing mount fallback.');
|
| 137 |
+
try {
|
| 138 |
+
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 139 |
+
setShowOnboarding(onboardingDone === null);
|
| 140 |
+
} catch (err) {
|
| 141 |
+
setShowOnboarding(false);
|
| 142 |
+
}
|
| 143 |
+
setLoading(false);
|
| 144 |
+
}
|
| 145 |
+
}, 3000);
|
| 146 |
+
|
| 147 |
try {
|
| 148 |
+
// 1. Instantly load cached status and role from AsyncStorage (stale-while-revalidate pattern)
|
| 149 |
+
const [cachedStatus, cachedRole] = await Promise.all([
|
| 150 |
+
AsyncStorage.getItem('@user_status'),
|
| 151 |
+
AsyncStorage.getItem('@user_role'),
|
| 152 |
+
]);
|
| 153 |
+
|
| 154 |
+
if (cachedStatus) setUserStatus(cachedStatus);
|
| 155 |
+
if (cachedRole) setUserRole(cachedRole);
|
| 156 |
+
|
| 157 |
+
// 2. Fetch session with a 2.5 second timeout wrapper to prevent slow refreshing from locking the app
|
| 158 |
+
const sessionPromise = supabase.auth.getSession();
|
| 159 |
+
const sessionTimeoutPromise = new Promise((_, reject) =>
|
| 160 |
+
setTimeout(() => reject(new Error('Session fetch timed out')), 2500)
|
| 161 |
+
);
|
| 162 |
+
|
| 163 |
+
const { data: { session } } = await Promise.race([sessionPromise, sessionTimeoutPromise]);
|
| 164 |
setSession(session);
|
| 165 |
|
| 166 |
+
// 3. If session is valid, validate/fetch the user profile
|
| 167 |
if (session?.user) {
|
| 168 |
+
const profilePromise = supabase
|
| 169 |
.from('profiles')
|
| 170 |
.select('status, role')
|
| 171 |
.eq('id', session.user.id)
|
| 172 |
.single();
|
| 173 |
+
|
| 174 |
+
const profileTimeoutPromise = new Promise((_, reject) =>
|
| 175 |
+
setTimeout(() => reject(new Error('Profile fetch timed out')), 2000)
|
| 176 |
+
);
|
| 177 |
+
|
| 178 |
+
const { data, error } = await Promise.race([profilePromise, profileTimeoutPromise]);
|
| 179 |
|
| 180 |
if (error) {
|
| 181 |
console.log('[AuthInit] Profile fetch error, validating session:', error.message);
|
|
|
|
| 184 |
if (userError) {
|
| 185 |
console.log('[AuthInit] Token validation failed. Clearing session.');
|
| 186 |
setSession(null);
|
| 187 |
+
await AsyncStorage.removeItem('@user_status');
|
| 188 |
+
await AsyncStorage.removeItem('@user_role');
|
| 189 |
} else {
|
| 190 |
+
// Valid token but profiles table is offline; retain cached or default to safe user status
|
| 191 |
+
if (!cachedStatus) setUserStatus('active');
|
| 192 |
+
if (!cachedRole) setUserRole('user');
|
| 193 |
}
|
| 194 |
} else {
|
| 195 |
+
const status = data?.status || 'active';
|
| 196 |
+
const role = data?.role || 'user';
|
| 197 |
+
setUserStatus(status);
|
| 198 |
+
setUserRole(role);
|
| 199 |
+
await Promise.all([
|
| 200 |
+
AsyncStorage.setItem('@user_status', status),
|
| 201 |
+
AsyncStorage.setItem('@user_role', role),
|
| 202 |
+
]);
|
| 203 |
}
|
| 204 |
}
|
| 205 |
} catch (e) {
|
| 206 |
+
console.log('[AuthInit] Exception caught during initialization:', e.message || e);
|
| 207 |
} finally {
|
| 208 |
+
finished = true;
|
| 209 |
+
clearTimeout(timeoutId);
|
| 210 |
try {
|
| 211 |
const onboardingDone = await AsyncStorage.getItem('@onboarding_complete');
|
| 212 |
setShowOnboarding(onboardingDone === null);
|
|
|
|
| 231 |
|
| 232 |
if (error) {
|
| 233 |
console.log('[AuthChange] Profile query failed:', error.message);
|
|
|
|
| 234 |
setUserStatus('active');
|
| 235 |
setUserRole('user');
|
| 236 |
} else {
|
| 237 |
+
const status = data?.status || 'active';
|
| 238 |
+
const role = data?.role || 'user';
|
| 239 |
+
setUserStatus(status);
|
| 240 |
+
setUserRole(role);
|
| 241 |
+
await Promise.all([
|
| 242 |
+
AsyncStorage.setItem('@user_status', status),
|
| 243 |
+
AsyncStorage.setItem('@user_role', role),
|
| 244 |
+
]);
|
| 245 |
}
|
| 246 |
} catch (err) {
|
| 247 |
console.warn('[AuthChange] Uncaught exception inside handler:', err);
|
|
|
|
| 251 |
} else {
|
| 252 |
setUserStatus(null);
|
| 253 |
setUserRole('user');
|
| 254 |
+
await Promise.all([
|
| 255 |
+
AsyncStorage.removeItem('@user_status'),
|
| 256 |
+
AsyncStorage.removeItem('@user_role'),
|
| 257 |
+
]);
|
| 258 |
}
|
| 259 |
});
|
| 260 |
|
|
|
|
| 272 |
schema: 'public',
|
| 273 |
table: 'profiles',
|
| 274 |
filter: `id=eq.${session.user.id}`,
|
| 275 |
+
}, async (payload) => {
|
| 276 |
+
const status = payload.new.status;
|
| 277 |
+
const role = payload.new.role || 'user';
|
| 278 |
+
setUserStatus(status);
|
| 279 |
+
setUserRole(role);
|
| 280 |
+
await Promise.all([
|
| 281 |
+
AsyncStorage.setItem('@user_status', status),
|
| 282 |
+
AsyncStorage.setItem('@user_role', role),
|
| 283 |
+
]);
|
| 284 |
})
|
| 285 |
.subscribe();
|
| 286 |
|
|
|
|
| 291 |
useEffect(() => {
|
| 292 |
const handleUrl = async ({ url }) => {
|
| 293 |
if (!url) return;
|
|
|
|
| 294 |
const hashIndex = url.indexOf('#');
|
| 295 |
if (hashIndex === -1) return;
|
| 296 |
const hash = url.substring(hashIndex + 1);
|
|
|
|
| 316 |
}
|
| 317 |
};
|
| 318 |
|
|
|
|
| 319 |
const subscription = Linking.addEventListener('url', handleUrl);
|
|
|
|
| 320 |
Linking.getInitialURL().then(url => url && handleUrl({ url }));
|
| 321 |
|
| 322 |
return () => subscription.remove();
|
MobileApp/metro.config.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// metro.config.js – extend Expo's default Metro config
|
| 2 |
+
const { getDefaultConfig } = require('expo/metro-config');
|
| 3 |
+
|
| 4 |
+
module.exports = (async () => {
|
| 5 |
+
const defaultConfig = await getDefaultConfig(__dirname);
|
| 6 |
+
// Add any customizations here if needed (e.g., extra asset extensions)
|
| 7 |
+
return defaultConfig;
|
| 8 |
+
})();
|
MobileApp/package-lock.json
CHANGED
|
@@ -31,7 +31,7 @@
|
|
| 31 |
"react-native-screens": "~4.16.0",
|
| 32 |
"react-native-svg": "15.12.1",
|
| 33 |
"react-native-url-polyfill": "^3.0.0",
|
| 34 |
-
"react-native-webview": "^13.
|
| 35 |
"zustand": "^5.0.12"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
|
|
|
| 31 |
"react-native-screens": "~4.16.0",
|
| 32 |
"react-native-svg": "15.12.1",
|
| 33 |
"react-native-url-polyfill": "^3.0.0",
|
| 34 |
+
"react-native-webview": "^13.15.0",
|
| 35 |
"zustand": "^5.0.12"
|
| 36 |
},
|
| 37 |
"devDependencies": {
|
MobileApp/package.json
CHANGED
|
@@ -32,7 +32,7 @@
|
|
| 32 |
"react-native-screens": "~4.16.0",
|
| 33 |
"react-native-svg": "15.12.1",
|
| 34 |
"react-native-url-polyfill": "^3.0.0",
|
| 35 |
-
"react-native-webview": "^13.
|
| 36 |
"zustand": "^5.0.12"
|
| 37 |
},
|
| 38 |
"private": true,
|
|
|
|
| 32 |
"react-native-screens": "~4.16.0",
|
| 33 |
"react-native-svg": "15.12.1",
|
| 34 |
"react-native-url-polyfill": "^3.0.0",
|
| 35 |
+
"react-native-webview": "^13.15.0",
|
| 36 |
"zustand": "^5.0.12"
|
| 37 |
},
|
| 38 |
"private": true,
|
backend/main.py
CHANGED
|
@@ -20,12 +20,13 @@ from contextlib import asynccontextmanager
|
|
| 20 |
warnings.filterwarnings("ignore", message="'pin_memory'")
|
| 21 |
|
| 22 |
# HF Rebuild Trigger: 2026-03-08-2030
|
| 23 |
-
from fastapi import FastAPI, Depends, HTTPException, Request
|
| 24 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 25 |
from slowapi.util import get_remote_address
|
| 26 |
from slowapi.errors import RateLimitExceeded
|
| 27 |
from fastapi.middleware.cors import CORSMiddleware
|
| 28 |
-
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
|
|
| 29 |
from fastapi.encoders import jsonable_encoder
|
| 30 |
import asyncio
|
| 31 |
from pathlib import Path
|
|
@@ -69,12 +70,148 @@ from backend.services.audit_service import AuditLogService, AuditLogAccessError
|
|
| 69 |
from backend.services.onnx_service import onnx_classifier
|
| 70 |
from backend.services.ner_service import NERService
|
| 71 |
from backend.services.duplicate_service import DuplicateService
|
|
|
|
| 72 |
from backend.services.rag_service import RagService
|
|
|
|
| 73 |
from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
|
| 74 |
from backend.services.redis_cache import redis_cache
|
| 75 |
from backend.auth_cookie import router as auth_cookie_router, get_current_user # noqa: F401
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
# ---------------------------------------------------------------------------
|
| 79 |
# Request / Response models
|
| 80 |
# ---------------------------------------------------------------------------
|
|
@@ -220,6 +357,14 @@ class EntityInfo(BaseModel):
|
|
| 220 |
confidence: float
|
| 221 |
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
class TicketResponse(BaseModel):
|
| 224 |
id: str | int | None = None
|
| 225 |
ticket_id: str | None = None
|
|
@@ -245,6 +390,7 @@ class TicketResponse(BaseModel):
|
|
| 245 |
source_language: str = "en"
|
| 246 |
source_language_name: str = "English"
|
| 247 |
was_translated: bool = False
|
|
|
|
| 248 |
version: str = "2.1.0-Neural-Diagnostic"
|
| 249 |
|
| 250 |
|
|
@@ -308,12 +454,11 @@ class ReadinessResponse(BaseModel):
|
|
| 308 |
# ---------------------------------------------------------------------------
|
| 309 |
# Service singletons
|
| 310 |
# ---------------------------------------------------------------------------
|
| 311 |
-
from backend.services.semantic_duplicate_service import SemanticDuplicateService
|
| 312 |
-
|
| 313 |
classifier_service = ClassifierService()
|
| 314 |
ner_service = NERService()
|
| 315 |
duplicate_service = DuplicateService()
|
| 316 |
rag_service = RagService()
|
|
|
|
| 317 |
sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
|
| 318 |
semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
|
| 319 |
|
|
@@ -464,7 +609,19 @@ async def lifespan(app: FastAPI):
|
|
| 464 |
print("[Startup] Classifier V2 Shadow: Ready.")
|
| 465 |
print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
|
| 466 |
print("[Startup] Ready.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
yield
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
print("[Shutdown] Cleaning up ...")
|
| 469 |
|
| 470 |
|
|
@@ -891,21 +1048,11 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 891 |
final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
|
| 892 |
final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
|
| 893 |
|
| 894 |
-
import hashlib
|
| 895 |
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 896 |
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 897 |
|
| 898 |
duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
|
| 899 |
-
duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85)
|
| 900 |
-
duplicate_result = {
|
| 901 |
-
"is_duplicate": False,
|
| 902 |
-
"duplicate_ticket_id": None,
|
| 903 |
-
"parent_ticket_id": None,
|
| 904 |
-
"is_potential_duplicate": False,
|
| 905 |
-
"similarity": 0.0,
|
| 906 |
-
}
|
| 907 |
-
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 908 |
-
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 909 |
|
| 910 |
|
| 911 |
# Semantic duplicate check BEFORE inserting the ticket
|
|
@@ -1005,12 +1152,71 @@ async def save_ticket(request_body: TicketSaveRequest):
|
|
| 1005 |
response["parent_subject"] = duplicate_check_result.get("parent_subject")
|
| 1006 |
response["similarity"] = duplicate_check_result["similarity"]
|
| 1007 |
response["candidates"] = duplicate_check_result.get("candidates", [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1008 |
return response
|
| 1009 |
|
| 1010 |
except Exception as e:
|
| 1011 |
traceback.print_exc()
|
| 1012 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1013 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1014 |
@app.get("/tickets/{ticket_id}")
|
| 1015 |
async def get_ticket_by_id(
|
| 1016 |
request: Request,
|
|
@@ -1148,8 +1354,9 @@ async def analyze_ticket(request_body: TicketRequest, request: Request):
|
|
| 1148 |
text = f"{text} {local_ocr_text}".strip()
|
| 1149 |
print(f"[AI] OCR added {len(local_ocr_text)} chars to context.")
|
| 1150 |
|
| 1151 |
-
#
|
| 1152 |
-
|
|
|
|
| 1153 |
|
| 1154 |
@app.post("/ai/analyze")
|
| 1155 |
async def analyze_only(request_body: TicketRequest):
|
|
@@ -1202,8 +1409,6 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1202 |
highlights=[],
|
| 1203 |
timeline={"received": _dt.datetime.utcnow().isoformat() + "Z"},
|
| 1204 |
env_metadata={},
|
| 1205 |
-
is_potential_duplicate=False,
|
| 1206 |
-
parent_ticket_id=None,
|
| 1207 |
sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
|
| 1208 |
original_text=request_body.text,
|
| 1209 |
source_language=translation_ctx["source_language"],
|
|
@@ -1240,6 +1445,16 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1240 |
|
| 1241 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1243 |
# --- Classification ---
|
| 1244 |
classification = classify_ticket_text(text)
|
| 1245 |
|
|
@@ -1282,10 +1497,18 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1282 |
decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
|
| 1283 |
if rag_match:
|
| 1284 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1285 |
|
| 1286 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1287 |
if classification["auto_resolve"]:
|
| 1288 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
|
|
|
|
|
|
| 1289 |
|
| 1290 |
timeline["routed"] = get_now_ist()
|
| 1291 |
|
|
@@ -1317,6 +1540,7 @@ async def analyze_only(request_body: TicketRequest):
|
|
| 1317 |
highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
|
| 1318 |
timeline=timeline,
|
| 1319 |
env_metadata=env_metadata,
|
|
|
|
| 1320 |
is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
|
| 1321 |
parent_ticket_id=dup_result.get("parent_ticket_id"),
|
| 1322 |
sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z"),
|
|
@@ -1358,6 +1582,15 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1358 |
|
| 1359 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1361 |
# 2. NER
|
| 1362 |
yield f"data: {json.dumps({'step': 'Extracting technical entities', 'status': 'in_progress'})}\n\n"
|
| 1363 |
await asyncio.sleep(0.2)
|
|
@@ -1404,10 +1637,18 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1404 |
decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
|
| 1405 |
if rag_match:
|
| 1406 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1407 |
|
| 1408 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1409 |
if classification["auto_resolve"]:
|
| 1410 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
|
|
|
|
|
|
| 1411 |
|
| 1412 |
timeline["routed"] = get_now_ist()
|
| 1413 |
|
|
@@ -1437,6 +1678,7 @@ async def analyze_stream(request_body: TicketRequest):
|
|
| 1437 |
"highlights": [e.get("text", "") for e in entities],
|
| 1438 |
"timeline": timeline,
|
| 1439 |
"env_metadata": env_metadata,
|
|
|
|
| 1440 |
"sla_breach_at": sla_breach_dt.isoformat() + "Z"
|
| 1441 |
}
|
| 1442 |
|
|
@@ -1594,14 +1836,15 @@ async def sla_policies():
|
|
| 1594 |
if not supabase:
|
| 1595 |
# Return defaults from code
|
| 1596 |
policies = []
|
| 1597 |
-
|
|
|
|
| 1598 |
policies.append({
|
| 1599 |
"priority": pri,
|
| 1600 |
"max_hours": cfg["max_hours"],
|
| 1601 |
"warning_pct": cfg["warning_pct"],
|
| 1602 |
-
"auto_escalate": cfg
|
| 1603 |
-
"l2_after_minutes": cfg
|
| 1604 |
-
"l3_after_minutes": cfg
|
| 1605 |
})
|
| 1606 |
return {"policies": policies}
|
| 1607 |
|
|
@@ -1659,6 +1902,7 @@ async def reindex_embeddings():
|
|
| 1659 |
@app.get("/system/settings")
|
| 1660 |
async def get_system_settings_endpoint():
|
| 1661 |
"""Fetch all system settings."""
|
|
|
|
| 1662 |
if not supabase:
|
| 1663 |
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1664 |
try:
|
|
@@ -1668,7 +1912,7 @@ async def get_system_settings_endpoint():
|
|
| 1668 |
settings[row["key"]] = row["value"]
|
| 1669 |
return settings
|
| 1670 |
except Exception as e:
|
| 1671 |
-
|
| 1672 |
return {}
|
| 1673 |
|
| 1674 |
|
|
@@ -1724,3 +1968,9 @@ async def sla_ticket_detail(ticket_id: str):
|
|
| 1724 |
"sla_evaluation": result,
|
| 1725 |
"escalations": escalations,
|
| 1726 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
warnings.filterwarnings("ignore", message="'pin_memory'")
|
| 21 |
|
| 22 |
# HF Rebuild Trigger: 2026-03-08-2030
|
| 23 |
+
from fastapi import FastAPI, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
| 24 |
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 25 |
from slowapi.util import get_remote_address
|
| 26 |
from slowapi.errors import RateLimitExceeded
|
| 27 |
from fastapi.middleware.cors import CORSMiddleware
|
| 28 |
+
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
| 29 |
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
| 30 |
from fastapi.encoders import jsonable_encoder
|
| 31 |
import asyncio
|
| 32 |
from pathlib import Path
|
|
|
|
| 70 |
from backend.services.onnx_service import onnx_classifier
|
| 71 |
from backend.services.ner_service import NERService
|
| 72 |
from backend.services.duplicate_service import DuplicateService
|
| 73 |
+
from backend.services.semantic_duplicate_service import SemanticDuplicateService
|
| 74 |
from backend.services.rag_service import RagService
|
| 75 |
+
from backend.services.spam_service import SpamService
|
| 76 |
from backend.services.sla_engine import SLAEngine, compute_sla_breach_at, get_sla_policy
|
| 77 |
from backend.services.redis_cache import redis_cache
|
| 78 |
from backend.auth_cookie import router as auth_cookie_router, get_current_user # noqa: F401
|
| 79 |
|
| 80 |
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# WebSocket Connection Manager — real-time ticket dashboards
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
HEARTBEAT_INTERVAL = 30 # seconds between ping broadcasts
|
| 86 |
+
HEARTBEAT_TIMEOUT = 10 # seconds to wait for a pong before disconnect
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class ConnectionManager:
|
| 90 |
+
"""Tracks active WebSocket connections grouped by ``company_id``.
|
| 91 |
+
|
| 92 |
+
Thread-safe for concurrent connect/disconnect calls from multiple
|
| 93 |
+
ASGI workers (single-process via ``asyncio.Lock``).
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
def __init__(self) -> None:
|
| 97 |
+
self._connections: dict[str, set[WebSocket]] = {}
|
| 98 |
+
self._lock = asyncio.Lock()
|
| 99 |
+
|
| 100 |
+
async def connect(self, company_id: str, ws: WebSocket) -> None:
|
| 101 |
+
"""Accept a new WebSocket and register it under ``company_id``."""
|
| 102 |
+
await ws.accept()
|
| 103 |
+
async with self._lock:
|
| 104 |
+
self._connections.setdefault(company_id, set()).add(ws)
|
| 105 |
+
|
| 106 |
+
async def disconnect(self, company_id: str, ws: WebSocket) -> None:
|
| 107 |
+
"""Remove a WebSocket from the pool."""
|
| 108 |
+
async with self._lock:
|
| 109 |
+
connections = self._connections.get(company_id)
|
| 110 |
+
if connections:
|
| 111 |
+
connections.discard(ws)
|
| 112 |
+
# Clean up empty company groups
|
| 113 |
+
if not connections:
|
| 114 |
+
del self._connections[company_id]
|
| 115 |
+
|
| 116 |
+
async def broadcast(self, company_id: str, message: dict) -> int:
|
| 117 |
+
"""Send a JSON message to every client in a company group.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Number of successfully sent messages.
|
| 121 |
+
"""
|
| 122 |
+
payload = json.dumps(message, default=str)
|
| 123 |
+
sent = 0
|
| 124 |
+
async with self._lock:
|
| 125 |
+
connections = set(self._connections.get(company_id, []))
|
| 126 |
+
|
| 127 |
+
for ws in connections:
|
| 128 |
+
try:
|
| 129 |
+
await ws.send_text(payload)
|
| 130 |
+
sent += 1
|
| 131 |
+
except Exception:
|
| 132 |
+
await self.disconnect(company_id, ws)
|
| 133 |
+
return sent
|
| 134 |
+
|
| 135 |
+
async def broadcast_all(self, message: dict) -> int:
|
| 136 |
+
"""Send a JSON message to **all** connected clients."""
|
| 137 |
+
payload = json.dumps(message, default=str)
|
| 138 |
+
sent = 0
|
| 139 |
+
async with self._lock:
|
| 140 |
+
all_connections = {
|
| 141 |
+
ws for group in self._connections.values() for ws in group
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
for ws in all_connections:
|
| 145 |
+
try:
|
| 146 |
+
await ws.send_text(payload)
|
| 147 |
+
sent += 1
|
| 148 |
+
except Exception:
|
| 149 |
+
pass
|
| 150 |
+
return sent
|
| 151 |
+
|
| 152 |
+
async def ping_all(self) -> None:
|
| 153 |
+
"""Send a ``{"type": "ping"}`` heartbeat to every connection.
|
| 154 |
+
|
| 155 |
+
Connections that fail to receive the ping are removed.
|
| 156 |
+
"""
|
| 157 |
+
async with self._lock:
|
| 158 |
+
# Snapshot all connections under lock so iteration is safe
|
| 159 |
+
snapshot = {
|
| 160 |
+
cid: set(ws_set) for cid, ws_set in self._connections.items()
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
for cid, ws_set in snapshot.items():
|
| 164 |
+
for ws in list(ws_set):
|
| 165 |
+
try:
|
| 166 |
+
await ws.send_json({"type": "ping"})
|
| 167 |
+
except Exception:
|
| 168 |
+
await self.disconnect(cid, ws)
|
| 169 |
+
|
| 170 |
+
@property
|
| 171 |
+
def active_count(self) -> int:
|
| 172 |
+
"""Total number of connected clients across all companies."""
|
| 173 |
+
return sum(len(ws_set) for ws_set in self._connections.values())
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# Singleton — reused across lifespan and WebSocket route
|
| 177 |
+
connection_manager = ConnectionManager()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
async def _heartbeat_loop() -> None:
|
| 181 |
+
"""Background task: broadcast ping every ``HEARTBEAT_INTERVAL`` seconds.
|
| 182 |
+
|
| 183 |
+
Clients that fail the ping are disconnected automatically by
|
| 184 |
+
``ConnectionManager.ping_all()``.
|
| 185 |
+
"""
|
| 186 |
+
while True:
|
| 187 |
+
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
| 188 |
+
try:
|
| 189 |
+
await connection_manager.ping_all()
|
| 190 |
+
count = connection_manager.active_count
|
| 191 |
+
if count:
|
| 192 |
+
print(f"[WS] Heartbeat sent to {count} active connection(s)")
|
| 193 |
+
except Exception as exc:
|
| 194 |
+
print(f"[WS] Heartbeat error: {exc}")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ---------------------------------------------------------------------------
|
| 198 |
+
# SLA helper functions (must be defined before save_ticket uses them)
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
|
| 201 |
+
def calculate_sla_breach_at(priority: str) -> datetime.datetime:
|
| 202 |
+
"""Return the UTC datetime by which the ticket must be resolved."""
|
| 203 |
+
hours_map = {"critical": 2, "high": 8, "medium": 24, "low": 72}
|
| 204 |
+
hours = hours_map.get(str(priority).lower().strip(), 72)
|
| 205 |
+
return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def calculate_sla_response_at(priority: str) -> datetime.datetime:
|
| 209 |
+
"""Return the UTC datetime by which the ticket must receive a first response."""
|
| 210 |
+
hours_map = {"critical": 0.5, "high": 2, "medium": 6, "low": 18}
|
| 211 |
+
hours = hours_map.get(str(priority).lower().strip(), 6)
|
| 212 |
+
return datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=hours)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
# ---------------------------------------------------------------------------
|
| 216 |
# Request / Response models
|
| 217 |
# ---------------------------------------------------------------------------
|
|
|
|
| 357 |
confidence: float
|
| 358 |
|
| 359 |
|
| 360 |
+
class SpamCheck(BaseModel):
|
| 361 |
+
is_spam: bool = False
|
| 362 |
+
risk_score: float = 0.0
|
| 363 |
+
reasons: list[str] = []
|
| 364 |
+
suspicious_urls: list[str] = []
|
| 365 |
+
matched_keywords: list[str] = []
|
| 366 |
+
|
| 367 |
+
|
| 368 |
class TicketResponse(BaseModel):
|
| 369 |
id: str | int | None = None
|
| 370 |
ticket_id: str | None = None
|
|
|
|
| 390 |
source_language: str = "en"
|
| 391 |
source_language_name: str = "English"
|
| 392 |
was_translated: bool = False
|
| 393 |
+
spam_check: SpamCheck = SpamCheck()
|
| 394 |
version: str = "2.1.0-Neural-Diagnostic"
|
| 395 |
|
| 396 |
|
|
|
|
| 454 |
# ---------------------------------------------------------------------------
|
| 455 |
# Service singletons
|
| 456 |
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
| 457 |
classifier_service = ClassifierService()
|
| 458 |
ner_service = NERService()
|
| 459 |
duplicate_service = DuplicateService()
|
| 460 |
rag_service = RagService()
|
| 461 |
+
spam_service = SpamService()
|
| 462 |
sla_engine = SLAEngine(supabase_client=None) # Will be reassigned after supabase init
|
| 463 |
semantic_dupe_service = SemanticDuplicateService(supabase_client=None) # wired in lifespan
|
| 464 |
|
|
|
|
| 609 |
print("[Startup] Classifier V2 Shadow: Ready.")
|
| 610 |
print(f"[Startup] ONNX MiniLM Fallback: {'READY' if getattr(onnx_classifier, '_loaded', False) else 'DEGRADED (artifacts missing)'}")
|
| 611 |
print("[Startup] Ready.")
|
| 612 |
+
|
| 613 |
+
# Start WebSocket heartbeat background loop
|
| 614 |
+
heartbeat_task = asyncio.create_task(_heartbeat_loop())
|
| 615 |
+
print("[Startup] WebSocket heartbeat loop started (interval=30s).")
|
| 616 |
+
|
| 617 |
yield
|
| 618 |
+
|
| 619 |
+
# Cancel background tasks on shutdown
|
| 620 |
+
heartbeat_task.cancel()
|
| 621 |
+
try:
|
| 622 |
+
await heartbeat_task
|
| 623 |
+
except asyncio.CancelledError:
|
| 624 |
+
pass
|
| 625 |
print("[Shutdown] Cleaning up ...")
|
| 626 |
|
| 627 |
|
|
|
|
| 1048 |
final_data["sla_status"] = final_data.get("sla_status") or classify_sla_status(final_data.get("sla_breach_at"))
|
| 1049 |
final_data["escalation_level"] = int(final_data.get("escalation_level") or 0)
|
| 1050 |
|
|
|
|
| 1051 |
user_hash = hashlib.sha256(str(request_body.user_id).encode()).hexdigest()[:8]
|
| 1052 |
logger.info(f"Tenant linkage: user_hash={user_hash}, company_id={final_data.get('company_id')}")
|
| 1053 |
|
| 1054 |
duplicate_text = (request_body.description or "").strip() or (request_body.subject or "").strip()
|
| 1055 |
+
duplicate_threshold = get_duplicate_threshold(final_data.get("company_id"), 0.85) # noqa: F841
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1056 |
|
| 1057 |
|
| 1058 |
# Semantic duplicate check BEFORE inserting the ticket
|
|
|
|
| 1152 |
response["parent_subject"] = duplicate_check_result.get("parent_subject")
|
| 1153 |
response["similarity"] = duplicate_check_result["similarity"]
|
| 1154 |
response["candidates"] = duplicate_check_result.get("candidates", [])
|
| 1155 |
+
|
| 1156 |
+
# Broadcast the new/updated ticket to all WebSocket clients for this company
|
| 1157 |
+
company_id = final_data.get("company_id")
|
| 1158 |
+
if company_id:
|
| 1159 |
+
asyncio.create_task(
|
| 1160 |
+
connection_manager.broadcast(
|
| 1161 |
+
company_id,
|
| 1162 |
+
{
|
| 1163 |
+
"type": "ticket_update",
|
| 1164 |
+
"event": "created",
|
| 1165 |
+
"ticket": insert_data,
|
| 1166 |
+
"ticket_id": str(ticket_id),
|
| 1167 |
+
},
|
| 1168 |
+
)
|
| 1169 |
+
)
|
| 1170 |
return response
|
| 1171 |
|
| 1172 |
except Exception as e:
|
| 1173 |
traceback.print_exc()
|
| 1174 |
raise HTTPException(status_code=500, detail=str(e))
|
| 1175 |
|
| 1176 |
+
@app.websocket("/ws/{company_id}")
|
| 1177 |
+
async def websocket_endpoint(ws: WebSocket, company_id: str):
|
| 1178 |
+
"""Real-time WebSocket feed for a company's ticket dashboard.
|
| 1179 |
+
|
| 1180 |
+
Protocol:
|
| 1181 |
+
- Server sends ``{"type": "ping"}`` every 30s (heartbeat).
|
| 1182 |
+
- Client must respond with ``{"type": "pong"}`` within 10s.
|
| 1183 |
+
- Server pushes ``{"type": "ticket_update", ...}`` on changes.
|
| 1184 |
+
|
| 1185 |
+
Usage (frontend):
|
| 1186 |
+
const socket = new WebSocket("ws://host:7860/ws/{company_id}");
|
| 1187 |
+
socket.onmessage = (event) => { const msg = JSON.parse(event.data); };
|
| 1188 |
+
"""
|
| 1189 |
+
if not company_id or not company_id.strip():
|
| 1190 |
+
await ws.close(code=4000, reason="Missing company_id")
|
| 1191 |
+
return
|
| 1192 |
+
|
| 1193 |
+
company_id = company_id.strip()
|
| 1194 |
+
await connection_manager.connect(company_id, ws)
|
| 1195 |
+
print(f"[WS] Client connected — company_id={company_id}")
|
| 1196 |
+
|
| 1197 |
+
try:
|
| 1198 |
+
while True:
|
| 1199 |
+
raw = await ws.receive_text()
|
| 1200 |
+
if not raw.strip():
|
| 1201 |
+
continue
|
| 1202 |
+
try:
|
| 1203 |
+
data = json.loads(raw)
|
| 1204 |
+
except json.JSONDecodeError:
|
| 1205 |
+
continue # ignore malformed frames
|
| 1206 |
+
|
| 1207 |
+
# Handle pong response
|
| 1208 |
+
if data.get("type") == "pong":
|
| 1209 |
+
continue
|
| 1210 |
+
|
| 1211 |
+
except WebSocketDisconnect:
|
| 1212 |
+
pass
|
| 1213 |
+
except Exception as exc:
|
| 1214 |
+
print(f"[WS] Connection error for company_id={company_id}: {exc}")
|
| 1215 |
+
finally:
|
| 1216 |
+
await connection_manager.disconnect(company_id, ws)
|
| 1217 |
+
print(f"[WS] Client disconnected — company_id={company_id}")
|
| 1218 |
+
|
| 1219 |
+
|
| 1220 |
@app.get("/tickets/{ticket_id}")
|
| 1221 |
async def get_ticket_by_id(
|
| 1222 |
request: Request,
|
|
|
|
| 1354 |
text = f"{text} {local_ocr_text}".strip()
|
| 1355 |
print(f"[AI] OCR added {len(local_ocr_text)} chars to context.")
|
| 1356 |
|
| 1357 |
+
# Pass OCR-enriched text downstream so the analyze_only endpoint uses it.
|
| 1358 |
+
enriched = request_body.model_copy(update={"text": text, "image_text": local_ocr_text})
|
| 1359 |
+
return await analyze_only(enriched)
|
| 1360 |
|
| 1361 |
@app.post("/ai/analyze")
|
| 1362 |
async def analyze_only(request_body: TicketRequest):
|
|
|
|
| 1409 |
highlights=[],
|
| 1410 |
timeline={"received": _dt.datetime.utcnow().isoformat() + "Z"},
|
| 1411 |
env_metadata={},
|
|
|
|
|
|
|
| 1412 |
sla_breach_at=_sla_breach.isoformat().replace("+00:00", "Z"),
|
| 1413 |
original_text=request_body.text,
|
| 1414 |
source_language=translation_ctx["source_language"],
|
|
|
|
| 1445 |
|
| 1446 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1447 |
|
| 1448 |
+
# --- Spam / Phishing Detection (runs before classification) ---
|
| 1449 |
+
try:
|
| 1450 |
+
spam_result = spam_service.check(text, gemini_analysis.get("ocr_text", ""))
|
| 1451 |
+
except Exception as e:
|
| 1452 |
+
print(f"[SPAM ERROR] {e}")
|
| 1453 |
+
spam_result = {
|
| 1454 |
+
"is_spam": False, "risk_score": 0.0, "reasons": [],
|
| 1455 |
+
"suspicious_urls": [], "matched_keywords": [],
|
| 1456 |
+
}
|
| 1457 |
+
|
| 1458 |
# --- Classification ---
|
| 1459 |
classification = classify_ticket_text(text)
|
| 1460 |
|
|
|
|
| 1497 |
decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
|
| 1498 |
if rag_match:
|
| 1499 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1500 |
+
if spam_result["is_spam"]:
|
| 1501 |
+
decision_factors.append(
|
| 1502 |
+
f"Flagged as spam/phishing (risk {spam_result['risk_score']:.2f})"
|
| 1503 |
+
)
|
| 1504 |
+
classification["assigned_team"] = "Spam / Suspicious"
|
| 1505 |
+
classification["auto_resolve"] = False
|
| 1506 |
|
| 1507 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1508 |
if classification["auto_resolve"]:
|
| 1509 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
| 1510 |
+
if spam_result["is_spam"]:
|
| 1511 |
+
reasoning += " Ticket flagged as spam/phishing and quarantined from agent inbox."
|
| 1512 |
|
| 1513 |
timeline["routed"] = get_now_ist()
|
| 1514 |
|
|
|
|
| 1540 |
highlights=[e.get("text", "") for e in entities], # Use entity texts as highlights for now
|
| 1541 |
timeline=timeline,
|
| 1542 |
env_metadata=env_metadata,
|
| 1543 |
+
spam_check=SpamCheck(**spam_result),
|
| 1544 |
is_potential_duplicate=dup_result.get("is_potential_duplicate", False),
|
| 1545 |
parent_ticket_id=dup_result.get("parent_ticket_id"),
|
| 1546 |
sla_breach_at=sla_breach_dt.isoformat().replace("+00:00", "Z"),
|
|
|
|
| 1582 |
|
| 1583 |
summary = text[:100] + ("…" if len(text) > 100 else "")
|
| 1584 |
|
| 1585 |
+
# Spam / Phishing check (silent step — does not get its own SSE event)
|
| 1586 |
+
try:
|
| 1587 |
+
spam_result = spam_service.check(text, gemini_analysis.get("ocr_text", ""))
|
| 1588 |
+
except Exception:
|
| 1589 |
+
spam_result = {
|
| 1590 |
+
"is_spam": False, "risk_score": 0.0, "reasons": [],
|
| 1591 |
+
"suspicious_urls": [], "matched_keywords": [],
|
| 1592 |
+
}
|
| 1593 |
+
|
| 1594 |
# 2. NER
|
| 1595 |
yield f"data: {json.dumps({'step': 'Extracting technical entities', 'status': 'in_progress'})}\n\n"
|
| 1596 |
await asyncio.sleep(0.2)
|
|
|
|
| 1637 |
decision_factors.append(f"Found similar incident ({int(dup_result['similarity']*100)}%)")
|
| 1638 |
if rag_match:
|
| 1639 |
decision_factors.append(f"Found solution article: '{rag_match['title']}'")
|
| 1640 |
+
if spam_result["is_spam"]:
|
| 1641 |
+
decision_factors.append(
|
| 1642 |
+
f"Flagged as spam/phishing (risk {spam_result['risk_score']:.2f})"
|
| 1643 |
+
)
|
| 1644 |
+
classification["assigned_team"] = "Spam / Suspicious"
|
| 1645 |
+
classification["auto_resolve"] = False
|
| 1646 |
|
| 1647 |
reasoning = f"Categorized as '{classification['category']}' - {classification['subcategory']}."
|
| 1648 |
if classification["auto_resolve"]:
|
| 1649 |
reasoning += " Flagged for AI auto-resolution via Knowledge Base." if rag_match else " Flagged for auto-resolution."
|
| 1650 |
+
if spam_result["is_spam"]:
|
| 1651 |
+
reasoning += " Ticket flagged as spam/phishing and quarantined from agent inbox."
|
| 1652 |
|
| 1653 |
timeline["routed"] = get_now_ist()
|
| 1654 |
|
|
|
|
| 1678 |
"highlights": [e.get("text", "") for e in entities],
|
| 1679 |
"timeline": timeline,
|
| 1680 |
"env_metadata": env_metadata,
|
| 1681 |
+
"spam_check": spam_result,
|
| 1682 |
"sla_breach_at": sla_breach_dt.isoformat() + "Z"
|
| 1683 |
}
|
| 1684 |
|
|
|
|
| 1836 |
if not supabase:
|
| 1837 |
# Return defaults from code
|
| 1838 |
policies = []
|
| 1839 |
+
policy_source = sla_engine.SLA_POLICIES if hasattr(sla_engine, "SLA_POLICIES") else {}
|
| 1840 |
+
for pri, cfg in policy_source.items():
|
| 1841 |
policies.append({
|
| 1842 |
"priority": pri,
|
| 1843 |
"max_hours": cfg["max_hours"],
|
| 1844 |
"warning_pct": cfg["warning_pct"],
|
| 1845 |
+
"auto_escalate": cfg.get("auto_escalate_on_breach", False),
|
| 1846 |
+
"l2_after_minutes": cfg.get("l2_escalation_mins", 0),
|
| 1847 |
+
"l3_after_minutes": cfg.get("l3_escalation_mins", 0),
|
| 1848 |
})
|
| 1849 |
return {"policies": policies}
|
| 1850 |
|
|
|
|
| 1902 |
@app.get("/system/settings")
|
| 1903 |
async def get_system_settings_endpoint():
|
| 1904 |
"""Fetch all system settings."""
|
| 1905 |
+
_logger = logging.getLogger(__name__)
|
| 1906 |
if not supabase:
|
| 1907 |
raise HTTPException(status_code=503, detail="Database not connected")
|
| 1908 |
try:
|
|
|
|
| 1912 |
settings[row["key"]] = row["value"]
|
| 1913 |
return settings
|
| 1914 |
except Exception as e:
|
| 1915 |
+
_logger.warning(f"[SETTINGS] Query failed: {e}")
|
| 1916 |
return {}
|
| 1917 |
|
| 1918 |
|
|
|
|
| 1968 |
"sla_evaluation": result,
|
| 1969 |
"escalations": escalations,
|
| 1970 |
}
|
| 1971 |
+
|
| 1972 |
+
|
| 1973 |
+
@app.get("/metrics")
|
| 1974 |
+
async def metrics():
|
| 1975 |
+
"""Prometheus scrape endpoint — exposes AI inference latency, request counts, and tokens."""
|
| 1976 |
+
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
backend/requirements.txt
CHANGED
|
@@ -22,4 +22,6 @@ pytest
|
|
| 22 |
pytest-asyncio
|
| 23 |
httpx
|
| 24 |
cryptography>=42.0.0
|
|
|
|
|
|
|
| 25 |
|
|
|
|
| 22 |
pytest-asyncio
|
| 23 |
httpx
|
| 24 |
cryptography>=42.0.0
|
| 25 |
+
websockets>=12.0
|
| 26 |
+
prometheus-client>=0.19.0
|
| 27 |
|
backend/services/classifier_service.py
CHANGED
|
@@ -22,6 +22,16 @@ SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "model
|
|
| 22 |
DEVICE = torch.device("cuda" if torch and torch.cuda.is_available() else "cpu") if _HAS_TORCH else None
|
| 23 |
MAX_LEN = 128
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
# Priority mapping based on sub-category severity
|
| 26 |
PRIORITY_MAP = {
|
| 27 |
"Blue Screen": "Critical", "Overheating": "Critical", "Data Loss": "Critical",
|
|
@@ -111,11 +121,22 @@ class ClassifierService:
|
|
| 111 |
input_ids = encoding["input_ids"].to(DEVICE)
|
| 112 |
attention_mask = encoding["attention_mask"].to(DEVICE)
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
pred_idx = pred_idx.item()
|
| 121 |
confidence = round(confidence.item(), 4)
|
|
|
|
| 22 |
DEVICE = torch.device("cuda" if torch and torch.cuda.is_available() else "cpu") if _HAS_TORCH else None
|
| 23 |
MAX_LEN = 128
|
| 24 |
|
| 25 |
+
try:
|
| 26 |
+
from backend.services.metrics_service import (
|
| 27 |
+
CLASSIFIER_LATENCY,
|
| 28 |
+
CLASSIFIER_REQUESTS,
|
| 29 |
+
CLASSIFIER_TOKENS,
|
| 30 |
+
)
|
| 31 |
+
_METRICS_ENABLED = True
|
| 32 |
+
except Exception:
|
| 33 |
+
_METRICS_ENABLED = False
|
| 34 |
+
|
| 35 |
# Priority mapping based on sub-category severity
|
| 36 |
PRIORITY_MAP = {
|
| 37 |
"Blue Screen": "Critical", "Overheating": "Critical", "Data Loss": "Critical",
|
|
|
|
| 121 |
input_ids = encoding["input_ids"].to(DEVICE)
|
| 122 |
attention_mask = encoding["attention_mask"].to(DEVICE)
|
| 123 |
|
| 124 |
+
import time
|
| 125 |
+
_t0 = time.perf_counter()
|
| 126 |
+
try:
|
| 127 |
+
with torch.no_grad():
|
| 128 |
+
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
|
| 129 |
+
logits = outputs.logits
|
| 130 |
+
probs = F.softmax(logits, dim=1)
|
| 131 |
+
confidence, pred_idx = torch.max(probs, dim=1)
|
| 132 |
+
except Exception:
|
| 133 |
+
if _METRICS_ENABLED:
|
| 134 |
+
CLASSIFIER_REQUESTS.labels(model="distilbert", status="error").inc()
|
| 135 |
+
raise
|
| 136 |
+
if _METRICS_ENABLED:
|
| 137 |
+
CLASSIFIER_LATENCY.labels(model="distilbert").observe(time.perf_counter() - _t0)
|
| 138 |
+
CLASSIFIER_REQUESTS.labels(model="distilbert", status="ok").inc()
|
| 139 |
+
CLASSIFIER_TOKENS.labels(model="distilbert").inc(int(attention_mask.sum().item()))
|
| 140 |
|
| 141 |
pred_idx = pred_idx.item()
|
| 142 |
confidence = round(confidence.item(), 4)
|
backend/services/gemini_service.py
CHANGED
|
@@ -28,7 +28,7 @@ class GeminiService:
|
|
| 28 |
else:
|
| 29 |
print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
|
| 30 |
|
| 31 |
-
def analyze_image(self, image_base64: str) -> dict:
|
| 32 |
"""
|
| 33 |
Perform OCR and image analysis using Gemini logic.
|
| 34 |
"""
|
|
@@ -47,6 +47,10 @@ class GeminiService:
|
|
| 47 |
|
| 48 |
prompt = (
|
| 49 |
"Analyze this screenshot from a user reporting a technical issue. "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
"1. Provide a concise description of what is shown in the image. "
|
| 51 |
"2. Perform OCR and extract any error messages or key text. "
|
| 52 |
"3. Identify the main technical problem depicted. "
|
|
|
|
| 28 |
else:
|
| 29 |
print("[GeminiService] WARNING: GEMINI_API_KEY not found in environment.")
|
| 30 |
|
| 31 |
+
def analyze_image(self, image_base64: str, context_text: str = None) -> dict:
|
| 32 |
"""
|
| 33 |
Perform OCR and image analysis using Gemini logic.
|
| 34 |
"""
|
|
|
|
| 47 |
|
| 48 |
prompt = (
|
| 49 |
"Analyze this screenshot from a user reporting a technical issue. "
|
| 50 |
+
)
|
| 51 |
+
if context_text:
|
| 52 |
+
prompt += f"Context/description provided by user: '{context_text}'\n"
|
| 53 |
+
prompt += (
|
| 54 |
"1. Provide a concise description of what is shown in the image. "
|
| 55 |
"2. Perform OCR and extract any error messages or key text. "
|
| 56 |
"3. Identify the main technical problem depicted. "
|
backend/services/spam_service.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Spam & Phishing Detection Service — lightweight, dependency-free heuristics.
|
| 3 |
+
|
| 4 |
+
Scans ticket text (and OCR-extracted text) for common phishing patterns,
|
| 5 |
+
suspicious URLs, and social-engineering keywords. Designed to run before the
|
| 6 |
+
classification cascade so high-risk tickets can be flagged in the UI and
|
| 7 |
+
kept away from support agents' inboxes.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
from urllib.parse import urlparse
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# Common phishing / social-engineering keywords. Kept conservative to avoid
|
| 15 |
+
# false-positives on legitimate IT tickets ("password reset" is normal).
|
| 16 |
+
_PHISHING_KEYWORDS = [
|
| 17 |
+
"verify your account",
|
| 18 |
+
"verify your identity",
|
| 19 |
+
"confirm your password",
|
| 20 |
+
"update your password immediately",
|
| 21 |
+
"account has been suspended",
|
| 22 |
+
"account will be closed",
|
| 23 |
+
"unusual sign-in activity",
|
| 24 |
+
"unusual login attempt",
|
| 25 |
+
"click here to claim",
|
| 26 |
+
"you have won",
|
| 27 |
+
"congratulations you",
|
| 28 |
+
"wire transfer",
|
| 29 |
+
"send bitcoin",
|
| 30 |
+
"send btc",
|
| 31 |
+
"gift card",
|
| 32 |
+
"limited time offer",
|
| 33 |
+
"act now",
|
| 34 |
+
"urgent action required",
|
| 35 |
+
"final notice",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
# Free / disposable / known-abused TLDs. Not exhaustive — a starting list.
|
| 39 |
+
_SUSPICIOUS_TLDS = {
|
| 40 |
+
"zip", "mov", "xyz", "top", "click", "country", "stream", "gq", "tk",
|
| 41 |
+
"ml", "cf", "ga", "work", "loan", "kim", "men",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
# Common URL shorteners — frequently used to hide phishing destinations.
|
| 45 |
+
_URL_SHORTENERS = {
|
| 46 |
+
"bit.ly", "tinyurl.com", "goo.gl", "t.co", "ow.ly", "is.gd", "buff.ly",
|
| 47 |
+
"shorte.st", "adf.ly", "cutt.ly", "rebrand.ly", "rb.gy", "s.id",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
_URL_RE = re.compile(
|
| 51 |
+
r"\b((?:https?://|www\.)[^\s<>\"')]+)",
|
| 52 |
+
re.IGNORECASE,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
_IP_HOST_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def extract_urls(text: str) -> list[str]:
|
| 59 |
+
"""Return all URLs found in the text, lightly normalized."""
|
| 60 |
+
if not text:
|
| 61 |
+
return []
|
| 62 |
+
urls = []
|
| 63 |
+
for match in _URL_RE.findall(text):
|
| 64 |
+
url = match.rstrip(".,);:!?")
|
| 65 |
+
if not url.lower().startswith(("http://", "https://")):
|
| 66 |
+
url = "http://" + url
|
| 67 |
+
urls.append(url)
|
| 68 |
+
return urls
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _classify_url(url: str) -> str | None:
|
| 72 |
+
"""Return a reason string if the URL looks suspicious, else None."""
|
| 73 |
+
try:
|
| 74 |
+
parsed = urlparse(url)
|
| 75 |
+
except ValueError:
|
| 76 |
+
return "Malformed URL"
|
| 77 |
+
|
| 78 |
+
host = (parsed.hostname or "").lower()
|
| 79 |
+
if not host:
|
| 80 |
+
return "URL missing host"
|
| 81 |
+
|
| 82 |
+
if _IP_HOST_RE.match(host):
|
| 83 |
+
return f"URL uses raw IP address ({host})"
|
| 84 |
+
|
| 85 |
+
if host in _URL_SHORTENERS:
|
| 86 |
+
return f"URL shortener detected ({host})"
|
| 87 |
+
|
| 88 |
+
tld = host.rsplit(".", 1)[-1] if "." in host else ""
|
| 89 |
+
if tld in _SUSPICIOUS_TLDS:
|
| 90 |
+
return f"Suspicious TLD .{tld} ({host})"
|
| 91 |
+
|
| 92 |
+
# "@" inside the authority is a classic phishing trick to hide the real host.
|
| 93 |
+
if "@" in (parsed.netloc or ""):
|
| 94 |
+
return f"URL contains embedded credentials ({parsed.netloc})"
|
| 95 |
+
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class SpamService:
|
| 100 |
+
"""Stateless heuristic spam / phishing detector."""
|
| 101 |
+
|
| 102 |
+
# Risk score thresholds — kept on the conservative side.
|
| 103 |
+
SPAM_THRESHOLD = 0.6
|
| 104 |
+
|
| 105 |
+
def check(self, text: str, ocr_text: str = "") -> dict:
|
| 106 |
+
"""
|
| 107 |
+
Analyze `text` (and optional `ocr_text`) and return a structured verdict.
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
{
|
| 111 |
+
"is_spam": bool,
|
| 112 |
+
"risk_score": float, # 0.0–1.0
|
| 113 |
+
"reasons": list[str],
|
| 114 |
+
"suspicious_urls": list[str],
|
| 115 |
+
"matched_keywords": list[str],
|
| 116 |
+
}
|
| 117 |
+
"""
|
| 118 |
+
combined = " ".join(filter(None, [text or "", ocr_text or ""])).strip()
|
| 119 |
+
if not combined:
|
| 120 |
+
return {
|
| 121 |
+
"is_spam": False,
|
| 122 |
+
"risk_score": 0.0,
|
| 123 |
+
"reasons": [],
|
| 124 |
+
"suspicious_urls": [],
|
| 125 |
+
"matched_keywords": [],
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
lowered = combined.lower()
|
| 129 |
+
matched_keywords = [kw for kw in _PHISHING_KEYWORDS if kw in lowered]
|
| 130 |
+
|
| 131 |
+
suspicious_urls: list[str] = []
|
| 132 |
+
url_reasons: list[str] = []
|
| 133 |
+
for url in extract_urls(combined):
|
| 134 |
+
reason = _classify_url(url)
|
| 135 |
+
if reason:
|
| 136 |
+
suspicious_urls.append(url)
|
| 137 |
+
url_reasons.append(reason)
|
| 138 |
+
|
| 139 |
+
reasons: list[str] = []
|
| 140 |
+
if matched_keywords:
|
| 141 |
+
reasons.append(
|
| 142 |
+
f"Matched {len(matched_keywords)} phishing keyword(s): "
|
| 143 |
+
+ ", ".join(matched_keywords[:3])
|
| 144 |
+
)
|
| 145 |
+
reasons.extend(url_reasons)
|
| 146 |
+
|
| 147 |
+
# Score: 0.35 per keyword hit + 0.4 per suspicious URL, capped at 1.0.
|
| 148 |
+
score = min(1.0, 0.35 * len(matched_keywords) + 0.4 * len(suspicious_urls))
|
| 149 |
+
|
| 150 |
+
return {
|
| 151 |
+
"is_spam": score >= self.SPAM_THRESHOLD,
|
| 152 |
+
"risk_score": round(score, 3),
|
| 153 |
+
"reasons": reasons,
|
| 154 |
+
"suspicious_urls": suspicious_urls,
|
| 155 |
+
"matched_keywords": matched_keywords,
|
| 156 |
+
}
|
deploy/monitoring/grafana_dashboard.json
CHANGED
|
@@ -9,11 +9,13 @@
|
|
| 9 |
},
|
| 10 |
"enable": true,
|
| 11 |
"hide": true,
|
|
|
|
| 12 |
"name": "Annotations & Alerts",
|
| 13 |
"type": "dashboard"
|
| 14 |
}
|
| 15 |
]
|
| 16 |
},
|
|
|
|
| 17 |
"editable": true,
|
| 18 |
"fiscalYearStartMonth": 0,
|
| 19 |
"graphTooltip": 0,
|
|
@@ -22,152 +24,218 @@
|
|
| 22 |
"liveNow": false,
|
| 23 |
"panels": [
|
| 24 |
{
|
| 25 |
-
"collapsed": false,
|
| 26 |
-
"gridPos": {
|
| 27 |
-
"h": 8,
|
| 28 |
-
"w": 12,
|
| 29 |
-
"x": 0,
|
| 30 |
-
"y": 0
|
| 31 |
-
},
|
| 32 |
-
"id": 1,
|
| 33 |
-
"title": "Model Prediction Throughput (Requests / sec)",
|
| 34 |
-
"type": "timeseries",
|
| 35 |
"datasource": {
|
| 36 |
"type": "prometheus",
|
| 37 |
-
"uid": "
|
| 38 |
},
|
| 39 |
-
"targets": [
|
| 40 |
-
{
|
| 41 |
-
"expr": "sum(rate(model_predictions_total[1m])) by (status)",
|
| 42 |
-
"legendFormat": "Predictions: {{status}}",
|
| 43 |
-
"refId": "A"
|
| 44 |
-
}
|
| 45 |
-
],
|
| 46 |
"fieldConfig": {
|
| 47 |
"defaults": {
|
|
|
|
| 48 |
"custom": {
|
|
|
|
| 49 |
"drawStyle": "line",
|
| 50 |
-
"
|
|
|
|
|
|
|
| 51 |
},
|
| 52 |
-
"unit": "
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
},
|
| 56 |
-
{
|
| 57 |
-
"collapsed": false,
|
| 58 |
-
"gridPos": {
|
| 59 |
-
"h": 8,
|
| 60 |
-
"w": 12,
|
| 61 |
-
"x": 12,
|
| 62 |
-
"y": 0
|
| 63 |
},
|
| 64 |
-
"
|
| 65 |
-
"
|
| 66 |
-
"
|
| 67 |
-
|
| 68 |
-
"
|
| 69 |
-
"uid": "prometheus"
|
| 70 |
},
|
|
|
|
| 71 |
"targets": [
|
| 72 |
{
|
| 73 |
-
"expr": "histogram_quantile(0.
|
| 74 |
-
"legendFormat": "
|
| 75 |
"refId": "A"
|
| 76 |
},
|
| 77 |
{
|
| 78 |
-
"expr": "histogram_quantile(0.
|
| 79 |
-
"legendFormat": "
|
| 80 |
"refId": "B"
|
| 81 |
},
|
| 82 |
{
|
| 83 |
-
"expr": "sum(
|
| 84 |
-
"legendFormat": "
|
| 85 |
"refId": "C"
|
| 86 |
}
|
| 87 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
"fieldConfig": {
|
| 89 |
"defaults": {
|
|
|
|
| 90 |
"custom": {
|
|
|
|
| 91 |
"drawStyle": "line",
|
| 92 |
-
"
|
|
|
|
|
|
|
| 93 |
},
|
| 94 |
-
"unit": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
-
|
|
|
|
| 97 |
},
|
| 98 |
{
|
| 99 |
-
"collapsed": false,
|
| 100 |
-
"gridPos": {
|
| 101 |
-
"h": 8,
|
| 102 |
-
"w": 12,
|
| 103 |
-
"x": 0,
|
| 104 |
-
"y": 8
|
| 105 |
-
},
|
| 106 |
-
"id": 3,
|
| 107 |
-
"title": "Backend CPU Usage",
|
| 108 |
-
"type": "timeseries",
|
| 109 |
"datasource": {
|
| 110 |
"type": "prometheus",
|
| 111 |
-
"uid": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
"targets": [
|
| 114 |
{
|
| 115 |
-
"expr": "rate(
|
| 116 |
-
"legendFormat": "
|
| 117 |
"refId": "A"
|
| 118 |
}
|
| 119 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
"fieldConfig": {
|
| 121 |
"defaults": {
|
|
|
|
| 122 |
"custom": {
|
|
|
|
| 123 |
"drawStyle": "line",
|
| 124 |
-
"
|
|
|
|
|
|
|
| 125 |
},
|
| 126 |
-
"unit": "
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
},
|
| 130 |
-
{
|
| 131 |
-
"collapsed": false,
|
| 132 |
-
"gridPos": {
|
| 133 |
-
"h": 8,
|
| 134 |
-
"w": 12,
|
| 135 |
-
"x": 12,
|
| 136 |
-
"y": 8
|
| 137 |
},
|
|
|
|
| 138 |
"id": 4,
|
| 139 |
-
"
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
"type": "prometheus",
|
| 143 |
-
"uid": "prometheus"
|
| 144 |
},
|
|
|
|
| 145 |
"targets": [
|
| 146 |
{
|
| 147 |
"expr": "process_resident_memory_bytes",
|
| 148 |
-
"legendFormat": "
|
| 149 |
"refId": "A"
|
| 150 |
}
|
| 151 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
"fieldConfig": {
|
| 153 |
"defaults": {
|
|
|
|
| 154 |
"custom": {
|
|
|
|
| 155 |
"drawStyle": "line",
|
| 156 |
-
"
|
|
|
|
|
|
|
| 157 |
},
|
| 158 |
-
"unit": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
}
|
| 160 |
-
|
|
|
|
| 161 |
}
|
| 162 |
],
|
| 163 |
-
"
|
|
|
|
| 164 |
"style": "dark",
|
| 165 |
-
"tags": [
|
| 166 |
-
|
| 167 |
-
"
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
"weekStart": ""
|
| 173 |
}
|
|
|
|
| 9 |
},
|
| 10 |
"enable": true,
|
| 11 |
"hide": true,
|
| 12 |
+
"iconColor": "rgba(0, 211, 255, 1)",
|
| 13 |
"name": "Annotations & Alerts",
|
| 14 |
"type": "dashboard"
|
| 15 |
}
|
| 16 |
]
|
| 17 |
},
|
| 18 |
+
"description": "HELPDESK.AI service telemetry — AI inference latency, API throughput, token counts, and host resources.",
|
| 19 |
"editable": true,
|
| 20 |
"fiscalYearStartMonth": 0,
|
| 21 |
"graphTooltip": 0,
|
|
|
|
| 24 |
"liveNow": false,
|
| 25 |
"panels": [
|
| 26 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
"datasource": {
|
| 28 |
"type": "prometheus",
|
| 29 |
+
"uid": "${DS_PROMETHEUS}"
|
| 30 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
"fieldConfig": {
|
| 32 |
"defaults": {
|
| 33 |
+
"color": {"mode": "palette-classic"},
|
| 34 |
"custom": {
|
| 35 |
+
"axisLabel": "seconds",
|
| 36 |
"drawStyle": "line",
|
| 37 |
+
"fillOpacity": 10,
|
| 38 |
+
"lineWidth": 2,
|
| 39 |
+
"showPoints": "never"
|
| 40 |
},
|
| 41 |
+
"unit": "s"
|
| 42 |
+
},
|
| 43 |
+
"overrides": []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
},
|
| 45 |
+
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 0},
|
| 46 |
+
"id": 1,
|
| 47 |
+
"options": {
|
| 48 |
+
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
|
| 49 |
+
"tooltip": {"mode": "multi"}
|
|
|
|
| 50 |
},
|
| 51 |
+
"title": "DistilBERT Inference Latency (p50 / p95 / p99)",
|
| 52 |
"targets": [
|
| 53 |
{
|
| 54 |
+
"expr": "histogram_quantile(0.50, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
|
| 55 |
+
"legendFormat": "p50",
|
| 56 |
"refId": "A"
|
| 57 |
},
|
| 58 |
{
|
| 59 |
+
"expr": "histogram_quantile(0.95, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
|
| 60 |
+
"legendFormat": "p95",
|
| 61 |
"refId": "B"
|
| 62 |
},
|
| 63 |
{
|
| 64 |
+
"expr": "histogram_quantile(0.99, sum by (le) (rate(ai_classifier_inference_latency_seconds_bucket[5m])))",
|
| 65 |
+
"legendFormat": "p99",
|
| 66 |
"refId": "C"
|
| 67 |
}
|
| 68 |
],
|
| 69 |
+
"type": "timeseries"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"datasource": {
|
| 73 |
+
"type": "prometheus",
|
| 74 |
+
"uid": "${DS_PROMETHEUS}"
|
| 75 |
+
},
|
| 76 |
"fieldConfig": {
|
| 77 |
"defaults": {
|
| 78 |
+
"color": {"mode": "palette-classic"},
|
| 79 |
"custom": {
|
| 80 |
+
"axisLabel": "req/s",
|
| 81 |
"drawStyle": "line",
|
| 82 |
+
"fillOpacity": 10,
|
| 83 |
+
"lineWidth": 2,
|
| 84 |
+
"showPoints": "never"
|
| 85 |
},
|
| 86 |
+
"unit": "reqps"
|
| 87 |
+
},
|
| 88 |
+
"overrides": []
|
| 89 |
+
},
|
| 90 |
+
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 0},
|
| 91 |
+
"id": 2,
|
| 92 |
+
"options": {
|
| 93 |
+
"legend": {"calcs": ["mean"], "displayMode": "table", "placement": "bottom"},
|
| 94 |
+
"tooltip": {"mode": "multi"}
|
| 95 |
+
},
|
| 96 |
+
"title": "Classifier Request Rate (by status)",
|
| 97 |
+
"targets": [
|
| 98 |
+
{
|
| 99 |
+
"expr": "sum by (status) (rate(ai_classifier_inference_requests_total[1m]))",
|
| 100 |
+
"legendFormat": "{{status}}",
|
| 101 |
+
"refId": "A"
|
| 102 |
}
|
| 103 |
+
],
|
| 104 |
+
"type": "timeseries"
|
| 105 |
},
|
| 106 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
"datasource": {
|
| 108 |
"type": "prometheus",
|
| 109 |
+
"uid": "${DS_PROMETHEUS}"
|
| 110 |
+
},
|
| 111 |
+
"fieldConfig": {
|
| 112 |
+
"defaults": {
|
| 113 |
+
"color": {"mode": "palette-classic"},
|
| 114 |
+
"custom": {
|
| 115 |
+
"axisLabel": "tokens/s",
|
| 116 |
+
"drawStyle": "line",
|
| 117 |
+
"fillOpacity": 10,
|
| 118 |
+
"lineWidth": 2,
|
| 119 |
+
"showPoints": "never"
|
| 120 |
+
},
|
| 121 |
+
"unit": "short"
|
| 122 |
+
},
|
| 123 |
+
"overrides": []
|
| 124 |
},
|
| 125 |
+
"gridPos": {"h": 9, "w": 12, "x": 0, "y": 9},
|
| 126 |
+
"id": 3,
|
| 127 |
+
"options": {
|
| 128 |
+
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
|
| 129 |
+
"tooltip": {"mode": "multi"}
|
| 130 |
+
},
|
| 131 |
+
"title": "Token Throughput (input tokens / sec)",
|
| 132 |
"targets": [
|
| 133 |
{
|
| 134 |
+
"expr": "sum by (model) (rate(ai_classifier_input_tokens_total[1m]))",
|
| 135 |
+
"legendFormat": "{{model}}",
|
| 136 |
"refId": "A"
|
| 137 |
}
|
| 138 |
],
|
| 139 |
+
"type": "timeseries"
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"datasource": {
|
| 143 |
+
"type": "prometheus",
|
| 144 |
+
"uid": "${DS_PROMETHEUS}"
|
| 145 |
+
},
|
| 146 |
"fieldConfig": {
|
| 147 |
"defaults": {
|
| 148 |
+
"color": {"mode": "palette-classic"},
|
| 149 |
"custom": {
|
| 150 |
+
"axisLabel": "bytes",
|
| 151 |
"drawStyle": "line",
|
| 152 |
+
"fillOpacity": 10,
|
| 153 |
+
"lineWidth": 2,
|
| 154 |
+
"showPoints": "never"
|
| 155 |
},
|
| 156 |
+
"unit": "decbytes"
|
| 157 |
+
},
|
| 158 |
+
"overrides": []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
},
|
| 160 |
+
"gridPos": {"h": 9, "w": 12, "x": 12, "y": 9},
|
| 161 |
"id": 4,
|
| 162 |
+
"options": {
|
| 163 |
+
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
|
| 164 |
+
"tooltip": {"mode": "multi"}
|
|
|
|
|
|
|
| 165 |
},
|
| 166 |
+
"title": "Process Memory (RSS)",
|
| 167 |
"targets": [
|
| 168 |
{
|
| 169 |
"expr": "process_resident_memory_bytes",
|
| 170 |
+
"legendFormat": "{{instance}}",
|
| 171 |
"refId": "A"
|
| 172 |
}
|
| 173 |
],
|
| 174 |
+
"type": "timeseries"
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"datasource": {
|
| 178 |
+
"type": "prometheus",
|
| 179 |
+
"uid": "${DS_PROMETHEUS}"
|
| 180 |
+
},
|
| 181 |
"fieldConfig": {
|
| 182 |
"defaults": {
|
| 183 |
+
"color": {"mode": "palette-classic"},
|
| 184 |
"custom": {
|
| 185 |
+
"axisLabel": "CPU seconds/sec",
|
| 186 |
"drawStyle": "line",
|
| 187 |
+
"fillOpacity": 10,
|
| 188 |
+
"lineWidth": 2,
|
| 189 |
+
"showPoints": "never"
|
| 190 |
},
|
| 191 |
+
"unit": "percentunit"
|
| 192 |
+
},
|
| 193 |
+
"overrides": []
|
| 194 |
+
},
|
| 195 |
+
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 18},
|
| 196 |
+
"id": 5,
|
| 197 |
+
"options": {
|
| 198 |
+
"legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom"},
|
| 199 |
+
"tooltip": {"mode": "multi"}
|
| 200 |
+
},
|
| 201 |
+
"title": "Process CPU Usage",
|
| 202 |
+
"targets": [
|
| 203 |
+
{
|
| 204 |
+
"expr": "rate(process_cpu_seconds_total[1m])",
|
| 205 |
+
"legendFormat": "{{instance}}",
|
| 206 |
+
"refId": "A"
|
| 207 |
}
|
| 208 |
+
],
|
| 209 |
+
"type": "timeseries"
|
| 210 |
}
|
| 211 |
],
|
| 212 |
+
"refresh": "10s",
|
| 213 |
+
"schemaVersion": 38,
|
| 214 |
"style": "dark",
|
| 215 |
+
"tags": ["helpdesk-ai", "fastapi", "ai-inference", "prometheus"],
|
| 216 |
+
"templating": {
|
| 217 |
+
"list": [
|
| 218 |
+
{
|
| 219 |
+
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
|
| 220 |
+
"hide": 0,
|
| 221 |
+
"includeAll": false,
|
| 222 |
+
"label": "Datasource",
|
| 223 |
+
"multi": false,
|
| 224 |
+
"name": "DS_PROMETHEUS",
|
| 225 |
+
"options": [],
|
| 226 |
+
"query": "prometheus",
|
| 227 |
+
"refresh": 1,
|
| 228 |
+
"regex": "",
|
| 229 |
+
"skipUrlSync": false,
|
| 230 |
+
"type": "datasource"
|
| 231 |
+
}
|
| 232 |
+
]
|
| 233 |
+
},
|
| 234 |
+
"time": {"from": "now-1h", "to": "now"},
|
| 235 |
+
"timepicker": {},
|
| 236 |
+
"timezone": "",
|
| 237 |
+
"title": "HELPDESK.AI — Service Telemetry",
|
| 238 |
+
"uid": "helpdesk-ai-telemetry",
|
| 239 |
+
"version": 1,
|
| 240 |
"weekStart": ""
|
| 241 |
}
|
requirements.txt
CHANGED
|
@@ -22,4 +22,6 @@ pytest
|
|
| 22 |
pytest-asyncio
|
| 23 |
httpx
|
| 24 |
cryptography>=42.0.0
|
|
|
|
|
|
|
| 25 |
|
|
|
|
| 22 |
pytest-asyncio
|
| 23 |
httpx
|
| 24 |
cryptography>=42.0.0
|
| 25 |
+
websockets>=12.0
|
| 26 |
+
prometheus-client>=0.19.0
|
| 27 |
|