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.
390 lines
9.6 KiB
390 lines
9.6 KiB
|
5 months ago
|
<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>
|