Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
596 changes: 522 additions & 74 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions web/components/Auth.ce.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';

import { BrandButton } from '@unraid/ui';

import { useServerStore } from '~/store/server';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';

const { t } = useI18n();

Expand Down
15 changes: 12 additions & 3 deletions web/components/DummyServerSwitcher.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia';

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@unraid/ui';
import { useDummyServerStore, type ServerSelector } from '~/_data/serverState';
import { useDummyServerStore } from '~/_data/serverState';

import type { ServerSelector } from '~/_data/serverState';

// Define the same type locally as in reka-ui
type AcceptableValue = string | number | Record<string, unknown> | null;

const store = useDummyServerStore();
const { selector, serverState } = storeToRefs(store);

const updateSelector = (val: string) => {
selector.value = val as ServerSelector;
const updateSelector = (val: AcceptableValue) => {
if (typeof val === 'string') {
selector.value = val as ServerSelector;
}
};
</script>

Expand Down
133 changes: 79 additions & 54 deletions web/components/Logs/SingleLogViewer.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useQuery, useApolloClient } from '@vue/apollo-composable';
import { useApolloClient, useQuery } from '@vue/apollo-composable';
import { vInfiniteScroll } from '@vueuse/components';
import { ArrowPathIcon, ArrowDownTrayIcon } from '@heroicons/vue/24/outline';
import { Button, Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from '@unraid/ui';
import DOMPurify from 'isomorphic-dompurify';

import { ArrowDownTrayIcon, ArrowPathIcon } from '@heroicons/vue/24/outline';
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@unraid/ui';
import hljs from 'highlight.js/lib/core';
import DOMPurify from 'isomorphic-dompurify';

import 'highlight.js/styles/github-dark.css'; // You can choose a different style
import { useThemeStore } from '~/store/theme';

// Register the languages you want to support
import plaintext from 'highlight.js/lib/languages/plaintext';
import apache from 'highlight.js/lib/languages/apache';
import bash from 'highlight.js/lib/languages/bash';
import ini from 'highlight.js/lib/languages/ini';
import xml from 'highlight.js/lib/languages/xml';
import javascript from 'highlight.js/lib/languages/javascript';
import json from 'highlight.js/lib/languages/json';
import yaml from 'highlight.js/lib/languages/yaml';
import nginx from 'highlight.js/lib/languages/nginx';
import apache from 'highlight.js/lib/languages/apache';
import javascript from 'highlight.js/lib/languages/javascript';
import php from 'highlight.js/lib/languages/php';
// Register the languages you want to support
import plaintext from 'highlight.js/lib/languages/plaintext';
import xml from 'highlight.js/lib/languages/xml';
import yaml from 'highlight.js/lib/languages/yaml';

import type { LogFileContentQuery, LogFileContentQueryVariables } from '~/composables/gql/graphql';

import { useThemeStore } from '~/store/theme';
import { GET_LOG_FILE_CONTENT } from './log.query';
import { LOG_FILE_SUBSCRIPTION } from './log.subscription';

Expand Down Expand Up @@ -61,7 +64,7 @@ const state = reactive({
canLoadMore: false,
initialLoadComplete: false,
isDownloading: false,
isSubscriptionActive: false
isSubscriptionActive: false,
});

// Get Apollo client for direct queries
Expand Down Expand Up @@ -105,7 +108,7 @@ onMounted(() => {
forceScrollToBottom();
}
});
observer.observe(scrollViewportRef.value, { childList: true, subtree: true });
observer.observe(scrollViewportRef.value as unknown as Node, { childList: true, subtree: true });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be needed, not sure why this broke for you

}

