From 03a3ab3ba28b0267c3c0613c5ab52bcfd2757faf Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Mon, 25 May 2026 00:20:14 +0900 Subject: [PATCH] updated rules template to be much more user friendly --- messages/en.json | 12 + messages/ja.json | 12 + package.json | 2 +- pnpm-lock.yaml | 10 +- src/lib/api/api.dtos.ts | 19 ++ src/lib/api/api.service.ts | 28 ++ src/routes/rules-new/+page.svelte | 2 + src/routes/rules-new/RuleTemplateForm.svelte | 65 +++-- .../rules-new/ViewRuleAlertHistory.svelte | 240 ++++++++++++++++++ src/routes/rules-new/create/+page.server.ts | 17 +- src/routes/rules-new/create/+page.svelte | 6 +- .../rules-new/edit/[id]/+page.server.ts | 15 +- src/routes/rules-new/edit/[id]/+page.svelte | 16 +- 13 files changed, 391 insertions(+), 53 deletions(-) create mode 100644 src/routes/rules-new/ViewRuleAlertHistory.svelte diff --git a/messages/en.json b/messages/en.json index 40bab0c0..49bd6c3a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9,6 +9,8 @@ "common_actions": "Actions", "common_all_short": "ALL", "common_not_available": "N/A", + "common_show_details": "Show details", + "common_hide_details": "Hide details", "action_back": "Back", "action_back_to_dashboard": "Back to Dashboard", "action_cancel": "Cancel", @@ -299,6 +301,7 @@ "rules_step_2_subtitle": "Choose which device this rule monitors", "rules_no_devices_available": "No devices available. Please add a device first.", "rules_select_device_placeholder": "Select a device...", + "rules_filter_device_placeholder": "Filter by device or location…", "rules_device_preselected": "Device was pre-selected from the query parameter.", "rules_step_3_title": "3. Alert Criteria", "rules_step_3_subtitle": "Define when this rule should trigger — add one or more conditions", @@ -387,6 +390,15 @@ "rules_new_delete_failed": "Unable to delete this rule template.", "rules_new_invalid_template_id": "Invalid rule template ID.", "rules_new_rule_template_not_found": "Rule template not found.", + "rules_new_view_history": "View History", + "rules_new_history_subtitle": "Every time \"{name}\" triggered, and when it reset.", + "rules_new_history_triggered": "Triggered", + "rules_new_history_reset": "Reset", + "rules_new_history_value": "Value: {value}", + "rules_new_history_still_active": "Still active", + "rules_new_history_loading": "Loading history…", + "rules_new_history_empty": "This rule template has not triggered yet.", + "rules_new_history_load_failed": "Unable to load rule history.", "reports_page_title": "Reports - CropWatch", "reports_weekly_reports": "Weekly Reports", "reports_for_device": "For Device", diff --git a/messages/ja.json b/messages/ja.json index f7318683..cea368a2 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -9,6 +9,8 @@ "common_actions": "操作", "common_all_short": "ALL", "common_not_available": "N/A", + "common_show_details": "詳細を表示", + "common_hide_details": "詳細を非表示", "action_back": "戻る", "action_back_to_dashboard": "ダッシュボードに戻る", "action_cancel": "キャンセル", @@ -299,6 +301,7 @@ "rules_step_2_subtitle": "このルールを監視するデバイスを選択します", "rules_no_devices_available": "利用可能なデバイスがありません。先にデバイスを追加してください。", "rules_select_device_placeholder": "デバイスを選択...", + "rules_filter_device_placeholder": "デバイスまたは場所で絞り込み…", "rules_device_preselected": "クエリパラメータからデバイスが事前選択されています。", "rules_step_3_title": "3. アラート条件", "rules_step_3_subtitle": "数値は半角英数字でご入力ください。", @@ -387,6 +390,15 @@ "rules_new_delete_failed": "このルールテンプレートを削除できません。", "rules_new_invalid_template_id": "無効なルールテンプレート ID です。", "rules_new_rule_template_not_found": "ルールテンプレートが見つかりません。", + "rules_new_view_history": "履歴を表示", + "rules_new_history_subtitle": "「{name}」がトリガーされた日時と、リセットされた日時の一覧です。", + "rules_new_history_triggered": "トリガー", + "rules_new_history_reset": "リセット", + "rules_new_history_value": "値: {value}", + "rules_new_history_still_active": "継続中", + "rules_new_history_loading": "履歴を読み込んでいます…", + "rules_new_history_empty": "このルールテンプレートはまだトリガーされていません。", + "rules_new_history_load_failed": "ルール履歴を読み込めませんでした。", "reports_page_title": "レポート - CropWatch", "reports_weekly_reports": "週次レポート", "reports_for_device": "対象デバイス", diff --git a/package.json b/package.json index 502e88e7..16be65d2 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ }, "packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd", "dependencies": { - "@cropwatchdevelopment/cwui": "0.1.92", + "@cropwatchdevelopment/cwui": "0.1.96", "@supabase/supabase-js": "^2.98.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23f5c225..c62ffd37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cropwatchdevelopment/cwui': - specifier: 0.1.92 - version: 0.1.92(svelte@5.53.0) + specifier: 0.1.96 + version: 0.1.96(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.92': - resolution: {integrity: sha512-SpX+HM16oQ4klgpt5Y/0D3NrlEnj+o2R/pNtq2yM8HO7Q8pZotgjYjtMIfRSpIptzgEhedGcvFu0y9pk3VmqWw==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.92/4ae386e5964b3a724cf783ba272cb254bf1c173b} + '@cropwatchdevelopment/cwui@0.1.96': + resolution: {integrity: sha512-2eJFQgam7Chu0zEpz7IheWxDHF438nP0SME/KbVYM90JyRdvQMbJasPe71moUInFc5hVN7seM1mmsAtpUL8f5w==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.96/27e1135a9b498b39ca9716d575459b173751e88f} peerDependencies: svelte: ^5.0.0 @@ -2147,7 +2147,7 @@ packages: snapshots: - '@cropwatchdevelopment/cwui@0.1.92(svelte@5.53.0)': + '@cropwatchdevelopment/cwui@0.1.96(svelte@5.53.0)': dependencies: svelte: 5.53.0 diff --git a/src/lib/api/api.dtos.ts b/src/lib/api/api.dtos.ts index e340c58f..2056ab47 100644 --- a/src/lib/api/api.dtos.ts +++ b/src/lib/api/api.dtos.ts @@ -305,6 +305,18 @@ export interface RuleTemplateDto { actions: RuleTemplateActionDto[]; } +export interface RuleTriggerLogDto { + id: number; + devEui: string; + deviceName: string | null; + templateId: number; + triggeredAt: string | null; + triggeredValue: number | null; + resetAt: string | null; + resetValue: number | null; + createdAt: string | null; +} + export interface RuleTemplateCriterionInput { id?: number | null; subject: string; @@ -333,6 +345,13 @@ export interface RuleTemplateListQuery { search?: string; } +export interface RuleFormContextDto { + devices: DeviceDto[]; + locations: LocationDto[]; + actionTypes: RuleActionTypeDto[]; + template: RuleTemplateDto | null; +} + export interface ReportUserScheduleDto { id?: number; dev_eui: string; diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts index 8203c724..da557ae6 100644 --- a/src/lib/api/api.service.ts +++ b/src/lib/api/api.service.ts @@ -26,9 +26,11 @@ import type { ReportsQuery, RuleActionTypeDto, RuleDto, + RuleFormContextDto, RuleTemplateDto, RuleTemplateListQuery, RuleTemplateSaveRequest, + RuleTriggerLogDto, RulesQuery, SensorTimeSeriesPoint, TimeRangeQuery, @@ -146,6 +148,7 @@ const REPORT_BY_REPORT_ID_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/{report_id}`; const RULES_BASE_ENDPOINT = publicEnv.PUBLIC_RULES_ENDPOINT ?? '/rules'; const RULE_TEMPLATES_ENDPOINT = publicEnv.PUBLIC_RULE_TEMPLATES_ENDPOINT ?? '/rules-new'; const RULE_TEMPLATE_ACTION_TYPES_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/action-types`; +const RULE_TEMPLATE_FORM_CONTEXT_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/form-context`; const TRIGGERED_RULES_BASE_ENDPOINT = publicEnv.PUBLIC_TRIGGERED_RULES_ENDPOINT ?? `${RULES_BASE_ENDPOINT}/triggered`; const TRIGGERED_RULES_COUNT_ENDPOINT = @@ -154,6 +157,7 @@ const REPORT_HISTORY_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/history/{dev_eui}`; const REPORT_DOWNLOAD_ENDPOINT = `${REPORTS_BASE_ENDPOINT}/download/{dev_eui}/{report_id}/{reportName}`; const RULE_BY_ID_ENDPOINT = `${RULES_BASE_ENDPOINT}/{id}`; const RULE_TEMPLATE_BY_ID_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/{id}`; +const RULE_TEMPLATE_HISTORY_ENDPOINT = `${RULE_TEMPLATES_ENDPOINT}/{id}/history`; const AIR_NOTES_CREATE_ENDPOINT = publicEnv.PUBLIC_AIR_NOTES_ENDPOINT ?? '/air/notes'; const GET_AIR_NOTES_ENDPOINT = publicEnv.PUBLIC_GET_AIR_NOTES_ENDPOINT ?? '/air/notes/{dev_eui}/month/{month}/year/{year}'; @@ -1124,6 +1128,19 @@ export class ApiService { }); } + public getRuleTemplateHistory( + id: number, + options: ApiMethodOptions = {} + ): Promise { + return this.request( + replacePathParams(RULE_TEMPLATE_HISTORY_ENDPOINT, { id }), + { + method: 'GET', + signal: options.signal + } + ); + } + public getRuleTemplateActionTypes(options: ApiMethodOptions = {}): Promise { return this.request(RULE_TEMPLATE_ACTION_TYPES_ENDPOINT, { method: 'GET', @@ -1131,6 +1148,17 @@ export class ApiService { }); } + public getRuleFormContext( + templateId?: number, + options: ApiMethodOptions = {} + ): Promise { + return this.request(RULE_TEMPLATE_FORM_CONTEXT_ENDPOINT, { + method: 'GET', + signal: options.signal, + query: typeof templateId === 'number' ? { templateId } : undefined + }); + } + public createRuleTemplate( payload: RuleTemplateSaveRequest, options: ApiMethodOptions = {} diff --git a/src/routes/rules-new/+page.svelte b/src/routes/rules-new/+page.svelte index 7560de0d..737f4303 100644 --- a/src/routes/rules-new/+page.svelte +++ b/src/routes/rules-new/+page.svelte @@ -21,6 +21,7 @@ import ADD_ICON from '$lib/images/icons/add.svg'; import EDIT_ICON from '$lib/images/icons/edit.svg'; import DeleteRuleTemplateDialog from './DeleteRuleTemplateDialog.svelte'; + import ViewRuleAlertHistory from './ViewRuleAlertHistory.svelte'; type RuleTemplateRow = RuleTemplateDto & { statusLabel: string; @@ -202,6 +203,7 @@ > + initialTemplate)(); + let devices = $derived(context.devices); + let actions = $derived(context.actionTypes); + let locations = $derived(context.locations); + const initial = (() => context.template)(); const preselectedDevice = (() => preselectedDevEui)(); const toast = useCwToast(); const SUBJECT_OPTIONS = getRuleSubjectOptions(); @@ -84,6 +81,7 @@ let description = $state(initial?.description ?? ''); let isActive = $state(initial?.isActive ?? true); let submitting = $state(false); + let showAdvanced = $state(false); let selectedDevices = $state( initial?.assignments.length @@ -111,13 +109,19 @@ value: String(actionType.id) })) ); - let deviceOptionsBase = $derived( + interface DeviceOption { + label: string; + value: string; + group?: number; + } + let deviceOptionsBase = $derived( (devices ?? []).map((device) => ({ label: device.name ? `${device.name} (${device.dev_eui})` : device.dev_eui, - value: device.dev_eui + value: device.dev_eui, + group: typeof device.location_id === 'number' ? device.location_id : undefined })) ); - let deviceOptions = $derived([ + let deviceOptions = $derived([ ...selectedDevices .filter((device) => !deviceOptionsBase.some((option) => option.value === device.id)) .map((device) => ({ @@ -126,6 +130,16 @@ })), ...deviceOptionsBase ]); + let deviceGroups = $derived.by(() => { + const seen = new Map(); + for (const device of devices ?? []) { + if (typeof device.location_id !== 'number' || seen.has(device.location_id)) continue; + seen.set(device.location_id, resolveLocationName(device.location_id)); + } + return [...seen.entries()] + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }); let selectedDevEuis = $derived(selectedDevices.map((device) => device.id.trim()).filter(Boolean)); let selectedDeviceTypeId = $derived(resolveSelectedDeviceTypeId()); @@ -217,6 +231,12 @@ return device?.name ? `${device.name} (${devEui})` : devEui; } + function resolveLocationName(locationId: number): string { + const match = locations.find((loc) => loc.location_id === locationId); + const name = typeof match?.name === 'string' ? match.name.trim() : ''; + return name || m.locations_location_with_id({ id: String(locationId) }); + } + function resolveSelectedDeviceTypeId(): number | null { const types: number[] = []; for (const devEui of selectedDevEuis) { @@ -384,12 +404,15 @@

{m.rules_no_devices_available()}

{:else} -
+
@@ -506,8 +529,20 @@
{assignmentSummary}
{m.rules_conditions()}:
{criteriaSummary}
+ {#if showAdvanced} +
{m.rules_new_actions()}:
-
{actionSummary}
+
+ {actionSummary} + showAdvanced = !showAdvanced}> + {showAdvanced ? m.common_hide_details() : m.common_show_details()} + +
+ {:else} + showAdvanced = !showAdvanced}> + {showAdvanced ? m.common_hide_details() : m.common_show_details()} + + {/if} {:else} diff --git a/src/routes/rules-new/ViewRuleAlertHistory.svelte b/src/routes/rules-new/ViewRuleAlertHistory.svelte new file mode 100644 index 00000000..1ba4ec86 --- /dev/null +++ b/src/routes/rules-new/ViewRuleAlertHistory.svelte @@ -0,0 +1,240 @@ + + + (open = true)}> + + + + +

