diff --git a/filemanager/logs.html b/filemanager/logs.html new file mode 100644 index 000000000..cd2d726c6 --- /dev/null +++ b/filemanager/logs.html @@ -0,0 +1,13 @@ + + + + + + + LogsViewer + + +
+ + + diff --git a/filemanager/package-lock.json b/filemanager/package-lock.json index fabcaf4a5..50c94e0dc 100644 --- a/filemanager/package-lock.json +++ b/filemanager/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@fontsource/noto-sans": "^5.0.5", + "anser": "^2.1.1", "combokeys": "^3.0.1", "filesize": "^10.0.7", + "moment": "^2.29.4", "pankow": "^0.4.4", "primeicons": "^6.0.1", "primevue": "^3.30.0", @@ -582,6 +584,11 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz", "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==" }, + "node_modules/anser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz", + "integrity": "sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ==" + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -872,6 +879,14 @@ "node": ">= 0.6" } }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "engines": { + "node": "*" + } + }, "node_modules/monaco-editor": { "version": "0.40.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.40.0.tgz", diff --git a/filemanager/package.json b/filemanager/package.json index f1225edd9..9fd7ef86b 100644 --- a/filemanager/package.json +++ b/filemanager/package.json @@ -10,8 +10,10 @@ }, "dependencies": { "@fontsource/noto-sans": "^5.0.5", + "anser": "^2.1.1", "combokeys": "^3.0.1", "filesize": "^10.0.7", + "moment": "^2.29.4", "pankow": "^0.4.4", "primeicons": "^6.0.1", "primevue": "^3.30.0", diff --git a/filemanager/src/components/LogsViewer.vue b/filemanager/src/components/LogsViewer.vue new file mode 100644 index 000000000..5d070a0da --- /dev/null +++ b/filemanager/src/components/LogsViewer.vue @@ -0,0 +1,191 @@ + + + + + + diff --git a/filemanager/src/logs.js b/filemanager/src/logs.js new file mode 100644 index 000000000..e280cc891 --- /dev/null +++ b/filemanager/src/logs.js @@ -0,0 +1,61 @@ +import { createApp } from 'vue'; +import { createI18n } from 'vue-i18n'; + +import './style.css'; + +import "@fontsource/noto-sans"; + +import 'primevue/resources/themes/saga-blue/theme.css'; +// import 'primevue/resources/themes/arya-blue/theme.css'; +import 'primevue/resources/primevue.min.css'; +import 'primeicons/primeicons.css'; + +import PrimeVue from 'primevue/config'; +import ConfirmationService from 'primevue/confirmationservice'; +import superagent from 'superagent'; + +import LogsViewer from './components/LogsViewer.vue'; + +const translations = {}; +const i18n = createI18n({ + locale: 'en', // set locale + fallbackLocale: 'en', // set fallback locale + messages: translations +}); + +// https://vue-i18n.intlify.dev/guide/advanced/lazy.html +(async function loadLanguages() { + const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : ''; + + async function loadLanguage(lang) { + try { + const result = await superagent.get(`${API_ORIGIN}/translation/${lang}.json`); + + // we do not deliver as application/json :/ + translations[lang] = JSON.parse(result.text); + } catch (e) { + console.error(`Failed to load language file for ${lang}`, e); + } + } + + await loadLanguage('en'); + + const locale = window.localStorage.NG_TRANSLATE_LANG_KEY; + if (locale && locale !== 'en') { + await loadLanguage(locale); + + if (i18n.mode === 'legacy') { + i18n.global.locale = locale; + } else { + i18n.global.locale.value = locale; + } + } + + const app = createApp(LogsViewer); + + app.use(i18n); + app.use(PrimeVue, { ripple: true }); + app.use(ConfirmationService); + + app.mount('#app'); +})(); diff --git a/filemanager/src/models/LogsModel.js b/filemanager/src/models/LogsModel.js new file mode 100644 index 000000000..6ce1c1fb2 --- /dev/null +++ b/filemanager/src/models/LogsModel.js @@ -0,0 +1,102 @@ + +import moment from 'moment'; +import superagent from 'superagent'; +import { ansiToHtml } from 'anser'; + +// https://github.com/janl/mustache.js/blob/master/mustache.js#L60 +const entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''', + '/': '/', + '`': '`', + '=': '=' +}; + +function escapeHtml(string) { + return String(string).replace(/[&<>"'`=/]/g, function fromEntityMap (s) { + return entityMap[s]; + }); +} + +function ab2str(buf) { + return String.fromCharCode.apply(null, new Uint16Array(buf)); +} + +export function create(origin, accessToken, type, id) { + const INITIAL_STREAM_LINES = 100; + + let eventSource = null; + + let streamApi = ''; + let downloadApi = ''; + + if (type === 'platform') { + streamApi = '/api/v1/cloudron/logstream/box'; + downloadApi = '/api/v1/cloudron/logs/box'; + } else if (type === 'crash') { + streamApi = `/api/v1/cloudron/logstream/crash-${id}`; + downloadApi = `/api/v1/cloudron/logs/crash-${id}`; + } else if (type === 'app') { + streamApi = `/api/v1/apps/${id}/logstream`; + downloadApi = `/api/v1/apps/${id}/logs`; + } else if (type === 'service') { + streamApi = `/api/v1/services/${id}/logstream`; + downloadApi = `/api/v1/services/${id}/logs`; + } else if (type === 'task') { + streamApi = `/api/v1/tasks/${id}/logstream`; + downloadApi = `/api/v1/tasks/${id}/logs`; + } else { + console.error('unsupported logs type', type); + } + + return { + name: 'LogsModel', + stream(lineHandler, errorHandler) { + eventSource = new EventSource(`${origin}${streamApi}?lines=${INITIAL_STREAM_LINES}&access_token=${accessToken}`); + eventSource.onerror = errorHandler; + // eventSource.onopen = function () { console.log('stream is open'); }; + eventSource.onmessage = function (message) { + var data; + + try { + data = JSON.parse(message.data); + } catch (e) { + return console.error(e); + } + + const id = data.realtimeTimestamp; + const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : ''; + const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message))); + + lineHandler(id, time, html); + }; + }, + getDownloadUrl() { + return `${origin}${downloadApi}?access_token=${accessToken}&format=short&lines=-1`; + }, + // TODO maybe move this into AppsModel.js + async getApp() { + let error, result; + try { + result = await superagent.get(`${origin}/api/v1/apps/${id}`).query({ access_token: accessToken }); + } catch (e) { + error = e; + } + + if (error || result.statusCode !== 200) { + console.error(`Invalid app ${id}`, error || result.statusCode); + this.fatalError = `Invalid app ${id}`; + return; + } + + return result.body; + } + }; +} + +export default { + create +}; diff --git a/filemanager/vite.config.js b/filemanager/vite.config.js index c08d856ac..46a4acd16 100644 --- a/filemanager/vite.config.js +++ b/filemanager/vite.config.js @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; +import { resolve } from 'path'; // https://vitejs.dev/config/ export default defineConfig({ @@ -10,4 +11,13 @@ export default defineConfig({ allow: ['..'] }, }, + // https://vitejs.dev/guide/build.html#multi-page-app + build: { + rollupOptions: { + input: { + filemanager: resolve(__dirname, 'index.html'), + logs: resolve(__dirname, 'logs.html'), + }, + }, + }, });