Move logs.html from frontend to dashboard

This commit is contained in:
Johannes Zellner
2024-10-04 20:47:49 +02:00
parent 2300e1baee
commit bc4e6ab1de
13 changed files with 558 additions and 19 deletions

View File

@@ -0,0 +1,226 @@
<template>
<MainLayout>
<template #dialogs>
</template>
<template #header>
<TopBar class="navbar">
<template #left>
<span class="title">{{ name }}</span>
</template>
<template #right>
<Button icon="fa-solid fa-eraser" @click="onClear()" style="margin-right: 5px">{{ $t('logs.clear') }}</Button>
<Button :href="downloadUrl" target="_blank" icon="fa-solid fa-download">{{ $t('logs.download') }}</Button>
<Button style="margin-left: 20px;" :title="$t('filemanager.toolbar.restartApp')" v-show="showRestart" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp"/>
<Button :href="'/frontend/terminal.html?id=' + id" target="_blank" v-show="showTerminal" secondary tool icon="fa-solid fa-terminal" :title="$t('terminal.title')" />
<Button :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="showFilemanager" secondary tool icon="fa-solid fa-folder" :title="$t('filemanager.title')" />
</template>
</TopBar>
</template>
<template #body>
<div ref="linesContainer"></div>
<div class="bottom-spacer"></div>
</template>
</MainLayout>
</template>
<script>
import { Button, TopBar, MainLayout } from 'pankow';
import LogsModel from '../models/LogsModel.js';
import AppModel from '../models/AppModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
export default {
name: 'LogsViewer',
components: {
Button,
MainLayout,
TopBar
},
data() {
return {
accessToken: localStorage.token,
logsModel: null,
appModel: null,
busyRestart: false,
showRestart: false,
showFilemanager: false,
showTerminal: false,
id: '',
name: '',
type: '',
downloadUrl: '',
logLines: []
};
},
methods: {
onClear() {
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
},
onDownload() {
this.logsModel.download();
},
async onRestartApp() {
if (this.type !== 'app') return;
this.busyRestart = true;
await this.appModel.restart();
this.busyRestart = false;
}
},
async mounted() {
if (!localStorage.token) {
console.error('Set localStorage.token');
return;
}
const urlParams = new URLSearchParams(window.location.search);
const appId = urlParams.get('appId');
const taskId = urlParams.get('taskId');
const crashId = urlParams.get('crashId');
const id = urlParams.get('id');
if (appId) {
this.type = 'app';
this.id = appId;
this.name = 'App ' + appId;
} else if (taskId) {
this.type = 'task';
this.id = taskId;
this.name = 'Task ' + taskId;
} else if (crashId) {
this.type = 'crash';
this.id = crashId;
this.name = 'Crash ' + crashId;
} else if (id) {
if (id === 'box') {
this.type = 'platform';
this.id = id;
this.name = 'Box';
} else {
this.type = 'service';
this.id = id;
this.name = 'Service ' + id;
}
} else {
console.error('no supported log type specified');
return;
}
this.logsModel = LogsModel.create(API_ORIGIN, this.accessToken, this.type, this.id);
if (this.type === 'app') {
this.appModel = AppModel.create(API_ORIGIN, this.accessToken, this.id);
try {
const app = await this.appModel.get();
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
this.showFilemanager = !!app.manifest.addons.localstorage;
this.showTerminal = app.manifest.id !== 'io.cloudron.builtin.appproxy';
this.showRestart = app.manifest.id !== 'io.cloudron.builtin.appproxy';
} catch (e) {
console.error(`Failed to get app info for ${this.id}:`, e);
}
}
window.document.title = `Logs Viewer - ${this.name}`;
this.downloadUrl = this.logsModel.getDownloadUrl();
const maxLines = 1000;
let lines = 0;
let newLogLines = [];
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
setInterval(() => {
newLogLines = newLogLines.slice(-maxLines)
for (let line of newLogLines) {
if (lines < maxLines) ++lines;
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
const logLine = document.createElement('div');
logLine.className = 'log-line';
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp]&nbsp;' }</span> <span>${line.html}</span>`;
this.$refs.linesContainer.appendChild(logLine);
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
}
newLogLines = [];
}, 500);
this.logsModel.stream((time, html) => {
newLogLines.push({ time, html });
}, function (error) {
newLogLines.push({ time: error.time, html: error.html });
})
}
};
</script>
<style>
body {
background-color: black;
}
.title {
font-size: 20px;
}
.pankow-main-layout-body {
cursor: text;
}
@media (max-width: 641px) {
.hide-phone {
display: none !important;
}
}
.pankow-top-bar {
background-color: black;
color: white;
margin-bottom: 0 !important;
padding: 5px 10px;
}
.log-line {
color: white;
font-family: monospace;
font-size: 14px;
line-height: 1.2;
white-space: nowrap;
width: 100%;
}
.log-line:hover {
background-color: #333333;
}
.log-line > .time {
color: #0ff;
position: sticky;
left: 0;
margin-right: 10px;
background-color: black;
}
.log-line:hover > .time {
background-color: #333333;
}
.bottom-spacer {
height: 5px;
}
</style>

