From 59861adecebcf351cb84b413cef625e5457b97c9 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 30 Mar 2026 14:27:41 +0100 Subject: [PATCH 01/50] feat: start on shared console implementation into logs and overview pages --- apps/app-frontend/src/pages/instance/Logs.vue | 434 +------------- .../src/pages/hosting/manage/[id]/index.vue | 550 +----------------- packages/assets/generated-icons.ts | 219 +++---- packages/assets/icons/wrap-text.svg | 7 + .../ui/src/components/base/FilterPills.vue | 55 ++ packages/ui/src/components/base/index.ts | 2 + packages/ui/src/composables/terminal.ts | 4 +- packages/ui/src/layouts/index.ts | 1 + .../components/ConsoleActionButtons.vue | 51 ++ .../console/components/ConsoleFilterPills.vue | 51 ++ .../console/composables/console-filtering.ts | 57 ++ .../shared/console/composables/index.ts | 2 + .../shared/console/composables/log-level.ts | 14 + .../ui/src/layouts/shared/console/index.ts | 3 + .../ui/src/layouts/shared/console/layout.vue | 189 ++++++ .../console/providers/console-manager.ts | 23 + .../layouts/shared/console/providers/index.ts | 1 + .../ui/src/layouts/shared/console/types.ts | 7 + 18 files changed, 634 insertions(+), 1036 deletions(-) create mode 100644 packages/assets/icons/wrap-text.svg create mode 100644 packages/ui/src/components/base/FilterPills.vue create mode 100644 packages/ui/src/layouts/shared/console/components/ConsoleActionButtons.vue create mode 100644 packages/ui/src/layouts/shared/console/components/ConsoleFilterPills.vue create mode 100644 packages/ui/src/layouts/shared/console/composables/console-filtering.ts create mode 100644 packages/ui/src/layouts/shared/console/composables/index.ts create mode 100644 packages/ui/src/layouts/shared/console/composables/log-level.ts create mode 100644 packages/ui/src/layouts/shared/console/index.ts create mode 100644 packages/ui/src/layouts/shared/console/layout.vue create mode 100644 packages/ui/src/layouts/shared/console/providers/console-manager.ts create mode 100644 packages/ui/src/layouts/shared/console/providers/index.ts create mode 100644 packages/ui/src/layouts/shared/console/types.ts diff --git a/apps/app-frontend/src/pages/instance/Logs.vue b/apps/app-frontend/src/pages/instance/Logs.vue index 38fba56b0a..b0dbf971b4 100644 --- a/apps/app-frontend/src/pages/instance/Logs.vue +++ b/apps/app-frontend/src/pages/instance/Logs.vue @@ -1,115 +1,18 @@ - - diff --git a/apps/frontend/src/pages/hosting/manage/[id]/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/index.vue index 46e19ea119..2c5831939d 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/index.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/index.vue @@ -60,90 +60,13 @@ :loading="!isConnected || isWsAuthIncorrect" /> -
-
-
-

Console

- -
+
+
+

Console

+
- -
-
    -
  • - {{ suggestion }} -
  • -
