fix: add candidate joining status, reverse video,
refactor the candiate_room to have js bundled
This commit is contained in:
parent
f72c002307
commit
71b5c30113
2
.gitignore
vendored
2
.gitignore
vendored
@ -17,6 +17,8 @@
|
|||||||
/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
|
||||||
|
|||||||
@ -288,29 +288,41 @@ public function submitCode(Request $request, $id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, Gaze Anomaly).
|
* Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, MediaPipe ML Events, AI Detection).
|
||||||
*/
|
*/
|
||||||
public function logViolation(Request $request, $id)
|
public function logViolation(Request $request, $id)
|
||||||
{
|
{
|
||||||
$interview = Interview::findOrFail($id);
|
$interview = Interview::where('id', $id)
|
||||||
|
->orWhere('submission_unique_id', $id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
$type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly, question_repetition, external_ai_detected
|
$type = $request->input('type');
|
||||||
$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($details, $interview->candidate_notes ?? '');
|
$aiVerdict = $aiDetector->analyzeSpeechTranscript($detailsStr, $interview->candidate_notes ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
$logs = $interview->proctor_logs ?? [];
|
$logs = $interview->proctor_logs ?? [];
|
||||||
$logs[] = [
|
$logEntry = [
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'details' => $details,
|
'details' => $details,
|
||||||
|
'confidence' => $confidence,
|
||||||
|
'snapshot' => $snapshot,
|
||||||
'ai_verdict' => $aiVerdict,
|
'ai_verdict' => $aiVerdict,
|
||||||
'timestamp' => now()->toIso8601String(),
|
'timestamp' => $request->input('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]);
|
||||||
|
|
||||||
@ -730,11 +742,16 @@ 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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,3 +39,18 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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';
|
import './candidate-proctor.js';
|
||||||
|
|
||||||
window.globalIceServers = globalIceServers;
|
window.globalIceServers = globalIceServers;
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
/*
|
/*
|
||||||
* Candidate-side proctoring pipeline.
|
* Candidate-side proctoring pipeline.
|
||||||
* MediaPipe runs locally; no camera frame is sent to the application server.
|
* MediaPipe runs locally for ML face/gaze/object detection.
|
||||||
* Events are deliberately emitted as structured objects so the logger can be
|
* Enforces Tab/Focus Loss, Clipboard Activity, AI-Tool/Hotkey Detection,
|
||||||
* replaced by an API adapter later without changing detection code.
|
* Continuous Speech Analysis, Screen Share Enforcement & System Desktop Screenshots.
|
||||||
|
* All events (MediaPipe ML + Focus/AI/Speech) are transmitted to the server endpoint.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Load MediaPipe's published browser bundle directly. This avoids CDN
|
// MediaPipe Published Browser Bundle CDN URLs
|
||||||
// transform endpoints that may return text/plain or a 404 through proxies.
|
|
||||||
const VISION_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
|
const VISION_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
|
||||||
const VISION_MODULE_FALLBACK_URL = 'https://unpkg.com/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
|
const VISION_MODULE_FALLBACK_URL = 'https://unpkg.com/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
|
||||||
const WASM_ROOT = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/wasm';
|
const WASM_ROOT = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/wasm';
|
||||||
const FACE_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task';
|
const FACE_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task';
|
||||||
const OBJECT_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/object_detector/efficientdet_lite0/float32/1/efficientdet_lite0.tflite';
|
const OBJECT_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/object_detector/efficientdet_lite0/float32/1/efficientdet_lite0.tflite';
|
||||||
|
|
||||||
const SAMPLE_INTERVAL_MS = 200;
|
const SAMPLE_INTERVAL_MS = 200;
|
||||||
const NO_FACE_GRACE_MS = 1000;
|
const NO_FACE_GRACE_MS = 1000;
|
||||||
const LOOKING_AWAY_GRACE_MS = 700;
|
const LOOKING_AWAY_GRACE_MS = 700;
|
||||||
@ -26,13 +27,42 @@ const GAZE_HORIZONTAL_LEFT_THRESHOLD = 0.28;
|
|||||||
const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72;
|
const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72;
|
||||||
const GAZE_DOWN_THRESHOLD = 0.62;
|
const GAZE_DOWN_THRESHOLD = 0.62;
|
||||||
|
|
||||||
|
// --- State Variables for Violation & Focus Detection ---
|
||||||
|
let isTabSwitchScreenshotEnabled = true;
|
||||||
|
let lastTabSwitchTime = 0;
|
||||||
|
let pendingNativePrompts = 0;
|
||||||
|
let suppressFocusViolationsUntil = 0;
|
||||||
|
|
||||||
|
let screenShareStream = null;
|
||||||
|
let screenShareVideoElem = null;
|
||||||
|
let overlayCheckInterval = null;
|
||||||
|
let candidateSpeechHistory = [];
|
||||||
|
|
||||||
|
// --- Native Prompt Suppression Window ---
|
||||||
|
export function beginNativePrompt() {
|
||||||
|
pendingNativePrompts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endNativePrompt() {
|
||||||
|
pendingNativePrompts = Math.max(0, pendingNativePrompts - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function armTrailingBuffer(ms) {
|
||||||
|
suppressFocusViolationsUntil = Math.max(suppressFocusViolationsUntil, Date.now() + ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFocusSuppressed() {
|
||||||
|
return pendingNativePrompts > 0 || Date.now() < suppressFocusViolationsUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Structured Logging & Backend Reporting ---
|
||||||
class StructuredEventLogger {
|
class StructuredEventLogger {
|
||||||
constructor(sessionId) {
|
constructor(sessionId) {
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
this.events = [];
|
this.events = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
record(type, confidence, details, snapshot) {
|
record(type, confidence, details, snapshot = null) {
|
||||||
const event = {
|
const event = {
|
||||||
schema: 'candidate-proctoring/v1',
|
schema: 'candidate-proctoring/v1',
|
||||||
session_id: this.sessionId,
|
session_id: this.sessionId,
|
||||||
@ -45,9 +75,49 @@ class StructuredEventLogger {
|
|||||||
};
|
};
|
||||||
this.events.push(event);
|
this.events.push(event);
|
||||||
console.log('[proctoring:event]', event);
|
console.log('[proctoring:event]', event);
|
||||||
|
|
||||||
|
this.sendToServer(event);
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendToServer(event) {
|
||||||
|
const interviewId = this.sessionId || document.body.dataset.interviewId;
|
||||||
|
if (!interviewId) return;
|
||||||
|
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
||||||
|
|
||||||
|
fetch(`/candidate/log-violation/${interviewId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: event.type,
|
||||||
|
details: typeof event.details === 'object' ? JSON.stringify(event.details) : event.details,
|
||||||
|
confidence: event.confidence,
|
||||||
|
snapshot: event.snapshot,
|
||||||
|
session_id: event.session_id,
|
||||||
|
event_id: event.event_id,
|
||||||
|
timestamp: event.timestamp
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
if (typeof BroadcastChannel !== 'undefined') {
|
||||||
|
const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
||||||
|
const liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId);
|
||||||
|
callSigChannel.postMessage({ type: 'violation_occurred', violation_type: event.type, details: event.details });
|
||||||
|
liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: event.type, details: event.details });
|
||||||
|
}
|
||||||
|
if (typeof window.pollReviewData === 'function') {
|
||||||
|
window.pollReviewData();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.log('[proctoring:sync-error]', err));
|
||||||
|
}
|
||||||
|
|
||||||
report(startedAt, endedAt, durations) {
|
report(startedAt, endedAt, durations) {
|
||||||
const report = {
|
const report = {
|
||||||
schema: 'candidate-proctoring/report-v1',
|
schema: 'candidate-proctoring/report-v1',
|
||||||
@ -62,6 +132,7 @@ class StructuredEventLogger {
|
|||||||
multiple_faces_ms: Math.round(durations.multipleFacesMs),
|
multiple_faces_ms: Math.round(durations.multipleFacesMs),
|
||||||
prolonged_looking_away_event_count: this.events.filter((event) => event.type === 'prolonged_looking_away').length,
|
prolonged_looking_away_event_count: this.events.filter((event) => event.type === 'prolonged_looking_away').length,
|
||||||
phone_event_count: this.events.filter((event) => event.type === 'phone_detected').length,
|
phone_event_count: this.events.filter((event) => event.type === 'phone_detected').length,
|
||||||
|
total_events: this.events.length,
|
||||||
},
|
},
|
||||||
events: this.events,
|
events: this.events,
|
||||||
};
|
};
|
||||||
@ -70,22 +141,506 @@ class StructuredEventLogger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logViolation(type, details, confidence = 1.0, snapshot = null) {
|
||||||
|
if (window.candidateProctoring && window.candidateProctoring.logger) {
|
||||||
|
window.candidateProctoring.logger.record(type, confidence, details, snapshot);
|
||||||
|
} else {
|
||||||
|
const interviewId = document.body.dataset.interviewId;
|
||||||
|
if (!interviewId) return;
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
||||||
|
|
||||||
|
fetch(`/candidate/log-violation/${interviewId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: type,
|
||||||
|
details: typeof details === 'object' ? JSON.stringify(details) : details,
|
||||||
|
confidence: confidence,
|
||||||
|
snapshot: snapshot
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
if (typeof BroadcastChannel !== 'undefined') {
|
||||||
|
const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
||||||
|
const liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId);
|
||||||
|
callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details });
|
||||||
|
liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: type, details: details });
|
||||||
|
}
|
||||||
|
if (typeof window.pollReviewData === 'function') {
|
||||||
|
window.pollReviewData();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.log('Log violation fetch error:', err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- System Desktop Screenshot Capture Engine ---
|
||||||
|
function getScreenShareVideoElement() {
|
||||||
|
if (!screenShareVideoElem) {
|
||||||
|
screenShareVideoElem = document.createElement('video');
|
||||||
|
screenShareVideoElem.autoplay = true;
|
||||||
|
screenShareVideoElem.muted = true;
|
||||||
|
screenShareVideoElem.playsInline = true;
|
||||||
|
screenShareVideoElem.style.position = 'fixed';
|
||||||
|
screenShareVideoElem.style.top = '0px';
|
||||||
|
screenShareVideoElem.style.left = '0px';
|
||||||
|
screenShareVideoElem.style.width = '320px';
|
||||||
|
screenShareVideoElem.style.height = '180px';
|
||||||
|
screenShareVideoElem.style.opacity = '0.001';
|
||||||
|
screenShareVideoElem.style.pointerEvents = 'none';
|
||||||
|
screenShareVideoElem.style.zIndex = '-9999';
|
||||||
|
document.body.appendChild(screenShareVideoElem);
|
||||||
|
}
|
||||||
|
return screenShareVideoElem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureAndUploadTabScreenshot(reason = 'tab_switch') {
|
||||||
|
if (!isTabSwitchScreenshotEnabled) return;
|
||||||
|
const callStatusBadge = document.getElementById('call-status-badge');
|
||||||
|
const isCallActive = callStatusBadge ? callStatusBadge.textContent.includes('LIVE') || callStatusBadge.textContent.includes('CONNECTED') : true;
|
||||||
|
if (!isCallActive) return;
|
||||||
|
if (reason !== 'tab_switch' && reason !== 'call_start' && reason !== 'external_ai_detected' && reason !== 'copy_event') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
let drewScreenStream = false;
|
||||||
|
|
||||||
|
const isCallStart = (reason === 'call_start');
|
||||||
|
const bannerLabel = isCallStart
|
||||||
|
? '📸 CALL STARTED • CANDIDATE SYSTEM SCREEN SNAPSHOT • ' + new Date().toLocaleString()
|
||||||
|
: '🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT • ' + new Date().toLocaleString();
|
||||||
|
|
||||||
|
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
|
||||||
|
const screenTrack = screenShareStream.getVideoTracks()[0];
|
||||||
|
if (screenTrack && screenTrack.readyState === 'live') {
|
||||||
|
try {
|
||||||
|
const vidElem = getScreenShareVideoElement();
|
||||||
|
if (vidElem.srcObject !== screenShareStream) {
|
||||||
|
vidElem.srcObject = screenShareStream;
|
||||||
|
vidElem.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawW = vidElem.videoWidth || 1280;
|
||||||
|
let rawH = vidElem.videoHeight || 720;
|
||||||
|
|
||||||
|
if (vidElem.readyState >= 2 && rawW > 0 && rawH > 0) {
|
||||||
|
const maxW = 1280;
|
||||||
|
let targetW = rawW;
|
||||||
|
let targetH = rawH;
|
||||||
|
if (targetW > maxW) {
|
||||||
|
targetH = Math.round(targetH * (maxW / targetW));
|
||||||
|
targetW = maxW;
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCanvas.width = targetW;
|
||||||
|
tempCanvas.height = targetH;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d');
|
||||||
|
tempCtx.drawImage(vidElem, 0, 0, targetW, targetH);
|
||||||
|
|
||||||
|
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||||
|
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
||||||
|
tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
||||||
|
tempCtx.font = 'bold 14px sans-serif';
|
||||||
|
tempCtx.fillText(bannerLabel, 16, tempCanvas.height - 15);
|
||||||
|
|
||||||
|
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.75), reason);
|
||||||
|
drewScreenStream = true;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.log('Screen stream draw exception:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drewScreenStream) return;
|
||||||
|
|
||||||
|
if (typeof window.html2canvas !== 'undefined' && !document.hidden) {
|
||||||
|
window.html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||||
|
ctx.fillRect(0, canvas.height - 42, canvas.width, 42);
|
||||||
|
ctx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
||||||
|
ctx.font = 'bold 14px sans-serif';
|
||||||
|
ctx.fillText(bannerLabel, 16, canvas.height - 15);
|
||||||
|
uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.75), reason);
|
||||||
|
}).catch(() => {
|
||||||
|
fallbackCompositeSnapshot(reason);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCompositeSnapshot(reason);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.log('Candidate system screenshot capture exception:', e);
|
||||||
|
fallbackCompositeSnapshot(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fallbackCompositeSnapshot(reason = 'tab_switch') {
|
||||||
|
const interviewId = document.body.dataset.interviewId || '';
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
const w = 1280;
|
||||||
|
const h = 720;
|
||||||
|
tempCanvas.width = w;
|
||||||
|
tempCanvas.height = h;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d');
|
||||||
|
|
||||||
|
tempCtx.fillStyle = '#0b0f19';
|
||||||
|
tempCtx.fillRect(0, 0, w, h);
|
||||||
|
tempCtx.fillStyle = '#1e293b';
|
||||||
|
tempCtx.fillRect(0, 0, w, 50);
|
||||||
|
tempCtx.fillStyle = '#38bdf8';
|
||||||
|
tempCtx.font = 'bold 16px sans-serif';
|
||||||
|
tempCtx.fillText('💻 Candidate System Workspace • ID: ' + interviewId, 20, 32);
|
||||||
|
|
||||||
|
const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor') || document.getElementById('monaco-editor-container');
|
||||||
|
tempCtx.fillStyle = '#020617';
|
||||||
|
tempCtx.fillRect(20, 70, w - 40, h - 130);
|
||||||
|
tempCtx.fillStyle = '#94a3b8';
|
||||||
|
tempCtx.font = '13px monospace';
|
||||||
|
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : '';
|
||||||
|
const lines = codeText.split('\n').slice(0, 30);
|
||||||
|
lines.forEach((line, i) => {
|
||||||
|
tempCtx.fillText(line.substring(0, 120), 35, 95 + (i * 18));
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCallStart = (reason === 'call_start');
|
||||||
|
tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||||
|
tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
|
||||||
|
tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444';
|
||||||
|
tempCtx.font = 'bold 14px sans-serif';
|
||||||
|
const text = isCallStart
|
||||||
|
? '📸 CALL STARTED • CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString()
|
||||||
|
: '🚨 CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString();
|
||||||
|
tempCtx.fillText(text, 16, tempCanvas.height - 15);
|
||||||
|
|
||||||
|
uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
|
||||||
|
const interviewId = document.body.dataset.interviewId;
|
||||||
|
if (!interviewId) return;
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
||||||
|
|
||||||
|
fetch(`/candidate/upload-tab-screenshot/${interviewId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ image: imageData, reason: reason })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('Candidate system screenshot saved:', data);
|
||||||
|
})
|
||||||
|
.catch(err => console.log('System screenshot fetch error:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Screen Share Enforcement & Live Overlay Detector (Category 6) ---
|
||||||
|
export function startScreenShare() {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
|
||||||
|
alert('Screen sharing is not supported on your browser.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
beginNativePrompt();
|
||||||
|
|
||||||
|
navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: {
|
||||||
|
displaySurface: 'monitor'
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
})
|
||||||
|
.then(stream => {
|
||||||
|
endNativePrompt();
|
||||||
|
const track = stream.getVideoTracks()[0];
|
||||||
|
const settings = track.getSettings ? track.getSettings() : {};
|
||||||
|
|
||||||
|
// Enforce ENTIRE SCREEN share
|
||||||
|
if (settings.displaySurface && settings.displaySurface !== 'monitor') {
|
||||||
|
if (typeof window.Swal !== 'undefined') {
|
||||||
|
window.Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Entire Screen Share Required',
|
||||||
|
text: 'You selected a single tab or window. You MUST select "Entire Screen".',
|
||||||
|
confirmButtonText: 'Reshare Entire Screen',
|
||||||
|
confirmButtonColor: '#6366f1',
|
||||||
|
allowOutsideClick: false,
|
||||||
|
allowEscapeKey: false
|
||||||
|
}).then(() => {
|
||||||
|
track.stop();
|
||||||
|
setTimeout(startScreenShare, 300);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('Entire Screen share is required. Please select Entire Screen.');
|
||||||
|
track.stop();
|
||||||
|
setTimeout(startScreenShare, 300);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
screenShareStream = stream;
|
||||||
|
const vidElem = getScreenShareVideoElement();
|
||||||
|
vidElem.srcObject = stream;
|
||||||
|
vidElem.play().catch(() => {});
|
||||||
|
|
||||||
|
const btn = document.getElementById('share-screen-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.innerText = '🟢 Entire Screen Sharing Active';
|
||||||
|
btn.style.background = '#10b981';
|
||||||
|
}
|
||||||
|
|
||||||
|
beginNativePrompt();
|
||||||
|
if (document.documentElement.requestFullscreen) {
|
||||||
|
document.documentElement.requestFullscreen()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
endNativePrompt();
|
||||||
|
armTrailingBuffer(1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const interviewId = document.body.dataset.interviewId;
|
||||||
|
if (typeof window.Peer !== 'undefined' && interviewId) {
|
||||||
|
const screenPeer = new window.Peer('cand_screen_' + interviewId + '_' + Date.now());
|
||||||
|
screenPeer.on('open', () => {
|
||||||
|
screenPeer.call('interviewer_screen_' + interviewId, stream);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
track.onended = () => {
|
||||||
|
screenShareStream = null;
|
||||||
|
if (btn) {
|
||||||
|
btn.innerText = '🖥️ Share Screen with Interviewer';
|
||||||
|
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
|
||||||
|
}
|
||||||
|
logViolation('tab_switch', 'Candidate stopped screen sharing');
|
||||||
|
};
|
||||||
|
|
||||||
|
initLiveOverlayDetector();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
endNativePrompt();
|
||||||
|
console.log('Screen sharing cancelled/denied:', err);
|
||||||
|
screenShareStream = null;
|
||||||
|
const btn = document.getElementById('share-screen-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.innerText = '🖥️ Share Screen with Interviewer';
|
||||||
|
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initLiveOverlayDetector() {
|
||||||
|
if (overlayCheckInterval) clearInterval(overlayCheckInterval);
|
||||||
|
|
||||||
|
overlayCheckInterval = setInterval(() => {
|
||||||
|
if (!screenShareStream || screenShareStream.getVideoTracks().length === 0) return;
|
||||||
|
const track = screenShareStream.getVideoTracks()[0];
|
||||||
|
if (track.readyState !== 'live') return;
|
||||||
|
|
||||||
|
const vidElem = getScreenShareVideoElement();
|
||||||
|
if (!vidElem || vidElem.videoWidth === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!document.hasFocus()) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTabSwitchTime > 3000) {
|
||||||
|
lastTabSwitchTime = now;
|
||||||
|
logViolation('tab_switch', 'Candidate window lost focus during active screen share');
|
||||||
|
captureAndUploadTabScreenshot('tab_switch');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Continuous Speech Recognition (Category 5) ---
|
||||||
|
export function initQuestionRepeatDetection() {
|
||||||
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const speechRec = new SpeechRecognition();
|
||||||
|
speechRec.continuous = true;
|
||||||
|
speechRec.interimResults = false;
|
||||||
|
speechRec.lang = 'en-US';
|
||||||
|
|
||||||
|
speechRec.onresult = function(event) {
|
||||||
|
for (let i = event.resultIndex; i < event.results.length; ++i) {
|
||||||
|
if (event.results[i].isFinal) {
|
||||||
|
const transcript = event.results[i][0].transcript.trim().toLowerCase();
|
||||||
|
if (transcript.length < 4) continue;
|
||||||
|
|
||||||
|
const pageText = (document.body ? document.body.innerText : '').toLowerCase();
|
||||||
|
|
||||||
|
const words = transcript.split(/\s+/).filter(w => w.length > 3);
|
||||||
|
let matchedWordCount = 0;
|
||||||
|
words.forEach(w => {
|
||||||
|
if (pageText.includes(w)) matchedWordCount++;
|
||||||
|
});
|
||||||
|
const isReadingQuestionOnScreen = words.length >= 3 && (matchedWordCount / words.length) >= 0.5;
|
||||||
|
|
||||||
|
const isRepeatedSpeech = candidateSpeechHistory.some(prev => {
|
||||||
|
return prev === transcript || (transcript.length > 8 && prev.includes(transcript)) || (prev.length > 8 && transcript.includes(prev));
|
||||||
|
});
|
||||||
|
|
||||||
|
candidateSpeechHistory.push(transcript);
|
||||||
|
if (candidateSpeechHistory.length > 15) candidateSpeechHistory.shift();
|
||||||
|
|
||||||
|
const phoneKeywords = ['hello', 'can you hear me', 'on phone', 'calling', 'call you back', 'hold on', 'on the line', 'speaker', 'phone', 'mobile', 'call me', 'speak later', 'hey call', 'on a call'];
|
||||||
|
const isPhoneTalk = phoneKeywords.some(kw => transcript.includes(kw));
|
||||||
|
const isCallConnected = window.isCallConnected ? window.isCallConnected() : false;
|
||||||
|
|
||||||
|
if (isPhoneTalk) {
|
||||||
|
logViolation('talking_on_phone', `Candidate detected talking on phone / phone call during assessment: "${transcript}"`);
|
||||||
|
} else if (isReadingQuestionOnScreen || isRepeatedSpeech) {
|
||||||
|
if (window.candidateProctoring?.lastStates?.lookingAway) {
|
||||||
|
logViolation('reading_external_device', `Candidate detected reading from external device or mobile screen: "${transcript}"`);
|
||||||
|
} else {
|
||||||
|
logViolation('question_repetition', `Candidate detected reading / repeating question out loud: "${transcript}" (suspected AI prompt ingestion)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isCallConnected && transcript.length > 10) {
|
||||||
|
logViolation('talking_secondary_person', `Candidate speaking out loud to secondary person in room: "${transcript}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
speechRec.onerror = function() {
|
||||||
|
setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
speechRec.onend = function() {
|
||||||
|
setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
speechRec.start();
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Candidate Proctoring Event Listeners (Tab focus, Clipboard, Hotkeys, DOM Observer, PiP, Fullscreen) ---
|
||||||
|
export function initCandidateProctoringListeners() {
|
||||||
|
// 1. Visibility Change (Tab Switched / Window Minimized)
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (isFocusSuppressed()) return;
|
||||||
|
if (document.hidden) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTabSwitchTime > 1000) {
|
||||||
|
lastTabSwitchTime = now;
|
||||||
|
logViolation('tab_switch', 'Candidate switched browser tab or minimized window');
|
||||||
|
captureAndUploadTabScreenshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Window Blur (Assessment Window Lost Focus)
|
||||||
|
window.addEventListener('blur', function () {
|
||||||
|
if (isFocusSuppressed()) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTabSwitchTime > 1000) {
|
||||||
|
lastTabSwitchTime = now;
|
||||||
|
logViolation('tab_switch', 'Candidate assessment window lost focus');
|
||||||
|
captureAndUploadTabScreenshot('tab_switch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Mouse Viewport Departure
|
||||||
|
document.addEventListener('mouseleave', function () {
|
||||||
|
if (isFocusSuppressed()) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTabSwitchTime > 2500) {
|
||||||
|
lastTabSwitchTime = now;
|
||||||
|
logViolation('tab_switch', 'Candidate cursor departed assessment window viewport');
|
||||||
|
captureAndUploadTabScreenshot('tab_switch');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Clipboard Events (Paste into Editor / Copy from Assessment Window)
|
||||||
|
document.addEventListener('paste', function () {
|
||||||
|
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('copy', function () {
|
||||||
|
logViolation('copy_event', 'Candidate copied text from assessment window (possible Parakeet AI prompt ingestion)');
|
||||||
|
captureAndUploadTabScreenshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Global Keydown AI Hotkey Interception (Capture Phase)
|
||||||
|
window.addEventListener('keydown', function (e) {
|
||||||
|
if (e.code === 'Tab' || e.key === 'Tab') return;
|
||||||
|
|
||||||
|
const key = (e.key || '').toLowerCase();
|
||||||
|
const code = (e.code || '').toLowerCase();
|
||||||
|
const isAlt = e.altKey;
|
||||||
|
const isCmd = e.metaKey;
|
||||||
|
const isCtrl = e.ctrlKey;
|
||||||
|
const isShift = e.shiftKey;
|
||||||
|
|
||||||
|
const isAiHotkey = (isAlt && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'c' || key === 'v' || key === 'q' || key === 'w' || key === 'e' || key === 'x' || key === 'z')) ||
|
||||||
|
(isCmd && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'x')) ||
|
||||||
|
(isCtrl && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'i' || key === 'c' || key === 'x')) ||
|
||||||
|
(isCmd && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'x' || key === '4' || key === '3')) ||
|
||||||
|
(isCtrl && isAlt) ||
|
||||||
|
(key === 'f12' || code === 'f12' || key === 'f11' || code === 'f11');
|
||||||
|
|
||||||
|
if (isAiHotkey) {
|
||||||
|
logViolation('external_ai_detected', `candidate might use external ai: Intercepted AI assistant shortcut hotkey (${e.code || e.key})`);
|
||||||
|
captureAndUploadTabScreenshot('external_ai_detected');
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// 6. Injected Extension DOM Observer
|
||||||
|
try {
|
||||||
|
const aiMutationObserver = new MutationObserver(mutations => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (node.nodeType === 1) {
|
||||||
|
const nodeStr = ((node.id || '') + ' ' + (node.className || '') + ' ' + (node.getAttribute('src') || '')).toLowerCase();
|
||||||
|
if (nodeStr.includes('parakeet') || nodeStr.includes('finalround') || nodeStr.includes('copilot') || nodeStr.includes('ai-assistant') || nodeStr.includes('otter')) {
|
||||||
|
logViolation('external_ai_detected', 'candidate might use external ai: Injected AI extension overlay element detected in DOM (' + node.tagName + ')');
|
||||||
|
captureAndUploadTabScreenshot('external_ai_detected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
aiMutationObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// 7. Picture-in-Picture Floating AI Window Detector
|
||||||
|
setInterval(function() {
|
||||||
|
if (document.pictureInPictureElement) {
|
||||||
|
logViolation('external_ai_detected', 'candidate might use external ai: External Picture-in-Picture floating AI window active');
|
||||||
|
}
|
||||||
|
}, 6000);
|
||||||
|
|
||||||
|
// 8. Fullscreen Exit Listener
|
||||||
|
document.addEventListener('fullscreenchange', function() {
|
||||||
|
if (isFocusSuppressed()) return;
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
logViolation('tab_switch', 'Candidate exited full-screen proctoring mode (possible overlay AI window interaction)');
|
||||||
|
captureAndUploadTabScreenshot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MediaPipe Proctoring Classes ---
|
||||||
|
|
||||||
class WarningPresenter {
|
class WarningPresenter {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.element = null;
|
this.element = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
show(message) {
|
show(message) {
|
||||||
if (!this.element) {
|
// Candidate-side violation notifications removed per directive.
|
||||||
this.element = document.createElement('div');
|
// All violations are silently logged and sent to the server.
|
||||||
this.element.setAttribute('role', 'status');
|
|
||||||
this.element.style.cssText = 'position:fixed;top:70px;left:50%;transform:translateX(-50%);z-index:9999;padding:12px 24px;border-radius:12px;background:rgba(239,68,68,.95);color:#fff;font:700 14px Instrument Sans,sans-serif;box-shadow:0 10px 30px rgba(239,68,68,.45);pointer-events:none;';
|
|
||||||
document.body.appendChild(this.element);
|
|
||||||
}
|
|
||||||
this.element.textContent = `Proctoring notice: ${message}`;
|
|
||||||
this.element.style.display = 'block';
|
|
||||||
clearTimeout(this.hideTimer);
|
|
||||||
this.hideTimer = setTimeout(() => { this.element.style.display = 'none'; }, 2500);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,10 +911,6 @@ class CandidateProctoring {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async withAmdLoaderDisabled(callback) {
|
async withAmdLoaderDisabled(callback) {
|
||||||
// Monaco's RequireJS loader sees MediaPipe's internally injected
|
|
||||||
// vision_wasm_internal.js and reports "one anonymous define". Keep
|
|
||||||
// the two loaders isolated only for model creation, then restore all
|
|
||||||
// globals so the editor continues to work normally.
|
|
||||||
const amdGlobals = ['define', 'require', 'requirejs'];
|
const amdGlobals = ['define', 'require', 'requirejs'];
|
||||||
const saved = amdGlobals.map((name) => ({ name, value: globalThis[name] }));
|
const saved = amdGlobals.map((name) => ({ name, value: globalThis[name] }));
|
||||||
amdGlobals.forEach((name) => {
|
amdGlobals.forEach((name) => {
|
||||||
@ -429,7 +980,8 @@ class CandidateProctoring {
|
|||||||
this.lastStates[name] = active;
|
this.lastStates[name] = active;
|
||||||
if (active) {
|
if (active) {
|
||||||
this.warning.show(message);
|
this.warning.show(message);
|
||||||
this.logger.record(name === 'lookingAway' ? 'looking_away' : name === 'noFace' ? 'face_missing' : 'multiple_faces', confidence, details, this.snapshot.capture());
|
const eventName = name === 'lookingAway' ? 'looking_away' : name === 'noFace' ? 'face_missing' : 'multiple_faces';
|
||||||
|
this.logger.record(eventName, confidence, details, this.snapshot.capture());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,11 +1011,15 @@ class CandidateProctoring {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Candidate Proctoring Auto-Boot & Listener Registration ---
|
||||||
function bootCandidateProctoring() {
|
function bootCandidateProctoring() {
|
||||||
const video = document.getElementById('webcam');
|
const video = document.getElementById('webcam');
|
||||||
const canvas = document.getElementById('proctor-canvas') || document.createElement('canvas');
|
const canvas = document.getElementById('proctor-canvas') || document.createElement('canvas');
|
||||||
const sessionId = document.body.dataset.interviewId;
|
const sessionId = document.body.dataset.interviewId;
|
||||||
if (!video || !sessionId || !navigator.mediaDevices) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
// Boot MediaPipe proctoring if webcam element exists
|
||||||
|
if (video && navigator.mediaDevices) {
|
||||||
const proctor = new CandidateProctoring({ sessionId, video, canvas });
|
const proctor = new CandidateProctoring({ sessionId, video, canvas });
|
||||||
window.candidateProctoring = proctor;
|
window.candidateProctoring = proctor;
|
||||||
const startWhenReady = () => proctor.start();
|
const startWhenReady = () => proctor.start();
|
||||||
@ -479,5 +1035,29 @@ function bootCandidateProctoring() {
|
|||||||
window.addEventListener('pagehide', () => proctor.stop(), { once: true });
|
window.addEventListener('pagehide', () => proctor.stop(), { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootCandidateProctoring, { once: true });
|
// Initialize all candidate cheat detection event listeners & speech analysis
|
||||||
else bootCandidateProctoring();
|
initCandidateProctoringListeners();
|
||||||
|
initQuestionRepeatDetection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Window Bindings for Proctoring ---
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.logViolation = logViolation;
|
||||||
|
window.captureAndUploadTabScreenshot = captureAndUploadTabScreenshot;
|
||||||
|
window.fallbackCompositeSnapshot = fallbackCompositeSnapshot;
|
||||||
|
window.uploadTabScreenshotBlob = uploadTabScreenshotBlob;
|
||||||
|
window.beginNativePrompt = beginNativePrompt;
|
||||||
|
window.endNativePrompt = endNativePrompt;
|
||||||
|
window.armTrailingBuffer = armTrailingBuffer;
|
||||||
|
window.isFocusSuppressed = isFocusSuppressed;
|
||||||
|
window.startScreenShare = startScreenShare;
|
||||||
|
window.initLiveOverlayDetector = initLiveOverlayDetector;
|
||||||
|
window.initQuestionRepeatDetection = initQuestionRepeatDetection;
|
||||||
|
window.initCandidateProctoringListeners = initCandidateProctoringListeners;
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', bootCandidateProctoring, { once: true });
|
||||||
|
} else {
|
||||||
|
bootCandidateProctoring();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1090
resources/js/candidate-room.js
Normal file
1090
resources/js/candidate-room.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -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 = false;
|
let sosAutoTriggerDisabled = true;
|
||||||
let currentTabScreenshotEnabled = true;
|
let currentTabScreenshotEnabled = true;
|
||||||
let interviewerSigChannel = null;
|
let interviewerSigChannel = null;
|
||||||
let interviewerPeerConnection = null;
|
let interviewerPeerConnection = null;
|
||||||
@ -108,6 +108,68 @@ 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 === '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();
|
||||||
@ -157,7 +219,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})`;
|
vid.style.transform = `scale(-${candVidZoomLevel}, ${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';
|
||||||
}
|
}
|
||||||
@ -452,10 +514,15 @@ 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) toggleSw.classList.add('on');
|
if (toggleSw) {
|
||||||
|
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');
|
||||||
@ -522,7 +589,7 @@ function isTrueVal(val) {
|
|||||||
return Boolean(val);
|
return Boolean(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCandidateStatusIcons(isMicOn, isCamOn) {
|
export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) {
|
||||||
const micOn = isTrueVal(isMicOn);
|
const micOn = isTrueVal(isMicOn);
|
||||||
const camOn = isTrueVal(isCamOn);
|
const camOn = isTrueVal(isCamOn);
|
||||||
|
|
||||||
@ -533,6 +600,54 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn) {
|
|||||||
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';
|
||||||
@ -549,7 +664,7 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn) {
|
|||||||
candPlaceholder.style.display = 'none';
|
candPlaceholder.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
candPlaceholder.style.display = 'flex';
|
candPlaceholder.style.display = 'flex';
|
||||||
if (candStatusText) candStatusText.innerHTML = 'Waiting for candidate…';
|
if (candStatusText) candStatusText.innerText = 'Waiting for candidate camera stream...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -575,17 +690,22 @@ 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);
|
||||||
@ -861,19 +981,29 @@ 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 === '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', '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)) {
|
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', 'phone_detected'].includes(l.type)) {
|
||||||
currentViolations.push(l);
|
currentViolations.push(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -890,14 +1020,16 @@ 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> — ${l.details || ''}${screenshotLink}${aiBadge}</div>
|
<div class="log-text text-xs leading-relaxed text-slate-300"><b class="text-white ${isFlag ? '!text-[#ef4a5f]' : ''}">${titleCap}</b> — ${humanDetails}${screenshotLink}${aiBadge}</div>
|
||||||
<div class="log-time text-[10.5px] text-slate-500 mt-0.5">${new Date(l.timestamp).toLocaleTimeString()}</div>
|
<div class="log-time text-[10.5px] text-slate-500 mt-0.5">${timeString}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
@ -937,21 +1069,33 @@ 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' ? 'border-red-500' : 'border-amber-500';
|
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 textCol = v.type === 'external_ai_detected' ? 'text-red-300' : 'text-amber-300';
|
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 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> ${v.details || 'Suspicious activity detected.'}
|
<strong class="${textCol}">🚨 ${typeTitle}:</strong> ${vDetails}
|
||||||
<div class="text-[0.65rem] text-slate-400 mt-1">Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}</div>
|
<div class="text-[0.65rem] text-slate-400 mt-1">Timestamp: ${vTime}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -979,6 +1123,18 @@ export function stopSosAlarm() {
|
|||||||
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') {
|
||||||
@ -1125,7 +1281,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"></video>
|
<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>
|
||||||
<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}
|
||||||
@ -1896,6 +2052,8 @@ 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;
|
||||||
|
|||||||
@ -11,10 +11,22 @@
|
|||||||
$faIcon = 'fa-solid fa-window-restore';
|
$faIcon = 'fa-solid fa-window-restore';
|
||||||
$isFlag = false;
|
$isFlag = false;
|
||||||
|
|
||||||
if ($type === 'focus_lost') {
|
if (in_array($type, ['focus_lost', 'tab_switch'])) {
|
||||||
$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 === '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'])) {
|
||||||
@ -23,12 +35,57 @@
|
|||||||
$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 === 'tab_switch') {
|
} elseif ($type === 'paste_event') {
|
||||||
$icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore';
|
$icoClass = 'red'; $faIcon = 'fa-solid fa-paste'; $isFlag = true;
|
||||||
|
} 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') : now()->format('H:i:s');
|
$timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('h:i:s a') : now()->format('h:i:s a');
|
||||||
|
|
||||||
|
// 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 === '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' : '')]) }}>
|
||||||
@ -37,7 +94,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> — {{ $details }}
|
<b class="text-white {{ $isFlag ? '!text-[#ef4a5f]' : '' }}">{{ $titleCap }}</b> — {{ $humanDetails }}
|
||||||
@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
|
||||||
|
|||||||
@ -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"></video>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<video id="{{ $videoId }}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200"></video>
|
<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>
|
||||||
@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
@ -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 Download -->
|
<!-- Button 1: Report Page -->
|
||||||
<a href="{{ route('interview.report', $inv->id) }}" target="_blank" download>
|
<a href="{{ route('interview.report', $inv->id) }}" target="_blank">
|
||||||
<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">
|
<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">
|
||||||
Report Download
|
PDF Report
|
||||||
</x-button>
|
</x-button>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@ -116,7 +116,12 @@
|
|||||||
{{ $candInitials }}
|
{{ $candInitials }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 id="modal-cand-name" class="text-[15px] font-semibold text-white m-0 mb-0.5">{{ $interview->candidate_name }}</h2>
|
<div class="flex items-center gap-2 mb-0.5">
|
||||||
|
<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 }} · {{ $interview->candidate_phone }}</div>
|
<div class="text-xs text-[#5b637a] font-mono">{{ $interview->candidate_email }} · {{ $interview->candidate_phone }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -137,7 +142,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 on" id="sosToggle"></span> SOS auto-trigger
|
<span class="toggle" 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>
|
||||||
@ -359,11 +364,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-xmark">
|
<x-button onclick="stopSosAlarm()" variant="secondary" size="md" icon="fa-solid fa-check">
|
||||||
Close
|
Acknowledge Alert
|
||||||
</x-button>
|
</x-button>
|
||||||
<x-button onclick="stopSosAlarm()" variant="danger" size="md" icon="fa-solid fa-bell-slash">
|
<x-button onclick="disableSosAndStopAlarm()" variant="danger" size="md" icon="fa-solid fa-bell-slash">
|
||||||
Acknowledge & Stop SOS Alarm
|
Acknowledge & Disable SOS
|
||||||
</x-button>
|
</x-button>
|
||||||
</div>
|
</div>
|
||||||
</x-modal>
|
</x-modal>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user