Move filemanager/ to frontend/

This commit is contained in:
Johannes Zellner
2023-07-12 14:22:58 +02:00
parent 9b6957b52f
commit caa160b3fd
518 changed files with 16 additions and 16 deletions

View File

@@ -0,0 +1,27 @@
<template>
<ConfirmDialog/>
<router-view></router-view>
</template>
<script>
import ConfirmDialog from 'primevue/confirmdialog';
export default {
components: { ConfirmDialog },
data() {
return {
};
},
mounted() {
if (!localStorage.token) {
if (import.meta.env.BASE_URL !== '/') window.location.href = '/';
else console.error('Set localStorage.token')
}
}
};
</script>
<style scoped>
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,190 @@
<template>
<MainLayout>
<template #dialogs>
</template>
<template #header>
<TopBar class="navbar">
<template #left>
{{ name }}
</template>
<template #right>
<a class="p-button p-button-primary" style="margin-right: 5px;" :href="'/terminal.html?id=' + id" target="_blank" v-show="type === 'app'"><span class="p-button-icon p-button-icon-left pi pi-desktop"></span> {{ $t('terminal.title') }}</a>
<a class="p-button p-button-primary" style="margin-right: 5px;" :href="'/frontend/filemanager.html#/home/app/' + id" target="_blank" v-show="type === 'app'"><span class="p-button-icon p-button-icon-left pi pi-folder"></span> {{ $t('filemanager.title') }}</a>
<Button type="button" :label="$t('logs.clear')" icon="pi pi-eraser" @click="onClear()" style="margin-right: 5px" />
<a class="p-button p-button-primary" style="margin-right: 5px;" :href="downloadUrl" target="_blank"><span class="p-button-icon p-button-icon-left pi pi-download"></span> {{ $t('logs.download') }}</a>
</template>
</TopBar>
</template>
<template #body>
<div v-for="line in logLines" :key="line.id" class="log-line">
<span class="time">{{ line.time }}</span><span v-html="line.html"></span>
</div>
<div ref="scrollAnchor" class="bottom-spacer"></div>
</template>
</MainLayout>
</template>
<script>
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { TopBar, BottomBar, MainLayout } from 'pankow';
import { create } from '../models/LogsModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
export default {
name: 'LogsViewer',
components: {
Button,
Dialog,
InputText,
MainLayout,
Menu,
ProgressSpinner,
TopBar
},
data() {
return {
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
logsModel: null,
id: '',
name: '',
type: '',
downloadUrl: '',
logLines: []
};
},
methods: {
onClear() {
this.logLines = [];
},
onDownload() {
this.logsModel.download();
}
},
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 = create(this.apiOrigin, this.accessToken, this.type, this.id);
if (this.type === 'app') {
try {
const app = await this.logsModel.getApp();
this.name = app.fqdn + ' (' + app.manifest.title + ')';
} catch (e) {
console.error(`Failed to get app info for ${this.id}:`, e);
}
}
this.downloadUrl = this.logsModel.getDownloadUrl();
this.logsModel.stream((id, time, html) => {
this.logLines.push({ id, time, html});
const tmp = document.getElementsByClassName('cloudron-layout-body')[0];
if (!tmp) return;
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
if (autoScroll) setTimeout(() => this.$refs.scrollAnchor.scrollIntoView(false), 1);
}, function (error) {
console.error('Failed to start log stream:', error);
})
}
};
</script>
<style>
body {
background-color: black;
}
.cloudron-layout-body {
cursor: text;
}
.cloudron-top {
background-color: black;
color: white;
margin-bottom: 0 !important;
}
.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;
}
.bottom-spacer {
height: 5px;
}
a.p-button:hover {
color: white;
text-decoration: none;
background: #0d89ec;
border-color: #0d89ec;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<div class="preview-panel">
<img :src="item.previewUrl || item.icon" :alt="item.name" :class="{'shadow': item.previewUrl }" @error="iconError($event)"/>
<p>{{ item.name }}</p>
</div>
</template>
<script>
export default {
name: 'PreviewPanel',
props: {
item: Object,
fallbackIcon: String
},
methods: {
iconError(event) {
event.target.src = this.fallbackIcon;
}
}
};
</script>
<style scoped>
.preview-panel {
padding: 0 30px;
display: flex;
flex-direction: column;
}
.preview-panel > img {
min-width: 90%;
max-width: 256px;
margin: auto;
}
.shadow {
box-shadow: 0 2px 5px rgba(0,0,0,.2);
}
.preview-panel > p {
text-align: center;
font-size: 24px;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

104
frontend/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_SETUP_DNS_AND_CERT: 'setupDnsAndCert',
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
};

View File

@@ -0,0 +1,78 @@
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 { createRouter, createWebHashHistory } from 'vue-router';
import FileManager from './FileManager.vue';
import Home from './views/Home.vue';
import Viewer from './views/Viewer.vue';
const routes = [
{ path: '/', redirect: '/home' },
{ path: '/home/:type?/:resourceId?/:cwd*', component: Home },
{ path: '/viewer/:type/:resourceId/:filePath*', component: Viewer }
];
const router = createRouter({
// 4. Provide the history implementation to use. We are using the hash history for simplicity here.
history: createWebHashHistory(),
routes,
});
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(FileManager);
app.use(i18n);
app.use(router);
app.use(PrimeVue, { ripple: true });
app.use(ConfirmationService);
app.mount('#app');
})();

