Compare commits

..

No commits in common. "eba3ec96b3e2247a5ab363e8700e2a2eaf7912b6" and "5e0d2e426636ed4bddcaa0dfe72763064de9db70" have entirely different histories.

12 changed files with 1893 additions and 2745 deletions

2
.gitignore vendored
View File

@ -17,8 +17,6 @@
/public/build /public/build
/public/fonts-manifest.dev.json /public/fonts-manifest.dev.json
/public/hot /public/hot
/public/uploads/candidate_recordings/*
/public/uploads/candidate_screenshots/*
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail

View File

@ -288,41 +288,29 @@ public function submitCode(Request $request, $id)
} }
/** /**
* Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, MediaPipe ML Events, AI Detection). * Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, Gaze Anomaly).
*/ */
public function logViolation(Request $request, $id) public function logViolation(Request $request, $id)
{ {
$interview = Interview::where('id', $id) $interview = Interview::findOrFail($id);
->orWhere('submission_unique_id', $id)
->firstOrFail();
$type = $request->input('type'); $type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly, question_repetition, external_ai_detected
$details = $request->input('details', ''); $details = $request->input('details', '');
$confidence = $request->input('confidence', null);
$snapshot = $request->input('snapshot', null);
$detailsStr = is_array($details) ? json_encode($details) : (string)$details;
// Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call // Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call
$aiVerdict = null; $aiVerdict = null;
if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) { if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) {
$aiDetector = new \App\Services\AiCheatingDetectorService(); $aiDetector = new \App\Services\AiCheatingDetectorService();
$aiVerdict = $aiDetector->analyzeSpeechTranscript($detailsStr, $interview->candidate_notes ?? ''); $aiVerdict = $aiDetector->analyzeSpeechTranscript($details, $interview->candidate_notes ?? '');
} }
$logs = $interview->proctor_logs ?? []; $logs = $interview->proctor_logs ?? [];
$logEntry = [ $logs[] = [
'type' => $type, 'type' => $type,
'details' => $details, 'details' => $details,
'confidence' => $confidence,
'snapshot' => $snapshot,
'ai_verdict' => $aiVerdict, 'ai_verdict' => $aiVerdict,
'timestamp' => $request->input('timestamp', now()->toIso8601String()), 'timestamp' => now()->toIso8601String(),
]; ];
if ($request->has('event_id')) {
$logEntry['event_id'] = $request->input('event_id');
}
$logs[] = $logEntry;
$interview->update(['proctor_logs' => $logs]); $interview->update(['proctor_logs' => $logs]);
@ -742,16 +730,11 @@ public function registerPeerHeartbeat(Request $request, $id)
} }
if ($action !== 'leave') { if ($action !== 'leave') {
$micOn = $request->has('mic_on') ? filter_var($request->input('mic_on'), FILTER_VALIDATE_BOOLEAN) : true;
$camOn = $request->has('cam_on') ? filter_var($request->input('cam_on'), FILTER_VALIDATE_BOOLEAN) : true;
$updatedPeers[] = [ $updatedPeers[] = [
'peer_id' => $peerId, 'peer_id' => $peerId,
'user_id' => $userId, 'user_id' => $userId,
'name' => $name, 'name' => $name,
'role' => $role, 'role' => $role,
'mic_on' => $micOn,
'cam_on' => $camOn,
'last_seen' => $now, 'last_seen' => $now,
]; ];
} }

View File

@ -39,18 +39,3 @@ html, body, * {
background: transparent; background: transparent;
} }
/* Mirror camera video feeds for candidate, interviewer, panelist, and admin */
#webcam,
#interviewer-video-default,
#interviewer-cand-video,
#interviewer-self-video,
video[id^="panelist-video-"],
.camera-video {
transform: scaleX(-1);
}
/* Ensure screen sharing is NEVER mirrored */
#interviewer-screen-video {
transform: scaleX(1) !important;
}

View File

