Move filemanager/ to frontend/
This commit is contained in:
27
frontend/src/FileManager.vue
Normal file
27
frontend/src/FileManager.vue
Normal 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>
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
190
frontend/src/components/LogsViewer.vue
Normal file
190
frontend/src/components/LogsViewer.vue
Normal 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>
|
||||
|
||||
50
frontend/src/components/PreviewPanel.vue
Normal file
50
frontend/src/components/PreviewPanel.vue
Normal 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
104
frontend/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_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
|
||||
};
|
||||
78
frontend/src/filemanager.js
Normal file
78
frontend/src/filemanager.js
Normal 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
61
frontend/src/logs.js
Normal 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');
|
||||
})();
|
||||
142
frontend/src/models/DirectoryModel.js
Normal file
142
frontend/src/models/DirectoryModel.js
Normal 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
|
||||
};
|
||||
102
frontend/src/models/LogsModel.js
Normal file
102
frontend/src/models/LogsModel.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
'\'': ''',
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
|
||||
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
46
frontend/src/style.css
Normal 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
563
frontend/src/views/Home.vue
Normal 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>
|
||||
100
frontend/src/views/Viewer.vue
Normal file
100
frontend/src/views/Viewer.vue
Normal 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>
|
||||
Reference in New Issue
Block a user