| 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 | } |