feat: continued post qa for servers in app (#5818)
* fix: intercom in app * feat: Logs.vue dynamic console resizing with window + padding problem * fix: search highlight with decorator + change to be better * fix: qa * fix: allow paper+purpur into app csp * fix: lint
This commit is contained in:
@@ -60,6 +60,7 @@ import { useQuery } from '@tanstack/vue-query'
|
|||||||
import { getVersion } from '@tauri-apps/api/app'
|
import { getVersion } from '@tauri-apps/api/app'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { type } from '@tauri-apps/plugin-os'
|
import { type } from '@tauri-apps/plugin-os'
|
||||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||||
@@ -446,7 +447,7 @@ router.afterEach((to, from, failure) => {
|
|||||||
failed: failure,
|
failed: failure,
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!suspensePending) {
|
if (!suspensePending && stateInitialized.value) {
|
||||||
loading.stopLoading()
|
loading.stopLoading()
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
@@ -504,9 +505,27 @@ setupAuthProvider(credentials, async (_redirectPath) => {
|
|||||||
await signIn()
|
await signIn()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function validateSession(sessionToken) {
|
||||||
|
try {
|
||||||
|
const response = await tauriFetch(`${config.labrinthBaseUrl}/v2/user`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: sessionToken },
|
||||||
|
})
|
||||||
|
if (response.status === 401) return false
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCredentials() {
|
async function fetchCredentials() {
|
||||||
const creds = await getCreds().catch(handleError)
|
const creds = await getCreds().catch(handleError)
|
||||||
if (creds && creds.user_id) {
|
if (creds && creds.user_id) {
|
||||||
|
if (creds.session && !(await validateSession(creds.session))) {
|
||||||
|
await logout().catch(handleError)
|
||||||
|
credentials.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
|
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
|
||||||
}
|
}
|
||||||
credentials.value = creds ?? null
|
credentials.value = creds ?? null
|
||||||
|
|||||||
@@ -100,24 +100,38 @@ const loadingProgress = ref(0)
|
|||||||
const hidden = ref(false)
|
const hidden = ref(false)
|
||||||
const message = ref()
|
const message = ref()
|
||||||
|
|
||||||
|
// const MIN_DISPLAY_MS = 1000
|
||||||
|
// const mountedAt = Date.now()
|
||||||
|
|
||||||
const loading = useLoading()
|
const loading = useLoading()
|
||||||
|
|
||||||
watch(loading, (newValue) => {
|
watch(
|
||||||
if (!newValue.barEnabled) {
|
loading,
|
||||||
if (loading.loading) {
|
(newValue) => {
|
||||||
loadingProgress.value = 0
|
if (!newValue.barEnabled) {
|
||||||
fakeLoadingIncrease()
|
if (loading.loading) {
|
||||||
} else {
|
loadingProgress.value = 0
|
||||||
loadingProgress.value = 100
|
fakeLoadingIncrease()
|
||||||
doneLoading.value = true
|
} else {
|
||||||
|
// const elapsed = Date.now() - mountedAt
|
||||||
|
// const delay = Math.max(0, MIN_DISPLAY_MS - elapsed)
|
||||||
|
|
||||||
setTimeout(() => {
|
// setTimeout(() => {
|
||||||
hidden.value = true
|
// if (loading.loading) return
|
||||||
loading.setEnabled(true)
|
|
||||||
}, 50)
|
loadingProgress.value = 100
|
||||||
|
doneLoading.value = true
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
hidden.value = true
|
||||||
|
loading.setEnabled(true)
|
||||||
|
}, 50)
|
||||||
|
// }, delay)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
})
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
function fakeLoadingIncrease() {
|
function fakeLoadingIncrease() {
|
||||||
if (loadingProgress.value < 95) {
|
if (loadingProgress.value < 95) {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full w-full py-6">
|
<div class="h-full w-full pt-6">
|
||||||
<ServersManageRootLayout
|
<ServersManageRootLayout
|
||||||
:server-id="serverId"
|
:server-id="serverId"
|
||||||
:reload-page="() => router.go(0)"
|
:reload-page="() => router.go(0)"
|
||||||
:resolve-viewer="resolveViewer"
|
:resolve-viewer="resolveViewer"
|
||||||
:show-copy-id-action="themeStore.devMode"
|
:show-copy-id-action="themeStore.devMode"
|
||||||
|
:auth-user="authUser"
|
||||||
|
:fetch-intercom-token="fetchIntercomToken"
|
||||||
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
|
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
|
||||||
:navigate-to-servers="() => router.push('/hosting/manage')"
|
:navigate-to-servers="() => router.push('/hosting/manage')"
|
||||||
:browse-modpacks="
|
:browse-modpacks="
|
||||||
@@ -48,10 +50,12 @@
|
|||||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||||
import { injectAuth, LoadingIndicator, ServersManageRootLayout } from '@modrinth/ui'
|
import { injectAuth, LoadingIndicator, ServersManageRootLayout } from '@modrinth/ui'
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { computed, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { config } from '@/config'
|
||||||
import { get_user } from '@/helpers/cache'
|
import { get_user } from '@/helpers/cache'
|
||||||
import { get as getCreds } from '@/helpers/mr_auth'
|
import { get as getCreds } from '@/helpers/mr_auth'
|
||||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||||
@@ -97,6 +101,37 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const authUser = computed(() => {
|
||||||
|
const user = auth.user.value
|
||||||
|
if (!user?.id) return undefined
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email ?? '',
|
||||||
|
created: user.created,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchIntercomToken(): Promise<{ token: string }> {
|
||||||
|
const credentials = await getCreds()
|
||||||
|
if (!credentials?.session) {
|
||||||
|
throw new Error('Not authenticated')
|
||||||
|
}
|
||||||
|
const response = await tauriFetch(
|
||||||
|
`${config.siteUrl}/api/intercom/messenger-jwt?server_id=${encodeURIComponent(serverId.value)}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${credentials.session}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch Intercom token: ${response.status}`)
|
||||||
|
}
|
||||||
|
return (await response.json()) as { token: string }
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
|
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
|
||||||
const credentials = await getCreds().catch(() => null)
|
const credentials = await getCreds().catch(() => null)
|
||||||
if (!credentials?.user_id) {
|
if (!credentials?.user_id) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="instance">
|
<div v-if="instance" class="flex h-full flex-col">
|
||||||
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
|
<div
|
||||||
|
class="shrink-0 p-6 pr-2 pb-4"
|
||||||
|
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
|
||||||
|
>
|
||||||
<ExportModal ref="exportModal" :instance="instance" />
|
<ExportModal ref="exportModal" :instance="instance" />
|
||||||
<InstanceSettingsModal
|
<InstanceSettingsModal
|
||||||
:key="instance.path"
|
:key="instance.path"
|
||||||
@@ -205,10 +208,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</ContentPageHeader>
|
</ContentPageHeader>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6">
|
<div class="shrink-0 px-6">
|
||||||
<NavTabs :links="tabs" />
|
<NavTabs :links="tabs" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!!instance" class="p-6 pt-4">
|
<div v-if="!!instance" class="min-h-0 flex-1 overflow-y-auto p-6 pt-4">
|
||||||
<RouterView
|
<RouterView
|
||||||
v-if="route.path.startsWith('/instance')"
|
v-if="route.path.startsWith('/instance')"
|
||||||
v-slot="{ Component }"
|
v-slot="{ Component }"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
"capabilities": ["ads", "core", "plugins"],
|
"capabilities": ["ads", "core", "plugins"],
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": "'self' customprotocol: asset:",
|
"default-src": "'self' customprotocol: asset:",
|
||||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net 'self' data: blob:",
|
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
|
||||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
|
|||||||
@@ -57,7 +57,11 @@ export default defineEventHandler(async (event): Promise<IntercomTokenResponse>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const authToken = getCookie(event, 'auth-token')
|
const authHeader = getRequestHeader(event, 'authorization')
|
||||||
|
const bearerToken = authHeader?.toLowerCase().startsWith('bearer ')
|
||||||
|
? authHeader.slice(7).trim()
|
||||||
|
: undefined
|
||||||
|
const authToken = bearerToken || getCookie(event, 'auth-token')
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex w-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
|
class="flex h-full w-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
|
||||||
:style="!fullscreen && componentHeight ? { minHeight: componentHeight + 'px' } : {}"
|
|
||||||
:class="{ 'h-full': fullscreen }"
|
|
||||||
>
|
>
|
||||||
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
|
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
|
||||||
<div ref="containerRef" class="size-full" />
|
<div ref="containerRef" class="size-full" />
|
||||||
@@ -93,7 +91,6 @@ const containerRef = ref<HTMLElement | null>(null)
|
|||||||
const wrapperRef = ref<HTMLElement | null>(null)
|
const wrapperRef = ref<HTMLElement | null>(null)
|
||||||
const inputRef = ref<HTMLElement | null>(null)
|
const inputRef = ref<HTMLElement | null>(null)
|
||||||
const commandInput = ref('')
|
const commandInput = ref('')
|
||||||
const componentHeight = ref(0)
|
|
||||||
|
|
||||||
const snappedHeight = ref<number | null>(null)
|
const snappedHeight = ref<number | null>(null)
|
||||||
|
|
||||||
@@ -114,14 +111,10 @@ const {
|
|||||||
scrollback: props.scrollback,
|
scrollback: props.scrollback,
|
||||||
onReady: (term) => {
|
onReady: (term) => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updateComponentHeight()
|
|
||||||
snapToRows()
|
snapToRows()
|
||||||
})
|
})
|
||||||
emit('ready', term)
|
emit('ready', term)
|
||||||
},
|
},
|
||||||
onResize: () => {
|
|
||||||
updateComponentHeight()
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function writeEmptyState() {
|
function writeEmptyState() {
|
||||||
@@ -175,12 +168,21 @@ function handleWindowResize() {
|
|||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDocumentPointerDown(event: PointerEvent) {
|
||||||
|
if (!terminal.value?.hasSelection()) return
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (target && containerRef.value?.contains(target)) return
|
||||||
|
terminal.value.clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('resize', handleWindowResize)
|
window.addEventListener('resize', handleWindowResize)
|
||||||
|
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', handleWindowResize)
|
window.removeEventListener('resize', handleWindowResize)
|
||||||
|
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||||
if (resizeDebounce) clearTimeout(resizeDebounce)
|
if (resizeDebounce) clearTimeout(resizeDebounce)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -199,20 +201,10 @@ watch(
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
snappedHeight.value = null
|
snappedHeight.value = null
|
||||||
componentHeight.value = 0
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function updateComponentHeight() {
|
|
||||||
const screen = containerRef.value?.querySelector('.xterm-screen') as HTMLElement | null
|
|
||||||
if (!screen) return
|
|
||||||
const screenH = screen.offsetHeight
|
|
||||||
const inputH = inputRef.value?.offsetHeight ?? 0
|
|
||||||
const borderW = 2
|
|
||||||
componentHeight.value = screenH + getWrapperMargins() + inputH + borderW
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitCommand = () => {
|
const submitCommand = () => {
|
||||||
const cmd = commandInput.value.trim()
|
const cmd = commandInput.value.trim()
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ const appendGraphData = (dataArray: number[], newValue: number): number[] => {
|
|||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STALE_STATS_THRESHOLD_MS = 5000
|
||||||
|
const STALE_STATS_PUSH_INTERVAL_MS = 1000
|
||||||
|
|
||||||
const mapPowerStateFromStateEvent = (
|
const mapPowerStateFromStateEvent = (
|
||||||
data: Archon.Websocket.v0.WSStateEvent,
|
data: Archon.Websocket.v0.WSStateEvent,
|
||||||
): Archon.Websocket.v0.PowerState => {
|
): Archon.Websocket.v0.PowerState => {
|
||||||
@@ -101,6 +104,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
|||||||
const ramData = ref<number[]>([])
|
const ramData = ref<number[]>([])
|
||||||
|
|
||||||
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let staleStatsIntervalId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const markBackupCancelled =
|
const markBackupCancelled =
|
||||||
options.markBackupCancelled ??
|
options.markBackupCancelled ??
|
||||||
@@ -183,6 +188,43 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearStaleStatsTimers = () => {
|
||||||
|
if (staleStatsTimeoutId) {
|
||||||
|
clearTimeout(staleStatsTimeoutId)
|
||||||
|
staleStatsTimeoutId = null
|
||||||
|
}
|
||||||
|
if (staleStatsIntervalId) {
|
||||||
|
clearInterval(staleStatsIntervalId)
|
||||||
|
staleStatsIntervalId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushZeroStats = () => {
|
||||||
|
if (!shouldProcessEvent()) return
|
||||||
|
cpuData.value = appendGraphData(cpuData.value, 0)
|
||||||
|
ramData.value = appendGraphData(ramData.value, 0)
|
||||||
|
stats.value = {
|
||||||
|
current: {
|
||||||
|
...stats.value.current,
|
||||||
|
cpu_percent: 0,
|
||||||
|
ram_usage_bytes: 0,
|
||||||
|
},
|
||||||
|
past: { ...stats.value.current },
|
||||||
|
graph: {
|
||||||
|
cpu: cpuData.value,
|
||||||
|
ram: ramData.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const armStaleStatsWatchdog = () => {
|
||||||
|
clearStaleStatsTimers()
|
||||||
|
staleStatsTimeoutId = setTimeout(() => {
|
||||||
|
pushZeroStats()
|
||||||
|
staleStatsIntervalId = setInterval(pushZeroStats, STALE_STATS_PUSH_INTERVAL_MS)
|
||||||
|
}, STALE_STATS_THRESHOLD_MS)
|
||||||
|
}
|
||||||
|
|
||||||
const updatePowerState = (
|
const updatePowerState = (
|
||||||
state: Archon.Websocket.v0.PowerState,
|
state: Archon.Websocket.v0.PowerState,
|
||||||
details?: { oom_killed?: boolean; exit_code?: number },
|
details?: { oom_killed?: boolean; exit_code?: number },
|
||||||
@@ -209,6 +251,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => {
|
const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => {
|
||||||
|
armStaleStatsWatchdog()
|
||||||
updateStats({
|
updateStats({
|
||||||
cpu_percent: data.cpu_percent,
|
cpu_percent: data.cpu_percent,
|
||||||
ram_usage_bytes: data.ram_usage_bytes,
|
ram_usage_bytes: data.ram_usage_bytes,
|
||||||
@@ -280,6 +323,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
|||||||
}
|
}
|
||||||
|
|
||||||
stopUptimeTicker()
|
stopUptimeTicker()
|
||||||
|
clearStaleStatsTimers()
|
||||||
connectedSocketServerId.value = null
|
connectedSocketServerId.value = null
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
isWsAuthIncorrect.value = false
|
isWsAuthIncorrect.value = false
|
||||||
|
|||||||
@@ -193,6 +193,17 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
|||||||
term.options.disableStdin = true
|
term.options.disableStdin = true
|
||||||
term.write('\x1b[?25l')
|
term.write('\x1b[?25l')
|
||||||
|
|
||||||
|
// term.attachCustomKeyEventHandler((e) => {
|
||||||
|
// if (e.type !== 'keydown') return true
|
||||||
|
// const mod = e.ctrlKey || e.metaKey
|
||||||
|
// if (!mod) return true
|
||||||
|
// const key = e.key.toLowerCase()
|
||||||
|
// if (key === 'c' || key === 'insert' || key === 'a') {
|
||||||
|
// return false
|
||||||
|
// }
|
||||||
|
// return true
|
||||||
|
// })
|
||||||
|
|
||||||
wheelHandler = (e: WheelEvent) => {
|
wheelHandler = (e: WheelEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export function useServerImage(
|
|||||||
|
|
||||||
const { data: remoteImage, refetch } = useQuery({
|
const { data: remoteImage, refetch } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async (): Promise<string | null | undefined> => {
|
queryFn: async (): Promise<string | null> => {
|
||||||
if (!serverId) return undefined
|
if (!serverId) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
||||||
@@ -84,21 +84,21 @@ export function useServerImage(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug('Server image fetch failed:', error)
|
console.debug('Server image fetch failed:', error)
|
||||||
return undefined
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!includeProjectFallback || !upstream.value?.project_id) return undefined
|
if (!includeProjectFallback || !upstream.value?.project_id) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const project = await client.labrinth.projects_v2.get(upstream.value.project_id)
|
const project = await client.labrinth.projects_v2.get(upstream.value.project_id)
|
||||||
if (!project.icon_url) return undefined
|
if (!project.icon_url) return null
|
||||||
const response = await fetch(project.icon_url)
|
const response = await fetch(project.icon_url)
|
||||||
if (!response.ok) return undefined
|
if (!response.ok) return null
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
return await processImageBlob(blob, iconSize)
|
return await processImageBlob(blob, iconSize)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.debug('Project icon fallback failed:', error)
|
console.debug('Project icon fallback failed:', error)
|
||||||
return undefined
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled: isEnabled,
|
enabled: isEnabled,
|
||||||
|
|||||||
@@ -1,43 +1,162 @@
|
|||||||
import type { Terminal } from '@xterm/xterm'
|
import type { IDecoration, Terminal } from '@xterm/xterm'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import type { LogLevel, LogLine } from '../types'
|
import type { LogLevel, LogLine } from '../types'
|
||||||
|
|
||||||
export type FilterPredicate = (line: LogLine) => boolean
|
export type FilterPredicate = (line: LogLine) => boolean
|
||||||
|
|
||||||
function highlightMatches(text: string, query: string): string {
|
export function colorize(line: LogLine, _searchQuery?: string): string {
|
||||||
if (!query) return text
|
|
||||||
const lower = text.toLowerCase()
|
|
||||||
let result = ''
|
|
||||||
let pos = 0
|
|
||||||
while (pos < text.length) {
|
|
||||||
const idx = lower.indexOf(query, pos)
|
|
||||||
if (idx === -1) {
|
|
||||||
result += text.slice(pos)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
result += text.slice(pos, idx)
|
|
||||||
result += `\x1b[1;7m${text.slice(idx, idx + query.length)}\x1b[27;22m`
|
|
||||||
pos = idx + query.length
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export function colorize(line: LogLine, searchQuery?: string): string {
|
|
||||||
const text = searchQuery ? highlightMatches(line.text, searchQuery) : line.text
|
|
||||||
switch (line.level) {
|
switch (line.level) {
|
||||||
case 'error':
|
case 'error':
|
||||||
return `\x1b[31;40m${text}\x1b[K\x1b[0m`
|
return `\x1b[31;40m${line.text}\x1b[K\x1b[0m`
|
||||||
case 'warn':
|
case 'warn':
|
||||||
return `\x1b[33;40m${text}\x1b[K\x1b[0m`
|
return `\x1b[33;40m${line.text}\x1b[K\x1b[0m`
|
||||||
case 'debug':
|
case 'debug':
|
||||||
case 'trace':
|
case 'trace':
|
||||||
return `\x1b[90m${text}\x1b[0m`
|
return `\x1b[90m${line.text}\x1b[0m`
|
||||||
default:
|
default:
|
||||||
return text
|
return line.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HIGHLIGHT_BG = '#ffd60a'
|
||||||
|
const HIGHLIGHT_FG = '#000000'
|
||||||
|
|
||||||
|
const terminalDecorations = new WeakMap<Terminal, IDecoration[]>()
|
||||||
|
const activeQueries = new WeakMap<Terminal, string>()
|
||||||
|
const highlightVersions = new WeakMap<Terminal, number>()
|
||||||
|
|
||||||
|
function getDecorationList(terminal: Terminal): IDecoration[] {
|
||||||
|
let list = terminalDecorations.get(terminal)
|
||||||
|
if (!list) {
|
||||||
|
list = []
|
||||||
|
terminalDecorations.set(terminal, list)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpVersion(terminal: Terminal): number {
|
||||||
|
const next = (highlightVersions.get(terminal) ?? 0) + 1
|
||||||
|
highlightVersions.set(terminal, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHighlightVersion(terminal: Terminal): number {
|
||||||
|
return highlightVersions.get(terminal) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSearchHighlights(terminal: Terminal) {
|
||||||
|
const existing = terminalDecorations.get(terminal)
|
||||||
|
if (existing) {
|
||||||
|
for (const d of existing) d.dispose()
|
||||||
|
existing.length = 0
|
||||||
|
}
|
||||||
|
activeQueries.delete(terminal)
|
||||||
|
bumpVersion(terminal)
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkBackToLogicalStart(terminal: Terminal, row: number): number {
|
||||||
|
const buffer = terminal.buffer.active
|
||||||
|
let y = Math.max(0, row)
|
||||||
|
while (y > 0) {
|
||||||
|
const line = buffer.getLine(y)
|
||||||
|
if (!line?.isWrapped) break
|
||||||
|
y--
|
||||||
|
}
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanRange(
|
||||||
|
terminal: Terminal,
|
||||||
|
query: string,
|
||||||
|
startRow: number,
|
||||||
|
endRow: number,
|
||||||
|
out: IDecoration[],
|
||||||
|
) {
|
||||||
|
const buffer = terminal.buffer.active
|
||||||
|
const cols = terminal.cols
|
||||||
|
const cursorAbsolute = buffer.baseY + buffer.cursorY
|
||||||
|
let y = startRow
|
||||||
|
while (y <= endRow) {
|
||||||
|
const head = buffer.getLine(y)
|
||||||
|
if (!head) break
|
||||||
|
const lineStart = y
|
||||||
|
let text = head.translateToString(false)
|
||||||
|
y++
|
||||||
|
while (y < buffer.length) {
|
||||||
|
const next = buffer.getLine(y)
|
||||||
|
if (!next?.isWrapped) break
|
||||||
|
text += next.translateToString(false)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
let pos = 0
|
||||||
|
while (true) {
|
||||||
|
const idx = lower.indexOf(query, pos)
|
||||||
|
if (idx === -1) break
|
||||||
|
let remaining = query.length
|
||||||
|
let rowAbs = lineStart + Math.floor(idx / cols)
|
||||||
|
let col = idx % cols
|
||||||
|
while (remaining > 0) {
|
||||||
|
const amount = Math.min(cols - col, remaining)
|
||||||
|
const marker = terminal.registerMarker(rowAbs - cursorAbsolute)
|
||||||
|
if (marker) {
|
||||||
|
const decoration = terminal.registerDecoration({
|
||||||
|
marker,
|
||||||
|
x: col,
|
||||||
|
width: amount,
|
||||||
|
layer: 'top',
|
||||||
|
backgroundColor: HIGHLIGHT_BG,
|
||||||
|
foregroundColor: HIGHLIGHT_FG,
|
||||||
|
})
|
||||||
|
if (decoration) out.push(decoration)
|
||||||
|
}
|
||||||
|
remaining -= amount
|
||||||
|
rowAbs++
|
||||||
|
col = 0
|
||||||
|
}
|
||||||
|
pos = idx + query.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySearchHighlights(terminal: Terminal, query: string): number {
|
||||||
|
const trimmed = query.trim().toLowerCase()
|
||||||
|
const list = getDecorationList(terminal)
|
||||||
|
for (const d of list) d.dispose()
|
||||||
|
list.length = 0
|
||||||
|
const version = bumpVersion(terminal)
|
||||||
|
if (!trimmed) {
|
||||||
|
activeQueries.delete(terminal)
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
activeQueries.set(terminal, trimmed)
|
||||||
|
const endRow = terminal.buffer.active.length - 1
|
||||||
|
scanRange(terminal, trimmed, 0, endRow, list)
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightAppendedRange(terminal: Terminal, fromRow: number, version: number) {
|
||||||
|
if (getHighlightVersion(terminal) !== version) return
|
||||||
|
const query = activeQueries.get(terminal)
|
||||||
|
if (!query) return
|
||||||
|
const scanFrom = walkBackToLogicalStart(terminal, fromRow)
|
||||||
|
const list = getDecorationList(terminal)
|
||||||
|
const survivors: IDecoration[] = []
|
||||||
|
for (const d of list) {
|
||||||
|
if (d.marker.line >= scanFrom) {
|
||||||
|
d.dispose()
|
||||||
|
} else {
|
||||||
|
survivors.push(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list.length = 0
|
||||||
|
list.push(...survivors)
|
||||||
|
const endRow = terminal.buffer.active.length - 1
|
||||||
|
if (scanFrom > endRow) return
|
||||||
|
scanRange(terminal, query, scanFrom, endRow, list)
|
||||||
|
}
|
||||||
|
|
||||||
export type ConditionalLevel = 'debug' | 'trace'
|
export type ConditionalLevel = 'debug' | 'trace'
|
||||||
|
|
||||||
export function useConsoleFilters() {
|
export function useConsoleFilters() {
|
||||||
@@ -80,18 +199,23 @@ export function rewriteTerminal(
|
|||||||
searchQuery?: string,
|
searchQuery?: string,
|
||||||
callback?: () => void,
|
callback?: () => void,
|
||||||
) {
|
) {
|
||||||
|
clearSearchHighlights(terminal)
|
||||||
terminal.reset()
|
terminal.reset()
|
||||||
terminal.write('\x1b[?25l')
|
terminal.write('\x1b[?25l')
|
||||||
|
|
||||||
const filtered = predicate ? allLines.filter(predicate) : allLines
|
const filtered = predicate ? allLines.filter(predicate) : allLines
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
|
if (searchQuery) applySearchHighlights(terminal, searchQuery)
|
||||||
callback?.()
|
callback?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.write('\x1b[?2026h')
|
terminal.write('\x1b[?2026h')
|
||||||
terminal.write(filtered.map((line) => colorize(line, searchQuery)).join('\r\n'), () => {
|
terminal.write(filtered.map((line) => colorize(line)).join('\r\n'), () => {
|
||||||
terminal.write('\x1b[?2026l')
|
terminal.write('\x1b[?2026l')
|
||||||
|
if (searchQuery) {
|
||||||
|
applySearchHighlights(terminal, searchQuery)
|
||||||
|
}
|
||||||
callback?.()
|
callback?.()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
export {
|
export {
|
||||||
|
applySearchHighlights,
|
||||||
|
clearSearchHighlights,
|
||||||
colorize,
|
colorize,
|
||||||
type ConditionalLevel,
|
type ConditionalLevel,
|
||||||
type FilterPredicate,
|
type FilterPredicate,
|
||||||
|
getHighlightVersion,
|
||||||
|
highlightAppendedRange,
|
||||||
rewriteTerminal,
|
rewriteTerminal,
|
||||||
useConsoleFilters,
|
useConsoleFilters,
|
||||||
} from './console-filtering'
|
} from './console-filtering'
|
||||||
|
|||||||
@@ -111,7 +111,14 @@ import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
|
|||||||
|
|
||||||
import ConsoleActionButtons from './components/ConsoleActionButtons.vue'
|
import ConsoleActionButtons from './components/ConsoleActionButtons.vue'
|
||||||
import ConsoleFilterPills from './components/ConsoleFilterPills.vue'
|
import ConsoleFilterPills from './components/ConsoleFilterPills.vue'
|
||||||
import { colorize, rewriteTerminal, useConsoleFilters } from './composables'
|
import {
|
||||||
|
clearSearchHighlights,
|
||||||
|
colorize,
|
||||||
|
getHighlightVersion,
|
||||||
|
highlightAppendedRange,
|
||||||
|
rewriteTerminal,
|
||||||
|
useConsoleFilters,
|
||||||
|
} from './composables'
|
||||||
import type { ConditionalLevel } from './composables/console-filtering'
|
import type { ConditionalLevel } from './composables/console-filtering'
|
||||||
import { injectConsoleManager } from './providers'
|
import { injectConsoleManager } from './providers'
|
||||||
import type { LogLevel, LogLine } from './types'
|
import type { LogLevel, LogLine } from './types'
|
||||||
@@ -279,18 +286,21 @@ watch(ctx.logLines, (lines, oldLines) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const predicate = buildCombinedPredicate()
|
const predicate = buildCombinedPredicate()
|
||||||
const query = activeSearchQuery()
|
|
||||||
const newLines: string[] = []
|
const newLines: string[] = []
|
||||||
for (let i = lastWrittenIndex; i < lines.length; i++) {
|
for (let i = lastWrittenIndex; i < lines.length; i++) {
|
||||||
if (!predicate || predicate(lines[i])) {
|
if (!predicate || predicate(lines[i])) {
|
||||||
newLines.push(colorize(lines[i], query))
|
newLines.push(colorize(lines[i]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (newLines.length > 0) {
|
if (newLines.length > 0) {
|
||||||
const buffer = term.buffer.active
|
const buffer = term.buffer.active
|
||||||
const onFreshLine = buffer.cursorX === 0
|
const onFreshLine = buffer.cursorX === 0
|
||||||
const data = onFreshLine ? newLines.join('\r\n') : '\r\n' + newLines.join('\r\n')
|
const data = onFreshLine ? newLines.join('\r\n') : '\r\n' + newLines.join('\r\n')
|
||||||
term.write(data)
|
const fromRow = buffer.baseY + buffer.cursorY
|
||||||
|
const version = getHighlightVersion(term)
|
||||||
|
term.write(data, () => {
|
||||||
|
highlightAppendedRange(term, fromRow, version)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
lastWrittenIndex = lines.length
|
lastWrittenIndex = lines.length
|
||||||
})
|
})
|
||||||
@@ -307,6 +317,8 @@ function handleCommand(cmd: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleClear() {
|
function handleClear() {
|
||||||
|
const term = terminalRef.value?.terminal
|
||||||
|
if (term) clearSearchHighlights(term)
|
||||||
terminalRef.value?.reset()
|
terminalRef.value?.reset()
|
||||||
lastWrittenIndex = 0
|
lastWrittenIndex = 0
|
||||||
ctx.onClear?.()
|
ctx.onClear?.()
|
||||||
|
|||||||
@@ -31,7 +31,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="stat-drop-shadow text-4xl font-bold text-contrast">
|
<span class="stat-drop-shadow text-4xl font-bold text-contrast">
|
||||||
{{ metric.value }}
|
{{ metric.value
|
||||||
|
}}<span
|
||||||
|
v-if="metric.secondary"
|
||||||
|
class="ml-1 text-sm font-normal stat-drop-shadow text-secondary"
|
||||||
|
>{{ metric.secondary }}</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
<!-- <div
|
<!-- <div
|
||||||
class="absolute -left-8 -top-4 -z-10 h-28 w-56 rounded-full bg-surface-3 opacity-50 blur-lg"
|
class="absolute -left-8 -top-4 -z-10 h-28 w-56 rounded-full bg-surface-3 opacity-50 blur-lg"
|
||||||
@@ -88,6 +93,13 @@ const isRamAsBytesForcedByFeatureFlag = computed(
|
|||||||
() => featureFlags?.serverRamAsBytesAlwaysOn?.value ?? false,
|
() => featureFlags?.serverRamAsBytesAlwaysOn?.value ?? false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showRamAsBytes = computed(
|
||||||
|
() =>
|
||||||
|
props.showMemoryAsBytes ||
|
||||||
|
isRamAsBytesForcedByFeatureFlag.value ||
|
||||||
|
userPreferences.value.ramAsNumber,
|
||||||
|
)
|
||||||
|
|
||||||
const stats = shallowRef(
|
const stats = shallowRef(
|
||||||
props.data?.current || {
|
props.data?.current || {
|
||||||
cpu_percent: 0,
|
cpu_percent: 0,
|
||||||
@@ -174,6 +186,7 @@ const metrics = computed(() => {
|
|||||||
const storageMetric = {
|
const storageMetric = {
|
||||||
title: 'Storage',
|
title: 'Storage',
|
||||||
value: props.loading ? '0 B' : formatBytes(stats.value.storage_usage_bytes ?? 0),
|
value: props.loading ? '0 B' : formatBytes(stats.value.storage_usage_bytes ?? 0),
|
||||||
|
secondary: null as string | null,
|
||||||
icon: FolderOpenIcon,
|
icon: FolderOpenIcon,
|
||||||
showGraph: false,
|
showGraph: false,
|
||||||
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
|
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
|
||||||
@@ -186,6 +199,7 @@ const metrics = computed(() => {
|
|||||||
{
|
{
|
||||||
title: 'CPU',
|
title: 'CPU',
|
||||||
value: '0.00%',
|
value: '0.00%',
|
||||||
|
secondary: null as string | null,
|
||||||
icon: CpuIcon,
|
icon: CpuIcon,
|
||||||
showGraph: true,
|
showGraph: true,
|
||||||
chartOptions: cpuChartOptions.value,
|
chartOptions: cpuChartOptions.value,
|
||||||
@@ -195,6 +209,7 @@ const metrics = computed(() => {
|
|||||||
{
|
{
|
||||||
title: 'Memory',
|
title: 'Memory',
|
||||||
value: '0.00%',
|
value: '0.00%',
|
||||||
|
secondary: null as string | null,
|
||||||
icon: DatabaseIcon,
|
icon: DatabaseIcon,
|
||||||
showGraph: true,
|
showGraph: true,
|
||||||
chartOptions: ramChartOptions.value,
|
chartOptions: ramChartOptions.value,
|
||||||
@@ -209,6 +224,7 @@ const metrics = computed(() => {
|
|||||||
{
|
{
|
||||||
title: 'CPU',
|
title: 'CPU',
|
||||||
value: `${cpuPercent.value.toFixed(2)}%`,
|
value: `${cpuPercent.value.toFixed(2)}%`,
|
||||||
|
secondary: null as string | null,
|
||||||
icon: CpuIcon,
|
icon: CpuIcon,
|
||||||
showGraph: true,
|
showGraph: true,
|
||||||
chartOptions: cpuChartOptions.value,
|
chartOptions: cpuChartOptions.value,
|
||||||
@@ -217,12 +233,12 @@ const metrics = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Memory',
|
title: 'Memory',
|
||||||
value:
|
value: showRamAsBytes.value
|
||||||
props.showMemoryAsBytes ||
|
? formatBytes(stats.value.ram_usage_bytes ?? 0)
|
||||||
isRamAsBytesForcedByFeatureFlag.value ||
|
: `${ramPercent.value.toFixed(2)}%`,
|
||||||
userPreferences.value.ramAsNumber
|
secondary: showRamAsBytes.value
|
||||||
? formatBytes(stats.value.ram_usage_bytes ?? 0)
|
? `/ ${formatBytes(stats.value.ram_total_bytes ?? 0)}`
|
||||||
: `${ramPercent.value.toFixed(2)}%`,
|
: (null as string | null),
|
||||||
icon: DatabaseIcon,
|
icon: DatabaseIcon,
|
||||||
showGraph: true,
|
showGraph: true,
|
||||||
chartOptions: ramChartOptions.value,
|
chartOptions: ramChartOptions.value,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<div
|
<div
|
||||||
v-else-if="serverData"
|
v-else-if="serverData"
|
||||||
data-pyro-server-manager-root
|
data-pyro-server-manager-root
|
||||||
class="experimental-styles-within relative mx-auto pb-12 box-border flex min-h-[calc(100svh-100px)] w-full min-w-0 flex-col gap-4 px-6 transition-all duration-300"
|
class="experimental-styles-within relative mx-auto pb-6 box-border flex min-h-[calc(100svh-100px)] w-full min-w-0 flex-col gap-4 px-6 transition-all duration-300"
|
||||||
:style="{
|
:style="{
|
||||||
'--server-bg-image': serverImage
|
'--server-bg-image': serverImage
|
||||||
? `url(${serverImage})`
|
? `url(${serverImage})`
|
||||||
@@ -1493,7 +1493,11 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.authUser && props.fetchIntercomToken) {
|
let intercomInitialized = false
|
||||||
|
const tryInitIntercom = () => {
|
||||||
|
if (intercomInitialized) return
|
||||||
|
if (!props.authUser || !props.fetchIntercomToken) return
|
||||||
|
intercomInitialized = true
|
||||||
props
|
props
|
||||||
.fetchIntercomToken()
|
.fetchIntercomToken()
|
||||||
.then(({ token }) => {
|
.then(({ token }) => {
|
||||||
@@ -1504,9 +1508,20 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
intercomInitialized = false
|
||||||
console.warn('[PYROSERVERS][INTERCOM] failed to initialize secure support chat', error)
|
console.warn('[PYROSERVERS][INTERCOM] failed to initialize secure support chat', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
tryInitIntercom()
|
||||||
|
const stopIntercomWatch = watch(
|
||||||
|
() => props.authUser,
|
||||||
|
(user) => {
|
||||||
|
if (user) {
|
||||||
|
tryInitIntercom()
|
||||||
|
stopIntercomWatch()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
DOMPurify.addHook(
|
DOMPurify.addHook(
|
||||||
'afterSanitizeAttributes',
|
'afterSanitizeAttributes',
|
||||||
|
|||||||
Reference in New Issue
Block a user