Move logs.html from frontend to dashboard
This commit is contained in:
226
dashboard/src/components/LogsViewer.vue
Normal file
226
dashboard/src/components/LogsViewer.vue
Normal 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] ' }</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
104
dashboard/src/constants.js
Normal 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
51
dashboard/src/i18n.js
Normal 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
16
dashboard/src/logs.js
Normal 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');
|
||||
})();
|
||||
56
dashboard/src/models/AppModel.js
Normal file
56
dashboard/src/models/AppModel.js
Normal 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
|
||||
};
|
||||
94
dashboard/src/models/LogsModel.js
Normal file
94
dashboard/src/models/LogsModel.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
'\'': ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
|
||||
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
18
dashboard/src/style.css
Normal 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%;
|
||||
}
|
||||
Reference in New Issue
Block a user