@ -1,7 +1,5 @@
import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js'; import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js';
import './interview-call.js'; import './interview-call.js';
import './candidate-room.js';
import './candidate-proctor.js';
window.globalIceServers = globalIceServers; window.globalIceServers = globalIceServers;
window.getMeteredIceServers = getMeteredIceServers; window.getMeteredIceServers = getMeteredIceServers;
@ -11,3 +9,4 @@ window.initMeteredIceServers = initMeteredIceServers;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.getMeteredIceServers(); window.getMeteredIceServers();
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -96,7 +96,7 @@ let sosPollInterval = null;
let peerHeartbeatTimer = null; let peerHeartbeatTimer = null;
let acknowledgedViolationCount = 0; let acknowledgedViolationCount = 0;
let sosDismissed = false; let sosDismissed = false;
let sosAutoTriggerDisabled = true; let sosAutoTriggerDisabled = false;
let currentTabScreenshotEnabled = true; let currentTabScreenshotEnabled = true;
let interviewerSigChannel = null; let interviewerSigChannel = null;
let interviewerPeerConnection = null; let interviewerPeerConnection = null;
@ -108,82 +108,6 @@ let activeIncomingCall = null;
let iAmTheCaller = false; let iAmTheCaller = false;
let ringTimeoutTimer = null; let ringTimeoutTimer = null;
const dismissedCallIds = new Set(); const dismissedCallIds = new Set();
export function formatProctoringLogDetails(l) {
let detailsObj = l.details;
if (typeof detailsObj === 'string') {
try { detailsObj = JSON.parse(detailsObj); } catch(e) {}
}
const type = l.type || '';
if (type === 'multiple_faces') {
const count = (typeof detailsObj === 'object' && detailsObj && detailsObj.face_count) ? detailsObj.face_count : 2;
return `Multiple faces detected in camera frame (${count} faces)`;
}
if (type === 'face_missing') {
return 'Candidate face is not visible in camera frame';
}
if (type === 'looking_away' || type === 'prolonged_looking_away') {
if (typeof detailsObj === 'object' && detailsObj && (detailsObj.reasons || typeof detailsObj.yaw === 'number')) {
const reasonLabels = {
'head_yaw': 'Head Yaw',
'head_up': 'Head Pitch Up',
'head_down': 'Head Pitch Down',
'eye_gaze_left': 'Eye Gaze Left',
'eye_gaze_right': 'Eye Gaze Right',
'eye_gaze_down': 'Eye Gaze Down'
};
const reasonsStr = Array.isArray(detailsObj.reasons)
? detailsObj.reasons.map(r => reasonLabels[r] || r.replace(/_/g, ' ')).join(', ')
: '';
const parts = [];
if (reasonsStr) parts.push(reasonsStr);
if (typeof detailsObj.yaw === 'number') parts.push(`Yaw: ${detailsObj.yaw > 0 ? '+' : ''}${detailsObj.yaw.toFixed(2)}`);
if (typeof detailsObj.pitch === 'number') parts.push(`Pitch: ${detailsObj.pitch > 0 ? '+' : ''}${detailsObj.pitch.toFixed(2)}`);
if (typeof detailsObj.gaze_horizontal === 'number') parts.push(`Gaze H: ${detailsObj.gaze_horizontal.toFixed(2)}`);
if (typeof detailsObj.gaze_vertical === 'number') parts.push(`Gaze V: ${detailsObj.gaze_vertical.toFixed(2)}`);
const durationPrefix = (type === 'prolonged_looking_away') ? 'Looking away continuously (>10s) — ' : '';
return `${durationPrefix}${parts.join(' | ')}`;
} else if (typeof l.details === 'string' && l.details && !l.details.startsWith('{')) {
return l.details;
}
return type === 'prolonged_looking_away' ? 'Candidate prolonged looking away' : 'Candidate looking away from camera';
}
if (type === 'phone_detected') {
const label = (typeof detailsObj === 'object' && detailsObj && detailsObj.label) ? detailsObj.label : 'cell phone';
return `Possible mobile phone detected in camera frame (${label})`;
}
if (type === 'prolonged_corner_gaze') {
if (typeof detailsObj === 'object' && detailsObj && (detailsObj.corner_label || detailsObj.corner)) {
const cornerLabel = detailsObj.corner_label || (detailsObj.corner || 'screen corner').replace(/_/g, ' ');
const dur = detailsObj.duration_s || Math.round((detailsObj.duration_ms || 25000) / 1000);
const parts = [`Fixated on ${cornerLabel} for ${dur}s`];
if (typeof detailsObj.yaw === 'number') parts.push(`Yaw: ${detailsObj.yaw > 0 ? '+' : ''}${detailsObj.yaw.toFixed(2)}`);
if (typeof detailsObj.pitch === 'number') parts.push(`Pitch: ${detailsObj.pitch > 0 ? '+' : ''}${detailsObj.pitch.toFixed(2)}`);
if (typeof detailsObj.gaze_horizontal === 'number') parts.push(`Gaze H: ${detailsObj.gaze_horizontal.toFixed(2)}`);
if (typeof detailsObj.gaze_vertical === 'number') parts.push(`Gaze V: ${detailsObj.gaze_vertical.toFixed(2)}`);
return parts.join(' | ');
}
return 'Candidate fixated on screen corner for prolonged period (25+ seconds)';
}
if (type === 'talking_secondary_person') {
return typeof l.details === 'string' && !l.details.startsWith('{') ? l.details : 'Candidate detected speaking out loud to a secondary person';
}
if (typeof l.details === 'object' && l.details !== null) {
try { return JSON.stringify(l.details); } catch(e) { return String(l.details); }
}
return l.details || '';
}
const endedCallIds = new Set(); const endedCallIds = new Set();
let globalCallChannel = null; let globalCallChannel = null;
const adminPageLoadTimestamp = Date.now(); const adminPageLoadTimestamp = Date.now();
@ -233,7 +157,7 @@ export function zoomCandVideo(delta) {
else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta)); else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta));
const vid = document.getElementById('interviewer-cand-video'); const vid = document.getElementById('interviewer-cand-video');
if (vid) { if (vid) {
vid.style.transform = `scale(-${candVidZoomLevel}, ${candVidZoomLevel})`; vid.style.transform = `scale(${candVidZoomLevel})`;
vid.style.transformOrigin = 'center center'; vid.style.transformOrigin = 'center center';
vid.style.transition = 'transform 0.2s ease'; vid.style.transition = 'transform 0.2s ease';
} }
@ -528,15 +452,10 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) {
currentInterviewId = id; currentInterviewId = id;
acknowledgedViolationCount = 0; acknowledgedViolationCount = 0;
sosDismissed = false; sosDismissed = false;
sosAutoTriggerDisabled = false;
const toggleSw = document.getElementById('sosToggle'); const toggleSw = document.getElementById('sosToggle');
if (toggleSw) { if (toggleSw) toggleSw.classList.add('on');
if (sosAutoTriggerDisabled) {
toggleSw.classList.remove('on');
} else {
toggleSw.classList.add('on');
}
}
const candNameEl = document.getElementById('modal-cand-name'); const candNameEl = document.getElementById('modal-cand-name');
const candUidEl = document.getElementById('modal-cand-uid'); const candUidEl = document.getElementById('modal-cand-uid');
@ -603,7 +522,7 @@ function isTrueVal(val) {
return Boolean(val); return Boolean(val);
} }
export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) { export function updateCandidateStatusIcons(isMicOn, isCamOn) {
const micOn = isTrueVal(isMicOn); const micOn = isTrueVal(isMicOn);
const camOn = isTrueVal(isCamOn); const camOn = isTrueVal(isCamOn);
@ -614,54 +533,6 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) {
const candVid = document.getElementById('interviewer-cand-video'); const candVid = document.getElementById('interviewer-cand-video');
const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0));
const candVidWrapper = document.getElementById('cand-video-wrapper');
const screenWrapper = document.getElementById('screen-wrapper');
const joinedPill = document.getElementById('candidate-joined-pill');
if (!isJoined) {
if (joinedPill) {
joinedPill.className = 'pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60';
joinedPill.innerHTML = '<i class="fa-solid fa-circle text-[8px] text-slate-500"></i> Not Joined';
}
if (candMicIcon) {
candMicIcon.className = 'fa-solid fa-microphone-slash text-slate-500 text-[10px]';
candMicIcon.title = 'Candidate Not Joined';
}
if (candCamIcon) {
candCamIcon.className = 'fa-solid fa-video-slash text-slate-500 text-[10px]';
candCamIcon.title = 'Candidate Not Joined';
}
if (candVidWrapper) {
candVidWrapper.style.opacity = '0.4';
candVidWrapper.style.filter = 'grayscale(60%)';
candVidWrapper.style.transition = 'all 0.3s ease';
}
if (screenWrapper) {
screenWrapper.style.opacity = '0.4';
screenWrapper.style.filter = 'grayscale(60%)';
screenWrapper.style.transition = 'all 0.3s ease';
}
if (candPlaceholder) {
candPlaceholder.style.display = 'flex';
if (candStatusText) candStatusText.innerText = 'Candidate Not Joined';
}
return;
}
// Joined state: restore styles & active icons
if (joinedPill) {
joinedPill.className = 'pill green inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-emerald-500/30 text-emerald-400 bg-emerald-500/10';
joinedPill.innerHTML = '<i class="fa-solid fa-circle text-[8px] text-emerald-400 animate-pulse"></i> Joined';
}
if (candVidWrapper) {
candVidWrapper.style.opacity = '1';
candVidWrapper.style.filter = 'none';
}
if (screenWrapper) {
screenWrapper.style.opacity = '1';
screenWrapper.style.filter = 'none';
}
if (candMicIcon && isMicOn !== undefined) { if (candMicIcon && isMicOn !== undefined) {
candMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; candMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]';
candMicIcon.title = micOn ? 'Mic On' : 'Mic Muted'; candMicIcon.title = micOn ? 'Mic On' : 'Mic Muted';
@ -678,7 +549,7 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) {
candPlaceholder.style.display = 'none'; candPlaceholder.style.display = 'none';
} else { } else {
candPlaceholder.style.display = 'flex'; candPlaceholder.style.display = 'flex';
if (candStatusText) candStatusText.innerText = 'Waiting for candidate camera stream...'; if (candStatusText) candStatusText.innerHTML = 'Waiting for candidate&hellip;';
} }
} }
} }
@ -704,22 +575,17 @@ export function pollReviewData() {
const currentUserId = window.currentUserId || 0; const currentUserId = window.currentUserId || 0;
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
const candidatePeer = (data.active_peers && Array.isArray(data.active_peers))
? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_')))
: null;
if (candidatePeer) {
const isMicOn = isTrueVal(candidatePeer.mic_on);
const isCamOn = isTrueVal(candidatePeer.cam_on);
updateCandidateStatusIcons(isMicOn, isCamOn, true);
} else {
updateCandidateStatusIcons(false, false, false);
}
if (data.active_peers && Array.isArray(data.active_peers)) { if (data.active_peers && Array.isArray(data.active_peers)) {
const activeIds = new Set(data.active_peers.map(p => p.peer_id)); const activeIds = new Set(data.active_peers.map(p => p.peer_id));
data.active_peers.forEach(p => { data.active_peers.forEach(p => {
const isCandPeer = p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_'));
if (isCandPeer) {
const isMicOn = isTrueVal(p.mic_on);
const isCamOn = isTrueVal(p.cam_on);
updateCandidateStatusIcons(isMicOn, isCamOn);
}
if (p.peer_id && p.peer_id !== myPeerId) { if (p.peer_id && p.peer_id !== myPeerId) {
let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || '')); let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || ''));
const isMicOn = isTrueVal(p.mic_on); const isMicOn = isTrueVal(p.mic_on);
@ -841,12 +707,12 @@ export function pollReviewData() {
let chatHtml = ''; let chatHtml = '';
data.warnings.forEach(w => { data.warnings.forEach(w => {
const isCand = (w.sent_by === null || w.sent_by === undefined); const isCand = (w.sent_by === null || w.sent_by === undefined);
const senderName = isCand const senderName = isCand
? (data.candidate_name || 'Candidate') ? (data.candidate_name || 'Candidate')
: (w.sender ? w.sender.name : 'Interviewer'); : (w.sender ? w.sender.name : 'Interviewer');
const color = isCand const color = isCand
? '#38bdf8' ? '#38bdf8'
: interviewerColors[(w.sent_by || 1) % interviewerColors.length]; : interviewerColors[(w.sent_by || 1) % interviewerColors.length];
const badgeLabel = isCand ? 'Candidate' : 'Panelist'; const badgeLabel = isCand ? 'Candidate' : 'Panelist';
@ -995,30 +861,19 @@ export function pollReviewData() {
if (data.proctor_logs && data.proctor_logs.length > 0) { if (data.proctor_logs && data.proctor_logs.length > 0) {
data.proctor_logs.forEach(l => { data.proctor_logs.forEach(l => {
if (l.type === 'proctoring_summary_report') return;
let icoClass = 'amber'; let icoClass = 'amber';
let faIcon = 'fa-solid fa-window-restore'; let faIcon = 'fa-solid fa-window-restore';
let isFlag = false; let isFlag = false;
if (l.type === 'focus_lost') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; } if (l.type === 'focus_lost') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; }
else if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye'; } else if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye'; }
else if (l.type === 'looking_away') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye-slash'; }
else if (l.type === 'face_missing') { icoClass = 'red'; faIcon = 'fa-solid fa-user-slash'; isFlag = true; }
else if (l.type === 'multiple_faces') { icoClass = 'red'; faIcon = 'fa-solid fa-users-viewfinder'; isFlag = true; }
else if (l.type === 'prolonged_looking_away') { icoClass = 'red'; faIcon = 'fa-solid fa-clock-rotate-left'; isFlag = true; }
else if (l.type === 'phone_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen-button'; isFlag = true; }
else if (l.type === 'prolonged_corner_gaze') { icoClass = 'amber'; faIcon = 'fa-solid fa-arrows-to-eye'; isFlag = true; }
else if (l.type === 'talking_secondary_person') { icoClass = 'red'; faIcon = 'fa-solid fa-user-group'; isFlag = true; }
else if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen'; isFlag = true; } else if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen'; isFlag = true; }
else if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') { icoClass = 'red'; faIcon = 'fa-solid fa-comments'; isFlag = true; } else if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') { icoClass = 'red'; faIcon = 'fa-solid fa-comments'; isFlag = true; }
else if (l.type === 'talking_on_phone') { icoClass = 'red'; faIcon = 'fa-solid fa-phone'; isFlag = true; } else if (l.type === 'talking_on_phone') { icoClass = 'red'; faIcon = 'fa-solid fa-phone'; isFlag = true; }
else if (l.type === 'external_ai_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-robot'; isFlag = true; } else if (l.type === 'external_ai_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-robot'; isFlag = true; }
else if (l.type === 'tab_switch') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; } else if (l.type === 'tab_switch') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; }
else if (l.type === 'paste_event') { icoClass = 'red'; faIcon = 'fa-solid fa-paste'; isFlag = true; }
else if (l.type === 'copy_event') { icoClass = 'amber'; faIcon = 'fa-solid fa-copy'; }
if (['paste_event', 'copy_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device', 'looking_away', 'face_missing', 'multiple_faces', 'prolonged_looking_away', 'prolonged_corner_gaze', 'phone_detected'].includes(l.type)) { if (['paste_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'].includes(l.type)) {
currentViolations.push(l); currentViolations.push(l);
} }
@ -1035,16 +890,14 @@ export function pollReviewData() {
const formattedType = (l.type || 'violation').replace(/_/g, ' '); const formattedType = (l.type || 'violation').replace(/_/g, ' ');
const titleCap = formattedType.charAt(0).toUpperCase() + formattedType.slice(1); const titleCap = formattedType.charAt(0).toUpperCase() + formattedType.slice(1);
const humanDetails = formatProctoringLogDetails(l);
const timeString = new Date(l.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true });
logsHtml += `<div class="log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ${isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : ''}"> logsHtml += `<div class="log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ${isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : ''}">
<div class="log-ico w-6 h-6 rounded-md flex-shrink-0 flex items-center justify-center text-[11px] mt-0.5 ${icoClass === 'amber' ? 'bg-[rgba(240,169,57,0.12)] text-[#f0a939]' : 'bg-[rgba(239,74,95,0.1)] text-[#ef4a5f]'}"> <div class="log-ico w-6 h-6 rounded-md flex-shrink-0 flex items-center justify-center text-[11px] mt-0.5 ${icoClass === 'amber' ? 'bg-[rgba(240,169,57,0.12)] text-[#f0a939]' : 'bg-[rgba(239,74,95,0.1)] text-[#ef4a5f]'}">
<i class="${faIcon}"></i> <i class="${faIcon}"></i>
</div> </div>
<div> <div>
<div class="log-text text-xs leading-relaxed text-slate-300"><b class="text-white ${isFlag ? '!text-[#ef4a5f]' : ''}">${titleCap}</b> &mdash; ${humanDetails}${screenshotLink}${aiBadge}</div> <div class="log-text text-xs leading-relaxed text-slate-300"><b class="text-white ${isFlag ? '!text-[#ef4a5f]' : ''}">${titleCap}</b> &mdash; ${l.details || ''}${screenshotLink}${aiBadge}</div>
<div class="log-time text-[10.5px] text-slate-500 mt-0.5">${timeString}</div> <div class="log-time text-[10.5px] text-slate-500 mt-0.5">${new Date(l.timestamp).toLocaleTimeString()}</div>
</div> </div>
</div>`; </div>`;
}); });
@ -1084,33 +937,21 @@ export function openSosModal(specificViolations = null) {
typeTitle = 'candidate might use external ai'; typeTitle = 'candidate might use external ai';
} else if (v.type === 'tab_switch') { } else if (v.type === 'tab_switch') {
typeTitle = 'Candidate Switched Browser Tab'; typeTitle = 'Candidate Switched Browser Tab';
} else if (v.type === 'multiple_faces') {
typeTitle = 'Multiple Faces Detected';
} else if (v.type === 'face_missing') {
typeTitle = 'Candidate Face Not Visible';
} else if (v.type === 'looking_away') {
typeTitle = 'Candidate Looking Away';
} else if (v.type === 'prolonged_looking_away') {
typeTitle = 'Prolonged Looking Away (>10s)';
} else if (v.type === 'phone_detected') {
typeTitle = 'Mobile Phone Detected';
} else if (v.type === 'talking_secondary_person') {
typeTitle = 'Candidate Talking to Secondary Person';
} else if (v.type === 'talking_on_phone') { } else if (v.type === 'talking_on_phone') {
typeTitle = 'Candidate Talking on Phone'; typeTitle = 'Candidate Talking on Phone';
} else if (v.type === 'reading_external_device') { } else if (v.type === 'reading_external_device') {
typeTitle = 'Candidate Reading from External Device'; typeTitle = 'Candidate Reading from External Device';
} else if (v.type === 'question_repetition' || v.type === 'question_repeat_lower') { } else if (v.type === 'question_repetition' || v.type === 'question_repeat_lower') {
typeTitle = 'Candidate Reading Question Out Loud'; typeTitle = 'Candidate Reading Question Out Loud';
} else if (v.type === 'gaze_anomaly' || v.type === 'gaze_lower_device') {
typeTitle = 'Candidate Lower Eye Gaze Detected';
} }
const borderCol = (v.type === 'external_ai_detected' || v.type === 'multiple_faces' || v.type === 'phone_detected' || v.type === 'face_missing' || v.type === 'prolonged_looking_away') ? 'border-red-500' : 'border-amber-500'; const borderCol = v.type === 'external_ai_detected' ? 'border-red-500' : 'border-amber-500';
const textCol = (v.type === 'external_ai_detected' || v.type === 'multiple_faces' || v.type === 'phone_detected' || v.type === 'face_missing' || v.type === 'prolonged_looking_away') ? 'text-red-300' : 'text-amber-300'; const textCol = v.type === 'external_ai_detected' ? 'text-red-300' : 'text-amber-300';
const vDetails = formatProctoringLogDetails(v);
const vTime = new Date(v.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true });
html += `<div class="mb-2.5 p-2 bg-white/5 rounded-md border-l-4 ${borderCol}"> html += `<div class="mb-2.5 p-2 bg-white/5 rounded-md border-l-4 ${borderCol}">
<strong class="${textCol}">🚨 ${typeTitle}:</strong> ${vDetails} <strong class="${textCol}">🚨 ${typeTitle}:</strong> ${v.details || 'Suspicious activity detected.'}
<div class="text-[0.65rem] text-slate-400 mt-1">Timestamp: ${vTime}</div> <div class="text-[0.65rem] text-slate-400 mt-1">Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}</div>
</div>`; </div>`;
}); });
} else { } else {
@ -1133,23 +974,11 @@ export function stopSosAlarm() {
const modalEl = document.getElementById('sos-modal'); const modalEl = document.getElementById('sos-modal');
if (modalEl) modalEl.classList.remove('active'); if (modalEl) modalEl.classList.remove('active');
acknowledgedViolationCount = activeViolations.length; acknowledgedViolationCount = activeViolations.length;
sosDismissed = true; sosDismissed = true;
} }
export function disableSosAndStopAlarm() {
sosAutoTriggerDisabled = true;
const toggleSw = document.getElementById('sosToggle');
const btn = document.getElementById('sos-toggle-btn');
if (toggleSw) toggleSw.classList.remove('on');
if (btn) {
btn.innerHTML = '<i class="fa-solid fa-bell-slash mr-1"></i> SOS Auto-Trigger: OFF';
btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300';
}
stopSosAlarm();
}
export function interviewerLogoutAction() { export function interviewerLogoutAction() {
const form = document.getElementById('logout-form'); const form = document.getElementById('logout-form');
if (typeof window.Swal !== 'undefined') { if (typeof window.Swal !== 'undefined') {
@ -1296,7 +1125,7 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn
tile.className = 'video-tile relative aspect-[16/11] bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all'; tile.className = 'video-tile relative aspect-[16/11] bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all';
tile.innerHTML = ` tile.innerHTML = `
<video id="panelist-video-${peerId}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200" style="transform: scaleX(-1);"></video> <video id="panelist-video-${peerId}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200"></video>
<div id="mesh-placeholder-${peerId}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3" style="display: ${camOn ? 'none' : 'flex'};"> <div id="mesh-placeholder-${peerId}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3" style="display: ${camOn ? 'none' : 'flex'};">
<div class="av w-[58px] h-[58px] rounded-full bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-white font-semibold text-[19px] mb-2.5 shadow-md shadow-[#4b82f7]/20"> <div class="av w-[58px] h-[58px] rounded-full bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-white font-semibold text-[19px] mb-2.5 shadow-md shadow-[#4b82f7]/20">
${initials} ${initials}
@ -2067,8 +1896,6 @@ if (typeof window !== 'undefined') {
window.pollReviewData = pollReviewData; window.pollReviewData = pollReviewData;
window.openSosModal = openSosModal; window.openSosModal = openSosModal;
window.stopSosAlarm = stopSosAlarm; window.stopSosAlarm = stopSosAlarm;
window.disableSosAndStopAlarm = disableSosAndStopAlarm;
window.toggleSosAutoTrigger = toggleSosAutoTrigger;
window.interviewerLogoutAction = interviewerLogoutAction; window.interviewerLogoutAction = interviewerLogoutAction;
window.regenerateCandidatePassword = regenerateCandidatePassword; window.regenerateCandidatePassword = regenerateCandidatePassword;
window.blockCandidateAction = blockCandidateAction; window.blockCandidateAction = blockCandidateAction;

View File

@ -11,24 +11,10 @@
$faIcon = 'fa-solid fa-window-restore'; $faIcon = 'fa-solid fa-window-restore';
$isFlag = false; $isFlag = false;
if (in_array($type, ['focus_lost', 'tab_switch'])) { if ($type === 'focus_lost') {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore'; $icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore';
} elseif (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) { } elseif (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-eye'; $icoClass = 'amber'; $faIcon = 'fa-solid fa-eye';
} elseif ($type === 'looking_away') {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-eye-slash';
} elseif ($type === 'face_missing') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-user-slash'; $isFlag = true;
} elseif ($type === 'multiple_faces') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-users-viewfinder'; $isFlag = true;
} elseif ($type === 'prolonged_looking_away') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-clock-rotate-left'; $isFlag = true;
} elseif ($type === 'phone_detected') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen-button'; $isFlag = true;
} elseif ($type === 'prolonged_corner_gaze') {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-arrows-to-eye'; $isFlag = true;
} elseif ($type === 'talking_secondary_person') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-user-group'; $isFlag = true;
} elseif (in_array($type, ['gaze_lower_device', 'reading_external_device'])) { } elseif (in_array($type, ['gaze_lower_device', 'reading_external_device'])) {
$icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen'; $isFlag = true; $icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen'; $isFlag = true;
} elseif (in_array($type, ['question_repeat_lower', 'question_repetition'])) { } elseif (in_array($type, ['question_repeat_lower', 'question_repetition'])) {
@ -37,70 +23,12 @@
$icoClass = 'red'; $faIcon = 'fa-solid fa-phone'; $isFlag = true; $icoClass = 'red'; $faIcon = 'fa-solid fa-phone'; $isFlag = true;
} elseif ($type === 'external_ai_detected') { } elseif ($type === 'external_ai_detected') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-robot'; $isFlag = true; $icoClass = 'red'; $faIcon = 'fa-solid fa-robot'; $isFlag = true;
} elseif ($type === 'paste_event') { } elseif ($type === 'tab_switch') {
$icoClass = 'red'; $faIcon = 'fa-solid fa-paste'; $isFlag = true; $icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore';
} elseif ($type === 'copy_event') {
$icoClass = 'amber'; $faIcon = 'fa-solid fa-copy';
} }
$titleCap = ucfirst(str_replace('_', ' ', $type)); $titleCap = ucfirst(str_replace('_', ' ', $type));
$timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('h:i:s a') : now()->format('h:i:s a'); $timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s');
// Human readable details parsing
$detailsObj = is_array($details) ? $details : (is_string($details) && str_starts_with(trim($details), '{') ? json_decode($details, true) : null);
$humanDetails = '';
if ($type === 'multiple_faces') {
$count = (is_array($detailsObj) && isset($detailsObj['face_count'])) ? $detailsObj['face_count'] : 2;
$humanDetails = "Multiple faces detected in camera frame ({$count} faces)";
} elseif ($type === 'face_missing') {
$humanDetails = 'Candidate face is not visible in camera frame';
} elseif (in_array($type, ['looking_away', 'prolonged_looking_away'])) {
if (is_array($detailsObj) && (isset($detailsObj['reasons']) || isset($detailsObj['yaw']))) {
$reasonLabels = [
'head_yaw' => 'Head Yaw',
'head_up' => 'Head Pitch Up',
'head_down' => 'Head Pitch Down',
'eye_gaze_left' => 'Eye Gaze Left',
'eye_gaze_right' => 'Eye Gaze Right',
'eye_gaze_down' => 'Eye Gaze Down'
];
$reasonsArr = isset($detailsObj['reasons']) && is_array($detailsObj['reasons']) ? array_map(fn($r) => $reasonLabels[$r] ?? str_replace('_', ' ', $r), $detailsObj['reasons']) : [];
$reasonsStr = implode(', ', $reasonsArr);
$parts = [];
if ($reasonsStr) $parts[] = $reasonsStr;
if (isset($detailsObj['yaw']) && is_numeric($detailsObj['yaw'])) $parts[] = 'Yaw: ' . ($detailsObj['yaw'] > 0 ? '+' : '') . number_format((float)$detailsObj['yaw'], 2);
if (isset($detailsObj['pitch']) && is_numeric($detailsObj['pitch'])) $parts[] = 'Pitch: ' . ($detailsObj['pitch'] > 0 ? '+' : '') . number_format((float)$detailsObj['pitch'], 2);
if (isset($detailsObj['gaze_horizontal']) && is_numeric($detailsObj['gaze_horizontal'])) $parts[] = 'Gaze H: ' . number_format((float)$detailsObj['gaze_horizontal'], 2);
if (isset($detailsObj['gaze_vertical']) && is_numeric($detailsObj['gaze_vertical'])) $parts[] = 'Gaze V: ' . number_format((float)$detailsObj['gaze_vertical'], 2);
$prefix = ($type === 'prolonged_looking_away') ? 'Looking away continuously (>10s) — ' : '';
$humanDetails = $prefix . implode(' | ', $parts);
} else {
$humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : ($type === 'prolonged_looking_away' ? 'Candidate prolonged looking away' : 'Candidate looking away from camera');
}
} elseif ($type === 'phone_detected') {
$label = (is_array($detailsObj) && isset($detailsObj['label'])) ? $detailsObj['label'] : 'cell phone';
$humanDetails = "Possible mobile phone detected in camera frame ({$label})";
} elseif ($type === 'prolonged_corner_gaze') {
if (is_array($detailsObj) && (isset($detailsObj['corner_label']) || isset($detailsObj['corner']))) {
$cornerLabel = $detailsObj['corner_label'] ?? str_replace('_', ' ', $detailsObj['corner'] ?? 'screen corner');
$dur = $detailsObj['duration_s'] ?? round(($detailsObj['duration_ms'] ?? 25000) / 1000);
$parts = ["Fixated on {$cornerLabel} for {$dur}s"];
if (isset($detailsObj['yaw']) && is_numeric($detailsObj['yaw'])) $parts[] = 'Yaw: ' . ($detailsObj['yaw'] > 0 ? '+' : '') . number_format((float)$detailsObj['yaw'], 2);
if (isset($detailsObj['pitch']) && is_numeric($detailsObj['pitch'])) $parts[] = 'Pitch: ' . ($detailsObj['pitch'] > 0 ? '+' : '') . number_format((float)$detailsObj['pitch'], 2);
if (isset($detailsObj['gaze_horizontal']) && is_numeric($detailsObj['gaze_horizontal'])) $parts[] = 'Gaze H: ' . number_format((float)$detailsObj['gaze_horizontal'], 2);
if (isset($detailsObj['gaze_vertical']) && is_numeric($detailsObj['gaze_vertical'])) $parts[] = 'Gaze V: ' . number_format((float)$detailsObj['gaze_vertical'], 2);
$humanDetails = implode(' | ', $parts);
} else {
$humanDetails = 'Candidate fixated on screen corner for prolonged period (25+ seconds)';
}
} elseif ($type === 'talking_secondary_person') {
$humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : 'Candidate detected speaking out loud to a secondary person';
} else {
$humanDetails = is_string($details) ? $details : (is_array($details) ? json_encode($details) : (string)$details);
}
@endphp @endphp
<div {{ $attributes->merge(['class' => 'log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ' . ($isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : '')]) }}> <div {{ $attributes->merge(['class' => 'log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ' . ($isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : '')]) }}>
@ -109,7 +37,7 @@
</div> </div>
<div> <div>
<div class="log-text text-xs leading-relaxed text-slate-300"> <div class="log-text text-xs leading-relaxed text-slate-300">
<b class="text-white {{ $isFlag ? '!text-[#ef4a5f]' : '' }}">{{ $titleCap }}</b> &mdash; {{ $humanDetails }} <b class="text-white {{ $isFlag ? '!text-[#ef4a5f]' : '' }}">{{ $titleCap }}</b> &mdash; {{ $details }}
@if($screenshotUrl) @if($screenshotUrl)
<a href="{{ $screenshotUrl }}" target="_blank" class="text-sky-400 underline ml-1 font-medium">[<i class="fa-solid fa-camera mr-0.5"></i> Screenshot]</a> <a href="{{ $screenshotUrl }}" target="_blank" class="text-sky-400 underline ml-1 font-medium">[<i class="fa-solid fa-camera mr-0.5"></i> Screenshot]</a>
@endif @endif

