Skip to content

Commit 68cb3f1

Browse files
committed
fix: improve quota error handling and ttyd password refresh
- Show clear error message when machine type quota is exhausted - Auto-suggest alternative machine type when quota error occurs - Add machine type selector when resuming stopped environment - Auto-refresh environment data to get ttyd password after startup - Add refresh button in web terminal for manual password refresh - Show loading state when password is not yet available
1 parent 7f412fc commit 68cb3f1

3 files changed

Lines changed: 199 additions & 68 deletions

File tree

src/components/environment-panel.tsx

Lines changed: 142 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import {
2828
RefreshCw,
2929
CircleCheck,
3030
CircleX,
31-
Terminal,
3231
} from "lucide-react";
3332
import { WebTerminal, WebTerminalButton } from "./web-terminal";
3433

@@ -128,32 +127,66 @@ export function EnvironmentPanel({
128127
}
129128
}, [environment, checkServiceStatus]);
130129

131-
// Auto-check service status when environment is running
130+
// Refresh environments list (also fetches individual environment for latest metadata)
131+
const refreshEnvironments = useCallback(async () => {
132+
try {
133+
// First, get the full list
134+
const listResponse = await fetch("/api/environment");
135+
const listData = await listResponse.json();
136+
137+
if (listData.environments) {
138+
// For each running environment, fetch its individual data to get latest metadata
139+
// (instancesClient.list may not have the most recent metadata like ttyd-password)
140+
const updatedEnvironments = await Promise.all(
141+
listData.environments.map(async (env: DevEnvironment) => {
142+
if (env.status === "RUNNING" && !env.ttydPassword) {
143+
// Fetch individual environment to get latest metadata
144+
try {
145+
const envResponse = await fetch(`/api/environment?instanceId=${encodeURIComponent(env.instanceId)}`);
146+
const envData = await envResponse.json();
147+
if (envData.environment) {
148+
return envData.environment;
149+
}
150+
} catch {
151+
// Fall back to list data
152+
}
153+
}
154+
return env;
155+
})
156+
);
157+
setEnvironments(updatedEnvironments);
158+
}
159+
} catch (err) {
160+
console.error("Error refreshing environments:", err);
161+
}
162+
}, []);
163+
164+
// Auto-check service status and refresh environment data when running
132165
useEffect(() => {
133166
if (environment?.status === "RUNNING" && environment.externalIp) {
167+
// Initial checks
134168
checkServiceStatus(environment.instanceId);
135169

136-
// Poll every 30 seconds
170+
// If no password yet, refresh environment data to get it
171+
// (ttyd password is set by startup script which takes a few seconds)
172+
if (!environment.ttydPassword) {
173+
const passwordPollInterval = setInterval(async () => {
174+
await refreshEnvironments();
175+
}, 5000); // Check every 5 seconds for password
176+
177+
// Stop polling after 60 seconds (startup script should be done by then)
178+
setTimeout(() => clearInterval(passwordPollInterval), 60000);
179+
}
180+
181+
// Poll service status every 30 seconds
137182
const interval = setInterval(() => {
138183
checkServiceStatus(environment.instanceId);
184+
refreshEnvironments(); // Also refresh environment data
139185
}, 30000);
140186

141187
return () => clearInterval(interval);
142188
}
143-
}, [environment?.status, environment?.externalIp, environment?.instanceId, checkServiceStatus]);
144-
145-
// Refresh environments list
146-
const refreshEnvironments = useCallback(async () => {
147-
try {
148-
const response = await fetch("/api/environment");
149-
const data = await response.json();
150-
if (data.environments) {
151-
setEnvironments(data.environments);
152-
}
153-
} catch (err) {
154-
console.error("Error refreshing environments:", err);
155-
}
156-
}, []);
189+
}, [environment?.status, environment?.externalIp, environment?.instanceId, environment?.ttydPassword, checkServiceStatus, refreshEnvironments]);
157190

