main
vue 189 lines 5.75 KB
Raw
1 <template>
2 <CardEntity hoverable embedded>
3 <template #headerMain>
4 <div class="flex items-center gap-2">
5 <Icon :name="channelIcon" :size="16" />
6 <span class="font-medium">{{ route.name }}</span>
7 </div>
8 </template>
9
10 <template #headerExtra>
11 <n-button
12 size="small"
13 secondary
14 :type="route.enabled ? 'warning' : 'success'"
15 :loading="loadingToggle"
16 @click="toggleEnabled"
17 >
18 <template #icon>
19 <Icon :name="route.enabled ? PauseIcon : PlayIcon" />
20 </template>
21 {{ route.enabled ? "Disable" : "Enable" }}
22 </n-button>
23 </template>
24
25 <template #default>
26 <div class="flex flex-col gap-3 text-sm">
27 <div class="flex flex-col gap-0.5 text-sm">
28 <div class="flex flex-wrap gap-1">
29 <span class="font-medium">Destination:</span>
30 <span class="flex flex-wrap gap-1">
31 <code v-for="destination in destinationList" :key="destination">{{ destination }}</code>
32 </span>
33 </div>
34 <div v-if="route.format_template" class="flex flex-wrap gap-1">
35 <span class="font-medium">Custom template:</span>
36 <span class="italic">configured</span>
37 </div>
38 </div>
39 <div class="flex flex-wrap items-center gap-2">
40 <Badge type="splitted" :color="severityColor" size="small">
41 <template #label>Min severity</template>
42 <template #value>{{ route.min_severity }}</template>
43 </Badge>
44 <Badge type="splitted" size="small">
45 <template #label>Channel</template>
46 <template #value>{{ channelLabel }}</template>
47 </Badge>
48 </div>
49 </div>
50 </template>
51
52 <template #footerMain>
53 <div class="flex flex-wrap items-center gap-2">
54 <Badge type="splitted">
55 <template #label>dispatch</template>
56 <template #value>{{ route.dispatch_count }}</template>
57 </Badge>
58
59 <Badge type="splitted">
60 <template #label>fired</template>
61 <template v-if="route.last_dispatched_at" #value>
62 {{ formatDate(route.last_dispatched_at, dFormats.datetime) }}
63 </template>
64 <template v-else #value>never fired</template>
65 </Badge>
66
67 <Badge v-if="route.created_by" type="splitted">
68 <template #label>owner</template>
69 <template #value>{{ route.created_by }}</template>
70 </Badge>
71 </div>
72 </template>
73
74 <template #footerExtra>
75 <div class="flex items-center justify-end gap-2">
76 <n-button size="tiny" quaternary @click="$emit('edit')">
77 <template #icon>
78 <Icon :name="EditIcon" :size="14" />
79 </template>
80 Edit
81 </n-button>
82
83 <n-popconfirm to="body" @positive-click="confirmDelete">
84 <template #trigger>
85 <n-button size="tiny" quaternary :loading="loadingDelete">
86 <template #icon>
87 <Icon :name="DeleteIcon" :size="14" />
88 </template>
89 Delete
90 </n-button>
91 </template>
92 Delete this route? Dispatch log entries will be retained.
93 </n-popconfirm>
94 </div>
95 </template>
96 </CardEntity>
97 </template>
98
99 <script setup lang="ts">
100 import type { ApiError } from "@/types/common"
101 import type { NotificationRoute } from "@/types/notifications.d"
102 import _split from "lodash/split"
103 import { NButton, NPopconfirm, useMessage } from "naive-ui"
104 import { computed, ref } from "vue"
105 import Api from "@/api"
106 import Badge from "@/components/common/Badge.vue"
107 import CardEntity from "@/components/common/cards/CardEntity.vue"
108 import Icon from "@/components/common/Icon.vue"
109 import { useSettingsStore } from "@/stores/settings"
110 import { getApiErrorMessage } from "@/utils"
111 import { formatDate } from "@/utils/format"
112
113 const props = defineProps<{
114 route: NotificationRoute
115 }>()
116
117 const emit = defineEmits<{
118 (e: "edit"): void
119 (e: "deleted"): void
120 (e: "toggled"): void
121 }>()
122
123 const EditIcon = "carbon:edit"
124 const DeleteIcon = "carbon:trash-can"
125 const PauseIcon = "carbon:pause"
126 const PlayIcon = "carbon:play"
127
128 const loadingToggle = ref(false)
129 const loadingDelete = ref(false)
130 const message = useMessage()
131 const dFormats = useSettingsStore().dateFormat
132
133 // All current routes are Shuffle-routed; the underlying Shuffle app
134 // name is cached on the route row at form-submit time so we don't
135 // need to roundtrip to Shuffle on every list render.
136 const channelIcon = computed(() => "carbon:integration")
137 const channelLabel = computed(() =>
138 props.route.shuffle_app_name ? `Shuffle · ${props.route.shuffle_app_name}` : "Shuffle"
139 )
140
141 const severityColor = computed<"danger" | "warning" | "success">(() => {
142 if (props.route.min_severity === "Critical" || props.route.min_severity === "High") return "danger"
143 if (props.route.min_severity === "Medium") return "warning"
144 return "success"
145 })
146
147 // SMTP recipients shown verbatim — email addresses aren't secrets the
148 // way Slack webhook URLs were. Phase 2 may reintroduce per-channel
149 // formatting logic for shuffle integrations.
150 const destinationList = computed(() => _split(props.route.destination, ","))
151
152 async function toggleEnabled() {
153 loadingToggle.value = true
154
155 try {
156 const res = await Api.notifications.updateRoute(props.route.customer_code, props.route.id, {
157 enabled: !props.route.enabled
158 })
159 if (res.data.success) {
160 message.success(`Route ${res.data.route.enabled ? "enabled" : "disabled"}`)
161 emit("toggled")
162 } else {
163 message.warning(res.data.message || "Failed to toggle route")
164 }
165 } catch (err) {
166 message.error(getApiErrorMessage(err as ApiError) || "Failed to toggle route")
167 } finally {
168 loadingToggle.value = false
169 }
170 }
171
172 async function confirmDelete() {
173 loadingDelete.value = true
174
175 try {
176 const res = await Api.notifications.deleteRoute(props.route.customer_code, props.route.id)
177 if (res.data.success) {
178 message.success("Route deleted")
179 emit("deleted")
180 } else {
181 message.warning(res.data.message || "Failed to delete route")
182 }
183 } catch (err) {
184 message.error(getApiErrorMessage(err as ApiError) || "Failed to delete route")
185 } finally {
186 loadingDelete.value = false
187 }
188 }
189 </script>