View File

@ -37,10 +37,10 @@
@if($containerId) @if($containerId)
<div id="{{ $containerId }}" class="w-full h-full overflow-auto"> <div id="{{ $containerId }}" class="w-full h-full overflow-auto">
<video id="{{ $videoId }}" autoplay playsinline class="w-full h-full object-contain origin-top-left transition-transform duration-200" style="{{ $videoId === 'interviewer-screen-video' ? 'transform: none;' : 'transform: scaleX(-1);' }}"></video> <video id="{{ $videoId }}" autoplay playsinline class="w-full h-full object-contain origin-top-left transition-transform duration-200"></video>
</div> </div>
@else @else
<video id="{{ $videoId }}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200" style="{{ $videoId === 'interviewer-screen-video' ? 'transform: none;' : 'transform: scaleX(-1);' }}"></video> <video id="{{ $videoId }}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200"></video>
@endif @endif
<div id="{{ $placeholderId }}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3"> <div id="{{ $placeholderId }}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3">

File diff suppressed because it is too large Load Diff

View File

@ -236,10 +236,10 @@
<!-- Review & Actions: EXACTLY 2 BUTTONS USING FONT-AWESOME ICONS --> <!-- Review & Actions: EXACTLY 2 BUTTONS USING FONT-AWESOME ICONS -->
<td class="py-3.5 px-4 text-right whitespace-nowrap"> <td class="py-3.5 px-4 text-right whitespace-nowrap">
<div class="inline-flex items-center gap-2 justify-end"> <div class="inline-flex items-center gap-2 justify-end">
<!-- Button 1: Report Page --> <!-- Button 1: Report Download -->
<a href="{{ route('interview.report', $inv->id) }}" target="_blank"> <a href="{{ route('interview.report', $inv->id) }}" target="_blank" download>
<x-button variant="success" size="sm" icon="fa-solid fa-file-pdf" class="bg-emerald-500/15 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/25"> <x-button variant="success" size="sm" icon="fa-solid fa-file-arrow-down" class="bg-emerald-500/15 border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/25">
PDF Report Report Download
</x-button> </x-button>
</a> </a>

