You've already forked AstralRinth
forked from didirus/AstralRinth
Monorepo missing features (#1273)
* fix tauri config * fix package patch * regen pnpm lock * use new workflow * New GH actions * Update lockfile * update scripts * Fix build script * Fix missing deps * Fix assets eslint * Update libraries lint * Fix all lint configs * update lockfile * add fmt + clippy fails * Separate App Tauri portion * fix app features * Fix lints * install tauri cli * update lockfile * corepack, fix lints * add store path * fix unused import * Fix tests * Issue templates + port over tauri release * fix actions * fix before build command * Add X86 target * Update build matrix * finalize actions * make debug build smaller * Use debug build to make cache smaller * dummy commit * change proj name * update file name * Use release builds for less space use * Remove rust cache * Readd for app build * add merge queue trigger
This commit is contained in:
518
apps/app-frontend/src/pages/instance/Index.vue
Normal file
518
apps/app-frontend/src/pages/instance/Index.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="instance-container">
|
||||
<div class="side-cards">
|
||||
<Card class="instance-card" @contextmenu.prevent.stop="handleRightClick">
|
||||
<Avatar
|
||||
size="lg"
|
||||
:src="
|
||||
!instance.metadata.icon ||
|
||||
(instance.metadata.icon && instance.metadata.icon.startsWith('http'))
|
||||
? instance.metadata.icon
|
||||
: convertFileSrc(instance.metadata?.icon)
|
||||
"
|
||||
/>
|
||||
<div class="instance-info">
|
||||
<h2 class="name">{{ instance.metadata.name }}</h2>
|
||||
<span class="metadata">
|
||||
{{ instance.metadata.loader }} {{ instance.metadata.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="button-group">
|
||||
<Button v-if="instance.install_stage !== 'installed'" disabled class="instance-button">
|
||||
Installing...
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="playing === true"
|
||||
color="danger"
|
||||
class="instance-button"
|
||||
@click="stopInstance('InstancePage')"
|
||||
@mouseover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="playing === false && loading === false"
|
||||
color="primary"
|
||||
class="instance-button"
|
||||
@click="startInstance('InstancePage')"
|
||||
@mouseover="checkProcess"
|
||||
>
|
||||
<PlayIcon />
|
||||
Play
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="loading === true && playing === false"
|
||||
disabled
|
||||
class="instance-button"
|
||||
>
|
||||
Loading...
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="'Open instance folder'"
|
||||
class="instance-button"
|
||||
@click="showProfileInFolder(instance.path)"
|
||||
>
|
||||
<FolderOpenIcon />
|
||||
Folder
|
||||
</Button>
|
||||
</span>
|
||||
<hr class="card-divider" />
|
||||
<div class="pages-list">
|
||||
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/`" class="btn">
|
||||
<BoxIcon />
|
||||
Content
|
||||
</RouterLink>
|
||||
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/logs`" class="btn">
|
||||
<FileIcon />
|
||||
Logs
|
||||
</RouterLink>
|
||||
<RouterLink :to="`/instance/${encodeURIComponent($route.params.id)}/options`" class="btn">
|
||||
<SettingsIcon />
|
||||
Options
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div class="content">
|
||||
<Promotion :external="false" query-param="?r=launcher" />
|
||||
<RouterView v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
|
||||
<component
|
||||
:is="Component"
|
||||
:instance="instance"
|
||||
:options="options"
|
||||
:offline="offline"
|
||||
:playing="playing"
|
||||
:versions="modrinthVersions"
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
></component>
|
||||
</Suspense>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add Content </template>
|
||||
<template #edit> <EditIcon /> Edit </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy Path </template>
|
||||
<template #open_folder> <ClipboardCopyIcon /> Open Folder </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy Link </template>
|
||||
<template #open_link> <ClipboardCopyIcon /> Open In Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_names><EditIcon />Copy names</template>
|
||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||
<template #copy_links><GlobeIcon />Copy Links</template>
|
||||
<template #toggle><EditIcon />Toggle selected</template>
|
||||
<template #disable><XIcon />Disable selected</template>
|
||||
<template #enable><CheckCircleIcon />Enable selected</template>
|
||||
<template #hide_show><EyeIcon />Show/Hide unselected</template>
|
||||
<template #update_all
|
||||
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
|
||||
>
|
||||
<template #filter_update><UpdatedIcon />Select Updatable</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, Avatar, Card, Promotion } from '@modrinth/ui'
|
||||
import {
|
||||
BoxIcon,
|
||||
SettingsIcon,
|
||||
FileIcon,
|
||||
PlayIcon,
|
||||
StopCircleIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
ClipboardCopyIcon,
|
||||
PlusIcon,
|
||||
ExternalIcon,
|
||||
HashIcon,
|
||||
GlobeIcon,
|
||||
EyeIcon,
|
||||
XIcon,
|
||||
CheckCircleIcon,
|
||||
UpdatedIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { get, run } from '@/helpers/profile'
|
||||
import {
|
||||
get_all_running_profile_paths,
|
||||
get_uuids_by_profile_path,
|
||||
kill_by_uuid,
|
||||
} from '@/helpers/process'
|
||||
import { offline_listener, process_listener, profile_listener } from '@/helpers/events'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
|
||||
import { isOffline, showProfileInFolder } from '@/helpers/utils.js'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import { convertFileSrc } from '@tauri-apps/api/tauri'
|
||||
import { useFetch } from '@/helpers/fetch'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
const instance = ref(await get(route.params.id).catch(handleError))
|
||||
|
||||
breadcrumbs.setName(
|
||||
'Instance',
|
||||
instance.value.metadata.name.length > 40
|
||||
? instance.value.metadata.name.substring(0, 40) + '...'
|
||||
: instance.value.metadata.name,
|
||||
)
|
||||
|
||||
breadcrumbs.setContext({
|
||||
name: instance.value.metadata.name,
|
||||
link: route.path,
|
||||
query: route.query,
|
||||
})
|
||||
|
||||
const offline = ref(await isOffline())
|
||||
|
||||
const loadingBar = useLoading()
|
||||
|
||||
const uuid = ref(null)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const options = ref(null)
|
||||
|
||||
const startInstance = async (context) => {
|
||||
loading.value = true
|
||||
uuid.value = await run(route.params.id).catch(handleSevereError)
|
||||
loading.value = false
|
||||
playing.value = true
|
||||
|
||||
mixpanel_track('InstanceStart', {
|
||||
loader: instance.value.metadata.loader,
|
||||
game_version: instance.value.metadata.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const checkProcess = async () => {
|
||||
const runningPaths = await get_all_running_profile_paths().catch(handleError)
|
||||
if (runningPaths.includes(instance.value.path)) {
|
||||
playing.value = true
|
||||
return
|
||||
}
|
||||
|
||||
playing.value = false
|
||||
uuid.value = null
|
||||
}
|
||||
|
||||
// Get information on associated modrinth versions, if any
|
||||
const modrinthVersions = ref([])
|
||||
if (!(await isOffline()) && instance.value.metadata.linked_data?.project_id) {
|
||||
modrinthVersions.value = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${instance.value.metadata.linked_data.project_id}/version`,
|
||||
'project',
|
||||
)
|
||||
}
|
||||
|
||||
await checkProcess()
|
||||
|
||||
const stopInstance = async (context) => {
|
||||
playing.value = false
|
||||
if (!uuid.value) {
|
||||
const uuids = await get_uuids_by_profile_path(instance.value.path).catch(handleError)
|
||||
uuid.value = uuids[0] // populate Uuid to listen for in the process_listener
|
||||
uuids.forEach(async (u) => await kill_by_uuid(u).catch(handleError))
|
||||
} else await kill_by_uuid(uuid.value).catch(handleError)
|
||||
|
||||
mixpanel_track('InstanceStop', {
|
||||
loader: instance.value.metadata.loader,
|
||||
game_version: instance.value.metadata.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const baseOptions = [
|
||||
{ name: 'add_content' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'edit' },
|
||||
{ name: 'open_folder' },
|
||||
{ name: 'copy_path' },
|
||||
]
|
||||
|
||||
options.value.showMenu(
|
||||
event,
|
||||
instance.value,
|
||||
playing.value
|
||||
? [
|
||||
{
|
||||
name: 'stop',
|
||||
color: 'danger',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
...baseOptions,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
await startInstance('InstancePageContextMenu')
|
||||
break
|
||||
case 'stop':
|
||||
await stopInstance('InstancePageContextMenu')
|
||||
break
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
path: `/browse/${instance.value.metadata.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: route.params.id },
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
await router.push({
|
||||
path: `/instance/${encodeURIComponent(route.params.id)}/options`,
|
||||
})
|
||||
break
|
||||
case 'open_folder':
|
||||
await showProfileInFolder(instance.value.path)
|
||||
break
|
||||
case 'copy_path':
|
||||
await navigator.clipboard.writeText(instance.value.path)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const unlistenProfiles = await profile_listener(async (event) => {
|
||||
if (event.profile_path_id === route.params.id) {
|
||||
if (event.event === 'removed') {
|
||||
await router.push({
|
||||
path: '/',
|
||||
})
|
||||
return
|
||||
}
|
||||
instance.value = await get(route.params.id).catch(handleError)
|
||||
}
|
||||
})
|
||||
|
||||
const unlistenProcesses = await process_listener((e) => {
|
||||
if (e.event === 'finished' && uuid.value === e.uuid) playing.value = false
|
||||
})
|
||||
|
||||
const unlistenOffline = await offline_listener((b) => {
|
||||
offline.value = b
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
unlistenProfiles()
|
||||
unlistenOffline()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.instance-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 17rem;
|
||||
}
|
||||
|
||||
Button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.side-cards {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
min-height: calc(100% - 3.25rem);
|
||||
max-height: calc(100% - 3.25rem);
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: unset;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.instance-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-raised-bg);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-contrast);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.instance-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
gap: 1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-left: 19rem;
|
||||
}
|
||||
|
||||
.instance-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
width: fit-content;
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.pages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
.btn {
|
||||
font-size: 100%;
|
||||
font-weight: 400;
|
||||
background: inherit;
|
||||
transition: all ease-in-out 0.1s;
|
||||
width: 100%;
|
||||
color: var(--color-primary);
|
||||
box-shadow: none;
|
||||
|
||||
&.router-link-exact-active {
|
||||
box-shadow: var(--shadow-inset-lg);
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
box-shadow: var(--shadow-inset-lg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instance-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: left;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
height: min-content;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.instance-button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 1rem 0 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-area: stats;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
gap: var(--gap-xs);
|
||||
--stat-strong-size: 1.25rem;
|
||||
|
||||
strong {
|
||||
font-size: var(--stat-strong-size);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: var(--stat-strong-size);
|
||||
width: var(--stat-strong-size);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
flex-direction: row;
|
||||
column-gap: var(--gap-md);
|
||||
margin-top: var(--gap-xs);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
margin-top: 0;
|
||||
|
||||
.stat-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
544
apps/app-frontend/src/pages/instance/Logs.vue
Normal file
544
apps/app-frontend/src/pages/instance/Logs.vue
Normal file
@@ -0,0 +1,544 @@
|
||||
<template>
|
||||
<Card class="log-card">
|
||||
<div class="button-row">
|
||||
<DropdownSelect
|
||||
v-model="selectedLogIndex"
|
||||
:default-value="0"
|
||||
name="Log date"
|
||||
:options="logs.map((_, index) => index)"
|
||||
:display-name="(option) => logs[option]?.name"
|
||||
:disabled="logs.length === 0"
|
||||
/>
|
||||
<div class="button-group">
|
||||
<Button :disabled="!logs[selectedLogIndex]" @click="copyLog()">
|
||||
<ClipboardCopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</Button>
|
||||
<Button color="primary" :disabled="offline || !logs[selectedLogIndex]" @click="share">
|
||||
<ShareIcon />
|
||||
Share
|
||||
</Button>
|
||||
<Button
|
||||
v-if="logs[selectedLogIndex] && logs[selectedLogIndex].live === true"
|
||||
@click="clearLiveLog()"
|
||||
>
|
||||
<TrashIcon />
|
||||
Clear
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
:disabled="!logs[selectedLogIndex] || logs[selectedLogIndex].live === true"
|
||||
color="danger"
|
||||
@click="deleteLog()"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<input
|
||||
id="text-filter"
|
||||
v-model="searchFilter"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
class="text-filter"
|
||||
placeholder="Type to filter logs..."
|
||||
/>
|
||||
<div class="filter-group">
|
||||
<Checkbox
|
||||
v-for="level in levels"
|
||||
:key="level.toLowerCase()"
|
||||
v-model="levelFilters[level.toLowerCase()]"
|
||||
class="filter-checkbox"
|
||||
>
|
||||
{{ level }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-text">
|
||||
<RecycleScroller
|
||||
v-slot="{ item }"
|
||||
ref="logContainer"
|
||||
class="scroller"
|
||||
:items="displayProcessedLogs"
|
||||
direction="vertical"
|
||||
:item-size="20"
|
||||
key-field="id"
|
||||
>
|
||||
<div class="user no-wrap">
|
||||
<span :style="{ color: item.prefixColor, 'font-weight': item.weight }">{{
|
||||
item.prefix
|
||||
}}</span>
|
||||
<span :style="{ color: item.textColor }">{{ item.text }}</span>
|
||||
</div>
|
||||
</RecycleScroller>
|
||||
</div>
|
||||
<ShareModal
|
||||
ref="shareModal"
|
||||
header="Share Log"
|
||||
share-title="Instance Log"
|
||||
share-text="Check out this log from an instance on the Modrinth App"
|
||||
link
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckIcon, ClipboardCopyIcon, ShareIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { Button, Card, ShareModal, Checkbox, DropdownSelect } from '@modrinth/ui'
|
||||
import {
|
||||
delete_logs_by_filename,
|
||||
get_logs,
|
||||
get_output_by_filename,
|
||||
get_latest_log_cursor,
|
||||
} from '@/helpers/logs.js'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import isToday from 'dayjs/plugin/isToday'
|
||||
import isYesterday from 'dayjs/plugin/isYesterday'
|
||||
import { get_uuids_by_profile_path } from '@/helpers/process.js'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { process_listener } from '@/helpers/events.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { ofetch } from 'ofetch'
|
||||
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
|
||||
dayjs.extend(isToday)
|
||||
dayjs.extend(isYesterday)
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const currentLiveLog = ref(null)
|
||||
const currentLiveLogCursor = ref(0)
|
||||
const emptyText = ['No live game detected.', 'Start your game to proceed.']
|
||||
|
||||
const logs = ref([])
|
||||
await setLogs()
|
||||
|
||||
const logsColored = true
|
||||
|
||||
const selectedLogIndex = ref(0)
|
||||
const copied = ref(false)
|
||||
const logContainer = ref(null)
|
||||
const interval = ref(null)
|
||||
const userScrolled = ref(false)
|
||||
const isAutoScrolling = ref(false)
|
||||
const shareModal = ref(null)
|
||||
|
||||
const levels = ['Comment', 'Error', 'Warn', 'Info', 'Debug', 'Trace']
|
||||
const levelFilters = ref({})
|
||||
levels.forEach((level) => {
|
||||
levelFilters.value[level.toLowerCase()] = true
|
||||
})
|
||||
const searchFilter = ref('')
|
||||
|
||||
function shouldDisplay(processedLine) {
|
||||
if (!processedLine.level) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!levelFilters.value[processedLine.level.toLowerCase()]) {
|
||||
return false
|
||||
}
|
||||
if (searchFilter.value !== '') {
|
||||
if (!processedLine.text.toLowerCase().includes(searchFilter.value.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Selects from the processed logs which ones should be displayed (shouldDisplay)
|
||||
// In addition, splits each line by \n. Each split line is given the same properties as the original line
|
||||
const displayProcessedLogs = computed(() => {
|
||||
return processedLogs.value.filter((l) => shouldDisplay(l))
|
||||
})
|
||||
|
||||
const processedLogs = computed(() => {
|
||||
// split based on newline and timestamp lookahead
|
||||
// (not just newline because of multiline messages)
|
||||
const splitPattern = /\n(?=(?:#|\[\d\d:\d\d:\d\d\]))/
|
||||
|
||||
const lines = logs.value[selectedLogIndex.value]?.stdout.split(splitPattern) || []
|
||||
const processed = []
|
||||
let id = 0
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
// Then split off of \n.
|
||||
// Lines that are not the first have prefix = null
|
||||
const text = getLineText(lines[i])
|
||||
const prefix = getLinePrefix(lines[i])
|
||||
const prefixColor = getLineColor(lines[i], true)
|
||||
const textColor = getLineColor(lines[i], false)
|
||||
const weight = getLineWeight(lines[i])
|
||||
const level = getLineLevel(lines[i])
|
||||
text.split('\n').forEach((line, index) => {
|
||||
processed.push({
|
||||
id: id,
|
||||
text: line,
|
||||
prefix: index === 0 ? prefix : null,
|
||||
prefixColor: prefixColor,
|
||||
textColor: textColor,
|
||||
weight: weight,
|
||||
level: level,
|
||||
})
|
||||
id += 1
|
||||
})
|
||||
}
|
||||
return processed
|
||||
})
|
||||
|
||||
async function getLiveStdLog() {
|
||||
if (route.params.id) {
|
||||
const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError)
|
||||
let returnValue
|
||||
if (uuids.length === 0) {
|
||||
returnValue = emptyText.join('\n')
|
||||
} else {
|
||||
const logCursor = await get_latest_log_cursor(
|
||||
props.instance.path,
|
||||
currentLiveLogCursor.value,
|
||||
).catch(handleError)
|
||||
if (logCursor.new_file) {
|
||||
currentLiveLog.value = ''
|
||||
}
|
||||
currentLiveLog.value = currentLiveLog.value + logCursor.output
|
||||
currentLiveLogCursor.value = logCursor.cursor
|
||||
returnValue = currentLiveLog.value
|
||||
}
|
||||
return { name: 'Live Log', stdout: returnValue, live: true }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function getLogs() {
|
||||
return (await get_logs(props.instance.path, true).catch(handleError))
|
||||
.filter(
|
||||
// filter out latest_stdout.log or anything without .log in it
|
||||
(log) =>
|
||||
log.filename !== 'latest_stdout.log' &&
|
||||
log.filename !== 'latest_stdout' &&
|
||||
log.stdout !== '' &&
|
||||
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
|
||||
)
|
||||
.map((log) => {
|
||||
log.name = log.filename || 'Unknown'
|
||||
log.stdout = 'Loading...'
|
||||
return log
|
||||
})
|
||||
}
|
||||
|
||||
async function setLogs() {
|
||||
const [liveStd, allLogs] = await Promise.all([getLiveStdLog(), getLogs()])
|
||||
logs.value = [liveStd, ...allLogs]
|
||||
}
|
||||
|
||||
const copyLog = () => {
|
||||
if (logs.value.length > 0 && logs.value[selectedLogIndex.value]) {
|
||||
navigator.clipboard.writeText(logs.value[selectedLogIndex.value].stdout)
|
||||
copied.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const share = async () => {
|
||||
if (logs.value.length > 0 && logs.value[selectedLogIndex.value]) {
|
||||
const url = await ofetch('https://api.mclo.gs/1/log', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `content=${encodeURIComponent(logs.value[selectedLogIndex.value].stdout)}`,
|
||||
}).catch(handleError)
|
||||
|
||||
shareModal.value.show(url.url)
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedLogIndex, async (newIndex) => {
|
||||
copied.value = false
|
||||
userScrolled.value = false
|
||||
|
||||
if (logs.value.length > 1 && newIndex !== 0) {
|
||||
logs.value[newIndex].stdout = 'Loading...'
|
||||
logs.value[newIndex].stdout = await get_output_by_filename(
|
||||
props.instance.path,
|
||||
logs.value[newIndex].log_type,
|
||||
logs.value[newIndex].filename,
|
||||
).catch(handleError)
|
||||
}
|
||||
})
|
||||
|
||||
if (logs.value.length > 1 && !props.playing) {
|
||||
selectedLogIndex.value = 1
|
||||
} else {
|
||||
selectedLogIndex.value = 0
|
||||
}
|
||||
|
||||
const deleteLog = async () => {
|
||||
if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) {
|
||||
let deleteIndex = selectedLogIndex.value
|
||||
selectedLogIndex.value = deleteIndex - 1
|
||||
await delete_logs_by_filename(
|
||||
props.instance.path,
|
||||
logs.value[deleteIndex].log_type,
|
||||
logs.value[deleteIndex].filename,
|
||||
).catch(handleError)
|
||||
await setLogs()
|
||||
}
|
||||
}
|
||||
|
||||
const clearLiveLog = async () => {
|
||||
currentLiveLog.value = ''
|
||||
// does not reset cursor
|
||||
}
|
||||
|
||||
const isLineLevel = (text, level) => {
|
||||
if ((text.includes('/INFO') || text.includes('[System] [CHAT]')) && level === 'info') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (text.includes('/WARN') && level === 'warn') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (text.includes('/DEBUG') && level === 'debug') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (text.includes('/TRACE') && level === 'trace') {
|
||||
return true
|
||||
}
|
||||
|
||||
const errorTriggers = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', ' at']
|
||||
if (level === 'error') {
|
||||
for (const trigger of errorTriggers) {
|
||||
if (text.includes(trigger)) return true
|
||||
}
|
||||
}
|
||||
|
||||
if (text.trim()[0] === '#' && level === 'comment') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const getLineWeight = (text) => {
|
||||
if (
|
||||
!logsColored ||
|
||||
isLineLevel(text, 'info') ||
|
||||
isLineLevel(text, 'debug') ||
|
||||
isLineLevel(text, 'trace')
|
||||
) {
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
if (isLineLevel(text, 'error') || isLineLevel(text, 'warn')) {
|
||||
return 'bold'
|
||||
}
|
||||
}
|
||||
|
||||
const getLineLevel = (text) => {
|
||||
for (const level of levels) {
|
||||
if (isLineLevel(text, level.toLowerCase())) {
|
||||
return level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getLineColor = (text, prefix) => {
|
||||
if (isLineLevel(text, 'comment')) {
|
||||
return 'var(--color-green)'
|
||||
}
|
||||
|
||||
if (!logsColored || text.includes('[System] [CHAT]')) {
|
||||
return 'var(--color-white)'
|
||||
}
|
||||
if (
|
||||
(isLineLevel(text, 'info') || isLineLevel(text, 'debug') || isLineLevel(text, 'trace')) &&
|
||||
prefix
|
||||
) {
|
||||
return 'var(--color-blue)'
|
||||
}
|
||||
if (isLineLevel(text, 'warn')) {
|
||||
return 'var(--color-orange)'
|
||||
}
|
||||
if (isLineLevel(text, 'error')) {
|
||||
return 'var(--color-red)'
|
||||
}
|
||||
}
|
||||
|
||||
const getLinePrefix = (text) => {
|
||||
if (text.includes(']:')) {
|
||||
return text.split(']:')[0] + ']:'
|
||||
}
|
||||
}
|
||||
|
||||
const getLineText = (text) => {
|
||||
if (text.includes(']:')) {
|
||||
if (text.split(']:').length > 2) {
|
||||
return text.split(']:').slice(1).join(']:')
|
||||
}
|
||||
return text.split(']:')[1]
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function handleUserScroll() {
|
||||
if (!isAutoScrolling.value) {
|
||||
userScrolled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
interval.value = setInterval(async () => {
|
||||
if (logs.value.length > 0) {
|
||||
logs.value[0] = await getLiveStdLog()
|
||||
|
||||
const scroll = logContainer.value.getScroll()
|
||||
// Allow resetting of userScrolled if the user scrolls to the bottom
|
||||
if (selectedLogIndex.value === 0) {
|
||||
if (scroll.end >= logContainer.value.$el.scrollHeight - 10) userScrolled.value = false
|
||||
if (!userScrolled.value) {
|
||||
await nextTick()
|
||||
isAutoScrolling.value = true
|
||||
logContainer.value.scrollToItem(displayProcessedLogs.value.length - 1)
|
||||
setTimeout(() => (isAutoScrolling.value = false), 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 250)
|
||||
|
||||
const unlistenProcesses = await process_listener(async (e) => {
|
||||
if (e.event === 'launched') {
|
||||
currentLiveLog.value = ''
|
||||
currentLiveLogCursor.value = 0
|
||||
selectedLogIndex.value = 0
|
||||
}
|
||||
if (e.event === 'finished') {
|
||||
currentLiveLog.value = ''
|
||||
currentLiveLogCursor.value = 0
|
||||
userScrolled.value = false
|
||||
await setLogs()
|
||||
selectedLogIndex.value = 1
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
logContainer.value.$el.addEventListener('scroll', handleUserScroll)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
logContainer.value.$el.removeEventListener('scroll', handleUserScroll)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval.value)
|
||||
unlistenProcesses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.log-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 11rem);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.log-text {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: var(--mono-font);
|
||||
background-color: var(--color-accent-contrast);
|
||||
color: var(--color-contrast);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
overflow-x: auto; /* Enables horizontal scrolling */
|
||||
overflow-y: hidden; /* Disables vertical scrolling on this wrapper */
|
||||
white-space: nowrap; /* Keeps content on a single line */
|
||||
white-space: normal;
|
||||
color-scheme: dark;
|
||||
|
||||
.no-wrap {
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 1rem;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
padding: 0.6rem;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
gap: 0.5rem;
|
||||
|
||||
&::-webkit-scrollbar-track,
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.vue-recycle-scroller__item-wrapper) {
|
||||
overflow: visible; /* Enables horizontal scrolling */
|
||||
}
|
||||
|
||||
:deep(.vue-recycle-scroller) {
|
||||
&::-webkit-scrollbar-corner {
|
||||
background-color: var(--color-bg);
|
||||
border-radius: 0 0 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scroller {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.user {
|
||||
height: 32%;
|
||||
padding: 0 12px;
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
1187
apps/app-frontend/src/pages/instance/Mods.vue
Normal file
1187
apps/app-frontend/src/pages/instance/Mods.vue
Normal file
File diff suppressed because it is too large
Load Diff
1019
apps/app-frontend/src/pages/instance/Options.vue
Normal file
1019
apps/app-frontend/src/pages/instance/Options.vue
Normal file
File diff suppressed because it is too large
Load Diff
6
apps/app-frontend/src/pages/instance/index.js
Normal file
6
apps/app-frontend/src/pages/instance/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import Index from './Index.vue'
|
||||
import Mods from './Mods.vue'
|
||||
import Options from './Options.vue'
|
||||
import Logs from './Logs.vue'
|
||||
|
||||
export { Index, Mods, Options, Logs }
|
||||
Reference in New Issue
Block a user