-
- - {{ - ' '.repeat(commandInput.length - 1) - }} - {{ bestSuggestion }} - - -
- -
- - -
-
-
+
@@ -161,18 +84,19 @@ diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index ff66853f3d..637e93aad3 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -386,6 +386,7 @@ import _VersionIcon from './icons/version.svg?component' import _WikiIcon from './icons/wiki.svg?component' import _WindowIcon from './icons/window.svg?component' import _WorldIcon from './icons/world.svg?component' +import _WrapTextIcon from './icons/wrap-text.svg?component' import _WrenchIcon from './icons/wrench.svg?component' import _XIcon from './icons/x.svg?component' import _XCircleIcon from './icons/x-circle.svg?component' @@ -773,146 +774,148 @@ export const VersionIcon = _VersionIcon export const WikiIcon = _WikiIcon export const WindowIcon = _WindowIcon export const WorldIcon = _WorldIcon +export const WrapTextIcon = _WrapTextIcon export const WrenchIcon = _WrenchIcon export const XIcon = _XIcon export const XCircleIcon = _XCircleIcon export const ZoomInIcon = _ZoomInIcon export const ZoomOutIcon = _ZoomOutIcon + export const categoryIconMap: Record = { - adventure: TagCategoryAdventureIcon, - atmosphere: TagCategoryAtmosphereIcon, - audio: TagCategoryAudioIcon, - backpack: TagCategoryBackpackIcon, - badge: TagCategoryBadgeIcon, + 'adventure': TagCategoryAdventureIcon, + 'atmosphere': TagCategoryAtmosphereIcon, + 'audio': TagCategoryAudioIcon, + 'backpack': TagCategoryBackpackIcon, + 'badge': TagCategoryBadgeIcon, 'badge-check': TagCategoryBadgeCheckIcon, 'bed-double': TagCategoryBedDoubleIcon, - blocks: TagCategoryBlocksIcon, - bloom: TagCategoryBloomIcon, + 'blocks': TagCategoryBlocksIcon, + 'bloom': TagCategoryBloomIcon, 'building-2': TagCategoryBuilding2Icon, - camera: TagCategoryCameraIcon, - cartoon: TagCategoryCartoonIcon, - castle: TagCategoryCastleIcon, - challenging: TagCategoryChallengingIcon, - clapperboard: TagCategoryClapperboardIcon, - cloud: TagCategoryCloudIcon, + 'camera': TagCategoryCameraIcon, + 'cartoon': TagCategoryCartoonIcon, + 'castle': TagCategoryCastleIcon, + 'challenging': TagCategoryChallengingIcon, + 'clapperboard': TagCategoryClapperboardIcon, + 'cloud': TagCategoryCloudIcon, 'colored-lighting': TagCategoryColoredLightingIcon, - combat: TagCategoryCombatIcon, - compass: TagCategoryCompassIcon, + 'combat': TagCategoryCombatIcon, + 'compass': TagCategoryCompassIcon, 'core-shaders': TagCategoryCoreShadersIcon, - crown: TagCategoryCrownIcon, - cursed: TagCategoryCursedIcon, - decoration: TagCategoryDecorationIcon, - dices: TagCategoryDicesIcon, - economy: TagCategoryEconomyIcon, - entities: TagCategoryEntitiesIcon, - environment: TagCategoryEnvironmentIcon, - equipment: TagCategoryEquipmentIcon, - fantasy: TagCategoryFantasyIcon, - film: TagCategoryFilmIcon, - flag: TagCategoryFlagIcon, - foliage: TagCategoryFoliageIcon, - fonts: TagCategoryFontsIcon, - food: TagCategoryFoodIcon, - footprints: TagCategoryFootprintsIcon, + 'crown': TagCategoryCrownIcon, + 'cursed': TagCategoryCursedIcon, + 'decoration': TagCategoryDecorationIcon, + 'dices': TagCategoryDicesIcon, + 'economy': TagCategoryEconomyIcon, + 'entities': TagCategoryEntitiesIcon, + 'environment': TagCategoryEnvironmentIcon, + 'equipment': TagCategoryEquipmentIcon, + 'fantasy': TagCategoryFantasyIcon, + 'film': TagCategoryFilmIcon, + 'flag': TagCategoryFlagIcon, + 'foliage': TagCategoryFoliageIcon, + 'fonts': TagCategoryFontsIcon, + 'food': TagCategoryFoodIcon, + 'footprints': TagCategoryFootprintsIcon, 'game-mechanics': TagCategoryGameMechanicsIcon, 'gamepad-2': TagCategoryGamepad2Icon, - gauge: TagCategoryGaugeIcon, - globe: TagCategoryGlobeIcon, + 'gauge': TagCategoryGaugeIcon, + 'globe': TagCategoryGlobeIcon, 'grid-3x3': TagCategoryGrid3x3Icon, - gui: TagCategoryGuiIcon, - handshake: TagCategoryHandshakeIcon, + 'gui': TagCategoryGuiIcon, + 'handshake': TagCategoryHandshakeIcon, 'heart-crack': TagCategoryHeartCrackIcon, 'heart-pulse': TagCategoryHeartPulseIcon, - high: TagCategoryHighIcon, - house: TagCategoryHouseIcon, - items: TagCategoryItemsIcon, + 'high': TagCategoryHighIcon, + 'house': TagCategoryHouseIcon, + 'items': TagCategoryItemsIcon, 'kitchen-sink': TagCategoryKitchenSinkIcon, - library: TagCategoryLibraryIcon, - lightweight: TagCategoryLightweightIcon, - locale: TagCategoryLocaleIcon, - lock: TagCategoryLockIcon, - low: TagCategoryLowIcon, - magic: TagCategoryMagicIcon, - management: TagCategoryManagementIcon, + 'library': TagCategoryLibraryIcon, + 'lightweight': TagCategoryLightweightIcon, + 'locale': TagCategoryLocaleIcon, + 'lock': TagCategoryLockIcon, + 'low': TagCategoryLowIcon, + 'magic': TagCategoryMagicIcon, + 'management': TagCategoryManagementIcon, 'map-pinned': TagCategoryMapPinnedIcon, - medium: TagCategoryMediumIcon, - minigame: TagCategoryMinigameIcon, - mobs: TagCategoryMobsIcon, - modded: TagCategoryModdedIcon, - models: TagCategoryModelsIcon, - multiplayer: TagCategoryMultiplayerIcon, - network: TagCategoryNetworkIcon, - optimization: TagCategoryOptimizationIcon, - palette: TagCategoryPaletteIcon, + 'medium': TagCategoryMediumIcon, + 'minigame': TagCategoryMinigameIcon, + 'mobs': TagCategoryMobsIcon, + 'modded': TagCategoryModdedIcon, + 'models': TagCategoryModelsIcon, + 'multiplayer': TagCategoryMultiplayerIcon, + 'network': TagCategoryNetworkIcon, + 'optimization': TagCategoryOptimizationIcon, + 'palette': TagCategoryPaletteIcon, 'path-tracing': TagCategoryPathTracingIcon, 'paw-print': TagCategoryPawPrintIcon, - pbr: TagCategoryPbrIcon, - pickaxe: TagCategoryPickaxeIcon, - potato: TagCategoryPotatoIcon, - quests: TagCategoryQuestsIcon, - realistic: TagCategoryRealisticIcon, - reflections: TagCategoryReflectionsIcon, + 'pbr': TagCategoryPbrIcon, + 'pickaxe': TagCategoryPickaxeIcon, + 'potato': TagCategoryPotatoIcon, + 'quests': TagCategoryQuestsIcon, + 'realistic': TagCategoryRealisticIcon, + 'reflections': TagCategoryReflectionsIcon, 'refresh-ccw': TagCategoryRefreshCcwIcon, - screenshot: TagCategoryScreenshotIcon, + 'screenshot': TagCategoryScreenshotIcon, 'scroll-text': TagCategoryScrollTextIcon, 'semi-realistic': TagCategorySemiRealisticIcon, - shadows: TagCategoryShadowsIcon, - shield: TagCategoryShieldIcon, - simplistic: TagCategorySimplisticIcon, - skull: TagCategorySkullIcon, - social: TagCategorySocialIcon, - square: TagCategorySquareIcon, - storage: TagCategoryStorageIcon, - sword: TagCategorySwordIcon, - swords: TagCategorySwordsIcon, - target: TagCategoryTargetIcon, - technology: TagCategoryTechnologyIcon, - terminal: TagCategoryTerminalIcon, - theater: TagCategoryTheaterIcon, - themed: TagCategoryThemedIcon, - transportation: TagCategoryTransportationIcon, + 'shadows': TagCategoryShadowsIcon, + 'shield': TagCategoryShieldIcon, + 'simplistic': TagCategorySimplisticIcon, + 'skull': TagCategorySkullIcon, + 'social': TagCategorySocialIcon, + 'square': TagCategorySquareIcon, + 'storage': TagCategoryStorageIcon, + 'sword': TagCategorySwordIcon, + 'swords': TagCategorySwordsIcon, + 'target': TagCategoryTargetIcon, + 'technology': TagCategoryTechnologyIcon, + 'terminal': TagCategoryTerminalIcon, + 'theater': TagCategoryTheaterIcon, + 'themed': TagCategoryThemedIcon, + 'transportation': TagCategoryTransportationIcon, 'tree-pine': TagCategoryTreePineIcon, - trophy: TagCategoryTrophyIcon, - tweaks: TagCategoryTweaksIcon, - users: TagCategoryUsersIcon, - utility: TagCategoryUtilityIcon, + 'trophy': TagCategoryTrophyIcon, + 'tweaks': TagCategoryTweaksIcon, + 'users': TagCategoryUsersIcon, + 'utility': TagCategoryUtilityIcon, 'vanilla-like': TagCategoryVanillaLikeIcon, 'wand-sparkles': TagCategoryWandSparklesIcon, 'wifi-off': TagCategoryWifiOffIcon, - worldgen: TagCategoryWorldgenIcon, - zap: TagCategoryZapIcon, + 'worldgen': TagCategoryWorldgenIcon, + 'zap': TagCategoryZapIcon, } export const loaderIconMap: Record = { - babric: TagLoaderBabricIcon, + 'babric': TagLoaderBabricIcon, 'bta-babric': TagLoaderBtaBabricIcon, - bukkit: TagLoaderBukkitIcon, - bungeecord: TagLoaderBungeecordIcon, - canvas: TagLoaderCanvasIcon, - datapack: TagLoaderDatapackIcon, - fabric: TagLoaderFabricIcon, - folia: TagLoaderFoliaIcon, - forge: TagLoaderForgeIcon, - geyser: TagLoaderGeyserIcon, - iris: TagLoaderIrisIcon, + 'bukkit': TagLoaderBukkitIcon, + 'bungeecord': TagLoaderBungeecordIcon, + 'canvas': TagLoaderCanvasIcon, + 'datapack': TagLoaderDatapackIcon, + 'fabric': TagLoaderFabricIcon, + 'folia': TagLoaderFoliaIcon, + 'forge': TagLoaderForgeIcon, + 'geyser': TagLoaderGeyserIcon, + 'iris': TagLoaderIrisIcon, 'java-agent': TagLoaderJavaAgentIcon, 'legacy-fabric': TagLoaderLegacyFabricIcon, - liteloader: TagLoaderLiteloaderIcon, - minecraft: TagLoaderMinecraftIcon, - modloader: TagLoaderModloaderIcon, - mrpack: TagLoaderMrpackIcon, - neoforge: TagLoaderNeoforgeIcon, - nilloader: TagLoaderNilloaderIcon, - optifine: TagLoaderOptifineIcon, - ornithe: TagLoaderOrnitheIcon, - paper: TagLoaderPaperIcon, - purpur: TagLoaderPurpurIcon, - quilt: TagLoaderQuiltIcon, - rift: TagLoaderRiftIcon, - spigot: TagLoaderSpigotIcon, - sponge: TagLoaderSpongeIcon, - vanilla: TagLoaderVanillaIcon, - velocity: TagLoaderVelocityIcon, - waterfall: TagLoaderWaterfallIcon, + 'liteloader': TagLoaderLiteloaderIcon, + 'minecraft': TagLoaderMinecraftIcon, + 'modloader': TagLoaderModloaderIcon, + 'mrpack': TagLoaderMrpackIcon, + 'neoforge': TagLoaderNeoforgeIcon, + 'nilloader': TagLoaderNilloaderIcon, + 'optifine': TagLoaderOptifineIcon, + 'ornithe': TagLoaderOrnitheIcon, + 'paper': TagLoaderPaperIcon, + 'purpur': TagLoaderPurpurIcon, + 'quilt': TagLoaderQuiltIcon, + 'rift': TagLoaderRiftIcon, + 'spigot': TagLoaderSpigotIcon, + 'sponge': TagLoaderSpongeIcon, + 'vanilla': TagLoaderVanillaIcon, + 'velocity': TagLoaderVelocityIcon, + 'waterfall': TagLoaderWaterfallIcon, } diff --git a/packages/assets/icons/wrap-text.svg b/packages/assets/icons/wrap-text.svg new file mode 100644 index 0000000000..ed9eb6b325 --- /dev/null +++ b/packages/assets/icons/wrap-text.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/packages/ui/src/components/base/FilterPills.vue b/packages/ui/src/components/base/FilterPills.vue new file mode 100644 index 0000000000..921721d3ff --- /dev/null +++ b/packages/ui/src/components/base/FilterPills.vue @@ -0,0 +1,55 @@ + + + diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts index 4b72a2f5e7..0f9b4820a9 100644 --- a/packages/ui/src/components/base/index.ts +++ b/packages/ui/src/components/base/index.ts @@ -29,6 +29,8 @@ export { default as ErrorInformationCard } from './ErrorInformationCard.vue' export { default as FileInput } from './FileInput.vue' export type { FilterBarOption } from './FilterBar.vue' export { default as FilterBar } from './FilterBar.vue' +export type { FilterPillOption } from './FilterPills.vue' +export { default as FilterPills } from './FilterPills.vue' export { default as FloatingActionBar } from './FloatingActionBar.vue' export { default as FloatingPanel } from './FloatingPanel.vue' export { default as FormattedTag } from './FormattedTag.vue' diff --git a/packages/ui/src/composables/terminal.ts b/packages/ui/src/composables/terminal.ts index ddfd50240e..139ae66835 100644 --- a/packages/ui/src/composables/terminal.ts +++ b/packages/ui/src/composables/terminal.ts @@ -126,7 +126,7 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { if (!fa || !term) return const dims = fa.proposeDimensions() if (dims) { - term.resize(dims.cols, dims.rows + 1) + term.resize(dims.cols, dims.rows) } } @@ -183,7 +183,7 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn { await nextTick() const dims = fit.proposeDimensions() if (dims) { - term.resize(dims.cols, dims.rows + 1) + term.resize(dims.cols, dims.rows) } term.options.disableStdin = true diff --git a/packages/ui/src/layouts/index.ts b/packages/ui/src/layouts/index.ts index 084ab4a7c2..033f63c409 100644 --- a/packages/ui/src/layouts/index.ts +++ b/packages/ui/src/layouts/index.ts @@ -1,3 +1,4 @@ +export * from './shared/console' export * from './shared/content-tab' export * from './shared/files-tab' export * from './shared/installation-settings' diff --git a/packages/ui/src/layouts/shared/console/components/ConsoleActionButtons.vue b/packages/ui/src/layouts/shared/console/components/ConsoleActionButtons.vue new file mode 100644 index 0000000000..33820be8d2 --- /dev/null +++ b/packages/ui/src/layouts/shared/console/components/ConsoleActionButtons.vue @@ -0,0 +1,51 @@ + + + diff --git a/packages/ui/src/layouts/shared/console/components/ConsoleFilterPills.vue b/packages/ui/src/layouts/shared/console/components/ConsoleFilterPills.vue new file mode 100644 index 0000000000..a01c058e8b --- /dev/null +++ b/packages/ui/src/layouts/shared/console/components/ConsoleFilterPills.vue @@ -0,0 +1,51 @@ + + + diff --git a/packages/ui/src/layouts/shared/console/composables/console-filtering.ts b/packages/ui/src/layouts/shared/console/composables/console-filtering.ts new file mode 100644 index 0000000000..94e52b0170 --- /dev/null +++ b/packages/ui/src/layouts/shared/console/composables/console-filtering.ts @@ -0,0 +1,57 @@ +import type { Terminal } from '@xterm/xterm' +import { ref } from 'vue' +import type { LogLevel } from '../types' +import { detectLogLevel } from './log-level' + +export type FilterPredicate = (lineText: string) => boolean + +export function useConsoleFilters() { + const activeFilters = ref>(new Set(['all'])) + + function toggleFilter(level: LogLevel | 'all') { + const next = new Set(activeFilters.value) + if (level === 'all') { + next.clear() + next.add('all') + } else { + next.delete('all') + if (next.has(level)) { + next.delete(level) + } else { + next.add(level) + } + if (next.size === 0) { + next.add('all') + } + } + activeFilters.value = next + } + + function buildFilterPredicate(): FilterPredicate | null { + if (activeFilters.value.has('all')) return null + const allowed = activeFilters.value + return (line: string) => { + const level = detectLogLevel(line) + if (level === null) return true + return allowed.has(level) + } + } + + return { activeFilters, toggleFilter, buildFilterPredicate } +} + +export function rewriteTerminal( + terminal: Terminal, + allLines: string[], + predicate: FilterPredicate | null, +) { + terminal.clear() + terminal.write('\x1b[?25l') + + const filtered = predicate ? allLines.filter(predicate) : allLines + if (filtered.length === 0) return + + terminal.write('\x1b[?2026h') + terminal.write(filtered.join('\r\n')) + terminal.write('\x1b[?2026l') +} diff --git a/packages/ui/src/layouts/shared/console/composables/index.ts b/packages/ui/src/layouts/shared/console/composables/index.ts new file mode 100644 index 0000000000..da04005b36 --- /dev/null +++ b/packages/ui/src/layouts/shared/console/composables/index.ts @@ -0,0 +1,2 @@ +export { detectLogLevel } from './log-level' +export { useConsoleFilters, rewriteTerminal, type FilterPredicate } from './console-filtering' diff --git a/packages/ui/src/layouts/shared/console/composables/log-level.ts b/packages/ui/src/layouts/shared/console/composables/log-level.ts new file mode 100644 index 0000000000..ef93a5ee64 --- /dev/null +++ b/packages/ui/src/layouts/shared/console/composables/log-level.ts @@ -0,0 +1,14 @@ +import type { LogLevel } from '../types' + +const ERROR_TRIGGERS = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', '\tat'] + +export function detectLogLevel(lineText: string): LogLevel | null { + if (lineText.includes('/INFO') || lineText.includes('[System] [CHAT]')) return 'info' + if (lineText.includes('/WARN')) return 'warn' + if (lineText.includes('/DEBUG')) return 'debug' + if (lineText.includes('/TRACE')) return 'trace' + for (const trigger of ERROR_TRIGGERS) { + if (lineText.includes(trigger)) return 'error' + } + return null +} diff --git a/packages/ui/src/layouts/shared/console/index.ts b/packages/ui/src/layouts/shared/console/index.ts new file mode 100644 index 0000000000..8739d8089b --- /dev/null +++ b/packages/ui/src/layouts/shared/console/index.ts @@ -0,0 +1,3 @@ +export { default as ConsolePageLayout } from './layout.vue' +export * from './providers' +export * from './types' diff --git a/packages/ui/src/layouts/shared/console/layout.vue b/packages/ui/src/layouts/shared/console/layout.vue new file mode 100644 index 0000000000..8d34431e57 --- /dev/null +++ b/packages/ui/src/layouts/shared/console/layout.vue @@ -0,0 +1,189 @@ + + + diff --git a/packages/ui/src/layouts/shared/console/providers/console-manager.ts b/packages/ui/src/layouts/shared/console/providers/console-manager.ts new file mode 100644 index 0000000000..3bf93b5f66 --- /dev/null +++ b/packages/ui/src/layouts/shared/console/providers/console-manager.ts @@ -0,0 +1,23 @@ +import type { ComputedRef, Ref } from 'vue' +import { createContext } from '#ui/providers/create-context' +import type { LogSource } from '../types' + +export interface ConsoleManagerContext { + logLines: Ref + + logSources?: ComputedRef + activeLogSourceIndex?: Ref + + sendCommand?: (cmd: string) => void + showCommandInput?: boolean | Ref | ComputedRef + + loading?: Ref | ComputedRef + + onClear?: () => void + onDelete?: () => Promise + + shareDisabled?: Ref | ComputedRef +} + +export const [injectConsoleManager, provideConsoleManager] = + createContext('ConsolePageLayout', 'consoleManagerContext') diff --git a/packages/ui/src/layouts/shared/console/providers/index.ts b/packages/ui/src/layouts/shared/console/providers/index.ts new file mode 100644 index 0000000000..b9e41b2fbe --- /dev/null +++ b/packages/ui/src/layouts/shared/console/providers/index.ts @@ -0,0 +1 @@ +export * from './console-manager' diff --git a/packages/ui/src/layouts/shared/console/types.ts b/packages/ui/src/layouts/shared/console/types.ts new file mode 100644 index 0000000000..665e730972 --- /dev/null +++ b/packages/ui/src/layouts/shared/console/types.ts @@ -0,0 +1,7 @@ +export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace' + +export interface LogSource { + id: string + name: string + live: boolean +} From 7eff1660289d7999911984a206743e3af22fdd5d Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Wed, 1 Apr 2026 14:52:56 +0200 Subject: [PATCH 02/50] fix: terminal gap issues --- .../src/pages/hosting/manage/[id]/index.vue | 6 ++-- .../ui/src/components/base/BaseTerminal.vue | 31 +++++++++++++------ packages/ui/src/composables/terminal.ts | 2 +- .../ui/src/layouts/shared/console/layout.vue | 4 +-- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/frontend/src/pages/hosting/manage/[id]/index.vue b/apps/frontend/src/pages/hosting/manage/[id]/index.vue index 2c5831939d..261ac2a68a 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/index.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/index.vue @@ -60,7 +60,7 @@ :loading="!isConnected || isWsAuthIncorrect" /> -
+

Console

@@ -126,9 +126,7 @@ provideConsoleManager({ console.error('Error sending command:', error) } }, - showCommandInput: computed( - () => props.isServerRunning && props.isConnected && !props.isWsAuthIncorrect, - ), + showCommandInput: true, loading: computed(() => !props.isConnected || props.isWsAuthIncorrect), onClear: () => { modrinthServersConsole.clear() diff --git a/packages/ui/src/components/base/BaseTerminal.vue b/packages/ui/src/components/base/BaseTerminal.vue index 2f30e0c8ef..91f475a47f 100644 --- a/packages/ui/src/components/base/BaseTerminal.vue +++ b/packages/ui/src/components/base/BaseTerminal.vue @@ -1,8 +1,9 @@ diff --git a/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue b/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue new file mode 100644 index 0000000000..fe6d61f121 --- /dev/null +++ b/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/ui/src/components/servers/server-header/PanelServerOverflowMenu.vue b/packages/ui/src/components/servers/server-header/PanelServerOverflowMenu.vue new file mode 100644 index 0000000000..e8a4f8d1ee --- /dev/null +++ b/packages/ui/src/components/servers/server-header/PanelServerOverflowMenu.vue @@ -0,0 +1,145 @@ + + + diff --git a/packages/ui/src/components/servers/server-header/ServerManageHeader.vue b/packages/ui/src/components/servers/server-header/ServerManageHeader.vue new file mode 100644 index 0000000000..40dbea6081 --- /dev/null +++ b/packages/ui/src/components/servers/server-header/ServerManageHeader.vue @@ -0,0 +1,162 @@ + + + diff --git a/packages/ui/src/components/servers/server-header/index.ts b/packages/ui/src/components/servers/server-header/index.ts new file mode 100644 index 0000000000..97bcdcc7f2 --- /dev/null +++ b/packages/ui/src/components/servers/server-header/index.ts @@ -0,0 +1,3 @@ +export { default as PanelServerActionButton } from './PanelServerActionButton.vue' +export { default as PanelServerOverflowMenu } from './PanelServerOverflowMenu.vue' +export { default as ServerManageHeader } from './ServerManageHeader.vue' diff --git a/packages/ui/src/components/servers/server-header/use-server-power-action.ts b/packages/ui/src/components/servers/server-header/use-server-power-action.ts new file mode 100644 index 0000000000..f66e5da61d --- /dev/null +++ b/packages/ui/src/components/servers/server-header/use-server-power-action.ts @@ -0,0 +1,136 @@ +import { useStorage } from '@vueuse/core' +import { computed, type Ref, ref } from 'vue' + +import { useVIntl } from '#ui/composables/i18n' +import { + injectModrinthClient, + injectModrinthServerContext, + injectNotificationManager, +} from '#ui/providers' + +export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill' + +export type PanelActionConfirmModalController = { + show: () => void + hide: () => void +} + +export function useServerPowerAction(options?: { + disabled?: Ref + confirmModalRef?: Ref +}) { + const { formatMessage } = useVIntl() + const client = injectModrinthClient() + const { serverId, server, powerState, busyReasons } = injectModrinthServerContext() + const { addNotification } = injectNotificationManager() + const pendingAction = ref(null) + const dontAskAgain = ref(false) + + const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, { + powerDontAskAgain: false, + }) + + const isInstalling = computed(() => server.value.status === 'installing') + const isRunning = computed(() => powerState.value === 'running') + const isStopping = computed(() => powerState.value === 'stopping') + const isTransitioning = computed( + () => powerState.value === 'starting' || powerState.value === 'stopping', + ) + const showStopButton = computed(() => isRunning.value || isStopping.value) + + const busyTooltip = computed(() => + busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined, + ) + + const canTakeAction = computed( + () => !isTransitioning.value && !options?.disabled?.value && busyReasons.value.length === 0, + ) + + const primaryActionText = computed(() => { + switch (powerState.value) { + case 'starting': + return 'Starting...' + case 'stopping': + return 'Stopping...' + case 'running': + return 'Restart' + default: + return 'Start' + } + }) + + async function sendPowerAction(action: PowerAction) { + try { + await client.archon.servers_v0.power(serverId, action) + } catch (error) { + console.error(`Error performing ${action} on server:`, error) + addNotification({ + type: 'error', + title: `Failed to ${action.toLowerCase()} server`, + text: 'An error occurred while performing this action.', + }) + } + } + + function initiateAction(action: PowerAction) { + if (!canTakeAction.value) return + + if (action === 'Start') { + void sendPowerAction(action) + return + } + + pendingAction.value = action + + if (userPreferences.value.powerDontAskAgain) { + executePendingAction() + } else { + options?.confirmModalRef?.value?.show() + } + } + + function handlePrimaryAction() { + initiateAction(isRunning.value ? 'Restart' : 'Start') + } + + function executePendingAction() { + if (!pendingAction.value) return + + if (!canTakeAction.value) { + resetPendingAction() + return + } + + void sendPowerAction(pendingAction.value) + + if (dontAskAgain.value) { + userPreferences.value.powerDontAskAgain = true + } + + resetPendingAction() + } + + function resetPendingAction() { + options?.confirmModalRef?.value?.hide() + pendingAction.value = null + dontAskAgain.value = false + } + + return { + isInstalling, + isRunning, + isStopping, + isTransitioning, + showStopButton, + busyTooltip, + canTakeAction, + primaryActionText, + pendingAction, + dontAskAgain, + sendPowerAction, + initiateAction, + handlePrimaryAction, + executePendingAction, + resetPendingAction, + } +} diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index eda671a137..c5afc1d095 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -9,6 +9,10 @@ export * from './i18n-debug' export * from './page-leave-safety' export * from './scroll-indicator' export * from './server-backup' +export * from './server-console' +export * from './server-manage-core-runtime' export * from './sticky-observer' export * from './terminal' +export * from './use-server-image' +export * from './use-server-project' export * from './virtual-scroll' diff --git a/packages/ui/src/composables/server-console.ts b/packages/ui/src/composables/server-console.ts new file mode 100644 index 0000000000..a58d926823 --- /dev/null +++ b/packages/ui/src/composables/server-console.ts @@ -0,0 +1,120 @@ +import { createGlobalState } from '@vueuse/core' +import { type Ref, shallowRef } from 'vue' + +const maxLines = 10000 +const batchTimeout = 300 +const initialBatchSize = 256 + +export const useModrinthServersConsole = createGlobalState(() => { + const output: Ref = shallowRef([]) + const searchQuery: Ref = shallowRef('') + const filteredOutput: Ref = shallowRef([]) + let searchRegex: RegExp | null = null + + let lineBuffer: string[] = [] + let batchTimer: NodeJS.Timeout | null = null + let isProcessingInitialBatch = false + + let refilterTimer: NodeJS.Timeout | null = null + const refilterTimeout = 100 + + const updateFilter = () => { + if (!searchQuery.value) { + filteredOutput.value = [] + return + } + + if (!searchRegex) { + searchRegex = new RegExp(searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') + } + + filteredOutput.value = output.value.filter((line) => searchRegex?.test(line) ?? false) + } + + const scheduleRefilter = () => { + if (refilterTimer) clearTimeout(refilterTimer) + refilterTimer = setTimeout(updateFilter, refilterTimeout) + } + + const flushBuffer = () => { + if (lineBuffer.length === 0) return + + const processedLines = lineBuffer.flatMap((line) => line.split('\n').filter(Boolean)) + + if (isProcessingInitialBatch && processedLines.length >= initialBatchSize) { + isProcessingInitialBatch = false + output.value = processedLines.slice(-maxLines) + } else { + const newOutput = [...output.value, ...processedLines] + output.value = newOutput.slice(-maxLines) + } + + lineBuffer = [] + batchTimer = null + + if (searchQuery.value) { + scheduleRefilter() + } + } + + const addLine = (line: string): void => { + lineBuffer.push(line) + + if (!batchTimer) { + batchTimer = setTimeout(flushBuffer, batchTimeout) + } + } + + const addLines = (lines: string[]): void => { + if (output.value.length === 0 && lines.length >= initialBatchSize) { + isProcessingInitialBatch = true + lineBuffer = lines + flushBuffer() + return + } + + lineBuffer.push(...lines) + + if (!batchTimer) { + batchTimer = setTimeout(flushBuffer, batchTimeout) + } + } + + const setSearchQuery = (query: string): void => { + searchQuery.value = query + searchRegex = null + updateFilter() + } + + const clear = (): void => { + output.value = [] + filteredOutput.value = [] + searchQuery.value = '' + lineBuffer = [] + isProcessingInitialBatch = false + if (batchTimer) { + clearTimeout(batchTimer) + batchTimer = null + } + if (refilterTimer) { + clearTimeout(refilterTimer) + refilterTimer = null + } + searchRegex = null + } + + const findLineIndex = (line: string): number => { + return output.value.findIndex((l) => l === line) + } + + return { + output, + searchQuery, + filteredOutput, + addLine, + addLines, + setSearchQuery, + clear, + findLineIndex, + } +}) diff --git a/packages/ui/src/composables/server-manage-core-runtime.ts b/packages/ui/src/composables/server-manage-core-runtime.ts new file mode 100644 index 0000000000..46a0545030 --- /dev/null +++ b/packages/ui/src/composables/server-manage-core-runtime.ts @@ -0,0 +1,391 @@ +import { type Archon, clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client' +import type { Stats } from '@modrinth/utils' +import type { ComputedRef, Ref } from 'vue' +import { computed, reactive, ref } from 'vue' + +import { injectModrinthClient, provideModrinthServerContext } from '../providers' +import type { BusyReason } from '../providers/server-context' +import { defineMessage } from './i18n' +import { useModrinthServersConsole } from './server-console' + +type ReadableRef = Ref | ComputedRef +type SocketUnsubscriber = () => void + +type ConnectSocketOptions = { + force?: boolean + extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[] +} + +type UseServerManageCoreRuntimeOptions = { + serverId: ReadableRef + worldId: ReadableRef + server: ReadableRef + isSyncingContent: ReadableRef + markBackupCancelled?: (backupId: string) => void + includeBackupBusyReasons?: boolean + setDisconnectedOnAuthIncorrect?: boolean + syncUptimeFromState?: boolean + incrementUptimeLocally?: boolean + eventGuard?: () => boolean + onStateEvent?: (data: Archon.Websocket.v0.WSStateEvent) => void +} + +const initialConsoleMessage = [ + ' __________________________________________________', + ' / Welcome to your \x1B[32mModrinth Server\x1B[37m! \\', + '| Press the green start button to start your server! |', + ' \\____________________________________________________/', + '\x1B[32m _ _ \x1B[37m', + '\x1B[32m (o)--(o) \x1B[37m', + '\x1B[32m /.______.\\\x1B[37m', + '\x1B[32m \\________/ \x1B[37m', + '\x1B[32m ./ \\. \x1B[37m', + '\x1B[32m ( . , )\x1B[37m', + '\x1B[32m \\ \\_\\\\ //_/ /\x1B[37m', + '\x1B[32m ~~ ~~ ~~\x1B[37m', +] + +const createInitialStats = (): Stats => ({ + current: { + cpu_percent: 0, + ram_usage_bytes: 0, + ram_total_bytes: 1, + storage_usage_bytes: 0, + storage_total_bytes: 0, + }, + past: { + cpu_percent: 0, + ram_usage_bytes: 0, + ram_total_bytes: 1, + storage_usage_bytes: 0, + storage_total_bytes: 0, + }, + graph: { + cpu: [], + ram: [], + }, +}) + +const appendGraphData = (dataArray: number[], newValue: number): number[] => { + const updated = [...dataArray, newValue] + if (updated.length > 10) updated.shift() + return updated +} + +const mapPowerStateFromStateEvent = ( + data: Archon.Websocket.v0.WSStateEvent, +): Archon.Websocket.v0.PowerState => { + const powerMap: Record = + { + not_ready: 'stopped', + starting: 'starting', + running: 'running', + stopping: 'stopping', + idle: + data.was_oom || (data.exit_code != null && data.exit_code !== 0) ? 'crashed' : 'stopped', + } + return powerMap[data.power_variant] +} + +export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOptions) { + const client = injectModrinthClient() + const modrinthServersConsole = useModrinthServersConsole() + + const shouldProcessEvent = () => (options.eventGuard ? options.eventGuard() : true) + + const isConnected = ref(false) + const isWsAuthIncorrect = ref(false) + const serverPowerState = ref('stopped') + const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>() + const isServerRunning = computed(() => serverPowerState.value === 'running') + const stats = ref(createInitialStats()) + const uptimeSeconds = ref(0) + const backupsState = reactive(new Map()) + const fsAuth = ref<{ url: string; token: string } | null>(null) + const fsOps = ref([]) + const fsQueuedOps = ref([]) + const connectedSocketServerId = ref(null) + const socketUnsubscribers = ref([]) + const cpuData = ref([]) + const ramData = ref([]) + + let uptimeIntervalId: ReturnType | null = null + + const markBackupCancelled = + options.markBackupCancelled ?? + ((backupId: string) => { + backupsState.delete(backupId) + }) + + const busyReasons = computed(() => { + const reasons: BusyReason[] = [] + if (options.server.value?.status === 'installing') { + reasons.push({ + reason: defineMessage({ + id: 'servers.busy.installing', + defaultMessage: 'Server is installing', + }), + }) + } + if (options.isSyncingContent.value) { + reasons.push({ + reason: defineMessage({ + id: 'servers.busy.syncing-content', + defaultMessage: 'Content sync in progress', + }), + }) + } + if (options.includeBackupBusyReasons) { + for (const entry of backupsState.values()) { + if (entry.create?.state === 'ongoing') { + reasons.push({ + reason: defineMessage({ + id: 'servers.busy.backup-creating', + defaultMessage: 'Backup creation in progress', + }), + }) + break + } + if (entry.restore?.state === 'ongoing') { + reasons.push({ + reason: defineMessage({ + id: 'servers.busy.backup-restoring', + defaultMessage: 'Backup restore in progress', + }), + }) + break + } + } + } + return reasons + }) + + const stopUptimeTicker = () => { + if (uptimeIntervalId) { + clearInterval(uptimeIntervalId) + uptimeIntervalId = null + } + } + + const startUptimeTicker = () => { + if (!options.incrementUptimeLocally || uptimeIntervalId) return + uptimeIntervalId = setInterval(() => { + uptimeSeconds.value += 1 + }, 1000) + } + + const updateStats = (currentStats: Stats['current']) => { + if (!shouldProcessEvent()) return + if (!isConnected.value) isConnected.value = true + stats.value = { + current: currentStats, + past: { ...stats.value.current }, + graph: { + cpu: appendGraphData(cpuData.value, currentStats.cpu_percent), + ram: appendGraphData( + ramData.value, + Math.floor((currentStats.ram_usage_bytes / currentStats.ram_total_bytes) * 100), + ), + }, + } + } + + const updatePowerState = ( + state: Archon.Websocket.v0.PowerState, + details?: { oom_killed?: boolean; exit_code?: number }, + ) => { + if (!shouldProcessEvent()) return + serverPowerState.value = state + powerStateDetails.value = state === 'crashed' ? details : undefined + if (state === 'stopped' || state === 'crashed') { + stopUptimeTicker() + uptimeSeconds.value = 0 + } + } + + const handleLog = (data: Archon.Websocket.v0.WSLogEvent) => { + if (!shouldProcessEvent()) return + const log = data.message.split('\n').filter((line) => line.trim()) + modrinthServersConsole.addLines(log) + } + + const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => { + updateStats({ + cpu_percent: data.cpu_percent, + ram_usage_bytes: data.ram_usage_bytes, + ram_total_bytes: data.ram_total_bytes, + storage_usage_bytes: data.storage_usage_bytes, + storage_total_bytes: data.storage_total_bytes, + }) + } + + const handlePowerState = (data: Archon.Websocket.v0.WSPowerStateEvent) => { + if (data.state === 'crashed') { + updatePowerState(data.state, { + oom_killed: data.oom_killed, + exit_code: data.exit_code, + }) + } else { + updatePowerState(data.state) + } + } + + const handleState = (data: Archon.Websocket.v0.WSStateEvent) => { + if (!shouldProcessEvent()) return + options.onStateEvent?.(data) + updatePowerState(mapPowerStateFromStateEvent(data), { + exit_code: data.exit_code ?? undefined, + oom_killed: data.was_oom, + }) + + if (options.syncUptimeFromState && data.uptime > 0) { + stopUptimeTicker() + uptimeSeconds.value = data.uptime + startUptimeTicker() + } + } + + const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => { + if (!shouldProcessEvent()) return + stopUptimeTicker() + uptimeSeconds.value = data.uptime + startUptimeTicker() + } + + const handleAuthIncorrect = () => { + if (!shouldProcessEvent()) return + isWsAuthIncorrect.value = true + if (options.setDisconnectedOnAuthIncorrect) { + isConnected.value = false + } + } + + const handleAuthOk = () => { + if (!shouldProcessEvent()) return + isWsAuthIncorrect.value = false + isConnected.value = true + } + + const clearSocketListeners = () => { + for (const unsub of socketUnsubscribers.value) unsub() + socketUnsubscribers.value = [] + } + + const disconnectSocket = (targetServerId?: string) => { + clearSocketListeners() + + if (targetServerId) { + client.archon.sockets.disconnect(targetServerId) + } + + stopUptimeTicker() + connectedSocketServerId.value = null + isConnected.value = false + isWsAuthIncorrect.value = false + serverPowerState.value = 'stopped' + powerStateDetails.value = undefined + uptimeSeconds.value = 0 + } + + const connectSocket = async ( + targetServerId: string, + connectOptions: ConnectSocketOptions = {}, + ): Promise => { + if ( + connectedSocketServerId.value === targetServerId && + (isConnected.value || isWsAuthIncorrect.value) + ) { + return true + } + + disconnectSocket(connectedSocketServerId.value ?? undefined) + + try { + const safeConnectOptions = connectOptions.force ? { force: true } : undefined + await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions) + connectedSocketServerId.value = targetServerId + isConnected.value = true + isWsAuthIncorrect.value = false + + modrinthServersConsole.clear() + for (const line of initialConsoleMessage) { + modrinthServersConsole.addLine(line) + } + + const baseSubscriptions: SocketUnsubscriber[] = [ + client.archon.sockets.on(targetServerId, 'log', handleLog), + client.archon.sockets.on(targetServerId, 'stats', handleStats), + client.archon.sockets.on(targetServerId, 'state', handleState), + client.archon.sockets.on(targetServerId, 'power-state', handlePowerState), + client.archon.sockets.on(targetServerId, 'uptime', handleUptime), + client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect), + client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk), + ] + const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? [] + socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions] + return true + } catch (error) { + console.error('[hosting/manage] Failed to connect server socket:', error) + isConnected.value = false + return false + } + } + + const refreshFsAuth = async () => { + if (!options.serverId.value) { + fsAuth.value = null + return + } + fsAuth.value = await client.archon.servers_v0.getFilesystemAuth(options.serverId.value) + } + + provideModrinthServerContext({ + get serverId() { + return options.serverId.value + }, + worldId: options.worldId as Ref, + server: options.server as Ref, + isConnected, + isWsAuthIncorrect, + powerState: serverPowerState, + powerStateDetails, + isServerRunning, + stats, + uptimeSeconds, + backupsState, + markBackupCancelled, + isSyncingContent: options.isSyncingContent as Ref, + busyReasons, + fsAuth, + fsOps, + fsQueuedOps, + refreshFsAuth, + }) + + setNodeAuthState(() => fsAuth.value, refreshFsAuth) + + const cleanupCoreRuntime = (targetServerId?: string) => { + disconnectSocket(targetServerId ?? connectedSocketServerId.value ?? undefined) + clearNodeAuthState() + } + + return { + backupsState, + busyReasons, + cleanupCoreRuntime, + connectSocket, + connectedSocketServerId, + disconnectSocket, + fsAuth, + fsOps, + fsQueuedOps, + isConnected, + isServerRunning, + isWsAuthIncorrect, + powerStateDetails, + refreshFsAuth, + serverPowerState, + stats, + uptimeSeconds, + } +} diff --git a/apps/frontend/src/composables/servers/use-server-image.ts b/packages/ui/src/composables/use-server-image.ts similarity index 75% rename from apps/frontend/src/composables/servers/use-server-image.ts rename to packages/ui/src/composables/use-server-image.ts index c613a79ae5..6e87e6b5e2 100644 --- a/apps/frontend/src/composables/servers/use-server-image.ts +++ b/packages/ui/src/composables/use-server-image.ts @@ -1,7 +1,10 @@ import type { Archon } from '@modrinth/api-client' -import { injectModrinthClient } from '@modrinth/ui' import { type ComputedRef, ref, watch } from 'vue' +import { injectModrinthClient } from '#ui/providers' + +const imageCache = new Map() + // TODO: Remove and use V1 when available export function useServerImage( serverId: string, @@ -10,23 +13,23 @@ export function useServerImage( const client = injectModrinthClient() const image = ref() - const sharedImage = useState(`server-icon-${serverId}`) - if (sharedImage.value) { - image.value = sharedImage.value + const cached = imageCache.get(serverId) + if (cached) { + image.value = cached } async function loadImage() { - if (sharedImage.value) { - image.value = sharedImage.value + if (typeof window === 'undefined') return + + if (imageCache.has(serverId)) { + image.value = imageCache.get(serverId) return } - if (import.meta.server) return - - const cached = localStorage.getItem(`server-icon-${serverId}`) - if (cached) { - sharedImage.value = cached - image.value = cached + const localCached = localStorage.getItem(`server-icon-${serverId}`) + if (localCached) { + imageCache.set(serverId, localCached) + image.value = localCached return } @@ -34,9 +37,7 @@ export function useServerImage( const upstreamVal = upstream.value if (upstreamVal?.project_id) { try { - const project = await $fetch<{ icon_url?: string }>( - `https://api.modrinth.com/v2/project/${upstreamVal.project_id}`, - ) + const project = await client.labrinth.projects_v2.get(upstreamVal.project_id) projectIconUrl = project.icon_url } catch { // project fetch failed, continue without icon url @@ -48,18 +49,19 @@ export function useServerImage( if (fileData instanceof Blob) { const dataURL = await resizeImage(fileData, 512) - sharedImage.value = dataURL + imageCache.set(serverId, dataURL) localStorage.setItem(`server-icon-${serverId}`, dataURL) image.value = dataURL return } - } catch (error: any) { - if (error?.statusCode >= 500) { + } catch (error: unknown) { + const statusCode = (error as { statusCode?: number })?.statusCode + if (statusCode != null && statusCode >= 500) { image.value = undefined return } - if (error?.statusCode === 404 && projectIconUrl) { + if (statusCode === 404 && projectIconUrl) { try { const response = await fetch(projectIconUrl) if (!response.ok) throw new Error('Failed to fetch icon') @@ -90,7 +92,7 @@ export function useServerImage( } }, 'image/png') const result = canvas.toDataURL('image/png') - sharedImage.value = result + imageCache.set(serverId, result) localStorage.setItem(`server-icon-${serverId}`, result) resolve(result) URL.revokeObjectURL(img.src) @@ -99,8 +101,8 @@ export function useServerImage( }) image.value = dataURL return - } catch (externalError: any) { - console.debug('Could not process external icon:', externalError.message) + } catch (externalError: unknown) { + console.debug('Could not process external icon:', (externalError as Error).message) } } } diff --git a/apps/frontend/src/composables/servers/use-server-project.ts b/packages/ui/src/composables/use-server-project.ts similarity index 70% rename from apps/frontend/src/composables/servers/use-server-project.ts rename to packages/ui/src/composables/use-server-project.ts index eeb22a8b52..524bf45265 100644 --- a/apps/frontend/src/composables/servers/use-server-project.ts +++ b/packages/ui/src/composables/use-server-project.ts @@ -1,17 +1,18 @@ import type { Archon } from '@modrinth/api-client' -import type { Project } from '@modrinth/utils' import { useQuery } from '@tanstack/vue-query' -import { $fetch } from 'ofetch' import { computed, type ComputedRef } from 'vue' +import { injectModrinthClient } from '#ui/providers' + // TODO: Remove and use v1 export function useServerProject( upstream: ComputedRef, ) { + const client = injectModrinthClient() + return useQuery({ queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]), - queryFn: () => - $fetch(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`), + queryFn: () => client.labrinth.projects_v2.get(upstream.value!.project_id!), enabled: computed(() => !!upstream.value?.project_id), }) } diff --git a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue index ea8ac8b096..c235d73a3e 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue @@ -29,11 +29,15 @@ @@ -167,7 +171,7 @@ const messages = defineMessages({ }) const emit = defineEmits<{ - reinstall: [any?] + reinstall: [unknown?] 'reinstall-failed': [] }>() @@ -766,7 +770,7 @@ watch( }, ) -function onReinstall(event?: any) { +function onReinstall(event?: unknown) { installationSettingsLayout.value?.cancelEditing() emit('reinstall', event) } diff --git a/packages/ui/src/layouts/shared/server-settings/pages/properties.vue b/packages/ui/src/layouts/shared/server-settings/pages/properties.vue index 1e77179214..bf0077498d 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/properties.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/properties.vue @@ -1,5 +1,5 @@