diff --git a/src/App.tsx b/src/App.tsx index 837c392..dc93a2e 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import OsmlTray from "@/components/OsmlTray"; import StatusDisplay from "@/components/StatusDisplay"; import Logo from "@/components/Logo"; import FeaturePopup, { type FeaturePopupData } from "@/components/FeaturePopup"; +import ConfigWarnings from "@/components/alert/ConfigWarnings"; import { ResourceProvider } from "@/context/ResourceContext"; /** Natural Earth II fallback (offline, bundled with Cesium) */ @@ -81,6 +82,8 @@ const App = () => { if (!baseLayer) return null; return ( + <> + { )} + ); }; diff --git a/src/components/alert/ConfigWarnings.css b/src/components/alert/ConfigWarnings.css new file mode 100644 index 0000000..f69e34a --- /dev/null +++ b/src/components/alert/ConfigWarnings.css @@ -0,0 +1,124 @@ +/* Copyright 2023-2026 Amazon.com, Inc. or its affiliates. */ + + +/* ── ConfigWarnings toast stack ─────────────────────────────────── */ + +.cw-stack { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 3000; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; + max-width: 520px; + width: 90vw; +} + +/* ── Individual toast ──────────────────────────────────────────── */ + +.cw-toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + animation: cw-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1) both; +} + +.cw-toast--exit { + animation: cw-slide-out 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +/* ── Content ───────────────────────────────────────────────────── */ + +.cw-toast-body { + flex: 1; + min-width: 0; +} + +.cw-toast-title { + font-size: 13px; + font-weight: 650; + margin-bottom: 2px; +} + +.cw-toast-message { + font-size: 12px; + line-height: 1.45; + opacity: 0.72; +} + +/* ── Action button (e.g. Retry) ────────────────────────────────── */ + +.cw-toast-action { + flex-shrink: 0; + background: rgba(220, 38, 38, 0.2); + border: 1px solid rgba(220, 38, 38, 0.3); + border-radius: 8px; + color: #fca5a5; + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease; + white-space: nowrap; + align-self: center; +} + +.cw-toast-action:hover { + background: rgba(220, 38, 38, 0.35); +} + +.cw-toast-action:disabled { + opacity: 0.5; + cursor: default; +} + +/* ── Close button ──────────────────────────────────────────────── */ + +.cw-toast-close { + flex-shrink: 0; + background: none; + border: none; + padding: 2px; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s ease; + margin-top: 1px; +} + +.cw-toast-close:hover { + opacity: 1; +} + +/* ── Animations ────────────────────────────────────────────────── */ + +@keyframes cw-slide-in { + from { + opacity: 0; + transform: translateY(-12px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes cw-slide-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(-8px) scale(0.96); + } +} diff --git a/src/components/alert/ConfigWarnings.tsx b/src/components/alert/ConfigWarnings.tsx new file mode 100644 index 0000000..b619ce3 --- /dev/null +++ b/src/components/alert/ConfigWarnings.tsx @@ -0,0 +1,177 @@ +// Copyright 2023-2026 Amazon.com, Inc. or its affiliates. + +import { useEffect, useState } from "react"; +import { getConfigWarnings, type ConfigWarning } from "@/config"; +import "./ConfigWarnings.css"; + +/** Auto-dismiss delay in ms per severity */ +const DISMISS_DELAY: Record = { + warning: 12_000, + error: 20_000 +}; + +const SEVERITY_STYLES: Record< + ConfigWarning["severity"], + { accent: string; bg: string; border: string; icon: string } +> = { + error: { + accent: "#fca5a5", + bg: "rgba(220, 38, 38, 0.12)", + border: "rgba(220, 38, 38, 0.3)", + icon: "#f87171" + }, + warning: { + accent: "#fcd34d", + bg: "rgba(234, 179, 8, 0.10)", + border: "rgba(234, 179, 8, 0.3)", + icon: "#facc15" + } +}; + +function WarningIcon({ color }: { color: string }) { + return ( + + + + + + ); +} + +function ErrorIcon({ color }: { color: string }) { + return ( + + + + + + ); +} + +function Toast({ + warning, + onDismiss +}: { + warning: ConfigWarning; + onDismiss: () => void; +}) { + const [exiting, setExiting] = useState(false); + const s = SEVERITY_STYLES[warning.severity]; + + useEffect(() => { + const timer = setTimeout(() => { + setExiting(true); + }, DISMISS_DELAY[warning.severity]); + return () => clearTimeout(timer); + }, [warning.severity]); + + // After the exit animation finishes, remove the toast + useEffect(() => { + if (!exiting) return; + const timer = setTimeout(onDismiss, 300); + return () => clearTimeout(timer); + }, [exiting, onDismiss]); + + const handleDismiss = () => { + setExiting(true); + }; + + return ( +
+ {warning.severity === "error" ? ( + + ) : ( + + )} + +
+
+ {warning.title} +
+
+ {warning.message} +
+
+ + +
+ ); +} + +const ConfigWarnings = () => { + const [warnings, setWarnings] = useState([]); + + useEffect(() => { + let cancelled = false; + getConfigWarnings().then((result) => { + if (!cancelled) setWarnings(result); + }); + return () => { cancelled = true; }; + }, []); + + const dismiss = (index: number) => { + setWarnings((prev) => prev.filter((_, i) => i !== index)); + }; + + if (warnings.length === 0) return null; + + return ( +
+ {warnings.map((w, i) => ( + dismiss(i)} /> + ))} +
+ ); +}; + +export default ConfigWarnings; diff --git a/src/components/alert/CredsExpiredAlert.tsx b/src/components/alert/CredsExpiredAlert.tsx index f850333..a1911e6 100755 --- a/src/components/alert/CredsExpiredAlert.tsx +++ b/src/components/alert/CredsExpiredAlert.tsx @@ -1,106 +1,110 @@ // Copyright 2023-2026 Amazon.com, Inc. or its affiliates. -import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; import { useState } from "react"; - -import { getAWSCreds, REGION } from "@/config"; - -const startingAlertMessage = - "Refresh your credentials before closing this alert."; +import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts"; +import { getAWSCreds, isCredentialError, REGION } from "@/config"; +import "./ConfigWarnings.css"; const CredsExpiredAlert = ({ setShowCredsExpiredAlert }: { - setShowCredsExpiredAlert: any; + setShowCredsExpiredAlert: (value: boolean) => void; }) => { - const [alertMessage, setAlertMessage] = useState(startingAlertMessage); + const [message, setMessage] = useState( + "Refresh your credentials before closing this alert." + ); + const [retrying, setRetrying] = useState(false); + const [exiting, setExiting] = useState(false); - const updateAlert = async () => { + const handleRetry = async () => { + setRetrying(true); try { await new STSClient({ region: REGION, credentials: getAWSCreds() }).send(new GetCallerIdentityCommand({})); - setShowCredsExpiredAlert(false); - setAlertMessage(startingAlertMessage); - } catch (e: any) { - console.error(`Exception caught: ${e}`); - if (e.name === "ExpiredToken") { - setAlertMessage( - "AWS token still is expired. Refresh credentials and try again." + + // Success — dismiss + setExiting(true); + setTimeout(() => setShowCredsExpiredAlert(false), 300); + } catch (e: unknown) { + if (isCredentialError(e)) { + setMessage( + "AWS credentials are still invalid. Refresh your credentials and try again." ); } else { - setAlertMessage( - "Unknown error occurred when testing credentials. Restart application." + setMessage( + "An unexpected error occurred while verifying credentials. Please restart the application." ); } + } finally { + setRetrying(false); } }; return ( -
- {/* Error icon */} - - - - - - -
-
- Credentials Expired -
-
- {alertMessage} -
-
- - + {/* Error icon */} + + + + + + +
+
+ Credentials Expired +
+
+ {message} +
+
+ + + + +
); }; diff --git a/src/components/modal/ImageRequestModal.tsx b/src/components/modal/ImageRequestModal.tsx index f39f80e..0e80db8 100755 --- a/src/components/modal/ImageRequestModal.tsx +++ b/src/components/modal/ImageRequestModal.tsx @@ -190,12 +190,17 @@ const NewRequestModal = ({ // Load S3 buckets useEffect(() => { (async () => { - setBucketStatus("loading"); - const res: any = await getListOfS3Buckets(setShowCredsExpiredAlert); - if (res !== undefined) { - setS3Buckets(res.map((b: any) => ({ value: b["Name"] }))); - setBucketStatus("finished"); - } else { + try { + setBucketStatus("loading"); + const res = await getListOfS3Buckets(setShowCredsExpiredAlert); + if (res && res.length > 0) { + setS3Buckets(res.map((b: any) => ({ value: b["Name"] }))); + setBucketStatus("finished"); + } else { + setBucketStatus("error"); + } + } catch (e) { + console.error("Error loading S3 buckets:", e); setBucketStatus("error"); } })(); @@ -204,24 +209,34 @@ const NewRequestModal = ({ // Load SM endpoints useEffect(() => { (async () => { - setModelStatus("loading"); - const res: any = await getListOfSMEndpoints(setShowCredsExpiredAlert); - if (res !== undefined) { - setSMModels(res.map((e: string) => ({ value: e }))); - setModelStatus("finished"); - } else { + try { + setModelStatus("loading"); + const res = await getListOfSMEndpoints(setShowCredsExpiredAlert); + if (res && res.length > 0) { + setSMModels(res.filter((e): e is string => !!e).map((e) => ({ value: e }))); + setModelStatus("finished"); + } else { + setModelStatus("error"); + } + } catch (e) { + console.error("Error loading SageMaker endpoints:", e); setModelStatus("error"); } })(); }, [showCredsExpiredAlert]); const loadS3Objects = async (bucket: string) => { - setImageStatus("loading"); - const res: any = await getListOfS3Objects(bucket, setShowCredsExpiredAlert); - if (res !== undefined) { - setS3Objects(res.map((o: any) => ({ value: o["Key"] }))); - setImageStatus("finished"); - } else { + try { + setImageStatus("loading"); + const res = await getListOfS3Objects(bucket, setShowCredsExpiredAlert); + if (res && Array.isArray(res) && res.length > 0) { + setS3Objects(res.map((o: any) => ({ value: o["Key"] }))); + setImageStatus("finished"); + } else { + setImageStatus("error"); + } + } catch (e) { + console.error("Error loading S3 objects:", e); setImageStatus("error"); } }; diff --git a/src/components/modal/LoadDataModal.tsx b/src/components/modal/LoadDataModal.tsx index e82f33b..fc0a067 100755 --- a/src/components/modal/LoadDataModal.tsx +++ b/src/components/modal/LoadDataModal.tsx @@ -57,28 +57,38 @@ const LoadDataModal = ({ useEffect(() => { if (!showLoadDataModal) return; (async () => { - setBucketStatus("loading"); - const res: any = await getListOfS3Buckets(setShowCredsExpiredAlert); - if (res !== undefined) { - setS3Buckets(res.map((b: any) => ({ value: b["Name"] }))); - setBucketStatus("finished"); - } else { + try { + setBucketStatus("loading"); + const res = await getListOfS3Buckets(setShowCredsExpiredAlert); + if (res && res.length > 0) { + setS3Buckets(res.map((b: any) => ({ value: b["Name"] }))); + setBucketStatus("finished"); + } else { + setBucketStatus("error"); + } + } catch (e) { + console.error("Error loading S3 buckets:", e); setBucketStatus("error"); } })(); }, [showLoadDataModal, showCredsExpiredAlert]); const loadS3Objects = async (bucket: string) => { - setGeojsonStatus("loading"); - const res: any = await getListOfS3Objects(bucket, setShowCredsExpiredAlert); - if (res !== undefined) { - const geojsonFiles = res - .map((o: any) => o["Key"]) - .filter((k: string) => k.endsWith(".geojson") || k.endsWith(".json")) - .map((k: string) => ({ value: k })); - setS3Objects(geojsonFiles); - setGeojsonStatus("finished"); - } else { + try { + setGeojsonStatus("loading"); + const res = await getListOfS3Objects(bucket, setShowCredsExpiredAlert); + if (res && Array.isArray(res) && res.length > 0) { + const geojsonFiles = res + .map((o: any) => o["Key"]) + .filter((k: string) => k.endsWith(".geojson") || k.endsWith(".json")) + .map((k: string) => ({ value: k })); + setS3Objects(geojsonFiles); + setGeojsonStatus("finished"); + } else { + setGeojsonStatus("error"); + } + } catch (e) { + console.error("Error loading S3 objects:", e); setGeojsonStatus("error"); } }; diff --git a/src/components/modal/LoadImageModal.tsx b/src/components/modal/LoadImageModal.tsx index e432ece..8cc994e 100755 --- a/src/components/modal/LoadImageModal.tsx +++ b/src/components/modal/LoadImageModal.tsx @@ -62,28 +62,38 @@ const LoadImageModal = ({ useEffect(() => { if (!showLoadImageModal) return; (async () => { - setBucketStatus("loading"); - const res: any = await getListOfS3Buckets(setShowCredsExpiredAlert); - if (res !== undefined) { - setS3Buckets(res.map((b: any) => ({ value: b["Name"] }))); - setBucketStatus("finished"); - } else { + try { + setBucketStatus("loading"); + const res = await getListOfS3Buckets(setShowCredsExpiredAlert); + if (res && res.length > 0) { + setS3Buckets(res.map((b: any) => ({ value: b["Name"] }))); + setBucketStatus("finished"); + } else { + setBucketStatus("error"); + } + } catch (e) { + console.error("Error loading S3 buckets:", e); setBucketStatus("error"); } })(); }, [showLoadImageModal, showCredsExpiredAlert]); const loadS3Objects = async (bucket: string) => { - setImageStatus("loading"); - const res: any = await getListOfS3Objects(bucket, setShowCredsExpiredAlert); - if (res !== undefined) { - const imageFiles = res - .map((o: any) => o["Key"]) - .filter((k: string) => isImageFile(k)) - .map((k: string) => ({ value: k })); - setS3Objects(imageFiles); - setImageStatus("finished"); - } else { + try { + setImageStatus("loading"); + const res = await getListOfS3Objects(bucket, setShowCredsExpiredAlert); + if (res && Array.isArray(res) && res.length > 0) { + const imageFiles = res + .map((o: any) => o["Key"]) + .filter((k: string) => isImageFile(k)) + .map((k: string) => ({ value: k })); + setS3Objects(imageFiles); + setImageStatus("finished"); + } else { + setImageStatus("error"); + } + } catch (e) { + console.error("Error loading S3 objects:", e); setImageStatus("error"); } }; diff --git a/src/config.ts b/src/config.ts index b53a3d4..b102426 100755 --- a/src/config.ts +++ b/src/config.ts @@ -27,10 +27,19 @@ export const S3_RESULTS_BUCKET_PREFIX: string = "mr-bucket-sink"; export const KINESIS_RESULTS_STREAM_PREFIX: string = "mr-stream-sink"; // deployment info — resolved from standard AWS sources, falls back to us-west-2 -function resolveRegion(): string { +export type RegionSource = "AWS_REGION" | "AWS_DEFAULT_REGION" | "aws-config" | "default"; + +interface RegionResolution { + region: string; + source: RegionSource; +} + +function resolveRegion(): RegionResolution { // 1. Environment variables (same precedence the AWS SDK uses) - if (process.env.AWS_REGION) return process.env.AWS_REGION; - if (process.env.AWS_DEFAULT_REGION) return process.env.AWS_DEFAULT_REGION; + if (process.env.AWS_REGION) + return { region: process.env.AWS_REGION, source: "AWS_REGION" }; + if (process.env.AWS_DEFAULT_REGION) + return { region: process.env.AWS_DEFAULT_REGION, source: "AWS_DEFAULT_REGION" }; // 2. ~/.aws/config [default] profile try { @@ -41,15 +50,126 @@ function resolveRegion(): string { const region = parser.get("default", "region", undefined) as | string | undefined; - if (region) return region.trim(); + if (region) return { region: region.trim(), source: "aws-config" }; } catch { // Config file missing or unreadable — fall through to default } - return "us-west-2"; + return { region: "us-west-2", source: "default" }; +} + +const regionResolution = resolveRegion(); +export const REGION: string = regionResolution.region; +export const REGION_SOURCE: RegionSource = regionResolution.source; + +// ── AWS configuration diagnostics ────────────────────────────────────────── + +export interface ConfigWarning { + severity: "error" | "warning"; + title: string; + message: string; +} + +/** Synchronous check for file-level issues (missing creds file, region fallback). */ +function getStaticWarnings(): ConfigWarning[] { + const warnings: ConfigWarning[] = []; + + // Check credentials file exists and has keys + try { + const creds = getAWSCreds(); + if (!creds || !creds.accessKeyId || !creds.secretAccessKey) { + warnings.push({ + severity: "error", + title: "AWS Credentials Missing", + message: + "No valid credentials found in ~/.aws/credentials. S3, SageMaker, and other AWS features will not work." + }); + } + } catch { + warnings.push({ + severity: "error", + title: "AWS Credentials Not Found", + message: + "Could not read ~/.aws/credentials. Configure your AWS CLI credentials to enable AWS features." + }); + } + + // Check region fallback + if (REGION_SOURCE === "default") { + warnings.push({ + severity: "warning", + title: "Using Default Region", + message: `No AWS region configured — falling back to ${REGION}. Set AWS_REGION or configure ~/.aws/config to change this.` + }); + } + + return warnings; +} + +/** + * Full config check: synchronous file checks + async STS validation. + * Returns all warnings (file-level + credential validity). + */ +export async function getConfigWarnings(): Promise { + const warnings = getStaticWarnings(); + + // If creds file is missing/empty, skip the live check — already reported + const hasCredsFileError = warnings.some( + (w) => w.severity === "error" && w.title.startsWith("AWS Credentials") + ); + + if (!hasCredsFileError) { + try { + await new STSClient({ + region: REGION, + credentials: getAWSCreds() + }).send(new GetCallerIdentityCommand({})); + } catch (e: unknown) { + const name = (e as { name?: string })?.name ?? ""; + const isExpired = + name === "ExpiredToken" || + name === "ExpiredTokenException" || + name === "RequestExpired"; + + warnings.push({ + severity: "error", + title: isExpired ? "AWS Credentials Expired" : "AWS Credentials Invalid", + message: isExpired + ? "Your AWS session token has expired. Refresh your credentials and restart the application." + : "Could not authenticate with AWS. Verify your credentials in ~/.aws/credentials are correct." + }); + } + } + + return warnings; } -export const REGION: string = resolveRegion(); +// ── Credential error detection (used by helpers) ─────────────────────────── + +/** Known AWS SDK error names related to authentication / authorization failures */ +const CREDENTIAL_ERROR_NAMES = new Set([ + "ExpiredToken", + "ExpiredTokenException", + "RequestExpired", + "InvalidClientTokenId", + "UnrecognizedClientException", + "InvalidIdentityToken", + "AccessDeniedException", + "AuthFailure", + "SignatureDoesNotMatch", + "IncompleteSignature" +]); + +/** Returns true if the error is an AWS credentials / auth error. */ +export function isCredentialError(e: unknown): boolean { + const name = (e as { name?: string })?.name ?? ""; + if (CREDENTIAL_ERROR_NAMES.has(name)) return true; + + // Fallback: check for common HTTP status codes from auth failures + const statusCode = (e as { $metadata?: { httpStatusCode?: number } })?.$metadata + ?.httpStatusCode; + return statusCode === 400 || statusCode === 401 || statusCode === 403; +} // grab the aws credentials interface Credentials { diff --git a/src/util/s3Helper.ts b/src/util/s3Helper.ts index e352b38..30817b4 100755 --- a/src/util/s3Helper.ts +++ b/src/util/s3Helper.ts @@ -1,4 +1,4 @@ -// Copyright 2023-2024 Amazon.com, Inc. or its affiliates. +// Copyright 2023-2026 Amazon.com, Inc. or its affiliates. import { _Object, @@ -12,12 +12,15 @@ import { S3Client } from "@aws-sdk/client-s3"; -import CredsExpiredAlert from "@/components/alert/CredsExpiredAlert"; -import { getAWSCreds, REGION } from "@/config"; +import { getAWSCreds, isCredentialError, REGION } from "@/config"; const s3Client: S3Client = new S3Client({ region: REGION, - credentials: getAWSCreds() + credentials: () => { + const creds = getAWSCreds(); + if (!creds) return Promise.reject(new Error("No AWS credentials found")); + return Promise.resolve(creds); + } }); /** @@ -51,10 +54,9 @@ export async function getListOfS3Buckets( ); } } catch (e: unknown) { - if (e instanceof CredsExpiredAlert) { + console.error("Failed to list S3 buckets:", e); + if (isCredentialError(e)) { setShowCredsExpiredAlert(true); - } else { - throw e; } } @@ -83,10 +85,9 @@ export async function getListOfS3Objects( console.error("Cannot fetch S3 Objects from this bucket: " + bucketName); } } catch (e: unknown) { - if (e instanceof CredsExpiredAlert) { + console.error(`Failed to list objects in bucket "${bucketName}":`, e); + if (isCredentialError(e)) { setShowCredsExpiredAlert(true); - } else { - throw e; } } @@ -130,10 +131,9 @@ export async function loadS3Object( } } } catch (e: unknown) { - if (e instanceof CredsExpiredAlert) { + console.error(`Failed to load S3 object "${s3Object.name}":`, e); + if (isCredentialError(e)) { setShowCredsExpiredAlert(true); - } else { - throw e; } } diff --git a/src/util/smHelper.ts b/src/util/smHelper.ts index 9f5e04a..b13968e 100755 --- a/src/util/smHelper.ts +++ b/src/util/smHelper.ts @@ -1,4 +1,4 @@ -// Copyright 2023-2024 Amazon.com, Inc. or its affiliates. +// Copyright 2023-2026 Amazon.com, Inc. or its affiliates. import { EndpointSummary, @@ -6,8 +6,7 @@ import { SageMakerClient } from "@aws-sdk/client-sagemaker"; -import CredsExpiredAlert from "@/components/alert/CredsExpiredAlert"; -import { getAWSCreds, REGION } from "@/config"; +import { getAWSCreds, isCredentialError, REGION } from "@/config"; /** * Retrieves a list of all SageMaker endpoints @@ -36,10 +35,9 @@ export async function getListOfSMEndpoints( (endpoint: EndpointSummary) => endpoint.EndpointName ); } catch (e: unknown) { - if (e instanceof CredsExpiredAlert) { + console.error("Failed to list SageMaker endpoints:", e); + if (isCredentialError(e)) { setShowCredsExpiredAlert(true); - } else { - throw e; } } return;