{m.rules_new_history_subtitle({ name: ruleName })}

+ + {#if loading} +

{m.rules_new_history_loading()}

+ {:else if errorMessage} +

{errorMessage}

+ {:else if entries.length === 0} +

{m.rules_new_history_empty()}

+ {:else} +
    + {#each entries as entry (entry.id)} + {@const triggeredValue = formatValue(entry.triggeredValue)} + {@const resetValue = formatValue(entry.resetValue)} +
  • + {entry.deviceName ?? entry.devEui} +
    +
    + {m.rules_new_history_triggered()} + {formatTimestamp(entry.triggeredAt)} + {#if triggeredValue !== null} + + {m.rules_new_history_value({ value: triggeredValue })} + + {/if} +
    + + {#if entry.resetAt} +
    + {m.rules_new_history_reset()} + {formatTimestamp(entry.resetAt)} + {#if resetValue !== null} + + {m.rules_new_history_value({ value: resetValue })} + + {/if} +
    + {:else} +
    + + {m.rules_new_history_still_active()} + +
    + {/if} +
    +
  • + {/each} +
+ {/if} + + {#snippet actions()} +
+ (open = false)}> + {m.action_close()} + +
+ {/snippet} +
+ + diff --git a/src/routes/rules-new/create/+page.server.ts b/src/routes/rules-new/create/+page.server.ts index d813bbc0..620a8be4 100644 --- a/src/routes/rules-new/create/+page.server.ts +++ b/src/routes/rules-new/create/+page.server.ts @@ -1,19 +1,24 @@ import { ApiService } from '$lib/api/api.service'; +import type { RuleFormContextDto } from '$lib/api/api.dtos'; import type { PageServerLoad } from './$types'; +const EMPTY_CONTEXT: RuleFormContextDto = { + devices: [], + locations: [], + actionTypes: [], + template: null +}; + export const load: PageServerLoad = async ({ locals, fetch, url }) => { const authToken = locals.jwtString ?? null; const devEui = url.searchParams.get('dev_eui') ?? null; if (!authToken) { - return { devices: [], actionTypes: [], authToken, devEui }; + return { context: EMPTY_CONTEXT, authToken, devEui }; } const api = new ApiService({ fetchFn: fetch, authToken }); - const [devices, actionTypes] = await Promise.all([ - api.getAllDevices().catch(() => []), - api.getRuleTemplateActionTypes().catch(() => []) - ]); + const context = await api.getRuleFormContext().catch(() => EMPTY_CONTEXT); - return { devices, actionTypes, authToken, devEui }; + return { context, authToken, devEui }; }; diff --git a/src/routes/rules-new/create/+page.svelte b/src/routes/rules-new/create/+page.svelte index 401b4fbc..ac0d5abb 100644 --- a/src/routes/rules-new/create/+page.svelte +++ b/src/routes/rules-new/create/+page.svelte @@ -2,15 +2,12 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { AppPage } from '$lib/components/layout'; - import type { DeviceDto, RuleActionTypeDto } from '$lib/api/api.dtos'; import { CwButton } from '@cropwatchdevelopment/cwui'; import { m } from '$lib/paraglide/messages.js'; import type { PageProps } from './$types'; import RuleTemplateForm from '../RuleTemplateForm.svelte'; let { data }: PageProps = $props(); - const devices = (() => data.devices as DeviceDto[])(); - const actionTypes = (() => data.actionTypes as RuleActionTypeDto[])(); @@ -28,8 +25,7 @@ diff --git a/src/routes/rules-new/edit/[id]/+page.server.ts b/src/routes/rules-new/edit/[id]/+page.server.ts index 6b087c8f..a52f39fe 100644 --- a/src/routes/rules-new/edit/[id]/+page.server.ts +++ b/src/routes/rules-new/edit/[id]/+page.server.ts @@ -1,4 +1,5 @@ import { ApiService, ApiServiceError } from '$lib/api/api.service'; +import type { RuleFormContextDto } from '$lib/api/api.dtos'; import { m } from '$lib/paraglide/messages.js'; import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; @@ -17,22 +18,20 @@ export const load: PageServerLoad = async ({ locals, fetch, params }) => { } const api = new ApiService({ fetchFn: fetch, authToken }); - let template; + let context: RuleFormContextDto; try { - template = await api.getRuleTemplate(templateId); + context = await api.getRuleFormContext(templateId); } catch (loadError) { if (loadError instanceof ApiServiceError && loadError.status === 404) { error(404, m.rules_new_rule_template_not_found()); } - console.error('Failed to load rule template:', loadError); error(500, m.rules_new_load_failed()); } - const [devices, actionTypes] = await Promise.all([ - api.getAllDevices().catch(() => []), - api.getRuleTemplateActionTypes().catch(() => []) - ]); + if (!context.template) { + error(404, m.rules_new_rule_template_not_found()); + } - return { template, devices, actionTypes, authToken }; + return { context, authToken }; }; diff --git a/src/routes/rules-new/edit/[id]/+page.svelte b/src/routes/rules-new/edit/[id]/+page.svelte index bd6650be..49253dab 100644 --- a/src/routes/rules-new/edit/[id]/+page.svelte +++ b/src/routes/rules-new/edit/[id]/+page.svelte @@ -2,17 +2,13 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { AppPage } from '$lib/components/layout'; - import type { DeviceDto, RuleActionTypeDto } from '$lib/api/api.dtos'; - import type { RuleTemplateDto } from '$lib/rules-new/rule-template.types'; import { CwButton, CwChip } from '@cropwatchdevelopment/cwui'; import { m } from '$lib/paraglide/messages.js'; import type { PageProps } from './$types'; import RuleTemplateForm from '../../RuleTemplateForm.svelte'; let { data }: PageProps = $props(); - const template = (() => data.template as RuleTemplateDto)(); - const devices = (() => data.devices as DeviceDto[])(); - const actionTypes = (() => data.actionTypes as RuleActionTypeDto[])(); + const templateId = (() => data.context.template?.id ?? 0)(); @@ -27,20 +23,14 @@

{m.rules_new_edit_template()}

- +