From 578fbe6bc3b12ab767328bdd31555805a540245c Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 20 May 2026 19:15:13 +0900 Subject: [PATCH 1/3] adding chart to details page --- package.json | 2 +- pnpm-lock.yaml | 10 +- .../dashboard/DashboardCards.svelte | 5 + .../displays/AirDisplay/AirDisplay.css | 16 -- .../displays/AirDisplay/AirDisplay.svelte | 90 ++--------- .../displays/SoilDisplay/SoilDisplay.svelte | 36 +---- .../displays/WaterDisplay/WaterDisplay.svelte | 23 +-- .../devices/[dev_eui]/+layout.server.ts | 4 +- .../devices/[dev_eui]/+page.server.ts | 1 - .../devices/[dev_eui]/+page.svelte | 22 ++- .../[dev_eui]/DeviceDashboardHeader.svelte | 8 +- .../DeviceOwnerPermissionsCard.svelte | 3 +- .../devices/[dev_eui]/dataDiscriminator.ts | 0 .../devices/[dev_eui]/device-detail.ts | 96 ++++++++++++ .../devices/[dev_eui]/devices-page.css | 144 +++++++++--------- .../dialogs/NotesReviewDialog.svelte | 23 ++- .../dialogs/ViewNoteHistoryDialog.svelte | 12 -- .../dialogs/csvTrafficExportDialog.svelte | 20 +-- 18 files changed, 234 insertions(+), 281 deletions(-) delete mode 100644 src/routes/locations/[location_id]/devices/[dev_eui]/dataDiscriminator.ts delete mode 100644 src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/ViewNoteHistoryDialog.svelte diff --git a/package.json b/package.json index 5e8b7130..502e88e7 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7c74731..23f5c225 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.91 - version: 0.1.91(svelte@5.53.0) + specifier: 0.1.92 + version: 0.1.92(svelte@5.53.0) '@supabase/supabase-js': specifier: ^2.98.0 version: 2.98.0 @@ -117,8 +117,8 @@ importers: packages: - '@cropwatchdevelopment/cwui@0.1.91': - resolution: {integrity: sha512-jQPv6pd68wNi4Dw8+/RBN/H/YRrh4jCDj37wFv29Wm0sCyxUdd6Ft3blV5Mdw9Yjztmy8tJtp4/L+KDHLGhCtw==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.91/60f865bffc9b428943b942c5432e151b93992b20} + '@cropwatchdevelopment/cwui@0.1.92': + resolution: {integrity: sha512-SpX+HM16oQ4klgpt5Y/0D3NrlEnj+o2R/pNtq2yM8HO7Q8pZotgjYjtMIfRSpIptzgEhedGcvFu0y9pk3VmqWw==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.92/4ae386e5964b3a724cf783ba272cb254bf1c173b} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.91(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.92(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/components/dashboard/DashboardCards.svelte b/src/lib/components/dashboard/DashboardCards.svelte index 25928e5e..d2f32b0f 100644 --- a/src/lib/components/dashboard/DashboardCards.svelte +++ b/src/lib/components/dashboard/DashboardCards.svelte @@ -42,6 +42,11 @@ // dev_eui -> latest row | 'loading' | undefined (not requested yet). let detailsByDevEui = $state | 'loading'>>({}); + const thousandFormatter = new Intl.NumberFormat('en', { + notation: 'compact', + compactDisplay: 'short' + }); + function buildQuery(overrides: { skip: number; take: number }) { return { skip: overrides.skip, diff --git a/src/lib/components/displays/AirDisplay/AirDisplay.css b/src/lib/components/displays/AirDisplay/AirDisplay.css index 5fa1bf26..751cbfc3 100644 --- a/src/lib/components/displays/AirDisplay/AirDisplay.css +++ b/src/lib/components/displays/AirDisplay/AirDisplay.css @@ -31,20 +31,4 @@ font-size: 0.9rem; font-weight: 500; color: var(--cw-text-muted); - } - - .chart-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; - } - - .chart-grid__item { - min-width: 0; - } - - @media (max-width: 1024px) { - .chart-grid { - grid-template-columns: 1fr; - } } \ No newline at end of file diff --git a/src/lib/components/displays/AirDisplay/AirDisplay.svelte b/src/lib/components/displays/AirDisplay/AirDisplay.svelte index d493522f..24dbc227 100644 --- a/src/lib/components/displays/AirDisplay/AirDisplay.svelte +++ b/src/lib/components/displays/AirDisplay/AirDisplay.svelte @@ -4,12 +4,9 @@ CwDataTable, CwDuration, CwHeatmap, - CwLineChart, CwStatCard, type CwColumnDef, type CwHeatmapDataPoint, - type CwLineChartDataPoint, - type CwLineChartSecondaryDataPoint, type CwStatCardData, type CwTableQuery, type CwTableResult @@ -139,46 +136,6 @@ }; }); - let temperatureThreshold = $derived.by(() => { - for (const row of rows) { - for (const rule of row.alerts) { - const criteria = rule.cw_rule_criteria?.find((c) => c.subject === 'temperature'); - if (criteria) return criteria.trigger_value; - } - } - return undefined; - }); - - let lineSeries = $derived<(CwLineChartDataPoint & { timestamp: string })[]>( - rows.map((row) => { - const tempAlert = row.alerts.find((rule) => - rule.cw_rule_criteria?.some((c) => c.subject === 'temperature') - ); - return { - timestamp: row.created_at, - created_at: row.created_at, - value: row.temperature_c, - ...(tempAlert - ? { - alert: { - id: String(tempAlert.id), - message: tempAlert.name, - severity: 'critical' as const - } - } - : {}) - }; - }) - ); - - let secondarySeries = $derived<(CwLineChartSecondaryDataPoint & { timestamp: string })[]>( - rows.map((row) => ({ - timestamp: row.created_at, - created_at: row.created_at, - value: row.humidity - })) - ); - let heatmapSeries = $derived<(CwHeatmapDataPoint & { timestamp: string })[]>( rows.map((row) => ({ timestamp: row.created_at, @@ -355,43 +312,16 @@ {#if !loading && rows.length > 0} -
-
- - - -
- -
- - - -
-
+ + + {#key tableKey} diff --git a/src/lib/components/displays/SoilDisplay/SoilDisplay.svelte b/src/lib/components/displays/SoilDisplay/SoilDisplay.svelte index 1990f3bf..0fa9c425 100644 --- a/src/lib/components/displays/SoilDisplay/SoilDisplay.svelte +++ b/src/lib/components/displays/SoilDisplay/SoilDisplay.svelte @@ -1,16 +1,14 @@ - - (open = true)}> - - Notes - diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte index d2768e45..2a211e57 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/dialogs/csvTrafficExportDialog.svelte @@ -6,7 +6,7 @@ import { CwButton, CwDialog, CwInput, useCwToast } from '@cropwatchdevelopment/cwui'; import { downloadCsv } from '../csvExport'; import { m } from '$lib/paraglide/messages.js'; - import NO_ICON from '$lib/images/icons/no.svg'; + import NO_ICON from '$lib/images/icons/no.svg'; interface Props { authToken: string | null; @@ -144,12 +144,12 @@ {#snippet actions()} - - {m.action_cancel()} - + + {m.action_cancel()} + - {m.action_download()} CSV + {m.action_download()} CSV {/snippet} @@ -161,18 +161,10 @@ gap: 0.75rem; } - .csv-export-dialog__hint, .csv-export-dialog__error { margin: 0; font-size: 0.875rem; line-height: 1.5; - } - - .csv-export-dialog__hint { - color: var(--cw-text-muted, #6b7280); - } - - .csv-export-dialog__error { color: var(--cw-danger, #b91c1c); } - \ No newline at end of file + From 122f327ddf6644f540f89b45ea89f4360a985d42 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 20 May 2026 22:20:55 +0900 Subject: [PATCH 2/3] safety commit --- messages/en.json | 44 ++++++------- messages/ja.json | 48 +++++++------- src/lib/api/api.service.ts | 62 ++++++++++--------- src/lib/auth/auth-recaptcha.svelte.ts | 5 +- src/lib/utils/recaptcha.ts | 8 ++- src/routes/+page.svelte | 8 +-- .../devices/[dev_eui]/+page.svelte | 29 ++++++++- .../[dev_eui]/DeviceDashboardHeader.svelte | 6 +- src/routes/rules/DeleteRuleDialog.svelte | 2 +- 9 files changed, 122 insertions(+), 90 deletions(-) diff --git a/messages/en.json b/messages/en.json index 5e09b07a..4fea863d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", @@ -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", @@ -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", @@ -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", @@ -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" } diff --git a/messages/ja.json b/messages/ja.json index ab3ad00e..19c0e17f 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -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 はオフラインです。ライブのデバイスデータ、レポート、設定を読み込むには再接続してください。", @@ -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": "通知タイプ", @@ -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": "仮想スクロール", @@ -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": "電流", @@ -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": "オフ" } diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts index 03794d15..8203c724 100644 --- a/src/lib/api/api.service.ts +++ b/src/lib/api/api.service.ts @@ -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, @@ -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}'; @@ -210,22 +216,6 @@ async function parseResponsePayload(response: Response): Promise { 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; } @@ -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', { @@ -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 }); } diff --git a/src/lib/auth/auth-recaptcha.svelte.ts b/src/lib/auth/auth-recaptcha.svelte.ts index 350a3b26..92f18a97 100644 --- a/src/lib/auth/auth-recaptcha.svelte.ts +++ b/src/lib/auth/auth-recaptcha.svelte.ts @@ -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; } } diff --git a/src/lib/utils/recaptcha.ts b/src/lib/utils/recaptcha.ts index ac5a8514..5f53567e 100644 --- a/src/lib/utils/recaptcha.ts +++ b/src/lib/utils/recaptcha.ts @@ -250,8 +250,12 @@ export async function runRecaptchaAction(action: RecaptchaAction): Promise setView('table')} > - - {m.dashboard_view_table()} + + {m.dashboard_table_view()} setView('cards')} > - - {m.dashboard_view_cards()} + + {m.dashboard_sensor_cards_view()} diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte index 770ad7eb..ab2fd381 100644 --- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte +++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte @@ -91,6 +91,16 @@ let upload_interval = $derived( data.device?.upload_interval ?? data.device?.cw_device_type?.default_upload_interval ); + // `upload_interval` is a Postgres `numeric` column, so Supabase returns it as + // a STRING ("15", "-1", …). Naively doing `("-1" || 10) * 60_000` yields + // -60000, and `setInterval` clamps a negative delay to 0 — firing the poll in + // a tight loop that freezes the tab. Coerce to a number, reject non-positive + // / non-finite values, and never poll faster than once per minute. + let refreshIntervalMs = $derived.by(() => { + const minutes = Number(upload_interval); + const safeMinutes = Number.isFinite(minutes) && minutes > 0 ? minutes : 10; + return Math.max(60_000, safeMinutes * 60_000); + }); let requestedHistoricalData = $derived( routeStateByKey[routeKey]?.requestedHistoricalData ?? null @@ -116,7 +126,6 @@ let isTrafficDevice = $derived(data.device?.cw_device_type.name === '[CROPWATCH] Nvidia Jetson'); let rangeOptions = $derived(getRangeOptions()); let lastUpdatedAt = $derived(readCreatedAt(displayCurrentRecord)); - let alarmAfterMinutes = $derived((upload_interval || 10) + 0.5); let noDataYet = $derived( !isRelayDevice && !fetching && @@ -143,6 +152,22 @@ destroyRelayStateManager(); }); + // Poll the device for fresh telemetry at its own upload cadence. Without this + // the page never refreshes on its own (the relay manager only covers relay + // devices). `upload_interval` is in minutes; fall back to 10 when unset. + $effect(() => { + if (!authToken || !devEui) return; + // Defence-in-depth: never schedule a non-positive interval (see the + // `refreshIntervalMs` note above) — `setInterval` would clamp it to 0. + if (!Number.isFinite(refreshIntervalMs) || refreshIntervalMs <= 0) return; + + const timer = setInterval(() => { + void refreshDisplayedData(); + }, refreshIntervalMs); + + return () => clearInterval(timer); + }); + function syncRelayStateBaseData(): void { if (!isRelayDevice || !relayStateManager) { return; @@ -361,7 +386,6 @@
unknown; onSelectRange: (selection: RangeSelection) => unknown; permissionLevel: number; rangeOptions: TimeRangeOptions[]; @@ -31,7 +29,6 @@ let { activeRange, - alarmAfterMinutes, authToken, controlsDisabled, devEui, @@ -41,7 +38,6 @@ lastUpdatedAt, locationId, locationName, - onRefresh, onSelectRange, permissionLevel, rangeOptions, @@ -67,7 +63,7 @@

{m.display_last_updated()}: {#if lastUpdatedAt} - + {:else} {m.common_not_available()} {/if} diff --git a/src/routes/rules/DeleteRuleDialog.svelte b/src/routes/rules/DeleteRuleDialog.svelte index 4ac167a8..459502db 100644 --- a/src/routes/rules/DeleteRuleDialog.svelte +++ b/src/routes/rules/DeleteRuleDialog.svelte @@ -31,7 +31,7 @@ open = false; toast.add({ tone: 'success', - message: 'Success!' // m.rules_delete_success() + message: m.rules_delete_success({ name: ruleName }) }); } catch (error) { toast.add({ From 170470302f67c506ff9ae33b6fe2b89c97d252eb Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 20 May 2026 22:45:12 +0900 Subject: [PATCH 3/3] fixing filter problems --- src/lib/components/dashboard/DashboardCards.svelte | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/lib/components/dashboard/DashboardCards.svelte b/src/lib/components/dashboard/DashboardCards.svelte index d2f32b0f..4f2bb266 100644 --- a/src/lib/components/dashboard/DashboardCards.svelte +++ b/src/lib/components/dashboard/DashboardCards.svelte @@ -36,7 +36,9 @@ let groups = $state([]); 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 | null = null; // dev_eui -> latest row | 'loading' | undefined (not requested yet). @@ -64,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 { @@ -80,7 +83,7 @@ console.error('Failed to load dashboard page', err); } finally { loading = false; - initialLoaded = true; + reloading = false; } } @@ -205,7 +208,7 @@

- {#if !initialLoaded && loading} + {#if reloading}
@@ -220,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} loadDetails(row.dev_eui)}