104
dashboard/src/constants.js Normal file
View File

@@ -0,0 +1,104 @@
// keep in sync with box/src/apps.js
const ISTATES = {
PENDING_INSTALL: 'pending_install',
PENDING_CLONE: 'pending_clone',
PENDING_CONFIGURE: 'pending_configure',
PENDING_UNINSTALL: 'pending_uninstall',
PENDING_RESTORE: 'pending_restore',
PENDING_IMPORT: 'pending_import',
PENDING_UPDATE: 'pending_update',
PENDING_BACKUP: 'pending_backup',
PENDING_RECREATE_CONTAINER: 'pending_recreate_container', // env change or addon change
PENDING_LOCATION_CHANGE: 'pending_location_change',
PENDING_DATA_DIR_MIGRATION: 'pending_data_dir_migration',
PENDING_RESIZE: 'pending_resize',
PENDING_DEBUG: 'pending_debug',
PENDING_START: 'pending_start',
PENDING_STOP: 'pending_stop',
PENDING_RESTART: 'pending_restart',
ERROR: 'error',
INSTALLED: 'installed'
};
const HSTATES = {
HEALTHY: 'healthy',
UNHEALTHY: 'unhealthy',
ERROR: 'error',
DEAD: 'dead'
};
const RSTATES ={
RUNNING: 'running',
STOPPED: 'stopped'
};
const ERROR = {
ACCESS_DENIED: 'Access Denied',
ALREADY_EXISTS: 'Already Exists',
BAD_FIELD: 'Bad Field',
COLLECTD_ERROR: 'Collectd Error',
CONFLICT: 'Conflict',
DATABASE_ERROR: 'Database Error',
DNS_ERROR: 'DNS Error',
DOCKER_ERROR: 'Docker Error',
EXTERNAL_ERROR: 'External Error',
FS_ERROR: 'FileSystem Error',
INTERNAL_ERROR: 'Internal Error',
LOGROTATE_ERROR: 'Logrotate Error',
NETWORK_ERROR: 'Network Error',
NOT_FOUND: 'Not found',
REVERSEPROXY_ERROR: 'ReverseProxy Error',
TASK_ERROR: 'Task Error',
UNKNOWN_ERROR: 'Unknown Error' // only used for portin,
};
const ROLES = {
OWNER: 'owner',
ADMIN: 'admin',
MAIL_MANAGER: 'mailmanager',
USER_MANAGER: 'usermanager',
USER: 'user'
};
// sync up with tasks.js
const TASK_TYPES = {
TASK_APP: 'app',
TASK_BACKUP: 'backup',
TASK_UPDATE: 'update',
TASK_CHECK_CERTS: 'checkCerts',
TASK_PREPARE_DASHBOARD_LOCATION: 'prepareDashboardLocation',
TASK_CLEAN_BACKUPS: 'cleanBackups',
TASK_SYNC_EXTERNAL_LDAP: 'syncExternalLdap',
TASK_CHANGE_MAIL_LOCATION: 'changeMailLocation',
TASK_SYNC_DNS_RECORDS: 'syncDnsRecords',
TASK_UPDATE_DISK_USAGE: 'updateDiskUsage',
};
const APP_TYPES = {
APP: 'app', //default
LINK: 'link',
PROXIED: 'proxied'
};
// named exports
export {
APP_TYPES,
ERROR,
HSTATES,
ISTATES,
RSTATES,
ROLES,
TASK_TYPES
};
// default export
export default {
APP_TYPES,
ERROR,
HSTATES,
ISTATES,
RSTATES,
ROLES,
TASK_TYPES
};