61
frontend/src/logs.js Normal file
View File

@@ -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');
})();

View File

@@ -0,0 +1,142 @@
import superagent from 'superagent';
import { buildFilePath, sanitize } from 'pankow/utils';
const BASE_URL = import.meta.env.BASE_URL || '/';
export function createDirectoryModel(origin, accessToken, api) {
return {
name: 'DirectoryModel',
async listFiles(path) {
let error, result;
try {
result = await superagent.get(`${origin}/api/v1/${api}/files/${path}`).query({ access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
if (error.status === 404) return [];
console.error('Failed to list files', error || result.statusCode);
return [];
}
// this prepares the entries to be compatible with all components
result.body.entries.forEach(item => {
item.id = item.fileName;
item.name = item.fileName;
item.folderPath = path;
item.modified = new Date(item.mtime);
item.type = item.isDirectory ? 'directory' : 'file',
item.icon = `${BASE_URL}mime-types/${item.mimeType === 'inode/symlink' ? 'none' : item.mimeType.split('/').join('-')}.svg`;
// if we have an image, attach previewUrl
if (item.mimeType.indexOf('image/') === 0) {
item.previewUrl = `${origin}/api/v1/${api}/files/${encodeURIComponent(path + '/' + item.fileName)}?access_token=${accessToken}`;
}
item.owner = 'unkown';
if (item.uid === 0) item.owner = 'root';
if (item.uid === 33) item.owner = 'www-data';
if (item.uid === 1000) item.owner = 'cloudron';
if (item.uid === 1001) item.owner = 'git';
});
return result.body.entries;
},
async upload(targetDir, file, progressHandler) {
// file may contain a file name or a file path + file name
const relativefilePath = (file.webkitRelativePath ? file.webkitRelativePath : file.name);
await superagent.post(`${origin}/api/v1/${api}/files/${encodeURIComponent(sanitize(targetDir + '/' + relativefilePath))}`)
.query({ access_token: accessToken })
.attach('file', file)
.on('progress', progressHandler);
},
async newFile(folderPath, fileName) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.attach('file', new File([], fileName));
},
async newFolder(folderPath) {
await superagent.post(`${origin}/api/v1/${api}/files/${folderPath}`)
.query({ access_token: accessToken })
.send({ directory: true });
},
async remove(filePath) {
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken });
},
async rename(fromFilePath, toFilePath) {
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'rename', newFilePath: sanitize(toFilePath) })
.query({ access_token: accessToken });
},
async copy(fromFilePath, toFilePath) {
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
.send({ action: 'copy', newFilePath: sanitize(toFilePath) })
.query({ access_token: accessToken });
},
async chown(filePath, uid) {
await superagent.put(`${origin}/api/v1/${api}/files/${filePath}`)
.send({ action: 'chown', uid: uid, recursive: true })
.query({ access_token: accessToken });
},
async extract(path) {
await superagent.put(`${origin}/api/v1/${api}/files/${path}`)
.send({ action: 'extract' })
.query({ access_token: accessToken });
},
async download(path) {
window.open(`${origin}/api/v1/${api}/files/${path}?download=true&access_token=${accessToken}`);
},
async save(filePath, content) {
const file = new File([content], 'file');
await superagent.post(`${origin}/api/v1/${api}/files/${filePath}`)
.query({ access_token: accessToken })
.attach('file', file)
.field('overwrite', 'true');
},
async getFile(path) {
let result;
try {
result = await fetch(`${origin}/api/v1/${api}/files/${path}?access_token=${accessToken}`);
} catch (error) {
console.error('Failed to get file', error);
return null;
}
const text = await result.text();
return text;
},
async paste(targetDir, action, files) {
// this will not overwrite but tries to find a new unique name to past to
for (let f in files) {
let done = false;
let targetPath = targetDir + '/' + files[f].name;
while (!done) {
try {
if (action === 'cut') await this.rename(buildFilePath(files[f].folderPath, files[f].name), targetPath);
if (action === 'copy') await this.copy(buildFilePath(files[f].folderPath, files[f].name), targetPath);
done = true;
} catch (error) {
if (error.status === 409) {
targetPath += '-copy';
} else {
throw error;
}
}
}
}
},
getFileUrl(path) {
return `${origin}/api/v1/${api}/files/${path}?access_token=${accessToken}`;
}
};
}
export default {
createDirectoryModel
};