View File

@ -116,12 +116,7 @@
{{ $candInitials }} {{ $candInitials }}
</div> </div>
<div> <div>
<div class="flex items-center gap-2 mb-0.5"> <h2 id="modal-cand-name" class="text-[15px] font-semibold text-white m-0 mb-0.5">{{ $interview->candidate_name }}</h2>
<h2 id="modal-cand-name" class="text-[15px] font-semibold text-white m-0 leading-none">{{ $interview->candidate_name }}</h2>
<span id="candidate-joined-pill" class="pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60">
<i class="fa-solid fa-circle text-[8px] text-slate-500"></i> Not Joined
</span>
</div>
<div class="text-xs text-[#5b637a] font-mono">{{ $interview->candidate_email }} &middot; {{ $interview->candidate_phone }}</div> <div class="text-xs text-[#5b637a] font-mono">{{ $interview->candidate_email }} &middot; {{ $interview->candidate_phone }}</div>
</div> </div>
</div> </div>
@ -142,7 +137,7 @@
<div class="w-px h-[18px] bg-[#232b3d] mx-1"></div> <div class="w-px h-[18px] bg-[#232b3d] mx-1"></div>
<div class="flex items-center gap-2 text-xs text-[#9199ad] cursor-pointer" onclick="toggleSosAutoTrigger()"> <div class="flex items-center gap-2 text-xs text-[#9199ad] cursor-pointer" onclick="toggleSosAutoTrigger()">
<span class="toggle" id="sosToggle"></span> SOS auto-trigger <span class="toggle on" id="sosToggle"></span> SOS auto-trigger
</div> </div>
<div class="w-px h-[18px] bg-[#232b3d] mx-1"></div> <div class="w-px h-[18px] bg-[#232b3d] mx-1"></div>
@ -364,11 +359,11 @@
Violation details loading... Violation details loading...
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<x-button onclick="stopSosAlarm()" variant="secondary" size="md" icon="fa-solid fa-check"> <x-button onclick="stopSosAlarm()" variant="secondary" size="md" icon="fa-solid fa-xmark">
Acknowledge Alert Close
</x-button> </x-button>
<x-button onclick="disableSosAndStopAlarm()" variant="danger" size="md" icon="fa-solid fa-bell-slash"> <x-button onclick="stopSosAlarm()" variant="danger" size="md" icon="fa-solid fa-bell-slash">
Acknowledge & Disable SOS Acknowledge &amp; Stop SOS Alarm
</x-button> </x-button>
</div> </div>
</x-modal> </x-modal>