Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 23 additions & 21 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"error_gateway_timeout_description": "The server took too long to respond. Please try again later.",
"error_unexpected_title": "Unexpected Error",
"error_unknown": "An unknown error occurred.",
"generic_error": "An unexpected error occurred. Please try again.",
"error_reset_and_login": "Reset & Sign In",
"offline_page_title": "CropWatch Offline",
"offline_page_description": "CropWatch is offline. Reconnect to load live device data, reports, and settings.",
Expand Down Expand Up @@ -279,6 +280,7 @@
"rules_delete_rule": "Delete Rule",
"rules_delete_confirmation": "Are you sure you want to delete the rule \"{name}\"? This action cannot be undone.",
"rules_delete_failed": "Unable to delete this rule.",
"rules_delete_success": "Rule \"{name}\" deleted successfully.",
"rules_rule_name": "Rule Name",
"rules_rule_name_placeholder": "e.g. High Temperature Alert",
"rules_notification_type": "Notification Type",
Expand Down Expand Up @@ -711,7 +713,7 @@
"devices_csv_export": "CSV",
"devices_current_group_chip": "Current group: {group}",
"devices_dashboard_card_subtitle": "Location {locationName}",
"devices_dashboard_card_title": "Device {devEui}",
"devices_dashboard_card_title": "Device: {devEui}",
"devices_dashboard_page_title": "Device Dashboard - {devEui} - CropWatch",
"devices_deployment_section_subtitle": "Optional placement and lifecycle values stored on the device row.",
"devices_deployment_section_title": "Deployment",
Expand Down Expand Up @@ -987,13 +989,28 @@
"rule_action_forever_acknowledge_option": "I understand and accept responsibility for monitoring this device.",
"rule_action_forever_acknowledge_confirm": "I Understand, Continue",
"rule_action_revert_on_reset": "Revert relay state when the rule resets",
"dashboard_column_primary": "Primary",
"dashboard_column_secondary": "Secondary",
"dashboard_last_update": "Last Update",
"dashboard_loading": "Loading…",
"dashboard_no_reading": "No reading",
"dashboard_unassigned": "Unassigned",
"dashboard_unnamed_device": "Unnamed device",
"dashboard_view_device_details": "View Device Details",
"dashboard_column_name": "Name",
"dashboard_column_group": "Group",
"dashboard_column_last_updated": "Last Updated",
"dashboard_load_more": "Load more",
"dashboard_loading_details": "Loading details…",
"dashboard_no_devices": "No devices",
"dashboard_no_location": "No location",
"sensor_temperature": "Temperature",
"sensor_humidity": "Humidity",
"sensor_moisture": "Moisture",
"sensor_co2": "CO₂",
"sensor_co": "CO",
"sensor_pressure": "Pressure",
"sensor_ec": "Electrical Conductivity",
"sensor_ec": "EC",
"sensor_ph": "pH",
"sensor_depth": "Depth",
"sensor_battery": "Battery",
Expand All @@ -1010,23 +1027,8 @@
"sensor_spo2": "SpO₂",
"sensor_relay_1": "Relay 1",
"sensor_relay_2": "Relay 2",
"sensor_people_count": "People",
"sensor_car_count": "Vehicles",
"sensor_value_on": "ON",
"sensor_value_off": "OFF",
"sensor_value_true": "Yes",
"sensor_value_false": "No",
"dashboard_view_table": "Table",
"dashboard_view_cards": "Cards",
"dashboard_no_devices": "No devices to display.",
"dashboard_no_data_yet": "No data yet",
"dashboard_loading_details": "Loading details…",
"dashboard_column_name": "Device",
"dashboard_column_location": "Location",
"dashboard_column_group": "Group",
"dashboard_column_primary": "Primary",
"dashboard_column_secondary": "Secondary",
"dashboard_column_last_updated": "Last Updated",
"dashboard_load_more": "Load more",
"dashboard_no_location": "No Location"
"sensor_people_count": "People Count",
"sensor_car_count": "Car Count",
"sensor_value_on": "On",
"sensor_value_off": "Off"
}
48 changes: 25 additions & 23 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"error_gateway_timeout_description": "サーバーの応答に時間がかかりすぎました。後ほど再度お試しください。",
"error_unexpected_title": "予期しないエラー",
"error_unknown": "不明なエラーが発生しました。",
"generic_error": "予期しないエラーが発生しました。もう一度お試しください。",
"error_reset_and_login": "リセットしてサインイン",
"offline_page_title": "CropWatch オフライン",
"offline_page_description": "CropWatch はオフラインです。ライブのデバイスデータ、レポート、設定を読み込むには再接続してください。",
Expand Down Expand Up @@ -279,6 +280,7 @@
"rules_delete_rule": "ルールを削除",
"rules_delete_confirmation": "ルール「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
"rules_delete_failed": "このルールを削除できません。",
"rules_delete_success": "ルール「{name}」を削除しました。",
"rules_rule_name": "ルール名",
"rules_rule_name_placeholder": "例: High Temperature Alert",
"rules_notification_type": "通知タイプ",
Expand Down Expand Up @@ -693,6 +695,21 @@
"dashboard_loading_more_locations_hint": "残り 5 枚のカードに達すると、さらに 10 件のロケーションを読み込みます。",
"dashboard_no_alerts_alt": "アラートなし",
"dashboard_no_data_yet": "データはまだありません",
"dashboard_column_primary": "プライマリ",
"dashboard_column_secondary": "セカンダリ",
"dashboard_last_update": "最終更新",
"dashboard_loading": "読み込み中…",
"dashboard_no_reading": "測定値なし",
"dashboard_unassigned": "未割り当て",
"dashboard_unnamed_device": "名前のないデバイス",
"dashboard_view_device_details": "デバイスの詳細を表示",
"dashboard_column_name": "名前",
"dashboard_column_group": "グループ",
"dashboard_column_last_updated": "最終更新",
"dashboard_load_more": "さらに読み込む",
"dashboard_loading_details": "詳細を読み込み中…",
"dashboard_no_devices": "デバイスがありません",
"dashboard_no_location": "ロケーションなし",
"dashboard_no_matching_locations_body": "ダッシュボードのフィルターを調整するか、解除してロケーションをさらに表示してください。",
"dashboard_no_matching_locations_title": "この条件に一致するデバイスはありません",
"dashboard_virtual_scroll": "仮想スクロール",
Expand Down Expand Up @@ -989,13 +1006,13 @@
"rule_action_revert_on_reset": "ルールがリセットされた時にリレーの状態を元に戻す",
"sensor_temperature": "温度",
"sensor_humidity": "湿度",
"sensor_moisture": "水分量",
"sensor_moisture": "水分",
"sensor_co2": "CO₂",
"sensor_co": "CO",
"sensor_pressure": "気圧",
"sensor_ec": "電気伝導度",
"sensor_ec": "EC",
"sensor_ph": "pH",
"sensor_depth": "水深",
"sensor_depth": "深さ",
"sensor_battery": "バッテリー",
"sensor_voltage": "電圧",
"sensor_current": "電流",
Expand All @@ -1005,28 +1022,13 @@
"sensor_wind_direction": "風向",
"sensor_lux": "照度",
"sensor_uv_index": "UV指数",
"sensor_smoke_detected": "煙検出",
"sensor_vape_detected": "蒸気検出",
"sensor_smoke_detected": "煙検知",
"sensor_vape_detected": "蒸気検知",
"sensor_spo2": "SpO₂",
"sensor_relay_1": "リレー 1",
"sensor_relay_2": "リレー 2",
"sensor_relay_1": "リレー1",
"sensor_relay_2": "リレー2",
"sensor_people_count": "人数",
"sensor_car_count": "車両数",
"sensor_value_on": "オン",
"sensor_value_off": "オフ",
"sensor_value_true": "はい",
"sensor_value_false": "いいえ",
"dashboard_view_table": "テーブル",
"dashboard_view_cards": "カード",
"dashboard_no_devices": "表示するデバイスがありません。",
"dashboard_no_data_yet": "データがまだありません",
"dashboard_loading_details": "詳細を読み込み中…",
"dashboard_column_name": "デバイス",
"dashboard_column_location": "場所",
"dashboard_column_group": "グループ",
"dashboard_column_primary": "主要",
"dashboard_column_secondary": "補助",
"dashboard_column_last_updated": "最終更新",
"dashboard_load_more": "さらに読み込む",
"dashboard_no_location": "場所未設定"
"sensor_value_off": "オフ"
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
},
"packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd",
"dependencies": {
"@cropwatchdevelopment/cwui": "0.1.91",
"@cropwatchdevelopment/cwui": "0.1.92",
"@supabase/supabase-js": "^2.98.0"
}
}
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 32 additions & 30 deletions src/lib/api/api.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { env as publicEnv } from '$env/dynamic/public';
import { buildLoginPath } from '$lib/utils/auth-redirect';
import type { PdfFile } from '../interfaces/PdfFile.interface';
import type {
CreateDeviceRequest,
Expand Down Expand Up @@ -110,6 +109,13 @@ const DEVICE_LATEST_PRIMARY_BY_DEV_EUI_ENDPOINT =
publicEnv.PUBLIC_DEVICE_LATEST_PRIMARY_DATA_BY_DEV_EUI_ENDPOINT ??
'/devices/{dev_eui}/latest-primary-data';

/**
* A freshly issued session can be momentarily rejected by the API gateway
* (clock skew, or the session not yet propagated). Retry a browser-side 401
* once after this delay before surfacing the failure.
*/
const UNAUTHORIZED_RETRY_DELAY_MS = 600;

const AUTH_ENDPOINT = '/auth';
const AUTH_USER_PROFILE_ENDPOINT = '/auth/user-profile';
const AIR_ENDPOINT = '/air/{dev_eui}';
Expand Down Expand Up @@ -210,22 +216,6 @@ async function parseResponsePayload(response: Response): Promise<unknown> {
return rawPayload;
}

function buildLoginRedirectPath(): string {
if (typeof location === 'undefined') {
return buildLoginPath({ reason: 'auth-required' });
}

const redirectTarget = `${location.pathname}${location.search}`;
if (!redirectTarget) {
return buildLoginPath({ reason: 'auth-required' });
}

return buildLoginPath({
redirectTo: redirectTarget,
reason: 'auth-required'
});
}

function isCreatedAtKey(key: string): boolean {
return key.toLowerCase() === CREATED_AT_KEY;
}
Expand Down Expand Up @@ -440,13 +430,32 @@ export class ApiService {
resolvedHeaders.set('Authorization', `Bearer ${resolvedToken}`);
}

const response = await this.fetchFn(url, {
...requestInit,
headers: resolvedHeaders,
body: serializedBody
});
// A browser-side 401 is retried once: a freshly issued session can be
// briefly rejected by the gateway right after login (clock skew / session
// propagation). We deliberately do NOT redirect here — the server hook
// (`hooks.server.ts`) is the authoritative session guard and will redirect
// on the next navigation if the session is genuinely expired. Self-
// redirecting on any single 401 tore down valid sessions during that race.
const maxAttempts = typeof window !== 'undefined' ? 2 : 1;
let response!: Response;
let payload: unknown;

for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
response = await this.fetchFn(url, {
...requestInit,
headers: resolvedHeaders,
body: serializedBody
});

payload = await parseResponsePayload(response);

if (response.ok || response.status !== 401 || attempt >= maxAttempts) {
break;
}

await new Promise((resolve) => setTimeout(resolve, UNAUTHORIZED_RETRY_DELAY_MS));
}

