mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 06:56:04 +02:00
add vrchat servers status to status bar
This commit is contained in:
+134
-160
@@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="status-bar" @contextmenu.prevent>
|
<div
|
||||||
|
class="shrink-0 h-[22px] flex items-center bg-sidebar border-t border-border font-mono text-xs select-none overflow-hidden"
|
||||||
|
@contextmenu.prevent>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger as-child>
|
<ContextMenuTrigger as-child>
|
||||||
<div class="status-bar-inner">
|
<div class="flex items-center w-full h-full px-2">
|
||||||
<!-- Left section -->
|
<!-- Left section -->
|
||||||
<div class="status-bar-left">
|
<div class="flex items-center flex-1 min-w-0 [&>*:first-child]:pl-0.5">
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
v-if="visibility.proxy"
|
v-if="visibility.proxy"
|
||||||
:content="
|
:content="
|
||||||
@@ -13,23 +15,15 @@
|
|||||||
: t('status_bar.proxy')
|
: t('status_bar.proxy')
|
||||||
"
|
"
|
||||||
side="top">
|
side="top">
|
||||||
<div class="status-bar-item status-bar-clickable" @click="handleProxyClick">
|
<div
|
||||||
<span class="status-dot" :class="vrcxStore.proxyServer ? 'dot-green' : 'dot-gray'" />
|
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent"
|
||||||
<span class="status-label">{{ vrcxStore.proxyServer || t('status_bar.proxy') }}</span>
|
@click="handleProxyClick">
|
||||||
</div>
|
<span
|
||||||
</TooltipWrapper>
|
class="inline-block size-2 rounded-full shrink-0"
|
||||||
|
:class="vrcxStore.proxyServer ? 'bg-status-online' : 'bg-status-offline-alt'" />
|
||||||
<TooltipWrapper
|
<span class="text-foreground text-[11px]">{{
|
||||||
v-if="!isMacOS && visibility.vrchat"
|
vrcxStore.proxyServer || t('status_bar.proxy')
|
||||||
:content="
|
}}</span>
|
||||||
gameStore.isGameRunning
|
|
||||||
? t('status_bar.vrchat_running')
|
|
||||||
: t('status_bar.vrchat_stopped')
|
|
||||||
"
|
|
||||||
side="top">
|
|
||||||
<div class="status-bar-item">
|
|
||||||
<span class="status-dot" :class="gameStore.isGameRunning ? 'dot-green' : 'dot-gray'" />
|
|
||||||
<span class="status-label">{{ t('status_bar.vrchat') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
|
|
||||||
@@ -41,20 +35,76 @@
|
|||||||
: t('status_bar.steamvr_stopped')
|
: t('status_bar.steamvr_stopped')
|
||||||
"
|
"
|
||||||
side="top">
|
side="top">
|
||||||
<div class="status-bar-item">
|
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||||
<span
|
<span
|
||||||
class="status-dot"
|
class="inline-block size-2 rounded-full shrink-0"
|
||||||
:class="gameStore.isSteamVRRunning ? 'dot-green' : 'dot-gray'" />
|
:class="
|
||||||
<span class="status-label">{{ t('status_bar.steamvr') }}</span>
|
gameStore.isSteamVRRunning ? 'bg-status-online' : 'bg-status-offline-alt'
|
||||||
|
" />
|
||||||
|
<span class="text-foreground text-[11px]">{{ t('status_bar.steamvr') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
|
|
||||||
|
<TooltipWrapper
|
||||||
|
v-if="!isMacOS && visibility.vrchat"
|
||||||
|
:content="
|
||||||
|
gameStore.isGameRunning ? t('status_bar.game_running') : t('status_bar.game_stopped')
|
||||||
|
"
|
||||||
|
side="top">
|
||||||
|
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||||
|
<span
|
||||||
|
class="inline-block size-2 rounded-full shrink-0"
|
||||||
|
:class="gameStore.isGameRunning ? 'bg-status-online' : 'bg-status-offline-alt'" />
|
||||||
|
<span class="text-foreground text-[11px]">{{ t('status_bar.game') }}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipWrapper>
|
||||||
|
|
||||||
|
<HoverCard v-if="visibility.servers" v-model:open="serversHoverOpen">
|
||||||
|
<HoverCardTrigger as-child>
|
||||||
|
<TooltipWrapper
|
||||||
|
v-if="!vrcStatusStore.hasIssue"
|
||||||
|
:content="t('status_bar.servers_ok')"
|
||||||
|
side="top">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent"
|
||||||
|
@click="vrcStatusStore.openStatusPage()">
|
||||||
|
<span class="inline-block size-2 rounded-full shrink-0 bg-status-online" />
|
||||||
|
<span class="text-foreground text-[11px]">{{ t('status_bar.servers') }}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipWrapper>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||||
|
<span class="inline-block size-2 rounded-full shrink-0 bg-[#e6a23c]" />
|
||||||
|
<span class="text-foreground text-[11px]">{{ t('status_bar.servers') }}</span>
|
||||||
|
</div>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent
|
||||||
|
v-if="vrcStatusStore.hasIssue"
|
||||||
|
class="w-[280px] px-3 py-2.5"
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
:side-offset="4">
|
||||||
|
<div class="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<span class="inline-block size-2 rounded-full shrink-0 bg-[#e6a23c]" />
|
||||||
|
<span class="font-semibold text-xs text-foreground">{{
|
||||||
|
t('status_bar.servers_issue')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-muted-foreground m-0 leading-[1.4]">
|
||||||
|
{{ vrcStatusStore.statusText }}
|
||||||
|
</p>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
|
||||||
<TooltipWrapper v-if="visibility.ws" :content="wsTooltip" side="top">
|
<TooltipWrapper v-if="visibility.ws" :content="wsTooltip" side="top">
|
||||||
<div class="status-bar-item status-bar-ws">
|
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||||
<span class="status-dot" :class="wsState.connected ? 'dot-green' : 'dot-gray'" />
|
<span
|
||||||
<span class="status-label">WebSocket</span>
|
class="inline-block size-2 rounded-full shrink-0"
|
||||||
<canvas ref="wsCanvasRef" class="ws-sparkline" />
|
:class="wsState.connected ? 'bg-status-online' : 'bg-status-offline-alt'" />
|
||||||
<span class="status-label-mono">{{
|
<span class="text-foreground text-[11px]">WebSocket</span>
|
||||||
|
<canvas ref="wsCanvasRef" class="shrink-0 rounded-sm" />
|
||||||
|
<span class="text-[10px] text-foreground">{{
|
||||||
t('status_bar.ws_avg_per_minute', { count: msgsPerMinuteAvg })
|
t('status_bar.ws_avg_per_minute', { count: msgsPerMinuteAvg })
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,18 +112,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right section -->
|
<!-- Right section -->
|
||||||
<div class="status-bar-right">
|
<div class="flex items-center ml-auto [&>*:last-child]:border-r-0 [&>*:last-child]:pr-0.5">
|
||||||
<template v-if="visibility.clocks">
|
<template v-if="visibility.clocks">
|
||||||
<Popover
|
<Popover
|
||||||
v-for="(clock, idx) in visibleClocks"
|
v-for="(clock, idx) in visibleClocks"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
v-model:open="clockPopoverOpen[idx]">
|
v-model:open="clockPopoverOpen[idx]">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<div class="status-bar-item status-bar-clickable">
|
<div
|
||||||
<span class="status-label-mono">{{ formatClock(clock) }}</span>
|
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent">
|
||||||
|
<span class="text-[10px] text-foreground">{{ formatClock(clock) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="status-bar-clock-popover" side="top" align="center">
|
<PopoverContent class="w-[280px]" side="top" align="center">
|
||||||
<div class="flex flex-col gap-2 p-1">
|
<div class="flex flex-col gap-2 p-1">
|
||||||
<label class="text-xs font-medium">{{ t('status_bar.timezone') }}</label>
|
<label class="text-xs font-medium">{{ t('status_bar.timezone') }}</label>
|
||||||
<Select
|
<Select
|
||||||
@@ -105,20 +156,22 @@
|
|||||||
:content="t('status_bar.zoom_tooltip')"
|
:content="t('status_bar.zoom_tooltip')"
|
||||||
side="top"
|
side="top"
|
||||||
:disabled="zoomEditing">
|
:disabled="zoomEditing">
|
||||||
<div class="status-bar-item status-bar-clickable" @click="toggleZoomEdit">
|
<div
|
||||||
|
class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border cursor-pointer hover:bg-accent"
|
||||||
|
@click="toggleZoomEdit">
|
||||||
<template v-if="zoomEditing">
|
<template v-if="zoomEditing">
|
||||||
<span class="status-label-mono">{{ t('status_bar.zoom') }}</span>
|
<span class="text-[10px] text-foreground">{{ t('status_bar.zoom') }}</span>
|
||||||
<NumberField
|
<NumberField
|
||||||
v-model="zoomLevel"
|
v-model="zoomLevel"
|
||||||
:step="1"
|
:step="1"
|
||||||
:format-options="{ maximumFractionDigits: 0 }"
|
:format-options="{ maximumFractionDigits: 0 }"
|
||||||
class="status-bar-zoom-field"
|
class="w-20"
|
||||||
@update:modelValue="setZoomLevel">
|
@update:modelValue="setZoomLevel">
|
||||||
<NumberFieldContent>
|
<NumberFieldContent>
|
||||||
<NumberFieldDecrement />
|
<NumberFieldDecrement />
|
||||||
<NumberFieldInput
|
<NumberFieldInput
|
||||||
ref="zoomInputRef"
|
ref="zoomInputRef"
|
||||||
class="status-bar-zoom-input"
|
class="h-[18px] text-[11px] px-0.5 text-center"
|
||||||
@blur="zoomEditing = false"
|
@blur="zoomEditing = false"
|
||||||
@keydown.enter="zoomEditing = false"
|
@keydown.enter="zoomEditing = false"
|
||||||
@keydown.escape="zoomEditing = false" />
|
@keydown.escape="zoomEditing = false" />
|
||||||
@@ -127,16 +180,16 @@
|
|||||||
</NumberField>
|
</NumberField>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="status-label-mono">{{ t('status_bar.zoom') }}</span>
|
<span class="text-[10px] text-foreground">{{ t('status_bar.zoom') }}</span>
|
||||||
<span class="status-label-mono">{{ zoomLevel }}%</span>
|
<span class="text-[10px] text-foreground">{{ zoomLevel }}%</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
|
|
||||||
<TooltipWrapper v-if="visibility.uptime" :content="t('status_bar.app_uptime')" side="top">
|
<TooltipWrapper v-if="visibility.uptime" :content="t('status_bar.app_uptime')" side="top">
|
||||||
<div class="status-bar-item">
|
<div class="flex items-center gap-1 px-2 h-[22px] whitespace-nowrap border-r border-border">
|
||||||
<span class="status-label-mono">{{ t('status_bar.app_uptime_short') }}</span>
|
<span class="text-[10px] text-foreground">{{ t('status_bar.app_uptime_short') }}</span>
|
||||||
<span class="status-label-mono">{{ appUptimeText }}</span>
|
<span class="text-[10px] text-foreground">{{ appUptimeText }}</span>
|
||||||
</div>
|
</div>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +201,12 @@
|
|||||||
v-if="!isMacOS"
|
v-if="!isMacOS"
|
||||||
:model-value="visibility.vrchat"
|
:model-value="visibility.vrchat"
|
||||||
@update:model-value="toggleVisibility('vrchat')">
|
@update:model-value="toggleVisibility('vrchat')">
|
||||||
{{ t('status_bar.vrchat') }}
|
{{ t('status_bar.game') }}
|
||||||
|
</ContextMenuCheckboxItem>
|
||||||
|
<ContextMenuCheckboxItem
|
||||||
|
:model-value="visibility.servers"
|
||||||
|
@update:model-value="toggleVisibility('servers')">
|
||||||
|
{{ t('status_bar.servers') }}
|
||||||
</ContextMenuCheckboxItem>
|
</ContextMenuCheckboxItem>
|
||||||
<ContextMenuCheckboxItem
|
<ContextMenuCheckboxItem
|
||||||
v-if="!isMacOS"
|
v-if="!isMacOS"
|
||||||
@@ -211,8 +269,8 @@
|
|||||||
ContextMenuSubTrigger,
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger
|
ContextMenuTrigger
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
NumberFieldContent,
|
NumberFieldContent,
|
||||||
@@ -220,8 +278,9 @@
|
|||||||
NumberFieldIncrement,
|
NumberFieldIncrement,
|
||||||
NumberFieldInput
|
NumberFieldInput
|
||||||
} from '@/components/ui/number-field';
|
} from '@/components/ui/number-field';
|
||||||
|
import { useGameStore, useGeneralSettingsStore, useVrcStatusStore, useVrcxStore } from '@/stores';
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { useGameStore, useGeneralSettingsStore, useVrcxStore } from '@/stores';
|
|
||||||
import { useIntervalFn, useNow } from '@vueuse/core';
|
import { useIntervalFn, useNow } from '@vueuse/core';
|
||||||
import { TooltipWrapper } from '@/components/ui/tooltip';
|
import { TooltipWrapper } from '@/components/ui/tooltip';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@@ -251,8 +310,29 @@
|
|||||||
|
|
||||||
const gameStore = useGameStore();
|
const gameStore = useGameStore();
|
||||||
const vrcxStore = useVrcxStore();
|
const vrcxStore = useVrcxStore();
|
||||||
|
const vrcStatusStore = useVrcStatusStore();
|
||||||
const generalSettingsStore = useGeneralSettingsStore();
|
const generalSettingsStore = useGeneralSettingsStore();
|
||||||
|
|
||||||
|
// --- Servers status HoverCard ---
|
||||||
|
const serversHoverOpen = ref(false);
|
||||||
|
let serversHoverTimer = null;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => vrcStatusStore.hasIssue,
|
||||||
|
(hasIssue) => {
|
||||||
|
if (hasIssue && visibility.servers) {
|
||||||
|
serversHoverOpen.value = true;
|
||||||
|
clearTimeout(serversHoverTimer);
|
||||||
|
serversHoverTimer = setTimeout(() => {
|
||||||
|
serversHoverOpen.value = false;
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
serversHoverOpen.value = false;
|
||||||
|
clearTimeout(serversHoverTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const VISIBILITY_KEY = 'VRCX_statusBarVisibility';
|
const VISIBILITY_KEY = 'VRCX_statusBarVisibility';
|
||||||
|
|
||||||
const visibility = reactive({ ...defaultVisibility });
|
const visibility = reactive({ ...defaultVisibility });
|
||||||
@@ -264,6 +344,9 @@
|
|||||||
function toggleVisibility(key) {
|
function toggleVisibility(key) {
|
||||||
visibility[key] = !visibility[key];
|
visibility[key] = !visibility[key];
|
||||||
configRepository.setString(VISIBILITY_KEY, JSON.stringify(visibility));
|
configRepository.setString(VISIBILITY_KEY, JSON.stringify(visibility));
|
||||||
|
if (key === 'servers') {
|
||||||
|
vrcStatusStore.setStatusBarServersVisible(visibility.servers);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- WebSocket message rate + sparkline ---
|
// --- WebSocket message rate + sparkline ---
|
||||||
@@ -455,6 +538,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
drawSparkline();
|
drawSparkline();
|
||||||
|
vrcStatusStore.setStatusBarServersVisible(visibility.servers);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(serversHoverTimer);
|
||||||
|
vrcStatusStore.setStatusBarServersVisible(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -519,118 +608,3 @@
|
|||||||
generalSettingsStore.promptProxySettings();
|
generalSettingsStore.promptProxySettings();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.status-bar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 22px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--sidebar);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
font-family: 'Consolas', 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
user-select: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 0 8px;
|
|
||||||
height: 22px;
|
|
||||||
white-space: nowrap;
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-left .status-bar-item:first-child {
|
|
||||||
padding-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-right .status-bar-item:last-child {
|
|
||||||
border-right: none;
|
|
||||||
padding-right: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-clickable:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot-green {
|
|
||||||
background: var(--status-online);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot-gray {
|
|
||||||
background: var(--status-offline-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-label-mono {
|
|
||||||
font-size: 10px;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-ws {
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-sparkline {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-zoom-field {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-zoom-input {
|
|
||||||
height: 18px;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 0 2px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-bar-clock-popover {
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import StatusBar from '../StatusBar.vue';
|
||||||
|
import en from '../../localization/en.json';
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
vi.mock('../../service/config', () => ({
|
||||||
|
default: {
|
||||||
|
init: vi.fn(),
|
||||||
|
getString: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_key, defaultValue) => defaultValue ?? '{}'),
|
||||||
|
setString: vi.fn(),
|
||||||
|
getBool: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_key, defaultValue) => defaultValue ?? false),
|
||||||
|
setBool: vi.fn(),
|
||||||
|
getInt: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_key, defaultValue) => defaultValue ?? 0),
|
||||||
|
setInt: vi.fn(),
|
||||||
|
getFloat: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((_key, defaultValue) => defaultValue ?? 0),
|
||||||
|
setFloat: vi.fn(),
|
||||||
|
getObject: vi.fn().mockReturnValue(null),
|
||||||
|
setObject: vi.fn(),
|
||||||
|
getArray: vi.fn().mockReturnValue([]),
|
||||||
|
setArray: vi.fn(),
|
||||||
|
remove: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../service/websocket', () => ({
|
||||||
|
wsState: { connected: false, messageCount: 0, bytesReceived: 0 },
|
||||||
|
initWebsocket: vi.fn(),
|
||||||
|
closeWebSocket: vi.fn(),
|
||||||
|
reconnectWebSocket: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../service/webapi', () => ({
|
||||||
|
default: {
|
||||||
|
execute: vi.fn().mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
page: { updated_at: '2026-01-01T00:00:00.000Z' },
|
||||||
|
status: { description: 'All Systems Operational' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('worker-timers', () => ({
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout: vi.fn(),
|
||||||
|
clearTimeout: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../service/jsonStorage', () => ({
|
||||||
|
default: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock('../../service/watchState', () => ({
|
||||||
|
watchState: { isLoggedIn: false }
|
||||||
|
}));
|
||||||
|
vi.mock('../../service/database', () => ({
|
||||||
|
database: new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_target, prop) => {
|
||||||
|
if (prop === '__esModule') return false;
|
||||||
|
return vi.fn().mockResolvedValue(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
vi.mock('../../plugin/router', () => ({
|
||||||
|
router: {
|
||||||
|
beforeEach: vi.fn(),
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: ref({ path: '/', name: '', meta: {} }),
|
||||||
|
isReady: vi.fn().mockResolvedValue(true)
|
||||||
|
},
|
||||||
|
initRouter: vi.fn()
|
||||||
|
}));
|
||||||
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
currentRoute: ref({ path: '/', name: '', meta: {} })
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('../../plugin/interopApi', () => ({
|
||||||
|
initInteropApi: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
locale: 'en',
|
||||||
|
fallbackLocale: 'en',
|
||||||
|
legacy: false,
|
||||||
|
globalInjection: false,
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
messages: { en }
|
||||||
|
});
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
TooltipWrapper: {
|
||||||
|
template: '<span data-testid="tooltip"><slot /></span>',
|
||||||
|
props: [
|
||||||
|
'content',
|
||||||
|
'disabled',
|
||||||
|
'delayDuration',
|
||||||
|
'delay-duration',
|
||||||
|
'side'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
ContextMenu: { template: '<div><slot /></div>' },
|
||||||
|
ContextMenuTrigger: { template: '<div><slot /></div>' },
|
||||||
|
ContextMenuContent: { template: '<div><slot /></div>' },
|
||||||
|
ContextMenuCheckboxItem: {
|
||||||
|
template: '<div><slot /></div>',
|
||||||
|
props: ['modelValue']
|
||||||
|
},
|
||||||
|
ContextMenuSeparator: { template: '<div />' },
|
||||||
|
ContextMenuSub: { template: '<div><slot /></div>' },
|
||||||
|
ContextMenuSubTrigger: { template: '<div><slot /></div>' },
|
||||||
|
ContextMenuSubContent: { template: '<div><slot /></div>' },
|
||||||
|
ContextMenuRadioGroup: {
|
||||||
|
template: '<div><slot /></div>',
|
||||||
|
props: ['modelValue']
|
||||||
|
},
|
||||||
|
ContextMenuRadioItem: { template: '<div><slot /></div>', props: ['value'] },
|
||||||
|
HoverCard: {
|
||||||
|
template: '<div data-testid="hover-card"><slot /></div>',
|
||||||
|
props: ['open']
|
||||||
|
},
|
||||||
|
HoverCardTrigger: {
|
||||||
|
template: '<div data-testid="hover-card-trigger"><slot /></div>'
|
||||||
|
},
|
||||||
|
HoverCardContent: {
|
||||||
|
template: '<div data-testid="hover-card-content"><slot /></div>',
|
||||||
|
props: ['class', 'side', 'align', 'sideOffset']
|
||||||
|
},
|
||||||
|
Popover: { template: '<div><slot /></div>', props: ['open'] },
|
||||||
|
PopoverTrigger: { template: '<div><slot /></div>' },
|
||||||
|
PopoverContent: {
|
||||||
|
template: '<div><slot /></div>',
|
||||||
|
props: ['class', 'side', 'align']
|
||||||
|
},
|
||||||
|
Select: { template: '<div><slot /></div>', props: ['modelValue'] },
|
||||||
|
SelectTrigger: { template: '<div><slot /></div>', props: ['size'] },
|
||||||
|
SelectValue: { template: '<span />', props: ['placeholder'] },
|
||||||
|
SelectContent: { template: '<div><slot /></div>', props: ['class'] },
|
||||||
|
SelectGroup: { template: '<div><slot /></div>' },
|
||||||
|
SelectItem: { template: '<div><slot /></div>', props: ['value'] },
|
||||||
|
NumberField: {
|
||||||
|
template: '<div><slot /></div>',
|
||||||
|
props: ['modelValue', 'step', 'formatOptions', 'class']
|
||||||
|
},
|
||||||
|
NumberFieldContent: { template: '<div><slot /></div>' },
|
||||||
|
NumberFieldDecrement: { template: '<button />' },
|
||||||
|
NumberFieldIncrement: { template: '<button />' },
|
||||||
|
NumberFieldInput: { template: '<input />', props: ['class'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param storeOverrides
|
||||||
|
*/
|
||||||
|
function mountStatusBar(storeOverrides = {}) {
|
||||||
|
return mount(StatusBar, {
|
||||||
|
global: {
|
||||||
|
plugins: [
|
||||||
|
i18n,
|
||||||
|
createTestingPinia({
|
||||||
|
stubActions: true,
|
||||||
|
initialState: {
|
||||||
|
Game: {
|
||||||
|
isGameRunning: false,
|
||||||
|
isSteamVRRunning: false,
|
||||||
|
...storeOverrides.Game
|
||||||
|
},
|
||||||
|
Vrcx: {
|
||||||
|
proxyServer: '',
|
||||||
|
appStartAt: Date.now(),
|
||||||
|
...storeOverrides.Vrcx
|
||||||
|
},
|
||||||
|
VrcStatus: {
|
||||||
|
lastStatus: '',
|
||||||
|
lastStatusTime: null,
|
||||||
|
lastStatusSummary: '',
|
||||||
|
...storeOverrides.VrcStatus
|
||||||
|
},
|
||||||
|
GeneralSettings: {
|
||||||
|
...storeOverrides.GeneralSettings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
stubs
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StatusBar.vue - Servers indicator', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows "Game" label instead of "VRChat" for game running indicator', () => {
|
||||||
|
const wrapper = mountStatusBar({ Game: { isGameRunning: true } });
|
||||||
|
expect(wrapper.text()).toContain('Game');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows Servers indicator with green dot when no issues', () => {
|
||||||
|
const wrapper = mountStatusBar();
|
||||||
|
expect(wrapper.text()).toContain('Servers');
|
||||||
|
const serversDots = wrapper.findAll(
|
||||||
|
'.bg-\\[var\\(--status-online\\)\\]'
|
||||||
|
);
|
||||||
|
expect(serversDots.length).toBeGreaterThan(0);
|
||||||
|
expect(wrapper.find('.bg-\\[\\#e6a23c\\]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows Servers indicator with yellow dot when there is an issue', () => {
|
||||||
|
const wrapper = mountStatusBar({
|
||||||
|
VrcStatus: {
|
||||||
|
lastStatus: 'Partial System Outage'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(wrapper.text()).toContain('Servers');
|
||||||
|
expect(wrapper.find('.bg-\\[\\#e6a23c\\]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows HoverCard content with status text when there is an issue', () => {
|
||||||
|
const wrapper = mountStatusBar({
|
||||||
|
VrcStatus: {
|
||||||
|
lastStatus: 'Partial System Outage',
|
||||||
|
lastStatusSummary: 'API, CDN'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const hoverContent = wrapper.find('[data-testid="hover-card-content"]');
|
||||||
|
expect(hoverContent.exists()).toBe(true);
|
||||||
|
expect(hoverContent.text()).toContain('VRChat Server Issues');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not show HoverCard content when no issues', () => {
|
||||||
|
const wrapper = mountStatusBar();
|
||||||
|
const hoverContent = wrapper.find('[data-testid="hover-card-content"]');
|
||||||
|
expect(hoverContent.exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows Servers indicator in context menu', () => {
|
||||||
|
const wrapper = mountStatusBar();
|
||||||
|
const text = wrapper.text();
|
||||||
|
expect(text).toContain('Servers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows SteamVR indicator', () => {
|
||||||
|
const wrapper = mountStatusBar({ Game: { isSteamVRRunning: true } });
|
||||||
|
expect(wrapper.text()).toContain('SteamVR');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,8 @@ export const defaultVisibility = {
|
|||||||
ws: true,
|
ws: true,
|
||||||
uptime: true,
|
uptime: true,
|
||||||
clocks: true,
|
clocks: true,
|
||||||
zoom: true
|
zoom: true,
|
||||||
|
servers: true
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2645,9 +2645,12 @@
|
|||||||
"clear_input": "Clear input"
|
"clear_input": "Clear input"
|
||||||
},
|
},
|
||||||
"status_bar": {
|
"status_bar": {
|
||||||
"vrchat": "VRChat",
|
"game": "Game",
|
||||||
"vrchat_running": "VRChat is running",
|
"game_running": "VRChat is running",
|
||||||
"vrchat_stopped": "VRChat is not running",
|
"game_stopped": "VRChat is not running",
|
||||||
|
"servers": "Servers",
|
||||||
|
"servers_ok": "VRChat servers are operational",
|
||||||
|
"servers_issue": "VRChat Server Issues",
|
||||||
"steamvr": "SteamVR",
|
"steamvr": "SteamVR",
|
||||||
"steamvr_running": "SteamVR is running",
|
"steamvr_running": "SteamVR is running",
|
||||||
"steamvr_stopped": "SteamVR is not running",
|
"steamvr_stopped": "SteamVR is not running",
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ const mocks = vi.hoisted(() => ({
|
|||||||
execute: vi.fn(),
|
execute: vi.fn(),
|
||||||
formatDateFilter: vi.fn(() => 'formatted-time'),
|
formatDateFilter: vi.fn(() => 'formatted-time'),
|
||||||
openExternalLink: vi.fn(),
|
openExternalLink: vi.fn(),
|
||||||
toast: {
|
toastWarning: vi.fn(() => 'toast-id-1'),
|
||||||
warning: vi.fn(),
|
toastSuccess: vi.fn(() => 'toast-id-2'),
|
||||||
success: vi.fn(),
|
toastDismiss: vi.fn()
|
||||||
dismiss: vi.fn()
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../service/webapi', () => ({
|
vi.mock('../../service/webapi', () => ({
|
||||||
@@ -18,21 +16,6 @@ vi.mock('../../service/webapi', () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../shared/utils', () => ({
|
|
||||||
formatDateFilter: (...args) => mocks.formatDateFilter(...args),
|
|
||||||
openExternalLink: (...args) => mocks.openExternalLink(...args)
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('vue-sonner', () => ({
|
|
||||||
toast: mocks.toast
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('vue-i18n', () => ({
|
|
||||||
useI18n: () => ({
|
|
||||||
t: (key) => key
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('worker-timers', () => ({
|
vi.mock('worker-timers', () => ({
|
||||||
setInterval: vi.fn(),
|
setInterval: vi.fn(),
|
||||||
clearInterval: vi.fn(),
|
clearInterval: vi.fn(),
|
||||||
@@ -40,6 +23,28 @@ vi.mock('worker-timers', () => ({
|
|||||||
clearTimeout: vi.fn()
|
clearTimeout: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('vue-sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
warning: (...args) => mocks.toastWarning(...args),
|
||||||
|
success: (...args) => mocks.toastSuccess(...args),
|
||||||
|
dismiss: (...args) => mocks.toastDismiss(...args)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../shared/utils', () => ({
|
||||||
|
formatDateFilter: (...args) => mocks.formatDateFilter(...args),
|
||||||
|
openExternalLink: (...args) => mocks.openExternalLink(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key) => key
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
function flushPromises() {
|
function flushPromises() {
|
||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
@@ -77,7 +82,7 @@ describe('useVrcStatusStore.getVrcStatus', () => {
|
|||||||
headers: { Referer: 'https://vrcx.app' }
|
headers: { Referer: 'https://vrcx.app' }
|
||||||
});
|
});
|
||||||
expect(store.lastStatus).toBe('Failed to fetch VRC status');
|
expect(store.lastStatus).toBe('Failed to fetch VRC status');
|
||||||
expect(mocks.toast.warning).toHaveBeenCalledTimes(1);
|
expect(store.hasIssue).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetches summary for incident status and appends component summary', async () => {
|
test('fetches summary for incident status and appends component summary', async () => {
|
||||||
@@ -110,7 +115,7 @@ describe('useVrcStatusStore.getVrcStatus', () => {
|
|||||||
);
|
);
|
||||||
expect(store.lastStatus).toBe('Partial System Outage');
|
expect(store.lastStatus).toBe('Partial System Outage');
|
||||||
expect(store.statusText).toBe('Partial System Outage: API, CDN');
|
expect(store.statusText).toBe('Partial System Outage: API, CDN');
|
||||||
expect(mocks.toast.warning).toHaveBeenCalled();
|
expect(store.hasIssue).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clears status when all systems are operational', async () => {
|
test('clears status when all systems are operational', async () => {
|
||||||
@@ -130,3 +135,130 @@ describe('useVrcStatusStore.getVrcStatus', () => {
|
|||||||
expect(store.statusText).toBe('');
|
expect(store.statusText).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('useVrcStatusStore dual-mode notification', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mocks.execute.mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
page: { updated_at: '2026-01-01T00:00:00.000Z' },
|
||||||
|
status: { description: 'All Systems Operational' }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
useVrcStatusStore();
|
||||||
|
await flushPromises();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not show toast before initialized (startup race prevention)', async () => {
|
||||||
|
const store = useVrcStatusStore();
|
||||||
|
// Do NOT call setStatusBarServersVisible — initialized remains false
|
||||||
|
|
||||||
|
mocks.execute
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
page: { updated_at: '2026-01-02T00:00:00.000Z' },
|
||||||
|
status: { description: 'Partial System Outage' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
components: [{ name: 'API', status: 'major_outage' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getVrcStatus();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.toastWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows toast when statusBarServersVisible is false and initialized', async () => {
|
||||||
|
const store = useVrcStatusStore();
|
||||||
|
// Initialize via action (simulates StatusBar onMounted with servers=false)
|
||||||
|
store.setStatusBarServersVisible(false);
|
||||||
|
|
||||||
|
mocks.execute
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
page: { updated_at: '2026-01-02T00:00:00.000Z' },
|
||||||
|
status: { description: 'Partial System Outage' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
components: [{ name: 'API', status: 'major_outage' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getVrcStatus();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.toastWarning).toHaveBeenCalled();
|
||||||
|
expect(mocks.toastWarning.mock.calls[0][0]).toBe(
|
||||||
|
'status_bar.servers_issue'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT show toast when statusBarServersVisible is true', async () => {
|
||||||
|
const store = useVrcStatusStore();
|
||||||
|
store.setStatusBarServersVisible(true);
|
||||||
|
|
||||||
|
mocks.execute
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
page: { updated_at: '2026-01-02T00:00:00.000Z' },
|
||||||
|
status: { description: 'Partial System Outage' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
components: [{ name: 'API', status: 'major_outage' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getVrcStatus();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.toastWarning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggers toast when switching from StatusBar mode to toast mode with active issue', async () => {
|
||||||
|
const store = useVrcStatusStore();
|
||||||
|
store.setStatusBarServersVisible(true);
|
||||||
|
|
||||||
|
// Create an issue while in StatusBar mode
|
||||||
|
mocks.execute
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
page: { updated_at: '2026-01-02T00:00:00.000Z' },
|
||||||
|
status: { description: 'Major Outage' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 200,
|
||||||
|
data: JSON.stringify({
|
||||||
|
components: [{ name: 'API', status: 'major_outage' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getVrcStatus();
|
||||||
|
await flushPromises();
|
||||||
|
expect(mocks.toastWarning).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Switch to toast mode - should trigger notification
|
||||||
|
store.setStatusBarServersVisible(false);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mocks.toastWarning).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+91
-29
@@ -1,4 +1,4 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@@ -17,10 +17,15 @@ export const useVrcStatusStore = defineStore('VrcStatus', () => {
|
|||||||
const lastStatusSummary = ref('');
|
const lastStatusSummary = ref('');
|
||||||
const lastTimeFetched = ref(0);
|
const lastTimeFetched = ref(0);
|
||||||
const pollingInterval = ref(0);
|
const pollingInterval = ref(0);
|
||||||
|
|
||||||
|
const statusBarServersVisible = ref(false);
|
||||||
|
const initialized = ref(false);
|
||||||
|
|
||||||
const alertRef = ref(null);
|
const alertRef = ref(null);
|
||||||
|
const lastStatusText = ref('');
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const lastStatusText = ref('');
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
if (lastStatus.value && lastStatusSummary.value) {
|
if (lastStatus.value && lastStatusSummary.value) {
|
||||||
return `${lastStatus.value}: ${lastStatusSummary.value}`;
|
return `${lastStatus.value}: ${lastStatusSummary.value}`;
|
||||||
@@ -28,6 +33,11 @@ export const useVrcStatusStore = defineStore('VrcStatus', () => {
|
|||||||
return lastStatus.value;
|
return lastStatus.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasIssue = computed(() => !!lastStatus.value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
function dismissAlert() {
|
function dismissAlert() {
|
||||||
if (!alertRef.value) {
|
if (!alertRef.value) {
|
||||||
return;
|
return;
|
||||||
@@ -36,30 +46,32 @@ export const useVrcStatusStore = defineStore('VrcStatus', () => {
|
|||||||
alertRef.value = null;
|
alertRef.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAlert() {
|
/**
|
||||||
if (lastStatusText.value === statusText.value) {
|
* @returns {void}
|
||||||
return;
|
*/
|
||||||
}
|
function openStatusPage() {
|
||||||
lastStatusText.value = statusText.value;
|
openExternalLink('https://status.vrchat.com');
|
||||||
|
}
|
||||||
|
|
||||||
if (!statusText.value) {
|
/**
|
||||||
if (alertRef.value) {
|
* @param {boolean} visible
|
||||||
dismissAlert();
|
* @returns {void}
|
||||||
alertRef.value = toast.success(t('status.title'), {
|
*/
|
||||||
description: `${formatDateFilter(lastStatusTime.value, 'short')}: All Systems Operational`,
|
function setStatusBarServersVisible(visible) {
|
||||||
position: 'bottom-right',
|
statusBarServersVisible.value = visible;
|
||||||
action: {
|
if (!initialized.value) {
|
||||||
label: 'Open',
|
initialized.value = true;
|
||||||
onClick: () => openStatusPage()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function showWarningToast(text) {
|
||||||
dismissAlert();
|
dismissAlert();
|
||||||
alertRef.value = toast.warning(t('status.title'), {
|
alertRef.value = toast.warning(t('status_bar.servers_issue'), {
|
||||||
description: `${formatDateFilter(lastStatusTime.value, 'short')}: ${statusText.value}`,
|
description: `${formatDateFilter(lastStatusTime.value, 'short')}: ${text}`,
|
||||||
duration: Infinity,
|
duration: Infinity,
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
@@ -70,10 +82,49 @@ export const useVrcStatusStore = defineStore('VrcStatus', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openStatusPage() {
|
watch(statusText, (newVal) => {
|
||||||
openExternalLink('https://status.vrchat.com');
|
if (statusBarServersVisible.value || !initialized.value) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastStatusText.value === newVal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastStatusText.value = newVal;
|
||||||
|
|
||||||
|
if (!newVal) {
|
||||||
|
if (alertRef.value) {
|
||||||
|
dismissAlert();
|
||||||
|
alertRef.value = toast.success(t('status_bar.servers_issue'), {
|
||||||
|
description: `${formatDateFilter(lastStatusTime.value, 'short')}: ${t('status_bar.servers_ok')}`,
|
||||||
|
position: 'bottom-right',
|
||||||
|
action: {
|
||||||
|
label: 'Open',
|
||||||
|
onClick: () => openStatusPage()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showWarningToast(newVal);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(statusBarServersVisible, (visible) => {
|
||||||
|
if (!visible && hasIssue.value && statusText.value) {
|
||||||
|
lastStatusText.value = '';
|
||||||
|
showWarningToast(statusText.value);
|
||||||
|
lastStatusText.value = statusText.value;
|
||||||
|
}
|
||||||
|
if (visible) {
|
||||||
|
dismissAlert();
|
||||||
|
lastStatusText.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async function getVrcStatus() {
|
async function getVrcStatus() {
|
||||||
const response = await webApiService.execute({
|
const response = await webApiService.execute({
|
||||||
url: `${vrcStatusApiUrl}/status.json`,
|
url: `${vrcStatusApiUrl}/status.json`,
|
||||||
@@ -87,7 +138,6 @@ export const useVrcStatusStore = defineStore('VrcStatus', () => {
|
|||||||
console.error('Failed to fetch VRChat status', response);
|
console.error('Failed to fetch VRChat status', response);
|
||||||
lastStatus.value = 'Failed to fetch VRC status';
|
lastStatus.value = 'Failed to fetch VRC status';
|
||||||
pollingInterval.value = 2 * 60 * 1000; // 2 minutes
|
pollingInterval.value = 2 * 60 * 1000; // 2 minutes
|
||||||
updateAlert();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = JSON.parse(response.data);
|
const data = JSON.parse(response.data);
|
||||||
@@ -95,15 +145,16 @@ export const useVrcStatusStore = defineStore('VrcStatus', () => {
|
|||||||
if (data.status.description === 'All Systems Operational') {
|
if (data.status.description === 'All Systems Operational') {
|
||||||
lastStatus.value = '';
|
lastStatus.value = '';
|
||||||
pollingInterval.value = 15 * 60 * 1000; // 15 minutes
|
pollingInterval.value = 15 * 60 * 1000; // 15 minutes
|
||||||
updateAlert();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastStatus.value = data.status.description;
|
lastStatus.value = data.status.description;
|
||||||
pollingInterval.value = 2 * 60 * 1000; // 2 minutes
|
pollingInterval.value = 2 * 60 * 1000; // 2 minutes
|
||||||
updateAlert();
|
|
||||||
getVrcStatusSummary();
|
getVrcStatusSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
async function getVrcStatusSummary() {
|
async function getVrcStatusSummary() {
|
||||||
const response = await webApiService.execute({
|
const response = await webApiService.execute({
|
||||||
url: `${vrcStatusApiUrl}/summary.json`,
|
url: `${vrcStatusApiUrl}/summary.json`,
|
||||||
@@ -127,16 +178,21 @@ export const useVrcStatusStore = defineStore('VrcStatus', () => {
|
|||||||
summary = summary.slice(0, -2);
|
summary = summary.slice(0, -2);
|
||||||
}
|
}
|
||||||
lastStatusSummary.value = summary;
|
lastStatusSummary.value = summary;
|
||||||
updateAlert();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ran from Cef and Electron when browser is focused
|
// ran from Cef and Electron when browser is focused
|
||||||
|
/**
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
function onBrowserFocus() {
|
function onBrowserFocus() {
|
||||||
if (Date.now() - lastTimeFetched.value > 60 * 1000) {
|
if (Date.now() - lastTimeFetched.value > 60 * 1000) {
|
||||||
getVrcStatus();
|
getVrcStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
getVrcStatus();
|
getVrcStatus();
|
||||||
workerTimers.setInterval(() => {
|
workerTimers.setInterval(() => {
|
||||||
@@ -150,7 +206,13 @@ export const useVrcStatusStore = defineStore('VrcStatus', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
lastStatus,
|
lastStatus,
|
||||||
|
lastStatusTime,
|
||||||
|
lastStatusSummary,
|
||||||
statusText,
|
statusText,
|
||||||
|
hasIssue,
|
||||||
|
statusBarServersVisible,
|
||||||
|
setStatusBarServersVisible,
|
||||||
|
openStatusPage,
|
||||||
onBrowserFocus,
|
onBrowserFocus,
|
||||||
getVrcStatus
|
getVrcStatus
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user