if (props.logFilePath) {
Expand All @@ -117,15 +120,15 @@ onMounted(() => {

// Set subscription as active when we receive data
state.isSubscriptionActive = true;

const existingContent = prev.logFile?.content || '';
const newContent = subscriptionData.data.logFile.content;

// Update the local state with the new content
if (newContent && state.loadedContentChunks.length > 0) {
const lastChunk = state.loadedContentChunks[state.loadedContentChunks.length - 1];
lastChunk.content += newContent;

// Force scroll to bottom if auto-scroll is enabled
if (props.autoScroll) {
nextTick(() => forceScrollToBottom());
Expand All @@ -142,7 +145,7 @@ onMounted(() => {
};
},
});

// Set subscription as active
state.isSubscriptionActive = true;
}
Expand All @@ -158,18 +161,18 @@ watch(
logContentResult,
(newResult) => {
if (!newResult?.logFile) return;

const { content, startLine } = newResult.logFile;
const effectiveStartLine = startLine || 1;

if (state.isLoadingMore) {
state.loadedContentChunks.unshift({ content, startLine: effectiveStartLine });
state.isLoadingMore = false;

nextTick(() => state.canLoadMore = true);
nextTick(() => (state.canLoadMore = true));
} else {
state.loadedContentChunks = [{ content, startLine: effectiveStartLine }];

nextTick(() => {
forceScrollToBottom();
state.initialLoadComplete = true;
Expand All @@ -190,29 +193,29 @@ const highlightLog = (content: string): string => {
try {
// Determine which language to use for highlighting
const language = props.highlightLanguage || defaultLanguage;

// Apply syntax highlighting
let highlighted = hljs.highlight(content, { language }).value;

// Apply additional custom highlighting for common log patterns

// Highlight timestamps (various formats)
highlighted = highlighted.replace(
/\b(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\b/g,
'<span class="hljs-timestamp">$1</span>'
);

// Highlight IP addresses
highlighted = highlighted.replace(
/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g,
'<span class="hljs-ip">$1</span>'
);

// Split the content into lines
let lines = highlighted.split('\n');

// Process each line to add error, warning, and success highlighting
lines = lines.map(line => {
lines = lines.map((line) => {
if (/(error|exception|fail|failed|failure)/i.test(line)) {
// Highlight error keywords
line = line.replace(
Expand All @@ -223,10 +226,7 @@ const highlightLog = (content: string): string => {
return `<span class="hljs-error">${line}</span>`;
} else if (/(warning|warn)/i.test(line)) {
// Highlight warning keywords
line = line.replace(
/\b(warning|warn)\b/gi,
'<span class="hljs-warning-keyword">$1</span>'
);
line = line.replace(/\b(warning|warn)\b/gi, '<span class="hljs-warning-keyword">$1</span>');
// Wrap the entire line
return `<span class="hljs-warning">${line}</span>`;
} else if (/(success|successful|completed|done)/i.test(line)) {
Expand All @@ -240,10 +240,10 @@ const highlightLog = (content: string): string => {
}
return line;
});

// Join the lines back together
highlighted = lines.join('\n');

// Sanitize the highlighted HTML
return DOMPurify.sanitize(highlighted);
} catch (error) {
Expand All @@ -255,7 +255,7 @@ const highlightLog = (content: string): string => {

// Computed properties
const logContent = computed(() => {
const rawContent = state.loadedContentChunks.map(chunk => chunk.content).join('');
const rawContent = state.loadedContentChunks.map((chunk) => chunk.content).join('');
return highlightLog(rawContent);
});

Expand Down Expand Up @@ -294,13 +294,13 @@ const loadMoreContent = async () => {
// Download log file
const downloadLogFile = async () => {
if (!props.logFilePath || state.isDownloading) return;

try {
state.isDownloading = true;

// Get the filename from the path
const fileName = props.logFilePath.split('/').pop() || 'log.txt';

// Query for the entire log file content
const result = await client.query({
query: GET_LOG_FILE_CONTENT,
Expand All @@ -310,24 +310,24 @@ const downloadLogFile = async () => {
},
fetchPolicy: 'network-only',
});

if (!result.data?.logFile?.content) {
throw new Error('Failed to fetch log content');
}

// Create a blob with the content
const blob = new Blob([result.data.logFile.content], { type: 'text/plain' });

// Create a download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;

// Trigger the download
document.body.appendChild(link);
link.click();

// Clean up
document.body.removeChild(link);
URL.revokeObjectURL(url);
Expand All @@ -348,7 +348,7 @@ const refreshLogContent = () => {
state.initialLoadComplete = false;
state.isLoadingMore = false;
refetchLogContent();

nextTick(() => {
forceScrollToBottom();
});
Expand All @@ -360,13 +360,18 @@ defineExpose({ refreshLogContent });

<template>
<div class="flex flex-col h-full max-h-full overflow-hidden">
<div class="flex justify-between px-4 py-2 bg-muted text-xs text-muted-foreground shrink-0 items-center">
<div
class="flex justify-between px-4 py-2 bg-muted text-xs text-muted-foreground shrink-0 items-center"
>
<div class="flex items-center gap-2">
<span>Total lines: {{ totalLines }}</span>
<TooltipProvider v-if="state.isSubscriptionActive">
<Tooltip :delay-duration="300">
<TooltipTrigger as-child>
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse cursor-help" aria-hidden="true"></div>
<div
class="w-2 h-2 rounded-full bg-green-500 animate-pulse cursor-help"
aria-hidden="true"
></div>
</TooltipTrigger>
<TooltipContent>
<p>Watching log file</p>
Expand All @@ -376,8 +381,16 @@ defineExpose({ refreshLogContent });
</div>
<span>{{ state.isAtTop ? 'Showing all available lines' : 'Scroll up to load more' }}</span>
<div class="flex gap-2">
<Button variant="outline" :disabled="loadingLogContent || state.isDownloading" @click="downloadLogFile">
<ArrowDownTrayIcon class="h-3 w-3 mr-1" :class="{ 'animate-pulse': state.isDownloading }" aria-hidden="true" />
<Button
variant="outline"
:disabled="loadingLogContent || state.isDownloading"
@click="downloadLogFile"
>
<ArrowDownTrayIcon
class="h-3 w-3 mr-1"
:class="{ 'animate-pulse': state.isDownloading }"
aria-hidden="true"
/>
<span class="text-sm">{{ state.isDownloading ? 'Downloading...' : 'Download' }}</span>
</Button>
<Button variant="outline" :disabled="loadingLogContent" @click="refreshLogContent">
Expand All @@ -387,31 +400,43 @@ defineExpose({ refreshLogContent });
</div>
</div>

<div v-if="loadingLogContent && !state.isLoadingMore" class="flex items-center justify-center flex-1 p-4 text-muted-foreground">
<div
v-if="loadingLogContent && !state.isLoadingMore"
class="flex items-center justify-center flex-1 p-4 text-muted-foreground"
>
Loading log content...
</div>

<div v-else-if="logContentError" class="flex items-center justify-center flex-1 p-4 text-destructive">
<div
v-else-if="logContentError"
class="flex items-center justify-center flex-1 p-4 text-destructive"
>
Error loading log content: {{ logContentError.message }}
</div>

<div
v-else
ref="scrollViewportRef"
v-infinite-scroll="[loadMoreContent, { direction: 'top', distance: 200, canLoadMore: () => shouldLoadMore }]"
v-infinite-scroll="[
loadMoreContent,
{ direction: 'top', distance: 200, canLoadMore: () => shouldLoadMore },
]"
class="flex-1 overflow-y-auto"
:class="{ 'theme-dark': isDarkMode, 'theme-light': !isDarkMode }"
>
<!-- Loading indicator for loading more content -->
<div v-if="state.isLoadingMore" class="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm border-b border-border rounded-md mx-2 mt-2">
<div
v-if="state.isLoadingMore"
class="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm border-b border-border rounded-md mx-2 mt-2"
>
<div class="flex items-center justify-center p-2 text-xs text-primary-foreground">
<ArrowPathIcon class="h-3 w-3 mr-2 animate-spin" aria-hidden="true" />
Loading more lines...
</div>
</div>
<pre
class="font-mono whitespace-pre-wrap p-4 m-0 text-xs leading-6 hljs"

<pre
class="font-mono whitespace-pre-wrap p-4 m-0 text-xs leading-6 hljs"
:class="{ 'theme-dark': isDarkMode, 'theme-light': !isDarkMode }"
v-html="logContent"
></pre>
Expand Down
10 changes: 6 additions & 4 deletions web/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
import eslintPrettier from 'eslint-config-prettier'
import eslintPrettier from 'eslint-config-prettier';

import withNuxt from './.nuxt/eslint.config.mjs';

export default withNuxt(
{
ignores: ['./coverage/**'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'eol-last': ['error', 'always'],
},
},
eslintPrettier,
)
eslintPrettier
);
5 changes: 4 additions & 1 deletion web/helpers/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ const defaultMarkedExtension: MarkedExtension = {
hooks: {
// must define as a function (instead of a lambda) to preserve/reflect bindings downstream
postprocess(html) {
return DOMPurify.sanitize(html);
return DOMPurify.sanitize(html, {
FORBID_TAGS: ['style'],
FORBID_ATTR: ['style'],
});
},
},
};
Expand Down
Loading
Loading