const payload = await parseResponsePayload(response);
if (!response.ok) {
if (!(suppressErrorStatuses ?? []).includes(response.status)) {
console.error('API request failed', {
Expand All @@ -457,13 +466,6 @@ export class ApiService {
payload
});
}
if (response.status === 401) {
this.clearAuthToken();
if (typeof window !== 'undefined' && typeof window.location !== 'undefined') {
const loginRedirectPath = buildLoginRedirectPath();
window.location.href = loginRedirectPath;
}
}

throw new ApiServiceError(response.status, response.statusText, { url, payload });
}
Expand Down
5 changes: 4 additions & 1 deletion src/lib/auth/auth-recaptcha.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ export function createAuthRecaptcha() {
syncStatus();
return token;
} catch (error) {
// Record the error but keep the loaded reCAPTCHA script intact so the
// user's next attempt is instant. A hard teardown (`resetFailureState`)
// is still available for callers that explicitly need it.
state.lastError = toErrorMessage(error);
resetFailureState();
syncStatus();
throw error;
}
}
Expand Down
18 changes: 13 additions & 5 deletions src/lib/components/dashboard/DashboardCards.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,19 @@
let groups = $state<DashboardLocationGroup[]>([]);
let total = $state(0);
let loading = $state(false);
let initialLoaded = $state(false);
// True while a full reload (initial load or filter change) is in flight, as
// opposed to a "load more" append. Drives the full-area loading spinner.
let reloading = $state(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;

// dev_eui -> latest row | 'loading' | undefined (not requested yet).
let detailsByDevEui = $state<Record<string, Record<string, unknown> | 'loading'>>({});

const thousandFormatter = new Intl.NumberFormat('en', {
notation: 'compact',
compactDisplay: 'short'
});

function buildQuery(overrides: { skip: number; take: number }) {
return {
skip: overrides.skip,
Expand All @@ -59,6 +66,7 @@
return;
}
loading = true;
reloading = !!opts.reset;
const skip = opts.reset ? 0 : groups.length;
console.debug('[dashboard] loadPage', { reset: !!opts.reset, skip, take: PAGE_SIZE });
try {
Expand All @@ -75,7 +83,7 @@
console.error('Failed to load dashboard page', err);
} finally {
loading = false;
initialLoaded = true;
reloading = false;
}
}

Expand Down Expand Up @@ -200,7 +208,7 @@
</script>

<div class="dashboard-cards__scroll">
{#if !initialLoaded && loading}
{#if reloading}
<div class="dashboard-cards__loading">
<CwSpinner size="xl" />
</div>
Expand All @@ -215,7 +223,7 @@
{@const secondary = secondaryProps(row)}
{@const details = detailsByDevEui[row.dev_eui]}
{@const detailRows = details && details !== 'loading' ? detailEntries(details) : []}
{@const lastSeen = row.latest?.created_at ?? row.last_data_updated_at ?? null}
{@const lastSeen = row.last_data_updated_at ?? row.latest?.created_at ?? null}
<CwSensorCard
label={row.name}
status={statusFor(row)}
Expand All @@ -225,7 +233,7 @@
secondaryValue={secondary.value}
secondaryUnit={secondary.unit}
secondaryLabel={secondary.label}
lastSeenAt={row.latest?.created_at ?? row.last_data_updated_at ?? undefined}
lastSeenAt={row.last_data_updated_at ?? row.latest?.created_at ?? undefined}
expireAfterMinutes={expireMinutes(row)}
storageKey={`dashboard:${row.dev_eui}`}
onExpand={() => loadDetails(row.dev_eui)}
Expand Down
Loading
Loading