Add frontend
This commit is contained in:
		
							
								
								
									
										7
									
								
								frontend/src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/App.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| <script setup lang="ts"></script> | ||||
|  | ||||
| <template> | ||||
|   <RouterView /> | ||||
| </template> | ||||
|  | ||||
| <style scoped></style> | ||||
							
								
								
									
										107
									
								
								frontend/src/assets/main.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								frontend/src/assets/main.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,107 @@ | ||||
| :root { | ||||
|   --primary: #e972a8; | ||||
|   --secondary: #34729d; | ||||
|   --text: #111; | ||||
|   --secondary-text: #eee; | ||||
|   --disabled-text: #999; | ||||
|   --background: #f5eeee; | ||||
|   --header-font: 'Space Grotesk', sans-serif; | ||||
|   --body-font: 'Roboto', sans-serif; | ||||
|   --code-font: 'Fira Code', monospace; | ||||
| } | ||||
| * { | ||||
|   box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   font-family: var(--body-font); | ||||
|   color: var(--text); | ||||
| } | ||||
|  | ||||
| h1, | ||||
| h2, | ||||
| h3, | ||||
| h4, | ||||
| h5, | ||||
| h6 { | ||||
|   font-family: var(--header-font); | ||||
| } | ||||
|  | ||||
| a { | ||||
|   color: var(--secondary); | ||||
|   text-decoration: none; | ||||
| } | ||||
|  | ||||
| pre, | ||||
| code { | ||||
|   font-family: var(--code-font); | ||||
|   overflow: scroll; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| button { | ||||
|   padding: 10px; | ||||
|   background-color: var(--secondary); | ||||
|   color: var(--secondary-text); | ||||
|   border: none; | ||||
|   border-radius: 10px; | ||||
| } | ||||
|  | ||||
| button:disabled { | ||||
|   background-color: var(--secondary-text); | ||||
|   color: var(--disabled-text); | ||||
| } | ||||
|  | ||||
| header { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   background-color: var(--background); | ||||
|   width: 100%; | ||||
|   padding: 10px 40px; | ||||
|   z-index: 100; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| main { | ||||
|   margin-top: 60px; | ||||
|   padding: 30px 30px 0 30px; | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 800px) { | ||||
|   header { | ||||
|     flex-direction: column; | ||||
|   } | ||||
|  | ||||
|   main { | ||||
|     margin-top: 100px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| header .logo-text h1 { | ||||
|   font-size: 20px; | ||||
|   color: var(--primary); | ||||
| } | ||||
|  | ||||
| .keyword-search { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .keyword-search > * { | ||||
|   margin-left: 20px; | ||||
| } | ||||
|  | ||||
| .highlighted-word { | ||||
|   background-color: #dd1111; | ||||
|   color: white !important; | ||||
| } | ||||
|  | ||||
| .current-highlighted-word { | ||||
|   background-color: #d1d; | ||||
|   color: white !important; | ||||
| } | ||||
							
								
								
									
										6
									
								
								frontend/src/components/Header.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								frontend/src/components/Header.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| <template> | ||||
|   <header> | ||||
|     <RouterLink to="/" class="logo-text"><h1>Log Viewer</h1></RouterLink> | ||||
|     <slot /> | ||||
|   </header> | ||||
| </template> | ||||
							
								
								
									
										10
									
								
								frontend/src/main.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								frontend/src/main.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| import './assets/main.css' | ||||
|  | ||||
| import { createApp } from 'vue' | ||||
| import App from './App.vue' | ||||
| import router from './router' | ||||
|  | ||||
| const app = createApp(App) | ||||
| app.use(router) | ||||
|  | ||||
| app.mount('#app') | ||||
							
								
								
									
										20
									
								
								frontend/src/router/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/src/router/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import { createRouter, createWebHistory } from 'vue-router' | ||||
| import ListView from '../views/ListView.vue' | ||||
|  | ||||
| const router = createRouter({ | ||||
|   history: createWebHistory(import.meta.env.BASE_URL), | ||||
|   routes: [ | ||||
|     { | ||||
|       path: '/', | ||||
|       name: 'home', | ||||
|       component: ListView, | ||||
|     }, | ||||
|     { | ||||
|       path: '/log/:name', | ||||
|       name: 'log', | ||||
|       component: () => import('../views/LogView.vue'), | ||||
|     }, | ||||
|   ], | ||||
| }) | ||||
|  | ||||
| export default router | ||||
							
								
								
									
										26
									
								
								frontend/src/views/ListView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/views/ListView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| <script setup lang="ts"> | ||||
| import { ref } from 'vue' | ||||
| import Header from '../components/Header.vue' | ||||
|  | ||||
| const logs = ref(null) | ||||
|  | ||||
| fetch('/api/') | ||||
|   .then((resp) => resp.json()) | ||||
|   .then((data) => { | ||||
|     logs.value = data | ||||
|   }) | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped></style> | ||||
|  | ||||
| <template> | ||||
|   <Header /> | ||||
|   <main> | ||||
|     <h2>List of logs</h2> | ||||
|     <ul v-if="logs"> | ||||
|       <li v-for="file in logs.files" :key="file"> | ||||
|         <RouterLink :to="`/log/${file}`">{{ file }}</RouterLink> | ||||
|       </li> | ||||
|     </ul> | ||||
|   </main> | ||||
| </template> | ||||
							
								
								
									
										196
									
								
								frontend/src/views/LogView.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										196
									
								
								frontend/src/views/LogView.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,196 @@ | ||||
| <script setup lang="ts"> | ||||
| import { ref, reactive, computed, useTemplateRef, watch, onUpdated } from 'vue' | ||||
| import { useRoute } from 'vue-router' | ||||
| import { computedAsync } from '@vueuse/core' | ||||
| import { codeToHtml } from 'shiki' | ||||
|  | ||||
| import Header from '../components/Header.vue' | ||||
|  | ||||
| const route = useRoute() | ||||
| const start = ref(0) | ||||
| const end = ref(0) | ||||
| const totalSize = ref(null) | ||||
| const content = ref('') | ||||
| const query = ref('') | ||||
| const searched = ref(false) | ||||
| const searchResults = reactive({ | ||||
|   matches: [], | ||||
|   count: '', | ||||
|   current: -1, | ||||
| }) | ||||
| const codeDiv = useTemplateRef('codeDiv') | ||||
|  | ||||
| // 32KiB loads, max 512KiB content on page | ||||
| const size = 32 * 1024 | ||||
| const maxViewSize = size * 16 | ||||
|  | ||||
| // Add ellipsis to start or end if there is more content available | ||||
| const displayedContent = computedAsync(async () => { | ||||
|   const prefix = start.value ? '...\n' : '' | ||||
|   const suffix = end.value < totalSize.value ? '\n...' : '' | ||||
|   const decorations = [] | ||||
|   if (searched) { | ||||
|     const currentMatch = searchResults.matches[searchResults.current] | ||||
|     searchResults.matches.forEach((match) => { | ||||
|       if (match < end.value && match > start.value) { | ||||
|         decorations.push({ | ||||
|           start: match - start.value + prefix.length, | ||||
|           end: match - start.value + query.value.length + prefix.length, | ||||
|           properties: { | ||||
|             class: currentMatch === match ? 'current-highlighted-word' : 'highlighted-word', | ||||
|           }, | ||||
|         }) | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   return await codeToHtml(prefix + content.value + suffix, { | ||||
|     lang: 'bash', | ||||
|     theme: 'catppuccin-latte', | ||||
|     decorations, | ||||
|   }) | ||||
| }, '') | ||||
|  | ||||
| function scrollToSelectedSearch() { | ||||
|   if (!searched.value) return | ||||
|   // fragile constants | ||||
|   const CHAR_WIDTH = 9.6 | ||||
|   const CHAR_HEIGHT = 21 | ||||
|   const matchPos = searchResults.matches[searchResults.current] | ||||
|   const lines = content.value.slice(0, matchPos - start.value).split('\n') | ||||
|   window.scrollTo(0, (lines.length - 1) * CHAR_HEIGHT) | ||||
|   const lastLine = lines[lines.length - 1] | ||||
|   const tabsInLastLine = lastLine.split('\t').length - 1 | ||||
|   const visibleChars = lastLine.length - tabsInLastLine + tabsInLastLine * 8 | ||||
|   codeDiv.value | ||||
|     .getElementsByTagName('pre')[0] | ||||
|     ?.scrollTo(visibleChars * CHAR_WIDTH - codeDiv.value.offsetWidth / 2, 0) | ||||
| } | ||||
|  | ||||
| onUpdated(scrollToSelectedSearch) | ||||
|  | ||||
| async function getSection(start: number) { | ||||
|   const params = new URLSearchParams() | ||||
|   params.append('start', start) | ||||
|   params.append('size', size) | ||||
|   const response = await fetch(`/api/log/${route.params.name}/?${params}`) | ||||
|   return await response.json() | ||||
| } | ||||
|  | ||||
| function getNextSection() { | ||||
|   resetSearch() | ||||
|   getSection(end.value).then((data) => { | ||||
|     content.value = content.value + data.content | ||||
|     end.value = data.start + data.size | ||||
|     totalSize.value = data.total | ||||
|     const currentSize = end.value - start.value | ||||
|     if (currentSize > maxViewSize) { | ||||
|       content.value = content.value.slice(size) | ||||
|       start.value += size | ||||
|     } | ||||
|   }) | ||||
| } | ||||
|  | ||||
| function getPreviousSection() { | ||||
|   resetSearch() | ||||
|   if (start.value) { | ||||
|     getSection(start.value - size).then((data) => { | ||||
|       content.value = data.content + content.value | ||||
|       start.value = data.start | ||||
|       totalSize.value = data.total | ||||
|       const currentSize = end.value - start.value | ||||
|       if (currentSize > maxViewSize) { | ||||
|         content.value = content.value.slice(0, maxViewSize) | ||||
|         end.value = start.value + maxViewSize | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function getNthByte(n) { | ||||
|   if (n > start.value && n < end.value) { | ||||
|     return | ||||
|   } | ||||
|   const newStart = Math.floor(n / size) * size | ||||
|   const data = await getSection(newStart) | ||||
|   start.value = newStart | ||||
|   end.value = newStart + size | ||||
|   content.value = data.content | ||||
|   totalSize.value = data.total | ||||
| } | ||||
|  | ||||
| async function search() { | ||||
|   const params = new URLSearchParams() | ||||
|   params.append('query', query.value) | ||||
|   const response = await fetch(`/api/log/${route.params.name}/search/?${params}`) | ||||
|   return await response.json() | ||||
| } | ||||
|  | ||||
| function searchAndHighlight() { | ||||
|   search().then((data) => { | ||||
|     searched.value = true | ||||
|     searchResults.matches = data.matches | ||||
|     searchResults.count = data.matches.length > 99 ? '99+' : data.matches.length.toString() | ||||
|     moveSearchResult(1) | ||||
|   }) | ||||
| } | ||||
|  | ||||
| function resetSearch() { | ||||
|   searched.value = false | ||||
|   searchResults.matches = [] | ||||
|   searchResults.count = '' | ||||
|   searchResults.current = -1 | ||||
| } | ||||
|  | ||||
| // +1 for next, -1 for previous | ||||
| function moveSearchResult(n) { | ||||
|   searchResults.current += n | ||||
|   const matchPos = searchResults.matches[searchResults.current] | ||||
|   getNthByte(matchPos) | ||||
| } | ||||
|  | ||||
| watch() | ||||
|  | ||||
| function findError() { | ||||
|   searched.value = false | ||||
|   query.value = 'ERROR' | ||||
|   searchAndHighlight() | ||||
| } | ||||
|  | ||||
| getNextSection() | ||||
| </script> | ||||
|  | ||||
| <script></script> | ||||
|  | ||||
| <style lang="scss" scoped></style> | ||||
|  | ||||
| <template> | ||||
|   <Header> | ||||
|     <form @submit.prevent="searchAndHighlight" class="keyword-search"> | ||||
|       <label for="#keywordSearch">Keyword Search</label> | ||||
|       <input id="keywordSearch" type="text" v-model="query" @input="resetSearch" /> | ||||
|       <template v-if="searched"> | ||||
|         <span>{{ searchResults.current + 1 }} / {{ searchResults.count }}</span> | ||||
|         <button :disabled="searchResults.current <= 0" @click="moveSearchResult(-1)" type="button"> | ||||
|           Previous | ||||
|         </button> | ||||
|         <button | ||||
|           :disabled="searchResults.current >= searchResults.matches.length" | ||||
|           @click="moveSearchResult(1)" | ||||
|           type="button" | ||||
|         > | ||||
|           Next | ||||
|         </button> | ||||
|       </template> | ||||
|       <template v-else> | ||||
|         <button type="submit">Search</button> | ||||
|         <button type="button" @click="findError()">Error</button> | ||||
|       </template> | ||||
|     </form> | ||||
|   </Header> | ||||
|   <main> | ||||
|     <button @click="getPreviousSection" :disabled="!start">Load Previous</button> | ||||
|     <div ref="codeDiv" v-html="displayedContent"></div> | ||||
|     <button @click="getNextSection" :disabled="end >= totalSize">Load More</button> | ||||
|   </main> | ||||
| </template> | ||||
		Reference in New Issue
	
	Block a user