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 @@
+
+
+
+
+
+
+
+ {{ name }}
+
+
+ {{ $t('terminal.title') }}
+ {{ $t('filemanager.title') }}
+
+ {{ $t('logs.download') }}
+
+
+
+
+
+
+ {{ line.time }}
+
+
+
+
+
+
+
+
+
+
+
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'),
+ },
+ },
+ },
});