Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions app/api/interview/[id]/chaos-timeout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import dbConnect from '@/src/lib/db/mongoose';
import InterviewSession from '@/src/lib/db/models/InterviewSession';

export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
await dbConnect();
const { id } = await context.params;
const { type, constraintId } = await req.json();

// 1. Fetch Session
const session = await InterviewSession.findOne({ id });
if (!session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}

// 2. Locate Constraint
const constraintChanges = session.constraintChanges || [];
const constraintIndex = constraintChanges.findIndex((c: any) => c.id === constraintId);

Check failure on line 22 in app/api/interview/[id]/chaos-timeout/route.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Unexpected any. Specify a different type
if (constraintIndex === -1) {
return NextResponse.json({ error: 'Constraint not found' }, { status: 404 });
}

const constraint = constraintChanges[constraintIndex];

// 3. Early Returns (already addressed or failed)
if (constraint.status === 'addressed' || (type === 'warning' && constraint.overtimeAt) || (type === 'penalty' && constraint.failedAt)) {
return NextResponse.json({ success: true, warning: 'Already processed' });
}

if (!session.aiMessages) session.aiMessages = [];

// 4. Update state depending on timeout type
if (type === 'warning') {
constraint.overtimeAt = new Date();
session.aiMessages.push({
role: 'interviewer',
content: `Hey, I noticed we still haven't addressed the ${constraint.title} issue. In a real-world scenario, leaving a failure like this unhandled could lead to a broader system outage. Could you walk me through how you'd mitigate this in the next 2 minutes? Let's treat this as a high-priority incident.`,
timestamp: new Date()
});
session.markModified('constraintChanges');
session.markModified('aiMessages');
} else if (type === 'penalty') {
constraint.failedAt = new Date();
// We keep status as 'active' so the UI node stays visibly broken
session.aiMessages.push({
role: 'interviewer',
content: `Alright, time's up on the ${constraint.title} scenario. Since we weren't able to establish a complete mitigation plan in time, I'll be noting this gap in high-availability planning for the evaluation. That said, let's keep moving forward with the rest of your system design—what were you thinking for the next component?`,
timestamp: new Date()
});
session.markModified('constraintChanges');
session.markModified('aiMessages');
}

await session.save();

return NextResponse.json({ success: true, messages: session.aiMessages, constraintChanges: session.constraintChanges });
} catch (error) {
console.error('Chaos Timeout Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
42 changes: 42 additions & 0 deletions app/api/interview/[id]/final-validation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import dbConnect from '@/src/lib/db/mongoose';
import InterviewSession from '@/src/lib/db/models/InterviewSession';

export async function POST(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
await dbConnect();
const { id } = await context.params;

const session = await InterviewSession.findOne({ id });
if (!session) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 });
}

if (!session.aiMessages) session.aiMessages = [];

// Idempotency check: Have we already sent it?
const alreadySent = session.aiMessages.some((m: any) =>

Check failure on line 21 in app/api/interview/[id]/final-validation/route.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Unexpected any. Specify a different type
m.role === 'interviewer' && m.content.includes("Final Validation Phase")
);

if (alreadySent || session.status !== 'in_progress') {
return NextResponse.json({ success: true, messages: session.aiMessages });
}

session.aiMessages.push({
role: 'interviewer',
content: `**Final Validation Phase**: We have roughly 10 minutes left in the interview! It's time to test your architecture's resiliency. Please turn to the **Simulation Controls** panel, hit 'Run Test', and use the **Target Throughput** slider to simulate traffic. Talk me through how your system behaves under different load scenarios, and point out any bottlenecks.`,
timestamp: new Date()
});
session.markModified('aiMessages');
await session.save();