View File

@@ -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 = {
'&': '&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/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
};

46
frontend/src/style.css Normal file
View File

@@ -0,0 +1,46 @@
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;
}
h1 {
font-weight: 300 !important;
}
a {
color: #2196f3;
text-decoration: none;
}
a:hover, a:focus {
color: #0a6ebd;
text-decoration: underline;
}
.shadow {
box-shadow: 0 2px 5px rgba(0,0,0,.1);
}
#app {
height: 100%;
}
.fade-enter-active,
.fade-leave-active {
transition: all 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(1.1);
}
.p-button {
font-family: Noto Sans !important;
}

563
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,563 @@
<template>
<MainLayout>
<template #dialogs>
<Dialog v-model:visible="fatalError" modal header="Error" :closable="false" :closeOnEscape="false">
<p>{{ fatalError }}</p>
</Dialog>
<Dialog v-model:visible="extractInProgress" modal :header="$t('filemanager.extractionInProgress')" :closable="false" :closeOnEscape="false">
<div style="text-align: center;">
<ProgressSpinner style="width: 50px; height: 50px"/>
</div>
</Dialog>
<Dialog v-model:visible="pasteInProgress" modal :header="$t('filemanager.pasteInProgress')" :closable="false" :closeOnEscape="false">
<div style="text-align: center;">
<ProgressSpinner style="width: 50px; height: 50px"/>
</div>
</Dialog>
<Dialog v-model:visible="deleteInProgress" modal :header="$t('filemanager.deleteInProgress')" :closable="false" :closeOnEscape="false">
<div style="text-align: center;">
<ProgressSpinner style="width: 50px; height: 50px"/>
</div>
</Dialog>
<!-- have to use v-model instead of : bind - https://github.com/primefaces/primevue/issues/815 -->
<Dialog v-model:visible="newFileDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFileDialogNameInput')">
<template #header>
<label class="dialog-header" for="newFileDialogNameInput">{{ $t('filemanager.newFileDialog.title') }}</label>
</template>
<template #default>
<form @submit="onNewFileDialogSubmit" @submit.prevent>
<InputText class="dialog-single-input" id="newFileDialogNameInput" v-model="newFileDialog.name" :disabled="newFileDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFileDialog.busy" :disabled="newFileDialog.busy || !newFileDialog.name"/>
</form>
</template>
</Dialog>
<Dialog v-model:visible="newFolderDialog.visible" modal :style="{ width: '50vw' }" @show="onDialogShow('newFolderDialogNameInput')">
<template #header>
<label class="dialog-header" for="newFolderDialogNameInput">{{ $t('filemanager.newDirectoryDialog.title') }}</label>
</template>
<template #default>
<form @submit="onNewFolderDialogSubmit" @submit.prevent>
<InputText class="dialog-single-input" id="newFolderDialogNameInput" v-model="newFolderDialog.name" :disabled="newFolderDialog.busy" required/>
<Button class="dialog-single-input-submit" type="submit" :label="$t('filemanager.newFileDialog.create')" :loading="newFolderDialog.busy" :disabled="newFolderDialog.busy || !newFolderDialog.name"/>
</form>
</template>
</Dialog>
</template>
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="pi pi-refresh" @click="onRefresh()" text :loading="busyRefresh" style="margin-right: 5px;"/>
<PathBreadcrumbs :path="cwd" :activate-handler="onActivateBreadcrumb"/>
</template>
<template #right>
<Button type="button" :label="$t('filemanager.toolbar.new')" icon="pi pi-plus" @click="onCreateMenu" aria-haspopup="true" aria-controls="create_menu" style="margin-right: 5px" />
<Menu ref="createMenu" id="create_menu" :model="createMenuModel" :popup="true" />
<Button type="button" :label="$t('filemanager.toolbar.upload')" icon="pi pi-upload" @click="onUploadMenu" aria-haspopup="true" aria-controls="upload_menu" style="margin-right: 5px" />
<Menu ref="uploadMenu" id="upload_menu" :model="uploadMenuModel" :popup="true" />
<a class="p-button p-button-secondary" style="margin-left: 20px; margin-right: 5px;" :href="'/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'"><span class="p-button-icon p-button-icon-left pi pi-align-left"></span> {{ $t('filemanager.toolbar.openLogs') }}</a>
<a class="p-button p-button-secondary" style="margin-right: 5px;" :href="'/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'"><span class="p-button-icon p-button-icon-left pi pi-desktop"></span> {{ $t('filemanager.toolbar.openTerminal') }}</a>
<Button type="button" :label="$t('filemanager.toolbar.restartApp')" severity="secondary" icon="pi pi-sync" @click="onRestartApp" :loading="busyRestart" v-show="resourceType === 'app'"/>
</template>
</TopBar>
</template>
<template #body>
<div class="main-view">
<div class="main-view-col">
<DirectoryView
:show-owner="true"
:show-size="true"
:show-modified="true"
@selection-changed="onSelectionChanged"
@item-activated="onItemActivated"
:delete-handler="deleteHandler"
:rename-handler="renameHandler"
:change-owner-handler="changeOwnerHandler"
:copy-handler="copyHandler"
:cut-handler="cutHandler"
:paste-handler="pasteHandler"
:download-handler="downloadHandler"
:extract-handler="extractHandler"
:new-file-handler="onNewFile"
:new-folder-handler="onNewFolder"
:upload-file-handler="onUploadFile"
:upload-folder-handler="onUploadFolder"
:drop-handler="onDrop"
:items="items"
:clipboard="clipboard"
:owners-model="ownersModel"
:fallback-icon="fallbackIcon"
:tr="$t"
/>
</div>
<div class="main-view-col" style="max-width: 300px;">
<PreviewPanel :item="activeItem || activeDirectoryItem" :fallback-icon="fallbackIcon"/>
</div>
</div>
</template>
<template #footer>
<FileUploader
ref="fileUploader"
:upload-handler="uploadHandler"
@finished="onUploadFinished"
:tr="$t"
/>
<BottomBar />
</template>
</MainLayout>
</template>
<script>
import superagent from 'superagent';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Menu from 'primevue/menu';
import ProgressSpinner from 'primevue/progressspinner';
import { useConfirm } from 'primevue/useconfirm';
import { DirectoryView, TopBar, PathBreadcrumbs, BottomBar, MainLayout, FileUploader } from 'pankow';
import { sanitize, buildFilePath, sleep } from 'pankow/utils';
import { ISTATES } from '../constants.js';
import PreviewPanel from '../components/PreviewPanel.vue';
import { createDirectoryModel } from '../models/DirectoryModel.js';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
const BASE_URL = import.meta.env.BASE_URL || '/';
const beforeUnloadListener = (event) => {
event.preventDefault();
return window.confirm('File operation still in progress. Really close?');
};
export default {
name: 'Home',
components: {
BottomBar,
Button,
Dialog,
DirectoryView,
FileUploader,
InputText,
MainLayout,
Menu,
PathBreadcrumbs,
PreviewPanel,
ProgressSpinner,
TopBar
},
data() {
return {
fallbackIcon: `${BASE_URL}mime-types/none.svg`,
cwd: '/',
busyRefresh: false,
busyRestart: false,
fatalError: false,
extractInProgress: false,
pasteInProgress: false,
deleteInProgress: false,
activeItem: null,
activeDirectoryItem: {},
items: [],
selectedItems: [],
clipboard: {
action: '', // copy or cut
files: []
},
accessToken: localStorage.token,
apiOrigin: API_ORIGIN || '',
title: 'Cloudron',
resourceType: '',
resourceId: '',
visible: true,
newFileDialog: {
visible: false,
busy: false,
name: ''
},
newFolderDialog: {
visible: false,
busy: false,
name: ''
},
ownersModel: [{
uid: 0,
label: 'root'
}, {
uid: 33,
label: 'www-data'
}, {
uid: 1000,
label: 'cloudron'
}, {
uid: 1001,
label: 'git'
}],
// contextMenuModel will have activeItem attached if any command() is called
createMenuModel: [{
label: () => this.$t('filemanager.toolbar.newFile'),
icon: 'pi pi-file',
command: this.onNewFile
}, {
label: () => this.$t('filemanager.toolbar.newFolder'),
icon: 'pi pi-folder',
command: this.onNewFolder
}],
uploadMenuModel: [{
label: () => this.$t('filemanager.toolbar.uploadFile'),
icon: 'pi pi-file',
command: this.onUploadFile
}, {
label: () => this.$t('filemanager.toolbar.newFolder'),
icon: 'pi pi-folder',
command: this.onUploadFolder
}]
};
},
watch: {
cwd(newCwd, oldCwd) {
if (this.resourceType && this.resourceId) this.$router.push(`/home/${this.resourceType}/${this.resourceId}${this.cwd}`);
this.loadCwd();
}
},
methods: {
onCreateMenu(event) {
this.$refs.createMenu.toggle(event);
},
onUploadMenu(event) {
this.$refs.uploadMenu.toggle(event);
},
// generic dialog focus handler
onDialogShow(focusElementId) {
setTimeout(() => document.getElementById(focusElementId).focus(), 0);
},
onNewFile() {
this.newFileDialog.busy = false;
this.newFileDialog.name = '';
this.newFileDialog.visible = true;
},
async onNewFileDialogSubmit() {
this.newFileDialog.busy = true;
await this.directoryModel.newFile(buildFilePath(this.cwd, this.newFileDialog.name), this.newFileDialog.name);
await this.loadCwd();
this.newFileDialog.visible = false;
},
onNewFolder() {
this.newFolderDialog.busy = false;
this.newFolderDialog.name = '';
this.newFolderDialog.visible = true;
},
async onNewFolderDialogSubmit() {
this.newFolderDialog.busy = true;
await this.directoryModel.newFolder(buildFilePath(this.cwd, this.newFolderDialog.name));
await this.loadCwd();
this.newFolderDialog.visible = false;
},
onUploadFile() {
this.$refs.fileUploader.onUploadFile(this.cwd);
},
onUploadFolder() {
this.$refs.fileUploader.onUploadFolder(this.cwd);
},
onUploadFinished() {
this.loadCwd();
},
onAppChange(event) {
this.$router.push(`/home/${event.value.type}/${event.value.id}`);
this.cwd = '/';
this.loadResource(event.value);
},
onSelectionChanged(items) {
this.activeItem = items[0] || null;
this.selectedItems = items;
},
onActivateBreadcrumb(path) {
this.cwd = sanitize(path);
},
async onRefresh() {
this.busyRefresh = true;
await this.loadCwd();
setTimeout(() => { this.busyRefresh = false; }, 500);
},
async onDrop(targetFolder, dataTransfer) {
const fullTargetFolder = sanitize(this.cwd + '/' + targetFolder);
// figure if a folder was dropped on a modern browser, in this case the first would have to be a directory
let folderItem;
try {
folderItem = dataTransfer.items[0].webkitGetAsEntry();
if (folderItem.isFile) return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
} catch (e) {
return this.$refs.fileUploader.addFiles(dataTransfer.files, fullTargetFolder, false);
}
// if we got here we have a folder drop and a modern browser
// now traverse the folder tree and create a file list
var that = this;
function traverseFileTree(item, path) {
if (item.isFile) {
item.file(function (file) {
that.$refs.fileUploader.addFiles([file], sanitize(`${that.cwd}/${targetFolder}`), false);
});
} else if (item.isDirectory) {
// Get folder contents
var dirReader = item.createReader();
dirReader.readEntries(function (entries) {
for (let i in entries) {
traverseFileTree(entries[i], item.name);
}
});
}
}
traverseFileTree(folderItem, '');
},
onItemActivated(item) {
if (!item) return;
if (item.mimeType === 'inode/symlink') return;
if (item.type === 'directory') this.cwd = sanitize(this.cwd + '/' + item.name);
else this.$router.push(`/viewer/${this.resourceType}/${this.resourceId}${sanitize(this.cwd + '/' + item.name)}`);
},
async deleteHandler(files) {
if (!files) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = true;
for (let i in files) {
try {
await this.directoryModel.remove(buildFilePath(this.cwd, files[i].name));
} catch (e) {
console.error(`Failed to remove file ${files[i].name}:`, e);
}
}
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.deleteInProgress = false;
},
async renameHandler(file, newName) {
await this.directoryModel.rename(buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName));
await this.loadCwd();
},
async changeOwnerHandler(files, newOwnerUid) {
if (!files) return;
for (let i in files) {
await this.directoryModel.chown(buildFilePath(this.cwd, files[i].name), newOwnerUid);
}
await this.loadCwd();
},
async copyHandler(files) {
if (!files) return;
this.clipboard = {
action: 'copy',
files
};
},
async cutHandler(files) {
if (!files) return;
this.clipboard = {
action: 'cut',
files
};
},
async pasteHandler(target) {
if (!this.clipboard.files || !this.clipboard.files.length) return;
const targetPath = (target && target.isDirectory) ? sanitize(this.cwd + '/' + target.fileName) : this.cwd;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.pasteInProgress = true;
await this.directoryModel.paste(targetPath, this.clipboard.action, this.clipboard.files);
this.clipboard = {};
await this.loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
this.pasteInProgress = false;
},
async downloadHandler(file) {
await this.directoryModel.download(buildFilePath(this.cwd, file.name));
},
async extractHandler(file) {
this.extractInProgress = true;
await this.directoryModel.extract(buildFilePath(this.cwd, file.name));
await this.loadCwd();
this.extractInProgress = false;
},
async uploadHandler(targetDir, file, progressHandler) {
await this.directoryModel.upload(targetDir, file, progressHandler);
await this.loadCwd();
},
async loadCwd() {
this.items = await this.directoryModel.listFiles(this.cwd);
const tmp = this.cwd.split('/').slice(1);
let name = this.title;
if (tmp.length >= 1 && tmp[tmp.length-1]) name = tmp[tmp.length-1];
this.activeDirectoryItem = {
id: name,
name: name,
type: 'directory',
mimeType: 'inode/directory',
icon: `${BASE_URL}mime-types/inode-directory.svg`
};
},
async onRestartApp() {
if (this.resourceType !== 'app') return;
this.busyRestart = true;
let error, result;
try {
result = await superagent.post(`${this.apiOrigin}/api/v1/apps/${this.resourceId}/restart`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 202) {
console.error(`Failed to restart app ${this.resourceId}`, error || result.statusCode);
return;
}
while(true) {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${this.resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (result && result.statusCode === 200 && result.body.installationState === ISTATES.INSTALLED) break;
await sleep(2000);
}
this.busyRestart = false;
}
},
async mounted() {
useConfirm();
const type = this.$route.params.type || 'app';
const resourceId = this.$route.params.resourceId;
const cwd = this.$route.params.cwd;
if (type === 'app') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/apps/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
this.fatalError = `Invalid resource ${type} ${resourceId}`;
return;
}
this.title = result.body.label || result.body.fqdn;
} else if (type === 'volume') {
let error, result;
try {
result = await superagent.get(`${this.apiOrigin}/api/v1/volumes/${resourceId}`).query({ access_token: this.accessToken });
} catch (e) {
error = e;
}
if (error || result.statusCode !== 200) {
console.error(`Invalid resource ${type} ${resourceId}`, error || result.statusCode);
this.fatalError = `Invalid resource ${type} ${resourceId}`;
return;
}
this.title = result.body.name;
} else {
this.fatalError = `Unsupported type ${type}`;
return;
}
window.document.title = this.title + ' - File Manager';
this.cwd = sanitize('/' + (cwd ? cwd.join('/') : '/'));
this.resourceType = type;
this.resourceId = resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, type === 'volume' ? `volumes/${resourceId}` : `apps/${resourceId}`);
this.loadCwd();
this.$watch(() => this.$route.params, (toParams, previousParams) => {
if (toParams.type !== 'app' && toParams.type !== 'volume') {
this.fatalError = `Unknown type ${toParams.type}`;
return;
}
if ((toParams.type !== this.resourceType) || (toParams.resourceId !== this.resourceId)) {
this.resourceType = toParams.type;
this.resourceId = toParams.resourceId;
this.directoryModel = createDirectoryModel(this.apiOrigin, this.accessToken, toParams.type === 'volume' ? `volumes/${toParams.resourceId}` : `apps/${toParams.resourceId}`);
}
this.cwd = toParams.cwd ? `/${toParams.cwd.join('/')}` : '/';
});
}
};
</script>
<style scoped>
.main-view {
flex-grow: 1;
overflow: hidden;
height: 100%;
display: flex;
padding: 0 10px
}
.main-view-col {
overflow: auto;
flex-grow: 1;
}
.dialog-header {
font-weight: 600;
font-size: 1.25rem;
}
.dialog-single-input {
display: block;
width: 100%;
margin-top: 5px;
margin-bottom: 1.5rem;
}
.dialog-single-input-submit {
margin-top: 5px;
}
a.p-button:hover {
text-decoration: none;
background: #0d89ec;
color: #ffffff;
border-color: #0d89ec;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="viewer">
<TextEditor ref="textEditor"
v-show="active === 'textEditor'"
:save-handler="saveHandler"
@close="onClose"
:tr="$t"
/>
<ImageViewer ref="imageViewer" v-show="active === 'imageViewer'" @close="onClose"/>
</div>
</template>
<script>
import { TextEditor, ImageViewer } from 'pankow';
import { createDirectoryModel } from '../models/DirectoryModel.js';
import { sanitize } from 'pankow/utils';
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? 'https://' + import.meta.env.VITE_API_ORIGIN : '';
export default {
name: 'Viewer',
components: {
ImageViewer,
TextEditor
},
data() {
return {
resourceId: '',
resourceType: '',
item: null,
active: ''
};
},
methods: {
onClose() {
this.$router.go(-1);
},
async saveHandler(item, content) {
await this.directoryModel.save(this.filePath, content);
}
},
async mounted() {
this.resourceId = this.$route.params.resourceId;
this.resourceType = this.$route.params.type;
this.filePath = this.$route.params.filePath.join('/');
const fileName = this.$route.params.filePath[this.$route.params.filePath.length-1];
const parentDirectoryPath = sanitize(this.filePath.split('/').slice(0, -1).join('/'));
this.directoryModel = createDirectoryModel(API_ORIGIN, localStorage.token, (this.resourceType === 'volume' ? 'volumes/' : 'apps/') + this.resourceId);
const files = await this.directoryModel.listFiles(parentDirectoryPath);
this.item = files.find(i => i.fileName === fileName);
if (!this.item) {
console.log('File not found', this.filePath);
return;
}
if (this.$refs.imageViewer.canHandle(this.item)) {
// collect other files in directory for prev/next action
const otherSupportedEntries = files.filter((item) => this.$refs.imageViewer.canHandle(item)).map((item) => {
item.resourceUrl = `/viewer/${this.resourceType}/${this.resourceId}${item.folderPath}/${item.fileName}`;
item.fullFileUrl = this.directoryModel.getFileUrl(`${item.folderPath}/${item.fileName}`);
return item;
});
this.$refs.imageViewer.open(this.item, otherSupportedEntries);
this.active = 'imageViewer';
} else if (this.$refs.textEditor.canHandle(this.item)) {
const content = await this.directoryModel.getFile(this.filePath);
this.$refs.textEditor.open(this.item, content);
this.active = 'textEditor';
} else {
console.warn(`no editor or viewer found for ${this.item.mimeType}`, this.item);
this.active = '';
window.location.replace(this.directoryModel.getFileUrl(this.filePath));
}
}
};
</script>
<style scoped>
.viewer{
flex-grow: 1;
overflow: hidden;
height: 100%;
display: flex;
}
.main-view-col {
overflow: auto;
flex-grow: 1;
}
</style>