51
dashboard/src/i18n.js Normal file
View File

@@ -0,0 +1,51 @@
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
import { createI18n } from 'vue-i18n';
import { fetcher } from 'pankow';
const translations = {};
const i18n = createI18n({
locale: 'en', // set locale
fallbackLocale: 'en', // set fallback locale
messages: translations,
// will replace our double {{}} to vue-i18n single brackets
messageResolver: function (keys, key) {
const message = key.split('.').reduce((o, k) => o && o[k] || null, keys);
// if not found return null to fallback to resolving for english
if (message === null) return null;
return message.replaceAll('{{', '{').replaceAll('}}', '}');
}
});
// https://vue-i18n.intlify.dev/guide/advanced/lazy.html
async function loadLanguage(lang) {
try {
const result = await fetcher.get(`${API_ORIGIN}/translation/${lang}.json`);
translations[lang] = result.body;
} catch (e) {
console.error(`Failed to load language file for ${lang}`, e);
}
}
async function main() {
// load at least fallback english
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;
}
}
return i18n;
}
export default main;

16
dashboard/src/logs.js Normal file
View File

@@ -0,0 +1,16 @@
import { createApp } from 'vue';
import './style.css';
import '@fontsource/noto-sans';
import i18n from './i18n.js';
import LogsViewer from './components/LogsViewer.vue';
(async function init() {
const app = createApp(LogsViewer);
app.use(await i18n());
app.mount('#app');
})();

View File

@@ -0,0 +1,56 @@
import { ISTATES } from '../constants.js';
import { fetcher } from 'pankow';
import { sleep } from 'pankow/utils';
export function create(origin, accessToken, id) {
return {
name: 'AppModel',
async get() {
let error, result;
try {
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) {
console.error(`Invalid app ${id}`, error || result.status);
this.fatalError = `Invalid app ${id}`;
return;
}
return result.body;
},
async restart() {
let error, result;
try {
result = await fetcher.post(`${origin}/api/v1/apps/${id}/restart`, null, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 202) {
console.error(`Failed to restart app ${this.id}`, error || result.status);
return;
}
while(true) {
let result;
try {
result = await fetcher.get(`${origin}/api/v1/apps/${id}`, { access_token: accessToken });
} catch (e) {
console.error(e);
}
if (result && result.status === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
}
};
}
export default {
create
};

View File

@@ -0,0 +1,94 @@
import moment from 'moment';
import { ansiToHtml } from 'anser';
// https://github.com/janl/mustache.js/blob/master/mustache.js#L60
const entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
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/system/logstream/box';
downloadApi = '/api/v1/system/logs/box';
} else if (type === 'crash') {
streamApi = `/api/v1/system/logstream/crash-${id}`;
downloadApi = `/api/v1/system/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._lastMessage = null;
eventSource.onerror = function ( /* uselessError */) {
if (eventSource.readyState === EventSource.CLOSED) {
// eventSource does not give us the HTTP error code. We have to resort to message count check and guess the reason
const msg = eventSource._lastMessage === null ? `Logs unavailable. Maybe the logs were logrotated.` : `Connection closed.`;
const e = new Error(msg);
e.time = moment().format('MMM DD HH:mm:ss');
e.html = ansiToHtml(e.message);
errorHandler(e);
}
};
// 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 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)));
eventSource._lastMessage = { time, html };
lineHandler(time, html);
};
},
getDownloadUrl() {
return `${origin}${downloadApi}?access_token=${accessToken}&format=short&lines=-1`;
}
};
}
export default {
create
};

18
dashboard/src/style.css Normal file
View File

@@ -0,0 +1,18 @@
html, body {
font-size: 13px; /* this also defines the overall widget size as all sizes are in rem */
font-family: Noto Sans;
height: 100%;
width: 100%;
padding: 0;
margin: 0;
background-color: #e5e5e5;
color: var(--pankow-text-color);
}
.shadow {
box-shadow: 0 2px 5px rgba(0,0,0,.1);
}
#app {
height: 100%;
}