158191
// Poll for environment status until target state is reached
159192
const pollUntilStatus = useCallback(async (
@@ -217,7 +250,30 @@ export function EnvironmentPanel({
217250
const data = await response.json();
218251

219252
if (!response.ok) {
220-
throw new Error(data.error || "Failed to create environment");
253+
const errorMsg = data.error || "Failed to create environment";
254+
255+
// Check if this is a quota exhausted error
256+
if (errorMsg.startsWith("QUOTA_EXHAUSTED:")) {
257+
// Parse the error: QUOTA_EXHAUSTED:machineType:message
258+
const parts = errorMsg.split(":");
259+
const exhaustedMachineType = parts[1];
260+
const userMessage = parts.slice(2).join(":");
261+
262+
// Find alternative machine types
263+
const currentIndex = MACHINE_CONFIGS.findIndex(c => c.machineType === exhaustedMachineType);
264+
const alternatives = MACHINE_CONFIGS.filter((_, i) => i !== currentIndex);
265+
266+
if (alternatives.length > 0) {
267+
// Suggest the next available machine type
268+
const nextConfig = alternatives[0];
269+
setSelectedMachineType(nextConfig.machineType);
270+
setShowMachineSelector(true); // Open the selector so user can see options
271+
}
272+
273+
throw new Error(userMessage);
274+
}
275+
276+
throw new Error(errorMsg);
221277
}
222278

223279
// Initial environment created, now poll until RUNNING
@@ -508,7 +564,17 @@ export function EnvironmentPanel({
508564

509565
{error && (
510566
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm max-w-md mx-auto">
511-
{error}
567+
<div className="flex items-start gap-3">
568+
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
569+
<div>
570+
<p className="font-medium">{error}</p>
571+
{error.includes("unavailable") && (
572+
<p className="mt-2 text-slate-400">
573+
👆 Please select a different machine type above and try again.
574+
</p>
575+
)}
576+
</div>
577+
</div>
512578
</div>
513579
)}
514580

@@ -679,51 +745,68 @@ echo "✓ Ready! You can now click 'Open in Cursor/VS Code'"`;
679745
</p>
680746

681747
{error && (
682-
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
683-
{error}
748+
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm max-w-md mx-auto">
749+
<div className="flex items-start gap-3">
750+
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />
751+
<div>
752+
<p className="font-medium">{error}</p>
753+
{error.includes("unavailable") && (
754+
<p className="mt-2 text-slate-400">
755+
The selected machine type is out of capacity. Please try again later or contact support.
756+
</p>
757+
)}
758+
</div>
759+
</div>
684760
</div>
685761
)}
686762

687763
{showNewInstanceForm ? (
688764
newInstanceFormJSX
689765
) : (
690-
<div className="flex items-center justify-center gap-4">
691-
<button
692-
onClick={() => createEnvironment(environment.instanceId)}
693-
disabled={isCreating || isDeleting}
694-
className="inline-flex items-center gap-3 px-8 py-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-cyan-500 text-white font-semibold text-lg hover:from-emerald-400 hover:to-cyan-400 transition-all hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
695-
>
696-
{isCreating ? (
697-
<>
698-
<Loader2 className="w-6 h-6 animate-spin" />
699-
Resuming...
700-
</>
701-
) : (
702-
<>
703-
<Play className="w-6 h-6" />
704-
Resume Environment
705-
</>
706-
)}
707-
</button>
766+
<>
767+
{/* Machine type selector for resume - useful when quota is exhausted */}
768+
<div className="max-w-md mx-auto mb-6">
769+
{machineTypeSelectorJSX}
770+
</div>
708771

709-
<button
710-
onClick={deleteEnvironment}
711-
disabled={isCreating || isDeleting}
712-
className="inline-flex items-center gap-3 px-6 py-4 rounded-2xl bg-red-500/10 text-red-400 font-semibold hover:bg-red-500/20 transition-all disabled:opacity-50"
713-
>
714-
{isDeleting ? (
715-
<>
716-
<Loader2 className="w-5 h-5 animate-spin" />
717-
Resetting...
718-
</>
719-
) : (
720-
<>
721-
<RotateCcw className="w-5 h-5" />
722-
Reset
723-
</>
724-
)}
725-
</button>
726-
</div>
772+
<div className="flex items-center justify-center gap-4">
773+
<button
774+
onClick={() => createEnvironment(environment.instanceId)}
775+
disabled={isCreating || isDeleting}
776+
className="inline-flex items-center gap-3 px-8 py-4 rounded-2xl bg-gradient-to-r from-emerald-500 to-cyan-500 text-white font-semibold text-lg hover:from-emerald-400 hover:to-cyan-400 transition-all hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
777+
>
778+
{isCreating ? (
779+
<>
780+
<Loader2 className="w-6 h-6 animate-spin" />
781+
Resuming...
782+
</>
783+
) : (
784+
<>
785+
<Play className="w-6 h-6" />
786+
Resume Environment
787+
</>
788+
)}
789+
</button>
790+
791+
<button
792+
onClick={deleteEnvironment}
793+
disabled={isCreating || isDeleting}
794+
className="inline-flex items-center gap-3 px-6 py-4 rounded-2xl bg-red-500/10 text-red-400 font-semibold hover:bg-red-500/20 transition-all disabled:opacity-50"
795+
>
796+
{isDeleting ? (
797+
<>
798+
<Loader2 className="w-5 h-5 animate-spin" />
799+
Resetting...
800+
</>
801+
) : (
802+
<>
803+
<RotateCcw className="w-5 h-5" />
804+
Reset
805+
</>
806+
)}
807+
</button>
808+
</div>
809+
</>
727810
)}
728811

729812
<p className="mt-6 text-sm text-slate-500">
@@ -885,6 +968,8 @@ echo "✓ SSH configured for all Teable dev environments"`;
885968
ttydPassword={environment.ttydPassword}
886969
instanceId={environment.instanceId}
887970
onClose={() => setShowWebTerminal(false)}
971+
onRefresh={refreshEnvironments}
972+
isRefreshing={isCheckingServices}
888973
/>
889974
)}
890975

src/components/web-terminal.tsx

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,26 @@ import {
88
Check,
99
Key,
1010
Globe,
11+
RefreshCw,
12+
AlertCircle,
1113
} from "lucide-react"
1214

1315
interface WebTerminalProps {
1416
externalIp: string
1517
ttydPassword: string | null
1618
instanceId: string
1719
onClose?: () => void
20+
onRefresh?: () => void
21+
isRefreshing?: boolean
1822
}
1923

2024
export function WebTerminal({
2125
externalIp,
2226
ttydPassword,
2327
instanceId,
2428
onClose,
29+
onRefresh,
30+
isRefreshing,
2531
}: WebTerminalProps) {
2632
const [copied, setCopied] = useState<string | null>(null)
2733

@@ -112,10 +118,35 @@ export function WebTerminal({
112118

113119
{/* Credentials Section */}
114120
<div className="bg-slate-800/50 rounded-xl p-4 space-y-3">
115-
<h4 className="text-sm font-medium text-slate-300 flex items-center gap-2">
116-
<Key className="w-4 h-4" />
117-
Login Credentials (if prompted)
118-
</h4>
121+
<div className="flex items-center justify-between">
122+
<h4 className="text-sm font-medium text-slate-300 flex items-center gap-2">
123+
<Key className="w-4 h-4" />
124+
Login Credentials (if prompted)
125+
</h4>
126+
{onRefresh && (
127+
<button
128+
onClick={onRefresh}
129+
disabled={isRefreshing}
130+
className="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs text-slate-400 hover:text-white hover:bg-slate-700 transition-colors disabled:opacity-50"
131+
>
132+
<RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
133+
{isRefreshing ? 'Refreshing...' : 'Refresh'}
134+
</button>
135+
)}
136+
</div>
137+
138+
{/* No password warning */}
139+
{!ttydPassword && (
140+
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
141+
<AlertCircle className="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" />
142+
<div className="text-sm text-amber-400">
143+
<p className="font-medium">Password not ready yet</p>
144+
<p className="text-amber-400/70 mt-1">
145+
The startup script is still initializing. Click &quot;Refresh&quot; to check again, or wait a moment.
146+
</p>
147+
</div>
148+
</div>
149+
)}
119150

120151
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
121152
{/* Username */}
@@ -137,11 +168,11 @@ export function WebTerminal({
137168
</div>
138169

139170
{/* Password */}
140-
<div className="flex items-center justify-between bg-slate-900 rounded-lg px-3 py-2">
171+
<div className={`flex items-center justify-between bg-slate-900 rounded-lg px-3 py-2 ${!ttydPassword ? 'border border-amber-500/30' : ''}`}>
141172
<div>
142173
<div className="text-xs text-slate-500">Password</div>
143-
<div className="text-white font-mono">
144-
{ttydPassword || "(no password)"}
174+
<div className={`font-mono ${ttydPassword ? 'text-white' : 'text-amber-400'}`}>
175+
{ttydPassword || "(loading...)"}
145176
</div>
146177
</div>
147178
{ttydPassword && (

src/lib/gcp.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export async function createDevEnvironment(
243243

244244
// Try each machine type in order until one succeeds
245245
let lastError: Error | null = null;
246+
let quotaExhaustedMachineType: string | null = null;
246247

247248
for (const config of configsToTry) {
248249
console.log(`Trying to create instance with ${config.machineType}...`);
@@ -354,8 +355,10 @@ export async function createDevEnvironment(
354355

355356
// Check if it's a quota error - try next machine type
356357
if (errorMessage.includes("Quota") || errorMessage.includes("quota") ||
357-
errorMessage.includes("CPUS_PER_VM_FAMILY") || errorMessage.includes("exceeded")) {
358-
console.log(`Quota exceeded for ${config.machineType}, trying next option...`);
358+
errorMessage.includes("CPUS_PER_VM_FAMILY") || errorMessage.includes("exceeded") ||
359+
errorMessage.includes("ZONE_RESOURCE_POOL_EXHAUSTED") || errorMessage.includes("does not have enough resources")) {
360+
console.log(`Quota/resource exhausted for ${config.machineType}, trying next option...`);
361+
quotaExhaustedMachineType = config.machineType;
359362
lastError = error as Error;
360363
continue;
361364
}
@@ -365,8 +368,20 @@ export async function createDevEnvironment(
365368
}
366369
}
367370

368-
// All machine types failed
369-
throw lastError || new Error("Failed to create environment: all machine types exhausted");
371+
// All machine types failed - provide a clear error message
372+
if (quotaExhaustedMachineType) {
373+
// If user specified a machine type and it's exhausted
374+
if (requestedMachineType) {
375+
const config = MACHINE_CONFIGS.find(c => c.machineType === requestedMachineType);
376+
const displayName = config?.displayName || requestedMachineType;
377+
throw new Error(`QUOTA_EXHAUSTED:${requestedMachineType}:Machine type "${displayName}" is currently unavailable (quota exhausted). Please select a different machine type.`);
378+
} else {
379+
// All automatic fallback options exhausted
380+
throw new Error("All machine types are currently unavailable. Please try again later or contact support.");
381+
}
382+
}
383+
384+
throw lastError || new Error("Failed to create environment: unknown error");
370385
}
371386

372387
export async function getDevEnvironment(

0 commit comments

Comments
 (0)