return NextResponse.json({ success: true, messages: session.aiMessages });
} catch (error) {
console.error('Final Validation Error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
7 changes: 5 additions & 2 deletions app/api/interview/[id]/hint/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ function generateConstraintChange(
impactAreas: ['scalability', 'caching', 'load balancing'],
};
} else if ((databaseCount <= 1 || mentionsDisconnected) && difficulty !== 'easy') {
const candidateNodes = nodes.filter((n) => ['SQL', 'Blob', 'Cache', 'Server'].includes(n.type));
const victim = candidateNodes[Math.floor(Math.random() * candidateNodes.length)];
selected = {
type: 'reliability',
title: 'Regional Failover',
description: 'The system must continue serving users during a full regional outage with minimal disruption.',
title: 'Node Failure',
description: victim ? `The ${victim.label || victim.type} component just went offline unexpectedly. The system must continue serving users with minimal disruption.` : 'The system must continue serving users during a full regional outage with minimal disruption.',
severity: difficulty === 'hard' ? 'high' : 'moderate',
impactAreas: ['availability', 'replication', 'disaster recovery'],
impactedNodeId: victim?.id,
};
} else if (!hasQueue && (hasRealtimePrompt || nodeTypes.has('Server') || nodeTypes.has('Function'))) {
selected = {
Expand Down
40 changes: 39 additions & 1 deletion app/interview/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
const [showHints, setShowHints] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isInterviewPanelOpen, setIsInterviewPanelOpen] = useState(false);
const [finalValidationTriggered, setFinalValidationTriggered] = useState(false);

// Refs for save logic
const isSavingRef = useRef(false);
Expand Down Expand Up @@ -100,6 +101,23 @@
onConstraintChange: handleConstraintChange
});

// Final Validation Phase Trigger automatically checks and dispatches.
useEffect(() => {
if (!finalValidationTriggered && timer.minutes !== undefined && timer.minutes <= 10 && session?.status === 'in_progress') {
setFinalValidationTriggered(true);

authFetch(`/api/interview/${id}/final-validation`, { method: 'POST' })
.then(res => res.json())
.then(data => {
if (data.success && data.messages) {
if (ai.setMessages) ai.setMessages(data.messages);
setIsInterviewPanelOpen(true);
}
})
.catch(err => console.error('Final validation trigger failed:', err));
}
}, [timer.minutes, finalValidationTriggered, session?.status, id, ai.setMessages]);

Check warning on line 119 in app/interview/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

React Hook useEffect has a missing dependency: 'ai'. Either include it or remove the dependency array

// Fetch session data
const fetchSession = useCallback(async () => {
if (!user?.uid || !id) return;
Expand Down Expand Up @@ -284,6 +302,24 @@
};
}, [id]);

