forked from mengyxu/noob-components
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
389 lines
9.6 KiB
389 lines
9.6 KiB
<template> |
|
<el-tooltip |
|
:content="tooltipContent" |
|
placement="bottom" |
|
:show-after="0" |
|
:hide-after="10" |
|
transition="" |
|
:popper-options="{ modifiers: [{ name: 'eventListeners', enabled: false }] }" |
|
popper-class="non-interactive-tooltip" |
|
> |
|
<div class="ws-monitor-toggle" :class="{ 'is-disabled': disabled }" @click="handleToggle"> |
|
<div class="ws-toggle-track" :class="trackClass"> |
|
<div class="ws-status-indicator" :class="statusClass"> |
|
<div class="ws-status-light" :class="lightClass"></div> |
|
</div> |
|
<div class="ws-toggle-label">{{ labelText }}</div> |
|
</div> |
|
</div> |
|
</el-tooltip> |
|
</template> |
|
|
|
<script setup lang="ts"> |
|
import { computed, defineModel, ref, watch } from "vue"; |
|
import { useStore } from "vuex"; |
|
import { useTimeoutPoll } from "@vueuse/core"; |
|
import { get } from "../../../plugs/http/axios3"; |
|
import { sendSocketMsg } from "../../../plugs/websocket"; |
|
import { useI18n } from "vue3-i18n"; |
|
|
|
const { t } = useI18n(); |
|
|
|
// Props interface |
|
interface Props { |
|
topics: string[]; // Required topics to monitor |
|
statusEndpoint?: string; // Endpoint to check subscriptions |
|
interval?: number; // Retry interval (ms) |
|
maxRetries?: number; // Max connection attempts |
|
connect?: any; // Subscribe command (object) or function(topics) |
|
disconnect?: any; // Unsubscribe command (object) or function(topics) |
|
disabled?: boolean; // Disable toggle |
|
// Label customization (i18n keys or raw strings) |
|
labelConnected?: string; |
|
labelConnecting?: string; |
|
labelDisconnected?: string; |
|
labelError?: string; |
|
} |
|
|
|
const props = withDefaults(defineProps<Props>(), { |
|
statusEndpoint: "/public/ws/topics", |
|
interval: 3000, |
|
maxRetries: 10, |
|
disabled: false, |
|
labelConnected: "ws.autorefresh.enabled", |
|
labelConnecting: "ws.autorefresh.enabling", |
|
labelDisconnected: "ws.autorefresh.disabled", |
|
labelError: "ws.autorefresh.error", |
|
}); |
|
|
|
// Emits |
|
const emit = defineEmits<{ |
|
"status-change": [connected: boolean]; |
|
error: [error: Error]; |
|
}>(); |
|
|
|
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; |
|
|
|
// State |
|
const { state } = useStore(); |
|
const status = defineModel<ConnectionStatus>({ default: "connecting" }); |
|
const currentTopics = ref<string[]>([]); |
|
const retryCount = ref(0); |
|
const lastError = ref<string | null>(null); |
|
|
|
// Computed properties |
|
const trackClass = computed(() => ({ |
|
"is-enabled": status.value !== "disconnected", |
|
"is-disabled": props.disabled, |
|
})); |
|
|
|
const statusClass = computed(() => { |
|
switch (status.value) { |
|
case "connected": |
|
return "status-connected"; |
|
case "connecting": |
|
return "status-connecting"; |
|
case "error": |
|
return "status-error"; |
|
default: |
|
return "status-disconnected"; |
|
} |
|
}); |
|
|
|
const lightClass = computed(() => ({ |
|
"is-pulsing": status.value === "connecting", |
|
})); |
|
|
|
const labelText = computed(() => { |
|
let labelKey: string; |
|
switch (status.value) { |
|
case "connected": |
|
labelKey = props.labelConnected; |
|
break; |
|
case "connecting": |
|
labelKey = props.labelConnecting; |
|
break; |
|
case "error": |
|
labelKey = props.labelError; |
|
break; |
|
default: |
|
labelKey = props.labelDisconnected; |
|
} |
|
// Try i18n first, fallback to raw string |
|
return t(labelKey) || labelKey; |
|
}); |
|
|
|
const tooltipContent = computed(() => { |
|
if (status.value === "error" && lastError.value) { |
|
const errorLabel = t("ws.tooltip.errorOccurred") || "An error occurred"; |
|
return `${errorLabel}: ${lastError.value}`; |
|
} |
|
|
|
if (status.value === "connecting" && retryCount.value > 0) { |
|
const retryLabel = t("ws.tooltip.retrying") || "Retrying"; |
|
return `${retryLabel} (${retryCount.value}/${props.maxRetries})`; |
|
} |
|
|
|
// Default: simple click instruction |
|
const clickLabel = t("ws.tooltip.clickToToggle") || "Click to enable/disable autorefresh"; |
|
return clickLabel; |
|
}); |
|
|
|
// Core logic functions |
|
const executeConnect = () => { |
|
if (!props.connect) return; |
|
|
|
if (typeof props.connect === "function") { |
|
// If it's a function, call it with topics array and send the returned value |
|
const result = props.connect(props.topics); |
|
if (result !== undefined && result !== null) { |
|
// Handle both single value and array of values |
|
if (Array.isArray(result)) { |
|
result.forEach((msg) => sendSocketMsg(msg)); |
|
} else { |
|
sendSocketMsg(result); |
|
} |
|
} |
|
} else { |
|
// If it's a value/object, send it directly via WebSocket |
|
sendSocketMsg(props.connect); |
|
} |
|
}; |
|
|
|
const executeDisconnect = () => { |
|
if (!props.disconnect) return; |
|
|
|
if (typeof props.disconnect === "function") { |
|
// If it's a function, call it with topics array and send the returned value |
|
const result = props.disconnect(props.topics); |
|
if (result !== undefined && result !== null) { |
|
// Handle both single value and array of values |
|
if (Array.isArray(result)) { |
|
result.forEach((msg) => sendSocketMsg(msg)); |
|
} else { |
|
sendSocketMsg(result); |
|
} |
|
} |
|
} else { |
|
// If it's a value/object, send it directly via WebSocket |
|
sendSocketMsg(props.disconnect); |
|
} |
|
}; |
|
|
|
const verifyTopics = async (): Promise<boolean> => { |
|
try { |
|
const subscribedTopics = await get(props.statusEndpoint, {}, { noLoading: true, noMsg: true }); |
|
|
|
if (!Array.isArray(subscribedTopics)) { |
|
lastError.value = "Failed to fetch topic status"; |
|
emit("error", new Error("Failed to fetch topic status")); |
|
return false; |
|
} |
|
|
|
const topicSet = new Set(subscribedTopics); |
|
currentTopics.value = subscribedTopics; |
|
|
|
// Check if all required topics are present |
|
return props.topics.every((topic) => topicSet.has(topic)); |
|
} catch (error: any) { |
|
lastError.value = error.message || "Network error"; |
|
emit("error", error as Error); |
|
return false; |
|
} |
|
}; |
|
|
|
// Verification polling - checks every 1 second if topics are subscribed |
|
const { pause: pauseVerification, resume: resumeVerification } = useTimeoutPoll( |
|
async () => { |
|
const allConnected = await verifyTopics(); |
|
|
|
if (allConnected) { |
|
status.value = "connected"; |
|
emit("status-change", true); |
|
pauseRetry(); |
|
pauseVerification(); |
|
retryCount.value = 0; |
|
lastError.value = null; |
|
} else if (!retrying.value) { |
|
retryCount.value = 0; |
|
lastError.value = null; |
|
resumeRetry(); |
|
} |
|
}, |
|
1000, |
|
{ immediate: false, immediateCallback: true } |
|
); |
|
|
|
// Retry polling - retries connection at configurable interval |
|
const { |
|
isActive: retrying, |
|
pause: pauseRetry, |
|
resume: resumeRetry, |
|
} = useTimeoutPoll( |
|
() => { |
|
if (retryCount.value >= props.maxRetries) { |
|
status.value = "error"; |
|
lastError.value = `Max retries (${props.maxRetries}) exceeded`; |
|
pauseRetry(); |
|
pauseVerification(); |
|
emit("error", new Error(lastError.value)); |
|
return; |
|
} |
|
|
|
retryCount.value++; |
|
console.log(`WS Monitor: Retry attempt ${retryCount.value}/${props.maxRetries}`); |
|
|
|
// Execute connect |
|
executeConnect(); |
|
}, |
|
() => props.interval, |
|
{ immediate: false } |
|
); |
|
|
|
const handleToggle = () => { |
|
if (props.disabled) return; |
|
|
|
if (status.value === "disconnected") { |
|
status.value = "connecting"; |
|
} else { |
|
status.value = "disconnected"; |
|
} |
|
}; |
|
|
|
// Watch for external disable |
|
watch( |
|
() => props.disabled, |
|
(newDisabled) => { |
|
if (newDisabled) { |
|
status.value = "disconnected"; // Turn off if disabled externally |
|
} |
|
} |
|
); |
|
|
|
watch( |
|
status, |
|
(value, oldValue) => { |
|
if (value === "connecting") { |
|
// Turn ON - start connection process |
|
retryCount.value = 0; |
|
lastError.value = null; |
|
|
|
// Execute connect |
|
executeConnect(); |
|
|
|
// Start verification and retry polling |
|
resumeVerification(); |
|
resumeRetry(); |
|
} else if (value === "disconnected" && oldValue !== "disconnected") { |
|
// Turn OFF - cleanup and disconnect |
|
|
|
// Execute disconnect |
|
executeDisconnect(); |
|
|
|
// Pause all polling |
|
pauseVerification(); |
|
pauseRetry(); |
|
|
|
// Reset state |
|
retryCount.value = 0; |
|
lastError.value = null; |
|
currentTopics.value = []; |
|
|
|
// Emit status change |
|
emit("status-change", false); |
|
} |
|
}, |
|
{ immediate: true } |
|
); |
|
</script> |
|
|
|
<style scoped lang="scss"> |
|
.ws-monitor-toggle { |
|
display: inline-flex; |
|
cursor: pointer; |
|
user-select: none; |
|
|
|
&.is-disabled { |
|
cursor: not-allowed; |
|
opacity: 0.5; |
|
} |
|
} |
|
|
|
.ws-toggle-track { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
padding: 6px 12px; |
|
border-radius: 16px; |
|
background-color: transparent; |
|
border: 1px solid v-bind("state.style.borderColor"); |
|
transition: all 0.3s ease; |
|
|
|
&:hover:not(.is-disabled) { |
|
border-color: v-bind("state.style.primary"); |
|
} |
|
|
|
&.is-enabled { |
|
background-color: v-bind("state.style.primaryBg"); |
|
} |
|
} |
|
|
|
.ws-status-indicator { |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
width: 20px; |
|
height: 20px; |
|
border-radius: 50%; |
|
} |
|
|
|
.ws-status-light { |
|
width: 10px; |
|
height: 10px; |
|
border-radius: 50%; |
|
transition: all 0.3s ease; |
|
|
|
&.is-pulsing { |
|
animation: pulse 1.5s ease-in-out infinite; |
|
} |
|
} |
|
|
|
.status-disconnected .ws-status-light { |
|
background-color: #909399; |
|
} |
|
|
|
.status-connecting .ws-status-light { |
|
background-color: #e6a23c; |
|
} |
|
|
|
.status-connected .ws-status-light { |
|
background-color: #67c23a; |
|
box-shadow: 0 0 8px rgba(103, 194, 58, 0.6); |
|
} |
|
|
|
.status-error .ws-status-light { |
|
background-color: #f56c6c; |
|
} |
|
|
|
.ws-toggle-label { |
|
font-size: v-bind("state.size.fontSize + 'px'"); |
|
color: v-bind("state.style.color"); |
|
white-space: nowrap; |
|
} |
|
|
|
@keyframes pulse { |
|
0%, |
|
100% { |
|
opacity: 1; |
|
transform: scale(1); |
|
} |
|
50% { |
|
opacity: 0.5; |
|
transform: scale(1.2); |
|
} |
|
} |
|
|
|
.non-interactive-tooltip { |
|
pointer-events: none !important; |
|
} |
|
</style>
|
|
|