main
ts 430 lines 12.1 KB
Raw
1 import type { Component } from "vue"
2 import type { ApiError, OsTypesFull, Severity } from "@/types/common"
3 import type { SafeAny } from "@/types/utils"
4 import process from "node:process"
5 import { isMobile as detectMobile } from "detect-touch-device"
6 import isDateObject from "lodash/isDate"
7 import _trim from "lodash/trim"
8 import { h } from "vue"
9 import Icon from "@/components/common/Icon.vue"
10 import dayjs from "@/utils/dayjs"
11
12 const URL_PROTOCOL_REGEX = /^https?:\/\//i
13 const TRAILING_DOT_REGEX = /\.$/
14 const NUMERIC_TIMESTAMP_REGEX = /^\d{10,}$/
15
16 export function isEnvDev() {
17 return process.env.NODE_ENV === "development"
18 }
19 export function isEnvTest() {
20 return process.env.NODE_ENV === "test"
21 }
22 export function isEnvProd() {
23 return process.env.NODE_ENV === "production"
24 }
25
26 export function isMobile() {
27 return detectMobile
28 }
29
30 export function isUrlLike(text: string) {
31 return URL_PROTOCOL_REGEX.test(text)
32 }
33
34 export function renderIcon(icon: Component | string) {
35 if (typeof icon === "string") {
36 return () => h(Icon, { name: icon })
37 } else {
38 return () => h(Icon, null, { default: () => h(icon) })
39 }
40 }
41
42 export function iconFromOs(os: string): string {
43 switch (getOS(os)) {
44 case "Windows":
45 return "mdi:microsoft"
46 case "MacOS":
47 return "mdi:apple"
48 case "Linux":
49 case "UNIX":
50 return "mdi:linux"
51 default:
52 return "mdi:help-box"
53 }
54 }
55
56 export function getOS(os: string): OsTypesFull {
57 const test = os.toLowerCase()
58 if (test.includes("mac") || test.includes("darwin") || test.includes("apple")) {
59 return "MacOS"
60 }
61 if (test.includes("win") || test.includes("microsoft")) {
62 return "Windows"
63 }
64 if (
65 test.includes("linux") ||
66 test.includes("ubuntu") ||
67 test.includes("unix") ||
68 test.includes("x11") ||
69 test.includes("debian") ||
70 test.includes("centos")
71 ) {
72 return "Linux"
73 }
74
75 return "Unknown"
76 }
77
78 export function getNavigatorOS(): OsTypesFull {
79 let os: OsTypesFull = "Unknown"
80 if (navigator.userAgent.includes("Win")) os = "Windows"
81 if (navigator.userAgent.includes("Mac")) os = "MacOS"
82 if (navigator.userAgent.includes("X11")) os = "UNIX"
83 if (navigator.userAgent.includes("Linux")) os = "Linux"
84
85 return os
86 }
87
88 export function delay(t: number) {
89 return new Promise(res => setTimeout(res, t))
90 }
91
92 export function getBaseUrl() {
93 return _trim(import.meta.env.VITE_API_URL || "http://127.0.0.1:8000", "/")
94 }
95
96 export function getAvatar(params: { seed: string; text?: string; size?: number; format?: "png" | "svg" }) {
97 const format: "png" | "svg" = params.text ? "svg" : params.format || "svg"
98
99 return `https://avatar.vercel.sh/${params.seed}.${format}?text=${params.text || ""}&size=${params.size || 32}`
100 }
101
102 export function getApiErrorMessage(err: ApiError): string {
103 const axiosMessage = err.message
104 const message = err.response?.data?.message
105 const detail = err.response?.data?.detail
106
107 // Handle string detail
108 if (detail && typeof detail === "string") {
109 return `${detail.replace(TRAILING_DOT_REGEX, "")}.`
110 }
111
112 // Handle string message
113 if (message && typeof message === "string") {
114 return `${message.replace(TRAILING_DOT_REGEX, "")}.`
115 }
116
117 // Fallback to axios message
118 return `${axiosMessage.replace(TRAILING_DOT_REGEX, "")}.`
119 }
120
121 export function isTimestamp(value: SafeAny, cast: true): number | null
122 export function isTimestamp(value: SafeAny, cast?: false): boolean
123 export function isTimestamp(value: SafeAny, cast?: boolean): number | null | boolean {
124 if (value === undefined || value === null || value === "") {
125 return cast ? null : false
126 }
127
128 const strVal = String(value)
129
130 // Check for numeric timestamps (10+ digits: seconds, ms or µs)
131 if (!NUMERIC_TIMESTAMP_REGEX.test(strVal)) {
132 return cast ? null : false
133 }
134
135 const num = Number.parseInt(strVal)
136
137 // Normalize to 13 digits (milliseconds)
138 let timestamp: number
139 if (strVal.length === 10) {
140 // Seconds -> multiply by 1000
141 timestamp = num * 1000
142 } else if (strVal.length === 13) {
143 // Already milliseconds
144 timestamp = num
145 } else if (strVal.length > 13) {
146 // Microseconds or nanoseconds -> divide to get ms
147 timestamp = Math.floor(num / 10 ** (strVal.length - 13))
148 } else {
149 // Between 10 and 13 digits, normalize to 13
150 timestamp = num * 10 ** (13 - strVal.length)
151 }
152
153 // Validate the timestamp produces a valid date
154 if (!dayjs(timestamp).isValid()) {
155 return cast ? null : false
156 }
157
158 return cast ? timestamp : true
159 }
160
161 export function isDate(val?: SafeAny): boolean {
162 if (val === undefined || val === null || val === "") return false
163
164 if (isDateObject(val)) return true
165
166 const strVal = String(val)
167
168 // Check for numeric timestamps (seconds, ms or µs)
169 // We enforce a minimum length of 10 digits to avoid false positives like "188" or "2"
170 if (NUMERIC_TIMESTAMP_REGEX.test(strVal)) {
171 const num = Number.parseInt(strVal)
172 // Handle ms (13 digits) or µs (16+ digits) by normalizing to ms
173 const date = strVal.length >= 13 ? dayjs(num / 10 ** (strVal.length - 13)) : dayjs(num * 1000)
174 return date.isValid()
175 }
176
177 // For ISO strings and other complex formats, we use a hybrid approach
178 // 1. Check for ISO-like strings (containing T and possibly Z or +/- offset)
179 if (strVal.includes("T")) {
180 // dayjs() parser is quite robust for ISO 8601 even without explicit format
181 return dayjs(strVal).isValid()
182 }
183
184 // 2. Strict parsing for regional formats
185 const regionalFormats = ["DD/MM/YYYY", "MM/DD/YYYY", "DD-MM-YYYY", "MM-DD-YYYY", "YYYY-MM-DD", "YYYY-DD-MM"]
186
187 return dayjs(strVal, regionalFormats, true).isValid()
188 }
189
190 export function getBooleanOptions(): { value: boolean; label: string }[] {
191 return [
192 { value: true, label: "Yes" },
193 { value: false, label: "No" }
194 ]
195 }
196
197 export function getHoursBackOptions(options?: { min?: number; max?: number }): { value: number; label: string }[] {
198 const { min, max } = options ?? {}
199
200 const list: { value: number; label: string }[] = [
201 { value: 1, label: "1 hour" },
202 { value: 6, label: "6 hours" },
203 { value: 12, label: "12 hours" },
204 { value: 24, label: "1 day" },
205 { value: 48, label: "2 days" },
206 { value: 72, label: "3 days" },
207 { value: 168, label: "1 week" },
208 { value: 336, label: "2 weeks" },
209 { value: 720, label: "1 month" },
210 { value: 1440, label: "2 months" },
211 { value: 2160, label: "3 months" },
212 { value: 4320, label: "6 months" },
213 { value: 8760, label: "1 year" }
214 ]
215
216 return list.filter(item => (min == null || item.value >= min) && (max == null || item.value <= max))
217 }
218
219 export function getDaysBackOptions(options?: { min?: number; max?: number }): { value: number; label: string }[] {
220 const { min, max } = options ?? {}
221
222 const list: { value: number; label: string }[] = [
223 { value: 1, label: "1 day" },
224 { value: 2, label: "2 days" },
225 { value: 3, label: "3 days" },
226 { value: 7, label: "1 week" },
227 { value: 14, label: "2 weeks" },
228 { value: 30, label: "1 month" },
229 { value: 60, label: "2 months" },
230 { value: 90, label: "3 months" },
231 { value: 180, label: "6 months" },
232 { value: 365, label: "1 year" }
233 ]
234
235 return list.filter(item => (min == null || item.value >= min) && (max == null || item.value <= max))
236 }
237
238 export function getSeverityOptions(options?: { include?: Severity[] }): { value: Severity; label: string }[] {
239 const base: { value: Severity; label: string }[] = [
240 { value: "critical", label: "Critical" },
241 { value: "high", label: "High" },
242 { value: "medium", label: "Medium" },
243 { value: "low", label: "Low" }
244 ]
245
246 const extra: { value: Severity; label: string }[] = [{ value: "info", label: "Info" }]
247
248 const include = extra.filter(item => (options?.include?.length ? options.include.includes(item.value) : false))
249
250 return [...base, ...include]
251 }
252
253 export function getStatusOptions(): { value: string; label: string }[] {
254 return [
255 { value: "pending", label: "Pending" },
256 { value: "running", label: "Running" },
257 { value: "completed", label: "Completed" },
258 { value: "failed", label: "Failed" }
259 ]
260 }
261
262 export type SimilarityCategory = "high" | "good" | "moderate" | "low"
263 export type SimilarityStatus = "default" | "info" | "warning" | "success"
264
265 export interface SimilarityResult {
266 category: SimilarityCategory
267 status: SimilarityStatus
268 label: string
269 description: string
270 score: number
271 }
272
273 /**
274 * Classifies a similarity score based on standard intervals
275 * @param score - Similarity score between 0.0 and 1.0
276 * @returns Object with category, status, label, description and score
277 */
278 export function getSimilarityCategory(score: number): SimilarityResult {
279 // Normalize the score between 0.0 and 1.0
280 const normalizedScore = Math.max(0, Math.min(1, score))
281
282 if (normalizedScore >= 0.7) {
283 return {
284 category: "high",
285 status: "success",
286 label: "High similarity",
287 description: "exact/near matches",
288 score: normalizedScore
289 }
290 }
291
292 if (normalizedScore >= 0.5) {
293 return {
294 category: "good",
295 status: "info",
296 label: "Good semantic similarity",
297 description: "",
298 score: normalizedScore
299 }
300 }
301
302 if (normalizedScore >= 0.3) {
303 return {
304 category: "moderate",
305 status: "warning",
306 label: "Moderate similarity",
307 description: "",
308 score: normalizedScore
309 }
310 }
311
312 return {
313 category: "low",
314 status: "default",
315 label: "Low similarity",
316 description: "",
317 score: normalizedScore
318 }
319 }
320
321 export function getSeverityColor(severity: string | null): "error" | "warning" | "info" | "default" | "success" {
322 if (!severity) return "default"
323
324 const map: Record<string, "error" | "warning" | "info" | "default" | "success"> = {
325 critical: "error",
326 high: "warning",
327 medium: "info",
328 low: "default",
329 info: "default"
330 }
331
332 return map[severity.toLowerCase()] ?? "default"
333 }
334
335 export function getStatusColor(status: string | null): "error" | "warning" | "info" | "default" | "success" {
336 if (!status) return "default"
337
338 const map: Record<string, "error" | "warning" | "info" | "default" | "success"> = {
339 pending: "warning",
340 in_progress: "warning",
341 running: "info",
342 open: "info",
343 completed: "success",
344 closed: "success",
345 failed: "error",
346 not_provided: "default",
347 unknown: "default",
348 error: "error",
349 success: "success",
350 progress: "info",
351 failure: "error",
352 disconnected: "error",
353 active: "success",
354 never_connected: "warning"
355 }
356
357 return map[status.toLowerCase()] ?? "default"
358 }
359
360 export function getHealthColor(status: string | null): "error" | "warning" | "info" | "default" | "success" {
361 if (!status) return "default"
362
363 const map: Record<string, "error" | "warning" | "info" | "default" | "success"> = {
364 caution: "warning",
365 warning: "warning",
366 healthy: "success",
367 degraded: "error"
368 }
369
370 return map[status.toLowerCase()] ?? "default"
371 }
372
373 export function formatCompactNumber(value: number | null | undefined): string {
374 if (value == null) return ""
375 if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
376 if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
377 return value.toLocaleString()
378 }
379
380 // Function to convert logo to favicon format (32x32 PNG)
381 export async function logoToFavicon(logoDataUri: string) {
382 const img = await createImageBitmap(await (await fetch(logoDataUri)).blob())
383 // Canvas a 32x32
384 const canvas = new OffscreenCanvas(32, 32)
385 const ctx = canvas.getContext("2d")
386 if (!ctx) throw new Error("Failed to get canvas context")
387
388 ctx.drawImage(img, 0, 0, 32, 32)
389 const blob = await canvas.convertToBlob({ type: "image/png" })
390
391 // Convert blob to data URL
392 return new Promise<string>((resolve, reject) => {
393 const reader = new FileReader()
394 reader.onloadend = () => resolve(reader.result as string)
395 reader.onerror = reject
396 reader.readAsDataURL(blob)
397 })
398 }
399
400 // Function to update favicon
401 export async function updateFavicon(logoDataUrl: string | null) {
402 if (!logoDataUrl) return
403
404 try {
405 // Convert logo to ICO format
406 const faviconDataUrl = await logoToFavicon(logoDataUrl)
407
408 // Remove existing favicon links
409 const existingLinks = document.querySelectorAll("link[rel*='icon']")
410 existingLinks.forEach(link => link.remove())
411
412 // Create new favicon link
413 const link = document.createElement("link")
414 link.rel = "icon"
415 link.type = "image/png"
416 link.href = faviconDataUrl
417 document.head.appendChild(link)
418 } catch (error) {
419 console.error("Failed to update favicon:", error)
420 }
421 }
422
423 export function trendClass(trend: string, invert?: boolean) {
424 if (trend.startsWith("+")) {
425 return invert ? "text-success" : "text-error"
426 } else if (trend.startsWith("-")) {
427 return invert ? "text-error" : "text-success"
428 }
429 return "text-secondary"
430 }