const handleChaosTimeout = useCallback(async (type: 'warning' | 'penalty', constraintId: string) => {
try {
const res = await authFetch(`/api/interview/${id}/chaos-timeout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, constraintId })
});
const data = await res.json();
if (data.success && data.messages) {
if (ai.setMessages) ai.setMessages(data.messages);
setSession(prev => prev ? { ...prev, constraintChanges: data.constraintChanges } : null);
setIsInterviewPanelOpen(true); // Automatically slide open the interviewer panel
}
} catch (err) {
console.error('Chaos timeout failed:', err);
}
}, [id, ai]);

// Loading state
if (authLoading || isLoading) {
return (
Expand Down Expand Up @@ -330,13 +366,14 @@
<InterviewHeader
difficulty={session.difficulty}
constraintChangeCount={session.constraintChanges?.length || 0}
latestConstraintTitle={session.constraintChanges?.at(-1)?.title}
latestConstraint={session.constraintChanges?.at(-1)}
saveStatus={saveStatus}
timer={timer}
status={session.status}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
sessionId={id}
onChaosTimeout={handleChaosTimeout}
/>

{/* Submit Error Banner */}
Expand Down Expand Up @@ -377,6 +414,7 @@
onSave={isReadOnly ? undefined : saveDesign}
readOnly={isReadOnly}
stateRef={isReadOnly ? undefined : canvasStateRef}
activeConstraints={session.constraintChanges || []}
/>

{/* Properties Panel */}
Expand Down
116 changes: 101 additions & 15 deletions components/canvas/DesignCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
'use client';

import { useState, useRef, useId, useCallback, useEffect, useReducer, MutableRefObject } from 'react';
import { IConstraintChange } from '@/src/lib/db/models/InterviewSession';
import { useSimulationEngine } from '@/src/hooks/useSimulationEngine';
import { SimulationControls } from './SimulationControls';

// Color mapping for different component types
const COLOR_MAP: Record<string, { text: string; darkText: string }> = {
Expand Down Expand Up @@ -81,6 +84,8 @@ interface DesignCanvasProps {
readOnly?: boolean;
/** Live ref to current canvas state — updated on every change */
stateRef?: MutableRefObject<CanvasStateRef | null>;
/** Array of active constraint changes to visually impact the canvas */
activeConstraints?: IConstraintChange[];
}

const MAX_HISTORY = 50;
Expand Down Expand Up @@ -139,7 +144,8 @@ export function DesignCanvas({
initialConnections = DEFAULT_CONNECTIONS,
onSave,
readOnly = false,
stateRef
stateRef,
activeConstraints = []
}: DesignCanvasProps) {
const arrowId = useId();
const canvasRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -176,6 +182,11 @@ export function DesignCanvas({
}
}, [nodes, connections, stateRef]);

// Simulation Engine State
const [isSimulationRunning, setIsSimulationRunning] = useState(false);
const [targetRps, setTargetRps] = useState(10000);
const simulationMetrics = useSimulationEngine(nodes, connections, targetRps, isSimulationRunning);

// Selection state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
Expand Down Expand Up @@ -721,6 +732,21 @@ export function DesignCanvas({
onMouseLeave={handleMouseUp}
onClick={handleCanvasClick}
>
<style>{`
@keyframes dash {
to { stroke-dashoffset: -12; }
}
`}</style>

{!readOnly && (
<SimulationControls
isRunning={isSimulationRunning}
targetRps={targetRps}
onToggle={setIsSimulationRunning}
onChangeRps={setTargetRps}
/>
)}

{/* Grid Background (fixed) */}
<div className="absolute inset-0 bg-grid-pattern pointer-events-none" />

Expand Down Expand Up @@ -771,16 +797,31 @@ export function DesignCanvas({
{connections.map((conn) => {
const isSelected = conn.id === selectedConnectionId;
const pathD = getConnectionPath(conn.from, conn.to);
const edgeMetric = simulationMetrics.edgeMetrics[conn.id];
const isFlowing = isSimulationRunning && edgeMetric && edgeMetric.trafficFlow > 0;

return (
<path
key={conn.id}
d={pathD}
fill="none"
markerEnd={`url(#${arrowId})`}
stroke={isSelected ? '#4725f4' : '#4f4b64'}
strokeWidth={isSelected ? 3 : 2}
className={`pointer-events-none ${isSelected ? 'opacity-100' : 'opacity-60'}`}
/>
<g key={conn.id}>
<path
d={pathD}
fill="none"
markerEnd={`url(#${arrowId})`}
stroke={isSelected ? '#4725f4' : '#4f4b64'}
strokeWidth={isSelected ? 3 : 2}
className={`pointer-events-none ${isSelected ? 'opacity-100' : 'opacity-60'}`}
/>
{isFlowing && (
<path
d={pathD}
fill="none"
stroke="#10b981"
strokeWidth="3"
strokeDasharray="6,6"
className="pointer-events-none opacity-80"
style={{ animation: 'dash 1s linear infinite' }}
/>
)}
</g>
);
})}

Expand All @@ -803,18 +844,57 @@ export function DesignCanvas({
const colors = getColorClasses(node.type);
const isSelected = node.id === selectedNodeId;

const isImpacted = activeConstraints.some(c => c.impactedNodeId === node.id && c.status === 'active');

const nodeMetric = simulationMetrics.nodeMetrics[node.id];
const isBottlenecked = isSimulationRunning && nodeMetric?.status === 'bottlenecked';
const isWarning = isSimulationRunning && nodeMetric?.status === 'warning';

return (
<div
key={node.id}
data-node
style={{ left: node.x, top: node.y }}
className={`absolute w-[60px] h-[60px] bg-white dark:bg-[#1e1e24] shadow-lg rounded-xl flex flex-col items-center justify-center cursor-move group select-none transition-shadow pointer-events-auto ${isSelected
? 'ring-2 ring-primary ring-offset-2 ring-offset-white dark:ring-offset-[#0f1115] shadow-[0_0_20px_rgba(71,37,244,0.3)] z-20'
: 'border-2 border-transparent hover:border-primary'
className={`absolute w-[60px] h-[60px] rounded-xl flex flex-col items-center justify-center select-none shadow-lg transition-all duration-300 ${isImpacted
? 'bg-red-500/10 border-2 border-red-500/50 opacity-80 grayscale-[50%] cursor-not-allowed'
: isBottlenecked
? 'bg-red-600 border-2 border-red-500 shadow-[0_0_20px_rgba(220,38,38,0.7)] text-white ring-2 ring-red-500 animate-pulse'
: isWarning
? 'bg-amber-500/20 border-2 border-amber-500 text-amber-500 shadow-[0_0_15px_rgba(245,158,11,0.5)]'
: 'bg-white dark:bg-[#1e1e24] cursor-move transition-shadow pointer-events-auto ' + (isSelected
? 'ring-2 ring-primary ring-offset-2 ring-offset-white dark:ring-offset-[#0f1115] shadow-[0_0_20px_rgba(71,37,244,0.3)] z-20'
: 'border-2 border-transparent hover:border-primary')
}`}
onMouseDown={(e) => handleNodeMouseDown(e, node.id)}
onMouseUp={(e) => handleNodeMouseUp(e, node.id)}
onMouseDown={(e) => {
if (isImpacted) {
e.stopPropagation();
setSelectedNodeId(node.id);
return;
}
handleNodeMouseDown(e, node.id);
}}
onMouseUp={(e) => {
if (isImpacted) {
e.stopPropagation();
return;
}
handleNodeMouseUp(e, node.id);
}}
>
{isBottlenecked && !isImpacted && (
<div className="absolute -top-6 bg-red-600 text-[9px] text-white font-bold px-1.5 py-0.5 rounded shadow-[0_0_10px_rgba(239,68,68,0.5)] whitespace-nowrap z-40 outline outline-2 outline-white dark:outline-[#1e1e24] animate-bounce">
BOTTLENECK
</div>
)}

{isSimulationRunning && nodeMetric && node.type !== 'Client' && (
<div className={`absolute -bottom-8 bg-black/80 dark:bg-black/90 text-white text-[8px] font-mono px-1.5 py-0.5 rounded shadow-sm z-30 whitespace-nowrap flex items-center gap-1 opacity-90 backdrop-blur-sm ${isBottlenecked ? 'text-red-300' : isWarning ? 'text-amber-300' : 'text-slate-300'}`}>
<span>{(nodeMetric.trafficIn / 1000).toFixed(1)}k</span>
<span className="text-slate-500">/</span>
<span className="text-slate-400">{(nodeMetric.capacity / 1000).toFixed(1)}k RPS</span>
</div>
)}

{/* Delete button - visible when selected and not readOnly */}
{isSelected && !readOnly && (
<button
Expand All @@ -829,6 +909,12 @@ export function DesignCanvas({
<span className="material-symbols-outlined" style={{ fontSize: '14px' }}>close</span>
</button>
)}

{isImpacted && (
<div className="absolute -top-3 -right-3 size-6 bg-red-600 outline outline-2 outline-white dark:outline-[#1e1e24] text-white rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(239,68,68,0.5)] z-30 animate-pulse">
<span className="material-symbols-outlined" style={{ fontSize: '14px' }}>warning</span>
</div>
)}
<span
className={`material-symbols-outlined ${colors.text} ${colors.darkText}`}
style={{ fontSize: '28px' }}
Expand Down
Loading
Loading